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