Files
remnawave-bedolaga-telegram…/app/services/reporting_service.py
T
Fringg ab43e74ab7 fix: manual admin top-ups missing from sales statistics
Cabinet API and WebAPI created admin balance transactions with
payment_method=NULL instead of 'manual', making them invisible
to sales statistics filters.

Changes:
- Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI
  balance update endpoints
- Add func.abs() to all transaction amount aggregations missing it
  across sales stats, dashboard stats, and reporting queries
- Remove redundant Python abs() on addon_revenue (SQL func.abs
  already applied)
- Add data migration 0044 to fix historical NULL payment_method
  records for admin top-ups
2026-03-21 07:01:22 +03:00

581 lines
22 KiB
Python

import asyncio
from dataclasses import dataclass
from datetime import UTC, date, datetime, time as datetime_time, timedelta
from enum import Enum
from html import escape
from zoneinfo import ZoneInfo
import structlog
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import cast, func, not_, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import false, true
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
SubscriptionStatus,
Ticket,
TicketStatus,
Transaction,
TransactionType,
User,
)
logger = structlog.get_logger(__name__)
class ReportingServiceError(RuntimeError):
"""Base error for the reporting service."""
class ReportPeriod(Enum):
DAILY = 'daily'
WEEKLY = 'weekly'
MONTHLY = 'monthly'
@dataclass(slots=True)
class ReportPeriodRange:
start_msk: datetime
end_msk: datetime
label: str
class ReportingService:
"""Generates admin summary reports (text only, no charts)."""
def __init__(self) -> None:
self.bot: Bot | None = None
self._task: asyncio.Task | None = None
self._moscow_tz = ZoneInfo('Europe/Moscow')
def set_bot(self, bot: Bot) -> None:
self.bot = bot
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
async def start(self) -> None:
await self.stop()
if not settings.ADMIN_REPORTS_ENABLED:
logger.info('Сервис отчетов отключен настройками')
return
if not self.bot:
logger.warning('Невозможно запустить сервис отчетов без экземпляра бота')
return
chat_id = settings.get_reports_chat_id()
if not chat_id:
logger.warning('Сервис отчетов не запущен: не указан чат для отправки отчетов')
return
send_time = settings.get_reports_send_time()
if not send_time:
logger.warning('Сервис отчетов не запущен: не указано время ежедневной отправки')
return
self._task = asyncio.create_task(self._auto_daily_loop(send_time))
logger.info('📊 Сервис отчетов запущен: ежедневная отправка в по МСК', send_time=send_time.strftime('%H:%M'))
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def send_report(
self,
period: ReportPeriod,
*,
report_date: date | None = None,
send_to_topic: bool = False,
) -> str:
report_text = await self._build_report(period, report_date)
if send_to_topic:
await self._deliver_report(report_text)
return report_text
async def _auto_daily_loop(self, send_time: datetime_time) -> None:
try:
next_run_utc, report_date = self._calculate_next_run(send_time)
while True:
now_utc = datetime.now(UTC)
delay = (next_run_utc - now_utc).total_seconds()
if delay > 0:
await asyncio.sleep(delay)
try:
await self.send_report(
ReportPeriod.DAILY,
report_date=report_date,
send_to_topic=True,
)
logger.info('📊 Автоматический отчет за отправлен', report_date=report_date.strftime('%d.%m.%Y'))
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error('Ошибка автоматической отправки отчета', exc=exc)
next_run_utc, report_date = self._calculate_next_run(send_time)
except asyncio.CancelledError:
logger.info('Сервис отчетов остановлен')
raise
except Exception as exc:
logger.error('Критическая ошибка в сервисе отчетов', exc=exc)
def _calculate_next_run(
self,
send_time: datetime_time,
) -> tuple[datetime, date]:
now_msk = datetime.now(self._moscow_tz)
candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz)
if now_msk >= candidate:
candidate += timedelta(days=1)
report_date = (candidate - timedelta(days=1)).date()
return candidate.astimezone(UTC), report_date
async def _deliver_report(self, report_text: str) -> None:
if not self.bot:
raise ReportingServiceError('Бот не инициализирован для отправки отчета')
chat_id = settings.get_reports_chat_id()
if not chat_id:
raise ReportingServiceError('Не задан чат для отправки отчета')
topic_id = settings.get_reports_topic_id()
try:
await self.bot.send_message(
chat_id=chat_id,
text=report_text,
message_thread_id=topic_id,
parse_mode='HTML',
)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.error('Не удалось отправить отчет', exc=exc)
raise ReportingServiceError('Не удалось отправить отчет в чат') from exc
# ---------- referral helpers ----------
def _referral_markers(self) -> list:
"""
Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT).
"""
clauses = []
# Явные флаги
if hasattr(Transaction, 'is_referral_bonus'):
clauses.append(Transaction.is_referral_bonus == true())
if hasattr(Transaction, 'is_bonus'):
clauses.append(Transaction.is_bonus == true())
# Источник/причина
if hasattr(Transaction, 'source'):
clauses.append(Transaction.source == 'referral')
clauses.append(Transaction.source == 'referral_bonus')
if hasattr(Transaction, 'reason'):
clauses.append(Transaction.reason == 'referral')
clauses.append(Transaction.reason == 'referral_bonus')
clauses.append(Transaction.reason == 'referral_reward')
# Текстовые поля
like_patterns = ['%реферал%', '%реферальн%', '%referral%']
if hasattr(Transaction, 'description'):
for pattern in like_patterns:
try:
clauses.append(Transaction.description.ilike(pattern))
except Exception:
pass
if hasattr(Transaction, 'comment'):
for pattern in like_patterns:
try:
clauses.append(Transaction.comment.ilike(pattern))
except Exception:
pass
return [clause for clause in clauses if clause is not None]
def _exclude_referral_deposits_condition(self):
"""
Условие «это НЕ реферальный бонус».
Если нет ни одного маркера — ничего не исключаем.
"""
markers = self._referral_markers()
if not markers:
return true()
return not_(or_(*markers))
# --------------------------------------
async def _build_report(
self,
period: ReportPeriod,
report_date: date | None,
) -> str:
period_range = self._get_period_range(period, report_date)
start_utc = period_range.start_msk.astimezone(UTC)
end_utc = period_range.end_msk.astimezone(UTC)
async with AsyncSessionLocal() as session:
totals = await self._collect_current_totals(session)
stats = await self._collect_period_stats(session, start_utc, end_utc)
top_referrers = await self._get_top_referrers(session, start_utc, end_utc, limit=5)
usage = await self._get_user_usage_stats(session)
conversion_rate = (
(stats['trial_to_paid_conversions'] / stats['new_trials'] * 100) if stats['new_trials'] > 0 else 0.0
)
lines: list[str] = []
header = (
f'📊 <b>Отчет за {period_range.label}</b>'
if period == ReportPeriod.DAILY
else f'📊 <b>Отчет за период {period_range.label}</b>'
)
lines += [header, '']
# TL;DR
lines += [
'🧭 <b>Итог по периоду</b>',
f'• Новых пользователей: <b>{stats["new_users"]}</b>',
f'• Новых триалов: <b>{stats["new_trials"]}</b>',
(
f'• Конверсий триал → платная: <b>{stats["trial_to_paid_conversions"]}</b> '
f'(<i>{conversion_rate:.1f}%</i>)'
),
f'• Новых платных (всего): <b>{stats["new_paid_subscriptions"]}</b>',
f'• Поступления всего (только пополнения): <b>{self._format_amount(stats["deposits_amount"])}</b>',
'',
]
# Подписки
lines += [
'💎 <b>Подписки</b>',
f'• Активные триалы сейчас: {totals["active_trials"]}',
f'• Активные платные сейчас: {totals["active_paid"]}',
'',
]
# Финансы
lines += [
'💰 <b>Финансы</b>',
(
'• Оплаты подписок: '
f'{stats["subscription_payments_count"]} на сумму {self._format_amount(stats["subscription_payments_amount"])}'
),
(f'• Пополнения: {stats["deposits_count"]} на сумму {self._format_amount(stats["deposits_amount"])}'),
(
'<i>Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы '
'исключены.</i>'
),
'',
]
# Поддержка
lines += [
'🎟️ <b>Поддержка</b>',
f'• Новых тикетов: {stats["new_tickets"]}',
f'• Активных тикетов сейчас: {totals["open_tickets"]}',
'',
]
# Активность пользователей
lines += [
'👤 <b>Активность пользователей</b>',
f'• Пользователей с активной платной подпиской: {usage["active_paid_users"]}',
f'• Пользователей, ни разу не подключившихся: {usage["never_connected_users"]}',
'',
]
# Топ по рефералам
lines += ['🤝 <b>Топ по рефералам (за период)</b>']
if top_referrers:
for index, row in enumerate(top_referrers, 1):
referrer_label = escape(row['referrer_label'], quote=False)
lines.append(f'{index}. {referrer_label}: {row["count"]} приглашений')
else:
lines.append('— данных нет')
return '\n'.join(lines)
def _get_period_range(
self,
period: ReportPeriod,
report_date: date | None,
) -> ReportPeriodRange:
now_msk = datetime.now(self._moscow_tz)
if period == ReportPeriod.DAILY:
target_date = report_date or (now_msk.date() - timedelta(days=1))
start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz)
end = start + timedelta(days=1)
elif period == ReportPeriod.WEEKLY:
end_date = report_date or now_msk.date()
start_date = end_date - timedelta(days=7)
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
elif period == ReportPeriod.MONTHLY:
end_date = report_date or now_msk.date()
start_date = end_date - timedelta(days=30)
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
else: # pragma: no cover - defensive branch
raise ReportingServiceError(f'Неизвестный период отчета: {period}')
label = self._format_period_label(start, end)
return ReportPeriodRange(start, end, label)
async def _collect_current_totals(self, session) -> dict:
stats = await get_subscriptions_statistics(session)
open_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.status.in_(
[
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value,
TicketStatus.PENDING.value,
]
)
)
)
open_tickets = int(open_tickets_result.scalar() or 0)
return {
'active_trials': stats.get('trial_subscriptions', 0) or 0,
'active_paid': stats.get('paid_subscriptions', 0) or 0,
'open_tickets': open_tickets,
}
async def _collect_period_stats(
self,
session,
start_utc: datetime,
end_utc: datetime,
) -> dict:
new_users = int(
(
await session.execute(
select(func.count(User.id)).where(
User.created_at >= start_utc,
User.created_at < end_utc,
)
)
).scalar()
or 0
)
new_trials = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == true(),
)
)
).scalar()
or 0
)
direct_paid = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == false(),
)
)
).scalar()
or 0
)
trial_to_paid_conversions = int(
(
await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
).scalar()
or 0
)
subscription_payments_count, subscription_payments_amount = (
await session.execute(
self._txn_query_base(
TransactionType.SUBSCRIPTION_PAYMENT.value,
start_utc,
end_utc,
)
)
).one()
deposits_count, deposits_amount = (
await session.execute(self._deposit_query_excluding_referrals(start_utc, end_utc))
).one()
new_tickets = int(
(
await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
).scalar()
or 0
)
return {
'new_users': new_users,
'new_trials': new_trials,
'new_paid_subscriptions': direct_paid + trial_to_paid_conversions,
'trial_to_paid_conversions': trial_to_paid_conversions,
'subscription_payments_count': int(subscription_payments_count or 0),
'subscription_payments_amount': int(subscription_payments_amount or 0),
'deposits_count': int(deposits_count or 0),
'deposits_amount': int(deposits_amount or 0),
'new_tickets': new_tickets,
}
def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime):
return select(
func.count(Transaction.id),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
).where(
Transaction.type == txn_type,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
"""Запрос депозитов только по реальным платежам.
Исключаются: колесо удачи, промокоды, админские пополнения, оплата с баланса.
"""
return select(
func.count(Transaction.id),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
# Только реальные платежи (исключаем колесо, промокоды, админские, баланс)
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
async def _get_top_referrers(
self,
session,
start_utc: datetime,
end_utc: datetime,
limit: int = 5,
) -> list[dict]:
rows = await session.execute(
select(
User.referred_by_id,
func.count(User.id).label('cnt'),
)
.where(
User.created_at >= start_utc,
User.created_at < end_utc,
User.referred_by_id.isnot(None),
)
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(limit)
)
rows = rows.all()
if not rows:
return []
ref_ids = [row[0] for row in rows if row[0] is not None]
users_map: dict[int, str] = {}
if ref_ids:
urows = await session.execute(select(User).where(User.id.in_(ref_ids)))
for user in urows.scalars().all():
users_map[user.id] = self._user_label(user)
return [
{'referrer_label': users_map.get(ref_id, f'User #{ref_id}'), 'count': int(count or 0)}
for ref_id, count in rows
]
async def _get_user_usage_stats(self, session) -> dict[str, int]:
now_utc = datetime.now(UTC)
active_paid_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.is_trial == false(),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now_utc,
)
)
active_paid_users = int(active_paid_q.scalar() or 0)
never_connected_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
or_(
Subscription.connected_squads.is_(None),
cast(Subscription.connected_squads, JSONB) == cast('[]', JSONB),
func.jsonb_typeof(cast(Subscription.connected_squads, JSONB)) != 'array',
)
)
)
never_connected_users = int(never_connected_q.scalar() or 0)
return {
'active_paid_users': active_paid_users,
'never_connected_users': never_connected_users,
}
def _user_label(self, user: User) -> str:
if getattr(user, 'username', None):
return f'@{user.username}'
parts = []
if getattr(user, 'first_name', None):
parts.append(user.first_name)
if getattr(user, 'last_name', None):
parts.append(user.last_name)
if parts:
return ' '.join(parts)
return f'User #{getattr(user, "id", "?")}'
def _format_period_label(self, start: datetime, end: datetime) -> str:
start_date = start.astimezone(self._moscow_tz).date()
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
end_date = end_boundary.date()
if start_date == end_date:
return start_date.strftime('%d.%m.%Y')
return f'{start_date.strftime("%d.%m.%Y")} - {end_date.strftime("%d.%m.%Y")}'
def _format_amount(self, amount_kopeks: int) -> str:
rubles = (amount_kopeks or 0) / 100
return f'{rubles:,.2f}'.replace(',', ' ')
reporting_service = ReportingService()