feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
+512
View File
@@ -0,0 +1,512 @@
using System.Security.Claims;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The ERP-owned role model: AuthHex proves who someone is; the role, its permissions and "is admin" come from the
/// ERP's own users/roles tables, are read on every request, and a change applies on the very next one.
/// </summary>
public class AccessControlTests
{
private const int AdminRole = 1, ApproverRole = 2, ViewerRole = 3, NoAccessRole = 4, InactiveRole = 5;
// ---------------------------------------------------------------- helpers
private sealed class FakeUser(int userId = 10, int? roleId = null, bool isAdmin = false) : ICurrentUser
{
public int AuditUserId { get; set; } = userId;
public int? RoleId { get; set; } = roleId;
public string? RoleCode { get; set; }
public bool IsAdmin { get; set; } = isAdmin;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
public int? BranchId => null;
}
private static async Task<ErpDbContext> SeedAsync(bool withDefault = true)
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = AdminRole, Code = "Admin", Name = "Administrator", IsAdmin = true, IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = ApproverRole, Code = "Approver", Name = "Approver", CreatedAt = DateTime.UtcNow },
new Role { RoleId = ViewerRole, Code = "Viewer", Name = "Viewer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = NoAccessRole, Code = "NoAccess", Name = "No access", IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = InactiveRole, Code = "Retired", Name = "Retired", Status = EntityStatus.Inactive, CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "procurement", Label = "Procurement" });
db.SubNavItems.Add(new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "procurement.vendors", Label = "Vendors" });
db.NavItems.Add(new NavItem { NavItemId = 102, Code = "stock", Label = "Stock" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:procurement", NavItemId = 100 },
new Permission { PermissionId = 201, Code = "NAV:procurement.vendors", SubNavItemId = 101 },
new Permission { PermissionId = 202, Code = "NAV:stock", NavItemId = 102 });
db.RolePermissions.AddRange(
new RolePermission { RoleId = ApproverRole, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Approve },
new RolePermission { RoleId = ViewerRole, PermissionId = 201, Actions = RolePermissionAction.View },
new RolePermission { RoleId = InactiveRole, PermissionId = 202, Actions = RolePermissionAction.All });
if (withDefault) db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = NoAccessRole });
await db.SaveChangesAsync();
return db;
}
private static ClaimsPrincipal Principal(Guid authUserId, string? tokenRoleCode = null)
{
var claims = new List<Claim> { new("UserId", authUserId.ToString()), new("NIC", "N-1") };
if (tokenRoleCode is not null) claims.Add(new Claim("RoleCode", tokenRoleCode));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ShadowUserClaimsTransformation Transformer(ErpDbContext db, bool legacyFallback = true) =>
new(db, new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:LegacyRoleClaimFallback"] = legacyFallback.ToString() })
.Build());
private static string? Claim(ClaimsPrincipal p, string type) => p.FindFirstValue(type);
private static User NewUser(Guid authId, int? roleId, EntityStatus status = EntityStatus.Active, int id = 0) => new()
{
UserId = id == 0 ? Random.Shared.Next(100, 100000) : id,
AuthUserId = authId, Username = $"u-{authId:N}", DisplayName = "Someone", RoleId = roleId, Status = status
};
private static NavPermissionService NavService(ErpDbContext db) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions()));
private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new Repository<Permission>(db), new Repository<Employee>(db),
new Repository<User>(db), new Repository<AccessConfig>(db), user, nav, new FakeBranchConfig(), new PassThroughUnitOfWork(db));
// The AuthHex client, link and employee services are not touched by role/status changes (users here have no AuthUserId).
private static UserManagementService UserSvc(ErpDbContext db, ICurrentUser user) =>
new(new Repository<User>(db), new Repository<Role>(db), new Repository<Employee>(db), null!, null!, null!, user, new PassThroughUnitOfWork(db));
// ---------------------------------------------------------------- claims: the role comes from the ERP
[Fact]
public async Task Role_IsReadFromTheErpTable_NotFromTheTokensRoleClaim()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ApproverRole));
await db.SaveChangesAsync();
// The token still says "Admin" (an old AuthHex token). The ERP says Approver, and the ERP wins.
var principal = await Transformer(db).TransformAsync(Principal(auth, tokenRoleCode: "Admin"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("Approver", Claim(principal, AuthHexClaims.ErpRoleCode));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task Admin_IsTheRoleFlag_NotTheRoleName()
{
await using var db = await SeedAsync();
var boss = Guid.NewGuid();
var impostor = Guid.NewGuid();
db.Roles.Add(new Role { RoleId = 20, Code = "Boss", Name = "The boss", IsAdmin = true, CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 21, Code = "ADMIN2", Name = "admin", CreatedAt = DateTime.UtcNow }); // named like an admin, is not
db.Users.AddRange(NewUser(boss, 20), NewUser(impostor, 21));
await db.SaveChangesAsync();
Assert.Equal("true", Claim(await Transformer(db).TransformAsync(Principal(boss)), AuthHexClaims.ErpIsAdmin));
Assert.Equal("false", Claim(await Transformer(db).TransformAsync(Principal(impostor)), AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task RenamingTheAdminRole_DoesNotCostAnyoneTheirAdminRights()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole));
await db.SaveChangesAsync();
db.Roles.Single(r => r.RoleId == AdminRole).Code = "Owners";
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("true", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal("Owners", Claim(principal, AuthHexClaims.ErpRoleCode));
}
[Fact]
public async Task FirstSignIn_CreatesTheUser_AndGivesItTheDefaultRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var principal = await Transformer(db).TransformAsync(Principal(auth));
var user = Assert.Single(db.Users.Where(u => u.AuthUserId == auth));
Assert.Equal(NoAccessRole, user.RoleId); // saved, so the Users screen shows it
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal(user.UserId.ToString(), Claim(principal, ClaimTypes.NameIdentifier));
}
[Fact]
public async Task WithNoDefaultRole_APersonWithoutARoleHasNoRoleClaims()
{
await using var db = await SeedAsync(withDefault: false);
var principal = await Transformer(db).TransformAsync(Principal(Guid.NewGuid()));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.NotNull(Claim(principal, ClaimTypes.NameIdentifier)); // still a known user
}
[Fact]
public async Task LegacyFallback_UsesTheTokensRole_ForARolelessUser_AndSavesIt()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: true).TransformAsync(Principal(auth, "Approver"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ApproverRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task LegacyFallbackOff_IgnoresTheTokenAndUsesTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: false).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task AnAssignedRole_IsNeverOverwrittenByTheFallbackOrTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ViewerRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(ViewerRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ViewerRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task InactiveUser_IsBlocked_AndGetsNoRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole, EntityStatus.Inactive));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("1", Claim(principal, AuthHexClaims.ErpBlocked));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task InactiveRole_GrantsNothing()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, InactiveRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpBlocked)); // not blocked — just no permissions
}
[Fact]
public async Task Branch_ComesFromTheLinkedEmployee_AndTransformingTwiceAddsNothingNew()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 500);
db.Users.Add(user);
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E1", FullName = "Emp", UserId = 500, BranchId = Branch2 });
await db.SaveChangesAsync();
var transformer = Transformer(db);
var principal = await transformer.TransformAsync(Principal(auth));
var claimsAfterFirst = principal.Claims.Count();
principal = await transformer.TransformAsync(principal);
Assert.Equal(Branch2.ToString(), Claim(principal, AuthHexClaims.BranchId));
Assert.Equal(claimsAfterFirst, principal.Claims.Count());
}
// ---------------------------------------------------------------- permissions by role id
[Fact]
public async Task Nav_AccessAndActions_AreDecidedByTheRolesPermissions()
{
await using var db = await SeedAsync();
var nav = NavService(db);
Assert.True(await nav.HasAccessAsync(ApproverRole, "procurement"));
Assert.True(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Approve));
Assert.False(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Delete));
Assert.False(await nav.HasAccessAsync(ApproverRole, "stock"));
// A parent module is reachable through a child grant; an action is exact.
Assert.True(await nav.HasAccessAsync(ViewerRole, "procurement"));
Assert.False(await nav.HasActionAsync(ViewerRole, "procurement", RolePermissionAction.View));
Assert.True(await nav.HasActionAsync(ViewerRole, "procurement.vendors", RolePermissionAction.View));
Assert.False(await nav.HasAccessAsync(null, "procurement"));
Assert.False(await nav.HasAccessAsync(999, "procurement"));
}
[Fact]
public async Task Nav_InactiveRole_HasNoAccess()
{
await using var db = await SeedAsync();
Assert.False(await NavService(db).HasAccessAsync(InactiveRole, "stock"));
}
[Fact]
public async Task EditingARolesPermissions_AppliesImmediately_NotAfterACacheTimeout()
{
await using var db = await SeedAsync();
var nav = NavService(db);
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), nav);
Assert.False(await nav.HasAccessAsync(ViewerRole, "stock")); // cached as "no"
await roles.AssignPermissionsAsync(ViewerRole, new Dtos.Rbac.AssignRolePermissionsRequest { NavItemIds = [102] });
Assert.True(await nav.HasAccessAsync(ViewerRole, "stock")); // visible straight away
}
// ---------------------------------------------------------------- /auth/me
[Fact]
public async Task Me_ForAnAdministrator_ListsEveryPermission_EvenOnesNeverGranted()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.True(me.IsAdmin);
Assert.Equal(AdminRole, me.RoleId);
Assert.Contains("procurement.vendors", me.NavCodes);
Assert.Contains("stock", me.NavCodes);
Assert.Equal((int)RolePermissionAction.All, me.Permissions!["stock"]);
}
[Fact]
public async Task Me_ForAnOrdinaryRole_ListsOnlyItsGrants_WithTheirActions()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: ApproverRole), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.False(me.IsAdmin);
Assert.Equal(["procurement"], me.NavCodes);
Assert.Equal((int)(RolePermissionAction.View | RolePermissionAction.Approve), me.Permissions!["procurement"]);
}
[Fact]
public async Task Me_WithoutARole_SaysSo_InsteadOfReturningAnEmptyMenu()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: null), NavService(db)).GetMeAsync();
Assert.False(me.HasRole);
Assert.Empty(me.NavCodes);
Assert.Empty(me.Permissions!);
}
// ---------------------------------------------------------------- role management guards
[Fact]
public async Task TheAdministratorSystemAndDefaultRoles_CannotBeDeactivatedOrDeleted()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(AdminRole));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(NoAccessRole)); // system role and the default
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(NoAccessRole, EntityStatus.Inactive));
}
[Fact]
public async Task ARoleInUse_CannotBeDeactivated_ButAnUnusedOneCanBeDeleted()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole));
await db.SaveChangesAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(ViewerRole, EntityStatus.Inactive));
await roles.DeleteAsync(ApproverRole);
Assert.False(db.Roles.Any(r => r.RoleId == ApproverRole));
}
[Fact]
public async Task CreatedRoles_NeverCarryTheAdminFlag()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
var created = (await roles.CreateAsync(new Dtos.Rbac.CreateRoleRequest { Code = "Clerk", Name = "Clerk" })).Value;
Assert.False(created.IsAdmin);
Assert.False(created.IsSystemRole);
Assert.Null(db.Roles.Single(r => r.RoleId == created.RoleId).AuthRoleId);
}
// ---------------------------------------------------------------- user management guards
[Fact]
public async Task ChangingAUsersRole_TakesEffectOnTheirNextRequest_WithNoRelogin()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 700);
db.Users.Add(user);
await db.SaveChangesAsync();
Assert.Equal(ViewerRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
await UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(700, new UpdateUserRoleRequest { RoleId = ApproverRole });
// A fresh request for the same login now carries the new role.
Assert.Equal(ApproverRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
}
[Fact]
public async Task TheLastActiveAdministrator_CannotBeDemotedOrDeactivated()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), AdminRole, id: 801));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 1, roleId: AdminRole, isAdmin: true));
var demote = await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
var deactivate = await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
Assert.Equal(409, demote.StatusCode);
Assert.Equal(409, deactivate.StatusCode);
Assert.Equal(AdminRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task AnAdministrator_CanBeDemoted_WhenAnotherActiveAdminRemains()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
await UserSvc(db, new FakeUser(userId: 802, roleId: AdminRole, isAdmin: true))
.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole });
Assert.Equal(ViewerRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task Nobody_CanRemoveTheirOwnAdminAccess_OrDeactivateThemselves()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 801, roleId: AdminRole, isAdmin: true));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
}
[Fact]
public async Task AUser_CannotBeGivenAnInactiveRole()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole, id: 900));
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(
() => UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(900, new UpdateUserRoleRequest { RoleId = InactiveRole }));
}
// ---------------------------------------------------------------- access config (default role)
private static AccessConfigService AccessSvc(ErpDbContext db) =>
new(new Repository<AccessConfig>(db), new Repository<Role>(db), new FakeUser(), new PassThroughUnitOfWork(db));
[Fact]
public async Task TheDefaultRole_CanNeverBeAnAdministratorOrAnInactiveRole()
{
await using var db = await SeedAsync();
var svc = AccessSvc(db);
var etag = db.AccessConfig.Single().RowVersion;
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag));
await Assert.ThrowsAsync<NotFoundException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = 999 }, etag));
Assert.Equal(NoAccessRole, db.AccessConfig.Single().DefaultRoleId);
var changed = (await svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = ViewerRole }, etag)).Value;
Assert.Equal(("Viewer", ViewerRole), (changed.DefaultRoleName, changed.DefaultRoleId));
}
// ---------------------------------------------------------------- seeding
[Fact]
public async Task Seeder_EnsuresAnAdminRoleAndALeastPrivilegeDefault_AndIsIdempotent()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
await DataSeeder.SeedAsync(db);
await DataSeeder.SeedAsync(db);
var admin = Assert.Single(db.Roles.Where(r => r.IsAdmin));
Assert.True(admin.IsSystemRole);
Assert.Equal(db.Permissions.Count(), db.RolePermissions.Count(rp => rp.RoleId == admin.RoleId));
var noAccess = Assert.Single(db.Roles.Where(r => r.Code == DataSeeder.DefaultNoAccessRoleCode));
Assert.Empty(db.RolePermissions.Where(rp => rp.RoleId == noAccess.RoleId));
Assert.Equal(noAccess.RoleId, Assert.Single(db.AccessConfig).DefaultRoleId);
}
[Fact]
public async Task Seeder_FlagsAnExistingRoleCalledAdmin_InsteadOfAddingASecond_AndKeepsAChosenDefault()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
db.Roles.Add(new Role { RoleId = 50, Code = "ADMIN", Name = "Old admin", CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 51, Code = "Reader", Name = "Reader", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
db.AccessConfig.Single().DefaultRoleId = 51; // an administrator's own choice
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
Assert.True(db.Roles.Single(r => r.RoleId == 50).IsAdmin);
Assert.Equal(1, db.Roles.Count(r => r.IsAdmin));
Assert.Equal(51, db.AccessConfig.Single().DefaultRoleId);
}
}
@@ -0,0 +1,158 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Customers;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Sales Configuration decides which optional customer fields exist (phone, email, address, city, country, credit limit,
/// credit days). The forms hide a switched-off field; the server refuses a new value for it and keeps what is stored.
/// </summary>
public class CustomerFieldSwitchTests
{
private static CustomerService Svc(ErpDbContext db) =>
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{
var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
tweak(config);
db.SalesConfig.Add(config);
await db.SaveChangesAsync();
}
private static CreateCustomerRequest NewCustomer(Action<CreateCustomerRequest>? tweak = null)
{
var request = new CreateCustomerRequest { CustomerCode = "C-1", Name = "Nimal Perera", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private static UpdateCustomerRequest Same(Customer c, Action<UpdateCustomerRequest>? tweak = null)
{
var request = new UpdateCustomerRequest
{
CustomerCode = c.CustomerCode, CustomerType = c.CustomerType, Name = c.Name, DisplayName = c.DisplayName,
Phone = c.Phone, Email = c.Email, AddressLine1 = c.AddressLine1, AddressLine2 = c.AddressLine2,
City = c.City, Country = c.Country, TaxRegistrationNo = c.TaxRegistrationNo,
CreditLimit = c.CreditLimit, CreditDays = c.CreditDays, BranchId = c.BranchId
};
tweak?.Invoke(request);
return request;
}
[Fact]
public async Task EveryFieldIsOn_WhenNothingIsConfigured_SoExistingBehaviourIsUnchanged()
{
await using var db = CreateContext(); // no sales_config row at all
var created = (await Svc(db).CreateAsync(NewCustomer(r =>
{
r.Phone = "0771234567"; r.Email = "a@b.lk"; r.AddressLine1 = "No. 12"; r.City = "Colombo";
r.Country = "Sri Lanka"; r.CreditLimit = 5000m; r.CreditDays = 30;
}))).Value;
Assert.Equal(("0771234567", "a@b.lk", "No. 12", "Colombo", "Sri Lanka"), (created.Phone, created.Email, created.AddressLine1, created.City, created.Country));
Assert.Equal((5000m, 30), (created.CreditLimit, created.CreditDays));
}
[Theory]
[InlineData("phone")]
[InlineData("email")]
[InlineData("address")]
[InlineData("city")]
[InlineData("country")]
[InlineData("creditLimit")]
[InlineData("creditDays")]
public async Task Create_ANewValueForASwitchedOffField_IsRefused(string field)
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = field != "phone";
c.CustomerEmailEnabled = field != "email";
c.CustomerAddressEnabled = field != "address";
c.CustomerCityEnabled = field != "city";
c.CustomerCountryEnabled = field != "country";
c.CustomerCreditLimitEnabled = field != "creditLimit";
c.CustomerCreditDaysEnabled = field != "creditDays";
});
var ex = await Assert.ThrowsAsync<DomainException>(() => Svc(db).CreateAsync(NewCustomer(r =>
{
switch (field)
{
case "phone": r.Phone = "0771234567"; break;
case "email": r.Email = "a@b.lk"; break;
case "address": r.AddressLine2 = "Wellawatte"; break;
case "city": r.City = "Colombo"; break;
case "country": r.Country = "Sri Lanka"; break;
case "creditLimit": r.CreditLimit = 100m; break;
default: r.CreditDays = 7; break;
}
})));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Empty(db.Customers.Where(c => c.CustomerCode == "C-1"));
}
[Fact]
public async Task Create_WithTheSwitchedOffFieldsLeftBlank_Works()
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = c.CustomerEmailEnabled = c.CustomerAddressEnabled = false;
c.CustomerCityEnabled = c.CustomerCountryEnabled = false;
c.CustomerCreditLimitEnabled = c.CustomerCreditDaysEnabled = false;
});
var created = (await Svc(db).CreateAsync(NewCustomer())).Value;
Assert.Equal("Nimal Perera", created.Name);
Assert.Null(created.Phone);
Assert.Equal((0m, 0), (created.CreditLimit, created.CreditDays));
}
[Fact]
public async Task Update_KeepsAStoredValueWhoseFieldWasSwitchedOff_EvenWhenTheFormSendsItBlank()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => { r.Phone = "0771234567"; r.CreditLimit = 5000m; r.CreditDays = 30; }))).Value;
await SwitchesAsync(db, c => { c.CustomerPhoneEnabled = false; c.CustomerCreditLimitEnabled = false; c.CustomerCreditDaysEnabled = false; });
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
// The edit form no longer has the fields, so it sends blanks / zeros.
var updated = (await svc.UpdateAsync(created.CustomerId,
Same(stored, r => { r.Name = "Nimal P."; r.Phone = null; r.CreditLimit = 0m; r.CreditDays = 0; }), stored.RowVersion)).Value;
Assert.Equal("Nimal P.", updated.Name);
Assert.Equal("0771234567", updated.Phone);
Assert.Equal((5000m, 30), (updated.CreditLimit, updated.CreditDays));
}
[Fact]
public async Task Update_ADifferentValueForASwitchedOffField_IsRefused()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => r.CreditLimit = 5000m))).Value;
await SwitchesAsync(db, c => c.CustomerCreditLimitEnabled = false);
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
}
+15 -7
View File
@@ -141,7 +141,7 @@ public class DualApprovalTests
[Fact] [Fact]
public async Task SecondApproval_CanBeLimitedToRoles() public async Task SecondApproval_CanBeLimitedToRoles()
{ {
var h = await Harness.CreateAsync(level2Roles: "Approver"); var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId; var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA); h.User.Be(ApproverA);
await h.Stock.ApproveAsync(id, null); await h.Stock.ApproveAsync(id, null);
@@ -250,7 +250,7 @@ public class DualApprovalTests
public required AdjustmentService Stock { get; init; } public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; } public required PriceAdjustmentService Prices { get; init; }
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null) public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null)
{ {
var db = new ErpDbContext( var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options, new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
@@ -283,7 +283,7 @@ public class DualApprovalTests
db.ApprovalConfig.Add(new ApprovalConfig db.ApprovalConfig.Add(new ApprovalConfig
{ {
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels, ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -292,7 +292,7 @@ public class DualApprovalTests
var notifications = new RecordingNotifications(); var notifications = new RecordingNotifications();
var approvals = new ApprovalService( var approvals = new ApprovalService(
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db), new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
new Repository<RolePermission>(db), user, notifications, uow); new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
var gl = new FakeGeneralLedger(); var gl = new FakeGeneralLedger();
var mutator = new RecordingMutator(); var mutator = new RecordingMutator();
var numbers = new FakeNumberSequence(); var numbers = new FakeNumberSequence();
@@ -308,8 +308,9 @@ public class DualApprovalTests
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db), new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user)); new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService( var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db), new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow); new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(db),
approvals, notifications, numbers, user, uow);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices }; return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
} }
@@ -323,7 +324,13 @@ public class DualApprovalTests
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk" [Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
}; };
private static readonly Dictionary<int, int> RoleIds = new()
{
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
};
public int AuditUserId { get; private set; } = userId; public int AuditUserId { get; private set; } = userId;
public int? RoleId { get; private set; } = RoleIds[userId];
public string? RoleCode { get; private set; } = roleCode; public string? RoleCode { get; private set; } = roleCode;
public string UserId => AuditUserId.ToString(); public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true; public bool IsAuthenticated => true;
@@ -333,6 +340,7 @@ public class DualApprovalTests
public void Be(int userId) public void Be(int userId)
{ {
AuditUserId = userId; AuditUserId = userId;
RoleId = RoleIds[userId];
RoleCode = Roles[userId]; RoleCode = Roles[userId];
} }
} }
@@ -368,7 +376,7 @@ public class DualApprovalTests
=> throw new NotSupportedException(); => throw new NotSupportedException();
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId, Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException(); DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m); public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
} }
@@ -0,0 +1,420 @@
using System.Reflection;
using System.Security.Claims;
using ERPCore.Controllers;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Search;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Caching.Memory;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Permission coverage (Phase B): every API endpoint that changes data sits behind a permission, every permission the
/// controllers name actually exists in the seeded catalogue, HR data is gated, global search respects the role,
/// state-changing cookie requests need the CSRF token, and a branch-less person can't read every branch.
/// </summary>
public class PermissionCoverageTests
{
// The seeded (HasData) rows live in the design-time model; the runtime model drops them.
private static IEnumerable<IDictionary<string, object?>> Seed<T>(ErpDbContext db) =>
Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService<IDesignTimeModel>(db)
.Model.FindEntityType(typeof(T))!.GetSeedData();
// ---------------------------------------------------------------- controllers <-> permissions
private static IEnumerable<Type> ApiControllers() =>
typeof(ApiControllerBase).Assembly.GetTypes()
.Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t));
private static IEnumerable<string> NavPolicyNames(MemberInfo member) =>
member.GetCustomAttributes<AuthorizeAttribute>(inherit: true)
.Select(a => a.Policy)
.Where(p => p is not null && p.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))!;
private static bool IsReadOnly(MethodInfo m) =>
m.GetCustomAttributes<HttpGetAttribute>().Any() && !m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any(a => a is not HttpGetAttribute);
/// <summary>Endpoints that deliberately sit behind the login door only: personal, or checked inside the service.</summary>
private static readonly HashSet<string> OpenByDesign = new()
{
nameof(AuthController), // sign-in, sessions, password: the person's own
nameof(NavController), // "what may I see": the person's own permissions
nameof(NotificationsController), // the person's own inbox
nameof(SupportTicketsController), // anyone may raise a ticket; the service scopes by owner/admin
nameof(TrackingController), // read-only serial/batch pickers used inside sales and stock screens
nameof(SearchController), // gated per result type inside GlobalSearchService
nameof(MetaController),
};
[Fact]
public void EveryDataChangingEndpoint_IsBehindAPermission()
{
var uncovered = new List<string>();
foreach (var controller in ApiControllers().Where(c => !OpenByDesign.Contains(c.Name)))
{
var classCovered = NavPolicyNames(controller).Any();
foreach (var action in controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any()))
{
if (IsReadOnly(action)) continue;
if (action.GetCustomAttributes<AllowAnonymousAttribute>().Any()) continue;
if (!classCovered && !NavPolicyNames(action).Any()) uncovered.Add($"{controller.Name}.{action.Name}");
}
}
Assert.True(uncovered.Count == 0, "Data-changing endpoints with no Nav permission: " + string.Join(", ", uncovered));
}
[Fact]
public void EveryControllerAboutSensitiveData_IsGatedAsAWhole()
{
// HR (salaries, bank accounts, loans, payslips) and the audit trail used to be open to every signed-in person.
foreach (var name in new[]
{
"AttendanceBatchesController", "LeaveRequestsController", "PayrollRunsController", "PayslipsController",
"HrReportsController", "EmployeesController", "SalaryComponentsController", "TaxSlabsController",
"PayrollStatutorySettingsController", "AuditLogsController",
})
{
var type = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == name);
Assert.True(NavPolicyNames(type).Any(), $"{name} has no class-level Nav permission.");
}
}
[Fact]
public void PayData_NeedsThePayrollPermission_OnTopOfEmployees()
{
var employees = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "EmployeesController");
foreach (var action in new[] { "ListBankDetails", "GetSalaryStructureHistory", "ListLoans", "GetLoan", "CreateLoan", "CreateSalaryStructure", "ReplaceBankDetails" })
{
var method = employees.GetMethod(action)!;
Assert.Contains(NavPolicyNames(method), p => p.StartsWith(NavPolicies.Prefix + "hrm.payroll", StringComparison.Ordinal));
}
var reports = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "HrReportsController");
foreach (var action in new[] { "PayrollRegister", "SalaryHistory" })
Assert.Contains(NavPolicyNames(reports.GetMethod(action)!), p => p == NavPolicies.Prefix + "hrm.payroll");
}
[Fact]
public void EveryNavCodeTheControllersName_ExistsInTheSeededCatalogue()
{
// The old Nav:vendors policy named a code that no longer existed, so only administrators got through.
using var db = CreateContext();
var seeded = Seed<Permission>(db)
.Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length))
.ToHashSet(StringComparer.Ordinal);
var missing = new SortedSet<string>();
foreach (var controller in ApiControllers())
{
var names = NavPolicyNames(controller).Concat(
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).SelectMany(NavPolicyNames));
foreach (var name in names)
{
var rest = name[NavPolicies.Prefix.Length..];
var sep = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
var code = sep >= 0 && Enum.TryParse<RolePermissionAction>(rest[(sep + 1)..], out _) ? rest[..sep] : rest;
// A module code is satisfied by the module itself or any screen inside it (HasAccessAsync does the same).
if (!seeded.Contains(code) && !seeded.Any(s => s.StartsWith(code + ".", StringComparison.Ordinal))) missing.Add($"{controller.Name}: {code}");
}
}
Assert.True(missing.Count == 0, "Controllers name permissions that are not seeded: " + string.Join("; ", missing));
}
[Fact]
public void SeededCatalogue_HasNoDuplicateCodes_AndEverySubItemHasAPermission()
{
using var db = CreateContext();
var permissions = Seed<Permission>(db).ToList();
var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList();
Assert.Equal(codes.Count, codes.Distinct().Count());
var subItems = Seed<SubNavItem>(db);
var granted = permissions.Where(p => p[nameof(Permission.SubNavItemId)] is not null).Select(p => (int)p[nameof(Permission.SubNavItemId)]!).ToHashSet();
foreach (var sub in subItems)
Assert.True(granted.Contains((int)sub[nameof(SubNavItem.SubNavItemId)]!), $"No permission row for sub item {sub[nameof(SubNavItem.Code)]}.");
}
[Theory]
[InlineData("hrm")]
[InlineData("hrm.employees")]
[InlineData("hrm.payroll")]
[InlineData("sales-returns")]
[InlineData("settings.audit-logs")]
public void NewPermissions_AreSeeded(string code)
{
using var db = CreateContext();
var codes = Seed<Permission>(db).Select(d => (string)d[nameof(Permission.Code)]!);
Assert.Contains("NAV:" + code, codes);
}
// ---------------------------------------------------------------- the policies actually decide
private sealed class FakeUser(int? roleId, bool isAdmin = false, int? branchId = null, bool branching = true) : ICurrentUser
{
public int AuditUserId => 10;
public int? RoleId => roleId;
public string? RoleCode => null;
public bool IsAdmin => isAdmin;
public string UserId => "10";
public bool IsAuthenticated => true;
public int? BranchId => branchId;
public bool BranchingEnabled => branching;
}
private static async Task<(ErpDbContext Db, NavPermissionService Nav)> RolesAsync()
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = 1, Code = "Hr", Name = "HR officer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = 2, Code = "Clerk", Name = "Clerk", CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "hrm", Label = "HR" });
db.SubNavItems.AddRange(
new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "hrm.employees", Label = "Employees" },
new SubNavItem { SubNavItemId = 102, NavItemId = 100, Code = "hrm.payroll", Label = "Payroll" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:hrm.employees", SubNavItemId = 101 },
new Permission { PermissionId = 201, Code = "NAV:hrm.payroll", SubNavItemId = 102 });
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Edit });
await db.SaveChangesAsync();
return (db, new NavPermissionService(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions())));
}
private static async Task<bool> PassesAsync(NavPermissionService nav, ICurrentUser user, string policyName)
{
var provider = new NavPolicyProvider(Microsoft.Extensions.Options.Options.Create(new AuthorizationOptions()));
var policy = (await provider.GetPolicyAsync(policyName))!;
var requirement = policy.Requirements.OfType<NavRequirement>().Single();
var context = new AuthorizationHandlerContext(new[] { requirement }, new ClaimsPrincipal(new ClaimsIdentity("test")), null);
await new NavAuthorizationHandler(user, nav).HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task HrOfficer_ReachesEmployees_ButNotPayData_AndAnAdministratorReachesAll()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
var hr = new FakeUser(roleId: 1);
Assert.True(await PassesAsync(nav, hr, "Nav:hrm")); // the module, via a screen inside it
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees"));
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees:Edit"));
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.employees:Delete")); // not granted
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.payroll")); // salaries, loans, bank accounts
Assert.False(await PassesAsync(nav, hr, "Nav:settings.audit-logs"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: 2), "Nav:hrm"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: null), "Nav:hrm"));
Assert.True(await PassesAsync(nav, new FakeUser(roleId: 2, isAdmin: true), "Nav:hrm.payroll:Approve"));
}
// ---------------------------------------------------------------- global search follows the role
[Fact]
public async Task Search_OnlyReturnsKindsTheRoleMayOpen()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E-1", FullName = "Alice Perera", BranchId = Branch1 });
await db.SaveChangesAsync();
GlobalSearchService Search(ICurrentUser u) => new(
new Repository<Item>(db), new Repository<PurchaseOrder>(db), new Repository<SalesSlip>(db), new Repository<Employee>(db), u, nav);
var hr = await Search(new FakeUser(roleId: 1)).SearchAsync("Alice");
Assert.Contains(hr, r => r.Type == GlobalSearchResultType.Employee);
var clerk = await Search(new FakeUser(roleId: 2)).SearchAsync("Alice");
Assert.DoesNotContain(clerk, r => r.Type == GlobalSearchResultType.Employee);
var admin = await Search(new FakeUser(roleId: 2, isAdmin: true)).SearchAsync("Alice");
Assert.Contains(admin, r => r.Type == GlobalSearchResultType.Employee);
}
// ---------------------------------------------------------------- CSRF on every cookie-authenticated mutation
private static (CsrfProtectionFilter Filter, ActionExecutingContext Context) CsrfCall(
string method, string? cookie = "token", string? header = null, bool sessionCookie = true,
bool bearer = false, bool anonymousEndpoint = false, bool hasCsrfAttribute = false)
{
var http = new DefaultHttpContext();
http.Request.Method = method;
var cookies = new List<string>();
if (sessionCookie) cookies.Add("erp_at=jwt");
if (cookie is not null) cookies.Add($"XSRF-TOKEN={cookie}");
if (cookies.Count > 0) http.Request.Headers.Cookie = string.Join("; ", cookies);
if (header is not null) http.Request.Headers["X-XSRF-TOKEN"] = header;
if (bearer) http.Request.Headers.Authorization = "Bearer abc";
var metadata = new List<object>();
if (anonymousEndpoint) metadata.Add(new AllowAnonymousAttribute());
if (hasCsrfAttribute) metadata.Add(new ValidateCsrfAttribute());
http.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "test"));
var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor());
var context = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object?>(), new object());
return (new CsrfProtectionFilter(), context);
}
private static async Task<bool> RunAsync(CsrfProtectionFilter filter, ActionExecutingContext context)
{
var ran = false;
await filter.OnActionExecutionAsync(context, () =>
{
ran = true;
return Task.FromResult(new ActionExecutedContext(context, context.Filters, context.Controller));
});
return ran;
}
[Theory]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("PATCH")]
[InlineData("DELETE")]
public async Task CookieRequest_WithoutTheCsrfHeader_IsRefused(string method)
{
var (filter, context) = CsrfCall(method, cookie: "token", header: null);
var ex = await Assert.ThrowsAsync<DomainException>(() => RunAsync(filter, context));
Assert.Equal(ErrorCodes.CsrfTokenMismatch, ex.Code);
Assert.Equal(403, ex.StatusCode);
}
[Fact]
public async Task CookieRequest_WithAWrongOrMissingCsrfCookie_IsRefused()
{
var (f1, c1) = CsrfCall("POST", cookie: "token", header: "other");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, header: "token");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f2, c2));
}
[Fact]
public async Task CookieRequest_WithMatchingCsrfHeader_Runs()
{
var (filter, context) = CsrfCall("DELETE", cookie: "token", header: "token");
Assert.True(await RunAsync(filter, context));
}
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
[InlineData("OPTIONS")]
public async Task SafeMethods_AreNotChecked(string method)
{
var (filter, context) = CsrfCall(method, cookie: null);
Assert.True(await RunAsync(filter, context));
}
[Fact]
public async Task BearerCallers_AnonymousEndpoints_AndRequestsWithoutASessionCookie_AreNotChecked()
{
var (f1, c1) = CsrfCall("POST", cookie: null, bearer: true);
Assert.True(await RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, anonymousEndpoint: true);
Assert.True(await RunAsync(f2, c2));
var (f3, c3) = CsrfCall("POST", cookie: null, sessionCookie: false);
Assert.True(await RunAsync(f3, c3));
}
[Fact]
public async Task ActionsThatCarryTheirOwnCsrfAttribute_AreNotCheckedTwice()
{
var (filter, context) = CsrfCall("POST", cookie: null, hasCsrfAttribute: true);
Assert.True(await RunAsync(filter, context)); // the attribute itself enforces it
}
[Fact]
public void TheCsrfFilter_IsRegisteredGlobally()
{
var program = File.ReadAllText(Path.Combine(FindBackendRoot(), "ERPCore", "Program.cs"));
Assert.Contains("Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>()", program);
}
private static string FindBackendRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "ERPCore"))) dir = dir.Parent;
return dir?.FullName ?? throw new DirectoryNotFoundException("Backend root not found.");
}
// ---------------------------------------------------------------- a branch-less person can't read every branch
[Fact]
public void BranchContext_AdministratorsAreNeverScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, isAdmin: true, branchId: 5));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public void BranchContext_AnEmployeeIsScopedToTheirOwnBranch()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: 5));
Assert.True(ctx.IsActive);
Assert.Equal(5, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_InABranchingDeployment_SeesNoBranchAtAll()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: true));
Assert.True(ctx.IsActive);
Assert.Equal(CurrentBranchContext.NoBranchId, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_WhenBranchingIsOff_IsNotScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: false));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public async Task QueryFilters_HideEveryWarehouse_FromABranchlessNonAdmin_ButNotFromAnAdminOrWhenBranchingIsOff()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
async Task<int> CountFor(ICurrentUser user, bool seed)
{
await using var db = new ErpDbContext(options, user, new CurrentBranchContext(user));
if (seed)
{
db.Warehouses.AddRange(
new Warehouse { WarehouseId = 1, Code = "W1", Name = "One", BranchId = 1 },
new Warehouse { WarehouseId = 2, Code = "W2", Name = "Two", BranchId = 2 });
await db.SaveChangesAsync();
}
return db.Warehouses.Count();
}
Assert.Equal(2, await CountFor(new FakeUser(1, isAdmin: true), seed: true));
Assert.Equal(1, await CountFor(new FakeUser(1, branchId: 2), seed: false));
Assert.Equal(0, await CountFor(new FakeUser(1, branchId: null, branching: true), seed: false));
Assert.Equal(2, await CountFor(new FakeUser(1, branchId: null, branching: false), seed: false));
}
}
@@ -0,0 +1,840 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances.
/// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale
/// or hand-built request sends.
/// </summary>
public class ProcurementAndOpeningBalanceTests
{
// ---------------------------------------------------------------- helpers
private static async Task SeedProcurementConfigAsync(
ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false,
bool terms = true, bool taxReg = true)
{
db.ProcurementConfig.Add(new ProcurementConfig
{
ConfigId = ProcurementConfig.SingletonId,
VendorContactMode = mode,
VendorAddressEnabled = address,
PoTaxEnabled = tax,
VendorPaymentTermsEnabled = terms,
VendorTaxRegEnabled = taxReg
});
await db.SaveChangesAsync();
}
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? tweak = null)
{
var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private sealed class NullNotifications : INotificationService
{
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask;
}
// ---------------------------------------------------------------- vendors
[Fact]
public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.None);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.Vendors);
}
[Fact]
public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value;
Assert.Equal("+94 77 123 4567", created.Mobile);
Assert.Null(created.Email);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
[Theory]
[InlineData("abc")]
[InlineData("12")]
public async Task Vendor_InvalidMobile_IsRejected(string mobile)
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile)));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Vendor_Address_NeedsTheSwitchOn()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, address: false);
var off = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road")));
Assert.Equal(ErrorCodes.ConfigDisabled, off.Code);
db.ProcurementConfig.Single().VendorAddressEnabled = true;
await db.SaveChangesAsync();
var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value;
Assert.Equal("12 Galle Road", created.Address);
}
[Fact]
public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r =>
{
r.Email = "accounts@supplier.lk";
r.Address = "Colombo";
}))).Value;
// Contact mode switched off later: an update must not erase what was recorded.
db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on
var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r =>
{
r.Terms = " NET30 ";
r.TaxReg = "134567890-7000";
}))).Value;
Assert.Equal("NET30", created.Terms);
Assert.Equal("134567890-7000", created.TaxReg);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, terms: false, taxReg: false);
var terms = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
var taxReg = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123")));
Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code);
Assert.Empty(db.Vendors);
// Blank is fine: the form simply doesn't send them.
var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value;
Assert.Null(ok.Terms);
}
[Fact]
public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r =>
{
r.Terms = "NET45";
r.TaxReg = "TRN-1";
}))).Value;
var cfg = db.ProcurementConfig.Single();
cfg.VendorPaymentTermsEnabled = false;
cfg.VendorTaxRegEnabled = false;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg));
}
// ---------------------------------------------------------------- purchase-order tax
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
ConfigService(db), new PassThroughUnitOfWork(db));
private static async Task<int> SeedVendorAndItemAsync(ErpDbContext db)
{
db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 });
await db.SaveChangesAsync();
return 1;
}
private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new()
{
VendorId = 1,
BranchId = Branch1,
SaveAsDraft = true,
Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }]
};
[Fact]
public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value;
var line = Assert.Single(po.Lines);
Assert.Equal(180m, line.TaxAmount);
Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal));
Assert.Equal(180m, db.PoLines.Single().TaxAmount);
}
[Fact]
public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
// 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding).
var request = PoRequest(0.05m);
request.Lines[0].Qty = 3;
request.Lines[0].UnitPrice = 33.5m;
var po = (await PoSvc(db).CreateAsync(request)).Value;
Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount);
}
[Fact]
public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: false);
await SeedVendorAndItemAsync(db);
var ex = await Assert.ThrowsAsync<DomainException>(() => PoSvc(db).CreateAsync(PoRequest(0.18m)));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.PurchaseOrders);
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.Equal(0m, po.Totals.Tax);
}
[Fact]
public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false);
await SeedVendorAndItemAsync(db);
var vendor = db.Vendors.Single();
vendor.Mobile = "0771234567";
vendor.Email = "po@vendor.lk";
vendor.Address = "Colombo";
await db.SaveChangesAsync();
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.NotNull(po.Vendor);
Assert.Equal("PO Vendor", po.Vendor!.Name);
Assert.Equal("po@vendor.lk", po.Vendor.Email);
Assert.Null(po.Vendor.Mobile); // contact mode is Email
Assert.Null(po.Vendor.Address); // address switched off
}
// ---------------------------------------------------------------- purchase-order list: date filter + sort
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
private static async Task<int[]> SeedListAsync(ErpDbContext db)
{
db.Vendors.AddRange(
new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active },
new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 });
PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new()
{
PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft,
CreatedAt = createdUtc,
Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }]
};
db.PurchaseOrders.AddRange(
Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50
Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300
Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100
await db.SaveChangesAsync();
return [1, 2, 3];
}
private static async Task<List<int>> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null)
{
var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to);
return page.Items.Select(i => i.PoId).ToList();
}
[Fact]
public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
// PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it.
Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1)));
Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2)));
Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10)));
Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30)));
Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3)));
}
[Fact]
public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first
Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first
Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first)
Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor"));
Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50
Assert.Equal([1, 3, 2], await ListIds(svc, "total"));
Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default
}
[Fact]
public async Task PoList_Search_MatchesVendorNameAndCode()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null);
var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null);
Assert.Equal([2], byName.Items.Select(i => i.PoId));
Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId));
}
// ---------------------------------------------------------------- opening balances
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
private sealed class OpeningGl : IGeneralLedgerService
{
public List<GlJournalEntryRequest> Posted { get; } = new();
public int AccountCreates { get; private set; }
public bool EquityExists { get; init; } = true;
public string BankCurrency { get; init; } = "LKR";
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
AccountCreates++;
return Task.FromResult(new GeneralLedgerResponse
{
StatusCode = 201,
Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}"""
});
}
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlBankAccount>>(
[
new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"),
new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency)
]);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlAccount>>(EquityExists
? [new("3000", "Opening Balance Equity", 3, true, true)]
: [new("1000", "Bank", 1, false, true)]);
}
private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) =>
new(new Repository<OpeningBalanceRun>(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new()
{
CutoverDate = date,
Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList()
};
private static readonly DateOnly OpeningDate = new(2026, 4, 1);
[Fact]
public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m)));
var entry = Assert.Single(gl.Posted);
Assert.Equal("OPENING_BALANCE", entry.JournalType);
Assert.Equal("OPENING", entry.SourceModule);
Assert.Equal(OpeningDate, entry.EntryDate);
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m });
Assert.Equal(2000m, run.TotalAmount);
Assert.Equal("JV-2026-000001", run.GlJournalNo);
Assert.Equal(2, run.Lines.Count);
Assert.Equal(0, gl.AccountCreates); // the equity account already existed
}
[Fact]
public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone()
{
await using var db = CreateContext();
var gl = new OpeningGl { EquityExists = false };
await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
Assert.Equal(1, gl.AccountCreates);
Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m);
}
[Fact]
public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m))));
Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code);
Assert.Equal(409, ex.StatusCode);
Assert.Single(gl.Posted);
Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount);
}
[Fact]
public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m)));
var entry = Assert.Single(gl.Posted);
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m });
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Equal(200m, run.TotalAmount);
}
[Fact]
public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint()
{
await using var db = CreateContext();
var svc = OpeningSvc(db, new OpeningGl());
var before = await svc.GetCashBankAsync();
Assert.Null(before.Posted);
Assert.All(before.Accounts, a => Assert.Null(a.Amount));
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m)));
var after = await svc.GetCashBankAsync();
Assert.NotNull(after.Posted);
Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount);
Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount);
}
[Fact]
public async Task Opening_RejectsBadInput_WithoutPostingAnything()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
// not a cash/bank account
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
// more than 2 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
// nothing but zeros
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
// the same account twice
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
// in the future
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m))));
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
}
[Fact]
public async Task Opening_RefusesANonBaseCurrencyAccount()
{
await using var db = CreateContext();
var gl = new OpeningGl { BankCurrency = "USD" };
var ex = await Assert.ThrowsAsync<DomainException>(
() => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m))));
Assert.Equal(ErrorCodes.Validation, ex.Code);
Assert.Empty(gl.Posted);
}
// ---------------------------------------------------------------- opening stock
private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl)
{
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService(
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
new TestCurrentUser(), new PassThroughUnitOfWork(db));
}
private static Item StockedItem(int id, Action<Item>? tweak = null)
{
var item = new Item
{
ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1,
StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active
};
tweak?.Invoke(item);
return item;
}
private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new()
{
CutoverDate = OpeningDate,
Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList()
};
[Fact]
public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m)));
Assert.Equal(330m, run.TotalValue);
Assert.Equal(2, run.LineCount);
var layer = db.StockLayers.Single(l => l.ItemId == 1);
Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost));
var row = db.StockLedger.Single(l => l.ItemId == 1);
Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance));
Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId);
// One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row.
var entry = Assert.Single(gl.Posted);
Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType));
Assert.Collection(entry.Lines,
l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN"));
}
[Fact]
public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 });
await db.SaveChangesAsync();
var gl = new OpeningGl();
var ex = await Assert.ThrowsAsync<DomainException>(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m))));
Assert.Equal(409, ex.StatusCode);
Assert.Contains("SKU-1", ex.Message);
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item
}
[Fact]
public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await svc.PostAsync(StockRequest((1, 5m, 2m)));
await svc.PostAsync(StockRequest((2, 7m, 4m)));
Assert.Equal(2, gl.Posted.Count);
Assert.Equal(2, (await svc.ListAsync()).Count);
// ...but the same item cannot be opened a second time.
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m))));
}
[Fact]
public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers()
{
await using var db = CreateContext();
db.Items.AddRange(
StockedItem(1, i => i.TrackingMode = TrackingMode.Serial),
StockedItem(2, i => i.TrackingMode = TrackingMode.Batch),
StockedItem(3, i => i.StockNature = StockNature.Service),
StockedItem(4, i => i.Status = EntityStatus.Inactive),
StockedItem(5));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate
Assert.Empty(gl.Posted);
Assert.Empty(db.StockLayers);
}
[Fact]
public async Task OpeningStock_ZeroCostRun_PostsNoJournal()
{
await using var db = CreateContext();
db.Items.Add(StockedItem(1));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m)));
Assert.Equal(0m, run.TotalValue);
Assert.Null(run.GlJournalNo);
Assert.Empty(gl.Posted);
Assert.Equal(10m, db.StockLayers.Single().QtyRemaining);
}
// ---------------------------------------------------------------- purchase-order rejection
[Fact]
public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
// Somebody else raised it: the approver can't be its creator.
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
var rejected = await svc.RejectAsync(po.PoId, "Wrong price");
Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status);
// A rejected order is reopened like a draft: it can be sent for approval again...
var resubmitted = await svc.SubmitAsync(po.PoId);
Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status);
}
[Fact]
public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
await svc.RejectAsync(po.PoId, null);
await svc.DeleteAsync(po.PoId);
Assert.Empty(db.PurchaseOrders);
}
// ---------------------------------------------------------------- General Ledger payload
private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient
{
public string? SentBody { get; private set; }
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct)
{
if (requestBody is not null)
{
using var reader = new StreamReader(requestBody);
SentBody = await reader.ReadToEndAsync(ct);
}
return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body };
}
}
private static GlJournalEntryRequest GrnJournal() => new()
{
PeriodId = 1,
EntryDate = new DateOnly(2026, 10, 18),
SourceModule = "GRN",
Reference = "GRN-1",
Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)]
};
[Fact]
public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing()
{
// GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model
// validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out.
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None);
Assert.Equal("J-1", result.JournalNo);
Assert.DoesNotContain("journalType", client.SentBody!);
Assert.DoesNotContain("null", client.SentBody!);
}
[Fact]
public async Task GlPost_KeepsAJournalTypeWhenOneIsSet()
{
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var request = GrnJournal();
request.JournalType = "OPENING_BALANCE";
await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None);
Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!);
}
[Fact]
public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection()
{
var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}";
var client = new CapturingGlClient(400, problem);
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code);
Assert.Contains("JournalType", ex.Message);
Assert.Contains("required", ex.Message);
}
[Fact]
public async Task GlPost_GlsOwnMessage_StillWins()
{
var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}");
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal("Period is locked", ex.Message);
}
// ---------------------------------------------------------------- stock movement vs. document journal
[Fact]
public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
var now = DateTime.UtcNow;
// A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one,
// or the movement is counted twice in the General Ledger.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now,
CancellationToken.None, postGlJournal: false);
await db.SaveChangesAsync();
Assert.Empty(gl.Posted);
Assert.Single(db.StockLedger);
// Production and transfers have no document journal, so they keep the generic one.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now,
CancellationToken.None);
var journal = Assert.Single(gl.Posted);
Assert.Equal("STOCK", journal.SourceModule);
}
}
@@ -14,6 +14,7 @@ internal sealed class TestCurrentUser : ICurrentUser
// Unauthenticated, so no role claim and no branch scoping: the three members the // Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser. // AuthHex integration added to ICurrentUser.
public int? RoleId => null;
public string? RoleCode => null; public string? RoleCode => null;
public bool IsAdmin => false; public bool IsAdmin => false;
public int? BranchId => null; public int? BranchId => null;
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Access configuration — the default role for people who have none. Both reading and changing it need the
/// <c>settings</c> nav permission (it decides what a brand-new sign-in can do).
/// </summary>
[Route("api/v1/access-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class AccessConfigController : ApiControllerBase
{
private readonly IAccessConfigService _config;
public AccessConfigController(IAccessConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<AccessConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<AccessConfigDto>> Update([FromBody] UpdateAccessConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,6 +1,8 @@
using ERPCore.Dtos.Audit; using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// the audit trail is required (AR-01 compensating control) and read access is the /// the audit trail is required (AR-01 compensating control) and read access is the
/// only way to consume it. /// only way to consume it.
/// </summary> /// </summary>
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
[Route("api/v1/audit-logs")] [Route("api/v1/audit-logs")]
public sealed class AuditLogsController : ApiControllerBase public sealed class AuditLogsController : ApiControllerBase
{ {
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct) public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{ {
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value; return Ok(await _roles.GetMeAsync(ct));
return Ok(await _roles.GetMeAsync(roleCode, ct));
} }
// ---- Session-issuing (UserManager) ------------------------------------ // ---- Session-issuing (UserManager) ------------------------------------
@@ -25,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List( public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId, [FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct) [FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct)); [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
[HttpGet("{grnId:int}")] [HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary> /// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
[Route("api/v1/attendance-batches")] [Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase public sealed class AttendanceBatchesController : ApiControllerBase
{ {
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost] [HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)] [RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct) int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct)); => Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{batchId:int}/records/{recordId:int}")] [HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct) int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct)); => Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/resolve-duplicate")] [HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/validate")] [HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct) public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct)); => Ok(await _attendance.ValidateAsync(batchId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/confirm")] [HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct) public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct)); => Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/unlock")] [HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value); return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPut("{branchId:int}")] [HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPatch("{branchId:int}/status")] [HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpDelete("{branchId:int}")] [HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary> /// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/departments")] [Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase public sealed class DepartmentsController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value); return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{departmentId:int}")] [HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{departmentId:int}/status")] [HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{departmentId:int}")] [HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary> /// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/designations")] [Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase public sealed class DesignationsController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value); return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{designationId:int}")] [HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{designationId:int}/status")] [HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{designationId:int}")] [HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff /// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3). /// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary> /// </summary>
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employees")] [Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase public sealed class EmployeesController : ApiControllerBase
{ {
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct)); => Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary> /// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("email-lookup")] [HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct) public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct))); => Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}")] [HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value); return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}")] [HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/status")] [HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +102,7 @@ public sealed class EmployeesController : ApiControllerBase
} }
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary> /// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[HttpDelete("{employeeId:int}")] [HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -103,6 +113,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result); return Ok(result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/link-user")] [HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpDelete("{employeeId:int}/link-user")] [HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/bank-details")] [HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct) public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct)); => Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/bank-details")] [HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -140,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct) public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct)); => Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/leave-balances")] [HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct) int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct)); => Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/salary-structure")] [HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct) public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct)); => Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/salary-structure")] [HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/salary-structure", result); return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans")] [HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct) public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct)); => Ok(await _loans.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans/{loanId:int}")] [HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{employeeId:int}/loans")] [HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result); return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents")] [HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct) public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct)); => Ok(await _documents.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/documents")] [HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)] [RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result); return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")] [HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
return File(content, contentType, fileName); return File(content, contentType, fileName);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")] [HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary> /// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employment-types")] [Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase public sealed class EmploymentTypesController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value); return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{employmentTypeId:int}")] [HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{employmentTypeId:int}/status")] [HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{employmentTypeId:int}")] [HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary> /// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/hr-document-types")] [Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase public sealed class HrDocumentTypesController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value); return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{hrDocumentTypeId:int}")] [HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{hrDocumentTypeId:int}/status")] [HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{hrDocumentTypeId:int}")] [HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary> /// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
[Route("api/v1/reports/hrm")] [Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase public sealed class HrReportsController : ApiControllerBase
{ {
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct) [FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct)); => Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("payroll-register")] [HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct) public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct)); => Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("salary-history")] [HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct) public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary> /// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
[Route("api/v1/leave-requests")] [Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase public sealed class LeaveRequestsController : ApiControllerBase
{ {
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result); return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/submit")] [HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct) public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct)); => Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/approve")] [HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct) public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct)); => Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/reject")] [HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct) public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct)); => Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/cancel")] [HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary> /// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/leave-types")] [Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase public sealed class LeaveTypesController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value); return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{leaveTypeId:int}")] [HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{leaveTypeId:int}/status")] [HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{leaveTypeId:int}")] [HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary> /// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payroll-runs")] [Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase public sealed class PayrollRunsController : ApiControllerBase
{ {
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result); return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/approve")] [HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct) public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct)); => Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/lock")] [HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct) public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct)); => Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/unlock")] [HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct) public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct)); => Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{payrollRunId:int}/generate-payslips")] [HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,11 +1,13 @@
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary> /// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/payroll-statutory-settings")] [Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase public sealed class PayrollStatutorySettingsController : ApiControllerBase
{ {
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct) public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct)); => Ok(await _settings.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct) public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary> /// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payslips")] [Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase public sealed class PayslipsController : ApiControllerBase
{ {
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary> /// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/salary-components")] [Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase public sealed class SalaryComponentsController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value); return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{salaryComponentId:int}")] [HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{salaryComponentId:int}/status")] [HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{salaryComponentId:int}")] [HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary> /// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/tax-slabs")] [Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase public sealed class TaxSlabsController : ApiControllerBase
{ {
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct) public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct)); => Ok(await _taxSlabs.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm; namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary> /// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/work-shifts")] [Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase public sealed class WorkShiftsController : ApiControllerBase
{ {
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value); return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{workShiftId:int}")] [HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{workShiftId:int}/status")] [HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return NoContent(); return NoContent();
} }
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{workShiftId:int}")] [HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -0,0 +1,71 @@
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// One-time opening balances. Gated on the <c>accounts.opening-balances</c> nav code (Admin always passes;
/// other roles need it granted under Settings → Roles).
/// </summary>
[Route("api/v1/opening-balances")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.opening-balances")]
public sealed class OpeningBalancesController : ApiControllerBase
{
private readonly IOpeningBalanceService _openingBalances;
private readonly IOpeningStockService _openingStock;
public OpeningBalancesController(IOpeningBalanceService openingBalances, IOpeningStockService openingStock)
{
_openingBalances = openingBalances;
_openingStock = openingStock;
}
/// <summary>The cash and bank accounts to enter opening amounts for — or the posted record, once done.</summary>
[HttpGet("cash-bank")]
[ProducesResponseType(typeof(CashBankOpeningStateDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashBankOpeningStateDto>> GetCashBank(CancellationToken ct) =>
Ok(await _openingBalances.GetCashBankAsync(ct));
/// <summary>Posts the cash and bank opening balances as one journal. Allowed once; later calls get 409.</summary>
[HttpPost("cash-bank")]
[ProducesResponseType(typeof(OpeningBalanceRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningBalanceRunDto>> PostCashBank(
[FromBody] PostCashBankOpeningRequest request, CancellationToken ct)
{
var run = await _openingBalances.PostCashBankAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
/// <summary>Opening-stock runs posted so far, newest first.</summary>
[HttpGet("stock")]
[ProducesResponseType(typeof(IReadOnlyList<OpeningStockRunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<OpeningStockRunSummaryDto>>> ListStock(CancellationToken ct) =>
Ok(await _openingStock.ListAsync(ct));
/// <summary>One opening-stock run with its lines.</summary>
[HttpGet("stock/{runId:int}")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OpeningStockRunDto>> GetStock(int runId, CancellationToken ct)
{
var run = await _openingStock.GetAsync(runId, ct);
return run is null ? NotFound() : Ok(run);
}
/// <summary>
/// Posts opening stock: a FIFO layer per line and one journal (Dr Inventory / Cr Opening Balance Equity).
/// An item that already has stock movements in that warehouse is refused with 409.
/// </summary>
[HttpPost("stock")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningStockRunDto>> PostStock([FromBody] PostOpeningStockRequest request, CancellationToken ct)
{
var run = await _openingStock.PostAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
}
@@ -0,0 +1,46 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Procurement configuration endpoints — optional vendor fields and PO tax.
/// <para>
/// <b>Authorization:</b> reading is open to any signed-in ERP user, because the vendor and purchase-order
/// forms must read the flags and those screens sit under different nav codes. The flags are not
/// sensitive. Changing them needs the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/procurement-config")]
public sealed class ProcurementConfigController : ApiControllerBase
{
private readonly IProcurementConfigService _config;
public ProcurementConfigController(IProcurementConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProcurementConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProcurementConfigDto>> Update(
[FromBody] UpdateProcurementConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -20,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List( public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct) [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
=> Ok(await _pos.ListAsync(query, status, vendorId, ct)); [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
[HttpGet("{poId:int}")] [HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference; using ERPCore.Dtos.Reference;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct) [FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _codes.ListAsync(context, query, ct)); => Ok(await _codes.ListAsync(context, query, ct));
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales; using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary> /// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")] [Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales")] [Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase public sealed class SalesReturnsController : ApiControllerBase
{ {
private readonly ISalesReturnService _returns; private readonly ISalesReturnService _returns;
+18 -2
View File
@@ -41,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct) public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct)); => Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")] [HttpGet("user-types")]
@@ -117,4 +117,20 @@ public sealed class UsersController : ApiControllerBase
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct)); return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
} }
/// <summary>
/// One-time migration helper (administrators only): copies each role-less user's role from AuthHex into the ERP.
/// Safe to repeat — users that already have a role are left alone. See
/// <see cref="IUserManagementService.BackfillRolesFromAuthHexAsync"/>.
/// </summary>
[HttpPost("backfill-roles")]
[ProducesResponseType(typeof(BackfillRolesResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<BackfillRolesResultDto>> BackfillRoles(CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can run the role backfill.", 403);
return Ok(await _users.BackfillRolesFromAuthHexAsync(ct));
}
} }
@@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors; using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers; namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary> /// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")] [Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "vendors")] [Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class VendorsController : ApiControllerBase public sealed class VendorsController : ApiControllerBase
{ {
private readonly IVendorService _vendors; private readonly IVendorService _vendors;
@@ -35,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -45,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value); return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{vendorId:int}")] [HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -57,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{vendorId:int}/status")] [HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,7 +70,7 @@ public sealed class VendorsController : ApiControllerBase
} }
[HttpDelete("{vendorId:int}")] [HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)] [Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -0,0 +1,26 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Access configuration — a singleton row (same shape as <see cref="GrnConfig"/>).
/// <see cref="DefaultRoleId"/> is the role a person gets on their first sign-in, and when they have none:
/// without it they would have no permissions at all. It must be a least-privilege role and can never be the
/// administrator role (enforced when it is saved).
/// </summary>
public class AccessConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public int? DefaultRoleId { get; set; }
public Role? DefaultRole { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -39,8 +39,9 @@ public class ApprovalConfig
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary> /// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; } public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary> /// <summary>Comma-separated role IDS allowed to give the second approval; empty = anyone with the Approve action.
public string? Level2RoleCodes { get; set; } /// (Ids, not codes: a role can be renamed without breaking this rule.)</summary>
public string? Level2RoleIds { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { get; set; }
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One posted opening balance (cash and bank, or stock) — the record that the opening entry was made, when,
/// by whom, and which General Ledger journal carries it. The General Ledger has no "opening posted" notion, so
/// this table is what refuses a second posting: cash/bank sets <see cref="OnceKey"/> (unique index), so it can
/// be posted once. Stock leaves it null and can be loaded in several runs; each item in a warehouse can be
/// opened only once (an item that already has stock movements there is refused).
/// </summary>
public class OpeningBalanceRun
{
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceKind Kind { get; set; }
/// <summary>Set (to the kind's name) only for kinds that may be posted once; a unique index on it is the
/// once-only guard. Null for stock, which may be loaded in batches (MySQL allows many NULLs under a unique index).</summary>
public string? OnceKey { get; set; }
/// <summary>The opening date — the GL journal's entry date. Reports starting after it show these amounts as opening.</summary>
public DateOnly CutoverDate { get; set; }
/// <summary>The GL journal number (JV-…). Null only for the instant between claiming the run and the GL
/// confirming the posting, inside one transaction; never null on a committed row.</summary>
public string? GlJournalNo { get; set; }
/// <summary>Sum of the debit amounts (what the Opening Balance Equity account is credited with).</summary>
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = "LKR";
public int PostedBy { get; set; }
public DateTime PostedAt { get; set; }
public ICollection<OpeningBalanceLine> Lines { get; set; } = new List<OpeningBalanceLine>();
public ICollection<OpeningStockLine> StockLines { get; set; } = new List<OpeningStockLine>();
}
/// <summary>One cash or bank account's opening amount within an <see cref="OpeningBalanceRun"/>.
/// Positive = a debit balance (money held); negative = a credit balance (an overdraft).</summary>
public class OpeningBalanceLine
{
public int OpeningBalanceLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public string AccountCode { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
/// <summary>One item's opening stock in one warehouse within a stock <see cref="OpeningBalanceRun"/>.</summary>
public class OpeningStockLine
{
public int OpeningStockLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Quantity in the item's base UOM.</summary>
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
/// <summary>Qty x UnitCost, rounded to 4 dp — what the inventory account is debited with.</summary>
public decimal Value { get; set; }
}
@@ -21,5 +21,10 @@ public class PoLine
public decimal Qty { get; set; } public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }// public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; } public decimal Tax { get; set; }
/// <summary>Tax amount for this line = round(Qty x UnitPrice x <see cref="Tax"/>, 2). Stored so the
/// figure on the PO, its PDF and the totals never drifts from what was approved. Always 0 while
/// Procurement configuration's PO tax switch is off.</summary>
public decimal TaxAmount { get; set; }
public decimal QtyReceived { get; set; } public decimal QtyReceived { get; set; }
} }
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Procurement configuration — a singleton row, same shape as <see cref="GrnConfig"/>.
/// Controls the optional vendor fields and whether purchase orders carry tax.
/// </summary>
public class ProcurementConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Which contact detail vendors carry: none (default), a mobile number or an email.
/// A value sent for a field that is not selected is rejected server-side (CONFIG_DISABLED).</summary>
public VendorContactMode VendorContactMode { get; set; } = VendorContactMode.None;
/// <summary>When on, vendors carry an optional company address.</summary>
public bool VendorAddressEnabled { get; set; }
/// <summary>When on (default — the field existed before it became optional), vendors carry optional payment terms.
/// When off the field is hidden and a value for it is rejected (CONFIG_DISABLED); stored values are kept.</summary>
public bool VendorPaymentTermsEnabled { get; set; } = true;
/// <summary>When on (default), vendors carry an optional tax registration number. Same rules as payment terms.
/// Purchase invoices still snapshot a vendor's stored number either way.</summary>
public bool VendorTaxRegEnabled { get; set; } = true;
/// <summary>When on, purchase-order lines carry a tax rate (prefilled from the item) and tax amount;
/// when off, tax is hidden everywhere on POs and a non-zero tax is rejected server-side.</summary>
public bool PoTaxEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+13 -8
View File
@@ -3,25 +3,30 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same /// An ERP role. The ERP owns roles and what they may do: <see cref="User.RoleId"/> points here and
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/> /// <see cref="RolePermission"/> holds the permissions. AuthHex only authenticates people and knows nothing about roles.
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// </summary> /// </summary>
public class Role public class Role
{ {
public int RoleId { get; set; } public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
/// <summary>Legacy link to the AuthHex role this one used to mirror. Unused now; kept (nullable) until the AuthHex
/// role table is retired, then dropped.</summary>
public Guid? AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public bool IsSystemRole { get; set; } public bool IsSystemRole { get; set; }
/// <summary>True for the administrator role: its holders pass every permission check, bypass branch scoping and can
/// manage users and roles. This flag — never the role's code or name — is what "admin" means, so the role can be
/// renamed freely. Not settable through the API; only the seeded Admin role carries it.</summary>
public bool IsAdmin { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; } public uint RowVersion { get; set; }
} }
@@ -51,6 +51,31 @@ public class SalesConfig
public int? DefaultWarehouseId { get; set; } public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; } public Warehouse? DefaultWarehouse { get; set; }
// ---- Optional customer fields. Each is on by default (the field existed before it became optional). When off the
// field is hidden on the customer forms and a new value for it is rejected server-side (CONFIG_DISABLED); values
// already stored are kept.
/// <summary>Customers carry an optional phone number.</summary>
public bool CustomerPhoneEnabled { get; set; } = true;
/// <summary>Customers carry an optional email address.</summary>
public bool CustomerEmailEnabled { get; set; } = true;
/// <summary>Customers carry an optional address (lines 1 and 2).</summary>
public bool CustomerAddressEnabled { get; set; } = true;
/// <summary>Customers carry an optional city.</summary>
public bool CustomerCityEnabled { get; set; } = true;
/// <summary>Customers carry an optional country.</summary>
public bool CustomerCountryEnabled { get; set; } = true;
/// <summary>Customers have a credit limit.</summary>
public bool CustomerCreditLimitEnabled { get; set; } = true;
/// <summary>Customers have credit days (how long an invoice may stay unpaid — the customer's "age").</summary>
public bool CustomerCreditDaysEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; } public int? UpdatedBy { get; set; }
@@ -15,6 +15,13 @@ public class Vendor
public string? Terms { get; set; } public string? Terms { get; set; }
public string? TaxReg { get; set; } public string? TaxReg { get; set; }
public string Currency { get; set; } = "LKR"; public string Currency { get; set; } = "LKR";
/// <summary>Mobile number — only recorded while Procurement configuration's vendor contact mode is Mobile.</summary>
public string? Mobile { get; set; }
/// <summary>Email — only recorded while the vendor contact mode is Email.</summary>
public string? Email { get; set; }
/// <summary>Company address — only recorded while vendor address is enabled.</summary>
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active; public EntityStatus Status { get; set; } = EntityStatus.Active;
public int BranchId { get; set; } public int BranchId { get; set; }
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which opening balance a run carries. Each kind is posted ONCE for the life of the system, so a
/// unique index on the kind is the "already posted" guard (the General Ledger itself does not stop a
/// second opening entry). Stored as a string.
/// </summary>
public enum OpeningBalanceKind
{
CashBank,
Stock
}
@@ -3,8 +3,10 @@ namespace ERPCore.Domain.Enums;
/// <summary> /// <summary>
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create /// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who /// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back /// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it to
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string. /// <see cref="Rejected"/> (FR-PROC-04) - a status of its own, so a rejected order is never mistaken for a fresh
/// Draft or one still awaiting approval. A Rejected order is editable like a Draft and goes back through
/// approval when resubmitted. Stored as a string.
/// </summary> /// </summary>
public enum PurchaseOrderStatus public enum PurchaseOrderStatus
{ {
@@ -14,5 +16,6 @@ public enum PurchaseOrderStatus
PartiallyReceived, PartiallyReceived,
FullyReceived, FullyReceived,
Closed, Closed,
Cancelled Cancelled,
Rejected
} }
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which single contact detail a deployment records for vendors (Procurement configuration).
/// <see cref="None"/> hides the field everywhere. Stored as a string.
/// </summary>
public enum VendorContactMode
{
None,
Mobile,
Email
}
@@ -10,7 +10,7 @@ public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int D
public sealed record ApprovalConfigDto( public sealed record ApprovalConfigDto(
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue, int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent, int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt); IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt);
public sealed class UpdateApprovalConfigRequest public sealed class UpdateApprovalConfigRequest
{ {
@@ -18,7 +18,8 @@ public sealed class UpdateApprovalConfigRequest
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; } [Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; } [Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; } [Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
public List<string> Level2RoleCodes { get; set; } = new(); /// <summary>Roles allowed to give the second approval; empty = anyone with the Approve action.</summary>
public List<int> Level2RoleIds { get; set; } = new();
} }
public sealed class ApproveRequest public sealed class ApproveRequest
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+3 -1
View File
@@ -57,7 +57,9 @@ public sealed class RegisterRequest
{ {
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary> /// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
[Required] public Guid RoleId { get; set; } /// <summary>Ignored for self-registration and never taken from a caller: the ERP assigns roles itself. Sent to AuthHex only
/// while the old AuthHex (which still demands a role id) is deployed — see <c>AuthHex:LegacyRoleId</c>.</summary>
public Guid? RoleId { get; set; }
[Required] public Guid UserTypeId { get; set; } [Required] public Guid UserTypeId { get; set; }
public string? Fullname { get; set; } public string? Fullname { get; set; }
public string? UserName { get; set; } public string? UserName { get; set; }
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Access configuration resource: which role a person gets when they have none.</summary>
public sealed record AccessConfigDto(int? DefaultRoleId, string? DefaultRoleName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>Full replacement. <c>null</c> clears the default (people without a role then have no permissions at all).</summary>
public sealed class UpdateAccessConfigRequest
{
[Range(1, int.MaxValue)] public int? DefaultRoleId { get; set; }
}
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Config;
/// <summary>Procurement configuration resource.</summary>
public sealed record ProcurementConfigDto(
VendorContactMode VendorContactMode, bool VendorAddressEnabled, bool PoTaxEnabled,
DateTime? UpdatedAt, int? UpdatedBy,
bool VendorPaymentTermsEnabled = true, bool VendorTaxRegEnabled = true);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable so an omitted flag is a 400 rather than silently binding to a default
/// (same reasoning as <see cref="UpdateGrnConfigRequest"/>).
/// </summary>
public sealed class UpdateProcurementConfigRequest
{
[Required] public VendorContactMode? VendorContactMode { get; set; }
[Required] public bool? VendorAddressEnabled { get; set; }
[Required] public bool? PoTaxEnabled { get; set; }
[Required] public bool? VendorPaymentTermsEnabled { get; set; }
[Required] public bool? VendorTaxRegEnabled { get; set; }
}
+11 -1
View File
@@ -6,7 +6,10 @@ namespace ERPCore.Dtos.Config;
/// the generic cash customer (null until the option has been switched on at least once).</summary> /// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto( public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale, bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy); bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy,
bool CustomerPhoneEnabled = true, bool CustomerEmailEnabled = true, bool CustomerAddressEnabled = true,
bool CustomerCityEnabled = true, bool CustomerCountryEnabled = true,
bool CustomerCreditLimitEnabled = true, bool CustomerCreditDaysEnabled = true);
/// <summary> /// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted. /// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -22,4 +25,11 @@ public sealed class UpdateSalesConfigRequest
[Required] public bool? AllowWarehouseSelection { get; set; } [Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary> /// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; } public int? DefaultWarehouseId { get; set; }
[Required] public bool? CustomerPhoneEnabled { get; set; }
[Required] public bool? CustomerEmailEnabled { get; set; }
[Required] public bool? CustomerAddressEnabled { get; set; }
[Required] public bool? CustomerCityEnabled { get; set; }
[Required] public bool? CustomerCountryEnabled { get; set; }
[Required] public bool? CustomerCreditLimitEnabled { get; set; }
[Required] public bool? CustomerCreditDaysEnabled { get; set; }
} }
+4 -2
View File
@@ -27,7 +27,8 @@ public sealed record GrnDto(
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo); int? PurchaseInvoiceId, string? PurchaseInvoiceNo,
string? PoDocNo = null, Dtos.Procurement.PoVendorDto? Vendor = null);
public sealed record CreatedLayerDto( public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId, int LayerId, int ItemId, int WarehouseId, int? BatchId,
@@ -93,7 +94,8 @@ public sealed class CreateGrnRequest
public int? VendorId { get; set; } public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually /// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary> /// scopes the document (resolved via IBranchConfigService; required when branching is enabled).
/// Ignored when <see cref="PoId"/> is set: a GRN against a purchase order always belongs to that order's branch.</summary>
[Required] public int BranchId { get; set; } [Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
} }
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.OpeningBalances;
/// <summary>One cash or bank account as the opening-balance screen shows it. <c>Amount</c> is filled only once posted.</summary>
public sealed record OpeningCashBankAccountDto(
string AccountType, string AccountName, string? BankName, string? AccountNumber,
string GlAccountCode, string CurrencyCode, decimal? Amount);
public sealed record OpeningBalanceLineDto(string AccountCode, string AccountName, decimal Amount);
/// <summary>A posted opening balance (the record of the one-time entry).</summary>
public sealed record OpeningBalanceRunDto(
int OpeningBalanceRunId, string Kind, DateOnly CutoverDate, string? GlJournalNo,
decimal TotalAmount, string Currency, DateTime PostedAt, int PostedBy,
IReadOnlyList<OpeningBalanceLineDto> Lines);
/// <summary>The cash/bank screen's state: the accounts to enter amounts for, or the posted record once done.</summary>
public sealed record CashBankOpeningStateDto(
OpeningBalanceRunDto? Posted, IReadOnlyList<OpeningCashBankAccountDto> Accounts, string BaseCurrency);
public sealed class OpeningBalanceLineInput
{
/// <summary>The GL account code of a cash or bank account, as returned by the state endpoint.</summary>
[Required, StringLength(20)] public string GlAccountCode { get; set; } = string.Empty;
/// <summary>Opening balance, at most 2 decimals. Positive = money held; negative = an overdraft. Zero lines are ignored.</summary>
public decimal Amount { get; set; }
}
public sealed class PostCashBankOpeningRequest
{
/// <summary>The opening date (the GL entry date). Must fall in an open accounting period and not be in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1)] public List<OpeningBalanceLineInput> Lines { get; set; } = new();
}
// ---------------------------------------------------------------- opening stock
public sealed class OpeningStockLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Quantity in the item's base UOM; more than 0, at most 4 decimals.</summary>
public decimal Qty { get; set; }
/// <summary>Cost of ONE base unit, 0 or more, at most 4 decimals — this becomes the FIFO layer's cost.</summary>
public decimal UnitCost { get; set; }
}
public sealed class PostOpeningStockRequest
{
/// <summary>The opening date (the stock layers' receipt date and the GL entry date). Not in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1), MaxLength(500)] public List<OpeningStockLineInput> Lines { get; set; } = new();
}
public sealed record OpeningStockLineDto(
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseCode,
decimal Qty, decimal UnitCost, decimal Value);
public sealed record OpeningStockRunSummaryDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy);
public sealed record OpeningStockRunDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy, IReadOnlyList<OpeningStockLineDto> Lines);
@@ -5,9 +5,14 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------ // Responses (docs/11 §3.3) ------------------------------------------------------
/// <summary><c>Tax</c> is the rate (0..1); <c>TaxAmount</c> the stored amount for the line.</summary>
public sealed record PoLineDto( public sealed record PoLineDto(
int PoLineId, int ItemId, int WarehouseId, int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived); decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived, decimal TaxAmount = 0);
/// <summary>Vendor details shown on a purchase order. <c>Mobile</c>/<c>Email</c>/<c>Address</c> are null
/// unless the matching Procurement configuration switch is on.</summary>
public sealed record PoVendorDto(int VendorId, string Code, string Name, string? Mobile, string? Email, string? Address);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency); public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -15,11 +20,12 @@ public sealed record PurchaseOrderDto(
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status, int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt, bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
DateTime CreatedAt, DateTime? UpdatedAt, DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines); PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines, PoVendorDto? Vendor = null);
public sealed record PurchaseOrderSummaryDto( public sealed record PurchaseOrderSummaryDto(
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status, int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals); bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals,
PoVendorDto? Vendor = null);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals // Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
+3 -1
View File
@@ -11,4 +11,6 @@ namespace ERPCore.Dtos.Rbac;
/// entirely rather than merely restricting them by role.</summary> /// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto( public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes, string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled); bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
int? RoleId = null, bool HasRole = false,
IReadOnlyDictionary<string, int>? Permissions = null);
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Rbac;
public sealed record RoleDto( public sealed record RoleDto(
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status, int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt); DateTime CreatedAt, DateTime? UpdatedAt, bool IsAdmin = false);
public sealed class CreateRoleRequest public sealed class CreateRoleRequest
{ {
+7
View File
@@ -84,3 +84,10 @@ public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary> /// <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); public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
/// <summary>Outcome of the one-time copy of each user's role from AuthHex into the ERP's users table.</summary>
public sealed record BackfillRolesResultDto(
int Examined, int Assigned, int AlreadyHadRole, IReadOnlyList<BackfillRolesIssueDto> Issues);
/// <summary>A user the backfill could not give a role, and why — for an administrator to assign by hand.</summary>
public sealed record BackfillRolesIssueDto(int UserId, string Username, string Reason);
+14 -1
View File
@@ -6,7 +6,8 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary> /// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto( public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg, int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt); string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt,
string? Mobile = null, string? Email = null, string? Address = null);
public sealed class CreateVendorRequest public sealed class CreateVendorRequest
{ {
@@ -15,6 +16,12 @@ public sealed class CreateVendorRequest
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; } [StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/> /// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary> /// when branching is enabled; ignored (defaulted) otherwise.</summary>
public int BranchId { get; set; } public int BranchId { get; set; }
@@ -27,6 +34,12 @@ public sealed class UpdateVendorRequest
[StringLength(50)] public string? Terms { get; set; } [StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; } [StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR"; [Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
public int BranchId { get; set; } public int BranchId { get; set; }
} }
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- UserManager -------------------------------------------------- // ---- UserManager --------------------------------------------------
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct) public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct); {
// Until AuthHex stops requiring a role (its roles are being retired), give it a fixed placeholder so
// registration keeps working. The ERP never reads this value; the person's real role is users.RoleId.
request.RoleId ??= Guid.TryParse(_config["AuthHex:LegacyRoleId"], out var legacy) ? legacy : null;
return CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
}
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct) public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct); => CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
@@ -149,23 +154,6 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct); => CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- RoleManager --------------------------------------------------
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
// ---- Transport -------------------------------------------------------- // ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,12 +57,4 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct); Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct); Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
} }
@@ -22,4 +22,24 @@ public static class AuthHexClaims
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md). /// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
/// </summary> /// </summary>
public const string BranchId = "erp:BranchId"; public const string BranchId = "erp:BranchId";
/// <summary>
/// Local claim: "false" when the deployment has branching switched off (Settings → Branch Configuration), absent or
/// "true" otherwise. Lets <see cref="ICurrentBranchContext"/> tell "a branch-less user in a single-branch
/// deployment" (sees everything) from "a branch-less user in a multi-branch deployment" (sees no branch's data).
/// </summary>
public const string BranchingEnabled = "erp:BranchingEnabled";
// ---- Local claims: the ERP's own authorization identity ---------------------------------------------------
// Injected by ShadowUserClaimsTransformation from the ERP's users/roles tables on every request. They are NEVER
// read from the token: the role belongs to the ERP, AuthHex only says who the person is. The AuthHex RoleId /
// RoleCode claims above are used only by the temporary legacy fallback.
/// <summary>The effective role's id (the user's role, or the default role). Absent when the person has no usable role.</summary>
public const string ErpRoleId = "erp:RoleId";
public const string ErpRoleCode = "erp:RoleCode";
/// <summary>"true" when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>).</summary>
public const string ErpIsAdmin = "erp:IsAdmin";
/// <summary>Present when the ERP user is inactive; the <c>ErpAccess</c> policy then refuses the request (403).</summary>
public const string ErpBlocked = "erp:Blocked";
} }
+18 -5
View File
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
/// <summary> /// <summary>
/// Resolves the audit actor from the current HTTP request's principal /// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/> /// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
/// when the request is unauthenticated (e.g. health checks, seeding). /// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
/// unauthenticated (e.g. health checks, seeding).
/// </summary> /// </summary>
public sealed class CurrentUser : ICurrentUser public sealed class CurrentUser : ICurrentUser
{ {
@@ -32,9 +33,18 @@ public sealed class CurrentUser : ICurrentUser
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId; public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode); public int? RoleId
{
get
{
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleId);
return int.TryParse(raw, out var id) ? id : null;
}
}
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase); public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleCode);
public bool IsAdmin => string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpIsAdmin), "true", StringComparison.Ordinal);
public int? BranchId public int? BranchId
{ {
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
return int.TryParse(raw, out var id) ? id : null; return int.TryParse(raw, out var id) ? id : null;
} }
} }
public bool BranchingEnabled =>
!string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchingEnabled), "false", StringComparison.Ordinal);
} }
@@ -9,7 +9,9 @@ namespace ERPCore.Infra.Auth;
public interface ICurrentBranchContext public interface ICurrentBranchContext
{ {
/// <summary>The branch to scope reads to. Null means "don't filter" — used for /// <summary>The branch to scope reads to. Null means "don't filter" — used for
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary> /// admins, unauthenticated/system contexts, and (only when the deployment has branching switched off) people with
/// no branch. In a branching deployment, a signed-in non-admin with no linked employee gets
/// <see cref="CurrentBranchContext.NoBranchId"/>: a branch that matches no data, so they see none (deny by default).</summary>
int? BranchId { get; } int? BranchId { get; }
/// <summary>False disables filtering entirely (e.g. background jobs, migrations, /// <summary>False disables filtering entirely (e.g. background jobs, migrations,
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
} }
/// <summary> /// <summary>
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with /// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/> /// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
/// already applies for write-path checks. /// <see cref="NoBranchId"/> — they used to fall through unfiltered and could read every branch's records, while the
/// write path (<see cref="Services.Interfaces.IBranchAccessService"/>) already refused them.
/// </summary> /// </summary>
public sealed class CurrentBranchContext : ICurrentBranchContext public sealed class CurrentBranchContext : ICurrentBranchContext
{ {
/// <summary>The "branch" of a person who has none in a branching deployment. No row has branch 0, so filters on it match nothing.</summary>
public const int NoBranchId = 0;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser; public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId; public int? BranchId
{
get
{
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return null;
if (_currentUser.BranchId is { } own) return own;
return _currentUser.BranchingEnabled ? NoBranchId : null;
}
}
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null; public bool IsActive => BranchId is not null;
} }
+20 -7
View File
@@ -1,9 +1,9 @@
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
/// <summary> /// <summary>
/// Abstraction over the authenticated principal, used purely as the audit actor /// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub` /// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
/// claim — never from the request body (see 00-CORE §4). /// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
/// </summary> /// </summary>
public interface ICurrentUser public interface ICurrentUser
{ {
@@ -20,13 +20,20 @@ public interface ICurrentUser
/// <summary>True when the request carries an authenticated principal.</summary> /// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; } bool IsAuthenticated { get; }
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary> /// <summary>
/// The id of the person's effective ERP role (their own, else the configured default), read from the ERP's users
/// table on each request. Null when they have no usable role (none assigned and no default, or the role is
/// inactive) — they then have no permissions.
/// </summary>
int? RoleId { get; }
/// <summary>The effective role's code, for display and logging. Never use it to make a permission decision.</summary>
string? RoleCode { get; } string? RoleCode { get; }
/// <summary> /// <summary>
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim /// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check; /// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>). /// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary> /// </summary>
bool IsAdmin { get; } bool IsAdmin { get; }
@@ -37,4 +44,10 @@ public interface ICurrentUser
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>). /// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
/// </summary> /// </summary>
int? BranchId { get; } int? BranchId { get; }
/// <summary>
/// Whether the deployment uses branches at all (Settings → Branch Configuration). When off, every document lives on the
/// one default branch and nobody is branch-scoped. Defaults to true — the safe side — for contexts that don't say.
/// </summary>
bool BranchingEnabled => true;
} }
@@ -37,7 +37,6 @@ public static class JwtAuthExtensions
var publicKeyXml = config["Auth:RsaPublicKeyXml"] var publicKeyXml = config["Auth:RsaPublicKeyXml"]
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured."); ?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
var requiredUserType = config["Auth:RequiredUserTypeCode"]; var requiredUserType = config["Auth:RequiredUserTypeCode"];
var requiredRole = config["Auth:RequiredRoleCode"];
// AuthHex publishes no JWKS; the RSA public key is configured statically. // AuthHex publishes no JWKS; the RSA public key is configured statically.
var rsa = RSA.Create(); var rsa = RSA.Create();
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
options.AddPolicy(ErpAccessPolicy, policy => options.AddPolicy(ErpAccessPolicy, policy =>
{ {
policy.RequireAuthenticatedUser(); policy.RequireAuthenticatedUser();
// Door gate: only enforce a UserType/Role when configured (AuthHex is a // Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
// shared IdP). Empty config = require a valid ERP token only. // require a valid ERP token only. (There is no role door gate any more: roles are the ERP's own,
// and are enforced per endpoint by the Nav policies.)
if (!string.IsNullOrWhiteSpace(requiredUserType)) if (!string.IsNullOrWhiteSpace(requiredUserType))
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType); policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
if (!string.IsNullOrWhiteSpace(requiredRole)) // An inactive ERP user is refused outright, even while their AuthHex token is still valid.
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole); policy.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == AuthHexClaims.ErpBlocked));
}); });
}); });
@@ -57,9 +57,9 @@ public sealed class NavRequirement : IAuthorizationRequirement
} }
} }
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this /// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission, /// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
/// and — when the policy names one — the specific action within it too.</summary> /// else needs the role's permission and — when the policy names one — the specific action within it too.</summary>
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement> public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
{ {
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
return; return;
} }
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return; if (!await _navPermissions.HasAccessAsync(_currentUser.RoleId, requirement.NavCode)) return;
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value)) if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleId, requirement.NavCode, requirement.Action.Value))
context.Succeed(requirement); context.Succeed(requirement);
} }
} }
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
/// <summary> /// <summary>
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5). /// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no /// <para>
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow /// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local /// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so /// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged. /// <see cref="Role"/> and branch, then injects:
/// <list type="bullet">
/// <item><c>NameIdentifier</c> — the local int user id (audit actor);</item>
/// <item><see cref="AuthHexClaims.ErpRoleId"/> / <see cref="AuthHexClaims.ErpRoleCode"/> / <see cref="AuthHexClaims.ErpIsAdmin"/> — the effective role;</item>
/// <item><see cref="AuthHexClaims.BranchId"/> — the branch of the linked employee;</item>
/// <item><see cref="AuthHexClaims.ErpBlocked"/> — when the ERP user is inactive (the door policy then answers 403).</item>
/// </list>
/// Because the role is read from the database on every request, changing someone's role or a role's permissions
/// takes effect on their very next request — no re-login, no stale token.
/// </para>
/// <para>
/// <b>Effective role</b> = the user's own role if it is active; otherwise nothing. A user with no role gets one
/// assigned and saved: during the rollout, the role named by a still-present AuthHex <c>RoleCode</c> claim
/// (<c>Auth:LegacyRoleClaimFallback</c>, on by default, to be switched off once every user has a role); else the
/// configured default role (<see cref="AccessConfig.DefaultRoleId"/>). With neither, they have no permissions.
/// </para>
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request. /// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
/// </summary> /// </summary>
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{ {
private readonly ErpDbContext _db; private readonly ErpDbContext _db;
private readonly bool _legacyRoleFallback;
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db; public ShadowUserClaimsTransformation(ErpDbContext db, IConfiguration configuration)
{
_db = db;
// Default ON: the safe choice while some users may still have no local role.
_legacyRoleFallback = !bool.TryParse(configuration["Auth:LegacyRoleClaimFallback"], out var enabled) || enabled;
}
private sealed record UserRow(
int UserId, EntityStatus Status, int? RoleId, string? RoleCode, EntityStatus? RoleStatus, bool RoleIsAdmin, int? BranchId, bool? BranchingEnabled);
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal) public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{ {
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
if (!Guid.TryParse(raw, out var authUserId)) if (!Guid.TryParse(raw, out var authUserId))
return principal; // no mappable identity → CurrentUser falls back to system return principal; // no mappable identity → CurrentUser falls back to system
var nic = principal.FindFirstValue(AuthHexClaims.Nic); var row = await LoadAsync(authUserId)
var localId = await ResolveOrProvisionAsync(authUserId, nic); ?? await ProvisionAsync(authUserId, principal.FindFirstValue(AuthHexClaims.Nic));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString())); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, row.UserId.ToString()));
if (row.BranchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, row.BranchId.Value.ToString()));
if (row.BranchingEnabled == false)
identity.AddClaim(new Claim(AuthHexClaims.BranchingEnabled, "false"));
var branchId = await _db.Employees.AsNoTracking() if (row.Status != EntityStatus.Active)
.Where(e => e.UserId == localId) {
.Select(e => (int?)e.BranchId) identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
.FirstOrDefaultAsync(); return principal;
if (branchId is not null) }
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
// No role yet? Assign one (and save it) so the Users screen shows what they actually have.
if (row.RoleId is null)
row = await AssignMissingRoleAsync(row, principal.FindFirstValue(AuthHexClaims.RoleCode));
// An inactive role grants nothing: the person is treated as having no role until it is reactivated or changed.
if (row.RoleId is not null && row.RoleStatus == EntityStatus.Active)
{
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleId, row.RoleId.Value.ToString()));
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleCode, row.RoleCode ?? string.Empty));
identity.AddClaim(new Claim(AuthHexClaims.ErpIsAdmin, row.RoleIsAdmin ? "true" : "false"));
}
return principal; return principal;
} }
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic) /// <summary>The user with its role and branch, in ONE round trip.</summary>
{ private Task<UserRow?> LoadAsync(Guid authUserId) =>
var existing = await _db.Users.AsNoTracking() _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId) .Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId) .Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existing != 0) return existing;
private Task<UserRow?> LoadAsync(int userId) =>
_db.Users.AsNoTracking()
.Where(u => u.UserId == userId)
.Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync();
private async Task<UserRow> ProvisionAsync(Guid authUserId, string? nic)
{
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim(); var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
var user = new User var user = new User
{ {
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{ {
_db.Users.Add(user); _db.Users.Add(user);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return user.UserId;
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
// Lost a race (unique auth_user_id) — the row now exists; re-read it. // Lost a race (unique auth_user_id) — the row now exists; re-read it below.
_db.Entry(user).State = EntityState.Detached; _db.Entry(user).State = EntityState.Detached;
return await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstAsync();
} }
return await LoadAsync(authUserId) ?? throw new InvalidOperationException("Could not provision the ERP user.");
}
private async Task<UserRow> AssignMissingRoleAsync(UserRow row, string? legacyRoleCode)
{
var role = null as Role;
// 1) Rollout safety net: the role the (old) AuthHex token still names — what this person could do until now.
if (_legacyRoleFallback && !string.IsNullOrWhiteSpace(legacyRoleCode))
{
var code = legacyRoleCode.Trim().ToLower();
role = await _db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Code.ToLower() == code && r.Status == EntityStatus.Active);
}
// 2) The configured default role (least privilege by design; never the admin role).
if (role is null)
{
role = await _db.AccessConfig.AsNoTracking()
.Where(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRole != null && c.DefaultRole.Status == EntityStatus.Active)
.Select(c => c.DefaultRole)
.FirstOrDefaultAsync();
}
if (role is null) return row; // no role and no default → no permissions
try
{
// Only fill a role that is still empty: one an administrator assigned a moment ago is never overwritten.
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.UserId == row.UserId);
if (tracked is not null && tracked.RoleId is null)
{
tracked.RoleId = role.RoleId;
await _db.SaveChangesAsync();
}
else if (tracked?.RoleId is int existing && existing != role.RoleId)
{
return await LoadAsync(row.UserId) ?? row; // somebody assigned a role meanwhile: use theirs
}
}
catch (DbUpdateException)
{
// Not fatal: the request still carries the role for this call; the next request retries the save.
_db.ChangeTracker.Clear();
}
return row with { RoleId = role.RoleId, RoleCode = role.Code, RoleStatus = role.Status, RoleIsAdmin = role.IsAdmin };
} }
} }
@@ -1,34 +1,68 @@
using ERPCore.System.Errors; using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Auth; namespace ERPCore.Infra.Auth;
/// <summary> /// <summary>
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing /// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers /// actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a /// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c> /// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController /// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
/// issues alongside the session cookies. /// issues alongside the session cookies.
/// <para>
/// Every mutating cookie-authenticated endpoint is covered by <see cref="CsrfProtectionFilter"/> (registered globally),
/// so this attribute is only needed on an action that must be checked even when the session cookie is absent
/// (AuthController's token-in-body endpoints).
/// </para>
/// </summary> /// </summary>
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
{ {
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ {
var request = context.HttpContext.Request; Enforce(context.HttpContext.Request);
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
{
await next(); await next();
return;
} }
/// <summary>Throws a 403 <c>CSRF_TOKEN_MISMATCH</c> unless the request is bearer-authenticated or carries a header matching the CSRF cookie.</summary>
public static void Enforce(HttpRequest request)
{
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) return;
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken)) if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403); throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString(); var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal)) if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403); throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
}
}
/// <summary>
/// Registered globally: any state-changing request (POST/PUT/PATCH/DELETE) that is authenticated by the browser's
/// session cookie must pass the double-submit check. Not applied to safe methods, to anonymous endpoints (login,
/// registration — no session to ride on), to actions that already carry <see cref="ValidateCsrfAttribute"/>, or to
/// requests with no session cookie at all (nothing for a forged request to ride on; those fail authentication anyway).
/// </summary>
public sealed class CsrfProtectionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var http = context.HttpContext;
var request = http.Request;
var unsafeMethod = HttpMethods.IsPost(request.Method) || HttpMethods.IsPut(request.Method)
|| HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method);
var endpoint = http.GetEndpoint();
if (unsafeMethod
&& request.Cookies.ContainsKey(JwtAuthExtensions.AccessTokenCookie)
&& endpoint?.Metadata.GetMetadata<IAllowAnonymous>() is null
&& endpoint?.Metadata.GetMetadata<ValidateCsrfAttribute>() is null)
{
ValidateCsrfAttribute.Enforce(request);
}
await next(); await next();
} }
@@ -0,0 +1,31 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>The singleton access-configuration row; the check constraint makes "singleton" a database guarantee.</summary>
public sealed class AccessConfigConfiguration : IEntityTypeConfiguration<AccessConfig>
{
public void Configure(EntityTypeBuilder<AccessConfig> builder)
{
builder.ToTable("access_config", t =>
t.HasCheckConstraint("ck_access_config_singleton", $"`ConfigId` = {AccessConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
// If the default role is deleted the setting clears itself rather than blocking the delete.
builder.HasOne(c => c.DefaultRole)
.WithMany()
.HasForeignKey(c => c.DefaultRoleId)
.OnDelete(DeleteBehavior.SetNull);
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -34,7 +34,7 @@ public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<Appro
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4); builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4); builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500); builder.Property(c => c.Level2RoleIds).HasMaxLength(500);
// Dual approval on by default for both documents (plan §8 recommended defaults). // Dual approval on by default for both documents (plan §8 recommended defaults).
builder.HasData(new ApprovalConfig builder.HasData(new ApprovalConfig
@@ -41,7 +41,11 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 }, new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }, new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }, new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 } new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 },
// Added with the permission-coverage pass (Phase B): these two sidebar sections had no row, so no role could be
// granted them and the sidebar had to bypass the check for everybody.
new NavItem { NavItemId = 15, Code = "hrm", Label = "Human Resources", Href = "/dashboard/hrm", SortOrder = 15 },
new NavItem { NavItemId = 16, Code = "sales-returns", Label = "Sales Returns", Href = "/dashboard/sales/sales-returns", SortOrder = 16 }
); );
} }
} }
@@ -0,0 +1,67 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Posted opening balances. The unique index on Kind is the once-only guard.</summary>
public sealed class OpeningBalanceRunConfiguration : IEntityTypeConfiguration<OpeningBalanceRun>
{
public void Configure(EntityTypeBuilder<OpeningBalanceRun> builder)
{
builder.ToTable("opening_balance_runs");
builder.HasKey(x => x.OpeningBalanceRunId);
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasIndex(x => x.Kind);
// The once-only guard: only kinds that may be posted once set this, so a second attempt hits the unique index.
builder.Property(x => x.OnceKey).HasMaxLength(20);
builder.HasIndex(x => x.OnceKey).IsUnique();
builder.Property(x => x.CutoverDate).IsRequired();
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.TotalAmount).HasPrecision(18, 4);
builder.Property(x => x.Currency).IsRequired().HasMaxLength(3);
builder.Property(x => x.PostedAt).IsRequired();
}
}
public sealed class OpeningBalanceLineConfiguration : IEntityTypeConfiguration<OpeningBalanceLine>
{
public void Configure(EntityTypeBuilder<OpeningBalanceLine> builder)
{
builder.ToTable("opening_balance_lines");
builder.HasKey(x => x.OpeningBalanceLineId);
builder.Property(x => x.AccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.AccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Amount).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.Lines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class OpeningStockLineConfiguration : IEntityTypeConfiguration<OpeningStockLine>
{
public void Configure(EntityTypeBuilder<OpeningStockLine> builder)
{
builder.ToTable("opening_stock_lines");
builder.HasKey(x => x.OpeningStockLineId);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitCost).HasPrecision(18, 4);
builder.Property(x => x.Value).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.StockLines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ItemId, x.WarehouseId });
}
}
@@ -90,7 +90,20 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 }, new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 }, new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 }, new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 } new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 },
new Permission { PermissionId = 60, Code = "NAV:procurement.po-approvals", SubNavItemId = 50 },
new Permission { PermissionId = 61, Code = "NAV:accounts.opening-balances", SubNavItemId = 51 },
new Permission { PermissionId = 62, Code = "NAV:hrm", NavItemId = 15 },
new Permission { PermissionId = 63, Code = "NAV:sales-returns", NavItemId = 16 },
new Permission { PermissionId = 64, Code = "NAV:hrm.employees", SubNavItemId = 52 },
new Permission { PermissionId = 65, Code = "NAV:hrm.attendance", SubNavItemId = 53 },
new Permission { PermissionId = 66, Code = "NAV:hrm.leave", SubNavItemId = 54 },
new Permission { PermissionId = 67, Code = "NAV:hrm.payroll", SubNavItemId = 55 },
new Permission { PermissionId = 68, Code = "NAV:hrm.reports", SubNavItemId = 56 },
new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 },
new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 },
// SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles.
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 }
); );
} }
} }
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton procurement-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors GrnConfig).
/// </summary>
public sealed class ProcurementConfigConfiguration : IEntityTypeConfiguration<ProcurementConfig>
{
public void Configure(EntityTypeBuilder<ProcurementConfig> builder)
{
builder.ToTable("procurement_config", t =>
t.HasCheckConstraint("ck_procurement_config_singleton", $"`ConfigId` = {ProcurementConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.VendorContactMode)
.HasConversion<string>()
.HasMaxLength(10)
.IsRequired()
.HasDefaultValue(VendorContactMode.None);
builder.Property(c => c.VendorAddressEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.PoTaxEnabled).IsRequired().HasDefaultValue(false);
// Default on, and the sentinel is the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.VendorPaymentTermsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.VendorTaxRegEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -52,6 +52,7 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
builder.Property(l => l.Qty).HasPrecision(18, 4); builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4); builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4); builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.TaxAmount).HasPrecision(18, 4).HasDefaultValue(0m);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4); builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.HasOne(l => l.PurchaseOrder) builder.HasOne(l => l.PurchaseOrder)
@@ -12,7 +12,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.ToTable("roles"); builder.ToTable("roles");
builder.HasKey(r => r.RoleId); builder.HasKey(r => r.RoleId);
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired(); builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id");
builder.HasIndex(r => r.AuthRoleId).IsUnique(); builder.HasIndex(r => r.AuthRoleId).IsUnique();
builder.Property(r => r.Code).IsRequired().HasMaxLength(50); builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
@@ -20,6 +20,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.Property(r => r.Name).IsRequired().HasMaxLength(200); builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false); builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
builder.Property(r => r.IsAdmin).IsRequired().HasDefaultValue(false);
builder.Property(r => r.Status) builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
@@ -27,6 +27,13 @@ public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesCon
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true); builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false); builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true); builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerPhoneEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerEmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerAddressEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCityEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCountryEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCreditLimitEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCreditDaysEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse) builder.HasOne(c => c.DefaultWarehouse)
.WithMany() .WithMany()
@@ -55,6 +55,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 }, 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 = 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 }, new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 50, NavItemId = 4, Code = "procurement.po-approvals", Label = "PO Approval Queue", Href = "/dashboard/procurement/purchase-orders/approvals", SortOrder = 6 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }, new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 }, new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 }, new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
@@ -73,6 +74,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 }, new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 }, new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 }, new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
new SubNavItem { SubNavItemId = 51, NavItemId = 12, Code = "accounts.opening-balances", Label = "Opening Balances", Href = "/dashboard/accounts/opening-balances", SortOrder = 7 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx). // Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was // ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the // reused by mistake in migration 20260820100345, which upserts row 24 to become the
@@ -91,7 +93,17 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 }, 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 = 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 = 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 } new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 },
// Human Resources (NavItemId 15) children — mirror the sidebar order (AppSidebar.tsx). hrm.payroll also guards
// salary structures, loans, bank details, payslips and the pay reports.
new SubNavItem { SubNavItemId = 52, NavItemId = 15, Code = "hrm.employees", Label = "Employees", Href = "/dashboard/hrm/employees", SortOrder = 1 },
new SubNavItem { SubNavItemId = 53, NavItemId = 15, Code = "hrm.attendance", Label = "Attendance", Href = "/dashboard/hrm/attendance", SortOrder = 2 },
new SubNavItem { SubNavItemId = 54, NavItemId = 15, Code = "hrm.leave", Label = "Leave", Href = "/dashboard/hrm/leave", SortOrder = 3 },
new SubNavItem { SubNavItemId = 55, NavItemId = 15, Code = "hrm.payroll", Label = "Payroll", Href = "/dashboard/hrm/payroll", SortOrder = 4 },
new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 },
new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 },
// Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large.
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 }
); );
} }
} }
@@ -18,6 +18,9 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.Name).IsRequired().HasMaxLength(200); builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50); builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50); builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Mobile).HasMaxLength(20);
builder.Property(v => v.Email).HasMaxLength(200);
builder.Property(v => v.Address).HasMaxLength(500);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR"); builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status) builder.Property(v => v.Status)
@@ -61,12 +61,67 @@ public static class DataSeeder
dirty |= await SeedBranchConfigAsync(db, ct); dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct); dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct); dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct); dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct); if (dirty) await db.SaveChangesAsync(ct);
// After the rest: needs the permission rows (HasData) and saves in steps, because the access config
// points at roles that must have real generated ids first.
await SeedAccessAsync(db, ct);
} }
/// <summary>
/// The roles every deployment must have, and the access configuration (idempotent):
/// <list type="bullet">
/// <item>an <b>Administrator</b> role flagged <see cref="Role.IsAdmin"/> — an existing role whose code is "admin" is
/// flagged rather than duplicated; a fresh database gets one with every permission, so it is never lock-out empty;</item>
/// <item>a <b>No access</b> role with no permissions at all — the default for new sign-ins, so a mistake can never grant access;</item>
/// <item>the singleton access config, pointing its default role at "No access" the first time only (an admin's later choice is never overwritten).</item>
/// </list>
/// </summary>
private static async Task SeedAccessAsync(ErpDbContext db, CancellationToken ct)
{
var now = DateTime.UtcNow;
var admin = await db.Roles.FirstOrDefaultAsync(r => r.IsAdmin, ct)
?? await db.Roles.FirstOrDefaultAsync(r => r.Code.ToLower() == "admin", ct);
if (admin is null)
{
admin = new Role { Code = "Admin", Name = "Administrator", IsSystemRole = true, IsAdmin = true, Status = EntityStatus.Active, CreatedAt = now };
db.Roles.Add(admin);
await db.SaveChangesAsync(ct);
foreach (var permissionId in await db.Permissions.Select(p => p.PermissionId).ToListAsync(ct))
db.RolePermissions.Add(new RolePermission { RoleId = admin.RoleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
await db.SaveChangesAsync(ct);
}
else if (!admin.IsAdmin)
{
admin.IsAdmin = true;
admin.UpdatedAt = now;
await db.SaveChangesAsync(ct);
}
var noAccess = await db.Roles.FirstOrDefaultAsync(r => r.Code == DefaultNoAccessRoleCode, ct);
if (noAccess is null)
{
noAccess = new Role { Code = DefaultNoAccessRoleCode, Name = "No access", IsSystemRole = true, Status = EntityStatus.Active, CreatedAt = now };
db.Roles.Add(noAccess);
await db.SaveChangesAsync(ct);
}
if (!await db.AccessConfig.AnyAsync(c => c.ConfigId == AccessConfig.SingletonId, ct))
{
db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = noAccess.RoleId });
await db.SaveChangesAsync(ct);
}
}
/// <summary>Code of the seeded least-privilege role new sign-ins start with.</summary>
public const string DefaultNoAccessRoleCode = "NoAccess";
/// <summary> /// <summary>
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent. /// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
/// </summary> /// </summary>
@@ -201,6 +256,16 @@ public static class DataSeeder
return true; return true;
} }
/// <summary>Seeds the procurement configuration singleton — vendor contact/address and PO tax all off,
/// until an admin opts in from Settings → Configuration → Procurement Configuration.</summary>
private static async Task<bool> SeedProcurementConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.ProcurementConfig.AnyAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)) return false;
db.ProcurementConfig.Add(new ProcurementConfig { ConfigId = ProcurementConfig.SingletonId });
return true;
}
/// <summary> /// <summary>
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings /// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
/// values once so an existing deployment keeps the mapping it has been running with. /// values once so an existing deployment keeps the mapping it has been running with.
@@ -48,6 +48,9 @@ public class ErpDbContext : DbContext
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>(); public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>(); public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>(); public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
public DbSet<ProcurementConfig> ProcurementConfig => Set<ProcurementConfig>();
/// <summary>Singleton row: the default role for people who have none.</summary>
public DbSet<AccessConfig> AccessConfig => Set<AccessConfig>();
/// <summary>Singleton row mapping each accounting role to a GL account code.</summary> /// <summary>Singleton row mapping each accounting role to a GL account code.</summary>
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>(); public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary> /// <summary>Singleton row for notification delivery preferences.</summary>
@@ -98,6 +101,9 @@ public class ErpDbContext : DbContext
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) --- // --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>(); public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
public DbSet<OpeningBalanceRun> OpeningBalanceRuns => Set<OpeningBalanceRun>();
public DbSet<OpeningBalanceLine> OpeningBalanceLines => Set<OpeningBalanceLine>();
public DbSet<OpeningStockLine> OpeningStockLines => Set<OpeningStockLine>();
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) --- // --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>(); public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProcurementConfigAndVendorContact : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "vendors",
type: "varchar(500)",
maxLength: 500,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<string>(
name: "Email",
table: "vendors",
type: "varchar(200)",
maxLength: 200,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<string>(
name: "Mobile",
table: "vendors",
type: "varchar(20)",
maxLength: 20,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "procurement_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
VendorContactMode = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, defaultValue: "None", collation: "utf8mb4_unicode_ci"),
VendorAddressEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
PoTaxEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_procurement_config", x => x.ConfigId);
table.CheckConstraint("ck_procurement_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_procurement_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_procurement_config_UpdatedBy",
table: "procurement_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "procurement_config");
migrationBuilder.DropColumn(
name: "Address",
table: "vendors");
migrationBuilder.DropColumn(
name: "Email",
table: "vendors");
migrationBuilder.DropColumn(
name: "Mobile",
table: "vendors");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPoLineTaxAmount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "TaxAmount",
table: "po_lines",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
// Totals now sum the stored amount instead of recomputing qty x price x rate, so give any
// line that already carries a rate its amount (same rounding: half away from zero, 2 dp).
migrationBuilder.Sql("UPDATE po_lines SET TaxAmount = ROUND(Qty * UnitPrice * Tax, 2) WHERE Tax <> 0;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TaxAmount",
table: "po_lines");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPoApprovalsNav : 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[] { 50, "procurement.po-approvals", "/dashboard/procurement/purchase-orders/approvals", null, "PO Approval Queue", 4, 6 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 60, "NAV:procurement.po-approvals", null, 50 });
// Grant the new queue entry. Admin gets everything (the server only lets an Admin approve
// anyway); any other role that can already open Purchase Orders (permission 30) gets View.
// Idempotent, like the grants in AddDualApproval.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 60, CASE WHEN LOWER(r.`Code`) = 'admin' THEN 31 ELSE 1 END
FROM `roles` r
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` = 30);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 60;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 60);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 50);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddOpeningBalances : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "opening_balance_runs",
columns: table => new
{
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Kind = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
OnceKey = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CutoverDate = table.Column<DateOnly>(type: "date", nullable: false),
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Currency = table.Column<string>(type: "varchar(3)", maxLength: 3, nullable: false, collation: "utf8mb4_unicode_ci"),
PostedBy = table.Column<int>(type: "int", nullable: false),
PostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_balance_runs", x => x.OpeningBalanceRunId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "opening_balance_lines",
columns: table => new
{
OpeningBalanceLineId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false),
AccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
AccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_balance_lines", x => x.OpeningBalanceLineId);
table.ForeignKey(
name: "FK_opening_balance_lines_opening_balance_runs_OpeningBalanceRun~",
column: x => x.OpeningBalanceRunId,
principalTable: "opening_balance_runs",
principalColumn: "OpeningBalanceRunId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "opening_stock_lines",
columns: table => new
{
OpeningStockLineId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
WarehouseId = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Value = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_stock_lines", x => x.OpeningStockLineId);
table.ForeignKey(
name: "FK_opening_stock_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_opening_stock_lines_opening_balance_runs_OpeningBalanceRunId",
column: x => x.OpeningBalanceRunId,
principalTable: "opening_balance_runs",
principalColumn: "OpeningBalanceRunId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_opening_stock_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 51, "accounts.opening-balances", "/dashboard/accounts/opening-balances", null, "Opening Balances", 12, 7 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 61, "NAV:accounts.opening-balances", null, 51 });
// Admin only: posting opening balances is a one-time ledger entry. Other roles can be granted it
// under Settings → Roles. Idempotent, like the other nav grants.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 61, 31 FROM `roles` r WHERE LOWER(r.`Code`) = 'admin';
""");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_lines_OpeningBalanceRunId",
table: "opening_balance_lines",
column: "OpeningBalanceRunId");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_runs_Kind",
table: "opening_balance_runs",
column: "Kind");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_runs_OnceKey",
table: "opening_balance_runs",
column: "OnceKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_ItemId_WarehouseId",
table: "opening_stock_lines",
columns: new[] { "ItemId", "WarehouseId" });
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_OpeningBalanceRunId",
table: "opening_stock_lines",
column: "OpeningBalanceRunId");
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_WarehouseId",
table: "opening_stock_lines",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 61;");
migrationBuilder.DropTable(
name: "opening_balance_lines");
migrationBuilder.DropTable(
name: "opening_stock_lines");
migrationBuilder.DropTable(
name: "opening_balance_runs");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 61);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 51);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddVendorFieldSwitches : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "VendorPaymentTermsEnabled",
table: "procurement_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "VendorTaxRegEnabled",
table: "procurement_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "VendorPaymentTermsEnabled",
table: "procurement_config");
migrationBuilder.DropColumn(
name: "VendorTaxRegEnabled",
table: "procurement_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class RolesOwnedByErp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Level2RoleCodes",
table: "approval_config",
newName: "Level2RoleIds");
migrationBuilder.AlterColumn<Guid>(
name: "auth_role_id",
table: "roles",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsAdmin",
table: "roles",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "access_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
DefaultRoleId = table.Column<int>(type: "int", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_access_config", x => x.ConfigId);
table.CheckConstraint("ck_access_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_access_config_roles_DefaultRoleId",
column: x => x.DefaultRoleId,
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_access_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
// ---- Data: the existing administrator role becomes THE administrator by flag, not by name.
migrationBuilder.Sql("UPDATE `roles` SET `IsAdmin` = 1 WHERE LOWER(`Code`) = 'admin';");
// ---- Data: "who may give the second approval" was a list of role CODES; it is now a list of role IDS
// (a rename can no longer break it). The renamed column still holds the codes — convert them in place.
// A code that matches no role simply drops out.
migrationBuilder.Sql("""
UPDATE `approval_config` c
SET c.`Level2RoleIds` = (
SELECT GROUP_CONCAT(r.`RoleId` ORDER BY r.`RoleId` SEPARATOR ',')
FROM `roles` r
WHERE FIND_IN_SET(LOWER(r.`Code`), LOWER(REPLACE(c.`Level2RoleIds`, ' ', ''))) > 0)
WHERE c.`Level2RoleIds` IS NOT NULL AND c.`Level2RoleIds` <> '';
""");
migrationBuilder.CreateIndex(
name: "IX_access_config_DefaultRoleId",
table: "access_config",
column: "DefaultRoleId");
migrationBuilder.CreateIndex(
name: "IX_access_config_UpdatedBy",
table: "access_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Turn the role ids back into codes before the column goes back to its old meaning.
migrationBuilder.Sql("""
UPDATE `approval_config` c
SET c.`Level2RoleIds` = (
SELECT GROUP_CONCAT(r.`Code` SEPARATOR ',')
FROM `roles` r
WHERE FIND_IN_SET(r.`RoleId`, REPLACE(c.`Level2RoleIds`, ' ', '')) > 0)
WHERE c.`Level2RoleIds` IS NOT NULL AND c.`Level2RoleIds` <> '';
""");
migrationBuilder.DropTable(
name: "access_config");
migrationBuilder.DropColumn(
name: "IsAdmin",
table: "roles");
migrationBuilder.RenameColumn(
name: "Level2RoleIds",
table: "approval_config",
newName: "Level2RoleCodes");
migrationBuilder.AlterColumn<Guid>(
name: "auth_role_id",
table: "roles",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class PermissionCoverage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[,]
{
{ 15, "hrm", "/dashboard/hrm", null, "Human Resources", 15 },
{ 16, "sales-returns", "/dashboard/sales/sales-returns", null, "Sales Returns", 16 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 71, "NAV:settings.notification-config", null, 24 });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 58, "settings.audit-logs", "/dashboard/settings/audit-logs", null, "Audit Log", 9, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 62, "NAV:hrm", 15, null },
{ 63, "NAV:sales-returns", 16, null },
{ 70, "NAV:settings.audit-logs", null, 58 }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 52, "hrm.employees", "/dashboard/hrm/employees", null, "Employees", 15, 1 },
{ 53, "hrm.attendance", "/dashboard/hrm/attendance", null, "Attendance", 15, 2 },
{ 54, "hrm.leave", "/dashboard/hrm/leave", null, "Leave", 15, 3 },
{ 55, "hrm.payroll", "/dashboard/hrm/payroll", null, "Payroll", 15, 4 },
{ 56, "hrm.reports", "/dashboard/hrm/reports", null, "Reports", 15, 5 },
{ 57, "hrm.settings", "/dashboard/hrm/settings", null, "HR Settings", 15, 6 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 64, "NAV:hrm.employees", null, 52 },
{ 65, "NAV:hrm.attendance", null, 53 },
{ 66, "NAV:hrm.leave", null, 54 },
{ 67, "NAV:hrm.payroll", null, 55 },
{ 68, "NAV:hrm.reports", null, 56 },
{ 69, "NAV:hrm.settings", null, 57 }
});
// Grants. Idempotent (INSERT IGNORE on the (RoleId, PermissionId) key), and keyed on the role's IsAdmin flag,
// never on its code or name. Everything below is least-privilege: nobody who is not an administrator gets
// Human Resources, the audit log or notification config until an administrator grants it under Settings → Roles.
// (HRM used to be open to every signed-in user; this closes it.)
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, p.`PermissionId`, 31
FROM `roles` r
JOIN `permissions` p ON p.`PermissionId` BETWEEN 62 AND 71
WHERE r.`IsAdmin` = 1;
""");
// Sales Returns used to ride on the Sales permission; it is its own sidebar entry now, so whoever could reach
// Sales (the module or any of its pages) keeps reaching Returns, with the same actions they hold on Sales.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT rp.`RoleId`, 63, BIT_OR(rp.`Actions`)
FROM `role_permissions` rp
JOIN `permissions` p ON p.`PermissionId` = rp.`PermissionId`
WHERE p.`Code` = 'NAV:sales' OR p.`Code` LIKE 'NAV:sales.%'
GROUP BY rp.`RoleId`;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` BETWEEN 62 AND 71;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 62);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 63);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 64);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 65);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 66);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 67);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 68);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 69);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 70);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 71);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 16);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 52);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 53);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 54);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 55);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 56);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 57);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 58);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 15);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCustomerFieldSwitches : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CustomerAddressEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCityEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCountryEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCreditDaysEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCreditLimitEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerEmailEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerPhoneEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomerAddressEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCityEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCountryEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCreditDaysEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCreditLimitEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerEmailEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerPhoneEnabled",
table: "sales_config");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using ERPCore.Infra.Persistence; using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -23,12 +23,42 @@ namespace ERPCore.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.AccessConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<int?>("DefaultRoleId")
.HasColumnType("int");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ConfigId");
b.HasIndex("DefaultRoleId");
b.HasIndex("UpdatedBy");
b.ToTable("access_config", null, t =>
{
t.HasCheckConstraint("ck_access_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b =>
{ {
b.Property<int>("ApprovalConfigId") b.Property<int>("ApprovalConfigId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("Level2RoleCodes") b.Property<string>("Level2RoleIds")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("varchar(500)"); .HasColumnType("varchar(500)");
@@ -3382,6 +3412,24 @@ namespace ERPCore.Migrations
Label = "Production", Label = "Production",
SortOrder = 14, SortOrder = 14,
Status = "Active" Status = "Active"
},
new
{
NavItemId = 15,
Code = "hrm",
Href = "/dashboard/hrm",
Label = "Human Resources",
SortOrder = 15,
Status = "Active"
},
new
{
NavItemId = 16,
Code = "sales-returns",
Href = "/dashboard/sales/sales-returns",
Label = "Sales Returns",
SortOrder = 16,
Status = "Active"
}); });
}); });
@@ -3556,6 +3604,127 @@ namespace ERPCore.Migrations
b.ToTable("number_sequences", (string)null); b.ToTable("number_sequences", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceLine", b =>
{
b.Property<int>("OpeningBalanceLineId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningBalanceLineId"));
b.Property<string>("AccountCode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("AccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("OpeningBalanceRunId")
.HasColumnType("int");
b.HasKey("OpeningBalanceLineId");
b.HasIndex("OpeningBalanceRunId");
b.ToTable("opening_balance_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceRun", b =>
{
b.Property<int>("OpeningBalanceRunId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningBalanceRunId"));
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("varchar(3)");
b.Property<DateOnly>("CutoverDate")
.HasColumnType("date");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("OnceKey")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("PostedAt")
.HasColumnType("datetime(6)");
b.Property<int>("PostedBy")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("OpeningBalanceRunId");
b.HasIndex("Kind");
b.HasIndex("OnceKey")
.IsUnique();
b.ToTable("opening_balance_runs", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningStockLine", b =>
{
b.Property<int>("OpeningStockLineId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningStockLineId"));
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<int>("OpeningBalanceRunId")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Value")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("int");
b.HasKey("OpeningStockLineId");
b.HasIndex("OpeningBalanceRunId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId");
b.ToTable("opening_stock_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.Property<int>("PayrollLineId") b.Property<int>("PayrollLineId")
@@ -4226,6 +4395,78 @@ namespace ERPCore.Migrations
PermissionId = 59, PermissionId = 59,
Code = "NAV:settings.approvals", Code = "NAV:settings.approvals",
SubNavItemId = 49 SubNavItemId = 49
},
new
{
PermissionId = 60,
Code = "NAV:procurement.po-approvals",
SubNavItemId = 50
},
new
{
PermissionId = 61,
Code = "NAV:accounts.opening-balances",
SubNavItemId = 51
},
new
{
PermissionId = 62,
Code = "NAV:hrm",
NavItemId = 15
},
new
{
PermissionId = 63,
Code = "NAV:sales-returns",
NavItemId = 16
},
new
{
PermissionId = 64,
Code = "NAV:hrm.employees",
SubNavItemId = 52
},
new
{
PermissionId = 65,
Code = "NAV:hrm.attendance",
SubNavItemId = 53
},
new
{
PermissionId = 66,
Code = "NAV:hrm.leave",
SubNavItemId = 54
},
new
{
PermissionId = 67,
Code = "NAV:hrm.payroll",
SubNavItemId = 55
},
new
{
PermissionId = 68,
Code = "NAV:hrm.reports",
SubNavItemId = 56
},
new
{
PermissionId = 69,
Code = "NAV:hrm.settings",
SubNavItemId = 57
},
new
{
PermissionId = 70,
Code = "NAV:settings.audit-logs",
SubNavItemId = 58
},
new
{
PermissionId = 71,
Code = "NAV:settings.notification-config",
SubNavItemId = 24
}); });
}); });
@@ -4255,6 +4496,12 @@ namespace ERPCore.Migrations
.HasPrecision(9, 4) .HasPrecision(9, 4)
.HasColumnType("decimal(9,4)"); .HasColumnType("decimal(9,4)");
b.Property<decimal>("TaxAmount")
.ValueGeneratedOnAdd()
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)")
.HasDefaultValue(0m);
b.Property<decimal>("UnitPrice") b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -4398,6 +4645,58 @@ namespace ERPCore.Migrations
b.ToTable("price_adjustment_lines", (string)null); b.ToTable("price_adjustment_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.ProcurementConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<bool>("PoTaxEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.Property<bool>("VendorAddressEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<string>("VendorContactMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("varchar(10)")
.HasDefaultValue("None");
b.Property<bool>("VendorPaymentTermsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("VendorTaxRegEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("procurement_config", null, t =>
{
t.HasCheckConstraint("ck_procurement_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{ {
b.Property<int>("ConfigId") b.Property<int>("ConfigId")
@@ -5121,7 +5420,7 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RoleId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RoleId"));
b.Property<Guid>("AuthRoleId") b.Property<Guid?>("AuthRoleId")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("auth_role_id"); .HasColumnName("auth_role_id");
@@ -5133,6 +5432,11 @@ namespace ERPCore.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<bool>("IsSystemRole") b.Property<bool>("IsSystemRole")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@@ -5530,6 +5834,41 @@ namespace ERPCore.Migrations
b.Property<int?>("CashCustomerId") b.Property<int?>("CashCustomerId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("CustomerAddressEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCityEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCountryEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCreditDaysEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCreditLimitEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerEmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerPhoneEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int?>("DefaultWarehouseId") b.Property<int?>("DefaultWarehouseId")
.HasColumnType("int"); .HasColumnType("int");
@@ -7403,6 +7742,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 50,
Code = "procurement.po-approvals",
Href = "/dashboard/procurement/purchase-orders/approvals",
Label = "PO Approval Queue",
NavItemId = 4,
SortOrder = 6,
Status = "Active"
},
new
{ {
SubNavItemId = 20, SubNavItemId = 20,
Code = "procurement.purchase-returns", Code = "procurement.purchase-returns",
@@ -7553,6 +7902,16 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 51,
Code = "accounts.opening-balances",
Href = "/dashboard/accounts/opening-balances",
Label = "Opening Balances",
NavItemId = 12,
SortOrder = 7,
Status = "Active"
},
new
{ {
SubNavItemId = 27, SubNavItemId = 27,
Code = "production.templates", Code = "production.templates",
@@ -7661,6 +8020,76 @@ namespace ERPCore.Migrations
NavItemId = 6, NavItemId = 6,
SortOrder = 8, SortOrder = 8,
Status = "Active" Status = "Active"
},
new
{
SubNavItemId = 52,
Code = "hrm.employees",
Href = "/dashboard/hrm/employees",
Label = "Employees",
NavItemId = 15,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 53,
Code = "hrm.attendance",
Href = "/dashboard/hrm/attendance",
Label = "Attendance",
NavItemId = 15,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 54,
Code = "hrm.leave",
Href = "/dashboard/hrm/leave",
Label = "Leave",
NavItemId = 15,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 55,
Code = "hrm.payroll",
Href = "/dashboard/hrm/payroll",
Label = "Payroll",
NavItemId = 15,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 56,
Code = "hrm.reports",
Href = "/dashboard/hrm/reports",
Label = "Reports",
NavItemId = 15,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 57,
Code = "hrm.settings",
Href = "/dashboard/hrm/settings",
Label = "HR Settings",
NavItemId = 15,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 58,
Code = "settings.audit-logs",
Href = "/dashboard/settings/audit-logs",
Label = "Audit Log",
NavItemId = 9,
SortOrder = 5,
Status = "Active"
}); });
}); });
@@ -8005,6 +8434,10 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("VendorId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("BranchId") b.Property<int>("BranchId")
.HasColumnType("int"); .HasColumnType("int");
@@ -8023,6 +8456,14 @@ namespace ERPCore.Migrations
.HasColumnType("varchar(3)") .HasColumnType("varchar(3)")
.HasDefaultValue("LKR"); .HasDefaultValue("LKR");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Mobile")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -8334,6 +8775,23 @@ namespace ERPCore.Migrations
b.ToTable("hr_work_shifts", (string)null); b.ToTable("hr_work_shifts", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.AccessConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Role", "DefaultRole")
.WithMany()
.HasForeignKey("DefaultRoleId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("DefaultRole");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch")
@@ -9127,6 +9585,44 @@ namespace ERPCore.Migrations
b.Navigation("Branch"); b.Navigation("Branch");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.OpeningBalanceRun", "Run")
.WithMany("Lines")
.HasForeignKey("OpeningBalanceRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Run");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningStockLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.OpeningBalanceRun", "Run")
.WithMany("StockLines")
.HasForeignKey("OpeningBalanceRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Run");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
@@ -9259,6 +9755,16 @@ namespace ERPCore.Migrations
b.Navigation("PriceAdjustment"); b.Navigation("PriceAdjustment");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.ProcurementConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -10693,6 +11199,13 @@ namespace ERPCore.Migrations
b.Navigation("Modules"); b.Navigation("Modules");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceRun", b =>
{
b.Navigation("Lines");
b.Navigation("StockLines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.Navigation("Components"); b.Navigation("Components");
+10 -1
View File
@@ -28,7 +28,12 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day)); .WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase). // Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers(o => o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>()) builder.Services.AddControllers(o =>
{
o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>();
// CSRF double-submit check on every mutating, cookie-authenticated endpoint (docs/02-SECURITY.md §B.2).
o.Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>();
})
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using // EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
@@ -119,6 +124,10 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>(); builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>(); builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>(); builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>(); builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>(); builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>(); builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
@@ -0,0 +1,79 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Access configuration over the singleton row seeded by DataSeeder. The default role is what every first sign-in
/// (and every role-less person) receives, so it is held to a hard rule: it must exist, be active, and NEVER be an
/// administrator role — otherwise anyone who can register would become an administrator.
/// </summary>
public sealed class AccessConfigService : IAccessConfigService
{
private readonly IRepository<AccessConfig> _config;
private readonly IRepository<Role> _roles;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public AccessConfigService(IRepository<AccessConfig> config, IRepository<Role> roles, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_roles = roles;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<AccessConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultRole)
.FirstOrDefaultAsync(c => c.ConfigId == AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
return new ETagged<AccessConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<AccessConfigDto>> UpdateAsync(
UpdateAccessConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
if (request.DefaultRoleId is int roleId)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
if (role.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Role '{role.Name}' is inactive and cannot be the default.", 422);
if (role.IsAdmin)
throw new DomainException(ErrorCodes.Validation, "The administrator role cannot be the default role — everyone who signs in would become an administrator.", 422);
}
config.DefaultRoleId = request.DefaultRoleId;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
private static AccessConfigDto Map(AccessConfig c) => new(c.DefaultRoleId, c.DefaultRole?.Name, c.UpdatedAt, c.UpdatedBy);
}
@@ -89,5 +89,5 @@ public sealed class ApprovalInboxService
} }
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct) private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct); => _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleId, navCode, RolePermissionAction.Approve, ct);
} }

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