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,
|
cronExpression: cronLowStock,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
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;
|
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.HousekeepingJobs>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
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 смонтирован ниже на
|
// SignalR + per-org notification publisher. Hub смонтирован ниже на
|
||||||
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
||||||
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ public class Organization : Entity
|
||||||
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
|
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
|
||||||
public string? MoySkladToken { get; set; }
|
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,
|
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
|
||||||
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
|
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
|
||||||
public Guid? DefaultCurrencyId { get; set; }
|
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")
|
b.Property<bool>("MultiCurrencyEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<long?>("OwnerTelegramChatId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<bool>("ShowCountryOfOriginOnProduct")
|
b.Property<bool>("ShowCountryOfOriginOnProduct")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,12 +205,107 @@ export function OrganizationSettingsPage() {
|
||||||
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TelegramSection />
|
||||||
<DemoSeedSection />
|
<DemoSeedSection />
|
||||||
</div>
|
</div>
|
||||||
</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 контрагентов /
|
* Кнопка «Заполнить демо-данными» — для стейджа: 50 товаров / 10 контрагентов /
|
||||||
* наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной org
|
* наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной 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