refactor(nalogo): улучшение системы чеков NaloGO

Сохранение времени оплаты:
  - Добавлен параметр operation_time в create_receipt()
  - Чеки из очереди создаются с оригинальным временем платежа
  - Парсинг created_at из Redis очереди

  Защита от дублей (3 уровня):
  - Проверка transaction.receipt_uuid перед созданием
  - Redis ключ nalogo:created:{payment_id} с TTL 30 дней
  - Сохранение receipt_uuid в транзакцию после создания

  Бесконечные повторы:
  - Убрано удаление чеков после 10 попыток
  - Чеки остаются в очереди до успешной отправки

  Обработка ошибок:
  - Добавлена обработка 500 и "внутренняя ошибка" как временной недоступности

  Сверка чеков:
  - Заменена API сверка на сверку по логам (logs/current/payments.log)
  - Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
  - Исправлена навигация кнопок "Назад"
This commit is contained in:
gy9vin
2025-12-30 00:39:00 +03:00
parent 4cab3f5ed4
commit d10ccc4844
7 changed files with 611 additions and 24 deletions

View File

@@ -930,10 +930,14 @@ class Transaction(Base):
description = Column(Text, nullable=True)
payment_method = Column(String(50), nullable=True)
external_id = Column(String(255), nullable=True)
external_id = Column(String(255), nullable=True)
is_completed = Column(Boolean, default=True)
# NaloGO чек
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
completed_at = Column(DateTime, nullable=True)

View File

@@ -4754,6 +4754,78 @@ async def add_subscription_purchased_traffic_column() -> bool:
return False
async def add_transaction_receipt_columns() -> bool:
"""Добавить колонки receipt_uuid и receipt_created_at в transactions."""
try:
receipt_uuid_exists = await check_column_exists("transactions", "receipt_uuid")
receipt_created_at_exists = await check_column_exists("transactions", "receipt_created_at")
if receipt_uuid_exists and receipt_created_at_exists:
logger.info("Колонки receipt_uuid и receipt_created_at уже существуют в transactions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not receipt_uuid_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
else:
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
logger.info("✅ Добавлена колонка receipt_uuid в transactions")
if not receipt_created_at_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at TIMESTAMP"
))
else:
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
))
logger.info("✅ Добавлена колонка receipt_created_at в transactions")
# Создаём индекс на receipt_uuid
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "postgresql":
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
elif db_type == "sqlite":
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
else:
await conn.execute(text(
"CREATE INDEX ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
except Exception as idx_error:
logger.warning(f"Индекс на receipt_uuid возможно уже существует: {idx_error}")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок чеков в transactions: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -5224,6 +5296,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей subscription_events")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЧЕКОВ В TRANSACTIONS ===")
receipt_columns_ready = await add_transaction_receipt_columns()
if receipt_columns_ready:
logger.info("✅ Колонки receipt_uuid и receipt_created_at готовы")
else:
logger.warning("⚠️ Проблемы с колонками чеков в transactions")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -5407,7 +5486,11 @@ async def check_migration_status():
)
status["users_last_pinned_column"] = await check_column_exists('users', 'last_pinned_message_id')
# Колонки чеков в transactions
status["transactions_receipt_uuid_column"] = await check_column_exists('transactions', 'receipt_uuid')
status["transactions_receipt_created_at_column"] = await check_column_exists('transactions', 'receipt_created_at')
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
@@ -5471,6 +5554,8 @@ async def check_migration_status():
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
"promo_offer_logs_table": "Таблица promo_offer_logs",
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
"transactions_receipt_uuid_column": "Колонка receipt_uuid в transactions",
"transactions_receipt_created_at_column": "Колонка receipt_created_at в transactions",
}
for check_key, check_status in status.items():

View File

@@ -931,14 +931,20 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
buttons = []
# Кнопка обработки очереди чеков если есть что обрабатывать
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
buttons.append([InlineKeyboardButton(
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
)])
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -1036,13 +1042,20 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
stats_text += nalogo_section
buttons = []
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
buttons.append([InlineKeyboardButton(
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
)])
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -1055,6 +1068,358 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_missing")
@admin_required
async def receipts_missing_callback(callback: CallbackQuery):
"""Сверка чеков по логам."""
# Напрямую вызываем сверку по логам
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_receipts_link_old")
@admin_required
async def receipts_link_old_callback(callback: CallbackQuery):
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy import select, and_
from datetime import date, timedelta
from app.database.models import Transaction, PaymentMethod, TransactionType
from app.services.nalogo_service import NaloGoService
await callback.answer("🔄 Загружаю чеки из NaloGO...", show_alert=False)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
async for db in get_db():
# Получаем старые транзакции без чеков
query = select(Transaction).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.YOOKASSA.value,
Transaction.receipt_uuid.is_(None),
Transaction.is_completed == True,
Transaction.created_at < TRACKING_START_DATE,
)
).order_by(Transaction.created_at.desc())
result = await db.execute(query)
transactions = result.scalars().all()
if not transactions:
await callback.answer("✅ Нет старых транзакций для привязки", show_alert=True)
return
# Получаем чеки из NaloGO за последние 60 дней
nalogo_service = NaloGoService()
to_date = date.today()
from_date = to_date - timedelta(days=60)
incomes = await nalogo_service.get_incomes(
from_date=from_date,
to_date=to_date,
limit=500,
)
if not incomes:
await callback.answer("Не удалось получить чеки из NaloGO", show_alert=True)
return
# Создаём словарь чеков по сумме для быстрого поиска
# Ключ: сумма в копейках, значение: список чеков
incomes_by_amount = {}
for income in incomes:
amount = float(income.get("totalAmount", income.get("amount", 0)))
amount_kopeks = int(amount * 100)
if amount_kopeks not in incomes_by_amount:
incomes_by_amount[amount_kopeks] = []
incomes_by_amount[amount_kopeks].append(income)
linked = 0
for t in transactions:
if t.amount_kopeks in incomes_by_amount:
matching_incomes = incomes_by_amount[t.amount_kopeks]
if matching_incomes:
# Берём первый подходящий чек
income = matching_incomes.pop(0)
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
if receipt_uuid:
t.receipt_uuid = receipt_uuid
# Парсим дату чека
operation_time = income.get("operationTime")
if operation_time:
try:
from dateutil.parser import isoparse
t.receipt_created_at = isoparse(operation_time)
except Exception:
t.receipt_created_at = datetime.utcnow()
linked += 1
if linked > 0:
await db.commit()
text = f"🔗 <b>Привязка завершена</b>\n\n"
text += f"Всего транзакций: {len(transactions)}\n"
text += f"Чеков в NaloGO: {len(incomes)}\n"
text += f"Привязано: <b>{linked}</b>\n"
text += f"Не удалось привязать: {len(transactions) - linked}"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка привязки старых чеков: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_reconcile")
@admin_required
async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMContext):
"""Меню выбора периода сверки."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Очищаем состояние на случай если остался ввод даты
await state.clear()
# Сразу показываем сверку по логам
await _do_reconcile_logs(callback)
async def _do_reconcile_logs(callback: CallbackQuery):
"""Внутренняя функция сверки по логам."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
from collections import defaultdict
await callback.answer("🔄 Анализирую логи платежей...", show_alert=False)
# Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
try:
await callback.message.edit_text(
"❌ <b>Файл логов не найден</b>\n\n"
f"Путь: <code>{payments_log}</code>\n\n"
"<i>Логи появятся после первого успешного платежа.</i>",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
except TelegramBadRequest:
pass # Сообщение не изменилось
return
# Паттерны для парсинга логов
# Успешный платёж: "Успешно обработан платеж YooKassa 30e3c6fc-000f-5001-9000-1a9c8b242396: пользователь 1046 пополнил баланс на 200.0₽"
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽"
)
# Чек создан: "Чек NaloGO создан для платежа 30e3c6fc-000f-5001-9000-1a9c8b242396: 243udsqtik"
receipt_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)"
)
# Читаем и парсим логи
payments = {} # payment_id -> {date, amount}
receipts = {} # payment_id -> {date, receipt_uuid}
try:
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
# Проверяем платежи
match = payment_pattern.search(line)
if match:
date_str, payment_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"amount": float(amount)
}
continue
# Проверяем чеки
match = receipt_pattern.search(line)
if match:
date_str, payment_id, receipt_uuid = match.groups()
receipts[payment_id] = {
"date": date_str,
"receipt_uuid": receipt_uuid
}
except Exception as e:
logger.error(f"Ошибка чтения логов: {e}")
await callback.message.edit_text(
f"❌ <b>Ошибка чтения логов</b>\n\n{str(e)}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
return
# Находим платежи без чеков
payments_without_receipts = []
for payment_id, payment_data in payments.items():
if payment_id not in receipts:
payments_without_receipts.append({
"payment_id": payment_id,
"date": payment_data["date"],
"amount": payment_data["amount"]
})
# Группируем по датам
by_date = defaultdict(list)
for p in payments_without_receipts:
by_date[p["date"]].append(p)
# Формируем отчёт
total_payments = len(payments)
total_receipts = len(receipts)
missing_count = len(payments_without_receipts)
missing_amount = sum(p["amount"] for p in payments_without_receipts)
text = "📋 <b>Сверка по логам</b>\n\n"
text += f"📦 <b>Всего платежей:</b> {total_payments}\n"
text += f"🧾 <b>Чеков создано:</b> {total_receipts}\n\n"
if missing_count == 0:
text += "✅ <b>Все платежи имеют чеки!</b>"
else:
text += f"⚠️ <b>Без чеков:</b> {missing_count} платежей на {missing_amount:,.2f}\n\n"
# Показываем по датам (последние)
sorted_dates = sorted(by_date.keys(), reverse=True)
for date_str in sorted_dates[:7]:
date_payments = by_date[date_str]
date_amount = sum(p["amount"] for p in date_payments)
text += f"• <b>{date_str}:</b> {len(date_payments)} шт. на {date_amount:,.2f}\n"
if len(sorted_dates) > 7:
text += f"\n<i>...и ещё {len(sorted_dates) - 7} дней</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="📄 Детали", callback_data="admin_mon_reconcile_logs_details")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass # Сообщение не изменилось
except TelegramBadRequest:
pass # Игнорируем если сообщение не изменилось
except Exception as e:
logger.error(f"Ошибка сверки по логам: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_reconcile_logs")
@admin_required
async def receipts_reconcile_logs_refresh_callback(callback: CallbackQuery):
"""Обновить сверку по логам."""
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_reconcile_logs_details")
@admin_required
async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
"""Детальный список платежей без чеков."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
await callback.answer("🔄 Загружаю детали...", show_alert=False)
# Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
await callback.answer("❌ Файл логов не найден", show_alert=True)
return
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽"
)
receipt_pattern = re.compile(
r"Чек NaloGO создан для платежа ([a-f0-9-]+)"
)
payments = {}
receipts = set()
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
match = payment_pattern.search(line)
if match:
date_str, time_str, payment_id, user_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"time": time_str,
"user_id": user_id,
"amount": float(amount)
}
continue
match = receipt_pattern.search(line)
if match:
receipts.add(match.group(1))
# Платежи без чеков
missing = []
for payment_id, data in payments.items():
if payment_id not in receipts:
missing.append({"payment_id": payment_id, **data})
# Сортируем по дате (новые сверху)
missing.sort(key=lambda x: (x["date"], x["time"]), reverse=True)
if not missing:
text = "✅ <b>Все платежи имеют чеки!</b>"
else:
text = f"📄 <b>Платежи без чеков ({len(missing)} шт.)</b>\n\n"
for p in missing[:20]:
text += (
f"• <b>{p['date']} {p['time']}</b>\n"
f" User: {p['user_id']} | {p['amount']:.0f}\n"
f" <code>{p['payment_id'][:18]}...</code>\n\n"
)
if len(missing) > 20:
text += f"<i>...и ещё {len(missing) - 20} платежей</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_reconcile_logs")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass
except TelegramBadRequest:
pass
except Exception as e:
logger.error(f"Ошибка детализации: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton

View File

@@ -3,9 +3,9 @@ Income API implementation.
Based on PHP library's Api\\Income class.
"""
from datetime import datetime
from datetime import datetime, date
from decimal import Decimal
from typing import Any
from typing import Any, Optional
from ._http import AsyncHTTPClient
from .dto.income import (
@@ -193,3 +193,47 @@ class IncomeAPI:
# Make API request
response = await self.http.post("/cancel", json_data=request.model_dump())
return response.json() # type: ignore[no-any-return]
async def get_list(
self,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""
Get list of income records for a period.
Args:
from_date: Start date (default: 30 days ago)
to_date: End date (default: today)
limit: Maximum number of records (default: 100)
offset: Offset for pagination (default: 0)
Returns:
Dictionary with income records list
Raises:
DomainException: For API errors
"""
from datetime import timedelta
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
# API использует GET с query параметрами
params = {
"from": from_date.isoformat(),
"to": to_date.isoformat(),
"limit": str(limit),
"offset": str(offset),
"sortBy": "OPERATION_TIME",
"sortOrder": "DESC",
}
# Формируем query string
query = "&".join(f"{k}={v}" for k, v in params.items())
response = await self.http.get(f"/incomes?{query}")
return response.json() # type: ignore[no-any-return]

View File

@@ -155,14 +155,11 @@ class NalogoQueueService:
payment_id = receipt_data.get("payment_id", "unknown")
amount = receipt_data.get("amount", 0)
# Проверяем количество попыток
if attempts >= self._max_attempts:
logger.error(
f"Чек {payment_id} превысил лимит попыток ({self._max_attempts}), "
f"удален из очереди"
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning(
f"Чек {payment_id} уже {attempts} попыток, продолжаем пытаться..."
)
skipped += 1
continue
# Пытаемся отправить чек
try:

View File

@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
from datetime import datetime, timezone, timedelta, date
from typing import Optional, Dict, Any, List
from decimal import Decimal
# Используем локальную исправленную версию библиотеки
@@ -61,6 +61,9 @@ class NaloGoService:
error_type = type(error).__name__.lower()
return (
"503" in error_str
or "500" in error_str
or "internal server error" in error_str
or "внутренняя ошибка" in error_str
or "service temporarily unavailable" in error_str
or "service unavailable" in error_str
or "ведутся работы" in error_str
@@ -157,6 +160,17 @@ class NaloGoService:
logger.warning("NaloGO не настроен, чек не создан")
return None
# Защита от дублей: проверяем не был ли уже создан чек для этого payment_id
if payment_id:
created_key = f"nalogo:created:{payment_id}"
already_created = await cache.get(created_key)
if already_created:
logger.info(
f"Чек для payment_id={payment_id} уже был создан ({already_created}), "
"пропускаем повторное создание"
)
return already_created # Возвращаем ранее созданный uuid
try:
# Аутентифицируемся, если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
@@ -194,6 +208,12 @@ class NaloGoService:
receipt_uuid = result.get("approvedReceiptUuid")
if receipt_uuid:
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}")
# Сохраняем в Redis чтобы предотвратить дубли (TTL 30 дней)
if payment_id:
created_key = f"nalogo:created:{payment_id}"
await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600)
return receipt_uuid
else:
logger.error(f"Ошибка создания чека: {result}")
@@ -230,3 +250,49 @@ class NaloGoService:
"""Вернуть чек обратно в очередь (при неудачной отправке)."""
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
async def get_incomes(
self,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
limit: int = 100,
) -> Optional[List[Dict[str, Any]]]:
"""Получить список доходов (чеков) за период.
Args:
from_date: Начало периода (по умолчанию 30 дней назад)
to_date: Конец периода (по умолчанию сегодня)
limit: Максимальное количество записей
Returns:
Список чеков с информацией, или None при ошибке
"""
if not self.configured:
logger.warning("NaloGO не настроен, невозможно получить список доходов")
return None
try:
# Аутентифицируемся если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
return []
income_api = self.client.income()
result = await income_api.get_list(
from_date=from_date,
to_date=to_date,
limit=limit,
)
# API возвращает структуру с полем content или items
incomes = result.get("content", result.get("items", []))
logger.info(f"Получено {len(incomes)} доходов из NaloGO")
return incomes
except Exception as error:
if self._is_service_unavailable(error):
logger.warning(f"NaloGO временно недоступен: {error}")
else:
logger.error(f"Ошибка получения списка доходов: {error}", exc_info=True)
return None # None = ошибка, [] = нет чеков

View File

@@ -23,7 +23,7 @@ from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
if TYPE_CHECKING:
from app.database.models import YooKassaPayment
from app.database.models import YooKassaPayment, Transaction
class YooKassaPaymentMixin:
@@ -970,7 +970,9 @@ class YooKassaPaymentMixin:
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
if hasattr(self, "nalogo_service") and self.nalogo_service:
await self._create_nalogo_receipt(
payment,
db=db,
payment=payment,
transaction=transaction,
telegram_user_id=user.telegram_id if user else None,
)
@@ -1026,7 +1028,9 @@ class YooKassaPaymentMixin:
async def _create_nalogo_receipt(
self,
db: AsyncSession,
payment: "YooKassaPayment",
transaction: Optional["Transaction"] = None,
telegram_user_id: Optional[int] = None,
) -> None:
"""Создание чека через NaloGO для успешного платежа."""
@@ -1034,6 +1038,14 @@ class YooKassaPaymentMixin:
logger.debug("NaloGO сервис не инициализирован, чек не создан")
return
# Защита от дублей: если у транзакции уже есть чек — не создаём новый
if transaction and getattr(transaction, "receipt_uuid", None):
logger.info(
f"Чек для платежа {payment.yookassa_payment_id} уже создан: {transaction.receipt_uuid}, "
"пропускаем повторное создание"
)
return
try:
amount_rubles = payment.amount_kopeks / 100
# Формируем описание из настроек (включает сумму и ID пользователя)
@@ -1052,6 +1064,20 @@ class YooKassaPaymentMixin:
if receipt_uuid:
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
# Сохраняем receipt_uuid в транзакцию
if transaction:
try:
transaction.receipt_uuid = receipt_uuid
transaction.receipt_created_at = datetime.utcnow()
await db.commit()
logger.debug(
f"Чек {receipt_uuid} привязан к транзакции {transaction.id}"
)
except Exception as save_error:
logger.warning(
f"Не удалось сохранить receipt_uuid в транзакцию: {save_error}"
)
# При временной недоступности чек добавляется в очередь автоматически
except Exception as error: