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
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:
parent
abace49a45
commit
3088237ea7
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
138
src/food-market.api/Background/OwnerDailySummaryJob.cs
Normal file
138
src/food-market.api/Background/OwnerDailySummaryJob.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 не падал и базовая структура была.
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue