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 };
}
}