feat(telegram): OwnerDailySummaryJob + bot binding (P2-14)
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>
This commit is contained in:
nns 2026-05-31 19:50:33 +05:00
parent abace49a45
commit 3088237ea7
10 changed files with 525 additions and 0 deletions

View file

@ -59,6 +59,15 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronLowStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Telegram-сводка владельцу — 09:00 МСК = 06:00 UTC. По вкусу
// переопределяется через Hangfire:Cron:TelegramOwnerDailySummary.
var cronTgDaily = _cfg["Hangfire:Cron:TelegramOwnerDailySummary"] ?? "0 6 * * *";
_jobs.AddOrUpdate<OwnerDailySummaryJob>(
recurringJobId: "telegram-owner-daily-summary",
methodCall: j => j.RunAsync(CancellationToken.None),
cronExpression: cronTgDaily,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask;
}

View file

@ -0,0 +1,138 @@
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();
}
}

View file

@ -0,0 +1,100 @@
using System.Security.Cryptography;
using foodmarket.Api.Integrations.Telegram;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Telegram;
/// <summary>Привязка Telegram-чата владельца к организации.
///
/// Flow:
/// 1. UI запрашивает <c>POST /api/organization/telegram/start</c> → сервер
/// генерирует одноразовый код (`tg_token`), кладёт в Telegram-payload
/// с deep-link <c>https://t.me/{bot}?start={code}</c> и orgId-маркером.
/// 2. Юзер открывает ссылку, бот через webhook /api/integrations/telegram/webhook
/// видит <c>/start {code}</c>, мапит code → orgId, отвечает «Связь установлена.
/// Введите код {Z}{Z}{Z}{Z} в админке».
/// Простоты ради реализуем lite-вариант: пользователь сам копирует chat_id
/// из @userinfobot или из ссылки бота и вставляет в UI. Полноценный webhook
/// — в следующей итерации.
/// 3. UI <c>PUT /api/organization/telegram/bind</c> с <c>chatId</c> → бот
/// отправляет подтверждающее сообщение, если 200 — сохраняем chatId в
/// <c>Organization.OwnerTelegramChatId</c>.
/// 4. <c>DELETE /api/organization/telegram</c> — отвязка (NULL чтобы джоб
/// больше не слал).
/// </summary>
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/organization/telegram")]
public class TelegramBindingController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITelegramBotClient _bot;
private readonly ITenantContext _tenant;
public TelegramBindingController(AppDbContext db, ITelegramBotClient bot, ITenantContext tenant)
{
_db = db;
_bot = bot;
_tenant = tenant;
}
public sealed record StatusResp(bool BotEnabled, string? BotUsername, long? ChatId, string? DeepLink);
[HttpGet("status")]
public async Task<ActionResult<StatusResp>> Status(CancellationToken ct)
{
var orgId = _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant in context");
var chatId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId).Select(o => o.OwnerTelegramChatId).FirstOrDefaultAsync(ct);
// Deep-link с маркером org_<orgIdSlice> — пользователь жмёт «Старт»,
// бот видит маркер и пишет ответом chat_id. UI просит юзера ввести
// chat_id руками (см. описание класса).
var deepLink = _bot.IsEnabled && !string.IsNullOrWhiteSpace(_bot.BotUsername)
? $"https://t.me/{_bot.BotUsername}?start=org_{orgId:N}"
: null;
return Ok(new StatusResp(_bot.IsEnabled, _bot.BotUsername, chatId, deepLink));
}
public sealed record BindInput(long ChatId);
[HttpPut("bind")]
public async Task<IActionResult> Bind([FromBody] BindInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant in context");
if (input.ChatId == 0) return BadRequest(new { error = "ChatId обязателен и не может быть 0." });
if (!_bot.IsEnabled) return BadRequest(new { error = "Telegram-бот не настроен на сервере (нет токена)." });
// Sanity-check: отправим тестовое сообщение. Если 200 — chat_id валиден.
var orgName = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId).Select(o => o.Name).FirstAsync(ct);
var ok = await _bot.SendMessageAsync(input.ChatId,
$"✅ Привязка к <b>{System.Net.WebUtility.HtmlEncode(orgName)}</b> подтверждена.\n" +
"Ежедневная сводка будет приходить в 09:00 по МСК.", ct);
if (!ok)
{
return BadRequest(new { error = "Не удалось отправить сообщение в Telegram — проверьте ChatId и что бот добавлен в чат." });
}
var org = await _db.Organizations.IgnoreQueryFilters().FirstAsync(o => o.Id == orgId, ct);
org.OwnerTelegramChatId = input.ChatId;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete]
public async Task<IActionResult> Unbind(CancellationToken ct)
{
var orgId = _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant in context");
var org = await _db.Organizations.IgnoreQueryFilters().FirstAsync(o => o.Id == orgId, ct);
org.OwnerTelegramChatId = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -0,0 +1,78 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
namespace foodmarket.Api.Integrations.Telegram;
/// <summary>Минимальный клиент Telegram Bot API — нужен только sendMessage.
/// Token читается из конфига <c>Telegram:BotToken</c> (можно через env
/// <c>Telegram__BotToken</c>). Если токен не задан — клиент тихо отключён,
/// <see cref="IsEnabled"/> = false; <see cref="SendMessageAsync"/> возвращает
/// false. Это позволяет не блокировать запуск сервиса в Dev/CI, где бота нет.</summary>
public sealed class TelegramOptions
{
public string? BotToken { get; set; }
/// <summary>Username бота без @ — для построения deep-link
/// `https://t.me/{BotUsername}?start={token}`.</summary>
public string? BotUsername { get; set; }
}
public interface ITelegramBotClient
{
bool IsEnabled { get; }
string? BotUsername { get; }
/// <summary>Возвращает true если успешно отправили. На сетевую ошибку
/// либо неверный ChatId логируем warning и возвращаем false, чтобы
/// caller'у не приходилось ловить исключение.</summary>
Task<bool> SendMessageAsync(long chatId, string text, CancellationToken ct = default);
}
public sealed class TelegramBotClient : ITelegramBotClient
{
private readonly HttpClient _http;
private readonly TelegramOptions _opts;
private readonly ILogger<TelegramBotClient> _log;
public TelegramBotClient(HttpClient http, IOptions<TelegramOptions> opts, ILogger<TelegramBotClient> log)
{
_http = http;
_opts = opts.Value;
_log = log;
}
public bool IsEnabled => !string.IsNullOrWhiteSpace(_opts.BotToken);
public string? BotUsername => _opts.BotUsername;
public async Task<bool> SendMessageAsync(long chatId, string text, CancellationToken ct = default)
{
if (!IsEnabled)
{
_log.LogDebug("Telegram disabled (no token) — skipping sendMessage to {ChatId}", chatId);
return false;
}
try
{
var url = $"https://api.telegram.org/bot{_opts.BotToken}/sendMessage";
// parse_mode=HTML позволяет жирные/курсив для сводки; text сюда
// должен идти HtmlEncode'нутым (см. OwnerDailySummaryJob).
var resp = await _http.PostAsJsonAsync(url, new
{
chat_id = chatId,
text,
parse_mode = "HTML",
disable_web_page_preview = true,
}, ct);
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct);
_log.LogWarning("Telegram sendMessage {ChatId} → {Status}: {Body}", chatId, (int)resp.StatusCode, body);
return false;
}
return true;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Telegram sendMessage exception (chatId={ChatId})", chatId);
return false;
}
}
}

View file

@ -299,6 +299,19 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token
// пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI
// переходит в режим «бот не настроен» — кнопка «Привязать Telegram»
// показывает подсказку об env-переменной.
builder.Services.Configure<foodmarket.Api.Integrations.Telegram.TelegramOptions>(
builder.Configuration.GetSection("Telegram"));
builder.Services.AddHttpClient<foodmarket.Api.Integrations.Telegram.ITelegramBotClient,
foodmarket.Api.Integrations.Telegram.TelegramBotClient>(c =>
{
c.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
// SignalR + per-org notification publisher. Hub смонтирован ниже на
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
// имена полей в DTO (см. NotificationsPublisher payload-records).

View file

@ -28,6 +28,14 @@ public class Organization : Entity
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { get; set; }
/// <summary>Chat ID владельца org в Telegram. Привязка делается через
/// deep-link на бота `food-market` — бот шлёт код подтверждения в
/// telegram, юзер вводит код в OrganizationSettings, мы сохраняем
/// chatId. По этому ChatId Hangfire-джоб OwnerDailySummaryJob шлёт
/// ежедневную сводку (выручка вчера, топ-3, low-stock).
/// null — привязки нет; ноль допустимым не считаем.</summary>
public long? OwnerTelegramChatId { get; set; }
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
public Guid? DefaultCurrencyId { get; set; }

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase9a — добавляем колонку <c>OwnerTelegramChatId</c> в
/// organizations. По ней Hangfire-джоб <c>OwnerDailySummaryJob</c> ходит
/// в Telegram Bot API и шлёт ежедневную сводку владельцу. NULL = бот не
/// привязан, рассылка не идёт.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260531100000_Phase9a_OwnerTelegramChatId")]
public partial class Phase9a_OwnerTelegramChatId : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<long>(
name: "OwnerTelegramChatId",
table: "organizations",
type: "bigint",
nullable: true);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "OwnerTelegramChatId", table: "organizations");
}
}
}

View file

@ -1102,6 +1102,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean");
b.Property<long?>("OwnerTelegramChatId")
.HasColumnType("bigint");
b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean");

View file

@ -205,12 +205,107 @@ export function OrganizationSettingsPage() {
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
</div>
<TelegramSection />
<DemoSeedSection />
</div>
</div>
)
}
interface TelegramStatus {
botEnabled: boolean
botUsername: string | null
chatId: number | null
deepLink: string | null
}
/**
* Привязка Telegram-бота владельца: владелец org вводит свой chat_id
* (получает из @userinfobot или через deep-link к боту food-market).
* Сервер проверяет, отправив тестовое сообщение если 200, сохраняем.
* После привязки Hangfire-джоб шлёт ежедневную сводку в 09:00 МСК.
*/
function TelegramSection() {
const qc = useQueryClient()
const status = useQuery<TelegramStatus>({
queryKey: ['/api/organization/telegram/status'],
queryFn: async () => (await api.get<TelegramStatus>('/api/organization/telegram/status')).data,
})
const [chatIdInput, setChatIdInput] = useState('')
const bind = useMutation({
mutationFn: async () => {
const cid = Number(chatIdInput.trim())
if (!Number.isInteger(cid) || cid === 0) throw new Error('ChatId — число, не равное 0')
return (await api.put('/api/organization/telegram/bind', { chatId: cid })).data
},
onSuccess: () => {
setChatIdInput('')
qc.invalidateQueries({ queryKey: ['/api/organization/telegram/status'] })
},
meta: { successMessage: 'Telegram привязан, тестовое сообщение отправлено' },
})
const unbind = useMutation({
mutationFn: async () => (await api.delete('/api/organization/telegram')).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/organization/telegram/status'] }),
meta: { successMessage: 'Привязка отменена' },
})
const s = status.data
const bound = !!s?.chatId
return (
<section className="border-t border-slate-200 pt-6 mt-6">
<h2 className="text-base font-semibold">📨 Telegram владельца</h2>
<p className="text-sm text-slate-500 mt-1">
Ежедневная сводка (выручка вчера, топ-3 товара, low-stock) в 09:00 МСК.
</p>
{!s?.botEnabled && (
<p className="mt-3 text-sm text-amber-700 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 rounded-md p-3">
Бот не настроен на сервере (нет токена в env <code>Telegram__BotToken</code>).
Привязка пока недоступна.
</p>
)}
{s?.botEnabled && bound && (
<div className="mt-3 flex items-center gap-3">
<span className="text-sm">Привязан: <code>chat_id={s.chatId}</code></span>
<Button variant="secondary" onClick={() => unbind.mutate()} disabled={unbind.isPending}>
Отвязать
</Button>
</div>
)}
{s?.botEnabled && !bound && (
<div className="mt-3 space-y-3">
{s.deepLink && (
<p className="text-sm">
1. Откройте <a className="text-emerald-700 underline" href={s.deepLink} target="_blank" rel="noreferrer">
@{s.botUsername}
</a> в Telegram, отправьте <code>/start</code>.
</p>
)}
<p className="text-sm">
2. Откройте <a className="text-emerald-700 underline" href="https://t.me/userinfobot" target="_blank" rel="noreferrer">@userinfobot</a>, чтобы узнать свой <code>chat_id</code>.
</p>
<div className="flex items-end gap-3">
<Field label="3. Ваш chat_id">
<TextInput
value={chatIdInput}
onChange={(e) => setChatIdInput(e.target.value)}
placeholder="например, 123456789"
/>
</Field>
<Button onClick={() => bind.mutate()} disabled={bind.isPending || !chatIdInput.trim()}>
{bind.isPending ? 'Проверяю…' : 'Привязать'}
</Button>
</div>
</div>
)}
</section>
)
}
/**
* Кнопка «Заполнить демо-данными» для стейджа: 50 товаров / 10 контрагентов /
* наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной org

View file

@ -0,0 +1,50 @@
using System.Net.Http.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Тест рендеринга ежедневной сводки OwnerDailySummaryJob.
/// Сравниваем text-output с ожидаемым: заголовок org-name, цифры,
/// топ-3 + low-stock. Не отправляем реальное сообщение — тестируем
/// чистую функцию RenderSummaryAsync (она publicна именно ради тестов).</summary>
[Collection(ApiCollection.Name)]
public class TelegramOwnerSummaryTests
{
private readonly ApiFactory _factory;
public TelegramOwnerSummaryTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Render_summary_contains_org_name_and_metrics()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"tg-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Telegram Test Org")).EnsureSuccessStatusCode();
var token = await actor.TokenAsync(email, "Passw0rd!");
actor.UseToken(token);
var meDoc = await actor.GetJsonAsync("/api/me");
var orgId = Guid.Parse(meDoc.GetProperty("orgId").GetString()!);
// Засеем демо-данные чтобы было что показать (50 товаров + 30 продаж за
// последние 30 дней + пара low-stock).
(await actor.Http.PostAsync("/api/admin/seed-demo", null)).EnsureSuccessStatusCode();
// Вытащим джоб напрямую из DI и сгенерируем текст.
using var scope = _factory.Services.CreateScope();
var job = scope.ServiceProvider.GetRequiredService<foodmarket.Api.Background.OwnerDailySummaryJob>();
var text = await job.RenderSummaryAsync(orgId, "Telegram Test Org", CancellationToken.None);
// Базовые поля
text.Should().Contain("Food Market");
text.Should().Contain("Telegram Test Org");
text.Should().Contain("Выручка");
text.Should().Contain("Чеков");
// HTML-форматирование parse_mode=HTML
text.Should().Contain("<b>");
// На свежей орге за «вчера» может ничего не быть (demo сеется на даты
// сегодня-30..сегодня). В разном таймзоне может попасть, может нет —
// главное чтобы render не падал и базовая структура была.
}
}