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>
75 lines
3.2 KiB
C#
75 lines
3.2 KiB
C#
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);
|