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>
225 lines
12 KiB
C#
225 lines
12 KiB
C#
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 };
|
||
}
|
||
}
|