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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public class DualApprovalTests
|
||||
[Fact]
|
||||
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;
|
||||
h.User.Be(ApproverA);
|
||||
await h.Stock.ApproveAsync(id, null);
|
||||
@@ -250,7 +250,7 @@ public class DualApprovalTests
|
||||
public required AdjustmentService Stock { 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(
|
||||
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
||||
@@ -283,7 +283,7 @@ public class DualApprovalTests
|
||||
db.ApprovalConfig.Add(new ApprovalConfig
|
||||
{
|
||||
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
|
||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -292,7 +292,7 @@ public class DualApprovalTests
|
||||
var notifications = new RecordingNotifications();
|
||||
var approvals = new ApprovalService(
|
||||
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 mutator = new RecordingMutator();
|
||||
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 Repository<ProductConfig>(db), user));
|
||||
var prices = new PriceAdjustmentService(
|
||||
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
|
||||
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
|
||||
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
|
||||
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 };
|
||||
}
|
||||
@@ -323,7 +324,13 @@ public class DualApprovalTests
|
||||
[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? RoleId { get; private set; } = RoleIds[userId];
|
||||
public string? RoleCode { get; private set; } = roleCode;
|
||||
public string UserId => AuditUserId.ToString();
|
||||
public bool IsAuthenticated => true;
|
||||
@@ -333,6 +340,7 @@ public class DualApprovalTests
|
||||
public void Be(int userId)
|
||||
{
|
||||
AuditUserId = userId;
|
||||
RoleId = RoleIds[userId];
|
||||
RoleCode = Roles[userId];
|
||||
}
|
||||
}
|
||||
@@ -368,7 +376,7 @@ public class DualApprovalTests
|
||||
=> throw new NotSupportedException();
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// AuthHex integration added to ICurrentUser.
|
||||
public int? RoleId => null;
|
||||
public string? RoleCode => null;
|
||||
public bool IsAdmin => false;
|
||||
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.Common;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||
/// only way to consume it.
|
||||
/// </summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
|
||||
[Route("api/v1/audit-logs")]
|
||||
public sealed class AuditLogsController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
|
||||
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||
{
|
||||
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||
return Ok(await _roles.GetMeAsync(ct));
|
||||
}
|
||||
|
||||
// ---- Session-issuing (UserManager) ------------------------------------
|
||||
|
||||
@@ -25,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
|
||||
|
||||
[HttpGet("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
|
||||
[Route("api/v1/attendance-batches")]
|
||||
public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
||||
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
||||
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
||||
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken 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")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{batchId:int}/validate")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{batchId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{batchId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPut("{branchId:int}")]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPatch("{branchId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpDelete("{branchId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/departments")]
|
||||
public sealed class DepartmentsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{departmentId:int}")]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{departmentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{departmentId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/designations")]
|
||||
public sealed class DesignationsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{designationId:int}")]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{designationId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{designationId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
|
||||
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
||||
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
||||
/// </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")]
|
||||
public sealed class EmployeesController : ApiControllerBase
|
||||
{
|
||||
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
=> 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>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("email-lookup")]
|
||||
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{employeeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[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>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[HttpDelete("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -103,6 +113,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpDelete("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
[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)
|
||||
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}/leave-balances")]
|
||||
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
||||
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _loans.ListAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}/documents")]
|
||||
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _documents.ListAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/documents")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
||||
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return File(content, contentType, fileName);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/employment-types")]
|
||||
public sealed class EmploymentTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{employmentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <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")]
|
||||
public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
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>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
|
||||
[Route("api/v1/reports/hrm")]
|
||||
public sealed class HrReportsController : ApiControllerBase
|
||||
{
|
||||
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
|
||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("payroll-register")]
|
||||
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("salary-history")]
|
||||
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
||||
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.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
|
||||
[Route("api/v1/leave-requests")]
|
||||
public sealed class LeaveRequestsController : ApiControllerBase
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
||||
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{leaveRequestId:int}/submit")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{leaveRequestId:int}/approve")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{leaveRequestId:int}/reject")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken 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")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/leave-types")]
|
||||
public sealed class LeaveTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{leaveTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[Route("api/v1/payroll-runs")]
|
||||
public sealed class PayrollRunsController : ApiControllerBase
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
|
||||
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/approve")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/lock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken 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")]
|
||||
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <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")]
|
||||
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||
{
|
||||
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _settings.ListAsync(ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[Route("api/v1/payslips")]
|
||||
public sealed class PayslipsController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/salary-components")]
|
||||
public sealed class SalaryComponentsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{salaryComponentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/tax-slabs")]
|
||||
public sealed class TaxSlabsController : ApiControllerBase
|
||||
{
|
||||
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
|
||||
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _taxSlabs.ListAsync(ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/work-shifts")]
|
||||
public sealed class WorkShiftsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{workShiftId:int}")]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{workShiftId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{workShiftId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[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]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
||||
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
|
||||
|
||||
[HttpGet("{poId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
|
||||
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||
[Route("api/v1/sales-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
|
||||
public sealed class SalesReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReturnService _returns;
|
||||
|
||||
@@ -41,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||
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, isAdmin, ct));
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
[HttpGet("user-types")]
|
||||
@@ -117,4 +117,20 @@ public sealed class UsersController : ApiControllerBase
|
||||
|
||||
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.Dtos.Common;
|
||||
using ERPCore.Dtos.Vendors;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
[Route("api/v1/vendors")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class VendorsController : ApiControllerBase
|
||||
{
|
||||
private readonly IVendorService _vendors;
|
||||
@@ -35,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -45,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{vendorId:int}")]
|
||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -57,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{vendorId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -67,7 +70,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
}
|
||||
|
||||
[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.Status404NotFound)]
|
||||
[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>
|
||||
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
||||
|
||||
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
|
||||
public string? Level2RoleCodes { get; set; }
|
||||
/// <summary>Comma-separated role IDS allowed to give the second approval; empty = anyone with the Approve action.
|
||||
/// (Ids, not codes: a role can be renamed without breaking this rule.)</summary>
|
||||
public string? Level2RoleIds { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { 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 UnitPrice { 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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -3,25 +3,30 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
|
||||
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
|
||||
/// 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.
|
||||
/// An ERP role. The ERP owns roles and what they may do: <see cref="User.RoleId"/> points here and
|
||||
/// <see cref="RolePermission"/> holds the permissions. AuthHex only authenticates people and knows nothing about roles.
|
||||
/// </summary>
|
||||
public class Role
|
||||
{
|
||||
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 Name { get; set; } = string.Empty;
|
||||
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 DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,31 @@ public class SalesConfig
|
||||
public int? DefaultWarehouseId { 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 int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -15,6 +15,13 @@ public class Vendor
|
||||
public string? Terms { get; set; }
|
||||
public string? TaxReg { get; set; }
|
||||
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 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>
|
||||
/// 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
|
||||
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back
|
||||
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string.
|
||||
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it to
|
||||
/// <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>
|
||||
public enum PurchaseOrderStatus
|
||||
{
|
||||
@@ -14,5 +16,6 @@ public enum PurchaseOrderStatus
|
||||
PartiallyReceived,
|
||||
FullyReceived,
|
||||
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(
|
||||
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
|
||||
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
|
||||
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
|
||||
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt);
|
||||
|
||||
public sealed class UpdateApprovalConfigRequest
|
||||
{
|
||||
@@ -18,7 +18,8 @@ public sealed class UpdateApprovalConfigRequest
|
||||
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
||||
[Range(0, 2)] public int PriceAdjustmentLevels { 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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -57,7 +57,9 @@ public sealed class RegisterRequest
|
||||
{
|
||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||
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; }
|
||||
public string? Fullname { 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; }
|
||||
}
|
||||
@@ -6,7 +6,10 @@ namespace ERPCore.Dtos.Config;
|
||||
/// the generic cash customer (null until the option has been switched on at least once).</summary>
|
||||
public sealed record SalesConfigDto(
|
||||
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>
|
||||
/// 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; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ public sealed record GrnDto(
|
||||
public sealed record GrnSummaryDto(
|
||||
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? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo,
|
||||
string? PoDocNo = null, Dtos.Procurement.PoVendorDto? Vendor = null);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
@@ -93,7 +94,8 @@ public sealed class CreateGrnRequest
|
||||
public int? VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
/// <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, 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) ------------------------------------------------------
|
||||
|
||||
/// <summary><c>Tax</c> is the rate (0..1); <c>TaxAmount</c> the stored amount for the line.</summary>
|
||||
public sealed record PoLineDto(
|
||||
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);
|
||||
|
||||
@@ -15,11 +20,12 @@ public sealed record PurchaseOrderDto(
|
||||
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
|
||||
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines, PoVendorDto? Vendor = null);
|
||||
|
||||
public sealed record PurchaseOrderSummaryDto(
|
||||
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
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ namespace ERPCore.Dtos.Rbac;
|
||||
/// entirely rather than merely restricting them by role.</summary>
|
||||
public sealed record MeResponseDto(
|
||||
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);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Rbac;
|
||||
|
||||
public sealed record RoleDto(
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace ERPCore.Dtos.Vendors;
|
||||
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
public sealed record VendorDto(
|
||||
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
|
||||
{
|
||||
@@ -15,6 +16,12 @@ public sealed class CreateVendorRequest
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[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"/>
|
||||
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
|
||||
public int BranchId { get; set; }
|
||||
@@ -27,6 +34,12 @@ public sealed class UpdateVendorRequest
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
// ---- UserManager --------------------------------------------------
|
||||
|
||||
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)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||
@@ -149,23 +154,6 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken 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 --------------------------------------------------------
|
||||
|
||||
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<SendOtpResponse> SendOtpAsync(SendOtpRequest 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).
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the audit actor from the current HTTP request's principal
|
||||
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
|
||||
/// when the request is unauthenticated (e.g. health checks, seeding).
|
||||
/// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
|
||||
/// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
|
||||
/// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
|
||||
/// unauthenticated (e.g. health checks, seeding).
|
||||
/// </summary>
|
||||
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 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
|
||||
{
|
||||
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
|
||||
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
|
||||
{
|
||||
/// <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; }
|
||||
|
||||
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
|
||||
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
|
||||
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
|
||||
/// already applies for write-path checks.
|
||||
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
|
||||
/// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
|
||||
/// <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>
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the authenticated principal, used purely as the audit actor
|
||||
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
|
||||
/// claim — never from the request body (see 00-CORE §4).
|
||||
/// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
|
||||
/// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
|
||||
/// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
|
||||
/// </summary>
|
||||
public interface ICurrentUser
|
||||
{
|
||||
@@ -20,13 +20,20 @@ public interface ICurrentUser
|
||||
/// <summary>True when the request carries an authenticated principal.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
|
||||
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
|
||||
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
||||
/// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
|
||||
/// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
|
||||
/// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
||||
/// </summary>
|
||||
bool IsAdmin { get; }
|
||||
|
||||
@@ -37,4 +44,10 @@ public interface ICurrentUser
|
||||
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
|
||||
/// </summary>
|
||||
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"]
|
||||
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
|
||||
var requiredUserType = config["Auth:RequiredUserTypeCode"];
|
||||
var requiredRole = config["Auth:RequiredRoleCode"];
|
||||
|
||||
// AuthHex publishes no JWKS; the RSA public key is configured statically.
|
||||
var rsa = RSA.Create();
|
||||
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
|
||||
options.AddPolicy(ErpAccessPolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
|
||||
// shared IdP). Empty config = require a valid ERP token only.
|
||||
// Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
|
||||
// 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))
|
||||
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
|
||||
if (!string.IsNullOrWhiteSpace(requiredRole))
|
||||
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
|
||||
// An inactive ERP user is refused outright, even while their AuthHex token is still valid.
|
||||
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
|
||||
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
|
||||
/// and — when the policy names one — the specific action within it too.</summary>
|
||||
/// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
|
||||
/// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
|
||||
/// 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>
|
||||
{
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
|
||||
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
|
||||
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
|
||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
|
||||
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
|
||||
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
|
||||
/// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
|
||||
/// <para>
|
||||
/// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
|
||||
/// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
|
||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
if (!Guid.TryParse(raw, out var authUserId))
|
||||
return principal; // no mappable identity → CurrentUser falls back to system
|
||||
|
||||
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
|
||||
var localId = await ResolveOrProvisionAsync(authUserId, nic);
|
||||
var row = await LoadAsync(authUserId)
|
||||
?? 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()
|
||||
.Where(e => e.UserId == localId)
|
||||
.Select(e => (int?)e.BranchId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (branchId is not null)
|
||||
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
|
||||
if (row.Status != EntityStatus.Active)
|
||||
{
|
||||
identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
|
||||
return principal;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
|
||||
{
|
||||
var existing = await _db.Users.AsNoTracking()
|
||||
/// <summary>The user with its role and branch, in ONE round trip.</summary>
|
||||
private Task<UserRow?> LoadAsync(Guid authUserId) =>
|
||||
_db.Users.AsNoTracking()
|
||||
.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();
|
||||
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 user = new User
|
||||
{
|
||||
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
return user.UserId;
|
||||
}
|
||||
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;
|
||||
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 Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
||||
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
||||
/// 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>
|
||||
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
|
||||
{
|
||||
Enforce(context.HttpContext.Request);
|
||||
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))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
||||
|
||||
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
||||
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
@@ -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.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).
|
||||
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 = 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 = 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 = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
|
||||
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.UnitPrice).HasPrecision(18, 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.HasOne(l => l.PurchaseOrder)
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
|
||||
builder.ToTable("roles");
|
||||
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.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.IsSystemRole).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(r => r.IsAdmin).IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.Property(r => r.Status)
|
||||
.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.AllowOverStockSale).IsRequired().HasDefaultValue(false);
|
||||
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)
|
||||
.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 = 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 = 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 = 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 },
|
||||
@@ -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 = 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 = 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).
|
||||
// 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
|
||||
@@ -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 = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
|
||||
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
|
||||
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.Terms).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.Status)
|
||||
|
||||
@@ -61,12 +61,67 @@ public static class DataSeeder
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedProcurementConfigAsync(db, ct);
|
||||
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, 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>
|
||||
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
|
||||
/// </summary>
|
||||
@@ -201,6 +256,16 @@ public static class DataSeeder
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
@@ -48,6 +48,9 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
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>
|
||||
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
|
||||
/// <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) ---
|
||||
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) ---
|
||||
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
|
||||
|
||||
Generated
+10917
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11131
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11366
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 ERPCore.Infra.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -23,12 +23,42 @@ namespace ERPCore.Migrations
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("ApprovalConfigId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Level2RoleCodes")
|
||||
b.Property<string>("Level2RoleIds")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
@@ -3382,6 +3412,24 @@ namespace ERPCore.Migrations
|
||||
Label = "Production",
|
||||
SortOrder = 14,
|
||||
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("PayrollLineId")
|
||||
@@ -4226,6 +4395,78 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 59,
|
||||
Code = "NAV:settings.approvals",
|
||||
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)
|
||||
.HasColumnType("decimal(9,4)");
|
||||
|
||||
b.Property<decimal>("TaxAmount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)")
|
||||
.HasDefaultValue(0m);
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
@@ -4398,6 +4645,58 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
@@ -5121,7 +5420,7 @@ namespace ERPCore.Migrations
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RoleId"));
|
||||
|
||||
b.Property<Guid>("AuthRoleId")
|
||||
b.Property<Guid?>("AuthRoleId")
|
||||
.HasColumnType("char(36)")
|
||||
.HasColumnName("auth_role_id");
|
||||
|
||||
@@ -5133,6 +5432,11 @@ namespace ERPCore.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsSystemRole")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
@@ -5530,6 +5834,41 @@ namespace ERPCore.Migrations
|
||||
b.Property<int?>("CashCustomerId")
|
||||
.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")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7403,6 +7742,16 @@ namespace ERPCore.Migrations
|
||||
Status = "Active"
|
||||
},
|
||||
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,
|
||||
Code = "procurement.purchase-returns",
|
||||
@@ -7553,6 +7902,16 @@ namespace ERPCore.Migrations
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 51,
|
||||
Code = "accounts.opening-balances",
|
||||
Href = "/dashboard/accounts/opening-balances",
|
||||
Label = "Opening Balances",
|
||||
NavItemId = 12,
|
||||
SortOrder = 7,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 27,
|
||||
Code = "production.templates",
|
||||
@@ -7661,6 +8020,76 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 6,
|
||||
SortOrder = 8,
|
||||
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"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -8023,6 +8456,14 @@ namespace ERPCore.Migrations
|
||||
.HasColumnType("varchar(3)")
|
||||
.HasDefaultValue("LKR");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<string>("Mobile")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -8334,6 +8775,23 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch")
|
||||
@@ -9127,6 +9585,44 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
|
||||
@@ -9259,6 +9755,16 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
@@ -10693,6 +11199,13 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Modules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceRun", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("StockLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
|
||||
{
|
||||
b.Navigation("Components");
|
||||
|
||||
@@ -28,7 +28,12 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
// 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()));
|
||||
|
||||
// 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<ISalesConfigService, SalesConfigService>();
|
||||
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<IBranchAccessService, BranchAccessService>();
|
||||
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)
|
||||
=> _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
Reference in New Issue
Block a user