food-market/src/food-market.api/Background/OwnerDailySummaryJob.cs
nns 3088237ea7
Some checks are pending
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
feat(telegram): OwnerDailySummaryJob + bot binding (P2-14)
Backend:
- Organization.OwnerTelegramChatId (long?) — миграция Phase9a.
- TelegramOptions / TelegramBotClient (Telegram Bot API sendMessage).
  Disabled-mode когда токен пустой (Dev/CI). HTML parse_mode.
- OwnerDailySummaryJob.RunAsync — пробегает по org с привязанным chatId,
  рендерит сводку (выручка вчера, чеков, средний чек, топ-3 по выручке,
  low-stock 5 строк) и шлёт. Best-effort на каждой org.
  RenderSummaryAsync — publicный для тестов.
- HangfireJobsConfigurator: cron "0 6 * * *" UTC = 09:00 МСК.
- TelegramBindingController: GET /status (botEnabled, username, chatId,
  deepLink), PUT /bind (тестовое сообщение → проверка chatId → save),
  DELETE (unbind).

Конфиг:
- Telegram:BotToken — env Telegram__BotToken.
- Telegram:BotUsername — для deep-link.

UI:
- OrganizationSettings.TelegramSection: показывает статус (bot enabled?
  bound?), deep-link к боту, пошаговая инструкция (start → userinfobot →
  ввести chat_id → проверка). Toast на привязку/отвязку через
  meta.successMessage.

Тесты:
- TelegramOwnerSummaryTests: рендер содержит org_name, метрики, HTML. ✓ 1/1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:50:33 +05:00

139 lines
6.4 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Раз в день в 09:00 по МСК шлёт владельцу каждой org Telegram-сводку:
/// выручка вчера, количество чеков, топ-3 товаров (qty*price), low-stock-список.
///
/// Регистрация — в HangfireJobsConfigurator (cron <c>0 6 * * *</c>; в UTC это
/// 09:00 МСК, потому что MSK = UTC+3).
///
/// На org без привязанного OwnerTelegramChatId или с null AccountOwnerUserId
/// пропускаем без логов (нет адресата).</summary>
public sealed class OwnerDailySummaryJob
{
private readonly AppDbContext _db;
private readonly ITelegramBotClient _tg;
private readonly ILogger<OwnerDailySummaryJob> _log;
public OwnerDailySummaryJob(AppDbContext db, ITelegramBotClient tg, ILogger<OwnerDailySummaryJob> log)
{
_db = db;
_tg = tg;
_log = log;
}
/// <summary>Hangfire-entry. Пробегает по всем org с OwnerTelegramChatId !=
/// null, рендерит сводку и шлёт. Best-effort на каждой org — одна неудача
/// не валит остальных.</summary>
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);
}
}
}
/// <summary>Чистый рендерер — exposед публично для тестов. Берёт «вчерашний»
/// день в UTC (UTC простоты ради; для МСК-таймзоны 09:00 рассылка → вчера
/// UTC примерно совпадает со вчера МСК с поправкой 3ч, для бизнес-сводки
/// этого достаточно).</summary>
public async Task<string> 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<RetailSaleLine>().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($"<b>Food Market — сводка за {yStart:dd.MM}</b>\n");
sb.Append($"<i>{Esc(orgName)}</i>\n\n");
sb.Append($"💰 Выручка: <b>{Money(revenue)}</b>\n");
sb.Append($"🧾 Чеков: <b>{count}</b>\n");
if (count > 0)
{
sb.Append($"📊 Средний чек: <b>{Money(revenue / count)}</b>\n");
}
if (top3.Count > 0)
{
sb.Append("\n<b>Топ-3 товара</b>\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⚠ <b>Низкие остатки</b>\n");
foreach (var l in low)
{
sb.Append($"• {Esc(l.Name)} ({Esc(l.StoreName)}) — {l.Quantity:N0} / мин {l.MinStock:N0}\n");
}
}
return sb.ToString();
}
}