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>
47 lines
2.4 KiB
C#
47 lines
2.4 KiB
C#
using System;
|
||
using foodmarket.Api.Background;
|
||
using FluentAssertions;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
/// <summary>Тихие часы агрегированной рассылки уведомлений об отрицательных
|
||
/// остатках. Время считается в зоне Asia/Almaty (UTC+5). Интервал может
|
||
/// переходить через полночь; null/равные границы = тихих часов нет.</summary>
|
||
public class NegativeStockNotifyQuietHoursTests
|
||
{
|
||
// 03:00 UTC = 08:00 Алматы.
|
||
private static readonly DateTime At08Almaty = new(2026, 10, 9, 3, 0, 0, DateTimeKind.Utc);
|
||
// 18:00 UTC = 23:00 Алматы.
|
||
private static readonly DateTime At23Almaty = new(2026, 10, 9, 18, 0, 0, DateTimeKind.Utc);
|
||
// 21:00 UTC = 02:00 Алматы (следующих суток).
|
||
private static readonly DateTime At02Almaty = new(2026, 10, 9, 21, 0, 0, DateTimeKind.Utc);
|
||
|
||
[Fact]
|
||
public void No_quiet_hours_when_null_or_equal()
|
||
{
|
||
NegativeStockNotificationJob.InQuietHours(At23Almaty, null, 8).Should().BeFalse();
|
||
NegativeStockNotificationJob.InQuietHours(At23Almaty, 22, null).Should().BeFalse();
|
||
NegativeStockNotificationJob.InQuietHours(At23Almaty, 0, 0).Should().BeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void Overnight_window_22_to_8_covers_late_night_and_early_morning()
|
||
{
|
||
// Тихие часы 22:00–08:00 (через полночь).
|
||
NegativeStockNotificationJob.InQuietHours(At23Almaty, 22, 8).Should().BeTrue(); // 23:00 — тихо
|
||
NegativeStockNotificationJob.InQuietHours(At02Almaty, 22, 8).Should().BeTrue(); // 02:00 — тихо
|
||
NegativeStockNotificationJob.InQuietHours(At08Almaty, 22, 8).Should().BeFalse(); // 08:00 — уже можно
|
||
}
|
||
|
||
[Fact]
|
||
public void Daytime_window_9_to_18_is_same_day_range()
|
||
{
|
||
NegativeStockNotificationJob.InQuietHours(At08Almaty, 9, 18).Should().BeFalse(); // 08:00 — вне окна
|
||
NegativeStockNotificationJob.InQuietHours(At23Almaty, 9, 18).Should().BeFalse(); // 23:00 — вне окна
|
||
// 13:00 Алматы = 08:00 UTC… проверим середину окна: 10:00 UTC = 15:00 Алматы.
|
||
var at15 = new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc);
|
||
NegativeStockNotificationJob.InQuietHours(at15, 9, 18).Should().BeTrue();
|
||
}
|
||
}
|