food-market/tests/food-market.IntegrationTests/NegativeStockTests.cs
nns 29a951a6ff
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
feat(notifications): центр уведомлений + агрегация/эскалация минусов + каналы
Персистентные уведомления (таблица 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>
2026-10-09 02:56:59 +05:00

163 lines
9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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