import asyncio import logging from dataclasses import dataclass from datetime import date, datetime, time as datetime_time, timedelta, timezone from enum import Enum from typing import Optional, Tuple from zoneinfo import ZoneInfo from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy import func, select from app.config import settings from app.database.crud.subscription import get_subscriptions_statistics from app.database.database import AsyncSessionLocal from app.database.models import ( Subscription, SubscriptionConversion, Transaction, TransactionType, ) logger = logging.getLogger(__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 and can schedule daily delivery.""" def __init__(self) -> None: self.bot: Optional[Bot] = None self._task: Optional[asyncio.Task] = 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( "📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК", 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: Optional[date] = 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(timezone.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( "📊 Автоматический отчет за %s отправлен", report_date.strftime("%d.%m.%Y"), ) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 logger.error("Ошибка автоматической отправки отчета: %s", exc) next_run_utc, report_date = self._calculate_next_run(send_time) except asyncio.CancelledError: logger.info("Сервис отчетов остановлен") raise except Exception as exc: # noqa: BLE001 logger.error("Критическая ошибка в сервисе отчетов: %s", 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(timezone.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, ) except (TelegramBadRequest, TelegramForbiddenError) as exc: logger.error("Не удалось отправить отчет: %s", exc) raise ReportingServiceError("Не удалось отправить отчет в чат") from exc async def _build_report( self, period: ReportPeriod, report_date: Optional[date], ) -> str: period_range = self._get_period_range(period, report_date) start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None) end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None) async with AsyncSessionLocal() as session: totals = await self._collect_current_totals(session) period_stats = await self._collect_period_stats(session, start_utc, end_utc) header = ( f"📊 Отчет за {period_range.label}" if period == ReportPeriod.DAILY else f"📊 Отчет за период {period_range.label}" ) lines = [ header, "", "🎯 Триалы", f"• Активных сейчас: {totals['active_trials']}", f"• Новых за период: {period_stats['new_trials']}", "", "💎 Платные подписки", f"• Активных сейчас: {totals['active_paid']}", f"• Новых за период: {period_stats['new_paid_subscriptions']}", "", "💰 Платежи", f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму " f"{self._format_amount(period_stats['subscription_payments_amount'])}", f"• Пополнений: {period_stats['deposits_count']} на сумму " f"{self._format_amount(period_stats['deposits_amount'])}", f"• Всего поступлений: {period_stats['total_payments_count']} на сумму " f"{self._format_amount(period_stats['total_payments_amount'])}", ] return "\n".join(lines) def _get_period_range( self, period: ReportPeriod, report_date: Optional[date], ) -> 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) return { "active_trials": stats.get("trial_subscriptions", 0) or 0, "active_paid": stats.get("paid_subscriptions", 0) or 0, } async def _collect_period_stats( self, session, start_utc: datetime, end_utc: datetime, ) -> dict: new_trials_result = await session.execute( select(func.count(Subscription.id)).where( Subscription.created_at >= start_utc, Subscription.created_at < end_utc, Subscription.is_trial == True, # noqa: E712 ) ) new_trials = int(new_trials_result.scalar() or 0) direct_paid_result = await session.execute( select(func.count(Subscription.id)).where( Subscription.created_at >= start_utc, Subscription.created_at < end_utc, Subscription.is_trial == False, # noqa: E712 ) ) direct_paid = int(direct_paid_result.scalar() or 0) conversions_result = await session.execute( select(func.count(SubscriptionConversion.id)).where( SubscriptionConversion.converted_at >= start_utc, SubscriptionConversion.converted_at < end_utc, ) ) conversions_count = int(conversions_result.scalar() or 0) subscription_payments_row = ( await session.execute( select( func.count(Transaction.id), func.coalesce(func.sum(Transaction.amount_kopeks), 0), ).where( Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, Transaction.is_completed == True, # noqa: E712 Transaction.created_at >= start_utc, Transaction.created_at < end_utc, ) ) ).one() deposits_row = ( await session.execute( select( func.count(Transaction.id), func.coalesce(func.sum(Transaction.amount_kopeks), 0), ).where( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, # noqa: E712 Transaction.created_at >= start_utc, Transaction.created_at < end_utc, ) ) ).one() subscription_payments_count = int(subscription_payments_row[0] or 0) subscription_payments_amount = int(subscription_payments_row[1] or 0) deposits_count = int(deposits_row[0] or 0) deposits_amount = int(deposits_row[1] or 0) total_payments_count = subscription_payments_count + deposits_count total_payments_amount = subscription_payments_amount + deposits_amount return { "new_trials": new_trials, "new_paid_subscriptions": direct_paid + conversions_count, "subscription_payments_count": subscription_payments_count, "subscription_payments_amount": subscription_payments_amount, "deposits_count": deposits_count, "deposits_amount": deposits_amount, "total_payments_count": total_payments_count, "total_payments_amount": total_payments_amount, } 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: if not amount_kopeks: return "0 ₽" rubles = amount_kopeks / 100 return f"{rubles:,.2f} ₽".replace(",", " ") reporting_service = ReportingService()