food-market/src/food-market.api/Realtime/NotificationsPublisher.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

75 lines
3.2 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 Microsoft.AspNetCore.SignalR;
namespace foodmarket.Api.Realtime;
/// <summary>Тонкий обёрточный сервис: контроллеры/сервисы вызывают
/// <see cref="PublishAsync(Guid, string, object)"/>, а доставка идёт через
/// <see cref="NotificationsHub"/> в группу <c>org:{Id}</c>.
///
/// Метод JSON-сериализуется в SignalR'е автоматически (System.Text.Json).
/// Имя метода = ключ для подписки на клиенте.</summary>
public interface INotificationsPublisher
{
/// <summary>Шлём всем подключённым сессиям одной org-и событие.</summary>
Task PublishAsync(Guid orgId, string method, object payload);
}
public sealed class NotificationsPublisher : INotificationsPublisher
{
private readonly IHubContext<NotificationsHub> _hub;
public NotificationsPublisher(IHubContext<NotificationsHub> hub) => _hub = hub;
public Task PublishAsync(Guid orgId, string method, object payload)
=> _hub.Clients.Group(NotificationsHub.GroupName(orgId)).SendAsync(method, payload);
}
/// <summary>DTO событий — стабильные имена для контракта с фронтом.</summary>
public static class NotificationEvents
{
/// <summary>Сразу после успешного post-a розничной продажи.</summary>
public const string SalePosted = "SalePosted";
/// <summary>Сразу после успешного post-a приёмки.</summary>
public const string SupplyPosted = "SupplyPosted";
/// <summary>Создано персистентное уведомление (центр уведомлений). Фронт по
/// событию перечитывает счётчик непрочитанных и список. Шлётся фоновым
/// джобом уведомлений об отрицательных остатках.</summary>
public const string NotificationCreated = "NotificationCreated";
/// <summary>Когда после движения остаток продукта стал ниже MinStock.
/// Один event на каждый продукт-склад, в котором факт пересечения
/// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на
/// каждой продаже сыпались бы тосты.</summary>
public const string LowStock = "LowStock";
}
public sealed record SalePostedPayload(
Guid SaleId,
string Number,
decimal Total,
Guid StoreId,
string? CashierName,
Guid? RetailPointId,
DateTime PostedAt);
public sealed record SupplyPostedPayload(
Guid SupplyId,
string Number,
decimal Total,
Guid SupplierId,
string? SupplierName,
DateTime PostedAt);
public sealed record LowStockPayload(
Guid ProductId,
string ProductName,
Guid StoreId,
string? StoreName,
decimal Quantity,
decimal MinStock);
public sealed record NotificationCreatedPayload(
string Type,
string Title,
Guid StoreId,
int Count,
bool Escalation);