Compare commits
174 Commits
| 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 | |||
| d0354ed169 | |||
| a788349138 | |||
| 3abcfa6e6f | |||
| 0d3f2fba14 | |||
| 1cddbaf84f | |||
| c9906d24c9 | |||
| a3591d3643 | |||
| c30ff473f5 | |||
| c41aed9a80 | |||
| 614df3065d | |||
| 60652d611e | |||
| 65976c5cd3 | |||
| 3c8837e21e | |||
| 63c8c8457f | |||
| e0601d5a8d | |||
| 9eab6372b3 | |||
| 92f571bbf1 | |||
| b236b2b126 | |||
| f753cae82d | |||
| 533b642367 | |||
| ea8c8210e5 | |||
| c97a7e9ae6 | |||
| 673ef06856 | |||
| 16a382a8ea | |||
| eae9dbb30d | |||
| 4efd5bda75 | |||
| dbf9e841f8 | |||
| 0ad5a1a71d | |||
| c1405da8d2 | |||
| cc83a3e59f | |||
| 8706c2557b | |||
| 9503ea5fe8 | |||
| 65f58c4000 | |||
| 9d710349c2 | |||
| 535616c48f | |||
| c6c9c6a9dd | |||
| dedb42365e | |||
| f4ec3b4c65 | |||
| 6c707d88b1 | |||
| 759c512281 | |||
| 3d3dbbbb59 | |||
| f4591a193a | |||
| 791d853c72 | |||
| cea433e320 | |||
| cba2f91689 | |||
| 9fd290e256 | |||
| 20ecfdc0b2 | |||
| dd82f5b5c4 | |||
| ce55d647b5 | |||
| 268b6562dc | |||
| b389c01dfe | |||
| d24072e20a | |||
| 9185d39c39 | |||
| a2790c13bb | |||
| 940583552a | |||
| 049c98298a | |||
| f5e8e0e5b9 | |||
| f35439e364 | |||
| 4c5e9b347a | |||
| fdd104284e | |||
| 72819e5f7b | |||
| edd1011330 | |||
| 3ae14fad72 | |||
| 84d3318f76 | |||
| bfb8e83a82 | |||
| 40c1d6da27 | |||
| 5aa709db17 | |||
| cf964a19a9 | |||
| a40c6bf598 | |||
| f951700b58 | |||
| b3939e0685 | |||
| af6f02023d | |||
| 9d569a1504 | |||
| 5a44702cf3 | |||
| 2a5dee3fef | |||
| 2584ab8715 | |||
| 6b9d191bf8 | |||
| 283fa21e87 | |||
| 97d19d83ab | |||
| 01d5159d27 | |||
| 80899ed88c | |||
| cfe2466fec | |||
| 9a124c8c63 | |||
| 42ab7e5964 | |||
| e7735135ca | |||
| f61d1f95b8 | |||
| f632c5fc99 | |||
| 70179e1abc | |||
| 8c3d62223c | |||
| e1f19fe413 | |||
| cb6b92d221 | |||
| 3f10ae3bca | |||
| 7274b89b28 | |||
| 61fe1a0e0c | |||
| 941f20d4c5 | |||
| b06d20e649 | |||
| 445d323bed | |||
| 9028255800 | |||
| 1b2f244853 | |||
| 475bcfec1a | |||
| 85db26ec82 | |||
| baee987d26 | |||
| f45a59f20d | |||
| 32304cbaff | |||
| 2b64bb0ebc | |||
| ffe5251236 | |||
| d04a7fb3bc | |||
| d76595085e | |||
| bad33a8492 | |||
| b0216fca30 | |||
| 49c112d188 | |||
| c836b57d54 | |||
| 3a583f80e5 | |||
| 83e0335504 | |||
| 513343b62a | |||
| 256e39ccff | |||
| efb37fce7a | |||
| 01fc2582b6 | |||
| 39a2dc60be | |||
| 3b87a6e421 | |||
| 2a7a44cc27 | |||
| c7cf63e7de | |||
| d1044e4728 | |||
| 821e96e011 | |||
| d6f973202d | |||
| 1d5a5cf68e | |||
| 8e472ac996 | |||
| 666979cf22 | |||
| eb36e0acfa | |||
| 0ff39e9e58 | |||
| afa9928dcc |
Submodule .claude/worktrees/agent-a98805dc618e0f67c deleted from b4993b202d
@@ -0,0 +1,106 @@
|
||||
name: CI/CD - ERP Core Monorepo
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- production
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
build-and-deploy-backend:
|
||||
name: Build & Deploy Backend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Restore
|
||||
run: dotnet restore Backend/ERPCore.sln
|
||||
- name: Publish
|
||||
run: |
|
||||
rm -rf /tmp/erpcore-deploy
|
||||
mkdir -p /tmp/erpcore-deploy
|
||||
dotnet publish Backend/ERPCore/ERPCore.csproj \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
-o /tmp/erpcore-deploy
|
||||
- name: Stop service
|
||||
run: systemctl stop erpcore.service
|
||||
- name: Copy to deploy folder
|
||||
run: |
|
||||
mkdir -p /home/erp/core/publish
|
||||
rm -rf /home/erp/core/publish/*
|
||||
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
|
||||
- name: Fix ownership and permissions
|
||||
run: |
|
||||
chown -R erpuser:erpuser /home/erp/core/publish
|
||||
find /home/erp/core/publish -type d -exec chmod 755 {} +
|
||||
find /home/erp/core/publish -type f -exec chmod 644 {} +
|
||||
chmod +x /home/erp/core/publish/ERPCore
|
||||
- name: Restart service
|
||||
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
|
||||
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
|
||||
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
|
||||
# all. If the unit does not set it, startup throws before serving and the Verify step
|
||||
# below fails the job.
|
||||
run: systemctl restart erpcore.service
|
||||
- name: Verify
|
||||
run: |
|
||||
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
|
||||
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
|
||||
# during MigrateAsync, after systemctl restart has already returned.
|
||||
sleep 5
|
||||
if ! systemctl is-active --quiet erpcore.service; then
|
||||
echo "❌ erpcore.service is not running:"
|
||||
journalctl -u erpcore.service -n 50 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Service running"
|
||||
|
||||
build-and-deploy-frontend:
|
||||
name: Build & Deploy Frontend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build --progress=plain --no-cache \
|
||||
-t erpcore-frontend:latest \
|
||||
./Frontend/erp-system
|
||||
- name: Stop & remove existing container
|
||||
run: |
|
||||
docker stop erpcore-frontend 2>/dev/null || true
|
||||
docker rm erpcore-frontend 2>/dev/null || true
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name erpcore-frontend \
|
||||
-p 6702:6702 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
|
||||
--restart unless-stopped \
|
||||
--memory="512m" \
|
||||
--cpus="1.0" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
erpcore-frontend:latest
|
||||
- name: Remove old images
|
||||
run: docker image prune -f
|
||||
- name: Verify
|
||||
run: |
|
||||
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
||||
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
|
||||
@@ -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,211 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Accounts;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>Recording and cancelling bank deposits (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2, §11).</summary>
|
||||
public class BankDepositServiceTests
|
||||
{
|
||||
private static readonly DateOnly Today = Clock.Today;
|
||||
private static readonly DateOnly Start = Today.AddDays(-10);
|
||||
private static readonly DateOnly Day = Today.AddDays(-2);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PostsBankDebitCashCredit_AndLowersCashInHand()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new FakeGeneralLedger();
|
||||
|
||||
var dto = await DepositService(db, Start, gl).CreateAsync(Request(600, depositDate: Day.AddDays(1)));
|
||||
|
||||
Assert.Equal(BankDepositStatus.Posted, dto.Status);
|
||||
Assert.Equal(("BOC Current", "Bank of Ceylon", "0001234567"), (dto.ToBankAccountName, dto.ToBankName, dto.ToAccountNumber));
|
||||
Assert.Equal("JE-1", dto.GlJournalNo);
|
||||
|
||||
var entry = Assert.Single(gl.Posted);
|
||||
Assert.Equal(Day.AddDays(1), entry.EntryDate);
|
||||
Assert.Equal("BANK_DEPOSIT", entry.SourceModule);
|
||||
Assert.Collection(entry.Lines,
|
||||
l => Assert.Equal(("1010-01", 600m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
|
||||
l => Assert.Equal(("1000-01", 0m, 600m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
|
||||
|
||||
var position = await CashService(db, Start).GetPositionAsync(Day, Branch1);
|
||||
Assert.Equal((600m, 400m), (position.Position.Deposited, position.Position.Closing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MoreThanCashInHand_IsRejected()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000, card: 5000));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new FakeGeneralLedger();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, gl).CreateAsync(Request(1000.01m)));
|
||||
|
||||
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
|
||||
Assert.Empty(gl.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_BackDatedDepositThatOverdrawsALaterDay_IsRejected()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
db.BankDeposits.Add(Deposit(Day.AddDays(1), 900)); // the next day already banked most of it
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(500)));
|
||||
|
||||
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
|
||||
Assert.Contains("100.00", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_ReversesGl_AndPutsTheCashBack()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
await db.SaveChangesAsync();
|
||||
var gl = new FakeGeneralLedger();
|
||||
var service = DepositService(db, Start, gl);
|
||||
var created = await service.CreateAsync(Request(1000));
|
||||
|
||||
var cancelled = await service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = " wrong bank " });
|
||||
|
||||
Assert.Equal(BankDepositStatus.Cancelled, cancelled.Status);
|
||||
Assert.Equal("wrong bank", cancelled.CancelReason);
|
||||
Assert.Equal("JE-2", cancelled.GlReversalJournalNo);
|
||||
Assert.Collection(gl.Posted[1].Lines,
|
||||
l => Assert.Equal(("1000-01", 1000m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
|
||||
l => Assert.Equal(("1010-01", 0m, 1000m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
|
||||
Assert.Equal(1000m, (await CashService(db, Start).GetPositionAsync(Day, Branch1)).Position.Closing);
|
||||
|
||||
var again = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = "again" }));
|
||||
Assert.Equal(ErrorCodes.BankDepositNotCancellable, again.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FakeGeneralLedger.BankAccountId, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
|
||||
[InlineData(FakeGeneralLedger.CashAccountId, FakeGeneralLedger.CashAccountId, ErrorCodes.BankDepositBankAccountInvalid)]
|
||||
[InlineData(99L, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
|
||||
public async Task Create_WrongAccountTypes_AreRejected(long from, long to, string code)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var request = Request(100);
|
||||
request.FromGlCashAccountId = from;
|
||||
request.ToGlBankAccountId = to;
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
|
||||
|
||||
Assert.Equal(code, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_CashAndBankAccountsSharingAnId_AreToldApartByType()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
await db.SaveChangesAsync();
|
||||
// GL numbers cash and bank accounts in separate tables, so both can be account 1.
|
||||
var gl = new FakeGeneralLedger
|
||||
{
|
||||
Accounts =
|
||||
[
|
||||
new("Cash", 1, "main", null, "Cash in Transit", "CASH-000001", 22, "1400-01-01", "LKR"),
|
||||
new("Bank", 1, "Main - 01", "Commercial Bank", null, "2032434543", 2, "1000-01", "LKR")
|
||||
]
|
||||
};
|
||||
var request = Request(400);
|
||||
request.FromGlCashAccountId = 1;
|
||||
request.ToGlBankAccountId = 1;
|
||||
|
||||
var dto = await DepositService(db, Start, gl).CreateAsync(request);
|
||||
|
||||
Assert.Equal(("main", "Main - 01"), (dto.FromCashAccountName, dto.ToBankAccountName));
|
||||
Assert.Collection(Assert.Single(gl.Posted).Lines,
|
||||
l => Assert.Equal(("1000-01", 400m), (l.AccountCode, l.DebitAmount)),
|
||||
l => Assert.Equal(("1400-01-01", 400m), (l.AccountCode, l.CreditAmount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_CannotUseAnotherBranchsCash()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000, warehouseId: Warehouse2));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(100, branchId: Branch1)));
|
||||
|
||||
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-11, null)] // before tracking starts
|
||||
[InlineData(1, null)] // business date in the future
|
||||
[InlineData(-2, -3)] // deposited before the business day
|
||||
[InlineData(-2, 2)] // deposit date beyond tomorrow
|
||||
public async Task Create_InvalidDates_AreRejected(int businessOffset, int? depositOffset)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var request = Request(100);
|
||||
request.BusinessDate = Today.AddDays(businessOffset);
|
||||
request.DepositDate = depositOffset is { } d ? Today.AddDays(d) : null;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
|
||||
|
||||
Assert.Equal(ErrorCodes.BankDepositDateInvalid, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_GlRejection_LeavesNoDeposit()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.Add(CashSlip(Day, 1000));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await Assert.ThrowsAsync<DomainException>(() =>
|
||||
DepositService(db, Start, new FakeGeneralLedger { Reject = true }).CreateAsync(Request(500)));
|
||||
|
||||
Assert.Equal(0, await db.BankDeposits.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FiltersAndTotalsEveryMatchingRow()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.BankDeposits.AddRange(
|
||||
Deposit(Day, 100), Deposit(Day, 200), Deposit(Day.AddDays(-1), 400),
|
||||
Deposit(Day, 800, branchId: Branch2), Deposit(Day, 1600, status: BankDepositStatus.Cancelled));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await DepositService(db, Start, new FakeGeneralLedger()).ListAsync(
|
||||
new() { PageSize = 1 }, new BankDepositListFilter(Day, Day, Branch1, null, BankDepositStatus.Posted));
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal(2, result.Pagination.TotalItems);
|
||||
Assert.Equal(300m, result.TotalAmount);
|
||||
}
|
||||
|
||||
private static CreateBankDepositRequest Request(decimal amount, int branchId = Branch1, DateOnly? depositDate = null) => new()
|
||||
{
|
||||
BranchId = branchId,
|
||||
BusinessDate = Day,
|
||||
DepositDate = depositDate,
|
||||
Amount = amount,
|
||||
FromGlCashAccountId = FakeGeneralLedger.CashAccountId,
|
||||
ToGlBankAccountId = FakeGeneralLedger.BankAccountId,
|
||||
DepositSlipNo = "SLIP-1"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §11).</summary>
|
||||
public class CashInHandServiceTests
|
||||
{
|
||||
private static readonly DateOnly Today = Clock.Today;
|
||||
private static readonly DateOnly Start = Today.AddDays(-10);
|
||||
|
||||
[Fact]
|
||||
public async Task OnlyCashIsCounted()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var day = Today.AddDays(-2);
|
||||
db.SalesSlips.AddRange(
|
||||
CashSlip(day, cash: 300, card: 200), // only the 300 is till cash
|
||||
CashSlip(day, cash: 0, card: 900));
|
||||
db.SalesInvoices.Add(new SalesInvoice
|
||||
{
|
||||
SalesInvoiceId = 1, InvoiceNo = "INV-1", InvoiceDate = At(day.AddDays(-5)), Status = SalesInvoiceStatus.Posted, BranchId = Branch1
|
||||
});
|
||||
db.SalesInvoicePayments.AddRange(
|
||||
new SalesInvoicePayment { SalesInvoicePaymentId = 1, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cash, Amount = 150 },
|
||||
new SalesInvoicePayment { SalesInvoicePaymentId = 2, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Card, Amount = 500 },
|
||||
new SalesInvoicePayment { SalesInvoicePaymentId = 3, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cheque, Amount = 70 });
|
||||
db.Expenses.Add(new Expense { ExpenseId = 1, DocNo = "EXP-1", BranchId = Branch1, ExpenseDate = day, Amount = 100, Status = ExpenseStatus.Posted });
|
||||
db.ExpensePayments.AddRange(
|
||||
new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.Cash, Amount = 40 },
|
||||
new ExpensePayment { ExpensePaymentId = 2, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.BankTransfer, Amount = 60 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var p = (await CashService(db, Start).GetPositionAsync(day, Branch1)).Position;
|
||||
|
||||
Assert.Equal(450m, p.CashIn);
|
||||
Assert.Equal(40m, p.CashOut);
|
||||
Assert.Equal(410m, p.NetCash);
|
||||
Assert.Equal(410m, p.Closing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UndepositedCash_CarriesToTheNextDay_AndDepositsReduceIt()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var d1 = Today.AddDays(-3);
|
||||
var d2 = Today.AddDays(-2);
|
||||
db.SalesSlips.AddRange(CashSlip(d1, 1000), CashSlip(d2, 500));
|
||||
db.BankDeposits.AddRange(
|
||||
Deposit(d1, 600),
|
||||
Deposit(d1, 999, status: BankDepositStatus.Cancelled)); // ignored
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var days = (await CashService(db, Start).GetDailyAsync(d1, d2, Branch1)).Days;
|
||||
|
||||
Assert.Equal((0m, 1000m, 600m, 400m), (days[0].Opening, days[0].NetCash, days[0].Deposited, days[0].Closing));
|
||||
Assert.Equal((400m, 500m, 0m, 900m), (days[1].Opening, days[1].NetCash, days[1].Deposited, days[1].Closing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_IsCarriedInFromBeforeTheWindow()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.AddRange(CashSlip(Today.AddDays(-6), 700), CashSlip(Today.AddDays(-5), 300));
|
||||
db.BankDeposits.Add(Deposit(Today.AddDays(-5), 200));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var day = (await CashService(db, Start).GetPositionAsync(Today.AddDays(-1), Branch1)).Position;
|
||||
|
||||
Assert.Equal(800m, day.Opening);
|
||||
Assert.Equal(800m, day.Closing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CashBeforeTheTrackingStart_IsNotCarried()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.AddRange(CashSlip(Start.AddDays(-1), 5000), CashSlip(Start, 100));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var days = (await CashService(db, Start).GetDailyAsync(Start.AddDays(-1), Start, Branch1)).Days;
|
||||
|
||||
Assert.Equal(5000m, days[0].NetCash); // still reported for the day itself
|
||||
Assert.Equal(0m, days[1].Opening); // but not carried into tracking
|
||||
Assert.Equal(100m, days[1].Closing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Available_IsTheLowestClosingFromThatDayToToday()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var d1 = Today.AddDays(-2);
|
||||
db.SalesSlips.Add(CashSlip(d1, 1000));
|
||||
db.BankDeposits.Add(Deposit(Today.AddDays(-1), 800)); // already took most of it the next day
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var position = await CashService(db, Start).GetPositionAsync(d1, Branch1);
|
||||
|
||||
Assert.Equal(1000m, position.Position.Closing);
|
||||
Assert.Equal(200m, position.AvailableToDeposit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Branches_AreKeptApart()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var day = Today.AddDays(-1);
|
||||
db.SalesSlips.AddRange(CashSlip(day, 100, warehouseId: Warehouse1), CashSlip(day, 900, warehouseId: Warehouse2));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var service = CashService(db, Start);
|
||||
|
||||
Assert.Equal(100m, (await service.GetPositionAsync(day, Branch1)).Position.Closing);
|
||||
Assert.Equal(900m, (await service.GetPositionAsync(day, Branch2)).Position.Closing);
|
||||
Assert.Equal(1000m, (await service.GetPositionAsync(day, null)).Position.Closing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateEveningSale_CountsOnTheLocalDay()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var day = Today.AddDays(-1);
|
||||
var slip = CashSlip(day, 250);
|
||||
slip.SlipDate = Clock.ToUtc(day.AddDays(1)).AddMinutes(-30); // 23:30 local
|
||||
db.SalesSlips.Add(slip);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var days = (await CashService(db, Start).GetDailyAsync(day, day.AddDays(1), Branch1)).Days;
|
||||
|
||||
Assert.Equal(250m, days[0].CashIn);
|
||||
Assert.Equal(0m, days[1].CashIn);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
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.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>Aggregates of the Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §7).</summary>
|
||||
public class DayEndReportServiceTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
// Asia/Colombo is UTC+05:30 all year (no DST), so local midnight is 18:30 UTC the day before.
|
||||
private static readonly DateOnly Day = new(2026, 10, 8);
|
||||
private static DateTime UtcAt(int hour, int minute = 0) => new(2026, 10, 8, hour, minute, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Day_MixOfSlipsBundlesAndInvoices_TotalsAndSplitsByMethod()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.AddRange(
|
||||
Slip(1, UtcAt(5), total: 1000, paid: 600, cash: 400, card: 200), // partial, mixed
|
||||
Slip(2, UtcAt(6), total: 200, paid: 200, cash: 200, card: 0), // all cash
|
||||
Slip(3, UtcAt(7), total: 999, paid: 999, cash: 999, card: 0, status: SalesSlipStatus.Draft));
|
||||
db.BundleSales.Add(new BundleSale { BundleSaleId = 1, BundleDate = UtcAt(8), Status = BundleSaleStatus.Posted, GrandTotal = 500, CashAmount = 0, CardAmount = 500 });
|
||||
db.SalesInvoices.Add(Invoice(1, UtcAt(9), netPayable: 2000, paid: 1500));
|
||||
db.SalesInvoicePayments.AddRange(
|
||||
Payment(1, invoiceId: 1, UtcAt(9), SalesPaymentMethod.Cash, 500),
|
||||
Payment(2, invoiceId: 1, UtcAt(10), SalesPaymentMethod.Card, 1000));
|
||||
SeedExpenseSetup(db);
|
||||
db.Expenses.AddRange(
|
||||
Expense(1, Day, 300, ExpenseStatus.Posted),
|
||||
Expense(2, Day, 700, ExpenseStatus.Cancelled),
|
||||
Expense(3, Day.AddDays(1), 50, ExpenseStatus.Posted));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var report = await CreateService(db).GetDayAsync(Day, null);
|
||||
var s = report.Summary;
|
||||
|
||||
Assert.Equal(3700m, s.TotalSales);
|
||||
Assert.Equal(2800m, s.PaidAmount);
|
||||
Assert.Equal(900m, s.DueAmount);
|
||||
Assert.Equal(1100m, s.PaidByCash);
|
||||
Assert.Equal(1700m, s.PaidByCard);
|
||||
Assert.Equal(0m, s.PaidByOther);
|
||||
Assert.Equal(4, s.SalesCount);
|
||||
Assert.Equal(1, s.CashSalesCount);
|
||||
Assert.Equal(1, s.CardSalesCount);
|
||||
Assert.Equal(2, s.MixedSalesCount);
|
||||
Assert.Equal(0, s.UnpaidSalesCount);
|
||||
Assert.Equal(300m, s.ExpenseTotal);
|
||||
Assert.Equal(3400m, s.Income);
|
||||
Assert.Single(report.Expenses);
|
||||
Assert.Equal("Electricity", report.Expenses[0].TitleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Day_SaleLateInTheLocalEvening_CountsOnTheLocalDay()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
// 19:00 UTC on Oct 7 is 00:30 on Oct 8 in Colombo; 18:00 UTC on Oct 8 is 23:30 on Oct 8.
|
||||
db.SalesSlips.AddRange(
|
||||
Slip(1, new DateTime(2026, 10, 7, 19, 0, 0, DateTimeKind.Utc), total: 100, paid: 100, cash: 100, card: 0),
|
||||
Slip(2, new DateTime(2026, 10, 8, 18, 0, 0, DateTimeKind.Utc), total: 40, paid: 40, cash: 40, card: 0),
|
||||
Slip(3, new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc), total: 7, paid: 7, cash: 7, card: 0));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var report = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day.AddDays(1), null);
|
||||
|
||||
Assert.Equal([0m, 140m, 7m], report.Days.Select(d => d.TotalSales));
|
||||
Assert.Equal(147m, report.Totals.TotalSales);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Day_PaymentOnAnEarlierInvoice_IsACollectionAndCountsInNetCash()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesInvoices.Add(Invoice(1, UtcAt(5).AddDays(-3), netPayable: 800, paid: 250));
|
||||
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(6), SalesPaymentMethod.Cash, 250));
|
||||
db.SalesSlips.Add(Slip(1, UtcAt(7), total: 60, paid: 60, cash: 60, card: 0));
|
||||
SeedExpenseSetup(db);
|
||||
db.Expenses.Add(Expense(1, Day, 30, ExpenseStatus.Posted));
|
||||
db.ExpensePayments.Add(new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, Amount = 30, PaymentDate = UtcAt(8), PaymentMethod = ExpensePaymentMethod.Cash });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
|
||||
|
||||
Assert.Equal(60m, s.TotalSales);
|
||||
Assert.Equal(60m, s.PaidAmount);
|
||||
Assert.Equal(250m, s.EarlierInvoiceCollections);
|
||||
Assert.Equal(60m + 250m - 30m, s.NetCash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Day_ShowsBankDepositsAndTheCashLeftInHand()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesSlips.AddRange(
|
||||
Slip(1, UtcAt(5).AddDays(-1), total: 300, paid: 300, cash: 300, card: 0), // yesterday, never banked
|
||||
Slip(2, UtcAt(6), total: 1000, paid: 1000, cash: 1000, card: 0));
|
||||
db.BankDeposits.AddRange(
|
||||
BankDeposit(1, Day, 800, BankDepositStatus.Posted),
|
||||
BankDeposit(2, Day, 5000, BankDepositStatus.Cancelled));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var report = await CreateService(db).GetDayAsync(Day, null);
|
||||
var s = report.Summary;
|
||||
|
||||
Assert.Equal((300m, 1000m, 800m, 500m), (s.OpeningCashInHand, s.NetCash, s.CashDeposited, s.ClosingCashInHand));
|
||||
var deposit = Assert.Single(report.Deposits);
|
||||
Assert.Equal(("BD-1", "BOC Current", 800m), (deposit.DocNo, deposit.BankAccountName, deposit.Amount));
|
||||
|
||||
var range = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day, null);
|
||||
Assert.Equal((0m, 800m, 500m), (range.Totals.OpeningCashInHand, range.Totals.CashDeposited, range.Totals.ClosingCashInHand));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Day_Profit_IsSalesLessStockCostLessExpenses_AndCanBeALoss()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 1000, paid: 1000));
|
||||
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(5), SalesPaymentMethod.Card, 1000));
|
||||
db.SalesSlips.Add(Slip(1, UtcAt(6), total: 200, paid: 0, cash: 0, card: 0));
|
||||
db.StockLedger.AddRange(
|
||||
Ledger(1, DocumentTypes.SalesInvoice, 1, Direction.Out, 600),
|
||||
Ledger(2, DocumentTypes.SalesInvoice, 1, Direction.Out, 100),
|
||||
Ledger(3, DocumentTypes.SalesSlip, 1, Direction.Out, 150),
|
||||
Ledger(4, DocumentTypes.SalesInvoice, 1, Direction.In, 999)); // not a sale's cost
|
||||
SeedExpenseSetup(db);
|
||||
db.Expenses.Add(Expense(1, Day, 900, ExpenseStatus.Posted));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var report = await CreateService(db).GetDayAsync(Day, null);
|
||||
var s = report.Summary;
|
||||
|
||||
Assert.Equal(850m, s.CostOfGoodsSold);
|
||||
Assert.Equal(350m, s.GrossProfit);
|
||||
Assert.Equal(-550m, s.NetProfit);
|
||||
Assert.Equal(2, report.Sales.Count);
|
||||
var invoice = report.Sales.Single(x => x.DocType == "Invoice");
|
||||
Assert.Equal(("Card", 700m, 300m), (invoice.PaidBy, invoice.Cost, invoice.GrossProfit));
|
||||
Assert.Equal("Unpaid", report.Sales.Single(x => x.DocType == "Slip").PaidBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Day_UnpaidInvoice_CountsAsUnpaidAndDue()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 450, paid: 0));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
|
||||
|
||||
Assert.Equal(1, s.UnpaidSalesCount);
|
||||
Assert.Equal(450m, s.DueAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Daily_FillsQuietDaysWithZeros()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var report = await CreateService(db).GetDailyAsync(Day, Day.AddDays(4), null);
|
||||
|
||||
Assert.Equal(5, report.Days.Count);
|
||||
Assert.All(report.Days, d => Assert.Equal(0m, d.Income));
|
||||
Assert.Equal(Day.AddDays(4), report.Days[^1].Date);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 0)] // to before from
|
||||
[InlineData(0, 92)] // 93 days
|
||||
public async Task Daily_InvalidRange_IsRejected(int fromOffset, int toOffset)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
CreateService(db).GetDailyAsync(Day.AddDays(fromOffset), Day.AddDays(toOffset), null));
|
||||
|
||||
Assert.Equal(ErrorCodes.DayEndReportRangeInvalid, ex.Code);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------------------------
|
||||
|
||||
private static SalesSlip Slip(int id, DateTime at, decimal total, decimal paid, decimal cash, decimal card,
|
||||
SalesSlipStatus status = SalesSlipStatus.Posted) => new()
|
||||
{
|
||||
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = at, Status = status,
|
||||
GrandTotal = total, PaidAmount = paid, BalanceAmount = total - paid, CashAmount = cash, CardAmount = card
|
||||
};
|
||||
|
||||
private static SalesInvoice Invoice(int id, DateTime at, decimal netPayable, decimal paid) => new()
|
||||
{
|
||||
SalesInvoiceId = id, InvoiceNo = $"INV-{id}", InvoiceDate = at, Status = SalesInvoiceStatus.Posted,
|
||||
GrandTotal = netPayable, NetPayable = netPayable, PaidAmount = paid, BalanceAmount = netPayable - paid
|
||||
};
|
||||
|
||||
private static SalesInvoicePayment Payment(int id, int invoiceId, DateTime at, SalesPaymentMethod method, decimal amount) => new()
|
||||
{
|
||||
SalesInvoicePaymentId = id, SalesInvoiceId = invoiceId, PaymentDate = at, PaymentMethod = method, Amount = amount
|
||||
};
|
||||
|
||||
private static StockLedger Ledger(int id, string docType, int docId, Direction direction, decimal value) => new()
|
||||
{
|
||||
LedgerId = id, SourceDocType = docType, SourceDocId = docId, Direction = direction, Value = value, QtyBase = 1, ItemId = 1, WarehouseId = 1
|
||||
};
|
||||
|
||||
private static Expense Expense(int id, DateOnly date, decimal amount, ExpenseStatus status) => new()
|
||||
{
|
||||
ExpenseId = id, DocNo = $"EXP-{id}", ExpenseDate = date, Amount = amount, Status = status,
|
||||
ExpenseCategoryId = 1, ExpenseTitleId = 1
|
||||
};
|
||||
|
||||
private static BankDeposit BankDeposit(int id, DateOnly businessDate, decimal amount, BankDepositStatus status) => new()
|
||||
{
|
||||
BankDepositId = id, DocNo = $"BD-{id}", BranchId = 1, BusinessDate = businessDate, DepositDate = businessDate, Amount = amount,
|
||||
Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
|
||||
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current"
|
||||
};
|
||||
|
||||
private static void SeedExpenseSetup(ErpDbContext db)
|
||||
{
|
||||
db.ExpenseCategories.Add(new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility" });
|
||||
db.ExpenseTitles.Add(new ExpenseTitle { ExpenseTitleId = 1, ExpenseCategoryId = 1, Name = "Electricity", GlExpenseAccountCode = "5200-01" });
|
||||
}
|
||||
|
||||
private static DayEndReportService CreateService(ErpDbContext db)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["DayEndReport:TimeZone"] = "Asia/Colombo" })
|
||||
.Build();
|
||||
var clock = new BusinessDayClock(config);
|
||||
var branch = new CurrentBranchContext(new TestCurrentUser());
|
||||
var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config);
|
||||
return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock);
|
||||
}
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Dtos.Products;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
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 Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13).</summary>
|
||||
public class DualApprovalTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
// 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 ApproverRoleId = 2, ClerkRoleId = 3;
|
||||
// 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 ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_IsARequest_NothingMovesUntilApproved()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
|
||||
var dto = await h.Stock.CreateAsync(Request(-3));
|
||||
|
||||
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
|
||||
Assert.Equal(2, dto.RequiredApprovals);
|
||||
Assert.Equal(1, dto.PendingLevel);
|
||||
Assert.Empty(h.Mutator.Calls);
|
||||
Assert.Empty(h.Gl.Posted);
|
||||
var pending = Assert.Single(h.Notifications.Targeted, n => n.ModuleKey == "approval.pending");
|
||||
Assert.Equal([ApproverA, ApproverB, Clerk], pending.UserIds.Order()); // never the requester
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_TwoDifferentApprovers_ThenItPosts()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
|
||||
|
||||
h.User.Be(Requester);
|
||||
var own = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||
Assert.Equal(ErrorCodes.ApprovalForbidden, own.Code);
|
||||
|
||||
h.User.Be(ApproverA);
|
||||
var first = await h.Stock.ApproveAsync(id, "counted twice");
|
||||
Assert.Equal(AdjustmentStatus.PendingSecondApproval, first.Status);
|
||||
Assert.Empty(h.Mutator.Calls);
|
||||
|
||||
var twice = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||
Assert.Equal(ErrorCodes.ApprovalSamePerson, twice.Code);
|
||||
|
||||
h.User.Be(ApproverB);
|
||||
var posted = await h.Stock.ApproveAsync(id, null);
|
||||
|
||||
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
|
||||
Assert.Equal((ApproverA, ApproverB), (posted.ApprovedLevel1By, posted.ApprovedLevel2By));
|
||||
var call = Assert.Single(h.Mutator.Calls);
|
||||
Assert.Equal(-3m, Assert.Single(call).QtyDelta);
|
||||
Assert.Single(h.Gl.Posted);
|
||||
Assert.NotNull(posted.PostedAt);
|
||||
Assert.Equal([1, 2], posted.ApprovalHistory!.Select(s => s.Level));
|
||||
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.approved" && n.UserIds.SequenceEqual([Requester]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_Rejected_NeedsAReason_AndNeverPosts()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
var id = (await h.Stock.CreateAsync(Request(5))).AdjustmentId;
|
||||
h.User.Be(ApproverA);
|
||||
|
||||
var noReason = await Assert.ThrowsAsync<DomainException>(() => h.Stock.RejectAsync(id, " "));
|
||||
Assert.Equal(ErrorCodes.ApprovalReasonRequired, noReason.Code);
|
||||
|
||||
var rejected = await h.Stock.RejectAsync(id, "wrong warehouse");
|
||||
Assert.Equal((AdjustmentStatus.Rejected, "wrong warehouse"), (rejected.Status, rejected.RejectReason));
|
||||
|
||||
h.User.Be(ApproverB);
|
||||
var late = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||
Assert.Equal(ErrorCodes.ApprovalNotPending, late.Code);
|
||||
Assert.Empty(h.Mutator.Calls);
|
||||
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_ApprovalsOff_PostsOnCreate()
|
||||
{
|
||||
var h = await Harness.CreateAsync(stockLevels: 0);
|
||||
|
||||
var dto = await h.Stock.CreateAsync(Request(2));
|
||||
|
||||
Assert.Equal((AdjustmentStatus.Posted, 0), (dto.Status, dto.RequiredApprovals));
|
||||
Assert.Single(h.Mutator.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_BelowTheValueThreshold_NeedsOneApproval()
|
||||
{
|
||||
// 3 units at 10.00 each = 30.00, below the 1,000 threshold.
|
||||
var h = await Harness.CreateAsync(stockThreshold: 1000);
|
||||
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
|
||||
|
||||
h.User.Be(ApproverA);
|
||||
var posted = await h.Stock.ApproveAsync(id, null);
|
||||
|
||||
Assert.Equal((AdjustmentStatus.Posted, 1), (posted.Status, posted.RequiredApprovals));
|
||||
Assert.Single(h.Mutator.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StockAdjustment_OnlyTheRequesterCanWithdraw()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||
|
||||
h.User.Be(ApproverA);
|
||||
var other = await Assert.ThrowsAsync<DomainException>(() => h.Stock.CancelAsync(id));
|
||||
Assert.Equal(ErrorCodes.ApprovalForbidden, other.Code);
|
||||
|
||||
h.User.Be(Requester);
|
||||
Assert.Equal(AdjustmentStatus.Cancelled, (await h.Stock.CancelAsync(id)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondApproval_CanBeLimitedToRoles()
|
||||
{
|
||||
var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
|
||||
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
|
||||
h.User.Be(ApproverA);
|
||||
await h.Stock.ApproveAsync(id, null);
|
||||
|
||||
h.User.Be(Clerk);
|
||||
var clerk = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
|
||||
Assert.Equal(ErrorCodes.ApprovalRoleRequired, clerk.Code);
|
||||
|
||||
h.User.Be(ApproverB);
|
||||
Assert.Equal(AdjustmentStatus.Posted, (await h.Stock.ApproveAsync(id, null)).Status);
|
||||
}
|
||||
|
||||
// --- Price adjustments ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task PriceAdjustment_AppliesOnlyAfterTheSecondApproval_AndKeepsHistory()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
var dto = await h.Prices.CreateAsync(PriceRequest(flat: 120m));
|
||||
Assert.Equal(PriceAdjustmentStatus.PendingApproval, dto.Status);
|
||||
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||
|
||||
h.User.Be(ApproverA);
|
||||
await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
|
||||
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||
|
||||
h.User.Be(ApproverB);
|
||||
var applied = await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
|
||||
|
||||
Assert.Equal(PriceAdjustmentStatus.Approved, applied.Status);
|
||||
Assert.Equal(120m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
|
||||
var history = Assert.Single(await h.Prices.ItemHistoryAsync(ItemId));
|
||||
Assert.Equal((100m, 120m, ApproverA, ApproverB, dto.DocNo), (history.OldFlatPrice, history.NewFlatPrice, history.ApprovedLevel1By, history.ApprovedLevel2By, history.DocNo));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PriceAdjustment_ItemRepricedSinceTheRequest_IsRefusedAsStale()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
var id = (await h.Prices.CreateAsync(PriceRequest(flat: 120m))).PriceAdjustmentId;
|
||||
h.User.Be(ApproverA);
|
||||
await h.Prices.ApproveAsync(id, null);
|
||||
|
||||
var item = await h.Db.Items.FirstAsync();
|
||||
item.SellingFlatPrice = 105m; // someone changed it in between
|
||||
await h.Db.SaveChangesAsync();
|
||||
|
||||
h.User.Be(ApproverB);
|
||||
var stale = await Assert.ThrowsAsync<DomainException>(() => h.Prices.ApproveAsync(id, null));
|
||||
Assert.Equal(ErrorCodes.PriceAdjustmentStale, stale.Code);
|
||||
Assert.Empty(await h.Prices.ItemHistoryAsync(ItemId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PriceAdjustment_OnePendingRequestPerItem()
|
||||
{
|
||||
var h = await Harness.CreateAsync();
|
||||
await h.Prices.CreateAsync(PriceRequest(flat: 120m));
|
||||
|
||||
var second = await Assert.ThrowsAsync<DomainException>(() => h.Prices.CreateAsync(PriceRequest(flat: 130m)));
|
||||
Assert.Equal(ErrorCodes.PriceAdjustmentItemPending, second.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 120, 20)]
|
||||
[InlineData(100, 80, 20)]
|
||||
[InlineData(-1, 50, 100)] // setting a price where there was none (-1 = no price)
|
||||
[InlineData(50, -1, 100)] // clearing one
|
||||
[InlineData(40, 40, 0)]
|
||||
public void ChangePercent_MeasuresTheSizeOfAPriceChange(int oldPrice, int newPrice, int expected)
|
||||
{
|
||||
decimal? Price(int v) => v < 0 ? null : v;
|
||||
Assert.Equal(expected, PriceAdjustmentService.ChangePercent(Price(oldPrice), Price(newPrice)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, 0, 5000, 2)] // no threshold: always two
|
||||
[InlineData(2, 1000, 50, 1)] // below threshold: one
|
||||
[InlineData(2, 1000, 1000, 2)] // at threshold: two
|
||||
[InlineData(1, 1000, 5000, 1)]
|
||||
[InlineData(0, 0, 5000, 0)]
|
||||
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
|
||||
=> 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 ------------------------------------------------------------------------------
|
||||
|
||||
private static CreateAdjustmentRequest Request(decimal qty, int reasonId = ReasonId) => new()
|
||||
{
|
||||
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = reasonId,
|
||||
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
|
||||
};
|
||||
|
||||
private static CreatePriceAdjustmentRequest PriceRequest(decimal flat) => new()
|
||||
{
|
||||
Reason = "Supplier price rise",
|
||||
Lines = [new PriceAdjustmentLineInput { ItemId = ItemId, NewMinPrice = 90m, NewMaxPrice = 150m, NewFlatPrice = flat }]
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required ErpDbContext Db { get; init; }
|
||||
public required SwitchableUser User { get; init; }
|
||||
public required RecordingMutator Mutator { get; init; }
|
||||
public required FakeGeneralLedger Gl { get; init; }
|
||||
public required RecordingNotifications Notifications { get; init; }
|
||||
public required AdjustmentService Stock { get; init; }
|
||||
public required PriceAdjustmentService Prices { get; init; }
|
||||
|
||||
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null, int wastageLevels = 0)
|
||||
{
|
||||
var db = new ErpDbContext(
|
||||
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
|
||||
new TestCurrentUser(), new CurrentBranchContext(new TestCurrentUser()));
|
||||
|
||||
db.Roles.AddRange(
|
||||
new Role { RoleId = 1, Code = "Requester", Name = "Requester" },
|
||||
new Role { RoleId = ApproverRoleId, Code = "Approver", Name = "Approver" },
|
||||
new Role { RoleId = ClerkRoleId, Code = "Clerk", Name = "Clerk" });
|
||||
db.Users.AddRange(
|
||||
new User { UserId = ERPCore.Domain.Entities.User.SystemUserId, Username = "system", DisplayName = "System" },
|
||||
new User { UserId = Requester, Username = "req", DisplayName = "Requester", RoleId = 1 },
|
||||
new User { UserId = ApproverA, Username = "a", DisplayName = "Approver A", RoleId = ApproverRoleId },
|
||||
new User { UserId = ApproverB, Username = "b", DisplayName = "Approver B", RoleId = ApproverRoleId },
|
||||
new User { UserId = Clerk, Username = "c", DisplayName = "Clerk", RoleId = ClerkRoleId });
|
||||
db.Permissions.AddRange(
|
||||
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments" },
|
||||
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments" });
|
||||
foreach (var roleId in new[] { ApproverRoleId, ClerkRoleId })
|
||||
foreach (var permissionId in new[] { 45, 58 })
|
||||
db.RolePermissions.Add(new RolePermission { RoleId = roleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
|
||||
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 45, Actions = RolePermissionAction.View | RolePermissionAction.Create });
|
||||
|
||||
db.Branches.Add(new Branch { BranchId = 1, Code = "BR-001", Name = "Main" });
|
||||
db.Warehouses.Add(new Warehouse { WarehouseId = WarehouseId, Code = "WH-1", Name = "Main Store", BranchId = 1 });
|
||||
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,
|
||||
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
|
||||
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
|
||||
{
|
||||
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
||||
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds,
|
||||
WastageLevels = wastageLevels
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var user = new SwitchableUser(Requester, "Requester");
|
||||
var uow = new PassThroughUnitOfWork(db);
|
||||
var notifications = new RecordingNotifications();
|
||||
var approvals = new ApprovalService(
|
||||
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
|
||||
new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
|
||||
var gl = new FakeGeneralLedger();
|
||||
var mutator = new RecordingMutator();
|
||||
var numbers = new FakeNumberSequence();
|
||||
|
||||
var stock = new AdjustmentService(
|
||||
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
|
||||
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
|
||||
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
|
||||
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 Repository<ProductConfig>(db), user));
|
||||
var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow);
|
||||
|
||||
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A signed-in user whose identity the test switches between steps.</summary>
|
||||
private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser
|
||||
{
|
||||
private static readonly Dictionary<int, string> Roles = new()
|
||||
{
|
||||
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<int, int> RoleIds = new()
|
||||
{
|
||||
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
|
||||
};
|
||||
|
||||
public int AuditUserId { get; private set; } = userId;
|
||||
public int? RoleId { get; private set; } = RoleIds[userId];
|
||||
public string? RoleCode { get; private set; } = roleCode;
|
||||
public string UserId => AuditUserId.ToString();
|
||||
public bool IsAuthenticated => true;
|
||||
public bool IsAdmin => false;
|
||||
public int? BranchId => null;
|
||||
|
||||
public void Be(int userId)
|
||||
{
|
||||
AuditUserId = userId;
|
||||
RoleId = RoleIds[userId];
|
||||
RoleCode = Roles[userId];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records what would be posted to stock; every unit is worth 10.00.</summary>
|
||||
private sealed class RecordingMutator : IStockMutator
|
||||
{
|
||||
public List<IReadOnlyList<StockDelta>> Calls { get; } = new();
|
||||
|
||||
public Task<IReadOnlyList<StockLedger>> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now,
|
||||
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
|
||||
{
|
||||
Calls.Add(deltas);
|
||||
IReadOnlyList<StockLedger> refs = deltas.Select(d => new StockLedger
|
||||
{
|
||||
ItemId = d.ItemId, WarehouseId = warehouseId, QtyBase = Math.Abs(d.QtyDelta), UnitCost = 10m, Value = Math.Abs(d.QtyDelta) * 10m,
|
||||
Direction = d.QtyDelta < 0 ? Direction.Out : Direction.In, SourceDocType = sourceDocType, SourceDocId = sourceDocId, CreatedAt = now
|
||||
}).ToList();
|
||||
return Task.FromResult(refs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold.</summary>
|
||||
private sealed class FixedValuation : IFifoCostingService
|
||||
{
|
||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO"));
|
||||
public Task<StockLayer> CreateInboundLayerAsync(int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId,
|
||||
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
|
||||
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
|
||||
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
|
||||
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
|
||||
}
|
||||
|
||||
private sealed class RecordingNotifications : INotificationService
|
||||
{
|
||||
public List<(IReadOnlyList<int> UserIds, string ModuleKey, string Message)> Targeted { get; } = new();
|
||||
|
||||
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)
|
||||
{
|
||||
Targeted.Add((userIds.ToList(), moduleKey, message));
|
||||
return 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) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.20" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Time;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
|
||||
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
|
||||
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
|
||||
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
|
||||
/// </summary>
|
||||
public class GlAccountCodeValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new(),
|
||||
new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100"),
|
||||
StubGeneralLedgerService.Postable("2000")
|
||||
]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new(),
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
|
||||
(GlAccountRole.Inventory, "1100"),
|
||||
(GlAccountRole.Cash, "1000-01"),
|
||||
(GlAccountRole.InventoryLoss, "5100"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("1000-01", ex.Message);
|
||||
Assert.Contains("5100", ex.Message);
|
||||
Assert.Contains("2 of 3", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
|
||||
/// "everything is missing" would refuse to start on a contract change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([]),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
|
||||
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
|
||||
/// mapping — escaped a hosted service and aborted the host.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(UnexpectedFailures))]
|
||||
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService(failure),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public static TheoryData<Exception> UnexpectedFailures() =>
|
||||
[
|
||||
StubGeneralLedgerService.Unreachable(),
|
||||
new NullReferenceException("items was null"),
|
||||
new global::System.Text.Json.JsonException("unexpected shape"),
|
||||
new InvalidOperationException("something else entirely"),
|
||||
new HttpRequestException("connection reset"),
|
||||
new OperationCanceledException(),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
|
||||
{
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
var validator = CreateValidator(new(), gl);
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
|
||||
{
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
gl,
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
Assert.Equal(1, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||
[Fact]
|
||||
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||
{
|
||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 29, 59, DateTimeKind.Utc)));
|
||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
private static GlAccountCodeValidator CreateValidator(
|
||||
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var services = new ServiceCollection()
|
||||
.AddScoped<IGeneralLedgerService>(_ => gl)
|
||||
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
|
||||
.BuildServiceProvider();
|
||||
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The point of this service is that a mapping GL cannot post to never reaches the database.
|
||||
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
|
||||
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
|
||||
/// existed in GL at all.
|
||||
/// </summary>
|
||||
public class GlAccountConfigServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var gl = new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||
StubGeneralLedgerService.Postable("2000", "GRNI")
|
||||
]);
|
||||
var service = CreateService(db, gl);
|
||||
var before = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
|
||||
|
||||
Assert.True(result.RowVersion > before);
|
||||
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
|
||||
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Equal(422, ex.StatusCode);
|
||||
Assert.Contains("9999", ex.Message);
|
||||
Assert.Contains("Clearing", ex.Message);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GL rejects a journal line against a non-postable account, so storing one only defers the
|
||||
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsANonPostableAccount()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
|
||||
]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Contains("not postable", ex.Message);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Equal(503, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsAStaleIfMatch()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TreatsBlankAsCleared()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
var service = CreateService(db, gl);
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
|
||||
|
||||
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
|
||||
}
|
||||
|
||||
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
|
||||
[Fact]
|
||||
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
|
||||
var codes = await service.GetCodesAsync();
|
||||
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
|
||||
Assert.Contains("Cogs", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
|
||||
[Fact]
|
||||
public async Task GetCodesAsync_ReadsOncePerScope()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
|
||||
var first = await service.GetCodesAsync();
|
||||
var second = await service.GetCodesAsync();
|
||||
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
|
||||
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.True(result.Value.GlUnavailable);
|
||||
Assert.Empty(result.Value.Options);
|
||||
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
|
||||
Assert.Equal("1100", inventory.AccountCode);
|
||||
}
|
||||
|
||||
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync(c =>
|
||||
{
|
||||
c.InventoryAccountCode = "1100";
|
||||
c.CashAccountCode = "1000-01";
|
||||
});
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
|
||||
]));
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
|
||||
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
|
||||
}
|
||||
|
||||
/// <summary>The picker must only offer what validation will accept.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
|
||||
new GlAccount("1200", "Retired", 1, true, false)
|
||||
]));
|
||||
|
||||
var options = (await service.GetAsync()).Value.Options;
|
||||
|
||||
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
|
||||
}
|
||||
|
||||
private static async Task AssertNothingStoredAsync(ErpDbContext db)
|
||||
{
|
||||
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
|
||||
Assert.Null(row!.InventoryAccountCode);
|
||||
Assert.Null(row.ClearingAccountCode);
|
||||
Assert.Null(row.CashAccountCode);
|
||||
}
|
||||
|
||||
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
|
||||
{
|
||||
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
|
||||
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||
|
||||
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
|
||||
configure?.Invoke(config);
|
||||
db.GlAccountConfig.Add(config);
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
|
||||
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
|
||||
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
|
||||
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
|
||||
/// </summary>
|
||||
public class ItemTrackingTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
private const int Wh1 = 1;
|
||||
private const int Wh2 = 2;
|
||||
private const int SerialItemId = 10;
|
||||
private const int BatchItemId = 20;
|
||||
private const int PlainItemId = 30;
|
||||
|
||||
// --- sales line validation --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
// D6: stock received before serial capture — a layer with no serial.
|
||||
db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet<int>());
|
||||
|
||||
Assert.Empty(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
// Serial 3 is in stock at warehouse 2.
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SoldSerial_IsNotAvailable()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
var documentSerials = new HashSet<int>();
|
||||
|
||||
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_ValidSerials_AreReturned()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet<int>());
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_UntrackedItem_RejectsSerials()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_ExpiredBatch_IsBlocked()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_BatchOfAnotherItem_IsRejected()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
// --- stock documents (transfers / adjustments / purchase returns) ------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
// Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveOutboundAsync(
|
||||
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock }));
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
|
||||
// ...a write-off or purchase return (InStock or Damaged) takes it.
|
||||
var serials = await tracking.ResolveOutboundAsync(
|
||||
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
|
||||
Assert.Single(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
// SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
|
||||
var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet<string>());
|
||||
await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
|
||||
Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
|
||||
Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
|
||||
Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Move_RecordsHistory_WithPreviousWarehouse()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
|
||||
|
||||
await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
|
||||
Assert.Equal(SerialStatus.InTransit, movement.Status);
|
||||
Assert.Equal(Wh1, movement.FromWarehouseId);
|
||||
Assert.Null(movement.WarehouseId);
|
||||
Assert.Null(serial.WarehouseId);
|
||||
}
|
||||
|
||||
// --- auto-generated serial numbers -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
|
||||
// A draft GRN line already holds PH-0042 (not yet a Serial).
|
||||
db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
|
||||
Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
|
||||
await db.SaveChangesAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
|
||||
|
||||
Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
|
||||
await db.SaveChangesAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
|
||||
|
||||
Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_RejectsTooMany()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
private static async Task<ErpDbContext> SeedAsync()
|
||||
{
|
||||
var db = CreateContext();
|
||||
db.Items.AddRange(
|
||||
new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
|
||||
new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
|
||||
new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
|
||||
db.Batches.AddRange(
|
||||
new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
|
||||
new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
|
||||
db.Serials.AddRange(
|
||||
new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
|
||||
new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
|
||||
new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
|
||||
new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
|
||||
new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
// Pricing is never touched by the tracking check, so it is not wired.
|
||||
private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
|
||||
new Repository<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
|
||||
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
|
||||
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
|
||||
CreateSerialTracking(db));
|
||||
|
||||
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
|
||||
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||
new Repository<ProductConfig>(db), new TestCurrentUser());
|
||||
|
||||
private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
|
||||
{
|
||||
db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// --- Product Configuration "Serial numbers" switch ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_SerialItemSells_WithoutSerials()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
await SwitchSerialNumbersAsync(db, enabled: false);
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
// No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet<int>());
|
||||
|
||||
Assert.Empty(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_SerialsSent_AreRefused()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
await SwitchSerialNumbersAsync(db, enabled: false);
|
||||
var sales = CreateSalesDomain(db);
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var sale = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>Low-stock / reorder alerts raised after stock issues.</summary>
|
||||
public class LowStockAlertServiceTests
|
||||
{
|
||||
private const int ItemId = 1, WarehouseId = 1;
|
||||
|
||||
[Theory]
|
||||
[InlineData(12, 4, 8, true)] // 12 → 8 crosses the alert level of 10
|
||||
[InlineData(12, 2, 10, true)] // landing exactly on the level counts
|
||||
[InlineData(20, 5, 15, false)] // still above the level
|
||||
[InlineData(9, 3, 6, false)] // was already low — no repeat alert on every sale
|
||||
public async Task Alerts_OnlyWhenAnIssueCrossesTheReorderPoint(decimal before, decimal issued, decimal after, bool alerts)
|
||||
{
|
||||
Assert.Equal(before - issued, after);
|
||||
await using var db = Seed();
|
||||
var tracker = new LowStockTracker();
|
||||
tracker.Track(ItemId, WarehouseId, issued);
|
||||
var notifications = new RecordingNotifications();
|
||||
|
||||
await Service(db, tracker, after, notifications).CheckAsync();
|
||||
|
||||
if (!alerts)
|
||||
{
|
||||
Assert.Empty(notifications.Raised);
|
||||
return;
|
||||
}
|
||||
var raised = Assert.Single(notifications.Raised);
|
||||
Assert.Equal(LowStockAlertService.ModuleKey, raised.ModuleKey);
|
||||
Assert.Contains("SKU-1", raised.Message);
|
||||
Assert.Contains($"down to {after:0.####} pcs at WH-1", raised.Message);
|
||||
Assert.Contains("Suggested reorder: 50 pcs", raised.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItemsWithoutARule_OrUntouched_AreIgnored()
|
||||
{
|
||||
await using var db = Seed();
|
||||
var tracker = new LowStockTracker();
|
||||
tracker.Track(itemId: 99, WarehouseId, 100); // no rule for this item
|
||||
var notifications = new RecordingNotifications();
|
||||
|
||||
await Service(db, tracker, available: 0, notifications).CheckAsync();
|
||||
await Service(db, new LowStockTracker(), available: 0, notifications).CheckAsync(); // nothing issued
|
||||
|
||||
Assert.Empty(notifications.Raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_SumsIssuesWithinARequest_AndEmptiesWhenDrained()
|
||||
{
|
||||
var tracker = new LowStockTracker();
|
||||
tracker.Track(ItemId, WarehouseId, 3);
|
||||
tracker.Track(ItemId, WarehouseId, 4);
|
||||
tracker.Track(ItemId, WarehouseId, 0);
|
||||
|
||||
Assert.Equal(7m, tracker.Drain()[(ItemId, WarehouseId)]);
|
||||
Assert.Empty(tracker.Drain());
|
||||
}
|
||||
|
||||
private static LowStockAlertService Service(ErpDbContext db, LowStockTracker tracker, decimal available, INotificationService notifications)
|
||||
=> new(tracker, new Repository<ItemReorder>(db), new FixedStock(available), notifications);
|
||||
|
||||
private static ErpDbContext Seed()
|
||||
{
|
||||
var db = BankDepositTestSupport.CreateContext(); // users, branches, warehouses WH-1 / WH-2
|
||||
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
|
||||
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola 500ml", BaseUomId = 1, CategoryId = 1 });
|
||||
db.ItemReorders.Add(new ItemReorder { ReorderId = 1, ItemId = ItemId, WarehouseId = WarehouseId, ReorderPoint = 10, ReorderQty = 50 });
|
||||
db.SaveChanges();
|
||||
return db;
|
||||
}
|
||||
|
||||
private sealed class FixedStock(decimal available) : IStockService
|
||||
{
|
||||
public Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
|
||||
public Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
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)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class RecordingNotifications : INotificationService
|
||||
{
|
||||
public List<(string ModuleKey, string Title, string Message)> Raised { get; } = new();
|
||||
|
||||
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null,
|
||||
int? relatedEntityId = null, CancellationToken ct = default)
|
||||
{
|
||||
Raised.Add((moduleKey, title, message));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
|
||||
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public class NotificationConfigServiceTests
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.Equal(7u, result.RowVersion);
|
||||
Assert.Equal(1u, result.RowVersion);
|
||||
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
|
||||
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
|
||||
}
|
||||
@@ -55,9 +55,9 @@ public class NotificationConfigServiceTests
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var updated = await service.UpdateAsync(request, 7);
|
||||
var updated = await service.UpdateAsync(request, 1);
|
||||
|
||||
Assert.Equal(7u, updated.RowVersion);
|
||||
Assert.Equal(2u, updated.RowVersion);
|
||||
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
|
||||
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
|
||||
|
||||
@@ -133,7 +133,7 @@ public class NotificationConfigServiceTests
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 1));
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
@@ -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,124 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Production;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services.Production;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the 412 translation on the two production services, which save through
|
||||
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
|
||||
/// concurrency differently from the inline pattern the other services use.
|
||||
/// </summary>
|
||||
public class ProductionConcurrencyTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
|
||||
// Validation runs before the transaction opens, so the referenced item has to exist.
|
||||
db.Items.Add(new Item
|
||||
{
|
||||
ItemId = 1,
|
||||
Sku = "FG-1",
|
||||
Name = "Finished good",
|
||||
CategoryId = 1,
|
||||
BaseUomId = 1,
|
||||
Status = EntityStatus.Active
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var service = CreateTemplateService(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var service = CreateRunService(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(
|
||||
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
|
||||
/// stock input and a single item-bearing output, and no edges.
|
||||
/// </summary>
|
||||
private static SaveTemplateRequest SingleStageTemplate() => new()
|
||||
{
|
||||
Code = "TPL-1",
|
||||
Name = "Template 1",
|
||||
Stages =
|
||||
[
|
||||
new SaveStageRequest
|
||||
{
|
||||
Key = "tmp-1",
|
||||
Name = "Mix",
|
||||
Inputs =
|
||||
[
|
||||
new SaveInputRequest
|
||||
{
|
||||
Source = StageInputSource.Stock,
|
||||
ItemId = 1,
|
||||
QtyUnit = StageQtyUnit.Pack,
|
||||
QtyPerBatch = 1m
|
||||
}
|
||||
],
|
||||
Outputs =
|
||||
[
|
||||
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
|
||||
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
|
||||
// which leaves the branch query filters off: what a unit test wants.
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
|
||||
=> new(
|
||||
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
|
||||
new Repository<StageInput>(db), new Repository<StageOutput>(db),
|
||||
new Repository<StageEdge>(db), new Repository<Item>(db),
|
||||
new Repository<Uom>(db), new Repository<ProductionRun>(db),
|
||||
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
|
||||
|
||||
// The costing, measurement and numbering collaborators are only ever touched inside the
|
||||
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
|
||||
private static ProductionRunService CreateRunService(ErpDbContext db)
|
||||
=> new(
|
||||
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
|
||||
new Repository<Warehouse>(db), new Repository<Bin>(db),
|
||||
new Repository<Item>(db), new Repository<ReasonCode>(db),
|
||||
fifo: null!, measure: null!, numbers: null!,
|
||||
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ERPCore.Tests.TestSupport;
|
||||
|
||||
/// <summary>Records every journal it is asked to post; can be told to reject them instead.</summary>
|
||||
internal sealed class FakeGeneralLedger : IGeneralLedgerService
|
||||
{
|
||||
public const long CashAccountId = 1, BankAccountId = 2, OtherBankAccountId = 3;
|
||||
|
||||
public List<GlJournalEntryRequest> Posted { get; } = new();
|
||||
public bool Reject { get; set; }
|
||||
|
||||
public IReadOnlyList<GlBankAccount> Accounts { get; init; } =
|
||||
[
|
||||
new("Cash", CashAccountId, "Main Till", null, "Petty", null, 101, "1000-01", "LKR"),
|
||||
new("Bank", BankAccountId, "BOC Current", "Bank of Ceylon", null, "0001234567", 102, "1010-01", "LKR"),
|
||||
new("Bank", OtherBankAccountId, "HNB Savings", "Hatton National Bank", null, "9876543210", 103, "1010-02", "LKR")
|
||||
];
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
{
|
||||
if (Reject) throw new DomainException(ErrorCodes.Validation, "GL rejected the entry.", 502);
|
||||
Posted.Add(request);
|
||||
return Task.FromResult(new GlJournalEntryResult($"JE-{Posted.Count}", true));
|
||||
}
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
||||
|
||||
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>
|
||||
internal sealed class PassThroughUnitOfWork(ErpDbContext db) : IUnitOfWork
|
||||
{
|
||||
public Task<int> SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
|
||||
|
||||
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||
{
|
||||
await action(ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||
{
|
||||
var result = await action(ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeNumberSequence : INumberSequenceService
|
||||
{
|
||||
private int _next;
|
||||
public Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
|
||||
public Task<string> NextAsync(string docType, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
|
||||
}
|
||||
|
||||
/// <summary>Branching on: the requested branch is used as-is.</summary>
|
||||
internal sealed class FakeBranchConfig : IBranchConfigService
|
||||
{
|
||||
public Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ETagged<BranchConfigDto>> UpdateAsync(UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
public Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
|
||||
=> requestedBranchId > 0 ? Task.FromResult(requestedBranchId) : throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
|
||||
public Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
public Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default) => Task.FromResult(requestedBranchId);
|
||||
public Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default) => Task.FromResult(1);
|
||||
}
|
||||
|
||||
/// <summary>Seeds and services for the cash-in-hand / bank-deposit tests. Two branches, one warehouse each.</summary>
|
||||
internal static class BankDepositTestSupport
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
public const int Branch1 = 1, Branch2 = 2;
|
||||
public const int Warehouse1 = 1, Warehouse2 = 2;
|
||||
|
||||
public static BusinessDayClock Clock { get; } = new(Config());
|
||||
|
||||
public static IConfiguration Config(DateOnly? trackingStart = null) => new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["DayEndReport:TimeZone"] = "Asia/Colombo",
|
||||
["BankDeposit:DefaultCashAccountCode"] = "1000-01",
|
||||
["BankDeposit:CashTrackingStartDate"] = trackingStart?.ToString("yyyy-MM-dd")
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>Midday on a local business day, as the UTC instant documents are stamped with.</summary>
|
||||
public static DateTime At(DateOnly day) => Clock.ToUtc(day).AddHours(6);
|
||||
|
||||
public static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
var currentUser = new TestCurrentUser();
|
||||
var db = new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
|
||||
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||
db.Branches.AddRange(
|
||||
new Branch { BranchId = Branch1, Code = "BR-001", Name = "Colombo" },
|
||||
new Branch { BranchId = Branch2, Code = "BR-002", Name = "Kandy" });
|
||||
db.Warehouses.AddRange(
|
||||
new Warehouse { WarehouseId = Warehouse1, Code = "WH-1", Name = "Colombo Store", BranchId = Branch1 },
|
||||
new Warehouse { WarehouseId = Warehouse2, Code = "WH-2", Name = "Kandy Store", BranchId = Branch2 });
|
||||
db.SaveChanges();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static int _id;
|
||||
|
||||
public static SalesSlip CashSlip(DateOnly day, decimal cash, decimal card = 0m, int warehouseId = Warehouse1)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _id);
|
||||
return new SalesSlip
|
||||
{
|
||||
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = At(day), Status = SalesSlipStatus.Posted, WarehouseId = warehouseId,
|
||||
GrandTotal = cash + card, PaidAmount = cash + card, CashAmount = cash, CardAmount = card
|
||||
};
|
||||
}
|
||||
|
||||
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
|
||||
=> new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
|
||||
|
||||
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
|
||||
{
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new BankDepositService(
|
||||
new Repository<BankDeposit>(db), new Repository<Branch>(db), CashService(db, trackingStart), gl,
|
||||
currentUser, new CurrentBranchContext(currentUser), new FakeNumberSequence(), new FakeBranchConfig(),
|
||||
new PassThroughUnitOfWork(db), db, Clock);
|
||||
}
|
||||
|
||||
public static BankDeposit Deposit(DateOnly businessDate, decimal amount, int branchId = Branch1,
|
||||
BankDepositStatus status = BankDepositStatus.Posted)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _id);
|
||||
return new BankDeposit
|
||||
{
|
||||
BankDepositId = id, DocNo = $"BD-{id}", BranchId = branchId, BusinessDate = businessDate, DepositDate = businessDate,
|
||||
Amount = amount, Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
|
||||
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current", CreatedBy = User.SystemUserId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
|
||||
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
|
||||
/// doing so fails loudly rather than against a silently empty fake.
|
||||
/// </summary>
|
||||
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IReadOnlyList<GlAccount> _accounts;
|
||||
private readonly Exception? _throws;
|
||||
|
||||
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
|
||||
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>A postable, active account — the ordinary case.</summary>
|
||||
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
|
||||
|
||||
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
|
||||
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
|
||||
|
||||
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return _throws is not null
|
||||
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
|
||||
: Task.FromResult(_accounts);
|
||||
}
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public static DomainException Unreachable() =>
|
||||
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
|
||||
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
|
||||
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
|
||||
{
|
||||
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
|
||||
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -11,6 +11,13 @@ internal sealed class TestCurrentUser : ICurrentUser
|
||||
public string UserId => "system";
|
||||
public int AuditUserId => User.SystemUserId;
|
||||
public bool IsAuthenticated => false;
|
||||
|
||||
// Unauthenticated, so no role claim and no branch scoping: the three members the
|
||||
// AuthHex integration added to ICurrentUser.
|
||||
public int? RoleId => null;
|
||||
public string? RoleCode => null;
|
||||
public bool IsAdmin => false;
|
||||
public int? BranchId => null;
|
||||
}
|
||||
|
||||
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
||||
@@ -25,6 +32,25 @@ internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
|
||||
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
|
||||
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
|
||||
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
|
||||
/// outside it.
|
||||
/// </summary>
|
||||
internal sealed class ThrowingTransactionUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
||||
{
|
||||
public Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
|
||||
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
|
||||
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
|
||||
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
|
||||
}
|
||||
|
||||
internal static class NotificationConfigTestSupport
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
@@ -35,7 +61,10 @@ internal static class NotificationConfigTestSupport
|
||||
.UseInMemoryDatabase(name, Root)
|
||||
.Options;
|
||||
|
||||
return new ErpDbContext(options, new TestCurrentUser());
|
||||
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
|
||||
// which leaves the branch query filters off: what a unit test wants.
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
|
||||
@@ -52,7 +81,8 @@ internal static class NotificationConfigTestSupport
|
||||
NotificationConfigId = NotificationConfig.SingletonId,
|
||||
UpdatedBy = User.SystemUserId,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
RowVersion = 7,
|
||||
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
|
||||
// inserted row with 1, so any seeded value is overwritten on save.
|
||||
};
|
||||
|
||||
if (modules is not 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
|
||||
/// signed-in user — it only lists what they may approve and their own requests. Reading the
|
||||
/// settings is open too (screens need to know whether prices are locked); changing them needs
|
||||
/// <c>settings.approvals</c>.
|
||||
/// </summary>
|
||||
[Route("api/v1/approvals")]
|
||||
public sealed class ApprovalsController : ApiControllerBase
|
||||
{
|
||||
private readonly ApprovalInboxService _inbox;
|
||||
private readonly IApprovalService _approvals;
|
||||
|
||||
public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
|
||||
{
|
||||
_inbox = inbox;
|
||||
_approvals = approvals;
|
||||
}
|
||||
|
||||
[HttpGet("pending")]
|
||||
[ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PendingApprovalsDto>> Pending(CancellationToken ct)
|
||||
=> Ok(await _inbox.PendingAsync(ct));
|
||||
|
||||
[HttpGet("config")]
|
||||
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApprovalConfigDto>> GetConfig(CancellationToken ct)
|
||||
=> Ok(await _approvals.GetConfigAsync(ct));
|
||||
|
||||
[HttpPut("config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
|
||||
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApprovalConfigDto>> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
|
||||
=> Ok(await _approvals.UpdateConfigAsync(request, ct));
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||
/// only way to consume it.
|
||||
/// </summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
|
||||
[Route("api/v1/audit-logs")]
|
||||
public sealed class AuditLogsController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
|
||||
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||
{
|
||||
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||
return Ok(await _roles.GetMeAsync(ct));
|
||||
}
|
||||
|
||||
// ---- Session-issuing (UserManager) ------------------------------------
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Accounts;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
|
||||
/// Recording needs the Create action on <c>accounts.bank-deposits</c>; cancelling needs Delete.
|
||||
/// </summary>
|
||||
[Route("api/v1/bank-deposits")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
|
||||
public sealed class BankDepositsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBankDepositService _deposits;
|
||||
private readonly ICashInHandService _cash;
|
||||
|
||||
public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
|
||||
{
|
||||
_deposits = deposits;
|
||||
_cash = cash;
|
||||
}
|
||||
|
||||
/// <summary>Deposits filtered by business date range, branch, bank account and status, plus their total.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BankDepositListDto>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
|
||||
[FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
|
||||
=> Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
|
||||
|
||||
[HttpGet("{bankDepositId:int}")]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BankDepositDto>> GetById(int bankDepositId, CancellationToken ct)
|
||||
{
|
||||
var result = await _deposits.GetAsync(bankDepositId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>One business day's cash position (defaults to today) and how much of it can still be deposited.</summary>
|
||||
[HttpGet("cash-in-hand")]
|
||||
[ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CashInHandDto>> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _cash.GetPositionAsync(date, branchId, ct));
|
||||
|
||||
/// <summary>Day-wise cash position for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||
[HttpGet("cash-in-hand/daily")]
|
||||
[ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CashInHandDailyDto>> CashInHandDaily(
|
||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
|
||||
|
||||
/// <summary>Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<BankDepositDto>> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _deposits.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.</summary>
|
||||
[HttpPost("{bankDepositId:int}/cancel")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<BankDepositDto>> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
|
||||
=> Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/branch-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class BranchConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IBranchConfigService _config;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
[Route("api/v1/brands")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class BrandsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBrandService _brands;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/bundle-sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class BundleSalesController : ApiControllerBase
|
||||
{
|
||||
private readonly IBundleSaleService _bundles;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
||||
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||
/// </summary>
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class CategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Customers;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
@@ -20,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false,
|
||||
[FromQuery] int? branchId = null)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -6,15 +8,29 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||
[Route("api/v1/dashboard")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
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")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats(
|
||||
[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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Reports;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
|
||||
/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
|
||||
[Route("api/v1/day-end-report")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
|
||||
public sealed class DayEndReportController : ApiControllerBase
|
||||
{
|
||||
private readonly IDayEndReportService _reports;
|
||||
|
||||
public DayEndReportController(IDayEndReportService reports) => _reports = reports;
|
||||
|
||||
/// <summary>One business day (defaults to today in the report's time zone).</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DayEndReportDto>> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _reports.GetDayAsync(date, branchId, ct));
|
||||
|
||||
/// <summary>Day-wise figures for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
|
||||
[HttpGet("daily")]
|
||||
[ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<DayEndDailyReportDto>> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
|
||||
/// anyone with Accounts access (the expense form needs the lists); changes need the
|
||||
/// Expense Setup permission. Categories are the fixed seeded four — there is no create
|
||||
/// endpoint; only their active flag can change. Titles can be added under them.
|
||||
/// </summary>
|
||||
[Route("api/v1")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts")]
|
||||
public sealed class ExpenseSetupController : ApiControllerBase
|
||||
{
|
||||
private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
|
||||
|
||||
private readonly IExpenseSetupService _setup;
|
||||
|
||||
public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
|
||||
|
||||
/// <summary>Categories, each with its titles, in display order.</summary>
|
||||
[HttpGet("expense-categories")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpenseCategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpenseCategoryDto>>> List([FromQuery] bool includeInactive, CancellationToken ct)
|
||||
=> Ok(await _setup.ListAsync(includeInactive, ct));
|
||||
|
||||
[HttpPut("expense-categories/{expenseCategoryId:int}")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseCategoryDto>> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||
=> Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
|
||||
|
||||
[HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseTitleDto>> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
|
||||
return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("expense-titles/{expenseTitleId:int}")]
|
||||
[Authorize(Policy = SetupPolicy)]
|
||||
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseTitleDto>> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
|
||||
=> Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Expenses;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).</summary>
|
||||
[Route("api/v1/expenses")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
|
||||
public sealed class ExpensesController : ApiControllerBase
|
||||
{
|
||||
private readonly IExpenseService _expenses;
|
||||
private readonly IExpensePaymentService _payments;
|
||||
|
||||
public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
|
||||
{
|
||||
_expenses = expenses;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ExpenseSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ExpenseSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
|
||||
[FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] bool overdueOnly, CancellationToken ct)
|
||||
=> Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
|
||||
|
||||
/// <summary>Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.</summary>
|
||||
[HttpGet("summary")]
|
||||
[ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ExpenseSummaryReportDto>> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _expenses.GetSummaryAsync(from, to, ct));
|
||||
|
||||
[HttpGet("{expenseId:int}")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ExpenseDto>> GetById(int expenseId, CancellationToken ct)
|
||||
{
|
||||
var result = await _expenses.GetAsync(expenseId, ct);
|
||||
if (result is null) return NotFound();
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ExpenseDto>> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _expenses.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{expenseId:int}")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ExpenseDto>> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Draft only.</summary>
|
||||
[HttpDelete("{expenseId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int expenseId, CancellationToken ct)
|
||||
{
|
||||
await _expenses.DeleteAsync(expenseId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).</summary>
|
||||
[HttpPost("{expenseId:int}/post")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> Post(int expenseId, CancellationToken ct)
|
||||
=> Ok(await _expenses.PostAsync(expenseId, ct));
|
||||
|
||||
/// <summary>Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.</summary>
|
||||
[HttpPost("{expenseId:int}/post-and-pay")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
|
||||
|
||||
/// <summary>Draft, or Posted with no payments (posts the reversing GL entry).</summary>
|
||||
[HttpPost("{expenseId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ExpenseDto>> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
|
||||
=> Ok(await _expenses.CancelAsync(expenseId, request, ct));
|
||||
|
||||
/// <summary>Pay against the balance, in full or in installments; one GL entry per submission.</summary>
|
||||
[HttpPost("{expenseId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var rows = await _payments.PayAsync(expenseId, request, ct);
|
||||
return Created($"/api/v1/expenses/{expenseId}/payments", rows);
|
||||
}
|
||||
|
||||
/// <summary>Payment history, newest first.</summary>
|
||||
[HttpGet("{expenseId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> ListPayments(int expenseId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(expenseId, ct));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
||||
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||
/// </summary>
|
||||
[Route("api/v1/free-issues")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class FreeIssuesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
@@ -29,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
|
||||
[HttpPut("{**path}")]
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return new ContentResult
|
||||
{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Role → GL account mapping endpoints.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
|
||||
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
|
||||
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
|
||||
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
|
||||
/// and the server-side validation work from the same list.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/gl-account-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class GlAccountConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGlAccountConfigService _config;
|
||||
|
||||
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
|
||||
/// not be reached, since a mapping that could not be verified is not stored.
|
||||
/// </summary>
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<ActionResult<GlAccountConfigDto>> Update(
|
||||
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
|
||||
/// Phase-1 posture as <see cref="SalesConfigController"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/grn-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnConfigService _config;
|
||||
|
||||
public GrnConfigController(IGrnConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<GrnConfigDto>> Update(
|
||||
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||
[Route("api/v1/grns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
@@ -22,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
|
||||
|
||||
[HttpGet("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
|
||||
[Route("api/v1/attendance-batches")]
|
||||
public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
||||
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
||||
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
||||
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
|
||||
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{batchId:int}/resolve-duplicate")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{batchId:int}/validate")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{batchId:int}/confirm")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
||||
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{batchId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPut("{branchId:int}")]
|
||||
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpPatch("{branchId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
|
||||
[HttpDelete("{branchId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/departments")]
|
||||
public sealed class DepartmentsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{departmentId:int}")]
|
||||
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{departmentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{departmentId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/designations")]
|
||||
public sealed class DesignationsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{designationId:int}")]
|
||||
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{designationId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{designationId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
|
||||
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
||||
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
||||
/// </summary>
|
||||
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
|
||||
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/employees")]
|
||||
public sealed class EmployeesController : ApiControllerBase
|
||||
{
|
||||
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
|
||||
|
||||
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("email-lookup")]
|
||||
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{employeeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -93,16 +102,18 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[HttpDelete("{employeeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
|
||||
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
|
||||
{
|
||||
await _employees.DeleteAsync(employeeId, ct);
|
||||
return NoContent();
|
||||
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpDelete("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}/bank-details")]
|
||||
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -140,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
|
||||
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{employeeId:int}/leave-balances")]
|
||||
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
||||
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/salary-structure")]
|
||||
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _loans.ListAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{employeeId:int}/loans")]
|
||||
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}/documents")]
|
||||
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
||||
=> Ok(await _documents.ListAsync(employeeId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPost("{employeeId:int}/documents")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
||||
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
|
||||
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return File(content, contentType, fileName);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/employment-types")]
|
||||
public sealed class EmploymentTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{employmentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{employmentTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/hr-document-types")]
|
||||
public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{hrDocumentTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
|
||||
[Route("api/v1/reports/hrm")]
|
||||
public sealed class HrReportsController : ApiControllerBase
|
||||
{
|
||||
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
|
||||
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("payroll-register")]
|
||||
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[HttpGet("salary-history")]
|
||||
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
|
||||
|
||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
|
||||
[Route("api/v1/leave-requests")]
|
||||
public sealed class LeaveRequestsController : ApiControllerBase
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
|
||||
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{leaveRequestId:int}/submit")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{leaveRequestId:int}/approve")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
||||
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{leaveRequestId:int}/reject")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
|
||||
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{leaveRequestId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/leave-types")]
|
||||
public sealed class LeaveTypesController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{leaveTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{leaveTypeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[Route("api/v1/payroll-runs")]
|
||||
public sealed class PayrollRunsController : ApiControllerBase
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
|
||||
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/approve")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/lock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[HttpPost("{payrollRunId:int}/unlock")]
|
||||
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
|
||||
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost("{payrollRunId:int}/generate-payslips")]
|
||||
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/payroll-statutory-settings")]
|
||||
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||
{
|
||||
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _settings.ListAsync(ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
|
||||
[Route("api/v1/payslips")]
|
||||
public sealed class PayslipsController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/salary-components")]
|
||||
public sealed class SalaryComponentsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{salaryComponentId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{salaryComponentId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/tax-slabs")]
|
||||
public sealed class TaxSlabsController : ApiControllerBase
|
||||
{
|
||||
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
|
||||
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
||||
=> Ok(await _taxSlabs.ListAsync(ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers.Hrm;
|
||||
|
||||
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
|
||||
[Route("api/v1/work-shifts")]
|
||||
public sealed class WorkShiftsController : ApiControllerBase
|
||||
{
|
||||
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPut("{workShiftId:int}")]
|
||||
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpPatch("{workShiftId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
|
||||
[HttpDelete("{workShiftId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
|
||||
/// into the client-generated SKU (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
[Route("api/v1/item-types")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTypeService _itemTypes;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
||||
[Route("api/v1/items")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemService _items;
|
||||
@@ -27,6 +30,40 @@ public sealed class ItemsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||
|
||||
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
|
||||
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
[HttpGet("scan")]
|
||||
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
|
||||
{
|
||||
var result = await _items.ScanAsync(code, itemId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The item's latest posted GRN receipts (default 5, newest first): unit cost, discount, net cost, vendor.</summary>
|
||||
[HttpGet("{itemId:int}/purchase-prices")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ItemPurchasePriceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<ItemPurchasePriceDto>>> PurchasePrices(int itemId, [FromQuery] int take, CancellationToken ct)
|
||||
=> Ok(await _items.GetPurchasePricesAsync(itemId, take <= 0 ? 5 : take, ct));
|
||||
|
||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||
[HttpGet("{itemId:int}")]
|
||||
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// Data only — no posting in Phase 1.
|
||||
/// </summary>
|
||||
[Route("api/v1/journal-entries")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class JournalEntriesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/notification-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class NotificationConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly INotificationConfigService _config;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/notifications")]
|
||||
public sealed class NotificationsController : ApiControllerBase
|
||||
{
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
|
||||
{
|
||||
_notifications = notifications;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
|
||||
{
|
||||
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
|
||||
return Ok(feed);
|
||||
}
|
||||
|
||||
[HttpPost("mark-all-read")]
|
||||
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
|
||||
{
|
||||
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -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,89 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Products;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
|
||||
/// <c>products.price-adjustments</c>, deciding needs Approve; an item's applied price history is
|
||||
/// readable by anyone who can see Products.
|
||||
/// </summary>
|
||||
[Route("api/v1/price-adjustments")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class PriceAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
|
||||
private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
|
||||
private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
|
||||
|
||||
private readonly IPriceAdjustmentService _prices;
|
||||
|
||||
public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PagedResponse<PriceAdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PriceAdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
|
||||
=> Ok(await _prices.ListAsync(query, status, itemId, ct));
|
||||
|
||||
[HttpGet("{priceAdjustmentId:int}")]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> GetById(int priceAdjustmentId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _prices.GetAsync(priceAdjustmentId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Request new selling prices for one or more items.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = CreatePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _prices.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Give the next approval; the final one applies the prices.</summary>
|
||||
[HttpPost("{priceAdjustmentId:int}/approve")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
|
||||
=> Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
|
||||
|
||||
[HttpPost("{priceAdjustmentId:int}/reject")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
|
||||
=> Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
|
||||
|
||||
/// <summary>The requester withdraws a pending request.</summary>
|
||||
[HttpPost("{priceAdjustmentId:int}/cancel")]
|
||||
[Authorize(Policy = Screen)]
|
||||
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PriceAdjustmentDto>> Cancel(int priceAdjustmentId, CancellationToken ct)
|
||||
=> Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
|
||||
|
||||
/// <summary>Every applied price change for an item, newest first, with who approved it.</summary>
|
||||
[HttpGet("/api/v1/items/{itemId:int}/price-history")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ItemPriceHistoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<ItemPriceHistoryDto>>> ItemHistory(int itemId, CancellationToken ct)
|
||||
=> Ok(await _prices.ItemHistoryAsync(itemId, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/product-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ProductConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductConfigService _config;
|
||||
@@ -42,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||
[Route("api/v1/production-runs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionRunService _runs;
|
||||
@@ -53,6 +56,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _runs.CreateAsync(request, ct);
|
||||
@@ -66,6 +70,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
|
||||
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
|
||||
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
|
||||
@@ -83,6 +88,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<StartStageResultDto>> Start(
|
||||
int runId, int runStageId,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -95,6 +101,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RunStageDto>> Complete(
|
||||
int runId, int runStageId, [FromBody] CompleteStageRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -106,6 +113,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ApproveStageResultDto>> Approve(
|
||||
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -117,6 +125,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TransferResultDto>> Transfer(
|
||||
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -129,6 +138,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
|
||||
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -139,6 +149,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -150,6 +161,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
|
||||
int runId, int runStageId, [FromBody] RejectRequest? request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
@@ -162,6 +174,7 @@ public sealed class ProductionRunsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CancelRunResultDto>> Cancel(
|
||||
int runId, [FromBody] CancelRunRequest request,
|
||||
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||
[Route("api/v1/production-templates")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionTemplateService _templates;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
@@ -9,6 +11,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
|
||||
[Route("api/v1/purchase-invoices")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseInvoiceService _invoices;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
||||
[Route("api/v1/purchase-orders")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseOrderService _pos;
|
||||
@@ -17,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
||||
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
|
||||
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
|
||||
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
|
||||
|
||||
[HttpGet("{poId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
@@ -32,7 +36,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
|
||||
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -57,7 +61,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
[HttpPost("{poId:int}/submit")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -67,6 +71,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
|
||||
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||
[HttpDelete("{poId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -76,13 +81,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
||||
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
|
||||
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
|
||||
[HttpPost("{poId:int}/approve")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
|
||||
=> Ok(await _pos.ApproveAsync(poId, ct));
|
||||
|
||||
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
|
||||
/// Same caller restrictions as <see cref="Approve"/>.</summary>
|
||||
[HttpPost("{poId:int}/reject")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
|
||||
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
|
||||
|
||||
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
|
||||
[HttpPost("{poId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||
[Route("api/v1/purchase-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseReturnService _returns;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
|
||||
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
||||
[Route("api/v1/requisitions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RequisitionsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRequisitionService _requisitions;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
||||
[Route("api/v1/rfqs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RfqsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRfqService _rfqs;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||
[Route("api/v1/roles")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class RolesController : ApiControllerBase
|
||||
{
|
||||
private readonly IRoleService _roles;
|
||||
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{roleId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
|
||||
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
|
||||
[Route("api/v1/sales-day-end")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesDayEndController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesDayEndService _dayEnds;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,15 +9,75 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-invoices")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
|
||||
// Under "sales" for the same reason as above; items absent from the list have no stock there.
|
||||
[HttpGet("stock-summary")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
|
||||
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -26,8 +88,30 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[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.ListAsync(query, status, customerId, warehouseId, branchId, 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}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
@@ -89,6 +173,18 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
|
||||
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
|
||||
/// </summary>
|
||||
[HttpPost("payments/bulk")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
=> Ok(await _payments.PayBulkAsync(request, ct));
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/reports/sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesReportsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReportService _reports;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||
[Route("api/v1/sales-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
|
||||
public sealed class SalesReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReturnService _returns;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesPromotionSuggestionService _suggestions;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Search;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/search")]
|
||||
public sealed class SearchController : ApiControllerBase
|
||||
{
|
||||
private readonly IGlobalSearchService _search;
|
||||
|
||||
public SearchController(IGlobalSearchService search) => _search = search;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
|
||||
{
|
||||
var results = await _search.SearchAsync(q, 6, ct);
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
|
||||
[Route("api/v1/serials")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class SerialsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTrackingService _tracking;
|
||||
|
||||
public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SerialSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
|
||||
|
||||
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
|
||||
[HttpGet("lookup")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> Lookup([FromQuery] string serialNo, CancellationToken ct)
|
||||
=> Ok(await _tracking.FindSerialAsync(serialNo, ct));
|
||||
|
||||
[HttpGet("{serialId:int}")]
|
||||
[ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _tracking.GetSerialAsync(serialId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Approvals;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -5,22 +9,30 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
/// <summary>
|
||||
/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
|
||||
/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
|
||||
/// <c>stock.adjustments</c>; the two-person rules are enforced by the service.
|
||||
/// </summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
|
||||
|
||||
private readonly IAdjustmentService _adjustments;
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>List posted adjustments, newest first.</summary>
|
||||
/// <summary>List adjustments (posted and requests), newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
|
||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
|
||||
[FromQuery] AdjustmentStatus? status, CancellationToken ct, [FromQuery] bool wastage = false, [FromQuery] int? itemId = null)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct, wastage, itemId));
|
||||
|
||||
/// <summary>Get one adjustment with its lines 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}")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -30,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
/// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -41,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
var dto = await _adjustments.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Give the next approval. The final one posts the stock movement and GL.</summary>
|
||||
[HttpPost("{adjustmentId:int}/approve")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AdjustmentDto>> Approve(int adjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ApproveAsync(adjustmentId, request?.Comment, ct));
|
||||
|
||||
[HttpPost("{adjustmentId:int}/reject")]
|
||||
[Authorize(Policy = ApprovePolicy)]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AdjustmentDto>> Reject(int adjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
|
||||
=> Ok(await _adjustments.RejectAsync(adjustmentId, request.Reason, ct));
|
||||
|
||||
/// <summary>The requester withdraws a pending request.</summary>
|
||||
[HttpPost("{adjustmentId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<AdjustmentDto>> Cancel(int adjustmentId, CancellationToken ct)
|
||||
=> Ok(await _adjustments.CancelAsync(adjustmentId, ct));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,17 +10,31 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||
[Route("api/v1/stock")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
private readonly IReorderService _reorder;
|
||||
private readonly IItemTrackingService _tracking;
|
||||
|
||||
public StockController(IStockService stock, IReorderService reorder)
|
||||
public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
|
||||
{
|
||||
_stock = stock;
|
||||
_reorder = reorder;
|
||||
_tracking = tracking;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
|
||||
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
|
||||
/// </summary>
|
||||
[HttpGet("batches")]
|
||||
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BatchStockDto>>> Batches(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
|
||||
|
||||
[HttpGet("on-hand")]
|
||||
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
@@ -42,8 +58,9 @@ public sealed class StockController : ApiControllerBase
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||
[FromQuery] PageQuery query, CancellationToken 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")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||
[Route("api/v1/stock-counts")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockCountsController : ApiControllerBase
|
||||
{
|
||||
private readonly ICountService _counts;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||
[Route("api/v1/stock-transfers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockTransfersController : ApiControllerBase
|
||||
{
|
||||
private readonly ITransferService _transfers;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// since a subcategory only exists in the context of one.
|
||||
/// </summary>
|
||||
[Route("api/v1/subcategories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class SubCategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using ERPCore.Dtos.Support;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/support-tickets")]
|
||||
public sealed class SupportTicketsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISupportTicketService _tickets;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
|
||||
{
|
||||
_tickets = tickets;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
|
||||
{
|
||||
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
|
||||
{
|
||||
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/messages")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/status")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.TaxRates;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
|
||||
[Route("api/v1/tax-rates")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class TaxRatesController : ApiControllerBase
|
||||
{
|
||||
private readonly ITaxRateService _taxRates;
|
||||
|
||||
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _taxRates.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{taxRateId:int}")]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _taxRates.GetAsync(taxRateId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _taxRates.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{taxRateId:int}")]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{taxRateId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Serial/batch pickers used across GRN, sales, returns and stock screens
|
||||
/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
|
||||
/// only ERP access — not one module's nav permission — as every one of those screens calls them.
|
||||
/// </summary>
|
||||
[Route("api/v1/tracking")]
|
||||
public sealed class TrackingController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTrackingService _tracking;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
|
||||
public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
|
||||
{
|
||||
_tracking = tracking;
|
||||
_serialTracking = serialTracking;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which tracking features are switched on (Product Configuration) — readable by every
|
||||
/// screen, unlike <c>/product-config</c> which needs the Products permission.
|
||||
/// </summary>
|
||||
[HttpGet("settings")]
|
||||
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
|
||||
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
|
||||
|
||||
/// <summary>
|
||||
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
|
||||
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
|
||||
/// uniqueness when it is saved and confirmed.
|
||||
/// </summary>
|
||||
[HttpGet("serials/next-numbers")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
|
||||
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
|
||||
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
|
||||
|
||||
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
|
||||
[HttpGet("serials/available")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> AvailableSerials(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
|
||||
=> Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
|
||||
|
||||
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
|
||||
[HttpGet("batches/available")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
|
||||
[HttpGet("batches/suggest")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
|
||||
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
[Route("api/v1/uoms")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class UomsController : ApiControllerBase
|
||||
{
|
||||
private readonly IUomService _uoms;
|
||||
@@ -15,8 +19,9 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -26,4 +31,14 @@ public sealed class UomsController : ApiControllerBase
|
||||
var dto = await _uoms.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{uomId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -14,6 +17,7 @@ namespace ERPCore.Controllers;
|
||||
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||
/// </summary>
|
||||
[Route("api/v1/users")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
@@ -37,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
[HttpGet("user-types")]
|
||||
@@ -70,17 +74,63 @@ public sealed class UsersController : ApiControllerBase
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||
|
||||
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||
/// <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")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||
|
||||
/// <summary>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"
|
||||
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||
[HttpGet("{userId:int}/login-devices")]
|
||||
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
|
||||
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
|
||||
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
|
||||
/// </summary>
|
||||
[HttpPost("purge-authhex-identity")]
|
||||
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!_currentUser.IsAdmin)
|
||||
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
|
||||
|
||||
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,13 +1,16 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Vendors;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
[Route("api/v1/vendors")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class VendorsController : ApiControllerBase
|
||||
{
|
||||
private readonly IVendorService _vendors;
|
||||
@@ -32,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -42,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPut("{vendorId:int}")]
|
||||
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -54,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
|
||||
[HttpPatch("{vendorId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -62,4 +68,15 @@ public sealed class VendorsController : ApiControllerBase
|
||||
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{vendorId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int vendorId, CancellationToken ct)
|
||||
{
|
||||
await _vendors.DeleteAsync(vendorId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Warehouses;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||
[Route("api/v1/warehouses")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
|
||||
public sealed class WarehousesController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouses;
|
||||
@@ -37,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
|
||||
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("{warehouseId:int}")]
|
||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
|
||||
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
|
||||
|
||||
[HttpDelete("{warehouseId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
|
||||
{
|
||||
await _warehouses.DeleteAsync(warehouseId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{warehouseId:int}/bins")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
|
||||
/// posting a sale, never directly — this controller only reads.
|
||||
/// </summary>
|
||||
[Route("api/v1/warranties")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class WarrantiesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTrackingService _tracking;
|
||||
|
||||
public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> List(
|
||||
[FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
|
||||
[FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,13 @@ public static class DocumentTypes
|
||||
|
||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||
public const string SalesDayEnd = "DEND";
|
||||
|
||||
/// <summary>Business expense bill (Accounts) — <c>EXP-BR-001-2026-00001</c>.</summary>
|
||||
public const string Expense = "EXP";
|
||||
|
||||
/// <summary>Till cash deposited to a bank account (Accounts) — <c>BD-BR-001-2026-00001</c>.</summary>
|
||||
public const string BankDeposit = "BD";
|
||||
|
||||
/// <summary>Selling-price change request (Products) — <c>PADJ-2026-00001</c>.</summary>
|
||||
public const string PriceAdjustment = "PADJ";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user