mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = ошибка, [] = нет чеков
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user