price adjustment and stock adjustments
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user