Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Персистентные уведомления (таблица notifications, Phase41) + REST
/api/notifications (список/счётчик/read/read-all) → центр уведомлений в вебе
(колокольчик, API-backed) и мобильном API.
Фоновый джоб NegativeStockNotificationJob (cron */5): агрегирует новые минусы
в ОДНО уведомление на склад (дебаунс по NotifiedAt + интервал орг., по умолч.
15 мин), уважает тихие часы (Asia/Almaty); эскалация просроченных (>N часов,
по умолч. 24) владельцу/админам. Получатели — админы, владелец, кладовщики
(право InventoryEdit). Каналы: SignalR (живой колокольчик), Telegram (владельцу),
push-заглушка.
Настройки организации: NegStockNotify{Enabled,IntervalMinutes,QuietFrom/ToHour,
EscalateHours} + GET/PUT /api/organization/settings + секция в настройках веба.
Блок «Просроченные минусы» на дашборде.
Тесты: unit тихих часов 3/3; integration — джоб создаёт уведомление админу,
дебаунс, read-all 3/3 (вся группа NegativeStockTests зелёная).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
9 KiB
C#
163 lines
9 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.Api.Background;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
[Collection(ApiCollection.Name)]
|
||
public class NegativeStockTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public NegativeStockTests(ApiFactory factory) => _factory = factory;
|
||
|
||
private static string RandomBarcode()
|
||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||
|
||
private async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
|
||
{
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId = productId, quantity = qty, unitCost = 50m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Loss_into_negative_is_allowed_creates_alert_recount_and_resolves_on_restock()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// 5 шт на складе, минусов нет.
|
||
await SeedStockAsync(api, refs, p, 5m);
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(5m);
|
||
var c0 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c0.GetProperty("total").GetInt32().Should().Be(0);
|
||
|
||
// Списание 8 → остаток −3. Списание в минус РАЗРЕШЕНО (решение владельца).
|
||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
reason = 0, notes = "тест минус",
|
||
lines = new[] { new { productId = p, quantity = 8m, unitCost = 50m } },
|
||
});
|
||
loss.EnsureSuccessStatusCode();
|
||
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
using var lpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { });
|
||
lpost.IsSuccessStatusCode.Should().BeTrue($"списание в минус разрешено: {(int)lpost.StatusCode} {await lpost.Content.ReadAsStringAsync()}");
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(-3m);
|
||
|
||
// Предупреждение создано.
|
||
var c1 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c1.GetProperty("total").GetInt32().Should().Be(1);
|
||
var alerts = await api.ListAsync("/api/negative-stock");
|
||
alerts.Should().ContainSingle();
|
||
alerts[0].GetProperty("quantity").GetDecimal().Should().Be(-3m);
|
||
alerts[0].GetProperty("productId").GetString().Should().Be(p);
|
||
alerts[0].GetProperty("causeDocType").GetString().Should().Be("loss");
|
||
|
||
// «Пересчитать эти товары» → черновик инвентаризации с этим товаром.
|
||
var rc = await api.Http.PostAsJsonAsync("/api/negative-stock/recount", new { storeId = refs.StoreId });
|
||
rc.EnsureSuccessStatusCode();
|
||
var rcj = await rc.Content.ReadFromJsonAsync<JsonElement>();
|
||
rcj.GetProperty("lines").GetInt32().Should().Be(1);
|
||
var invId = rcj.GetProperty("inventoryId").GetString();
|
||
var inv = await api.GetJsonAsync($"/api/inventory/inventories/{invId}");
|
||
inv.GetProperty("lines").EnumerateArray()
|
||
.Should().ContainSingle(l => l.GetProperty("productId").GetString() == p);
|
||
|
||
// Довоз 3 → остаток 0 → предупреждение закрылось автоматически.
|
||
await SeedStockAsync(api, refs, p, 3m);
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(0m);
|
||
var c2 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c2.GetProperty("total").GetInt32().Should().Be(0);
|
||
(await api.ListAsync("/api/negative-stock")).Should().BeEmpty("минус закрыт — алерт resolved");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Negative_stock_report_filter_returns_only_negatives()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"neg-rep-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
await SeedStockAsync(api, refs, p, 2m);
|
||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } },
|
||
});
|
||
loss.EnsureSuccessStatusCode();
|
||
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
var neg = await api.GetJsonAsync("/api/inventory/stock?negativeOnly=true&pageSize=200");
|
||
var items = neg.GetProperty("items").EnumerateArray().ToList();
|
||
items.Should().NotBeEmpty();
|
||
items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Notify_job_creates_persistent_notification_for_admin_and_marks_read()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"neg-notif-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// Уходим в минус (−4).
|
||
await SeedStockAsync(api, refs, p, 1m);
|
||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } },
|
||
});
|
||
loss.EnsureSuccessStatusCode();
|
||
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
// До прогона джоба — уведомлений нет.
|
||
(await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0);
|
||
|
||
// Прогоняем фоновый джоб (агрегация по складам) напрямую.
|
||
using (var scope = _factory.Services.CreateScope())
|
||
{
|
||
var job = scope.ServiceProvider.GetRequiredService<NegativeStockNotificationJob>();
|
||
await job.RunAsync();
|
||
}
|
||
|
||
// Админу (он же владелец) создано персистентное уведомление «в минус».
|
||
var list = await api.GetJsonAsync("/api/notifications");
|
||
list.GetProperty("unread").GetInt32().Should().BeGreaterThanOrEqualTo(1);
|
||
var mine = list.GetProperty("items").EnumerateArray()
|
||
.Where(x => x.GetProperty("type").GetString() == "negative-stock").ToList();
|
||
mine.Should().NotBeEmpty("джоб агрегирует минусы по складу в одно уведомление");
|
||
mine[0].GetProperty("title").GetString().Should().Contain("минус");
|
||
mine[0].GetProperty("storeId").GetString().Should().Be(refs.StoreId);
|
||
|
||
// Повторный прогон в пределах интервала — новых уведомлений по складу нет (дебаунс).
|
||
var unreadAfterFirst = (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32();
|
||
using (var scope = _factory.Services.CreateScope())
|
||
{
|
||
var job = scope.ServiceProvider.GetRequiredService<NegativeStockNotificationJob>();
|
||
await job.RunAsync();
|
||
}
|
||
(await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32()
|
||
.Should().Be(unreadAfterFirst, "дебаунс: в пределах интервала повторно не шлём");
|
||
|
||
// Пометка «прочитано всё» обнуляет счётчик.
|
||
(await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode();
|
||
(await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0);
|
||
}
|
||
}
|