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) => 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(); } }