Compare commits
44 Commits
ui-bug-fixers
..
Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d4ae3871b | |||
| b6b5a142a0 | |||
| f100a0260d | |||
| 0a212ed1aa | |||
| e6efc33ac0 | |||
| a27bdaea64 | |||
| 47479fb4f2 | |||
| 0e423b806c | |||
| 5feb2ae28b | |||
| a40e463e8b | |||
| 15716a558c | |||
| e9334be01f | |||
| 9a3ab2a4f6 | |||
| 6e4d16dc88 | |||
| a0793b35d3 | |||
| dcdb628647 | |||
| ce35973b58 | |||
| efab804b04 | |||
| 8726531d94 | |||
| 7da8834ce9 | |||
| e7b1f270a5 | |||
| 70e8193247 | |||
| 8b61c0910f | |||
| c27db06880 | |||
| e289b8430f | |||
| 379ad204bd | |||
| 61330f3927 | |||
| 81695a1fda | |||
| e33fe85bbb | |||
| 5ec3edc0c6 | |||
| eb274e62c4 | |||
| 0a1ef66f68 | |||
| 0ff6f33e9a | |||
| fd019e8ee6 | |||
| 7fa871c449 | |||
| 6b87cd2d64 | |||
| b78152b428 | |||
| d926254419 | |||
| 169a7c325c | |||
| 24a8b1045c | |||
| b3f3b45c27 | |||
| a1160e4f15 | |||
| 3be425284c | |||
| 1cddbaf84f |
@@ -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,156 @@
|
|||||||
|
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 CustomerRepository(db), new FakeBranchConfig(), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,13 +237,8 @@ public class DayEndReportServiceTests
|
|||||||
.Build();
|
.Build();
|
||||||
var clock = new BusinessDayClock(config);
|
var clock = new BusinessDayClock(config);
|
||||||
var branch = new CurrentBranchContext(new TestCurrentUser());
|
var branch = new CurrentBranchContext(new TestCurrentUser());
|
||||||
var cash = new CashInHandService(
|
var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config);
|
||||||
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
|
return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock);
|
||||||
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
|
|
||||||
return new DayEndReportService(
|
|
||||||
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
|
|
||||||
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
|
|
||||||
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ErpDbContext CreateContext()
|
private static ErpDbContext CreateContext()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ERPCore.Domain;
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Approvals;
|
using ERPCore.Dtos.Approvals;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Notifications;
|
using ERPCore.Dtos.Notifications;
|
||||||
using ERPCore.Dtos.Products;
|
using ERPCore.Dtos.Products;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
@@ -9,6 +10,7 @@ using ERPCore.Infra.Auth;
|
|||||||
using ERPCore.Infra.Persistence;
|
using ERPCore.Infra.Persistence;
|
||||||
using ERPCore.Repositories;
|
using ERPCore.Repositories;
|
||||||
using ERPCore.Services;
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
@@ -27,7 +29,8 @@ public class DualApprovalTests
|
|||||||
// Requester, two approvers, and a clerk who can approve but isn't a manager.
|
// Requester, two approvers, and a clerk who can approve but isn't a manager.
|
||||||
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
|
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
|
||||||
private const int ApproverRoleId = 2, ClerkRoleId = 3;
|
private const int ApproverRoleId = 2, ClerkRoleId = 3;
|
||||||
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
|
// ReasonId is an ordinary adjustment reason (VAR); WastageReasonId is a wastage one (DMG), which has its own approval switch.
|
||||||
|
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1, WastageReasonId = 2;
|
||||||
|
|
||||||
// --- Stock adjustments ------------------------------------------------------------------
|
// --- Stock adjustments ------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -140,7 +143,7 @@ public class DualApprovalTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SecondApproval_CanBeLimitedToRoles()
|
public async Task SecondApproval_CanBeLimitedToRoles()
|
||||||
{
|
{
|
||||||
var h = await Harness.CreateAsync(level2Roles: "Approver");
|
var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
|
||||||
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||||
h.User.Be(ApproverA);
|
h.User.Be(ApproverA);
|
||||||
await h.Stock.ApproveAsync(id, null);
|
await h.Stock.ApproveAsync(id, null);
|
||||||
@@ -225,11 +228,95 @@ public class DualApprovalTests
|
|||||||
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
|
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
|
||||||
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
|
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
|
||||||
|
|
||||||
|
// --- Wastage: its own approval switch ------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wastage_WithTheSwitchOff_ReducesStockAtOnce_EvenWhenAdjustmentsNeedTwoApprovals()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
|
||||||
|
|
||||||
|
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
|
||||||
|
|
||||||
|
Assert.Equal(AdjustmentStatus.Posted, dto.Status);
|
||||||
|
Assert.Equal(0, dto.RequiredApprovals);
|
||||||
|
Assert.Single(h.Mutator.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wastage_WithTheSwitchOn_IsARequest_AndMovesNothingUntilApproved()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 2);
|
||||||
|
|
||||||
|
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
|
||||||
|
|
||||||
|
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
|
||||||
|
Assert.Equal(2, dto.RequiredApprovals);
|
||||||
|
Assert.Empty(h.Mutator.Calls);
|
||||||
|
|
||||||
|
h.User.Be(ApproverA);
|
||||||
|
await h.Stock.ApproveAsync(dto.AdjustmentId, null);
|
||||||
|
Assert.Empty(h.Mutator.Calls); // one of two: still nothing
|
||||||
|
h.User.Be(ApproverB);
|
||||||
|
var posted = await h.Stock.ApproveAsync(dto.AdjustmentId, null);
|
||||||
|
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
|
||||||
|
Assert.Single(h.Mutator.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheWastageSwitch_DoesNotLoosenOrdinaryAdjustments_AndTheirLevelsDoNotTouchWastage()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
|
||||||
|
|
||||||
|
// An ordinary adjustment (not a wastage reason) still needs two approvals.
|
||||||
|
var ordinary = await h.Stock.CreateAsync(Request(-3));
|
||||||
|
Assert.Equal(AdjustmentStatus.PendingApproval, ordinary.Status);
|
||||||
|
|
||||||
|
// A stock GAIN with a wastage reason is not wastage, so it follows the adjustment levels too.
|
||||||
|
var gain = await h.Stock.CreateAsync(Request(+3, WastageReasonId));
|
||||||
|
Assert.Equal(AdjustmentStatus.PendingApproval, gain.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WastageList_ReturnsOnlyAdjustmentsWithAWastageReason()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
|
||||||
|
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
|
||||||
|
await h.Stock.CreateAsync(Request(-2));
|
||||||
|
|
||||||
|
var all = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null);
|
||||||
|
var wastage = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, all.Items.Count);
|
||||||
|
var only = Assert.Single(wastage.Items);
|
||||||
|
Assert.Equal(WastageReasonId, only.ReasonCodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WastageList_CanBeNarrowedToOneItem()
|
||||||
|
{
|
||||||
|
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
|
||||||
|
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
|
||||||
|
|
||||||
|
var forItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: ItemId);
|
||||||
|
var otherItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: 999);
|
||||||
|
|
||||||
|
Assert.Single(forItem.Items);
|
||||||
|
Assert.Empty(otherItem.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WastageApproval_IsOffByDefault_AndTheSettingIsReadBack()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, new ApprovalConfig().WastageLevels);
|
||||||
|
Assert.True(ERPCore.Domain.WastageReasonCodes.IsWastage("dmg"));
|
||||||
|
Assert.False(ERPCore.Domain.WastageReasonCodes.IsWastage("VAR"));
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ------------------------------------------------------------------------------
|
// --- helpers ------------------------------------------------------------------------------
|
||||||
|
|
||||||
private static CreateAdjustmentRequest Request(decimal qty) => new()
|
private static CreateAdjustmentRequest Request(decimal qty, int reasonId = ReasonId) => new()
|
||||||
{
|
{
|
||||||
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
|
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = reasonId,
|
||||||
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
|
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,7 +336,7 @@ public class DualApprovalTests
|
|||||||
public required AdjustmentService Stock { get; init; }
|
public required AdjustmentService Stock { get; init; }
|
||||||
public required PriceAdjustmentService Prices { get; init; }
|
public required PriceAdjustmentService Prices { get; init; }
|
||||||
|
|
||||||
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
|
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null, int wastageLevels = 0)
|
||||||
{
|
{
|
||||||
var db = new ErpDbContext(
|
var db = new ErpDbContext(
|
||||||
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
||||||
@@ -278,11 +365,14 @@ public class DualApprovalTests
|
|||||||
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
|
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
|
||||||
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
|
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
|
||||||
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
|
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
|
||||||
db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
|
db.ReasonCodes.AddRange(
|
||||||
|
new ReasonCode { ReasonCodeId = ReasonId, Code = "VAR", Description = "Variance", Context = ReasonContext.Adjustment },
|
||||||
|
new ReasonCode { ReasonCodeId = WastageReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
|
||||||
db.ApprovalConfig.Add(new ApprovalConfig
|
db.ApprovalConfig.Add(new ApprovalConfig
|
||||||
{
|
{
|
||||||
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
||||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
|
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds,
|
||||||
|
WastageLevels = wastageLevels
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -291,7 +381,7 @@ public class DualApprovalTests
|
|||||||
var notifications = new RecordingNotifications();
|
var notifications = new RecordingNotifications();
|
||||||
var approvals = new ApprovalService(
|
var approvals = new ApprovalService(
|
||||||
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
|
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
|
||||||
new Repository<RolePermission>(db), user, notifications, uow);
|
new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
|
||||||
var gl = new FakeGeneralLedger();
|
var gl = new FakeGeneralLedger();
|
||||||
var mutator = new RecordingMutator();
|
var mutator = new RecordingMutator();
|
||||||
var numbers = new FakeNumberSequence();
|
var numbers = new FakeNumberSequence();
|
||||||
@@ -300,12 +390,13 @@ public class DualApprovalTests
|
|||||||
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
|
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
|
||||||
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
|
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
|
||||||
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
|
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
|
||||||
new ConfigurationBuilder().Build(),
|
new StubGlAccountConfigService(
|
||||||
|
(GlAccountRole.Inventory, "1200"),
|
||||||
|
(GlAccountRole.InventoryGain, "4900"),
|
||||||
|
(GlAccountRole.InventoryLoss, "5900")),
|
||||||
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||||
new Repository<ProductConfig>(db), user));
|
new Repository<ProductConfig>(db), user));
|
||||||
var prices = new PriceAdjustmentService(
|
var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow);
|
||||||
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);
|
|
||||||
|
|
||||||
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
||||||
}
|
}
|
||||||
@@ -319,7 +410,13 @@ public class DualApprovalTests
|
|||||||
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
|
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<int, int> RoleIds = new()
|
||||||
|
{
|
||||||
|
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
|
||||||
|
};
|
||||||
|
|
||||||
public int AuditUserId { get; private set; } = userId;
|
public int AuditUserId { get; private set; } = userId;
|
||||||
|
public int? RoleId { get; private set; } = RoleIds[userId];
|
||||||
public string? RoleCode { get; private set; } = roleCode;
|
public string? RoleCode { get; private set; } = roleCode;
|
||||||
public string UserId => AuditUserId.ToString();
|
public string UserId => AuditUserId.ToString();
|
||||||
public bool IsAuthenticated => true;
|
public bool IsAuthenticated => true;
|
||||||
@@ -329,6 +426,7 @@ public class DualApprovalTests
|
|||||||
public void Be(int userId)
|
public void Be(int userId)
|
||||||
{
|
{
|
||||||
AuditUserId = userId;
|
AuditUserId = userId;
|
||||||
|
RoleId = RoleIds[userId];
|
||||||
RoleCode = Roles[userId];
|
RoleCode = Roles[userId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,7 +462,7 @@ public class DualApprovalTests
|
|||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
|
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
|
||||||
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
|
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
|
||||||
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
|
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ public class LowStockAlertServiceTests
|
|||||||
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
|
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
|
||||||
|
int? categoryId = null, int? brandId = null) => throw new NotSupportedException();
|
||||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
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")]
|
||||||
|
[InlineData("dashboard.stock")]
|
||||||
|
[InlineData("dashboard.procurement")]
|
||||||
|
[InlineData("dashboard.approvals")]
|
||||||
|
[InlineData("dashboard.sales")]
|
||||||
|
[InlineData("dashboard.accounts")]
|
||||||
|
[InlineData("dashboard.hrm")]
|
||||||
|
[InlineData("dashboard.production")]
|
||||||
|
[InlineData("dashboard.service")]
|
||||||
|
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,837 @@
|
|||||||
|
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 ProcurementConfigRepository(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 PurchaseOrderRepository(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 OpeningStockRepository(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.Persistence;
|
||||||
|
using ERPCore.Repositories;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
|
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||||
|
|
||||||
|
namespace ERPCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>The Stock Ledger screen: item name/SKU/category/brand on every row, category and brand filters, and a running
|
||||||
|
/// balance that is the real balance after each movement (not whatever a posting stored).</summary>
|
||||||
|
public class StockLedgerTests
|
||||||
|
{
|
||||||
|
private static StockService Svc(ErpDbContext db) =>
|
||||||
|
new(null!, new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<StockTransferLine>(db),
|
||||||
|
new Repository<Warehouse>(db), new Repository<Item>(db));
|
||||||
|
|
||||||
|
private static StockLedger Row(int id, int item, int warehouse, Direction dir, decimal qty, decimal unitCost, decimal storedBalance = 0m) => new()
|
||||||
|
{
|
||||||
|
LedgerId = id, ItemId = item, WarehouseId = warehouse, Direction = dir, QtyBase = qty, UnitCost = unitCost,
|
||||||
|
Value = qty * unitCost, RunningBalance = storedBalance, SourceDocType = "T", SourceDocId = id, UserId = 1,
|
||||||
|
CreatedAt = new DateTime(2026, 10, 18, 4, 0, 0, DateTimeKind.Utc).AddMinutes(id)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<ErpDbContext> SeedAsync()
|
||||||
|
{
|
||||||
|
var db = CreateContext();
|
||||||
|
db.Categories.AddRange(new Category { CategoryId = 1, Name = "Beverages" }, new Category { CategoryId = 2, Name = "Snacks" });
|
||||||
|
db.Brands.AddRange(new Brand { BrandId = 1, Name = "Milo" }, new Brand { BrandId = 2, Name = "Munchee" });
|
||||||
|
db.Items.AddRange(
|
||||||
|
new Item { ItemId = 1, Sku = "MILO-240", Name = "Milo 240ml", CategoryId = 1, BrandId = 1, BaseUomId = 1 },
|
||||||
|
new Item { ItemId = 2, Sku = "BISC-1", Name = "Biscuit", CategoryId = 2, BrandId = 2, BaseUomId = 1 });
|
||||||
|
db.StockLedger.AddRange(
|
||||||
|
Row(1, 1, Warehouse1, Direction.In, 100m, 200m, storedBalance: 100m),
|
||||||
|
Row(2, 1, Warehouse1, Direction.Out, 30m, 200m, storedBalance: 0m), // a sales issue: stored balance was never filled in
|
||||||
|
Row(3, 1, Warehouse1, Direction.In, 50m, 210m, storedBalance: 120m),
|
||||||
|
Row(4, 1, Warehouse2, Direction.In, 10m, 200m, storedBalance: 10m),
|
||||||
|
Row(5, 2, Warehouse1, Direction.In, 5m, 40m, storedBalance: 5m));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<PagedResponse<ERPCore.Dtos.Stock.StockLedgerRowDto>> Ledger(
|
||||||
|
ErpDbContext db, int page = 1, int pageSize = 50, int? itemId = null, int? warehouseId = null, int? categoryId = null, int? brandId = null) =>
|
||||||
|
Svc(db).GetLedgerAsync(itemId, warehouseId, null, null, null, null, null,
|
||||||
|
new PageQuery { Page = page, PageSize = pageSize }, CancellationToken.None, categoryId, brandId);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunningBalance_IsTheRealBalanceAfterEachMovement_PerItemAndWarehouse()
|
||||||
|
{
|
||||||
|
await using var db = await SeedAsync();
|
||||||
|
|
||||||
|
var rows = (await Ledger(db)).Items.ToDictionary(r => r.LedgerId);
|
||||||
|
|
||||||
|
Assert.Equal(100m, rows[1].RunningBalance);
|
||||||
|
Assert.Equal(70m, rows[2].RunningBalance); // the stored 0 is ignored
|
||||||
|
Assert.Equal(120m, rows[3].RunningBalance);
|
||||||
|
Assert.Equal(10m, rows[4].RunningBalance); // another warehouse starts from its own zero
|
||||||
|
Assert.Equal(5m, rows[5].RunningBalance); // another item too
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunningBalance_IsStillRight_OnALaterPage_AndWithFiltersApplied()
|
||||||
|
{
|
||||||
|
await using var db = await SeedAsync();
|
||||||
|
|
||||||
|
// Newest first, 2 per page: page 2 holds ledger 3 and 2, whose balances depend on rows before and after it.
|
||||||
|
var page2 = (await Ledger(db, page: 2, pageSize: 2)).Items;
|
||||||
|
Assert.Equal(new long[] { 3, 2 }, page2.Select(r => (long)r.LedgerId));
|
||||||
|
Assert.Equal(new[] { 120m, 70m }, page2.Select(r => r.RunningBalance));
|
||||||
|
|
||||||
|
// Filtering to one warehouse and item changes which rows show, not what the balance is.
|
||||||
|
var only = (await Ledger(db, itemId: 1, warehouseId: Warehouse1)).Items.OrderBy(r => r.LedgerId).ToList();
|
||||||
|
Assert.Equal(new[] { 100m, 70m, 120m }, only.Select(r => r.RunningBalance));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EveryRow_CarriesTheItemsSkuNameCategoryAndBrand()
|
||||||
|
{
|
||||||
|
await using var db = await SeedAsync();
|
||||||
|
|
||||||
|
var row = (await Ledger(db, itemId: 1)).Items.First();
|
||||||
|
|
||||||
|
Assert.Equal(("MILO-240", "Milo 240ml", "Beverages", "Milo"), (row.ItemSku, row.ItemName, row.CategoryName, row.BrandName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CategoryAndBrandFilters_NarrowTheLedgerToThoseItems()
|
||||||
|
{
|
||||||
|
await using var db = await SeedAsync();
|
||||||
|
|
||||||
|
Assert.All((await Ledger(db, categoryId: 2)).Items, r => Assert.Equal(2, r.ItemId));
|
||||||
|
Assert.Equal(4, (await Ledger(db, brandId: 1)).Items.Count);
|
||||||
|
Assert.Empty((await Ledger(db, categoryId: 2, brandId: 1)).Items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ internal sealed class FakeGeneralLedger : IGeneralLedgerService
|
|||||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
||||||
|
|
||||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
|
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
|
||||||
|
|
||||||
|
/// <summary>Not implemented on purpose — nothing here picks a GL account by code; see
|
||||||
|
/// <see cref="StubGeneralLedgerService"/> for the fake that does.</summary>
|
||||||
|
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
|
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
|
||||||
@@ -141,9 +145,7 @@ internal static class BankDepositTestSupport
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
|
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
|
||||||
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
|
=> new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
||||||
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
|
|
||||||
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
|
||||||
|
|
||||||
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
|
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ internal sealed class TestCurrentUser : ICurrentUser
|
|||||||
|
|
||||||
// Unauthenticated, so no role claim and no branch scoping: the three members the
|
// Unauthenticated, so no role claim and no branch scoping: the three members the
|
||||||
// AuthHex integration added to ICurrentUser.
|
// AuthHex integration added to ICurrentUser.
|
||||||
|
public int? RoleId => null;
|
||||||
public string? RoleCode => null;
|
public string? RoleCode => null;
|
||||||
public bool IsAdmin => false;
|
public bool IsAdmin => false;
|
||||||
public int? BranchId => null;
|
public int? BranchId => null;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access configuration — the default role for people who have none. Both reading and changing it need the
|
||||||
|
/// <c>settings</c> nav permission (it decides what a brand-new sign-in can do).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/access-config")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
public sealed class AccessConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAccessConfigService _config;
|
||||||
|
|
||||||
|
public AccessConfigController(IAccessConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<AccessConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<AccessConfigDto>> Update([FromBody] UpdateAccessConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using ERPCore.Dtos.Audit;
|
using ERPCore.Dtos.Audit;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
|||||||
/// the audit trail is required (AR-01 compensating control) and read access is the
|
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||||
/// only way to consume it.
|
/// only way to consume it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
|
||||||
[Route("api/v1/audit-logs")]
|
[Route("api/v1/audit-logs")]
|
||||||
public sealed class AuditLogsController : ApiControllerBase
|
public sealed class AuditLogsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
return Ok(await _roles.GetMeAsync(ct));
|
||||||
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Session-issuing (UserManager) ------------------------------------
|
// ---- Session-issuing (UserManager) ------------------------------------
|
||||||
|
|||||||
@@ -12,12 +12,25 @@ namespace ERPCore.Controllers;
|
|||||||
public sealed class DashboardController : ApiControllerBase
|
public sealed class DashboardController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDashboardService _dashboard;
|
private readonly IDashboardService _dashboard;
|
||||||
|
private readonly IDashboardChartsService _charts;
|
||||||
|
|
||||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
public DashboardController(IDashboardService dashboard, IDashboardChartsService charts)
|
||||||
|
{
|
||||||
|
_dashboard = dashboard;
|
||||||
|
_charts = charts;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
/// <summary>Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR.</summary>
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
public async Task<ActionResult<DashboardStatsDto>> GetStats(
|
||||||
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||||
|
=> Ok(await _dashboard.GetStatsAsync(branchId, from, to, ct));
|
||||||
|
|
||||||
|
/// <summary>Datasets for the dashboard graphs; the time-based ones follow the optional from / to period.</summary>
|
||||||
|
[HttpGet("charts")]
|
||||||
|
[ProducesResponseType(typeof(DashboardChartsDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<DashboardChartsDto>> GetCharts(
|
||||||
|
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||||
|
=> Ok(await _charts.GetChartsAsync(branchId, from, to, ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
|
|||||||
[HttpPut("{**path}")]
|
[HttpPut("{**path}")]
|
||||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||||
|
|
||||||
|
[HttpPatch("{**path}")]
|
||||||
|
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
|
||||||
|
|
||||||
|
[HttpDelete("{**path}")]
|
||||||
|
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
|
||||||
|
|
||||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
|
||||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||||
return new ContentResult
|
return new ContentResult
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||||
|
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
|
||||||
|
|
||||||
[HttpGet("{grnId:int}")]
|
[HttpGet("{grnId:int}")]
|
||||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
|
||||||
[Route("api/v1/attendance-batches")]
|
[Route("api/v1/attendance-batches")]
|
||||||
public sealed class AttendanceBatchesController : ApiControllerBase
|
public sealed class AttendanceBatchesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
||||||
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
|||||||
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
||||||
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
||||||
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
|||||||
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
|
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
|
||||||
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
|
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPost("{batchId:int}/resolve-duplicate")]
|
[HttpPost("{batchId:int}/resolve-duplicate")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPost("{batchId:int}/validate")]
|
[HttpPost("{batchId:int}/validate")]
|
||||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
||||||
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{batchId:int}/confirm")]
|
[HttpPost("{batchId:int}/confirm")]
|
||||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
||||||
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{batchId:int}/unlock")]
|
[HttpPost("{batchId:int}/unlock")]
|
||||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||||
[HttpPut("{branchId:int}")]
|
[HttpPut("{branchId:int}")]
|
||||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||||
[HttpPatch("{branchId:int}/status")]
|
[HttpPatch("{branchId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||||
[HttpDelete("{branchId:int}")]
|
[HttpDelete("{branchId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/departments")]
|
[Route("api/v1/departments")]
|
||||||
public sealed class DepartmentsController : ApiControllerBase
|
public sealed class DepartmentsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{departmentId:int}")]
|
[HttpPut("{departmentId:int}")]
|
||||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{departmentId:int}/status")]
|
[HttpPatch("{departmentId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{departmentId:int}")]
|
[HttpDelete("{departmentId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/designations")]
|
[Route("api/v1/designations")]
|
||||||
public sealed class DesignationsController : ApiControllerBase
|
public sealed class DesignationsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{designationId:int}")]
|
[HttpPut("{designationId:int}")]
|
||||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{designationId:int}/status")]
|
[HttpPatch("{designationId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{designationId:int}")]
|
[HttpDelete("{designationId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
|
|||||||
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
||||||
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
|
||||||
|
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/employees")]
|
[Route("api/v1/employees")]
|
||||||
public sealed class EmployeesController : ApiControllerBase
|
public sealed class EmployeesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
|
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
|
||||||
|
|
||||||
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||||
[HttpGet("email-lookup")]
|
[HttpGet("email-lookup")]
|
||||||
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||||
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||||
[HttpGet("{employeeId:int}")]
|
[HttpGet("{employeeId:int}")]
|
||||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPut("{employeeId:int}")]
|
[HttpPut("{employeeId:int}")]
|
||||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPatch("{employeeId:int}/status")]
|
[HttpPatch("{employeeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -93,6 +102,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
[HttpDelete("{employeeId:int}")]
|
[HttpDelete("{employeeId:int}")]
|
||||||
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -103,6 +113,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPost("{employeeId:int}/link-user")]
|
[HttpPost("{employeeId:int}/link-user")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpDelete("{employeeId:int}/link-user")]
|
[HttpDelete("{employeeId:int}/link-user")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("{employeeId:int}/bank-details")]
|
[HttpGet("{employeeId:int}/bank-details")]
|
||||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
||||||
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPut("{employeeId:int}/bank-details")]
|
[HttpPut("{employeeId:int}/bank-details")]
|
||||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -140,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
|
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
|
||||||
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPut("{employeeId:int}/leave-balances")]
|
[HttpPut("{employeeId:int}/leave-balances")]
|
||||||
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
||||||
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("{employeeId:int}/salary-structure")]
|
[HttpGet("{employeeId:int}/salary-structure")]
|
||||||
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
||||||
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPost("{employeeId:int}/salary-structure")]
|
[HttpPost("{employeeId:int}/salary-structure")]
|
||||||
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("{employeeId:int}/loans")]
|
[HttpGet("{employeeId:int}/loans")]
|
||||||
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
||||||
=> Ok(await _loans.ListAsync(employeeId, ct));
|
=> Ok(await _loans.ListAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
||||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost("{employeeId:int}/loans")]
|
[HttpPost("{employeeId:int}/loans")]
|
||||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||||
[HttpGet("{employeeId:int}/documents")]
|
[HttpGet("{employeeId:int}/documents")]
|
||||||
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
||||||
=> Ok(await _documents.ListAsync(employeeId, ct));
|
=> Ok(await _documents.ListAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPost("{employeeId:int}/documents")]
|
[HttpPost("{employeeId:int}/documents")]
|
||||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||||
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
||||||
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||||
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
|
|||||||
return File(content, contentType, fileName);
|
return File(content, contentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/employment-types")]
|
[Route("api/v1/employment-types")]
|
||||||
public sealed class EmploymentTypesController : ApiControllerBase
|
public sealed class EmploymentTypesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{employmentTypeId:int}")]
|
[HttpPut("{employmentTypeId:int}")]
|
||||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{employmentTypeId:int}/status")]
|
[HttpPatch("{employmentTypeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{employmentTypeId:int}")]
|
[HttpDelete("{employmentTypeId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/hr-document-types")]
|
[Route("api/v1/hr-document-types")]
|
||||||
public sealed class HrDocumentTypesController : ApiControllerBase
|
public sealed class HrDocumentTypesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{hrDocumentTypeId:int}")]
|
[HttpPut("{hrDocumentTypeId:int}")]
|
||||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{hrDocumentTypeId:int}")]
|
[HttpDelete("{hrDocumentTypeId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
|
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
|
||||||
[Route("api/v1/reports/hrm")]
|
[Route("api/v1/reports/hrm")]
|
||||||
public sealed class HrReportsController : ApiControllerBase
|
public sealed class HrReportsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
|
|||||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||||
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("payroll-register")]
|
[HttpGet("payroll-register")]
|
||||||
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
||||||
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[HttpGet("salary-history")]
|
[HttpGet("salary-history")]
|
||||||
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
|
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
|
||||||
[Route("api/v1/leave-requests")]
|
[Route("api/v1/leave-requests")]
|
||||||
public sealed class LeaveRequestsController : ApiControllerBase
|
public sealed class LeaveRequestsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost("{leaveRequestId:int}/submit")]
|
[HttpPost("{leaveRequestId:int}/submit")]
|
||||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
||||||
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{leaveRequestId:int}/approve")]
|
[HttpPost("{leaveRequestId:int}/approve")]
|
||||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
||||||
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{leaveRequestId:int}/reject")]
|
[HttpPost("{leaveRequestId:int}/reject")]
|
||||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
|
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
|
||||||
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
|
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost("{leaveRequestId:int}/cancel")]
|
[HttpPost("{leaveRequestId:int}/cancel")]
|
||||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/leave-types")]
|
[Route("api/v1/leave-types")]
|
||||||
public sealed class LeaveTypesController : ApiControllerBase
|
public sealed class LeaveTypesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{leaveTypeId:int}")]
|
[HttpPut("{leaveTypeId:int}")]
|
||||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{leaveTypeId:int}/status")]
|
[HttpPatch("{leaveTypeId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{leaveTypeId:int}")]
|
[HttpDelete("{leaveTypeId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[Route("api/v1/payroll-runs")]
|
[Route("api/v1/payroll-runs")]
|
||||||
public sealed class PayrollRunsController : ApiControllerBase
|
public sealed class PayrollRunsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{payrollRunId:int}/approve")]
|
[HttpPost("{payrollRunId:int}/approve")]
|
||||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
||||||
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{payrollRunId:int}/lock")]
|
[HttpPost("{payrollRunId:int}/lock")]
|
||||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
||||||
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||||
[HttpPost("{payrollRunId:int}/unlock")]
|
[HttpPost("{payrollRunId:int}/unlock")]
|
||||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
|
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
|
||||||
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
|
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost("{payrollRunId:int}/generate-payslips")]
|
[HttpPost("{payrollRunId:int}/generate-payslips")]
|
||||||
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
|
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/payroll-statutory-settings")]
|
[Route("api/v1/payroll-statutory-settings")]
|
||||||
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
|||||||
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
||||||
=> Ok(await _settings.ListAsync(ct));
|
=> Ok(await _settings.ListAsync(ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
||||||
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||||
[Route("api/v1/payslips")]
|
[Route("api/v1/payslips")]
|
||||||
public sealed class PayslipsController : ApiControllerBase
|
public sealed class PayslipsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/salary-components")]
|
[Route("api/v1/salary-components")]
|
||||||
public sealed class SalaryComponentsController : ApiControllerBase
|
public sealed class SalaryComponentsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{salaryComponentId:int}")]
|
[HttpPut("{salaryComponentId:int}")]
|
||||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{salaryComponentId:int}/status")]
|
[HttpPatch("{salaryComponentId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{salaryComponentId:int}")]
|
[HttpDelete("{salaryComponentId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/tax-slabs")]
|
[Route("api/v1/tax-slabs")]
|
||||||
public sealed class TaxSlabsController : ApiControllerBase
|
public sealed class TaxSlabsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
|
|||||||
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
||||||
=> Ok(await _taxSlabs.ListAsync(ct));
|
=> Ok(await _taxSlabs.ListAsync(ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Hrm;
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers.Hrm;
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||||
[Route("api/v1/work-shifts")]
|
[Route("api/v1/work-shifts")]
|
||||||
public sealed class WorkShiftsController : ApiControllerBase
|
public sealed class WorkShiftsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPut("{workShiftId:int}")]
|
[HttpPut("{workShiftId:int}")]
|
||||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpPatch("{workShiftId:int}/status")]
|
[HttpPatch("{workShiftId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||||
[HttpDelete("{workShiftId:int}")]
|
[HttpDelete("{workShiftId:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using ERPCore.Dtos.OpeningBalances;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time opening balances. Gated on the <c>accounts.opening-balances</c> nav code (Admin always passes;
|
||||||
|
/// other roles need it granted under Settings → Roles).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/opening-balances")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "accounts.opening-balances")]
|
||||||
|
public sealed class OpeningBalancesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOpeningBalanceService _openingBalances;
|
||||||
|
private readonly IOpeningStockService _openingStock;
|
||||||
|
|
||||||
|
public OpeningBalancesController(IOpeningBalanceService openingBalances, IOpeningStockService openingStock)
|
||||||
|
{
|
||||||
|
_openingBalances = openingBalances;
|
||||||
|
_openingStock = openingStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cash and bank accounts to enter opening amounts for — or the posted record, once done.</summary>
|
||||||
|
[HttpGet("cash-bank")]
|
||||||
|
[ProducesResponseType(typeof(CashBankOpeningStateDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CashBankOpeningStateDto>> GetCashBank(CancellationToken ct) =>
|
||||||
|
Ok(await _openingBalances.GetCashBankAsync(ct));
|
||||||
|
|
||||||
|
/// <summary>Posts the cash and bank opening balances as one journal. Allowed once; later calls get 409.</summary>
|
||||||
|
[HttpPost("cash-bank")]
|
||||||
|
[ProducesResponseType(typeof(OpeningBalanceRunDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<OpeningBalanceRunDto>> PostCashBank(
|
||||||
|
[FromBody] PostCashBankOpeningRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var run = await _openingBalances.PostCashBankAsync(request, ct);
|
||||||
|
return StatusCode(StatusCodes.Status201Created, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opening-stock runs posted so far, newest first.</summary>
|
||||||
|
[HttpGet("stock")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<OpeningStockRunSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<OpeningStockRunSummaryDto>>> ListStock(CancellationToken ct) =>
|
||||||
|
Ok(await _openingStock.ListAsync(ct));
|
||||||
|
|
||||||
|
/// <summary>One opening-stock run with its lines.</summary>
|
||||||
|
[HttpGet("stock/{runId:int}")]
|
||||||
|
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<OpeningStockRunDto>> GetStock(int runId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var run = await _openingStock.GetAsync(runId, ct);
|
||||||
|
return run is null ? NotFound() : Ok(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts opening stock: a FIFO layer per line and one journal (Dr Inventory / Cr Opening Balance Equity).
|
||||||
|
/// An item that already has stock movements in that warehouse is refused with 409.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("stock")]
|
||||||
|
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<OpeningStockRunDto>> PostStock([FromBody] PostOpeningStockRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var run = await _openingStock.PostAsync(request, ct);
|
||||||
|
return StatusCode(StatusCodes.Status201Created, run);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.OwnerReport;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
|
||||||
|
/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
|
||||||
|
/// configuration screens (BranchConfigController, GlAccountConfigController).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/settings/owner-report")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
public sealed class OwnerReportController : ApiControllerBase
|
||||||
|
{
|
||||||
|
// One test send per minute (whole deployment) so the button can't be used to spam the owner.
|
||||||
|
private static readonly SemaphoreSlim TestGate = new(1, 1);
|
||||||
|
private static DateTime _lastTestUtc = DateTime.MinValue;
|
||||||
|
|
||||||
|
private readonly IOwnerReportService _service;
|
||||||
|
|
||||||
|
public OwnerReportController(IOwnerReportService service) => _service = service;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<OwnerReportConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _service.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<OwnerReportConfigDto>> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("preview")]
|
||||||
|
[ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
|
||||||
|
=> Ok(await _service.PreviewAsync(date, ct));
|
||||||
|
|
||||||
|
[HttpPost("test")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await TestGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
|
||||||
|
return StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
|
new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
|
||||||
|
_lastTestUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await _service.SendTestAsync(request?.Date, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("dispatches")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _service.ListDispatchesAsync(query, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Procurement configuration endpoints — optional vendor fields and PO tax.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> reading is open to any signed-in ERP user, because the vendor and purchase-order
|
||||||
|
/// forms must read the flags and those screens sit under different nav codes. The flags are not
|
||||||
|
/// sensitive. Changing them needs the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/procurement-config")]
|
||||||
|
public sealed class ProcurementConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IProcurementConfigService _config;
|
||||||
|
|
||||||
|
public ProcurementConfigController(IProcurementConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ProcurementConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
|
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ProcurementConfigDto>> Update(
|
||||||
|
[FromBody] UpdateProcurementConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
|
||||||
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
|
||||||
|
|
||||||
[HttpGet("{poId:int}")]
|
[HttpGet("{poId:int}")]
|
||||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Reference;
|
using ERPCore.Dtos.Reference;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
|
|||||||
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
=> Ok(await _codes.ListAsync(context, query, ct));
|
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -92,8 +92,26 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[FromQuery] DateOnly? to,
|
[FromQuery] DateOnly? to,
|
||||||
[FromQuery] int? createdBy,
|
[FromQuery] int? createdBy,
|
||||||
[FromQuery] bool? paid,
|
[FromQuery] bool? paid,
|
||||||
|
[FromQuery] bool? overdue,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
|
||||||
|
|
||||||
|
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
|
||||||
|
[HttpGet("totals")]
|
||||||
|
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
|
||||||
|
[FromQuery] string? q,
|
||||||
|
[FromQuery] SalesInvoiceStatus? status,
|
||||||
|
[FromQuery] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
[FromQuery] int? branchId,
|
||||||
|
[FromQuery] DateOnly? from,
|
||||||
|
[FromQuery] DateOnly? to,
|
||||||
|
[FromQuery] int? createdBy,
|
||||||
|
[FromQuery] bool? paid,
|
||||||
|
[FromQuery] bool? overdue,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using ERPCore.Infra.Auth;
|
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Sales;
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||||
[Route("api/v1/sales-returns")]
|
[Route("api/v1/sales-returns")]
|
||||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
|
||||||
public sealed class SalesReturnsController : ApiControllerBase
|
public sealed class SalesReturnsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesReturnService _returns;
|
private readonly ISalesReturnService _returns;
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
|
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
|
||||||
[FromQuery] AdjustmentStatus? status, CancellationToken ct)
|
[FromQuery] AdjustmentStatus? status, CancellationToken ct, [FromQuery] bool wastage = false, [FromQuery] int? itemId = null)
|
||||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
|
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct, wastage, itemId));
|
||||||
|
|
||||||
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
|
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
|
||||||
[HttpGet("{adjustmentId:int}")]
|
[HttpGet("{adjustmentId:int}")]
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ public sealed class StockController : ApiControllerBase
|
|||||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||||
[FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] PageQuery query, CancellationToken ct,
|
||||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
|
[FromQuery] int? categoryId = null, [FromQuery] int? brandId = null)
|
||||||
|
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct, categoryId, brandId));
|
||||||
|
|
||||||
[HttpGet("valuation")]
|
[HttpGet("valuation")]
|
||||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
|
||||||
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
=> Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
|
||||||
|
|
||||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
[HttpGet("user-types")]
|
[HttpGet("user-types")]
|
||||||
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
|
||||||
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
/// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
|
||||||
|
[HttpPut("{userId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.UpdateAsync(userId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||||
[HttpPatch("{userId:int}/status")]
|
[HttpPatch("{userId:int}/status")]
|
||||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||||
|
|
||||||
|
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
|
||||||
|
[HttpPost("{userId:int}/send-reset-link")]
|
||||||
|
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
|
||||||
|
=> Ok(await _users.SendResetLinkAsync(userId, ct));
|
||||||
|
|
||||||
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
||||||
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||||
[HttpGet("{userId:int}/login-devices")]
|
[HttpGet("{userId:int}/login-devices")]
|
||||||
@@ -103,4 +117,20 @@ public sealed class UsersController : ApiControllerBase
|
|||||||
|
|
||||||
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
|
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time migration helper (administrators only): copies each role-less user's role from AuthHex into the ERP.
|
||||||
|
/// Safe to repeat — users that already have a role are left alone. See
|
||||||
|
/// <see cref="IUserManagementService.BackfillRolesFromAuthHexAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("backfill-roles")]
|
||||||
|
[ProducesResponseType(typeof(BackfillRolesResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<ActionResult<BackfillRolesResultDto>> BackfillRoles(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_currentUser.IsAdmin)
|
||||||
|
throw new DomainException("FORBIDDEN", "Only an administrator can run the role backfill.", 403);
|
||||||
|
|
||||||
|
return Ok(await _users.BackfillRolesFromAuthHexAsync(ct));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using ERPCore.Infra.Auth;
|
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Vendors;
|
using ERPCore.Dtos.Vendors;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||||
[Route("api/v1/vendors")]
|
[Route("api/v1/vendors")]
|
||||||
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
|
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||||
public sealed class VendorsController : ApiControllerBase
|
public sealed class VendorsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IVendorService _vendors;
|
private readonly IVendorService _vendors;
|
||||||
@@ -35,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
@@ -45,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
|
|||||||
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
|
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPut("{vendorId:int}")]
|
[HttpPut("{vendorId:int}")]
|
||||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -57,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||||
[HttpPatch("{vendorId:int}/status")]
|
[HttpPatch("{vendorId:int}/status")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
@@ -67,7 +70,7 @@ public sealed class VendorsController : ApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{vendorId:int}")]
|
[HttpDelete("{vendorId:int}")]
|
||||||
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access configuration — a singleton row (same shape as <see cref="GrnConfig"/>).
|
||||||
|
/// <see cref="DefaultRoleId"/> is the role a person gets on their first sign-in, and when they have none:
|
||||||
|
/// without it they would have no permissions at all. It must be a least-privilege role and can never be the
|
||||||
|
/// administrator role (enforced when it is saved).
|
||||||
|
/// </summary>
|
||||||
|
public class AccessConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
public int? DefaultRoleId { get; set; }
|
||||||
|
public Role? DefaultRole { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
|
|||||||
public string TemplateCode { get; set; } = string.Empty;
|
public string TemplateCode { get; set; } = string.Empty;
|
||||||
public string TemplateName { get; set; } = string.Empty;
|
public string TemplateName { get; set; } = string.Empty;
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// What the whole bundle sells for. On save it is split across the included lines' unit prices
|
||||||
|
/// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
|
||||||
|
/// 0 on templates created before bundle prices existed — their line prices stand as entered.
|
||||||
|
/// </summary>
|
||||||
|
public decimal BundlePrice { get; set; }
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|||||||
@@ -35,12 +35,17 @@ public class ApprovalConfig
|
|||||||
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
|
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
|
||||||
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Approvals wastage (damage / theft / expiry write-offs) needs: 0 = none, stock reduces on save (default);
|
||||||
|
/// 2 = two different approvers first. Separate from <see cref="StockAdjustmentLevels"/>, which governs every other adjustment.</summary>
|
||||||
|
public int WastageLevels { get; set; }
|
||||||
|
|
||||||
public int PriceAdjustmentLevels { get; set; } = 2;
|
public int PriceAdjustmentLevels { get; set; } = 2;
|
||||||
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
|
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
|
||||||
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
||||||
|
|
||||||
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
|
/// <summary>Comma-separated role IDS allowed to give the second approval; empty = anyone with the Approve action.
|
||||||
public string? Level2RoleCodes { get; set; }
|
/// (Ids, not codes: a role can be renamed without breaking this rule.)</summary>
|
||||||
|
public string? Level2RoleIds { get; set; }
|
||||||
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public int? UpdatedBy { get; set; }
|
public int? UpdatedBy { get; set; }
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class GlAccountConfig
|
|||||||
|
|
||||||
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
|
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
|
||||||
public string? CashAccountCode { get; set; }
|
public string? CashAccountCode { get; set; }
|
||||||
|
public string? AccruedExpensesCode { get; set; }
|
||||||
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Day-end owner report settings — a singleton row, same shape as <see cref="SalesConfig"/>
|
||||||
|
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When <see cref="Enabled"/>, OwnerReportJob sends the
|
||||||
|
/// day's figures to every active <see cref="OwnerReportRecipient"/> at <see cref="SendAtLocalTime"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class OwnerReportConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Master switch — nothing is sent automatically while off ("Send test now" still works).</summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
public bool SmsEnabled { get; set; } = true;
|
||||||
|
public bool EmailEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Local time of day (DayEndReport:TimeZone) after which the day's report goes out.</summary>
|
||||||
|
public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
|
||||||
|
|
||||||
|
/// <summary>Adds one summary row per active branch when branching is switched on.</summary>
|
||||||
|
public bool IncludeBranchBreakdown { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Someone who receives the day-end report — by SMS, email or both.</summary>
|
||||||
|
public class OwnerReportRecipient
|
||||||
|
{
|
||||||
|
public int RecipientId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).</summary>
|
||||||
|
public string? Mobile { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One send of one day's report on one channel to one recipient — the send log, and the guard
|
||||||
|
/// against sending twice: (BusinessDate, Channel, RecipientId, IsTest) is unique for automatic
|
||||||
|
/// sends, so a restart or a second server instance can't send the same day again.
|
||||||
|
/// </summary>
|
||||||
|
public class OwnerReportDispatch
|
||||||
|
{
|
||||||
|
public long DispatchId { get; set; }
|
||||||
|
public DateOnly BusinessDate { get; set; }
|
||||||
|
public OwnerReportChannel Channel { get; set; }
|
||||||
|
|
||||||
|
public int? RecipientId { get; set; }
|
||||||
|
public OwnerReportRecipient? Recipient { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number / email address at send time — kept even if the recipient is edited later.</summary>
|
||||||
|
public string Destination { get; set; } = string.Empty;
|
||||||
|
public string RecipientName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public OwnerReportDispatchStatus Status { get; set; }
|
||||||
|
public int Attempts { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Test sends ("Send test now") — never block or count as the day's automatic send.</summary>
|
||||||
|
public bool IsTest { get; set; }
|
||||||
|
|
||||||
|
/// <summary>For tests: a unique token so repeated tests don't collide on the unique index.</summary>
|
||||||
|
public string DedupeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? SentAt { get; set; }
|
||||||
|
public DateTime? NextAttemptAt { get; set; }
|
||||||
|
}
|
||||||
@@ -21,5 +21,10 @@ public class PoLine
|
|||||||
public decimal Qty { get; set; }
|
public decimal Qty { get; set; }
|
||||||
public decimal UnitPrice { get; set; }//
|
public decimal UnitPrice { get; set; }//
|
||||||
public decimal Tax { get; set; }
|
public decimal Tax { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tax amount for this line = round(Qty x UnitPrice x <see cref="Tax"/>, 2). Stored so the
|
||||||
|
/// figure on the PO, its PDF and the totals never drifts from what was approved. Always 0 while
|
||||||
|
/// Procurement configuration's PO tax switch is off.</summary>
|
||||||
|
public decimal TaxAmount { get; set; }
|
||||||
public decimal QtyReceived { get; set; }
|
public decimal QtyReceived { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Procurement configuration — a singleton row, same shape as <see cref="GrnConfig"/>.
|
||||||
|
/// Controls the optional vendor fields and whether purchase orders carry tax.
|
||||||
|
/// </summary>
|
||||||
|
public class ProcurementConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which contact detail vendors carry: none (default), a mobile number or an email.
|
||||||
|
/// A value sent for a field that is not selected is rejected server-side (CONFIG_DISABLED).</summary>
|
||||||
|
public VendorContactMode VendorContactMode { get; set; } = VendorContactMode.None;
|
||||||
|
|
||||||
|
/// <summary>When on, vendors carry an optional company address.</summary>
|
||||||
|
public bool VendorAddressEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When on (default — the field existed before it became optional), vendors carry optional payment terms.
|
||||||
|
/// When off the field is hidden and a value for it is rejected (CONFIG_DISABLED); stored values are kept.</summary>
|
||||||
|
public bool VendorPaymentTermsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When on (default), vendors carry an optional tax registration number. Same rules as payment terms.
|
||||||
|
/// Purchase invoices still snapshot a vendor's stored number either way.</summary>
|
||||||
|
public bool VendorTaxRegEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When on, purchase-order lines carry a tax rate (prefilled from the item) and tax amount;
|
||||||
|
/// when off, tax is hidden everywhere on POs and a non-zero tax is rejected server-side.</summary>
|
||||||
|
public bool PoTaxEnabled { get; set; }
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -51,6 +51,31 @@ public class ProductConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CustomWarrantyPeriodsEnabled { get; set; }
|
public bool CustomWarrantyPeriodsEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the stock-nature choice (Stocked / Non-stocked / Service) on item screens. Off:
|
||||||
|
/// new items are Stocked. Enforced on item writes - an item keeps the nature it already
|
||||||
|
/// has, only a change <em>to</em> a non-Stocked nature is refused.
|
||||||
|
/// </summary>
|
||||||
|
public bool StockNatureEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the tracking-mode choice on item screens. Off: new items are untracked (None).
|
||||||
|
/// Enforced on item writes like <see cref="StockNatureEnabled"/> - existing modes are kept.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrackingModeEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the low-stock alert (reorder point / qty) section on item create and the item page.
|
||||||
|
/// Advisory: it gates the item screens; reorder rules already saved keep working.
|
||||||
|
/// </summary>
|
||||||
|
public bool LowStockAlertsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets the low-stock alert be pinned to one warehouse. Off: the alert watches every
|
||||||
|
/// warehouse. Depends on <see cref="LowStockAlertsEnabled"/>; advisory like it.
|
||||||
|
/// </summary>
|
||||||
|
public bool AlertWarehouseEnabled { get; set; } = true;
|
||||||
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
public int? UpdatedBy { get; set; }
|
public int? UpdatedBy { get; set; }
|
||||||
|
|||||||
@@ -3,25 +3,30 @@ using ERPCore.Domain.Enums;
|
|||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
|
/// An ERP role. The ERP owns roles and what they may do: <see cref="User.RoleId"/> points here and
|
||||||
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
|
/// <see cref="RolePermission"/> holds the permissions. AuthHex only authenticates people and knows nothing about roles.
|
||||||
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
|
|
||||||
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
|
|
||||||
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
|
|
||||||
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Role
|
public class Role
|
||||||
{
|
{
|
||||||
public int RoleId { get; set; }
|
public int RoleId { get; set; }
|
||||||
public Guid AuthRoleId { get; set; }
|
|
||||||
|
/// <summary>Legacy link to the AuthHex role this one used to mirror. Unused now; kept (nullable) until the AuthHex
|
||||||
|
/// role table is retired, then dropped.</summary>
|
||||||
|
public Guid? AuthRoleId { get; set; }
|
||||||
|
|
||||||
public string Code { get; set; } = string.Empty;
|
public string Code { get; set; } = string.Empty;
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public bool IsSystemRole { get; set; }
|
public bool IsSystemRole { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for the administrator role: its holders pass every permission check, bypass branch scoping and can
|
||||||
|
/// manage users and roles. This flag — never the role's code or name — is what "admin" means, so the role can be
|
||||||
|
/// renamed freely. Not settable through the API; only the seeded Admin role carries it.</summary>
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
|
||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,31 @@ public class SalesConfig
|
|||||||
public int? DefaultWarehouseId { get; set; }
|
public int? DefaultWarehouseId { get; set; }
|
||||||
public Warehouse? DefaultWarehouse { get; set; }
|
public Warehouse? DefaultWarehouse { get; set; }
|
||||||
|
|
||||||
|
// ---- Optional customer fields. Each is on by default (the field existed before it became optional). When off the
|
||||||
|
// field is hidden on the customer forms and a new value for it is rejected server-side (CONFIG_DISABLED); values
|
||||||
|
// already stored are kept.
|
||||||
|
|
||||||
|
/// <summary>Customers carry an optional phone number.</summary>
|
||||||
|
public bool CustomerPhoneEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers carry an optional email address.</summary>
|
||||||
|
public bool CustomerEmailEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers carry an optional address (lines 1 and 2).</summary>
|
||||||
|
public bool CustomerAddressEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers carry an optional city.</summary>
|
||||||
|
public bool CustomerCityEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers carry an optional country.</summary>
|
||||||
|
public bool CustomerCountryEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers have a credit limit.</summary>
|
||||||
|
public bool CustomerCreditLimitEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Customers have credit days (how long an invoice may stay unpaid — the customer's "age").</summary>
|
||||||
|
public bool CustomerCreditDaysEnabled { get; set; } = true;
|
||||||
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
public int? UpdatedBy { get; set; }
|
public int? UpdatedBy { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public class Vendor
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? TaxReg { get; set; }
|
public string? TaxReg { get; set; }
|
||||||
public string Currency { get; set; } = "LKR";
|
public string Currency { get; set; } = "LKR";
|
||||||
|
|
||||||
|
/// <summary>Mobile number — only recorded while Procurement configuration's vendor contact mode is Mobile.</summary>
|
||||||
|
public string? Mobile { get; set; }
|
||||||
|
/// <summary>Email — only recorded while the vendor contact mode is Email.</summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
/// <summary>Company address — only recorded while vendor address is enabled.</summary>
|
||||||
|
public string? Address { get; set; }
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public int BranchId { get; set; }
|
public int BranchId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which opening balance a run carries. Each kind is posted ONCE for the life of the system, so a
|
||||||
|
/// unique index on the kind is the "already posted" guard (the General Ledger itself does not stop a
|
||||||
|
/// second opening entry). Stored as a string.
|
||||||
|
/// </summary>
|
||||||
|
public enum OpeningBalanceKind
|
||||||
|
{
|
||||||
|
CashBank,
|
||||||
|
Stock
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).</summary>
|
||||||
|
public enum OwnerReportChannel
|
||||||
|
{
|
||||||
|
Sms,
|
||||||
|
Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>State of one owner-report send.</summary>
|
||||||
|
public enum OwnerReportDispatchStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Sent,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ namespace ERPCore.Domain.Enums;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
|
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
|
||||||
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
|
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
|
||||||
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back
|
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it to
|
||||||
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string.
|
/// <see cref="Rejected"/> (FR-PROC-04) - a status of its own, so a rejected order is never mistaken for a fresh
|
||||||
|
/// Draft or one still awaiting approval. A Rejected order is editable like a Draft and goes back through
|
||||||
|
/// approval when resubmitted. Stored as a string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum PurchaseOrderStatus
|
public enum PurchaseOrderStatus
|
||||||
{
|
{
|
||||||
@@ -14,5 +16,6 @@ public enum PurchaseOrderStatus
|
|||||||
PartiallyReceived,
|
PartiallyReceived,
|
||||||
FullyReceived,
|
FullyReceived,
|
||||||
Closed,
|
Closed,
|
||||||
Cancelled
|
Cancelled,
|
||||||
|
Rejected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which single contact detail a deployment records for vendors (Procurement configuration).
|
||||||
|
/// <see cref="None"/> hides the field everywhere. Stored as a string.
|
||||||
|
/// </summary>
|
||||||
|
public enum VendorContactMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Mobile,
|
||||||
|
Email
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ERPCore.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which Adjustment reason codes make a stock adjustment "wastage" (damage, theft, expiry). Wastage is stored as a stock
|
||||||
|
/// adjustment; this is what tells the two apart. Matches the frontend's <c>isWastageReasonCode</c> (lib/api/wastage.ts).
|
||||||
|
/// </summary>
|
||||||
|
public static class WastageReasonCodes
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyCollection<string> Codes = new[] { "DMG", "THEFT", "EXP" };
|
||||||
|
|
||||||
|
public static bool IsWastage(string? reasonCode) =>
|
||||||
|
reasonCode is not null && Codes.Contains(reasonCode.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int D
|
|||||||
public sealed record ApprovalConfigDto(
|
public sealed record ApprovalConfigDto(
|
||||||
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
|
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
|
||||||
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
|
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
|
||||||
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
|
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt, int WastageLevels = 0);
|
||||||
|
|
||||||
public sealed class UpdateApprovalConfigRequest
|
public sealed class UpdateApprovalConfigRequest
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,11 @@ public sealed class UpdateApprovalConfigRequest
|
|||||||
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
|
||||||
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
|
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
|
||||||
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
|
||||||
public List<string> Level2RoleCodes { get; set; } = new();
|
/// <summary>Roles allowed to give the second approval; empty = anyone with the Approve action.</summary>
|
||||||
|
public List<int> Level2RoleIds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>0 = wastage posts on save, 2 = two approvals first. Null keeps the stored value (an older client that doesn't know the setting).</summary>
|
||||||
|
[Range(0, 2)] public int? WastageLevels { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ApproveRequest
|
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; }
|
|
||||||
}
|
|
||||||
@@ -44,11 +44,22 @@ public sealed class DeletedAuthHexUser
|
|||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
|
||||||
|
public sealed class AdminUpdateUserRequest
|
||||||
|
{
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? MobileNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class RegisterRequest
|
public sealed class RegisterRequest
|
||||||
{
|
{
|
||||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
[Required] public Guid RoleId { get; set; }
|
/// <summary>Ignored for self-registration and never taken from a caller: the ERP assigns roles itself. Sent to AuthHex only
|
||||||
|
/// while the old AuthHex (which still demands a role id) is deployed — see <c>AuthHex:LegacyRoleId</c>.</summary>
|
||||||
|
public Guid? RoleId { get; set; }
|
||||||
[Required] public Guid UserTypeId { get; set; }
|
[Required] public Guid UserTypeId { get; set; }
|
||||||
public string? Fullname { get; set; }
|
public string? Fullname { get; set; }
|
||||||
public string? UserName { get; set; }
|
public string? UserName { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>Access configuration resource: which role a person gets when they have none.</summary>
|
||||||
|
public sealed record AccessConfigDto(int? DefaultRoleId, string? DefaultRoleName, DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>Full replacement. <c>null</c> clears the default (people without a role then have no permissions at all).</summary>
|
||||||
|
public sealed class UpdateAccessConfigRequest
|
||||||
|
{
|
||||||
|
[Range(1, int.MaxValue)] public int? DefaultRoleId { get; set; }
|
||||||
|
}
|
||||||
@@ -47,4 +47,6 @@ public sealed class UpdateGlAccountConfigRequest
|
|||||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
||||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
||||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
||||||
|
/// <summary>Optional so an older client that doesn't know this role can't blank it out: null keeps the stored code.</summary>
|
||||||
|
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccruedExpensesCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).</summary>
|
||||||
|
/// <param name="SendAtLocalTime">"HH:mm" in <paramref name="TimeZone"/>.</param>
|
||||||
|
public sealed record OwnerReportConfigDto(
|
||||||
|
bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
|
||||||
|
IReadOnlyList<OwnerReportRecipientDto> Recipients, string TimeZone, DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
|
public sealed record OwnerReportRecipientDto(int RecipientId, string Name, string? Mobile, string? Email, bool IsActive);
|
||||||
|
|
||||||
|
public sealed class UpdateOwnerReportConfigRequest
|
||||||
|
{
|
||||||
|
[Required] public bool? Enabled { get; set; }
|
||||||
|
[Required] public bool? SmsEnabled { get; set; }
|
||||||
|
[Required] public bool? EmailEnabled { get; set; }
|
||||||
|
[Required] public bool? IncludeBranchBreakdown { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"HH:mm", 24-hour.</summary>
|
||||||
|
[Required, RegularExpression(@"^([01]\d|2[0-3]):[0-5]\d$", ErrorMessage = "Send time must be HH:mm (24-hour).")]
|
||||||
|
public string SendAtLocalTime { get; set; } = "21:00";
|
||||||
|
|
||||||
|
/// <summary>The full list — recipients not in it are removed. At most 5.</summary>
|
||||||
|
[Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
|
||||||
|
public List<OwnerReportRecipientInput> Recipients { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OwnerReportRecipientInput
|
||||||
|
{
|
||||||
|
/// <summary>Existing recipient to update; null adds a new one.</summary>
|
||||||
|
public int? RecipientId { get; set; }
|
||||||
|
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
|
||||||
|
[StringLength(20)] public string? Mobile { get; set; }
|
||||||
|
[StringLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The report as it would be sent for <paramref name="Date"/>.</summary>
|
||||||
|
public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
|
||||||
|
|
||||||
|
public sealed class OwnerReportTestRequest
|
||||||
|
{
|
||||||
|
/// <summary>Day to report; null = today in the report's time zone.</summary>
|
||||||
|
public DateOnly? Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OwnerReportSendResultDto(string Channel, string RecipientName, string Destination, bool Ok, string? Error);
|
||||||
|
|
||||||
|
public sealed record OwnerReportDispatchDto(
|
||||||
|
long DispatchId, DateOnly BusinessDate, string Channel, string RecipientName, string Destination,
|
||||||
|
string Status, int Attempts, string? Error, bool IsTest, DateTime CreatedAt, DateTime? SentAt);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
|
|||||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||||
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||||
bool CustomWarrantyPeriodsEnabled,
|
bool CustomWarrantyPeriodsEnabled,
|
||||||
|
bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
|
||||||
DateTime? UpdatedAt, int? UpdatedBy);
|
DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
|
|||||||
[Required] public bool? CustomVariantsEnabled { get; set; }
|
[Required] public bool? CustomVariantsEnabled { get; set; }
|
||||||
[Required] public bool? ItemImagesEnabled { get; set; }
|
[Required] public bool? ItemImagesEnabled { get; set; }
|
||||||
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
|
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
|
||||||
|
[Required] public bool? StockNatureEnabled { get; set; }
|
||||||
|
[Required] public bool? TrackingModeEnabled { get; set; }
|
||||||
|
[Required] public bool? LowStockAlertsEnabled { get; set; }
|
||||||
|
[Required] public bool? AlertWarehouseEnabled { 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>
|
/// the generic cash customer (null until the option has been switched on at least once).</summary>
|
||||||
public sealed record SalesConfigDto(
|
public sealed record SalesConfigDto(
|
||||||
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
|
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
|
||||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
|
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy,
|
||||||
|
bool CustomerPhoneEnabled = true, bool CustomerEmailEnabled = true, bool CustomerAddressEnabled = true,
|
||||||
|
bool CustomerCityEnabled = true, bool CustomerCountryEnabled = true,
|
||||||
|
bool CustomerCreditLimitEnabled = true, bool CustomerCreditDaysEnabled = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
@@ -22,4 +25,11 @@ public sealed class UpdateSalesConfigRequest
|
|||||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||||
public int? DefaultWarehouseId { get; set; }
|
public int? DefaultWarehouseId { get; set; }
|
||||||
|
[Required] public bool? CustomerPhoneEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerEmailEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerAddressEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerCityEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerCountryEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerCreditLimitEnabled { get; set; }
|
||||||
|
[Required] public bool? CustomerCreditDaysEnabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ERPCore.Dtos.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every dataset behind the dashboard graphs, in one computed-on-read object (same idea as
|
||||||
|
/// <see cref="DashboardStatsDto"/>). Time-based sets follow the From-To period the dashboard filter sends;
|
||||||
|
/// "snapshot" sets (ageing, batches expiring, low stock, warranties) describe what is open now.
|
||||||
|
/// Purchases-vs-sales by month and stock value by warehouse are already in <see cref="DashboardStatsDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DashboardChartsDto(
|
||||||
|
// Sales
|
||||||
|
IReadOnlyList<DailySalesPointDto> DailySales,
|
||||||
|
IReadOnlyList<ChartPointDto> PaymentSplit,
|
||||||
|
IReadOnlyList<ChartPointDto> TopItems,
|
||||||
|
IReadOnlyList<ChartPointDto> SalesByCategory,
|
||||||
|
IReadOnlyList<ChartPointDto> SalesByCustomer,
|
||||||
|
IReadOnlyList<DocumentStatusDto> DocumentStatus,
|
||||||
|
// Money
|
||||||
|
IReadOnlyList<ChartPointDto> CashInHandByDay,
|
||||||
|
IReadOnlyList<ChartPointDto> ReceivablesAgeing,
|
||||||
|
IReadOnlyList<ChartPointDto> PayablesAgeing,
|
||||||
|
IReadOnlyList<ChartPointDto> ExpenseBreakdown,
|
||||||
|
// Purchasing
|
||||||
|
IReadOnlyList<ChartPointDto> TopVendors,
|
||||||
|
// Stock
|
||||||
|
IReadOnlyList<ChartPairDto> StockInVsOut,
|
||||||
|
IReadOnlyList<ChartPointDto> BatchesExpiring,
|
||||||
|
// HR
|
||||||
|
IReadOnlyList<ChartPointDto> AttendanceMix,
|
||||||
|
IReadOnlyList<ChartPointDto> PayrollCostByMonth,
|
||||||
|
IReadOnlyList<ChartPointDto> LeaveStatus,
|
||||||
|
// Service and production
|
||||||
|
IReadOnlyList<ChartPairDto> TicketsOverTime,
|
||||||
|
IReadOnlyList<ChartPointDto> ProductionStatus,
|
||||||
|
IReadOnlyList<ChartPointDto> WarrantyExpiryByMonth);
|
||||||
|
|
||||||
|
/// <summary>One labelled value: a bar, a donut slice or a point on a line.</summary>
|
||||||
|
public sealed record ChartPointDto(string Label, decimal Value);
|
||||||
|
|
||||||
|
/// <summary>Two values under one label (stock in/out by warehouse, tickets opened/resolved by day).</summary>
|
||||||
|
public sealed record ChartPairDto(string Label, decimal First, decimal Second);
|
||||||
|
|
||||||
|
/// <summary>One day of the Daily Sales, Gross Profit and Expenses line chart.</summary>
|
||||||
|
public sealed record DailySalesPointDto(string Label, decimal Sales, decimal GrossProfit, decimal Expenses);
|
||||||
|
|
||||||
|
/// <summary>Draft / Posted / Cancelled counts for one kind of document.</summary>
|
||||||
|
public sealed record DocumentStatusDto(string Doc, int Draft, int Posted, int Cancelled);
|
||||||
@@ -10,6 +10,9 @@ namespace ERPCore.Dtos.Dashboard;
|
|||||||
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no
|
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no
|
||||||
/// aggregation needed. A single computed-on-read object, not a stored entity — same as
|
/// aggregation needed. A single computed-on-read object, not a stored entity — same as
|
||||||
/// reorder alerts (docs/11 §5.7).
|
/// reorder alerts (docs/11 §5.7).
|
||||||
|
/// Most counts are a snapshot of what is open right now. The few that describe activity
|
||||||
|
/// (sales returns, bundle sales, bank deposits, employees present) are counted inside the
|
||||||
|
/// optional From-To period the dashboard filter sends (default: today).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record DashboardStatsDto(
|
public sealed record DashboardStatsDto(
|
||||||
int LowStockAlerts,
|
int LowStockAlerts,
|
||||||
@@ -24,7 +27,37 @@ public sealed record DashboardStatsDto(
|
|||||||
int OpenRfqs,
|
int OpenRfqs,
|
||||||
int PendingLeaveRequests,
|
int PendingLeaveRequests,
|
||||||
int OverdueSalesInvoices,
|
int OverdueSalesInvoices,
|
||||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales,
|
||||||
|
// Stock & warehouse
|
||||||
|
int BatchesExpiringSoon,
|
||||||
|
int TransfersInTransit,
|
||||||
|
int DraftStockAdjustments,
|
||||||
|
int WastagePendingApproval,
|
||||||
|
// Approvals
|
||||||
|
int PendingApprovals,
|
||||||
|
int PriceAdjustmentsPending,
|
||||||
|
// Sales (SalesReturns and BundleSales are counted inside the From-To period)
|
||||||
|
int DraftSalesInvoices,
|
||||||
|
int DraftSalesSlips,
|
||||||
|
int SalesReturns,
|
||||||
|
int BundleSales,
|
||||||
|
// Purchasing
|
||||||
|
int PurchaseReturnsPending,
|
||||||
|
int UnpaidPurchaseInvoices,
|
||||||
|
// Money
|
||||||
|
decimal CashInHand,
|
||||||
|
decimal BankDeposits,
|
||||||
|
// Service & warranty
|
||||||
|
int OpenSupportTickets,
|
||||||
|
int WarrantiesExpiringSoon,
|
||||||
|
int WarrantyClaimsOpen,
|
||||||
|
// Production
|
||||||
|
int ProductionRunsInProgress,
|
||||||
|
// HR
|
||||||
|
int PayrollRunsPending,
|
||||||
|
int AttendanceBatchesPending,
|
||||||
|
int EmployeesPresent,
|
||||||
|
int TotalActiveEmployees);
|
||||||
|
|
||||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public sealed record GrnDto(
|
|||||||
public sealed record GrnSummaryDto(
|
public sealed record GrnSummaryDto(
|
||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
||||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
int? PurchaseInvoiceId, string? PurchaseInvoiceNo,
|
||||||
|
string? PoDocNo = null, Dtos.Procurement.PoVendorDto? Vendor = null);
|
||||||
|
|
||||||
public sealed record CreatedLayerDto(
|
public sealed record CreatedLayerDto(
|
||||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||||
@@ -93,7 +94,8 @@ public sealed class CreateGrnRequest
|
|||||||
public int? VendorId { get; set; }
|
public int? VendorId { get; set; }
|
||||||
[Required] public int WarehouseId { get; set; }
|
[Required] public int WarehouseId { get; set; }
|
||||||
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
|
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
|
||||||
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
|
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).
|
||||||
|
/// Ignored when <see cref="PoId"/> is set: a GRN against a purchase order always belongs to that order's branch.</summary>
|
||||||
[Required] public int BranchId { get; set; }
|
[Required] public int BranchId { get; set; }
|
||||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.OpeningBalances;
|
||||||
|
|
||||||
|
/// <summary>One cash or bank account as the opening-balance screen shows it. <c>Amount</c> is filled only once posted.</summary>
|
||||||
|
public sealed record OpeningCashBankAccountDto(
|
||||||
|
string AccountType, string AccountName, string? BankName, string? AccountNumber,
|
||||||
|
string GlAccountCode, string CurrencyCode, decimal? Amount);
|
||||||
|
|
||||||
|
public sealed record OpeningBalanceLineDto(string AccountCode, string AccountName, decimal Amount);
|
||||||
|
|
||||||
|
/// <summary>A posted opening balance (the record of the one-time entry).</summary>
|
||||||
|
public sealed record OpeningBalanceRunDto(
|
||||||
|
int OpeningBalanceRunId, string Kind, DateOnly CutoverDate, string? GlJournalNo,
|
||||||
|
decimal TotalAmount, string Currency, DateTime PostedAt, int PostedBy,
|
||||||
|
IReadOnlyList<OpeningBalanceLineDto> Lines);
|
||||||
|
|
||||||
|
/// <summary>The cash/bank screen's state: the accounts to enter amounts for, or the posted record once done.</summary>
|
||||||
|
public sealed record CashBankOpeningStateDto(
|
||||||
|
OpeningBalanceRunDto? Posted, IReadOnlyList<OpeningCashBankAccountDto> Accounts, string BaseCurrency);
|
||||||
|
|
||||||
|
public sealed class OpeningBalanceLineInput
|
||||||
|
{
|
||||||
|
/// <summary>The GL account code of a cash or bank account, as returned by the state endpoint.</summary>
|
||||||
|
[Required, StringLength(20)] public string GlAccountCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Opening balance, at most 2 decimals. Positive = money held; negative = an overdraft. Zero lines are ignored.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PostCashBankOpeningRequest
|
||||||
|
{
|
||||||
|
/// <summary>The opening date (the GL entry date). Must fall in an open accounting period and not be in the future.</summary>
|
||||||
|
[Required] public DateOnly? CutoverDate { get; set; }
|
||||||
|
|
||||||
|
[Required, MinLength(1)] public List<OpeningBalanceLineInput> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- opening stock
|
||||||
|
|
||||||
|
public sealed class OpeningStockLineInput
|
||||||
|
{
|
||||||
|
[Required] public int ItemId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Quantity in the item's base UOM; more than 0, at most 4 decimals.</summary>
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Cost of ONE base unit, 0 or more, at most 4 decimals — this becomes the FIFO layer's cost.</summary>
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PostOpeningStockRequest
|
||||||
|
{
|
||||||
|
/// <summary>The opening date (the stock layers' receipt date and the GL entry date). Not in the future.</summary>
|
||||||
|
[Required] public DateOnly? CutoverDate { get; set; }
|
||||||
|
|
||||||
|
[Required, MinLength(1), MaxLength(500)] public List<OpeningStockLineInput> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OpeningStockLineDto(
|
||||||
|
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseCode,
|
||||||
|
decimal Qty, decimal UnitCost, decimal Value);
|
||||||
|
|
||||||
|
public sealed record OpeningStockRunSummaryDto(
|
||||||
|
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
|
||||||
|
DateTime PostedAt, int PostedBy);
|
||||||
|
|
||||||
|
public sealed record OpeningStockRunDto(
|
||||||
|
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
|
||||||
|
DateTime PostedAt, int PostedBy, IReadOnlyList<OpeningStockLineDto> Lines);
|
||||||
@@ -5,9 +5,14 @@ namespace ERPCore.Dtos.Procurement;
|
|||||||
|
|
||||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary><c>Tax</c> is the rate (0..1); <c>TaxAmount</c> the stored amount for the line.</summary>
|
||||||
public sealed record PoLineDto(
|
public sealed record PoLineDto(
|
||||||
int PoLineId, int ItemId, int WarehouseId,
|
int PoLineId, int ItemId, int WarehouseId,
|
||||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived, decimal TaxAmount = 0);
|
||||||
|
|
||||||
|
/// <summary>Vendor details shown on a purchase order. <c>Mobile</c>/<c>Email</c>/<c>Address</c> are null
|
||||||
|
/// unless the matching Procurement configuration switch is on.</summary>
|
||||||
|
public sealed record PoVendorDto(int VendorId, string Code, string Name, string? Mobile, string? Email, string? Address);
|
||||||
|
|
||||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||||
|
|
||||||
@@ -15,11 +20,12 @@ public sealed record PurchaseOrderDto(
|
|||||||
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
|
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
|
||||||
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
|
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
|
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines, PoVendorDto? Vendor = null);
|
||||||
|
|
||||||
public sealed record PurchaseOrderSummaryDto(
|
public sealed record PurchaseOrderSummaryDto(
|
||||||
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
|
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
|
||||||
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals);
|
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals,
|
||||||
|
PoVendorDto? Vendor = null);
|
||||||
|
|
||||||
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
|
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ namespace ERPCore.Dtos.Rbac;
|
|||||||
/// entirely rather than merely restricting them by role.</summary>
|
/// entirely rather than merely restricting them by role.</summary>
|
||||||
public sealed record MeResponseDto(
|
public sealed record MeResponseDto(
|
||||||
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
|
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
|
||||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
|
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
|
||||||
|
int? RoleId = null, bool HasRole = false,
|
||||||
|
IReadOnlyDictionary<string, int>? Permissions = null);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Rbac;
|
|||||||
|
|
||||||
public sealed record RoleDto(
|
public sealed record RoleDto(
|
||||||
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
|
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
DateTime CreatedAt, DateTime? UpdatedAt, bool IsAdmin = false);
|
||||||
|
|
||||||
public sealed class CreateRoleRequest
|
public sealed class CreateRoleRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
|
|||||||
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
||||||
|
|
||||||
public sealed record BundleSaleTemplateDto(
|
public sealed record BundleSaleTemplateDto(
|
||||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
|
||||||
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
|
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
|
||||||
|
|
||||||
public sealed record BundleSaleTemplateSummaryDto(
|
public sealed record BundleSaleTemplateSummaryDto(
|
||||||
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
|
||||||
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
public sealed record BundleSalePostingIssueDto(
|
public sealed record BundleSalePostingIssueDto(
|
||||||
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
|
|||||||
{
|
{
|
||||||
[Required] public string TemplateName { get; set; } = string.Empty;
|
[Required] public string TemplateName { get; set; } = string.Empty;
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
|
||||||
|
/// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
|
||||||
|
/// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
|
||||||
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto(
|
|||||||
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||||
|
|
||||||
|
/// <summary>Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page.</summary>
|
||||||
|
public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount);
|
||||||
|
|
||||||
public sealed record SalesInvoiceSummaryDto(
|
public sealed record SalesInvoiceSummaryDto(
|
||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||||
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public sealed record StockOnHandDto(
|
|||||||
public sealed record StockLedgerRowDto(
|
public sealed record StockLedgerRowDto(
|
||||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
|
||||||
|
string? ItemSku = null, string? ItemName = null, string? CategoryName = null, string? BrandName = null);
|
||||||
|
|
||||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||||
|
|||||||
@@ -67,5 +67,27 @@ public sealed class UpdateUserStatusRequest
|
|||||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edits an existing user's identity fields in both backends: the local shadow row
|
||||||
|
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
|
||||||
|
/// account itself via the admin-only AdminUpdateUser function. A user created before
|
||||||
|
/// AuthHex linking (AuthUserId null) only updates locally.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateManagedUserRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
|
||||||
|
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
|
||||||
|
public string? MobileNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
|
||||||
|
|
||||||
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
||||||
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
||||||
|
|
||||||
|
/// <summary>Outcome of the one-time copy of each user's role from AuthHex into the ERP's users table.</summary>
|
||||||
|
public sealed record BackfillRolesResultDto(
|
||||||
|
int Examined, int Assigned, int AlreadyHadRole, IReadOnlyList<BackfillRolesIssueDto> Issues);
|
||||||
|
|
||||||
|
/// <summary>A user the backfill could not give a role, and why — for an administrator to assign by hand.</summary>
|
||||||
|
public sealed record BackfillRolesIssueDto(int UserId, string Username, string Reason);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace ERPCore.Dtos.Vendors;
|
|||||||
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||||
public sealed record VendorDto(
|
public sealed record VendorDto(
|
||||||
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
|
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
|
||||||
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
|
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
|
string? Mobile = null, string? Email = null, string? Address = null);
|
||||||
|
|
||||||
public sealed class CreateVendorRequest
|
public sealed class CreateVendorRequest
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,12 @@ public sealed class CreateVendorRequest
|
|||||||
[StringLength(50)] public string? Terms { get; set; }
|
[StringLength(50)] public string? Terms { get; set; }
|
||||||
[StringLength(50)] public string? TaxReg { get; set; }
|
[StringLength(50)] public string? TaxReg { get; set; }
|
||||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||||
|
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(20)] public string? Mobile { get; set; }
|
||||||
|
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(200)] public string? Email { get; set; }
|
||||||
|
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(500)] public string? Address { get; set; }
|
||||||
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
|
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
|
||||||
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
|
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
|
||||||
public int BranchId { get; set; }
|
public int BranchId { get; set; }
|
||||||
@@ -27,6 +34,12 @@ public sealed class UpdateVendorRequest
|
|||||||
[StringLength(50)] public string? Terms { get; set; }
|
[StringLength(50)] public string? Terms { get; set; }
|
||||||
[StringLength(50)] public string? TaxReg { get; set; }
|
[StringLength(50)] public string? TaxReg { get; set; }
|
||||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||||
|
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(20)] public string? Mobile { get; set; }
|
||||||
|
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(200)] public string? Email { get; set; }
|
||||||
|
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
|
||||||
|
[StringLength(500)] public string? Address { get; set; }
|
||||||
public int BranchId { get; set; }
|
public int BranchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
|
|||||||
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
|
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
|
||||||
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
|
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
|
||||||
|
|
||||||
|
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
|
||||||
|
/// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.</summary>
|
||||||
|
public static class PhoneRules
|
||||||
|
{
|
||||||
|
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$";
|
||||||
|
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567), or 94 followed by 9 digits (94771234567 or +94771234567).";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class CreateWarehouseRequest
|
public sealed class CreateWarehouseRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(300)] public string? Address { get; set; }
|
[StringLength(300)] public string? Address { get; set; }
|
||||||
[StringLength(30)] public string? Phone { get; set; }
|
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
|
||||||
[Required] public int BranchId { get; set; }
|
[Required] public int BranchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
|
|||||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(300)] public string? Address { get; set; }
|
[StringLength(300)] public string? Address { get; set; }
|
||||||
[StringLength(30)] public string? Phone { get; set; }
|
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
|
||||||
[Required] public int BranchId { get; set; }
|
[Required] public int BranchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
// ---- UserManager --------------------------------------------------
|
// ---- UserManager --------------------------------------------------
|
||||||
|
|
||||||
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
||||||
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
{
|
||||||
|
// Until AuthHex stops requiring a role (its roles are being retired), give it a fixed placeholder so
|
||||||
|
// registration keeps working. The ERP never reads this value; the person's real role is users.RoleId.
|
||||||
|
request.RoleId ??= Guid.TryParse(_config["AuthHex:LegacyRoleId"], out var legacy) ? legacy : null;
|
||||||
|
return CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||||
@@ -93,16 +98,35 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
||||||
|
|
||||||
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
|
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
|
||||||
{
|
=> CallAsync<DeleteAuthHexUserResult>(
|
||||||
var adminApiKey = _config["AuthHex:AdminApiKey"];
|
|
||||||
if (string.IsNullOrEmpty(adminApiKey))
|
|
||||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
|
|
||||||
|
|
||||||
return CallAsync<DeleteAuthHexUserResult>(
|
|
||||||
"user", "deleteUser",
|
"user", "deleteUser",
|
||||||
new { email, mobileNumber, nic, userName = username },
|
new { email, mobileNumber, nic, userName = username },
|
||||||
null, ct,
|
null, ct,
|
||||||
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey });
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
|
||||||
|
=> CallAsync<UserSummaryDto?>(
|
||||||
|
"user", "AdminUpdateUser",
|
||||||
|
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
|
||||||
|
null, ct,
|
||||||
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
|
||||||
|
=> CallAsync<UserSummaryDto?>(
|
||||||
|
"user", "AdminSetUserStatus",
|
||||||
|
new { userId, isActive },
|
||||||
|
null, ct,
|
||||||
|
extraHeaders: AdminHeaders());
|
||||||
|
|
||||||
|
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
|
||||||
|
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
|
||||||
|
private Dictionary<string, string> AdminHeaders()
|
||||||
|
{
|
||||||
|
var adminApiKey = _config["AuthHex:AdminApiKey"];
|
||||||
|
if (string.IsNullOrEmpty(adminApiKey))
|
||||||
|
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
|
||||||
|
|
||||||
|
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RecoveryManager ------------------------------------------------
|
// ---- RecoveryManager ------------------------------------------------
|
||||||
@@ -130,22 +154,66 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
||||||
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
||||||
|
|
||||||
// ---- RoleManager --------------------------------------------------
|
// ---- Notify -------------------------------------------------------------
|
||||||
|
|
||||||
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
|
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
|
||||||
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
|
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
|
||||||
|
|
||||||
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
|
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
|
||||||
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
|
=> NotifyAsync("sms", new { to, text }, ct);
|
||||||
|
|
||||||
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
|
/// <summary>
|
||||||
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
|
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
|
||||||
|
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<AuthHexNotifyResult>> NotifyAsync(string channel, object body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
|
||||||
|
foreach (var (key, value) in AdminHeaders())
|
||||||
|
httpRequest.Headers.Add(key, value);
|
||||||
|
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
|
||||||
|
|
||||||
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
|
HttpResponseMessage httpResponse;
|
||||||
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
|
try
|
||||||
|
{
|
||||||
|
httpResponse = await _http.SendAsync(httpRequest, ct);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
|
NotifyResponse? payload = null;
|
||||||
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
|
try
|
||||||
|
{
|
||||||
|
payload = await httpResponse.Content.ReadFromJsonAsync<NotifyResponse>(JsonOptions, ct);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Fall through — reported below with the HTTP status.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
|
||||||
|
{
|
||||||
|
var message = payload?.Message
|
||||||
|
?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
|
||||||
|
? "The authentication service does not support sending messages yet (no /api/notify)."
|
||||||
|
: $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
|
||||||
|
throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NotifyResponse
|
||||||
|
{
|
||||||
|
public List<AuthHexNotifyResult>? Results { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Transport --------------------------------------------------------
|
// ---- Transport --------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ public interface IAuthHexClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
|
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Admin-only: updates another user's identity fields (not self-service —
|
||||||
|
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
|
||||||
|
/// Gated by AuthHex:AdminApiKey.</summary>
|
||||||
|
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
|
||||||
|
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
|
||||||
|
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
|
||||||
|
|
||||||
// RecoveryManager (POST /api/recovery)
|
// RecoveryManager (POST /api/recovery)
|
||||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
||||||
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
||||||
@@ -49,11 +58,11 @@ public interface IAuthHexClient
|
|||||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
||||||
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
||||||
|
|
||||||
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
|
// Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
|
||||||
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
|
// SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
|
||||||
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
|
Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
|
||||||
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
|
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
|
||||||
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
|
|
||||||
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
|
|
||||||
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
|
||||||
|
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
|
||||||
|
|||||||
@@ -22,4 +22,24 @@ public static class AuthHexClaims
|
|||||||
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
|
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string BranchId = "erp:BranchId";
|
public const string BranchId = "erp:BranchId";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local claim: "false" when the deployment has branching switched off (Settings → Branch Configuration), absent or
|
||||||
|
/// "true" otherwise. Lets <see cref="ICurrentBranchContext"/> tell "a branch-less user in a single-branch
|
||||||
|
/// deployment" (sees everything) from "a branch-less user in a multi-branch deployment" (sees no branch's data).
|
||||||
|
/// </summary>
|
||||||
|
public const string BranchingEnabled = "erp:BranchingEnabled";
|
||||||
|
|
||||||
|
// ---- Local claims: the ERP's own authorization identity ---------------------------------------------------
|
||||||
|
// Injected by ShadowUserClaimsTransformation from the ERP's users/roles tables on every request. They are NEVER
|
||||||
|
// read from the token: the role belongs to the ERP, AuthHex only says who the person is. The AuthHex RoleId /
|
||||||
|
// RoleCode claims above are used only by the temporary legacy fallback.
|
||||||
|
|
||||||
|
/// <summary>The effective role's id (the user's role, or the default role). Absent when the person has no usable role.</summary>
|
||||||
|
public const string ErpRoleId = "erp:RoleId";
|
||||||
|
public const string ErpRoleCode = "erp:RoleCode";
|
||||||
|
/// <summary>"true" when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>).</summary>
|
||||||
|
public const string ErpIsAdmin = "erp:IsAdmin";
|
||||||
|
/// <summary>Present when the ERP user is inactive; the <c>ErpAccess</c> policy then refuses the request (403).</summary>
|
||||||
|
public const string ErpBlocked = "erp:Blocked";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
|
|||||||
namespace ERPCore.Infra.Auth;
|
namespace ERPCore.Infra.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the audit actor from the current HTTP request's principal
|
/// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
|
||||||
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
|
/// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
|
||||||
/// when the request is unauthenticated (e.g. health checks, seeding).
|
/// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
|
||||||
|
/// unauthenticated (e.g. health checks, seeding).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CurrentUser : ICurrentUser
|
public sealed class CurrentUser : ICurrentUser
|
||||||
{
|
{
|
||||||
@@ -32,9 +33,18 @@ public sealed class CurrentUser : ICurrentUser
|
|||||||
|
|
||||||
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
|
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
|
||||||
|
|
||||||
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
|
public int? RoleId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleId);
|
||||||
|
return int.TryParse(raw, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
|
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleCode);
|
||||||
|
|
||||||
|
public bool IsAdmin => string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpIsAdmin), "true", StringComparison.Ordinal);
|
||||||
|
|
||||||
public int? BranchId
|
public int? BranchId
|
||||||
{
|
{
|
||||||
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
|
|||||||
return int.TryParse(raw, out var id) ? id : null;
|
return int.TryParse(raw, out var id) ? id : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool BranchingEnabled =>
|
||||||
|
!string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchingEnabled), "false", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ namespace ERPCore.Infra.Auth;
|
|||||||
public interface ICurrentBranchContext
|
public interface ICurrentBranchContext
|
||||||
{
|
{
|
||||||
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
|
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
|
||||||
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary>
|
/// admins, unauthenticated/system contexts, and (only when the deployment has branching switched off) people with
|
||||||
|
/// no branch. In a branching deployment, a signed-in non-admin with no linked employee gets
|
||||||
|
/// <see cref="CurrentBranchContext.NoBranchId"/>: a branch that matches no data, so they see none (deny by default).</summary>
|
||||||
int? BranchId { get; }
|
int? BranchId { get; }
|
||||||
|
|
||||||
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
|
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
|
||||||
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
|
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
|
||||||
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
|
/// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
|
||||||
/// already applies for write-path checks.
|
/// <see cref="NoBranchId"/> — they used to fall through unfiltered and could read every branch's records, while the
|
||||||
|
/// write path (<see cref="Services.Interfaces.IBranchAccessService"/>) already refused them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CurrentBranchContext : ICurrentBranchContext
|
public sealed class CurrentBranchContext : ICurrentBranchContext
|
||||||
{
|
{
|
||||||
|
/// <summary>The "branch" of a person who has none in a branching deployment. No row has branch 0, so filters on it match nothing.</summary>
|
||||||
|
public const int NoBranchId = 0;
|
||||||
|
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
|
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
|
||||||
|
|
||||||
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
|
public int? BranchId
|
||||||
|
{
|
||||||
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
|
get
|
||||||
|
{
|
||||||
|
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return null;
|
||||||
|
if (_currentUser.BranchId is { } own) return own;
|
||||||
|
return _currentUser.BranchingEnabled ? NoBranchId : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsActive => BranchId is not null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace ERPCore.Infra.Auth;
|
namespace ERPCore.Infra.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Abstraction over the authenticated principal, used purely as the audit actor
|
/// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
|
||||||
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
|
/// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
|
||||||
/// claim — never from the request body (see 00-CORE §4).
|
/// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICurrentUser
|
public interface ICurrentUser
|
||||||
{
|
{
|
||||||
@@ -20,13 +20,20 @@ public interface ICurrentUser
|
|||||||
/// <summary>True when the request carries an authenticated principal.</summary>
|
/// <summary>True when the request carries an authenticated principal.</summary>
|
||||||
bool IsAuthenticated { get; }
|
bool IsAuthenticated { get; }
|
||||||
|
|
||||||
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
|
/// <summary>
|
||||||
|
/// The id of the person's effective ERP role (their own, else the configured default), read from the ERP's users
|
||||||
|
/// table on each request. Null when they have no usable role (none assigned and no default, or the role is
|
||||||
|
/// inactive) — they then have no permissions.
|
||||||
|
/// </summary>
|
||||||
|
int? RoleId { get; }
|
||||||
|
|
||||||
|
/// <summary>The effective role's code, for display and logging. Never use it to make a permission decision.</summary>
|
||||||
string? RoleCode { get; }
|
string? RoleCode { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
|
/// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
|
||||||
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
|
/// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
|
||||||
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
/// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsAdmin { get; }
|
bool IsAdmin { get; }
|
||||||
|
|
||||||
@@ -37,4 +44,10 @@ public interface ICurrentUser
|
|||||||
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
|
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
int? BranchId { get; }
|
int? BranchId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the deployment uses branches at all (Settings → Branch Configuration). When off, every document lives on the
|
||||||
|
/// one default branch and nobody is branch-scoped. Defaults to true — the safe side — for contexts that don't say.
|
||||||
|
/// </summary>
|
||||||
|
bool BranchingEnabled => true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ public static class JwtAuthExtensions
|
|||||||
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
|
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
|
||||||
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
|
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
|
||||||
var requiredUserType = config["Auth:RequiredUserTypeCode"];
|
var requiredUserType = config["Auth:RequiredUserTypeCode"];
|
||||||
var requiredRole = config["Auth:RequiredRoleCode"];
|
|
||||||
|
|
||||||
// AuthHex publishes no JWKS; the RSA public key is configured statically.
|
// AuthHex publishes no JWKS; the RSA public key is configured statically.
|
||||||
var rsa = RSA.Create();
|
var rsa = RSA.Create();
|
||||||
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
|
|||||||
options.AddPolicy(ErpAccessPolicy, policy =>
|
options.AddPolicy(ErpAccessPolicy, policy =>
|
||||||
{
|
{
|
||||||
policy.RequireAuthenticatedUser();
|
policy.RequireAuthenticatedUser();
|
||||||
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
|
// Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
|
||||||
// shared IdP). Empty config = require a valid ERP token only.
|
// require a valid ERP token only. (There is no role door gate any more: roles are the ERP's own,
|
||||||
|
// and are enforced per endpoint by the Nav policies.)
|
||||||
if (!string.IsNullOrWhiteSpace(requiredUserType))
|
if (!string.IsNullOrWhiteSpace(requiredUserType))
|
||||||
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
|
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
|
||||||
if (!string.IsNullOrWhiteSpace(requiredRole))
|
// An inactive ERP user is refused outright, even while their AuthHex token is still valid.
|
||||||
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
|
policy.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == AuthHexClaims.ErpBlocked));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ public sealed class NavRequirement : IAuthorizationRequirement
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
|
/// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
|
||||||
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
|
/// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
|
||||||
/// and — when the policy names one — the specific action within it too.</summary>
|
/// else needs the role's permission and — when the policy names one — the specific action within it too.</summary>
|
||||||
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
|
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
|
||||||
{
|
{
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
|
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleId, requirement.NavCode)) return;
|
||||||
|
|
||||||
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
|
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleId, requirement.NavCode, requirement.Action.Value))
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace ERPCore.Infra.Auth;
|
namespace ERPCore.Infra.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
|
/// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
|
||||||
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
|
/// <para>
|
||||||
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
|
/// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
|
||||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
|
/// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
|
||||||
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
|
/// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
|
||||||
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
|
/// <see cref="Role"/> and branch, then injects:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>NameIdentifier</c> — the local int user id (audit actor);</item>
|
||||||
|
/// <item><see cref="AuthHexClaims.ErpRoleId"/> / <see cref="AuthHexClaims.ErpRoleCode"/> / <see cref="AuthHexClaims.ErpIsAdmin"/> — the effective role;</item>
|
||||||
|
/// <item><see cref="AuthHexClaims.BranchId"/> — the branch of the linked employee;</item>
|
||||||
|
/// <item><see cref="AuthHexClaims.ErpBlocked"/> — when the ERP user is inactive (the door policy then answers 403).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Because the role is read from the database on every request, changing someone's role or a role's permissions
|
||||||
|
/// takes effect on their very next request — no re-login, no stale token.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Effective role</b> = the user's own role if it is active; otherwise nothing. A user with no role gets one
|
||||||
|
/// assigned and saved: during the rollout, the role named by a still-present AuthHex <c>RoleCode</c> claim
|
||||||
|
/// (<c>Auth:LegacyRoleClaimFallback</c>, on by default, to be switched off once every user has a role); else the
|
||||||
|
/// configured default role (<see cref="AccessConfig.DefaultRoleId"/>). With neither, they have no permissions.
|
||||||
|
/// </para>
|
||||||
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
|
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||||
{
|
{
|
||||||
private readonly ErpDbContext _db;
|
private readonly ErpDbContext _db;
|
||||||
|
private readonly bool _legacyRoleFallback;
|
||||||
|
|
||||||
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
|
public ShadowUserClaimsTransformation(ErpDbContext db, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
// Default ON: the safe choice while some users may still have no local role.
|
||||||
|
_legacyRoleFallback = !bool.TryParse(configuration["Auth:LegacyRoleClaimFallback"], out var enabled) || enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record UserRow(
|
||||||
|
int UserId, EntityStatus Status, int? RoleId, string? RoleCode, EntityStatus? RoleStatus, bool RoleIsAdmin, int? BranchId, bool? BranchingEnabled);
|
||||||
|
|
||||||
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
|||||||
if (!Guid.TryParse(raw, out var authUserId))
|
if (!Guid.TryParse(raw, out var authUserId))
|
||||||
return principal; // no mappable identity → CurrentUser falls back to system
|
return principal; // no mappable identity → CurrentUser falls back to system
|
||||||
|
|
||||||
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
|
var row = await LoadAsync(authUserId)
|
||||||
var localId = await ResolveOrProvisionAsync(authUserId, nic);
|
?? await ProvisionAsync(authUserId, principal.FindFirstValue(AuthHexClaims.Nic));
|
||||||
|
|
||||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, row.UserId.ToString()));
|
||||||
|
if (row.BranchId is not null)
|
||||||
|
identity.AddClaim(new Claim(AuthHexClaims.BranchId, row.BranchId.Value.ToString()));
|
||||||
|
if (row.BranchingEnabled == false)
|
||||||
|
identity.AddClaim(new Claim(AuthHexClaims.BranchingEnabled, "false"));
|
||||||
|
|
||||||
var branchId = await _db.Employees.AsNoTracking()
|
if (row.Status != EntityStatus.Active)
|
||||||
.Where(e => e.UserId == localId)
|
{
|
||||||
.Select(e => (int?)e.BranchId)
|
identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
|
||||||
.FirstOrDefaultAsync();
|
return principal;
|
||||||
if (branchId is not null)
|
}
|
||||||
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
|
|
||||||
|
// No role yet? Assign one (and save it) so the Users screen shows what they actually have.
|
||||||
|
if (row.RoleId is null)
|
||||||
|
row = await AssignMissingRoleAsync(row, principal.FindFirstValue(AuthHexClaims.RoleCode));
|
||||||
|
|
||||||
|
// An inactive role grants nothing: the person is treated as having no role until it is reactivated or changed.
|
||||||
|
if (row.RoleId is not null && row.RoleStatus == EntityStatus.Active)
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleId, row.RoleId.Value.ToString()));
|
||||||
|
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleCode, row.RoleCode ?? string.Empty));
|
||||||
|
identity.AddClaim(new Claim(AuthHexClaims.ErpIsAdmin, row.RoleIsAdmin ? "true" : "false"));
|
||||||
|
}
|
||||||
|
|
||||||
return principal;
|
return principal;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
|
/// <summary>The user with its role and branch, in ONE round trip.</summary>
|
||||||
{
|
private Task<UserRow?> LoadAsync(Guid authUserId) =>
|
||||||
var existing = await _db.Users.AsNoTracking()
|
_db.Users.AsNoTracking()
|
||||||
.Where(u => u.AuthUserId == authUserId)
|
.Where(u => u.AuthUserId == authUserId)
|
||||||
.Select(u => u.UserId)
|
.Select(u => new UserRow(
|
||||||
|
u.UserId, u.Status, u.RoleId,
|
||||||
|
u.Role != null ? u.Role.Code : null,
|
||||||
|
u.Role != null ? (EntityStatus?)u.Role.Status : null,
|
||||||
|
u.Role != null && u.Role.IsAdmin,
|
||||||
|
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
|
||||||
|
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (existing != 0) return existing;
|
|
||||||
|
|
||||||
|
private Task<UserRow?> LoadAsync(int userId) =>
|
||||||
|
_db.Users.AsNoTracking()
|
||||||
|
.Where(u => u.UserId == userId)
|
||||||
|
.Select(u => new UserRow(
|
||||||
|
u.UserId, u.Status, u.RoleId,
|
||||||
|
u.Role != null ? u.Role.Code : null,
|
||||||
|
u.Role != null ? (EntityStatus?)u.Role.Status : null,
|
||||||
|
u.Role != null && u.Role.IsAdmin,
|
||||||
|
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
|
||||||
|
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
private async Task<UserRow> ProvisionAsync(Guid authUserId, string? nic)
|
||||||
|
{
|
||||||
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
|
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
|||||||
{
|
{
|
||||||
_db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return user.UserId;
|
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
{
|
{
|
||||||
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
|
// Lost a race (unique auth_user_id) — the row now exists; re-read it below.
|
||||||
_db.Entry(user).State = EntityState.Detached;
|
_db.Entry(user).State = EntityState.Detached;
|
||||||
return await _db.Users.AsNoTracking()
|
}
|
||||||
.Where(u => u.AuthUserId == authUserId)
|
|
||||||
.Select(u => u.UserId)
|
return await LoadAsync(authUserId) ?? throw new InvalidOperationException("Could not provision the ERP user.");
|
||||||
.FirstAsync();
|
}
|
||||||
|
|
||||||
|
private async Task<UserRow> AssignMissingRoleAsync(UserRow row, string? legacyRoleCode)
|
||||||
|
{
|
||||||
|
var role = null as Role;
|
||||||
|
|
||||||
|
// 1) Rollout safety net: the role the (old) AuthHex token still names — what this person could do until now.
|
||||||
|
if (_legacyRoleFallback && !string.IsNullOrWhiteSpace(legacyRoleCode))
|
||||||
|
{
|
||||||
|
var code = legacyRoleCode.Trim().ToLower();
|
||||||
|
role = await _db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Code.ToLower() == code && r.Status == EntityStatus.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) The configured default role (least privilege by design; never the admin role).
|
||||||
|
if (role is null)
|
||||||
|
{
|
||||||
|
role = await _db.AccessConfig.AsNoTracking()
|
||||||
|
.Where(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRole != null && c.DefaultRole.Status == EntityStatus.Active)
|
||||||
|
.Select(c => c.DefaultRole)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role is null) return row; // no role and no default → no permissions
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Only fill a role that is still empty: one an administrator assigned a moment ago is never overwritten.
|
||||||
|
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.UserId == row.UserId);
|
||||||
|
if (tracked is not null && tracked.RoleId is null)
|
||||||
|
{
|
||||||
|
tracked.RoleId = role.RoleId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else if (tracked?.RoleId is int existing && existing != role.RoleId)
|
||||||
|
{
|
||||||
|
return await LoadAsync(row.UserId) ?? row; // somebody assigned a role meanwhile: use theirs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Not fatal: the request still carries the role for this call; the next request retries the save.
|
||||||
|
_db.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return row with { RoleId = role.RoleId, RoleCode = role.Code, RoleStatus = role.Status, RoleIsAdmin = role.IsAdmin };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,68 @@
|
|||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
namespace ERPCore.Infra.Auth;
|
namespace ERPCore.Infra.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
|
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
|
||||||
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
/// actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
||||||
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
|
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
|
||||||
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
||||||
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
||||||
/// issues alongside the session cookies.
|
/// issues alongside the session cookies.
|
||||||
|
/// <para>
|
||||||
|
/// Every mutating cookie-authenticated endpoint is covered by <see cref="CsrfProtectionFilter"/> (registered globally),
|
||||||
|
/// so this attribute is only needed on an action that must be checked even when the session cookie is absent
|
||||||
|
/// (AuthController's token-in-body endpoints).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||||
{
|
{
|
||||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
{
|
{
|
||||||
var request = context.HttpContext.Request;
|
Enforce(context.HttpContext.Request);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
|
|
||||||
{
|
|
||||||
await next();
|
await next();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Throws a 403 <c>CSRF_TOKEN_MISMATCH</c> unless the request is bearer-authenticated or carries a header matching the CSRF cookie.</summary>
|
||||||
|
public static void Enforce(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) return;
|
||||||
|
|
||||||
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
|
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
|
||||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
||||||
|
|
||||||
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
||||||
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
||||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registered globally: any state-changing request (POST/PUT/PATCH/DELETE) that is authenticated by the browser's
|
||||||
|
/// session cookie must pass the double-submit check. Not applied to safe methods, to anonymous endpoints (login,
|
||||||
|
/// registration — no session to ride on), to actions that already carry <see cref="ValidateCsrfAttribute"/>, or to
|
||||||
|
/// requests with no session cookie at all (nothing for a forged request to ride on; those fail authentication anyway).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CsrfProtectionFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var http = context.HttpContext;
|
||||||
|
var request = http.Request;
|
||||||
|
|
||||||
|
var unsafeMethod = HttpMethods.IsPost(request.Method) || HttpMethods.IsPut(request.Method)
|
||||||
|
|| HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method);
|
||||||
|
var endpoint = http.GetEndpoint();
|
||||||
|
|
||||||
|
if (unsafeMethod
|
||||||
|
&& request.Cookies.ContainsKey(JwtAuthExtensions.AccessTokenCookie)
|
||||||
|
&& endpoint?.Metadata.GetMetadata<IAllowAnonymous>() is null
|
||||||
|
&& endpoint?.Metadata.GetMetadata<ValidateCsrfAttribute>() is null)
|
||||||
|
{
|
||||||
|
ValidateCsrfAttribute.Enforce(request);
|
||||||
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using ERPCore.Infra.Auth.AuthHex;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Messaging;
|
||||||
|
|
||||||
|
/// <summary>Per-recipient outcome of a send.</summary>
|
||||||
|
public sealed record SendResult(string To, bool Ok, string? Error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
|
||||||
|
/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
|
||||||
|
/// Implementations report per-recipient failures in the result and only throw when the whole
|
||||||
|
/// call failed (e.g. AuthHex unreachable).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMessageSender
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
|
||||||
|
public sealed class AuthHexMessageSender : IMessageSender
|
||||||
|
{
|
||||||
|
private readonly IAuthHexClient _authHex;
|
||||||
|
|
||||||
|
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
|
||||||
|
=> (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
|
||||||
|
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
|
||||||
|
/// development and tests, so nothing reaches real phones or inboxes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LogOnlyMessageSender : IMessageSender
|
||||||
|
{
|
||||||
|
private readonly ILogger<LogOnlyMessageSender> _logger;
|
||||||
|
|
||||||
|
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
|
||||||
|
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
|
||||||
|
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>The singleton access-configuration row; the check constraint makes "singleton" a database guarantee.</summary>
|
||||||
|
public sealed class AccessConfigConfiguration : IEntityTypeConfiguration<AccessConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AccessConfig> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("access_config", t =>
|
||||||
|
t.HasCheckConstraint("ck_access_config_singleton", $"`ConfigId` = {AccessConfig.SingletonId}"));
|
||||||
|
|
||||||
|
builder.HasKey(c => c.ConfigId);
|
||||||
|
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||||
|
|
||||||
|
// If the default role is deleted the setting clears itself rather than blocking the delete.
|
||||||
|
builder.HasOne(c => c.DefaultRole)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.DefaultRoleId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(c => c.UpdatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UpdatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<Appro
|
|||||||
|
|
||||||
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
|
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
|
||||||
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
|
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
|
||||||
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
|
builder.Property(c => c.Level2RoleIds).HasMaxLength(500);
|
||||||
|
|
||||||
// Dual approval on by default for both documents (plan §8 recommended defaults).
|
// Dual approval on by default for both documents (plan §8 recommended defaults).
|
||||||
builder.HasData(new ApprovalConfig
|
builder.HasData(new ApprovalConfig
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
|
|||||||
builder.HasIndex(x => x.TemplateCode).IsUnique();
|
builder.HasIndex(x => x.TemplateCode).IsUnique();
|
||||||
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
|
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
|
||||||
builder.Property(x => x.Description).HasMaxLength(1000);
|
builder.Property(x => x.Description).HasMaxLength(1000);
|
||||||
|
builder.Property(x => x.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
|
||||||
builder.Property(x => x.Status)
|
builder.Property(x => x.Status)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
.HasDefaultValue(EntityStatus.Active);
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration<GlAc
|
|||||||
nameof(GlAccountConfig.InventoryGainAccountCode),
|
nameof(GlAccountConfig.InventoryGainAccountCode),
|
||||||
nameof(GlAccountConfig.CogsAccountCode),
|
nameof(GlAccountConfig.CogsAccountCode),
|
||||||
nameof(GlAccountConfig.InventoryLossAccountCode),
|
nameof(GlAccountConfig.InventoryLossAccountCode),
|
||||||
nameof(GlAccountConfig.CashAccountCode)
|
nameof(GlAccountConfig.CashAccountCode),
|
||||||
|
nameof(GlAccountConfig.AccruedExpensesCode)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
|
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
|||||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
||||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
|
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 },
|
||||||
|
// Added with the permission-coverage pass (Phase B): these two sidebar sections had no row, so no role could be
|
||||||
|
// granted them and the sidebar had to bypass the check for everybody.
|
||||||
|
new NavItem { NavItemId = 15, Code = "hrm", Label = "Human Resources", Href = "/dashboard/hrm", SortOrder = 15 },
|
||||||
|
new NavItem { NavItemId = 16, Code = "sales-returns", Label = "Sales Returns", Href = "/dashboard/sales/sales-returns", SortOrder = 16 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>Posted opening balances. The unique index on Kind is the once-only guard.</summary>
|
||||||
|
public sealed class OpeningBalanceRunConfiguration : IEntityTypeConfiguration<OpeningBalanceRun>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OpeningBalanceRun> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("opening_balance_runs");
|
||||||
|
builder.HasKey(x => x.OpeningBalanceRunId);
|
||||||
|
|
||||||
|
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.HasIndex(x => x.Kind);
|
||||||
|
|
||||||
|
// The once-only guard: only kinds that may be posted once set this, so a second attempt hits the unique index.
|
||||||
|
builder.Property(x => x.OnceKey).HasMaxLength(20);
|
||||||
|
builder.HasIndex(x => x.OnceKey).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(x => x.CutoverDate).IsRequired();
|
||||||
|
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||||
|
builder.Property(x => x.TotalAmount).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.Currency).IsRequired().HasMaxLength(3);
|
||||||
|
builder.Property(x => x.PostedAt).IsRequired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpeningBalanceLineConfiguration : IEntityTypeConfiguration<OpeningBalanceLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OpeningBalanceLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("opening_balance_lines");
|
||||||
|
builder.HasKey(x => x.OpeningBalanceLineId);
|
||||||
|
|
||||||
|
builder.Property(x => x.AccountCode).IsRequired().HasMaxLength(20);
|
||||||
|
builder.Property(x => x.AccountName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.Amount).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Run)
|
||||||
|
.WithMany(r => r.Lines)
|
||||||
|
.HasForeignKey(x => x.OpeningBalanceRunId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpeningStockLineConfiguration : IEntityTypeConfiguration<OpeningStockLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OpeningStockLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("opening_stock_lines");
|
||||||
|
builder.HasKey(x => x.OpeningStockLineId);
|
||||||
|
|
||||||
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.UnitCost).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.Value).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Run)
|
||||||
|
.WithMany(r => r.StockLines)
|
||||||
|
.HasForeignKey(x => x.OpeningBalanceRunId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasIndex(x => new { x.ItemId, x.WarehouseId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
|
||||||
|
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OwnerReportConfig> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("owner_report_config", t =>
|
||||||
|
t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
|
||||||
|
|
||||||
|
builder.HasKey(c => c.ConfigId);
|
||||||
|
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
|
||||||
|
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
|
||||||
|
builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
builder.Property(c => c.SendAtLocalTime).IsRequired();
|
||||||
|
|
||||||
|
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(c => c.UpdatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UpdatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration<OwnerReportRecipient>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OwnerReportRecipient> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("owner_report_recipients");
|
||||||
|
builder.HasKey(r => r.RecipientId);
|
||||||
|
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(r => r.Mobile).HasMaxLength(20);
|
||||||
|
builder.Property(r => r.Email).HasMaxLength(200);
|
||||||
|
builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration<OwnerReportDispatch>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("owner_report_dispatches");
|
||||||
|
builder.HasKey(d => d.DispatchId);
|
||||||
|
|
||||||
|
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
|
||||||
|
builder.Property(d => d.Status).HasConversion<string>().HasMaxLength(10).IsRequired();
|
||||||
|
builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
|
||||||
|
builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(d => d.Error).HasMaxLength(1000);
|
||||||
|
builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
|
||||||
|
|
||||||
|
// The "send once" guard: one automatic send per day, channel and recipient. Test sends get
|
||||||
|
// a unique DedupeKey so any number of them can coexist.
|
||||||
|
builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ux_owner_report_dispatch_once");
|
||||||
|
builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
|
||||||
|
builder.HasIndex(d => d.CreatedAt);
|
||||||
|
|
||||||
|
// Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
|
||||||
|
builder.HasOne(d => d.Recipient)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(d => d.RecipientId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user