food-market/src/food-market.api/Background/NegativeStockNotificationJob.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

225 lines
12 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;
using System.Text;
using System.Text.Json;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Integrations.Telegram;
using foodmarket.Api.Realtime;
using foodmarket.Application.Notifications;
using foodmarket.Domain.Notifications;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Фоновый джоб уведомлений об отрицательных остатках. Бежит часто
/// (cron каждые 5 минут), но сам дебаунсит через <c>NegativeStockAlert.NotifiedAt</c>:
///
/// 1. <b>Агрегация</b> — по каждому складу, где появились новые минусы
/// (NotifiedAt == null) и с прошлой рассылки по складу прошло ≥ N минут
/// (настройка орг., по умолч. 15), шлём ОДНО уведомление «N товаров ушли в
/// минус» со списком. Получатели — администраторы, владелец и кладовщики
/// (роль с InventoryEdit). Тихие часы (настройка) — рассылку откладываем.
/// 2. <b>Эскалация</b> — минус, не закрытый за N часов (настройка, по умолч. 24)
/// и ещё не эскалированный (EscalatedAt == null), повторно шлём владельцу/
/// админам как срочный. Эскалация тихие часы игнорирует (срочно).
///
/// Запись уведомления персистентная (таблица notifications → центр уведомлений
/// в вебе и мобильном API). Дополнительно пушим SignalR-событие (живой
/// колокольчик), Telegram (владельцу) и push-заглушку.</summary>
public sealed class NegativeStockNotificationJob
{
// Казахстан — единая зона Asia/Almaty = UTC+5 (с 01.03.2024 вся страна).
private const int KzUtcOffsetHours = 5;
private readonly AppDbContext _db;
private readonly INotificationsPublisher _publisher;
private readonly ITelegramBotClient _tg;
private readonly IPushSender _push;
private readonly ILogger<NegativeStockNotificationJob> _log;
public NegativeStockNotificationJob(
AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg,
IPushSender push, ILogger<NegativeStockNotificationJob> log)
{
_db = db; _publisher = publisher; _tg = tg; _push = push; _log = log;
}
public async Task RunAsync(CancellationToken ct = default)
{
var orgs = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.NegStockNotifyEnabled && !o.IsArchived && o.IsActive)
.Select(o => new OrgCfg(o.Id, o.Name, o.NegStockNotifyIntervalMinutes,
o.NegStockQuietFromHour, o.NegStockQuietToHour, o.NegStockEscalateHours,
o.OwnerTelegramChatId, o.AccountOwnerUserId))
.ToListAsync(ct);
foreach (var org in orgs)
{
try
{
using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false);
await ProcessOrgAsync(org, ct);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Negative-stock notify failed for org={OrgId}", org.Id);
}
}
}
private sealed record OrgCfg(Guid Id, string Name, int IntervalMinutes, int? QuietFrom,
int? QuietTo, int EscalateHours, long? OwnerChatId, Guid? OwnerUserId);
private async Task ProcessOrgAsync(OrgCfg org, CancellationToken ct)
{
var now = DateTime.UtcNow;
// Все открытые минусы орг. с названиями товара/склада — одним запросом.
var open = await (from a in _db.NegativeStockAlerts
where a.ResolvedAt == null
join p in _db.Products on a.ProductId equals p.Id
join s in _db.Stores on a.StoreId equals s.Id
select new OpenAlert(a.Id, a.StoreId, s.Name, a.ProductId, p.Name,
a.Quantity, a.FirstDetectedAt, a.NotifiedAt, a.EscalatedAt))
.ToListAsync(ct);
if (open.Count == 0) return;
// Получателей считаем один раз на орг (не на склад) — списки совпадают.
var recipients = await ResolveRecipientsAsync(org.OwnerUserId, ct);
// ── 1. Агрегация по складам ───────────────────────────────────────────
var quiet = InQuietHours(now, org.QuietFrom, org.QuietTo);
if (!quiet)
{
foreach (var g in open.GroupBy(a => new { a.StoreId, a.StoreName }))
{
var hasNew = g.Any(a => a.NotifiedAt == null);
if (!hasNew) continue;
var lastNotified = g.Where(a => a.NotifiedAt != null).Max(a => (DateTime?)a.NotifiedAt!.Value);
if (lastNotified is { } ln && (now - ln).TotalMinutes < org.IntervalMinutes) continue;
await NotifyStoreAsync(org, g.Key.StoreId, g.Key.StoreName, g.ToList(), recipients,
escalation: false, ct);
// Штампуем NotifiedAt на всех открытых алертах этого склада.
var ids = g.Select(a => a.Id).ToList();
await _db.NegativeStockAlerts.Where(a => ids.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.NotifiedAt, now), ct);
}
}
// ── 2. Эскалация просроченных (quiet hours игнорируем — срочно) ─────────
var overdueCutoff = now.AddHours(-org.EscalateHours);
foreach (var g in open.GroupBy(a => new { a.StoreId, a.StoreName }))
{
var overdue = g.Where(a => a.EscalatedAt == null && a.FirstDetectedAt <= overdueCutoff).ToList();
if (overdue.Count == 0) continue;
await NotifyStoreAsync(org, g.Key.StoreId, g.Key.StoreName, overdue, recipients,
escalation: true, ct);
var ids = overdue.Select(a => a.Id).ToList();
await _db.NegativeStockAlerts.Where(a => ids.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.EscalatedAt, now), ct);
}
}
private sealed record OpenAlert(Guid Id, Guid StoreId, string StoreName, Guid ProductId,
string ProductName, decimal Quantity, DateTime FirstDetectedAt, DateTime? NotifiedAt,
DateTime? EscalatedAt);
private async Task NotifyStoreAsync(OrgCfg org, Guid storeId, string storeName,
List<OpenAlert> alerts, IReadOnlyList<Guid> recipients, bool escalation, CancellationToken ct)
{
var n = alerts.Count;
var title = escalation
? $"Просрочено: {n} {Plural(n, "товар", "товара", "товаров")} в минусе на «{storeName}»"
: $"{n} {Plural(n, "товар ушёл", "товара ушли", "товаров ушли")} в минус на «{storeName}»";
var list = string.Join("\n", alerts.OrderBy(a => a.Quantity)
.Take(15).Select(a => $"• {a.ProductName}: {a.Quantity:0.###}"));
if (alerts.Count > 15) list += $"\n…и ещё {alerts.Count - 15}";
var body = escalation
? $"Минус не закрыт более {org.EscalateHours} ч. Срочно проведите инвентаризацию:\n{list}"
: $"Нужна инвентаризация. Товары в минусе:\n{list}";
var link = $"/inventory/stock?negativeOnly=true&storeId={storeId}";
var dataJson = JsonSerializer.Serialize(new
{
storeId, storeName, count = n, escalation,
productIds = alerts.Select(a => a.ProductId).ToArray(),
});
var type = escalation ? "negative-stock-overdue" : "negative-stock";
// Персистентно — по записи на получателя.
foreach (var uid in recipients)
{
_db.Notifications.Add(new Notification
{
OrganizationId = org.Id, RecipientUserId = uid, Type = type,
Severity = "warning", Title = title, Body = body, Link = link,
DataJson = dataJson, StoreId = storeId,
});
}
await _db.SaveChangesAsync(ct);
// Живой колокольчик (SignalR) — фронт по событию перечитывает счётчик/список.
await _publisher.PublishAsync(org.Id, NotificationEvents.NotificationCreated,
new NotificationCreatedPayload(type, title, storeId, n, escalation));
// Push-заглушка (реальный APNs/FCM позже).
try { await _push.SendAsync(recipients, title, $"{n} товаров в минусе — нужна инвентаризация", null, ct); }
catch (Exception ex) { _log.LogDebug(ex, "push stub failed"); }
// Telegram — владельцу org (если привязан).
if (org.OwnerChatId is { } chatId && _tg.IsEnabled)
{
var tgText = $"<b>{WebUtility.HtmlEncode(title)}</b>\n{WebUtility.HtmlEncode(body)}";
await _tg.SendMessageAsync(chatId, tgText, ct);
}
}
/// <summary>Получатели: администраторы (роль Admin), владелец аккаунта и
/// кладовщики (сотрудники с правом InventoryEdit и привязанным логином).</summary>
private async Task<IReadOnlyList<Guid>> ResolveRecipientsAsync(Guid? ownerUserId, CancellationToken ct)
{
var set = new HashSet<Guid>();
if (ownerUserId is { } oid) set.Add(oid);
var adminUserIds = await _db.UserRoles.AsNoTracking()
.Join(_db.Roles.AsNoTracking(), ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name })
.Where(x => x.Name == "Admin")
.Select(x => x.UserId).ToListAsync(ct);
var activeAdmins = await _db.Users.Where(u => adminUserIds.Contains(u.Id) && u.IsActive)
.Select(u => u.Id).ToListAsync(ct);
foreach (var id in activeAdmins) set.Add(id);
// Кладовщики: Permissions — owned JSON, предикат по нему EF не переводит,
// поэтому тянем активных сотрудников с логином в память (орг. небольшие).
var keepers = await _db.Employees
.Where(e => e.IsActive && !e.IsDeleted && e.UserId != null)
.Select(e => new { e.UserId, e.Role.Permissions.InventoryEdit })
.ToListAsync(ct);
foreach (var k in keepers.Where(k => k.InventoryEdit && k.UserId != null)) set.Add(k.UserId!.Value);
return set.ToList();
}
/// <summary>Текущий час в зоне Asia/Almaty (UTC+5) внутри [from,to)?
/// Интервал может переходить через полночь. from==to или любой null → нет тихих часов.</summary>
public static bool InQuietHours(DateTime utcNow, int? from, int? to)
{
if (from is null || to is null || from == to) return false;
var hour = (utcNow.Hour + KzUtcOffsetHours) % 24;
int f = ((from.Value % 24) + 24) % 24, t = ((to.Value % 24) + 24) % 24;
return f < t ? (hour >= f && hour < t) : (hour >= f || hour < t);
}
private static string Plural(int n, string one, string few, string many)
{
var mod100 = n % 100; var mod10 = n % 10;
if (mod100 is >= 11 and <= 14) return many;
return mod10 switch { 1 => one, >= 2 and <= 4 => few, _ => many };
}
}