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; /// Фоновый джоб уведомлений об отрицательных остатках. Бежит часто /// (cron каждые 5 минут), но сам дебаунсит через NegativeStockAlert.NotifiedAt: /// /// 1. Агрегация — по каждому складу, где появились новые минусы /// (NotifiedAt == null) и с прошлой рассылки по складу прошло ≥ N минут /// (настройка орг., по умолч. 15), шлём ОДНО уведомление «N товаров ушли в /// минус» со списком. Получатели — администраторы, владелец и кладовщики /// (роль с InventoryEdit). Тихие часы (настройка) — рассылку откладываем. /// 2. Эскалация — минус, не закрытый за N часов (настройка, по умолч. 24) /// и ещё не эскалированный (EscalatedAt == null), повторно шлём владельцу/ /// админам как срочный. Эскалация тихие часы игнорирует (срочно). /// /// Запись уведомления персистентная (таблица notifications → центр уведомлений /// в вебе и мобильном API). Дополнительно пушим SignalR-событие (живой /// колокольчик), Telegram (владельцу) и push-заглушку. 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 _log; public NegativeStockNotificationJob( AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg, IPushSender push, ILogger 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 alerts, IReadOnlyList 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 = $"{WebUtility.HtmlEncode(title)}\n{WebUtility.HtmlEncode(body)}"; await _tg.SendMessageAsync(chatId, tgText, ct); } } /// Получатели: администраторы (роль Admin), владелец аккаунта и /// кладовщики (сотрудники с правом InventoryEdit и привязанным логином). private async Task> ResolveRecipientsAsync(Guid? ownerUserId, CancellationToken ct) { var set = new HashSet(); 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(); } /// Текущий час в зоне Asia/Almaty (UTC+5) внутри [from,to)? /// Интервал может переходить через полночь. from==to или любой null → нет тихих часов. 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 }; } }