"""Фоновый сервис для обработки очереди чеков NaloGO.
При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
и отправляются позже этим сервисом.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from aiogram import Bot
from dateutil.parser import isoparse
from app.config import settings
from app.services.nalogo_service import NaloGoService
from app.utils.cache import cache
logger = logging.getLogger(__name__)
class NalogoQueueService:
"""Сервис фоновой обработки очереди чеков NaloGO."""
def __init__(self, nalogo_service: NaloGoService | None = None):
self._nalogo_service = nalogo_service
self._bot: Bot | None = None
self._task: asyncio.Task | None = None
self._running = False
self._last_notification_time: datetime | None = None
self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
def set_nalogo_service(self, service: NaloGoService) -> None:
"""Установить сервис NaloGO."""
self._nalogo_service = service
def set_bot(self, bot: Bot) -> None:
"""Установить бота для отправки уведомлений."""
self._bot = bot
def is_running(self) -> bool:
"""Проверка, запущен ли сервис."""
return self._running and self._task is not None and not self._task.done()
@property
def _check_interval(self) -> int:
"""Интервал проверки очереди в секундах."""
return getattr(settings, 'NALOGO_QUEUE_CHECK_INTERVAL', 300)
@property
def _receipt_delay(self) -> int:
"""Задержка между отправкой чеков в секундах."""
return getattr(settings, 'NALOGO_QUEUE_RECEIPT_DELAY', 3)
@property
def _max_attempts(self) -> int:
"""Максимальное количество попыток отправки чека."""
return getattr(settings, 'NALOGO_QUEUE_MAX_ATTEMPTS', 10)
async def start(self) -> None:
"""Запустить фоновую обработку очереди."""
if not self._nalogo_service or not self._nalogo_service.configured:
logger.info('NaloGO не настроен, сервис очереди чеков не запущен')
return
if self.is_running():
logger.warning('Сервис очереди чеков уже запущен')
return
self._running = True
self._task = asyncio.create_task(self._process_queue_loop())
logger.info(
f'Сервис очереди чеков NaloGO запущен '
f'(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)'
)
async def stop(self) -> None:
"""Остановить фоновую обработку."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info('Сервис очереди чеков NaloGO остановлен')
async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
"""Отправить уведомление админам о чеках."""
if not self._bot:
return
chat_id = settings.get_admin_notifications_chat_id()
if not chat_id:
return
topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
# Проверяем cooldown (можно пропустить для важных уведомлений)
if not skip_cooldown:
now = datetime.now()
if self._last_notification_time:
if now - self._last_notification_time < self._notification_cooldown:
logger.debug('Уведомление о чеках пропущено (cooldown)')
return
try:
await self._bot.send_message(
chat_id=chat_id,
message_thread_id=topic_id,
text=message,
parse_mode='HTML',
)
self._last_notification_time = datetime.now()
logger.info('Отправлено уведомление о чеках NaloGO')
except Exception as error:
logger.error(f'Ошибка отправки уведомления о чеках: {error}')
async def _process_queue_loop(self) -> None:
"""Основной цикл обработки очереди."""
while self._running:
try:
await self._process_pending_receipts()
except Exception as error:
logger.error(f'Ошибка в цикле обработки очереди чеков: {error}')
await asyncio.sleep(self._check_interval)
async def _process_pending_receipts(self) -> None:
"""Обработать все ожидающие чеки в очереди."""
if not self._nalogo_service:
return
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return
logger.info(f'Начинаем обработку очереди чеков: {queue_length} шт.')
self._had_pending_receipts = True
processed = 0
failed = 0
skipped = 0
total_processed_amount = 0.0
service_unavailable = False
while True:
receipt_data = await self._nalogo_service.pop_receipt_from_queue()
if not receipt_data:
break
attempts = receipt_data.get('attempts', 0)
payment_id = receipt_data.get('payment_id', 'unknown')
amount = receipt_data.get('amount', 0)
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning(f'Чек {payment_id} уже {attempts} попыток, продолжаем пытаться...')
# Пытаемся отправить чек
try:
# Восстанавливаем описание из сохранённых данных
telegram_user_id = receipt_data.get('telegram_user_id')
amount_kopeks = receipt_data.get('amount_kopeks')
# Извлекаем время оплаты из очереди (чтобы чек был с правильным временем)
operation_time = None
created_at_str = receipt_data.get('created_at')
if created_at_str:
try:
operation_time = isoparse(created_at_str)
except (ValueError, TypeError) as parse_error:
logger.warning(f"Не удалось распарсить created_at '{created_at_str}': {parse_error}")
# Формируем описание заново из настроек (если есть данные)
if amount_kopeks is not None:
receipt_name = settings.get_balance_payment_description(amount_kopeks, telegram_user_id)
else:
# Fallback на сохранённое имя
receipt_name = receipt_data.get(
'name', settings.get_balance_payment_description(int(amount * 100), telegram_user_id)
)
receipt_uuid = await self._nalogo_service.create_receipt(
name=receipt_name,
amount=amount,
quantity=receipt_data.get('quantity', 1),
client_info=receipt_data.get('client_info'),
payment_id=payment_id,
queue_on_failure=False, # Не добавлять в очередь повторно автоматически
telegram_user_id=telegram_user_id,
amount_kopeks=amount_kopeks,
operation_time=operation_time, # Время оплаты, а не отправки
)
if receipt_uuid:
processed += 1
total_processed_amount += amount
# Удаляем метку "в очереди" (чек создан успешно)
if payment_id:
queued_key = f'nalogo:queued:{payment_id}'
await cache.delete(queued_key)
logger.info(
f'Чек из очереди успешно создан: {receipt_uuid} '
f'(payment_id={payment_id}, попытка {attempts + 1})'
)
else:
# Вернуть в очередь с увеличенным счетчиком попыток
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
service_unavailable = True
logger.warning(
f'Не удалось создать чек из очереди (payment_id={payment_id}), '
f'возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})'
)
# Если сервис недоступен, прекращаем попытки до следующего цикла
break
except Exception as error:
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
logger.error(f'Ошибка при создании чека из очереди (payment_id={payment_id}): {error}')
# Прекращаем попытки при ошибке
break
# Задержка между чеками чтобы не долбить API
await asyncio.sleep(self._receipt_delay)
if processed > 0 or failed > 0 or skipped > 0:
logger.info(f'Обработка очереди завершена: успешно={processed}, неудачно={failed}, пропущено={skipped}')
# Проверяем остаток в очереди
remaining = await self._nalogo_service.get_queue_length()
# Отправляем уведомление если есть проблемы
if service_unavailable or failed > 0:
if remaining > 0:
queued = await self._nalogo_service.get_queued_receipts()
total_queued_amount = sum(r.get('amount', 0) for r in queued)
message = (
f'⚠️ Проблема с отправкой чеков NaloGO\n\n'
f'Сервис nalog.ru временно недоступен.\n\n'
f'📋 В очереди: {remaining} чек(ов)\n'
f'💰 На сумму: {total_queued_amount:,.2f} ₽\n\n'
f'Чеки будут отправлены автоматически когда сервис восстановится.'
)
await self._send_admin_notification(message)
# Уведомление об успешной разгрузке очереди
elif remaining == 0 and self._had_pending_receipts and processed > 0:
self._had_pending_receipts = False
message = (
f'✅ Очередь чеков NaloGO разгружена\n\n'
f'Все отложенные чеки успешно отправлены!\n\n'
f'📋 Отправлено: {processed} чек(ов)\n'
f'💰 На сумму: {total_processed_amount:,.2f} ₽'
)
await self._send_admin_notification(message, skip_cooldown=True)
async def force_process(self) -> dict:
"""Принудительно обработать очередь (для ручного запуска)."""
if not self._nalogo_service:
return {'error': 'NaloGO сервис не настроен'}
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return {'message': 'Очередь пуста', 'processed': 0}
await self._process_pending_receipts()
new_length = await self._nalogo_service.get_queue_length()
return {
'message': 'Обработка завершена',
'was_in_queue': queue_length,
'remaining': new_length,
'processed': queue_length - new_length,
}
async def get_status(self) -> dict:
"""Получить статус сервиса и очереди."""
queue_length = 0
total_amount = 0.0
queued_receipts = []
pending_verification_count = 0
pending_verification_amount = 0.0
pending_verification_receipts = []
if self._nalogo_service:
queue_length = await self._nalogo_service.get_queue_length()
if queue_length > 0:
queued_receipts = await self._nalogo_service.get_queued_receipts()
total_amount = sum(r.get('amount', 0) for r in queued_receipts)
# Чеки ожидающие ручной проверки
pending_verification_count = await self._nalogo_service.get_pending_verification_count()
if pending_verification_count > 0:
pending_verification_receipts = await self._nalogo_service.get_pending_verification_receipts()
pending_verification_amount = sum(r.get('amount', 0) for r in pending_verification_receipts)
return {
'running': self.is_running(),
'check_interval_seconds': self._check_interval,
'receipt_delay_seconds': self._receipt_delay,
'queue_length': queue_length,
'total_amount': total_amount,
'max_attempts': self._max_attempts,
'queued_receipts': queued_receipts[:10],
# Чеки требующие ручной проверки (таймаут после успешной авторизации)
'pending_verification_count': pending_verification_count,
'pending_verification_amount': pending_verification_amount,
'pending_verification_receipts': pending_verification_receipts[:10],
}
# Глобальный экземпляр сервиса
nalogo_queue_service = NalogoQueueService()