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
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>
139 lines
6.4 KiB
C#
139 lines
6.4 KiB
C#
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();
|
||
}
|
||
}
|