using System.Net; using foodmarket.Api.Integrations.Telegram; using foodmarket.Domain.Inventory; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Background; /// Раз в день в 09:00 по МСК шлёт владельцу каждой org Telegram-сводку: /// выручка вчера, количество чеков, топ-3 товаров (qty*price), low-stock-список. /// /// Регистрация — в HangfireJobsConfigurator (cron 0 6 * * *; в UTC это /// 09:00 МСК, потому что MSK = UTC+3). /// /// На org без привязанного OwnerTelegramChatId или с null AccountOwnerUserId /// пропускаем без логов (нет адресата). public sealed class OwnerDailySummaryJob { private readonly AppDbContext _db; private readonly ITelegramBotClient _tg; private readonly ILogger _log; public OwnerDailySummaryJob(AppDbContext db, ITelegramBotClient tg, ILogger log) { _db = db; _tg = tg; _log = log; } /// Hangfire-entry. Пробегает по всем org с OwnerTelegramChatId != /// null, рендерит сводку и шлёт. Best-effort на каждой org — одна неудача /// не валит остальных. public async Task RunAsync(CancellationToken ct) { if (!_tg.IsEnabled) { _log.LogInformation("Telegram bot disabled (no token) — skipping daily summary"); return; } var orgs = await _db.Organizations.IgnoreQueryFilters() .Where(o => o.OwnerTelegramChatId != null && !o.IsArchived && o.IsActive) .Select(o => new { o.Id, o.Name, ChatId = o.OwnerTelegramChatId!.Value }) .ToListAsync(ct); foreach (var o in orgs) { try { var text = await RenderSummaryAsync(o.Id, o.Name, ct); await _tg.SendMessageAsync(o.ChatId, text, ct); } catch (Exception ex) { _log.LogWarning(ex, "Daily summary failed for org={OrgId}", o.Id); } } } /// Чистый рендерер — exposед публично для тестов. Берёт «вчерашний» /// день в UTC (UTC простоты ради; для МСК-таймзоны 09:00 рассылка → вчера /// UTC примерно совпадает со вчера МСК с поправкой 3ч, для бизнес-сводки /// этого достаточно). public async Task RenderSummaryAsync(Guid orgId, string orgName, CancellationToken ct) { var todayStart = DateTime.UtcNow.Date; var yStart = todayStart.AddDays(-1); var yEnd = todayStart; // Розничные продажи вчера, проведённые. var ySales = _db.RetailSales.IgnoreQueryFilters() .Where(s => s.OrganizationId == orgId && s.Status == RetailSaleStatus.Posted && !s.IsReturn && s.Date >= yStart && s.Date < yEnd); var revenue = await ySales.SumAsync(s => (decimal?)s.Total, ct) ?? 0m; var count = await ySales.CountAsync(ct); // Топ-3 товара по выручке вчера. var top3 = await (from sl in _db.Set().IgnoreQueryFilters() join s in _db.RetailSales.IgnoreQueryFilters() on sl.RetailSaleId equals s.Id join p in _db.Products.IgnoreQueryFilters() on sl.ProductId equals p.Id where s.OrganizationId == orgId && s.Status == RetailSaleStatus.Posted && !s.IsReturn && s.Date >= yStart && s.Date < yEnd group new { sl, p } by new { sl.ProductId, p.Name } into g orderby g.Sum(x => x.sl.LineTotal) descending select new { g.Key.Name, Total = g.Sum(x => x.sl.LineTotal), Qty = g.Sum(x => x.sl.Quantity), }) .Take(3).ToListAsync(ct); // Low-stock: продукты у которых остаток на ЛЮБОМ складе ниже MinStock. var low = await (from p in _db.Products.IgnoreQueryFilters() join s in _db.Stocks.IgnoreQueryFilters() on p.Id equals s.ProductId join st in _db.Stores.IgnoreQueryFilters() on s.StoreId equals st.Id where p.OrganizationId == orgId && p.MinStock != null && s.Quantity < p.MinStock orderby s.Quantity ascending select new { p.Name, StoreName = st.Name, s.Quantity, p.MinStock }) .Take(5).ToListAsync(ct); // HTML-кодирование чтобы спецсимволы в названиях не сломали parse_mode=HTML. string Esc(string s) => WebUtility.HtmlEncode(s); string Money(decimal d) => $"{d:N0} ₸"; var sb = new System.Text.StringBuilder(); sb.Append($"Food Market — сводка за {yStart:dd.MM}\n"); sb.Append($"{Esc(orgName)}\n\n"); sb.Append($"💰 Выручка: {Money(revenue)}\n"); sb.Append($"🧾 Чеков: {count}\n"); if (count > 0) { sb.Append($"📊 Средний чек: {Money(revenue / count)}\n"); } if (top3.Count > 0) { sb.Append("\nТоп-3 товара\n"); for (int i = 0; i < top3.Count; i++) { sb.Append($"{i + 1}. {Esc(top3[i].Name)} — {Money(top3[i].Total)} ({top3[i].Qty:N0} шт.)\n"); } } if (low.Count > 0) { sb.Append("\n⚠️ Низкие остатки\n"); foreach (var l in low) { sb.Append($"• {Esc(l.Name)} ({Esc(l.StoreName)}) — {l.Quantity:N0} / мин {l.MinStock:N0}\n"); } } return sb.ToString(); } }