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