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;
/// Low-stock / reorder alerts raised after stock issues.
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(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 GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
public Task> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task> 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 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 userIds, string moduleKey, string title, string message,
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
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();
}
}