389 lines
19 KiB
C#
389 lines
19 KiB
C#
using ERPCore.Domain;
|
|
using ERPCore.Domain.Entities;
|
|
using ERPCore.Domain.Enums;
|
|
using ERPCore.Dtos.Approvals;
|
|
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.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;
|
|
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
|
|
|
|
// --- 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(level2Roles: "Approver");
|
|
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));
|
|
|
|
// --- helpers ------------------------------------------------------------------------------
|
|
|
|
private static CreateAdjustmentRequest Request(decimal qty) => 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? level2Roles = null)
|
|
{
|
|
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.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
|
|
db.ApprovalConfig.Add(new ApprovalConfig
|
|
{
|
|
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
|
|
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
|
|
});
|
|
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), 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 ConfigurationBuilder().Build(),
|
|
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
|
new Repository<ProductConfig>(db), user));
|
|
var prices = new PriceAdjustmentService(
|
|
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
|
|
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
|
|
|
|
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"
|
|
};
|
|
|
|
public int AuditUserId { get; private set; } = 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;
|
|
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) => 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();
|
|
}
|
|
}
|