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.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; /// Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13). 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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 CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null) { var db = new ErpDbContext( new DbContextOptionsBuilder().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, Level2RoleIds = level2RoleIds }); await db.SaveChangesAsync(); var user = new SwitchableUser(Requester, "Requester"); var uow = new PassThroughUnitOfWork(db); var notifications = new RecordingNotifications(); var approvals = new ApprovalService( new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), user, notifications, uow); var gl = new FakeGeneralLedger(); var mutator = new RecordingMutator(); var numbers = new FakeNumberSequence(); var stock = new AdjustmentService( new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(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(db), new Repository(db), new Repository(db), new Repository(db), user)); var prices = new PriceAdjustmentService( new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), approvals, notifications, numbers, user, uow); return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices }; } } /// A signed-in user whose identity the test switches between steps. private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser { private static readonly Dictionary Roles = new() { [Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk" }; private static readonly Dictionary 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]; } } /// Records what would be posted to stock; every unit is worth 10.00. private sealed class RecordingMutator : IStockMutator { public List> Calls { get; } = new(); public Task> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now, IReadOnlyList deltas, CancellationToken ct = default) { Calls.Add(deltas); IReadOnlyList 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); } } /// 10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold. private sealed class FixedValuation : IFifoCostingService { public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO")); public Task 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> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, bool allowShortfall = false, IReadOnlyCollection? serialIds = null) => throw new NotSupportedException(); public Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default) => throw new NotSupportedException(); public Task 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 GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m); } private sealed class RecordingNotifications : INotificationService { public List<(IReadOnlyList 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 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 GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException(); public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException(); } }