diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py
index 997cf902..f2e0af1a 100644
--- a/app/handlers/admin/contests.py
+++ b/app/handlers/admin/contests.py
@@ -1015,6 +1015,10 @@ async def show_virtual_participants(
text='➕ Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
+ types.InlineKeyboardButton(
+ text='🎭 Массовка',
+ callback_data=f'admin_contest_vp_mass_{contest_id}',
+ ),
],
]
if vps:
@@ -1160,7 +1164,10 @@ async def delete_virtual_participant_handler(
lines.append('Пока нет виртуальных участников.')
rows = [
- [types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
+ [
+ types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
+ types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
+ ],
]
if vps:
for v in vps:
@@ -1180,6 +1187,164 @@ async def delete_virtual_participant_handler(
)
+@admin_required
+@error_handler
+async def start_mass_virtual_participants(
+ callback: types.CallbackQuery,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Начинает массовое создание виртуальных участников (массовка)."""
+ contest_id = int(callback.data.split('_')[-1])
+ await state.set_state(AdminStates.adding_mass_virtual_count)
+ await state.update_data(mass_vp_contest_id=contest_id)
+
+ text = """
+🎭 Массовка — массовое создание виртуальных участников
+
+Для чего это нужно?
+Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
+
+Это помогает:
+• Мотивировать реальных участников соревноваться
+• Задать планку для участия
+• Сделать конкурс более живым
+
+Введите количество призраков для создания:
+(от 1 до 50)
+"""
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
+ ]
+ ),
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_mass_virtual_count(
+ message: types.Message,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Обрабатывает количество призраков для массового создания."""
+ try:
+ count = int(message.text.strip())
+ if count < 1 or count > 50:
+ await message.answer(
+ '❌ Введите число от 1 до 50:',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
+ ]
+ ),
+ )
+ return
+ except ValueError:
+ await message.answer(
+ '❌ Введите корректное число от 1 до 50:',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
+ ]
+ ),
+ )
+ return
+
+ await state.update_data(mass_vp_count=count)
+ await state.set_state(AdminStates.adding_mass_virtual_referrals)
+
+ data = await state.get_data()
+ contest_id = data.get('mass_vp_contest_id')
+
+ await message.answer(
+ f'✅ Будет создано {count} призраков.\n\n'
+ f'Введите количество рефералов у каждого:\n'
+ f'(от 1 до 100)',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
+ ]
+ ),
+ )
+
+
+@admin_required
+@error_handler
+async def process_mass_virtual_referrals(
+ message: types.Message,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Создаёт массовку призраков с рандомными именами."""
+ import random
+ import string
+
+ try:
+ referrals_count = int(message.text.strip())
+ if referrals_count < 1 or referrals_count > 100:
+ await message.answer('❌ Введите число от 1 до 100:')
+ return
+ except ValueError:
+ await message.answer('❌ Введите корректное число от 1 до 100:')
+ return
+
+ data = await state.get_data()
+ contest_id = data.get('mass_vp_contest_id')
+ ghost_count = data.get('mass_vp_count', 1)
+
+ await state.clear()
+
+ # Генерируем и создаём призраков
+ created = []
+ for _ in range(ghost_count):
+ # Рандомное имя до 5 символов (буквы + цифры)
+ name_length = random.randint(3, 5)
+ name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
+
+ vp = await add_virtual_participant(db, contest_id, name, referrals_count)
+ created.append(vp)
+
+ # Показываем результат
+ text = f"""
+✅ Массовка создана!
+
+📊 Результат:
+• Создано призраков: {len(created)}
+• Рефералов у каждого: {referrals_count}
+• Всего виртуальных рефералов: {len(created) * referrals_count}
+
+👻 Созданные призраки:
+"""
+ for vp in created[:10]:
+ text += f'• {vp.display_name} — {vp.referral_count} реф.\n'
+
+ if len(created) > 10:
+ text += f'... и ещё {len(created) - 10}\n'
+
+ await message.answer(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}'
+ )
+ ],
+ [types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
+ ]
+ ),
+ )
+
+
@admin_required
@error_handler
async def start_edit_virtual_participant(
@@ -1282,7 +1447,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
+ dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
+ dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
+ dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py
index b5b65c06..950e6a37 100644
--- a/app/handlers/admin/payments.py
+++ b/app/handlers/admin/payments.py
@@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str:
return 'CryptoBot'
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
+ if method == PaymentMethod.KASSA_AI:
+ return settings.get_kassa_ai_display_name()
+ if method == PaymentMethod.FREEKASSA:
+ return settings.get_freekassa_display_name()
return method.value
@@ -144,6 +148,18 @@ def _status_info(
}
return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
+ if record.method == PaymentMethod.KASSA_AI:
+ mapping = {
+ 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
+ 'created': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
+ 'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')),
+ 'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
+ 'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
+ 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')),
+ 'error': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')),
+ }
+ return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
+
return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')
@@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
if record.method == PaymentMethod.FREEKASSA:
- return status in {'pending', ''}
+ return status in {'pending', 'created', ''}
+ if record.method == PaymentMethod.KASSA_AI:
+ return status in {'pending', 'created', 'processing', ''}
return False
@@ -184,6 +202,7 @@ def _build_list_keyboard(
page: int,
total_pages: int,
language: str,
+ has_checkable: bool = False,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
@@ -204,6 +223,28 @@ def _build_list_keyboard(
]
)
+ # Кнопка "Проверить все" если есть что проверять
+ if has_checkable:
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'),
+ callback_data='admin_payments_check_all',
+ )
+ ]
+ )
+
+ # Кнопка экспорта если есть платежи
+ if records:
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'),
+ callback_data='admin_payments_export',
+ )
+ ]
+ )
+
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
@@ -485,11 +526,22 @@ async def show_payments_overview(
lines = [header, '', description]
+ # Проверяем есть ли платежи для массовой проверки
+ checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
+ has_checkable = len(checkable_records) > 0
+
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append('')
lines.append(notice)
+ if has_checkable:
+ lines.append('')
+ lines.append(
+ texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format(
+ count=len(checkable_records)
+ )
+ )
else:
empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.')
lines.append('')
@@ -500,6 +552,7 @@ async def show_payments_overview(
page=page,
total_pages=total_pages,
language=db_user.language,
+ has_checkable=has_checkable,
)
await callback.message.edit_text(
@@ -550,28 +603,42 @@ async def manual_check_payment(
db_user: User,
db: AsyncSession,
) -> None:
+ import logging
+
+ logger = logging.getLogger(__name__)
+ logger.info('manual_check_payment called: %s', callback.data)
+
parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_')
if not parsed:
+ logger.warning('Failed to parse: %s', callback.data)
await callback.answer('❌ Invalid payment reference', show_alert=True)
return
method, payment_id = parsed
+ logger.info('Checking payment: method=%s, id=%s', method, payment_id)
+
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
+ logger.warning('Payment not found: method=%s, id=%s', method, payment_id)
await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True)
return
+ logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid)
+
if not _is_checkable(record):
+ logger.info('Payment not checkable: method=%s, status=%s', method, record.status)
await callback.answer(
texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'),
show_alert=True,
)
return
+ logger.info('Running manual check...')
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
+ logger.info('Check result: updated=%s', updated is not None)
if not updated:
await callback.answer(
@@ -597,7 +664,189 @@ async def manual_check_payment(
await callback.answer(message, show_alert=True)
+@admin_required
+@error_handler
+async def check_all_payments(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+) -> None:
+ """Массовая проверка всех ожидающих платежей."""
+ import logging
+
+ logger = logging.getLogger(__name__)
+ logger.info('check_all_payments called')
+
+ texts = get_texts(db_user.language)
+
+ # Получаем все ожидающие платежи
+ records = await list_recent_pending_payments(db)
+ logger.info('Found %d total records', len(records))
+
+ checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
+ logger.info('Found %d checkable records', len(checkable_records))
+
+ if not checkable_records:
+ await callback.answer(
+ texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'),
+ show_alert=True,
+ )
+ return
+
+ await callback.answer(
+ texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)),
+ )
+
+ payment_service = PaymentService(callback.bot)
+ checked = 0
+ confirmed = 0
+ failed = 0
+
+ for record in checkable_records:
+ try:
+ logger.info('Checking %s payment id=%s', record.method.value, record.local_id)
+ updated = await run_manual_check(db, record.method, record.local_id, payment_service)
+ checked += 1
+ logger.info('Check result: is_paid=%s', updated.is_paid if updated else None)
+ if updated and updated.is_paid and not record.is_paid:
+ confirmed += 1
+ except Exception as e:
+ logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True)
+ failed += 1
+
+ logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed)
+
+ # Показываем результат
+ result_lines = [
+ texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 Результат проверки'),
+ '',
+ texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked),
+ texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed),
+ ]
+ if failed:
+ result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed))
+
+ # Перезагружаем список платежей
+ records = await list_recent_pending_payments(db)
+ total = len(records)
+ total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
+ page_records = records[:PAGE_SIZE]
+ checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
+
+ result_lines.append('')
+ result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 Top-up verification'))
+
+ if page_records:
+ result_lines.append('')
+ for idx, record in enumerate(page_records, start=1):
+ result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
+ result_lines.append('')
+
+ keyboard = _build_list_keyboard(
+ page_records,
+ page=1,
+ total_pages=total_pages,
+ language=db_user.language,
+ has_checkable=len(checkable_records) > 0,
+ )
+
+ logger.info('Updating message with results...')
+ try:
+ await callback.message.edit_text(
+ '\n'.join(result_lines),
+ parse_mode='HTML',
+ reply_markup=keyboard,
+ )
+ logger.info('Message updated successfully')
+ except Exception as e:
+ logger.error('Failed to update message: %s', e, exc_info=True)
+
+
+@admin_required
+@error_handler
+async def export_payments(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+) -> None:
+ """Экспорт данных платежей в JSON файл."""
+ import json
+
+ from aiogram.types import BufferedInputFile
+
+ texts = get_texts(db_user.language)
+
+ records = await list_recent_pending_payments(db)
+
+ if not records:
+ await callback.answer(
+ texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'),
+ show_alert=True,
+ )
+ return
+
+ # Формируем данные для экспорта
+ export_data = []
+ for record in records:
+ payment = record.payment
+ user = record.user
+
+ payment_data = {
+ 'id': record.local_id,
+ 'method': record.method.value,
+ 'method_display': _method_display(record.method),
+ 'identifier': record.identifier,
+ 'amount_kopeks': record.amount_kopeks,
+ 'amount_rubles': record.amount_kopeks / 100,
+ 'status': record.status,
+ 'is_paid': record.is_paid,
+ 'created_at': record.created_at.isoformat() if record.created_at else None,
+ 'expires_at': record.expires_at.isoformat() if record.expires_at else None,
+ 'user': {
+ 'id': user.id,
+ 'telegram_id': user.telegram_id,
+ 'username': user.username,
+ 'full_name': user.full_name,
+ },
+ }
+
+ # Добавляем специфичные поля в зависимости от метода
+ if hasattr(payment, 'order_id'):
+ payment_data['order_id'] = payment.order_id
+ if hasattr(payment, 'payment_url'):
+ payment_data['payment_url'] = payment.payment_url
+ if hasattr(payment, 'callback_payload'):
+ payment_data['callback_payload'] = payment.callback_payload
+
+ export_data.append(payment_data)
+
+ # Создаём JSON файл
+ json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str)
+ file_bytes = json_content.encode('utf-8')
+
+ # Отправляем файл
+ from datetime import datetime
+
+ filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
+
+ await callback.message.answer_document(
+ document=BufferedInputFile(file_bytes, filename=filename),
+ caption=texts.t(
+ 'ADMIN_PAYMENTS_EXPORT_CAPTION',
+ '📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}',
+ ).format(
+ count=len(export_data),
+ paid=sum(1 for r in export_data if r['is_paid']),
+ pending=sum(1 for r in export_data if not r['is_paid']),
+ ),
+ )
+
+ await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен'))
+
+
def register_handlers(dp: Dispatcher) -> None:
+ dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all')
+ dp.callback_query.register(export_payments, F.data == 'admin_payments_export')
dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_'))
dp.callback_query.register(
show_payment_details,
diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py
index 1f5ab589..0ba96478 100644
--- a/app/handlers/admin/referrals.py
+++ b/app/handlers/admin/referrals.py
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
keyboard_rows = [
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
+ [types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
]
# Кнопка заявок на вывод (если функция включена)
@@ -650,11 +651,812 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
)
+def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
+ """Возвращает начальную и конечную даты для заданного периода."""
+ now = datetime.datetime.now()
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ if period == 'today':
+ start_date = today
+ end_date = today + datetime.timedelta(days=1)
+ elif period == 'yesterday':
+ start_date = today - datetime.timedelta(days=1)
+ end_date = today
+ elif period == 'week':
+ start_date = today - datetime.timedelta(days=7)
+ end_date = today + datetime.timedelta(days=1)
+ elif period == 'month':
+ start_date = today - datetime.timedelta(days=30)
+ end_date = today + datetime.timedelta(days=1)
+ else:
+ # По умолчанию — сегодня
+ start_date = today
+ end_date = today + datetime.timedelta(days=1)
+
+ return start_date, end_date
+
+
+def _get_period_display_name(period: str) -> str:
+ """Возвращает человекочитаемое название периода."""
+ names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
+ return names.get(period, 'сегодня')
+
+
+async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str):
+ """Внутренняя функция для отображения диагностики за указанный период."""
+ try:
+ await callback.answer('Анализирую логи...')
+
+ from app.services.referral_diagnostics_service import referral_diagnostics_service
+
+ # Сохраняем период в state
+ await state.update_data(diagnostics_period=period)
+ from app.states import AdminStates
+
+ await state.set_state(AdminStates.referral_diagnostics_period)
+
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+
+ # Формируем отчёт
+ period_display = _get_period_display_name(period)
+
+ text = f"""
+🔍 Диагностика рефералов — {period_display}
+
+📊 Статистика переходов:
+• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
+• Уникальных пользователей: {report.unique_users_clicked}
+• Потерянных рефералов: {len(report.lost_referrals)}
+"""
+
+ if report.lost_referrals:
+ text += '\n❌ Потерянные рефералы:\n'
+ text += '(пришли по ссылке, но реферер не засчитался)\n\n'
+
+ for i, lost in enumerate(report.lost_referrals[:15], 1):
+ # Статус пользователя
+ if not lost.registered:
+ status = '⚠️ Не в БД'
+ elif not lost.has_referrer:
+ status = '❌ Без реферера'
+ else:
+ status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
+
+ # Имя или ID
+ user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
+ if lost.username:
+ user_name = f'@{lost.username}'
+
+ # Ожидаемый реферер
+ referrer_info = ''
+ if lost.expected_referrer_name:
+ referrer_info = f' → {lost.expected_referrer_name}'
+ elif lost.expected_referrer_id:
+ referrer_info = f' → ID{lost.expected_referrer_id}'
+
+ # Время
+ time_str = lost.click_time.strftime('%H:%M')
+
+ text += f'{i}. {user_name} — {status}\n'
+ text += f' {lost.referral_code}{referrer_info} ({time_str})\n'
+
+ if len(report.lost_referrals) > 15:
+ text += f'\n... и ещё {len(report.lost_referrals) - 15}\n'
+ else:
+ text += '\n✅ Все рефералы засчитаны!\n'
+
+ # Информация о логах
+ log_path = referral_diagnostics_service.log_path
+ log_exists = log_path.exists()
+ log_size = log_path.stat().st_size if log_exists else 0
+
+ text += f'\n📂 {log_path.name}'
+ if log_exists:
+ text += f' ({log_size / 1024:.0f} KB)'
+ text += f' | Строк: {report.lines_in_period}'
+ else:
+ text += ' (не найден!)'
+ text += ''
+
+ # Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
+ keyboard_rows = [
+ [
+ types.InlineKeyboardButton(text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'),
+ ],
+ [types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')],
+ [types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')],
+ [
+ types.InlineKeyboardButton(
+ text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest'
+ )
+ ],
+ ]
+
+ # Кнопки действий (только если есть потерянные рефералы)
+ if report.lost_referrals:
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
+ )
+
+ keyboard_rows.extend(
+ [
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
+ [types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
+ ]
+ )
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
+ await callback.answer('Ошибка при анализе логов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Показывает диагностику реферальной системы по логам."""
+ # Определяем период из callback_data или используем "today" по умолчанию
+ if ':' in callback.data:
+ period = callback.data.split(':')[1]
+ else:
+ period = 'today'
+
+ await _show_diagnostics_for_period(callback, db, state, period)
+
+
+@admin_required
+@error_handler
+async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Показывает предпросмотр исправлений потерянных рефералов."""
+ try:
+ await callback.answer('Анализирую...')
+
+ # Получаем период из state
+ state_data = await state.get_data()
+ period = state_data.get('diagnostics_period', 'today')
+
+ from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
+
+ # Проверяем, работаем ли с загруженным файлом
+ if period == 'uploaded_file':
+ # Используем сохранённый отчёт из загруженного файла (десериализуем)
+ report_data = state_data.get('uploaded_file_report')
+ if not report_data:
+ await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
+ return
+ report = DiagnosticReport.from_dict(report_data)
+ period_display = 'загруженный файл'
+ else:
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+ period_display = _get_period_display_name(period)
+
+ if not report.lost_referrals:
+ await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
+ return
+
+ # Запускаем предпросмотр исправлений
+ fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
+
+ # Формируем отчёт
+ text = f"""
+📋 Предпросмотр исправлений — {period_display}
+
+📊 Что будет сделано:
+• Исправлено рефералов: {fix_report.users_fixed}
+• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
+• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
+• Ошибок: {fix_report.errors}
+
+🔍 Детали:
+"""
+
+ # Показываем первые 10 деталей
+ for i, detail in enumerate(fix_report.details[:10], 1):
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ if detail.username:
+ user_name = f'@{detail.username}'
+
+ if detail.error:
+ text += f'{i}. {user_name} — ❌ {detail.error}\n'
+ else:
+ text += f'{i}. {user_name}\n'
+ if detail.referred_by_set:
+ text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
+ if detail.had_first_topup:
+ text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
+ if detail.bonus_to_referral_kopeks > 0:
+ text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
+ if detail.bonus_to_referrer_kopeks > 0:
+ text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
+
+ if len(fix_report.details) > 10:
+ text += f'\n... и ещё {len(fix_report.details) - 10}\n'
+
+ text += '\n⚠️ Внимание! Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
+
+ # Кнопка назад зависит от источника
+ back_button_text = '⬅️ К диагностике'
+ back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
+ [types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
+ await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Применяет исправления потерянных рефералов."""
+ try:
+ await callback.answer('Применяю исправления...')
+
+ # Получаем период из state
+ state_data = await state.get_data()
+ period = state_data.get('diagnostics_period', 'today')
+
+ from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
+
+ # Проверяем, работаем ли с загруженным файлом
+ if period == 'uploaded_file':
+ # Используем сохранённый отчёт из загруженного файла (десериализуем)
+ report_data = state_data.get('uploaded_file_report')
+ if not report_data:
+ await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
+ return
+ report = DiagnosticReport.from_dict(report_data)
+ period_display = 'загруженный файл'
+ else:
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+ period_display = _get_period_display_name(period)
+
+ if not report.lost_referrals:
+ await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
+ return
+
+ # Применяем исправления
+ fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
+
+ # Формируем отчёт
+ text = f"""
+✅ Исправления применены — {period_display}
+
+📊 Результаты:
+• Исправлено рефералов: {fix_report.users_fixed}
+• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
+• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
+• Ошибок: {fix_report.errors}
+
+🔍 Детали:
+"""
+
+ # Показываем первые 10 успешных деталей
+ success_count = 0
+ for detail in fix_report.details:
+ if not detail.error and success_count < 10:
+ success_count += 1
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ if detail.username:
+ user_name = f'@{user_name}'
+
+ text += f'{success_count}. {user_name}\n'
+ if detail.referred_by_set:
+ text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
+ if detail.bonus_to_referral_kopeks > 0:
+ text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
+ if detail.bonus_to_referrer_kopeks > 0:
+ text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
+
+ if fix_report.users_fixed > 10:
+ text += f'\n... и ещё {fix_report.users_fixed - 10} исправлений\n'
+
+ # Показываем ошибки
+ if fix_report.errors > 0:
+ text += '\n❌ Ошибки:\n'
+ error_count = 0
+ for detail in fix_report.details:
+ if detail.error and error_count < 5:
+ error_count += 1
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ text += f'• {user_name}: {detail.error}\n'
+ if fix_report.errors > 5:
+ text += f'... и ещё {fix_report.errors - 5} ошибок\n'
+
+ # Кнопки зависят от источника
+ keyboard_rows = []
+ if period != 'uploaded_file':
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
+ )
+ keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ # Очищаем сохранённый отчёт из state
+ if period == 'uploaded_file':
+ await state.update_data(uploaded_file_report=None)
+
+ except Exception as e:
+ logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
+ await callback.answer('Ошибка при применении исправлений', show_alert=True)
+
+
+# =============================================================================
+# Проверка бонусов по БД
+# =============================================================================
+
+
+@admin_required
+@error_handler
+async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Проверяет по БД — всем ли рефералам начислены бонусы."""
+ from app.services.referral_diagnostics_service import (
+ referral_diagnostics_service,
+ )
+
+ await callback.answer('🔍 Проверяю бонусы...')
+
+ try:
+ report = await referral_diagnostics_service.check_missing_bonuses(db)
+
+ # Сохраняем отчёт в state для последующего применения
+ await state.update_data(missing_bonuses_report=report.to_dict())
+
+ text = f"""
+🔍 Проверка бонусов по БД
+
+📊 Статистика:
+• Всего рефералов: {report.total_referrals_checked}
+• С пополнением ≥ минимума: {report.referrals_with_topup}
+• Без бонусов: {len(report.missing_bonuses)}
+"""
+
+ if report.missing_bonuses:
+ text += f"""
+💰 Требуется начислить:
+• Рефералам: {report.total_missing_to_referrals / 100:.0f}₽
+• Рефереерам: {report.total_missing_to_referrers / 100:.0f}₽
+• Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}₽
+
+👤 Список ({len(report.missing_bonuses)} чел.):
+"""
+ for i, mb in enumerate(report.missing_bonuses[:15], 1):
+ referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
+ referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
+ text += f'\n{i}. {referral_name}'
+ text += f'\n └ Пригласил: {referrer_name}'
+ text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽'
+ text += f'\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}₽'
+
+ if len(report.missing_bonuses) > 15:
+ text += f'\n\n... и ещё {len(report.missing_bonuses) - 15} чел.'
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+ else:
+ text += '\n✅ Все бонусы начислены!'
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
+ await callback.answer('Ошибка при проверке бонусов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Применяет начисление пропущенных бонусов."""
+ from app.services.referral_diagnostics_service import (
+ MissingBonusReport,
+ referral_diagnostics_service,
+ )
+
+ await callback.answer('💰 Начисляю бонусы...')
+
+ try:
+ # Получаем сохранённый отчёт
+ data = await state.get_data()
+ report_dict = data.get('missing_bonuses_report')
+
+ if not report_dict:
+ await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
+ return
+
+ report = MissingBonusReport.from_dict(report_dict)
+
+ if not report.missing_bonuses:
+ await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
+ return
+
+ # Применяем исправления
+ fix_report = await referral_diagnostics_service.fix_missing_bonuses(db, report.missing_bonuses, apply=True)
+
+ text = f"""
+✅ Бонусы начислены!
+
+📊 Результат:
+• Обработано: {fix_report.users_fixed} пользователей
+• Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}₽
+• Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}₽
+• Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}₽
+"""
+
+ if fix_report.errors > 0:
+ text += f'\n⚠️ Ошибок: {fix_report.errors}'
+
+ # Очищаем отчёт из state
+ await state.update_data(missing_bonuses_report=None)
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
+ await callback.answer('Ошибка при начислении бонусов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def sync_referrals_with_contest(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ """Синхронизирует всех рефералов с активными конкурсами."""
+ from app.database.crud.referral_contest import get_contests_for_events
+ from app.services.referral_contest_service import referral_contest_service
+
+ await callback.answer('🏆 Синхронизирую с конкурсами...')
+
+ try:
+ from datetime import datetime
+
+ now_utc = datetime.utcnow()
+
+ # Получаем активные конкурсы
+ paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
+ reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
+
+ all_contests = list(paid_contests) + list(reg_contests)
+
+ if not all_contests:
+ await callback.message.edit_text(
+ '❌ Нет активных конкурсов рефералов\n\n'
+ 'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Синхронизируем каждый конкурс
+ total_created = 0
+ total_updated = 0
+ total_skipped = 0
+ contest_results = []
+
+ for contest in all_contests:
+ stats = await referral_contest_service.sync_contest(db, contest.id)
+ if 'error' not in stats:
+ total_created += stats.get('created', 0)
+ total_updated += stats.get('updated', 0)
+ total_skipped += stats.get('skipped', 0)
+ contest_results.append(f'• {contest.title}: +{stats.get("created", 0)} новых')
+ else:
+ contest_results.append(f'• {contest.title}: ошибка')
+
+ text = f"""
+🏆 Синхронизация с конкурсами завершена!
+
+📊 Результат:
+• Конкурсов обработано: {len(all_contests)}
+• Новых событий добавлено: {total_created}
+• Обновлено: {total_updated}
+• Пропущено (уже есть): {total_skipped}
+
+📋 По конкурсам:
+"""
+ text += '\n'.join(contest_results)
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
+ await callback.answer('Ошибка при синхронизации', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def request_log_file_upload(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Запрашивает загрузку лог-файла для анализа."""
+ await state.set_state(AdminStates.waiting_for_log_file)
+
+ text = """
+📤 Загрузка лог-файла для анализа
+
+Отправьте файл лога (расширение .log или .txt).
+
+Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
+
+⚠️ Важно:
+• Файл должен быть текстовым (.log, .txt)
+• Максимальный размер: 50 MB
+• После анализа файл будет автоматически удалён
+
+Если ротация логов удалила старые данные — загрузите резервную копию.
+"""
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
+ """Получает и анализирует загруженный лог-файл."""
+ import tempfile
+ from pathlib import Path
+
+ if not message.document:
+ await message.answer(
+ '❌ Пожалуйста, отправьте файл документом.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Проверяем расширение файла
+ file_name = message.document.file_name or 'unknown'
+ file_ext = Path(file_name).suffix.lower()
+
+ if file_ext not in ['.log', '.txt']:
+ await message.answer(
+ f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Проверяем размер файла
+ max_size = 50 * 1024 * 1024 # 50 MB
+ if message.document.file_size > max_size:
+ await message.answer(
+ f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\nМаксимальный размер: 50 MB',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Информируем о начале загрузки
+ status_message = await message.answer(
+ f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
+ )
+
+ temp_file_path = None
+
+ try:
+ # Скачиваем файл во временную директорию
+ temp_dir = tempfile.gettempdir()
+ temp_file_path = str(Path(temp_dir) / f'ref_diagnostics_{message.from_user.id}_{file_name}')
+
+ # Скачиваем файл
+ file = await message.bot.get_file(message.document.file_id)
+ await message.bot.download_file(file.file_path, temp_file_path)
+
+ logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
+
+ # Обновляем статус
+ await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
+
+ # Анализируем файл
+ from app.services.referral_diagnostics_service import referral_diagnostics_service
+
+ report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
+
+ # Формируем отчёт
+ text = f"""
+🔍 Анализ лог-файла: {file_name}
+
+📊 Статистика переходов:
+• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
+• Уникальных пользователей: {report.unique_users_clicked}
+• Потерянных рефералов: {len(report.lost_referrals)}
+• Строк в файле: {report.lines_in_period}
+"""
+
+ if report.lost_referrals:
+ text += '\n❌ Потерянные рефералы:\n'
+ text += '(пришли по ссылке, но реферер не засчитался)\n\n'
+
+ for i, lost in enumerate(report.lost_referrals[:15], 1):
+ # Статус пользователя
+ if not lost.registered:
+ status = '⚠️ Не в БД'
+ elif not lost.has_referrer:
+ status = '❌ Без реферера'
+ else:
+ status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
+
+ # Имя или ID
+ user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
+ if lost.username:
+ user_name = f'@{lost.username}'
+
+ # Ожидаемый реферер
+ referrer_info = ''
+ if lost.expected_referrer_name:
+ referrer_info = f' → {lost.expected_referrer_name}'
+ elif lost.expected_referrer_id:
+ referrer_info = f' → ID{lost.expected_referrer_id}'
+
+ # Время
+ time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
+
+ text += f'{i}. {user_name} — {status}\n'
+ text += f' {lost.referral_code}{referrer_info} ({time_str})\n'
+
+ if len(report.lost_referrals) > 15:
+ text += f'\n... и ещё {len(report.lost_referrals) - 15}\n'
+ else:
+ text += '\n✅ Все рефералы засчитаны!\n'
+
+ # Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
+ await state.update_data(
+ diagnostics_period='uploaded_file',
+ uploaded_file_report=report.to_dict(),
+ )
+
+ # Кнопки действий
+ keyboard_rows = []
+
+ if report.lost_referrals:
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
+ )
+
+ keyboard_rows.extend(
+ [
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ [types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
+ ]
+ )
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ # Удаляем статусное сообщение
+ await status_message.delete()
+
+ # Отправляем результат
+ await message.answer(text, reply_markup=keyboard)
+
+ # Очищаем состояние
+ await state.set_state(AdminStates.referral_diagnostics_period)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
+
+ try:
+ await status_message.edit_text(
+ f'❌ Ошибка при анализе файла\n\n'
+ f'Файл: {file_name}\n'
+ f'Ошибка: {e!s}\n\n'
+ f'Проверьте, что файл является текстовым логом бота.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload'
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text='⬅️ К диагностике', callback_data='admin_referral_diagnostics'
+ )
+ ],
+ ]
+ ),
+ )
+ except:
+ await message.answer(
+ f'❌ Ошибка при анализе файла: {e!s}',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+
+ finally:
+ # Удаляем временный файл
+ if temp_file_path and Path(temp_file_path).exists():
+ try:
+ Path(temp_file_path).unlink()
+ logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
+ except Exception as e:
+ logger.error(f'Ошибка удаления временного файла: {e}')
+
+
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals')
dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top')
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:'))
dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings')
+ dp.callback_query.register(show_referral_diagnostics, F.data == 'admin_referral_diagnostics')
+ dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
+ dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
+ dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
+
+ # Загрузка лог-файла
+ dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
+ dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
+
+ # Проверка бонусов по БД
+ dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
+ dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
+ dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
# Хендлеры заявок на вывод
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 6ba6c31e..c6cdc4ba 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -65,11 +65,8 @@ class UserFilterType(Enum):
"""Типы фильтрации пользователей."""
BALANCE = 'balance'
- TRAFFIC = 'traffic'
- ACTIVITY = 'activity'
- SPENDING = 'spending'
- PURCHASES = 'purchases'
CAMPAIGN = 'campaign'
+ POTENTIAL_CUSTOMERS = 'potential_customers'
@dataclass
@@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_balance_list',
order_param='order_by_balance',
),
- UserFilterType.TRAFFIC: UserFilterConfig(
- fsm_state=AdminStates.viewing_user_from_traffic_list,
- title='👥 Список пользователей по использованному трафику',
- empty_message='📶 Пользователи с трафиком не найдены',
- pagination_prefix='admin_users_traffic_list',
- order_param='order_by_traffic',
- ),
- UserFilterType.ACTIVITY: UserFilterConfig(
- fsm_state=AdminStates.viewing_user_from_last_activity_list,
- title='👥 Пользователи по активности',
- empty_message='🕒 Пользователи с активностью не найдены',
- pagination_prefix='admin_users_activity_list',
- order_param='order_by_last_activity',
- ),
- UserFilterType.SPENDING: UserFilterConfig(
- fsm_state=AdminStates.viewing_user_from_spending_list,
- title='👥 Пользователи по сумме трат',
- empty_message='💳 Пользователи с тратами не найдены',
- pagination_prefix='admin_users_spending_list',
- order_param='order_by_total_spent',
- ),
- UserFilterType.PURCHASES: UserFilterConfig(
- fsm_state=AdminStates.viewing_user_from_purchases_list,
- title='👥 Пользователи по количеству покупок',
- empty_message='🛒 Пользователи с покупками не найдены',
- pagination_prefix='admin_users_purchases_list',
- order_param='order_by_purchase_count',
- ),
UserFilterType.CAMPAIGN: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_campaign_list,
title='👥 Пользователи по кампании регистрации',
@@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_campaign_list',
order_param='', # использует специальный метод
),
+ UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig(
+ fsm_state=AdminStates.viewing_user_from_potential_customers_list,
+ title='👥 Потенциальные клиенты',
+ empty_message='💰 Потенциальные клиенты не найдены',
+ pagination_prefix='admin_users_potential_customers_list',
+ order_param='', # использует специальный метод
+ ),
}
@@ -173,34 +149,6 @@ def _build_user_button_text(
days_left = (user.subscription.end_date - datetime.utcnow()).days
button_text += f' | 📅 {days_left}д'
- elif filter_type == UserFilterType.TRAFFIC:
- if user.subscription:
- sub = user.subscription
- used = sub.traffic_used_gb or 0.0
- if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
- limit_display = f'{sub.traffic_limit_gb}'
- else:
- limit_display = '♾️'
- traffic_display = f'{used:.1f}/{limit_display} ГБ'
- else:
- traffic_display = 'нет подписки'
- button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}'
- if user.balance_kopeks > 0:
- button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
-
- elif filter_type == UserFilterType.ACTIVITY:
- activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно'
- button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}'
-
- elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
- stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {}
- total_spent = stats.get('total_spent', 0)
- purchases = stats.get('purchase_count', 0)
- if filter_type == UserFilterType.SPENDING:
- button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}'
- else:
- button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}'
-
elif filter_type == UserFilterType.CAMPAIGN:
info = extra_data.get(user.id, {}) if extra_data else {}
campaign_name = info.get('campaign_name') or 'Без кампании'
@@ -219,18 +167,6 @@ def _build_user_button_text(
button_text = f'{status_emoji} {sub_emoji} {short_name}'
if user.balance_kopeks > 0:
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
- elif filter_type == UserFilterType.TRAFFIC:
- if user.subscription:
- sub = user.subscription
- used = sub.traffic_used_gb or 0.0
- if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
- limit_display = f'{sub.traffic_limit_gb}'
- else:
- limit_display = '♾️'
- traffic_display = f'{used:.1f}/{limit_display} ГБ'
- else:
- traffic_display = 'нет'
- button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}'
else:
button_text = f'{status_emoji} {short_name}'
@@ -280,10 +216,6 @@ async def _show_users_list_filtered(
await callback.answer()
return
- # Для spending/purchases нужны дополнительные данные
- if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
- extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users])
-
# Формируем текст заголовка
text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n'
text += 'Нажмите на пользователя для управления:'
@@ -576,38 +508,122 @@ async def show_users_ready_to_renew(
@admin_required
@error_handler
-async def show_users_list_by_traffic(
+async def show_potential_customers(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
- """Список пользователей, отсортированный по использованному трафику (убывание)."""
- await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page)
+ """Показывает пользователей без активной подписки с балансом >= месячной цены."""
+ await state.set_state(AdminStates.viewing_user_from_potential_customers_list)
+ texts = get_texts(db_user.language)
+ from app.config import PERIOD_PRICES
-@admin_required
-@error_handler
-async def show_users_list_by_last_activity(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
-):
- """Список пользователей, отсортированный по последней активности."""
- await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page)
+ monthly_price = PERIOD_PRICES.get(30, 99000)
+ user_service = UserService()
+ users_data = await user_service.get_potential_customers(
+ db,
+ min_balance_kopeks=monthly_price,
+ page=page,
+ limit=10,
+ )
-@admin_required
-@error_handler
-async def show_users_list_by_spending(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
-):
- """Список пользователей, отсортированный по сумме трат (убывание)."""
- await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page)
+ amount_text = settings.format_price(monthly_price)
+ header = texts.t(
+ 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE',
+ '💰 Потенциальные клиенты',
+ )
+ description = texts.t(
+ 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC',
+ 'Пользователи без активной подписки с балансом {amount} или больше.',
+ ).format(amount=amount_text)
+ if not users_data['users']:
+ empty_text = texts.t(
+ 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY',
+ 'Сейчас нет пользователей, которые подходят под этот фильтр.',
+ )
+ await callback.message.edit_text(
+ f'{header}\n\n{description}\n\n{empty_text}',
+ reply_markup=get_admin_users_keyboard(db_user.language),
+ )
+ await callback.answer()
+ return
-@admin_required
-@error_handler
-async def show_users_list_by_purchases(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
-):
- """Список пользователей, отсортированный по количеству покупок (убывание)."""
- await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page)
+ text = f'{header}\n\n{description}\n\n'
+ text += 'Нажмите на пользователя для управления:'
+
+ keyboard = []
+
+ for user in users_data['users']:
+ subscription = user.subscription
+ status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫'
+ subscription_emoji = '❌'
+
+ if subscription:
+ if subscription.is_trial:
+ subscription_emoji = '🎁'
+ elif subscription.is_active:
+ subscription_emoji = '💎'
+ else:
+ subscription_emoji = '⏰'
+
+ button_text = (
+ f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}'
+ )
+
+ if len(button_text) > 60:
+ short_name = user.full_name
+ if len(short_name) > 20:
+ short_name = short_name[:17] + '...'
+ button_text = (
+ f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}'
+ )
+
+ keyboard.append(
+ [
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f'admin_user_manage_{user.id}',
+ )
+ ]
+ )
+
+ if users_data['total_pages'] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data['current_page'],
+ users_data['total_pages'],
+ 'admin_users_potential_customers_list',
+ 'admin_users_potential_customers_filter',
+ db_user.language,
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend(
+ [
+ [
+ types.InlineKeyboardButton(
+ text='🔍 Поиск',
+ callback_data='admin_users_search',
+ ),
+ types.InlineKeyboardButton(
+ text='📊 Статистика',
+ callback_data='admin_users_stats',
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text='⬅️ Назад',
+ callback_data='admin_users',
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
+ )
+ await callback.answer()
@admin_required
@@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination(
await show_users_list_by_balance(callback, db_user, db, state, 1)
-@admin_required
-@error_handler
-async def handle_users_traffic_list_pagination(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
-):
- try:
- callback_parts = callback.data.split('_')
- page = int(callback_parts[-1])
- await show_users_list_by_traffic(callback, db_user, db, state, page)
- except (ValueError, IndexError) as e:
- logger.error(f'Ошибка парсинга номера страницы: {e}')
- await show_users_list_by_traffic(callback, db_user, db, state, 1)
-
-
-@admin_required
-@error_handler
-async def handle_users_activity_list_pagination(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
-):
- try:
- callback_parts = callback.data.split('_')
- page = int(callback_parts[-1])
- await show_users_list_by_last_activity(callback, db_user, db, state, page)
- except (ValueError, IndexError) as e:
- logger.error(f'Ошибка парсинга номера страницы: {e}')
- await show_users_list_by_last_activity(callback, db_user, db, state, 1)
-
-
-@admin_required
-@error_handler
-async def handle_users_spending_list_pagination(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
-):
- try:
- callback_parts = callback.data.split('_')
- page = int(callback_parts[-1])
- await show_users_list_by_spending(callback, db_user, db, state, page)
- except (ValueError, IndexError) as e:
- logger.error(f'Ошибка парсинга номера страницы: {e}')
- await show_users_list_by_spending(callback, db_user, db, state, 1)
-
-
-@admin_required
-@error_handler
-async def handle_users_purchases_list_pagination(
- callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
-):
- try:
- callback_parts = callback.data.split('_')
- page = int(callback_parts[-1])
- await show_users_list_by_purchases(callback, db_user, db, state, page)
- except (ValueError, IndexError) as e:
- logger.error(f'Ошибка парсинга номера страницы: {e}')
- await show_users_list_by_purchases(callback, db_user, db, state, 1)
-
-
@admin_required
@error_handler
async def handle_users_ready_to_renew_pagination(
@@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination(
await show_users_ready_to_renew(callback, db_user, db, state, 1)
+@admin_required
+@error_handler
+async def handle_potential_customers_pagination(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ try:
+ page = int(callback.data.split('_')[-1])
+ await show_potential_customers(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f'Ошибка парсинга номера страницы: {e}')
+ await show_potential_customers(callback, db_user, db, state, 1)
+
+
@admin_required
@error_handler
async def handle_users_campaign_list_pagination(
@@ -1310,18 +1283,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
current_state = await state.get_state()
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = 'admin_users_balance_filter'
- elif current_state == AdminStates.viewing_user_from_traffic_list:
- back_callback = 'admin_users_traffic_filter'
- elif current_state == AdminStates.viewing_user_from_last_activity_list:
- back_callback = 'admin_users_activity_filter'
- elif current_state == AdminStates.viewing_user_from_spending_list:
- back_callback = 'admin_users_spending_filter'
- elif current_state == AdminStates.viewing_user_from_purchases_list:
- back_callback = 'admin_users_purchases_filter'
elif current_state == AdminStates.viewing_user_from_campaign_list:
back_callback = 'admin_users_campaign_filter'
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
back_callback = 'admin_users_ready_to_renew_filter'
+ elif current_state == AdminStates.viewing_user_from_potential_customers_list:
+ back_callback = 'admin_users_potential_customers_filter'
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -5483,26 +5450,14 @@ def register_handlers(dp: Dispatcher):
handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_')
)
- dp.callback_query.register(
- handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_')
- )
-
- dp.callback_query.register(
- handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_')
- )
-
- dp.callback_query.register(
- handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_')
- )
-
- dp.callback_query.register(
- handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_')
- )
-
dp.callback_query.register(
handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_')
)
+ dp.callback_query.register(
+ handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_')
+ )
+
dp.callback_query.register(
handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_')
)
@@ -5659,14 +5614,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter')
- dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter')
-
- dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter')
-
- dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter')
-
- dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter')
-
dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter')
+ dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter')
+
dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter')
diff --git a/app/handlers/start.py b/app/handlers/start.py
index 3d941b77..f3189fde 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -309,24 +309,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
data = await state.get_data() or {}
- had_pending_payload = 'pending_start_payload' in data
- pending_start_payload = data.pop('pending_start_payload', None)
- had_campaign_notification_flag = 'campaign_notification_sent' in data
- campaign_notification_sent = data.pop('campaign_notification_sent', False)
- state_needs_update = had_pending_payload or had_campaign_notification_flag
+
+ # ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
+ # pending_start_payload обрабатывается отдельно ниже
+ campaign_notification_sent = data.get('campaign_notification_sent', False)
+ state_needs_update = False
+
+ # Получаем payload из state или Redis
+ pending_start_payload = data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
+ data['pending_start_payload'] = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
- # Очищаем Redis после получения
- await delete_pending_payload_from_redis(message.from_user.id)
+ # НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
referral_code = None
campaign = None
@@ -1167,6 +1170,12 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
refresh_subscription_error,
)
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
+ await delete_pending_payload_from_redis(callback.from_user.id)
+ logger.info(
+ '🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id
+ )
+
await state.clear()
if campaign_message:
@@ -1454,6 +1463,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
refresh_subscription_error,
)
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
+ await delete_pending_payload_from_redis(message.from_user.id)
+ logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
+
await state.clear()
if campaign_message:
@@ -1725,6 +1738,7 @@ async def required_sub_channel_check(
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
+ state_data['pending_start_payload'] = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
@@ -1780,16 +1794,31 @@ async def required_sub_channel_check(
only_active=True,
)
- if campaign:
- state_data['campaign_id'] = campaign.id
- logger.info(
- '📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
- campaign.id,
+ # Обрабатываем payload только если ещё не обработан
+ # (проверяем по наличию referral_code или campaign_id в state)
+ if not state_data.get('referral_code') and not state_data.get('campaign_id'):
+ campaign = await get_campaign_by_start_parameter(
+ db,
+ pending_start_payload,
+ only_active=True,
)
+
+ if campaign:
+ state_data['campaign_id'] = campaign.id
+ logger.info(
+ '📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
+ campaign.id,
+ )
+ else:
+ state_data['referral_code'] = pending_start_payload
+ logger.info(
+ '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
+ pending_start_payload,
+ )
else:
- state_data['referral_code'] = pending_start_payload
logger.info(
- '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
+ '✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
+ state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
)
await state.set_data(state_data)
@@ -1829,6 +1858,12 @@ async def required_sub_channel_check(
except Exception as e:
logger.warning(f'Не удалось удалить сообщение: {e}')
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
+ # и перед показом главного меню или завершением регистрации
+ if pending_start_payload:
+ await delete_pending_payload_from_redis(query.from_user.id)
+ logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
+
if user and user.status != UserStatus.DELETED.value:
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
@@ -1911,6 +1946,11 @@ async def required_sub_channel_check(
)
await db.refresh(user, ['subscription'])
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
+ state_data.pop('pending_start_payload', None)
+ await state.set_data(state_data)
+ logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
+
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 4a1a17d9..e0295c33 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -342,36 +342,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark
callback_data='admin_users_balance_filter',
)
],
- [
- InlineKeyboardButton(
- text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'),
- callback_data='admin_users_traffic_filter',
- )
- ],
- [
- InlineKeyboardButton(
- text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'),
- callback_data='admin_users_activity_filter',
- )
- ],
- [
- InlineKeyboardButton(
- text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'),
- callback_data='admin_users_spending_filter',
- )
- ],
- [
- InlineKeyboardButton(
- text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'),
- callback_data='admin_users_purchases_filter',
- )
- ],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'),
callback_data='admin_users_ready_to_renew_filter',
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'),
+ callback_data='admin_users_potential_customers_filter',
+ )
+ ],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'),
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index d73ca6ff..491254ef 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -807,6 +807,10 @@
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
+ "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты",
+ "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты",
+ "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.",
+ "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py
index b4b252c4..5d780c11 100644
--- a/app/middlewares/channel_checker.py
+++ b/app/middlewares/channel_checker.py
@@ -396,10 +396,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
- # НЕ реактивируем подписку заблокированных пользователей
+ # НЕ реактивируем подписку заблокированным пользователям
if user.status == UserStatus.BLOCKED.value:
logger.info(
- '🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
+ '🚫 Пропуск реактивации для заблокированного пользователя %s',
telegram_id,
)
return
diff --git a/app/services/kassa_ai_service.py b/app/services/kassa_ai_service.py
index 007c1328..b219791f 100644
--- a/app/services/kassa_ai_service.py
+++ b/app/services/kassa_ai_service.py
@@ -246,7 +246,7 @@ class KassaAiService:
}
params['signature'] = self._generate_hmac_signature(params)
- logger.debug(f'KassaAI get_order_status: order_id={order_id}')
+ logger.info(f'KassaAI get_order_status: order_id={order_id}')
try:
async with (
@@ -259,7 +259,7 @@ class KassaAiService:
) as response,
):
text = await response.text()
- logger.debug(f'KassaAI get_order_status response: {text}')
+ logger.info(f'KassaAI get_order_status response: {text}')
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f'KassaAI API connection error: {e}')
diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py
index 90380bcc..1cc3818d 100644
--- a/app/services/payment/kassa_ai.py
+++ b/app/services/payment/kassa_ai.py
@@ -69,8 +69,13 @@ class KassaAiPaymentMixin:
)
return None
- # Генерируем уникальный order_id
- order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}'
+ # Получаем telegram_id пользователя для order_id
+ payment_module = import_module('app.services.payment_service')
+ user = await payment_module.get_user_by_id(db, user_id)
+ tg_id = user.telegram_id if user else user_id
+
+ # Генерируем уникальный order_id с telegram_id для удобного поиска
+ order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.KASSA_AI_CURRENCY
@@ -487,3 +492,91 @@ class KassaAiPaymentMixin:
except Exception as e:
logger.exception('KassaAI: ошибка проверки статуса: %s', e)
return None
+
+ async def get_kassa_ai_payment_status(
+ self,
+ db: AsyncSession,
+ local_payment_id: int,
+ ) -> dict[str, Any] | None:
+ """
+ Проверяет статус платежа KassaAI по локальному ID через API.
+ Если платёж оплачен — автоматически начисляет баланс.
+ """
+ logger.info('KassaAI: checking payment status for id=%s', local_payment_id)
+ kassa_ai_crud = import_module('app.database.crud.kassa_ai')
+
+ payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id)
+ if not payment:
+ logger.warning('KassaAI payment not found: id=%s', local_payment_id)
+ return None
+
+ if payment.is_paid:
+ return {
+ 'payment': payment,
+ 'status': 'success',
+ 'is_paid': True,
+ }
+
+ if not settings.KASSA_AI_API_KEY:
+ return {
+ 'payment': payment,
+ 'status': payment.status or 'pending',
+ 'is_paid': payment.is_paid,
+ }
+
+ try:
+ # Запрашиваем статус заказа в KassaAI (api.fk.life)
+ response = await kassa_ai_service.get_order_status(payment.order_id)
+
+ # KassaAI возвращает список заказов (как Freekassa)
+ orders = response.get('orders', [])
+ target_order = None
+
+ # Ищем наш заказ в списке
+ for order in orders:
+ order_key = str(order.get('merchant_order_id') or order.get('paymentId'))
+ if order_key == str(payment.order_id):
+ target_order = order
+ break
+
+ if target_order:
+ # Статус 1 = Оплачен (как в Freekassa)
+ kai_status = int(target_order.get('status', 0))
+
+ if kai_status == 1:
+ logger.info('KassaAI payment %s confirmed via API', payment.order_id)
+
+ callback_payload = {
+ 'check_source': 'api',
+ 'kai_order_data': target_order,
+ }
+
+ # ID заказа на стороне KassaAI
+ kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
+
+ # Обновляем статус
+ payment = await kassa_ai_crud.update_kassa_ai_payment_status(
+ db=db,
+ payment=payment,
+ status='success',
+ is_paid=True,
+ kassa_ai_order_id=kai_intid,
+ payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
+ callback_payload=callback_payload,
+ )
+
+ # Финализируем (начисляем баланс)
+ await self._finalize_kassa_ai_payment(
+ db,
+ payment,
+ intid=kai_intid,
+ trigger='api_check',
+ )
+ except Exception as e:
+ logger.error('Error checking KassaAI payment status: %s', e)
+
+ return {
+ 'payment': payment,
+ 'status': payment.status or 'pending',
+ 'is_paid': payment.is_paid,
+ }
diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py
index f06f637e..87c6feb0 100644
--- a/app/services/payment_method_config_service.py
+++ b/app/services/payment_method_config_service.py
@@ -164,42 +164,78 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None:
"""Initialize payment method configs if they don't exist yet.
Called on startup to seed defaults from env vars.
+ Also adds any missing methods that were added after initial setup.
"""
- count_result = await db.execute(select(func.count()).select_from(PaymentMethodConfig))
- count = count_result.scalar() or 0
+ # Get existing method IDs
+ existing_result = await db.execute(select(PaymentMethodConfig.method_id))
+ existing_method_ids = set(existing_result.scalars().all())
- if count > 0:
- return # Already initialized
+ if not existing_method_ids:
+ # First-time initialization
+ logger.info('Initializing payment method configurations from env vars...')
+ defaults = _get_method_defaults()
- logger.info('Initializing payment method configurations from env vars...')
+ for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
+ method_def = defaults.get(method_id, {})
+ is_configured = method_def.get('is_configured', False)
+ sub_options = None
+ available = method_def.get('available_sub_options')
+ if available:
+ # Enable all sub-options by default
+ sub_options = {opt['id']: True for opt in available}
+ config = PaymentMethodConfig(
+ method_id=method_id,
+ sort_order=idx,
+ is_enabled=is_configured,
+ display_name=None,
+ sub_options=sub_options,
+ min_amount_kopeks=None,
+ max_amount_kopeks=None,
+ user_type_filter='all',
+ first_topup_filter='any',
+ promo_group_filter_mode='all',
+ )
+ db.add(config)
+
+ await db.commit()
+ logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
+ return
+
+ # Add missing methods (for cases when new methods are added to code)
defaults = _get_method_defaults()
+ missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids]
- for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
- method_def = defaults.get(method_id, {})
- is_configured = method_def.get('is_configured', False)
- sub_options = None
- available = method_def.get('available_sub_options')
- if available:
- # Enable all sub-options by default
- sub_options = {opt['id']: True for opt in available}
+ if missing_methods:
+ logger.info(f'Adding missing payment methods: {missing_methods}')
+ # Get max sort_order to append new methods at the end
+ max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order)))
+ max_order = max_order_result.scalar() or 0
- config = PaymentMethodConfig(
- method_id=method_id,
- sort_order=idx,
- is_enabled=is_configured,
- display_name=None,
- sub_options=sub_options,
- min_amount_kopeks=None,
- max_amount_kopeks=None,
- user_type_filter='all',
- first_topup_filter='any',
- promo_group_filter_mode='all',
- )
- db.add(config)
+ for idx, method_id in enumerate(missing_methods, start=max_order + 1):
+ method_def = defaults.get(method_id, {})
+ is_configured = method_def.get('is_configured', False)
+ sub_options = None
+ available = method_def.get('available_sub_options')
+ if available:
+ sub_options = {opt['id']: True for opt in available}
- await db.commit()
- logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
+ config = PaymentMethodConfig(
+ method_id=method_id,
+ sort_order=idx,
+ is_enabled=is_configured,
+ display_name=None,
+ sub_options=sub_options,
+ min_amount_kopeks=None,
+ max_amount_kopeks=None,
+ user_type_filter='all',
+ first_topup_filter='any',
+ promo_group_filter_mode='all',
+ )
+ db.add(config)
+
+ await db.commit()
+ logger.info(f'Added {len(missing_methods)} missing payment method(s).')
# ============ CRUD ============
diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py
index 0b432306..9d94fe7d 100644
--- a/app/services/payment_verification_service.py
+++ b/app/services/payment_verification_service.py
@@ -71,6 +71,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
+ PaymentMethod.KASSA_AI,
}
)
@@ -87,6 +88,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# WATA removed - API returns 429 "Use webhook – polling is rate-limited".
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
+ PaymentMethod.KASSA_AI,
}
)
@@ -955,6 +957,9 @@ async def run_manual_check(
elif method == PaymentMethod.FREEKASSA:
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
+ elif method == PaymentMethod.KASSA_AI:
+ result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
+ payment = result.get('payment') if result else None
else:
logger.warning('Manual check requested for unsupported method %s', method)
return None
diff --git a/app/services/referral_diagnostics_service.py b/app/services/referral_diagnostics_service.py
new file mode 100644
index 00000000..d7320f1f
--- /dev/null
+++ b/app/services/referral_diagnostics_service.py
@@ -0,0 +1,1063 @@
+"""
+Сервис диагностики реферальной системы по логам.
+
+Анализирует логи бота для выявления проблем с реферальной системой:
+- Переходы по реф-ссылкам
+- Сверка с БД — засчитался ли реферал
+- Выявление потерянных рефералов
+"""
+
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.referral import create_referral_earning
+from app.database.crud.user import add_user_balance
+from app.database.models import ReferralEarning, User
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ReferralClick:
+ """Информация о переходе по реф-ссылке."""
+
+ timestamp: datetime
+ telegram_id: int
+ raw_code: str # Код как в логе (может быть ref_refXXX)
+ clean_code: str # Очищенный код (refXXX)
+ log_line: str
+
+
+@dataclass
+class LostReferral:
+ """Потерянный реферал — пришёл по ссылке, но реферер не засчитался."""
+
+ telegram_id: int
+ username: Optional[str]
+ full_name: Optional[str]
+ referral_code: str # По какому коду пришёл
+ expected_referrer_code: str # Код реферера
+ expected_referrer_id: Optional[int] # ID реферера в БД
+ expected_referrer_name: Optional[str] # Имя реферера
+ click_time: datetime
+ registered: bool # Есть в БД?
+ has_referrer: bool # Есть referred_by_id?
+ current_referrer_id: Optional[int] # Текущий referred_by_id
+
+ def to_dict(self) -> dict:
+ """Сериализация в dict для хранения в Redis."""
+ return {
+ 'telegram_id': self.telegram_id,
+ 'username': self.username,
+ 'full_name': self.full_name,
+ 'referral_code': self.referral_code,
+ 'expected_referrer_code': self.expected_referrer_code,
+ 'expected_referrer_id': self.expected_referrer_id,
+ 'expected_referrer_name': self.expected_referrer_name,
+ 'click_time': self.click_time.isoformat() if self.click_time else None,
+ 'registered': self.registered,
+ 'has_referrer': self.has_referrer,
+ 'current_referrer_id': self.current_referrer_id,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'LostReferral':
+ """Десериализация из dict."""
+ click_time = data.get('click_time')
+ if click_time and isinstance(click_time, str):
+ click_time = datetime.fromisoformat(click_time)
+ return cls(
+ telegram_id=data['telegram_id'],
+ username=data.get('username'),
+ full_name=data.get('full_name'),
+ referral_code=data['referral_code'],
+ expected_referrer_code=data['expected_referrer_code'],
+ expected_referrer_id=data.get('expected_referrer_id'),
+ expected_referrer_name=data.get('expected_referrer_name'),
+ click_time=click_time,
+ registered=data.get('registered', False),
+ has_referrer=data.get('has_referrer', False),
+ current_referrer_id=data.get('current_referrer_id'),
+ )
+
+
+@dataclass
+class DiagnosticReport:
+ """Отчёт о диагностике реферальной системы."""
+
+ # Статистика
+ total_ref_clicks: int = 0 # Всего переходов по реф-ссылкам
+ unique_users_clicked: int = 0 # Уникальных пользователей
+
+ # Проблемные случаи
+ lost_referrals: list[LostReferral] = field(default_factory=list)
+
+ # Период анализа
+ analysis_period_start: Optional[datetime] = None
+ analysis_period_end: Optional[datetime] = None
+
+ # Статистика парсинга
+ total_lines_parsed: int = 0
+ lines_in_period: int = 0
+
+ def to_dict(self) -> dict:
+ """Сериализация в dict для хранения в Redis."""
+ return {
+ 'total_ref_clicks': self.total_ref_clicks,
+ 'unique_users_clicked': self.unique_users_clicked,
+ 'lost_referrals': [lr.to_dict() for lr in self.lost_referrals],
+ 'analysis_period_start': self.analysis_period_start.isoformat() if self.analysis_period_start else None,
+ 'analysis_period_end': self.analysis_period_end.isoformat() if self.analysis_period_end else None,
+ 'total_lines_parsed': self.total_lines_parsed,
+ 'lines_in_period': self.lines_in_period,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'DiagnosticReport':
+ """Десериализация из dict."""
+ start = data.get('analysis_period_start')
+ end = data.get('analysis_period_end')
+ if start and isinstance(start, str):
+ start = datetime.fromisoformat(start)
+ if end and isinstance(end, str):
+ end = datetime.fromisoformat(end)
+
+ lost_referrals = [LostReferral.from_dict(lr) for lr in data.get('lost_referrals', [])]
+
+ return cls(
+ total_ref_clicks=data.get('total_ref_clicks', 0),
+ unique_users_clicked=data.get('unique_users_clicked', 0),
+ lost_referrals=lost_referrals,
+ analysis_period_start=start,
+ analysis_period_end=end,
+ total_lines_parsed=data.get('total_lines_parsed', 0),
+ lines_in_period=data.get('lines_in_period', 0),
+ )
+
+
+@dataclass
+class FixDetail:
+ """Детали исправления одного потерянного реферала."""
+
+ telegram_id: int
+ username: Optional[str]
+ full_name: Optional[str]
+
+ # Что сделано
+ referred_by_set: bool # Установлен referred_by_id
+ referrer_id: Optional[int] # ID реферера
+ referrer_name: Optional[str] # Имя реферера
+
+ # Бонусы
+ bonus_to_referral_kopeks: int = 0 # Бонус рефералу
+ bonus_to_referrer_kopeks: int = 0 # Бонус рефереру
+
+ # Статус
+ had_first_topup: bool = False # Было первое пополнение
+ topup_amount_kopeks: int = 0 # Сумма пополнения
+
+ # Ошибки
+ error: Optional[str] = None
+
+
+@dataclass
+class FixReport:
+ """Отчёт об исправлении потерянных рефералов."""
+
+ users_fixed: int = 0 # Исправлено referred_by_id
+ bonuses_to_referrals: int = 0 # Бонусов рефералам (копейки)
+ bonuses_to_referrers: int = 0 # Бонусов рефереам (копейки)
+ details: list[FixDetail] = field(default_factory=list)
+ errors: int = 0 # Количество ошибок
+
+
+@dataclass
+class MissingBonus:
+ """Информация о ненначисленном бонусе."""
+
+ # Реферал (приглашённый)
+ referral_id: int
+ referral_telegram_id: int
+ referral_username: Optional[str]
+ referral_full_name: Optional[str]
+
+ # Реферер (пригласивший)
+ referrer_id: int
+ referrer_telegram_id: int
+ referrer_username: Optional[str]
+ referrer_full_name: Optional[str]
+
+ # Первое пополнение
+ first_topup_amount_kopeks: int
+ first_topup_date: Optional[datetime]
+
+ # Какие бонусы не начислены
+ missing_referral_bonus: bool = False # Бонус рефералу
+ missing_referrer_bonus: bool = False # Бонус рефереру
+
+ # Суммы для начисления
+ referral_bonus_amount: int = 0
+ referrer_bonus_amount: int = 0
+
+ def to_dict(self) -> dict:
+ """Сериализация для Redis."""
+ return {
+ 'referral_id': self.referral_id,
+ 'referral_telegram_id': self.referral_telegram_id,
+ 'referral_username': self.referral_username,
+ 'referral_full_name': self.referral_full_name,
+ 'referrer_id': self.referrer_id,
+ 'referrer_telegram_id': self.referrer_telegram_id,
+ 'referrer_username': self.referrer_username,
+ 'referrer_full_name': self.referrer_full_name,
+ 'first_topup_amount_kopeks': self.first_topup_amount_kopeks,
+ 'first_topup_date': self.first_topup_date.isoformat() if self.first_topup_date else None,
+ 'missing_referral_bonus': self.missing_referral_bonus,
+ 'missing_referrer_bonus': self.missing_referrer_bonus,
+ 'referral_bonus_amount': self.referral_bonus_amount,
+ 'referrer_bonus_amount': self.referrer_bonus_amount,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'MissingBonus':
+ """Десериализация из dict."""
+ topup_date = data.get('first_topup_date')
+ if topup_date and isinstance(topup_date, str):
+ topup_date = datetime.fromisoformat(topup_date)
+ return cls(
+ referral_id=data['referral_id'],
+ referral_telegram_id=data['referral_telegram_id'],
+ referral_username=data.get('referral_username'),
+ referral_full_name=data.get('referral_full_name'),
+ referrer_id=data['referrer_id'],
+ referrer_telegram_id=data['referrer_telegram_id'],
+ referrer_username=data.get('referrer_username'),
+ referrer_full_name=data.get('referrer_full_name'),
+ first_topup_amount_kopeks=data.get('first_topup_amount_kopeks', 0),
+ first_topup_date=topup_date,
+ missing_referral_bonus=data.get('missing_referral_bonus', False),
+ missing_referrer_bonus=data.get('missing_referrer_bonus', False),
+ referral_bonus_amount=data.get('referral_bonus_amount', 0),
+ referrer_bonus_amount=data.get('referrer_bonus_amount', 0),
+ )
+
+
+@dataclass
+class MissingBonusReport:
+ """Отчёт о ненначисленных бонусах."""
+
+ total_referrals_checked: int = 0 # Всего проверено рефералов
+ referrals_with_topup: int = 0 # Рефералов с первым пополнением
+ missing_bonuses: list[MissingBonus] = field(default_factory=list)
+
+ # Суммы
+ total_missing_to_referrals: int = 0 # Всего не начислено рефералам
+ total_missing_to_referrers: int = 0 # Всего не начислено рефереерам
+
+ def to_dict(self) -> dict:
+ """Сериализация для Redis."""
+ return {
+ 'total_referrals_checked': self.total_referrals_checked,
+ 'referrals_with_topup': self.referrals_with_topup,
+ 'missing_bonuses': [mb.to_dict() for mb in self.missing_bonuses],
+ 'total_missing_to_referrals': self.total_missing_to_referrals,
+ 'total_missing_to_referrers': self.total_missing_to_referrers,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'MissingBonusReport':
+ """Десериализация из dict."""
+ missing_bonuses = [MissingBonus.from_dict(mb) for mb in data.get('missing_bonuses', [])]
+ return cls(
+ total_referrals_checked=data.get('total_referrals_checked', 0),
+ referrals_with_topup=data.get('referrals_with_topup', 0),
+ missing_bonuses=missing_bonuses,
+ total_missing_to_referrals=data.get('total_missing_to_referrals', 0),
+ total_missing_to_referrers=data.get('total_missing_to_referrers', 0),
+ )
+
+
+class ReferralDiagnosticsService:
+ """Сервис диагностики реферальной системы."""
+
+ # Возможные пути к логам (приоритет: current > стандартный)
+ LOG_PATHS = [
+ 'logs/current/bot.log',
+ '/app/logs/current/bot.log',
+ 'logs/bot.log',
+ '/app/logs/bot.log',
+ ]
+
+ def __init__(self, log_path: str | None = None):
+ if log_path:
+ self.log_path = Path(log_path)
+ else:
+ self.log_path = self._find_log_file()
+
+ def _find_log_file(self) -> Path:
+ """Ищет существующий лог-файл, предпочитая свежие."""
+ today = datetime.now().date()
+ candidates = []
+
+ for path_str in self.LOG_PATHS:
+ path = Path(path_str)
+ if path.exists() and path.stat().st_size > 0:
+ mtime = datetime.fromtimestamp(path.stat().st_mtime).date()
+ is_fresh = mtime >= today - timedelta(days=1)
+ candidates.append((path, is_fresh, path.stat().st_mtime))
+ logger.info(f'📁 Найден лог: {path} (свежий: {is_fresh})')
+
+ candidates.sort(key=lambda x: (not x[1], -x[2]))
+
+ if candidates:
+ selected = candidates[0][0]
+ logger.info(f'✅ Выбран лог-файл: {selected}')
+ return selected
+
+ return Path('logs/current/bot.log')
+
+ @staticmethod
+ def clean_referral_code(raw_code: str) -> str:
+ """
+ Очищает реферальный код от лишних префиксов.
+
+ ref_refXXX -> refXXX (miniapp добавляет ref_)
+ refXXX -> refXXX (без изменений)
+ """
+ if raw_code.startswith('ref_ref'):
+ return raw_code[4:] # Убираем "ref_"
+ return raw_code
+
+ async def analyze_today(self, db: AsyncSession) -> DiagnosticReport:
+ """Анализирует реферальные события за сегодня."""
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+ return await self.analyze_period(db, today, tomorrow)
+
+ async def analyze_period(self, db: AsyncSession, start_date: datetime, end_date: datetime) -> DiagnosticReport:
+ """Анализирует реферальные события за указанный период."""
+
+ # 1. Парсим логи — находим все переходы по реф-ссылкам
+ clicks, total_lines, lines_in_period = await self._parse_clicks(start_date, end_date)
+
+ # 2. Группируем по telegram_id (берём последний клик)
+ user_clicks: dict[int, ReferralClick] = {}
+ for click in clicks:
+ user_clicks[click.telegram_id] = click
+
+ # 3. Сверяем с БД — находим потерянных рефералов
+ lost_referrals = await self._find_lost_referrals(db, list(user_clicks.values()))
+
+ return DiagnosticReport(
+ total_ref_clicks=len(clicks),
+ unique_users_clicked=len(user_clicks),
+ lost_referrals=lost_referrals,
+ analysis_period_start=start_date,
+ analysis_period_end=end_date,
+ total_lines_parsed=total_lines,
+ lines_in_period=lines_in_period,
+ )
+
+ async def analyze_file(self, db: AsyncSession, file_path: str) -> DiagnosticReport:
+ """
+ Анализирует загруженный лог-файл на наличие потерянных рефералов.
+
+ Args:
+ db: Database session
+ file_path: Путь к загруженному файлу
+
+ Returns:
+ DiagnosticReport с результатами анализа всего файла
+ """
+ logger.info(f'📂 Начинаю анализ файла: {file_path}')
+
+ # Парсим весь файл без фильтра по дате
+ # Используем широкий диапазон дат (все время)
+ start_date = datetime(2000, 1, 1)
+ end_date = datetime(2100, 1, 1)
+
+ # Временно меняем путь к логу
+ original_log_path = self.log_path
+ self.log_path = Path(file_path)
+
+ try:
+ # skip_date_filter=True — парсим ВСЕ строки без фильтра по дате
+ clicks, total_lines, lines_in_period = await self._parse_clicks(start_date, end_date, skip_date_filter=True)
+
+ # Группируем по telegram_id (берём последний клик)
+ user_clicks: dict[int, ReferralClick] = {}
+ for click in clicks:
+ user_clicks[click.telegram_id] = click
+
+ # Сверяем с БД — находим потерянных рефералов
+ lost_referrals = await self._find_lost_referrals(db, list(user_clicks.values()))
+
+ logger.info(
+ f'✅ Анализ файла завершён: строк={total_lines}, '
+ f'реф-кликов={len(clicks)}, потерянных={len(lost_referrals)}'
+ )
+
+ return DiagnosticReport(
+ total_ref_clicks=len(clicks),
+ unique_users_clicked=len(user_clicks),
+ lost_referrals=lost_referrals,
+ analysis_period_start=None,
+ analysis_period_end=None,
+ total_lines_parsed=total_lines,
+ lines_in_period=lines_in_period,
+ )
+ finally:
+ # Восстанавливаем оригинальный путь
+ self.log_path = original_log_path
+
+ async def _parse_clicks(
+ self, start_date: datetime, end_date: datetime, skip_date_filter: bool = False
+ ) -> tuple[list[ReferralClick], int, int]:
+ """Парсит логи и находит все переходы по реф-ссылкам."""
+
+ clicks = []
+ total_lines = 0
+ lines_in_period = 0
+
+ if not self.log_path.exists():
+ logger.warning(f'❌ Лог-файл не найден: {self.log_path}')
+ return clicks, 0, 0
+
+ file_size = self.log_path.stat().st_size
+ logger.info(f'📂 Читаю лог-файл: {self.log_path} ({file_size / 1024 / 1024:.2f} MB)')
+
+ # Паттерн timestamp
+ timestamp_pattern = re.compile(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ - .+ - .+ - (.+)$')
+
+ # Паттерны для поиска реф-кликов
+ # /start refXXX или /start ref_refXXX
+ start_pattern = re.compile(r'📩 Сообщение от ID:(\d+).*?/start\s+(ref[\w_]+)')
+ # Сохранение payload
+ payload_pattern = re.compile(r"💾 Сохранен start payload '(ref[\w_]+)' для пользователя\s*(\d+)")
+
+ # Для быстрой фильтрации по дате (только если не пропускаем фильтр)
+ use_date_prefix = not skip_date_filter and (end_date - start_date).days <= 31
+ date_prefix = start_date.strftime('%Y-%m-%d') if use_date_prefix else None
+
+ try:
+ with open(self.log_path, encoding='utf-8', errors='ignore') as f:
+ for line in f:
+ total_lines += 1
+ line = line.strip()
+ if not line:
+ continue
+
+ # Убираем Docker-префикс
+ if ' | ' in line[:50]:
+ line = line.split(' | ', 1)[-1]
+
+ # Быстрая проверка по дате (только для коротких периодов)
+ if date_prefix and date_prefix not in line[:10]:
+ continue
+
+ # Парсим timestamp
+ match = timestamp_pattern.match(line)
+ if not match:
+ continue
+
+ timestamp_str, message = match.groups()
+ try:
+ timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
+ except ValueError:
+ continue
+
+ if not (start_date <= timestamp < end_date):
+ continue
+
+ lines_in_period += 1
+
+ # Ищем реф-клики
+ for pattern in [start_pattern, payload_pattern]:
+ event_match = pattern.search(message)
+ if event_match:
+ if pattern == start_pattern:
+ telegram_id = int(event_match.group(1))
+ raw_code = event_match.group(2)
+ else:
+ raw_code = event_match.group(1)
+ telegram_id = int(event_match.group(2))
+
+ clean_code = self.clean_referral_code(raw_code)
+
+ clicks.append(
+ ReferralClick(
+ timestamp=timestamp,
+ telegram_id=telegram_id,
+ raw_code=raw_code,
+ clean_code=clean_code,
+ log_line=line,
+ )
+ )
+ break
+
+ except Exception as e:
+ logger.error(f'Ошибка парсинга логов: {e}', exc_info=True)
+
+ logger.info(f'📊 Парсинг: строк={total_lines}, за период={lines_in_period}, реф-кликов={len(clicks)}')
+ return clicks, total_lines, lines_in_period
+
+ async def _find_lost_referrals(self, db: AsyncSession, clicks: list[ReferralClick]) -> list[LostReferral]:
+ """Находит потерянных рефералов — пришли по ссылке, но реферер не засчитался."""
+
+ if not clicks:
+ return []
+
+ lost = []
+ telegram_ids = [c.telegram_id for c in clicks]
+
+ # Получаем пользователей из БД
+ result = await db.execute(select(User).where(User.telegram_id.in_(telegram_ids)))
+ users_map = {u.telegram_id: u for u in result.scalars().all()}
+
+ # Получаем всех рефереров по кодам
+ codes = list({c.clean_code for c in clicks})
+ referrers_result = await db.execute(select(User).where(User.referral_code.in_(codes)))
+ referrers_map = {u.referral_code: u for u in referrers_result.scalars().all()}
+
+ for click in clicks:
+ user = users_map.get(click.telegram_id)
+ referrer = referrers_map.get(click.clean_code)
+
+ # Проверяем — засчитался ли реферал?
+ is_lost = False
+
+ if user is None:
+ # Пользователь не в БД — не завершил регистрацию
+ is_lost = True
+ elif user.created_at and user.created_at < click.timestamp:
+ # Пользователь был создан ДО клика по реф-ссылке
+ # Это старый пользователь, который просто зашёл по чужой ссылке
+ is_lost = False
+ logger.debug(f'⏭️ Пропускаем {click.telegram_id}: создан {user.created_at} < клик {click.timestamp}')
+ elif user.referred_by_id is None:
+ # Пользователь в БД, но без реферера (и создан после клика)
+ is_lost = True
+ elif referrer and user.referred_by_id != referrer.id:
+ # Реферер другой (странный случай)
+ is_lost = True
+
+ if is_lost:
+ lost.append(
+ LostReferral(
+ telegram_id=click.telegram_id,
+ username=user.username if user else None,
+ full_name=user.full_name if user else None,
+ referral_code=click.clean_code,
+ expected_referrer_code=click.clean_code,
+ expected_referrer_id=referrer.id if referrer else None,
+ expected_referrer_name=referrer.full_name if referrer else None,
+ click_time=click.timestamp,
+ registered=user is not None,
+ has_referrer=user.referred_by_id is not None if user else False,
+ current_referrer_id=user.referred_by_id if user else None,
+ )
+ )
+
+ logger.info(f'🔍 Найдено потерянных рефералов: {len(lost)}')
+ return lost
+
+ async def _add_to_active_contests(
+ self,
+ db: AsyncSession,
+ referral: User,
+ referrer: User,
+ amount_kopeks: int,
+ ) -> None:
+ """
+ Добавляет восстановленного реферала в активные конкурсы.
+
+ Проверяет все активные конкурсы и добавляет событие если:
+ - Реферал зарегистрирован в период конкурса
+ - Событие ещё не было добавлено
+ """
+ from datetime import datetime
+
+ from app.database.crud.referral_contest import add_contest_event, get_contests_for_events
+
+ if not settings.is_contests_enabled():
+ return
+
+ now_utc = datetime.utcnow()
+
+ # Проверяем конкурсы по оплаченным рефералам
+ contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
+
+ for contest in contests:
+ try:
+ # Проверяем что реферал зарегистрировался В ПЕРИОД конкурса
+ user_created_at = (
+ referral.created_at
+ if referral.created_at.tzinfo is None
+ else referral.created_at.replace(tzinfo=None)
+ )
+ contest_start = (
+ contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
+ )
+ contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
+
+ if user_created_at < contest_start or user_created_at > contest_end:
+ logger.debug(f'Реферал {referral.id} зарегистрирован вне периода конкурса {contest.id}')
+ continue
+
+ event = await add_contest_event(
+ db,
+ contest_id=contest.id,
+ referrer_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=amount_kopeks,
+ event_type='restored_referral',
+ )
+ if event:
+ logger.info(
+ f'🏆 Восстановленный реферал добавлен в конкурс {contest.id}: '
+ f'реферер {referrer.id}, реферал {referral.id}'
+ )
+ except Exception as exc:
+ logger.error(f'Не удалось добавить в конкурс {contest.id}: {exc}')
+
+ # Также проверяем конкурсы по регистрации (если есть)
+ reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
+
+ for contest in reg_contests:
+ try:
+ user_created_at = (
+ referral.created_at
+ if referral.created_at.tzinfo is None
+ else referral.created_at.replace(tzinfo=None)
+ )
+ contest_start = (
+ contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
+ )
+ contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
+
+ if user_created_at < contest_start or user_created_at > contest_end:
+ continue
+
+ event = await add_contest_event(
+ db,
+ contest_id=contest.id,
+ referrer_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=0,
+ event_type='restored_referral_registration',
+ )
+ if event:
+ logger.info(f'🏆 Восстановленный реферал (регистрация) добавлен в конкурс {contest.id}')
+ except Exception as exc:
+ logger.error(f'Не удалось добавить в конкурс регистрации {contest.id}: {exc}')
+
+ async def fix_lost_referrals(
+ self, db: AsyncSession, lost_referrals: list[LostReferral], apply: bool = False
+ ) -> FixReport:
+ """
+ Исправляет потерянных рефералов.
+
+ Args:
+ db: Database session
+ lost_referrals: Список потерянных рефералов
+ apply: Если False — только предпросмотр, если True — применить изменения
+
+ Returns:
+ FixReport с деталями исправлений
+ """
+ report = FixReport()
+
+ if not lost_referrals:
+ logger.info('🔍 Нет потерянных рефералов для исправления')
+ return report
+
+ # Получаем всех пользователей и рефереров
+ telegram_ids = [lr.telegram_id for lr in lost_referrals]
+ result = await db.execute(select(User).where(User.telegram_id.in_(telegram_ids)))
+ users_map = {u.telegram_id: u for u in result.scalars().all()}
+
+ referrer_ids = list({lr.expected_referrer_id for lr in lost_referrals if lr.expected_referrer_id})
+ referrers_result = await db.execute(select(User).where(User.id.in_(referrer_ids)))
+ referrers_map = {u.id: u for u in referrers_result.scalars().all()}
+
+ for lost in lost_referrals:
+ detail = FixDetail(
+ telegram_id=lost.telegram_id,
+ username=lost.username,
+ full_name=lost.full_name,
+ referred_by_set=False,
+ referrer_id=lost.expected_referrer_id,
+ referrer_name=lost.expected_referrer_name,
+ )
+
+ try:
+ user = users_map.get(lost.telegram_id)
+ if not user:
+ detail.error = 'Пользователь не найден в БД'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ referrer = referrers_map.get(lost.expected_referrer_id) if lost.expected_referrer_id else None
+ if not referrer:
+ detail.error = 'Реферер не найден'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ # 1. Устанавливаем referred_by_id
+ if user.referred_by_id != referrer.id:
+ if apply:
+ user.referred_by_id = referrer.id
+ logger.info(f'✅ Установлен referred_by_id={referrer.id} для пользователя {user.telegram_id}')
+ detail.referred_by_set = True
+ report.users_fixed += 1
+
+ # 2. Проверяем первое пополнение
+ # Ищем первое пополнение пользователя
+ from app.database.models import Transaction, TransactionType
+
+ first_topup_result = await db.execute(
+ select(Transaction)
+ .where(Transaction.user_id == user.id, Transaction.type == TransactionType.DEPOSIT.value)
+ .order_by(Transaction.created_at.asc())
+ .limit(1)
+ )
+ first_topup = first_topup_result.scalar_one_or_none()
+
+ if first_topup and first_topup.amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
+ detail.had_first_topup = True
+ detail.topup_amount_kopeks = first_topup.amount_kopeks
+
+ # Проверяем, не начисляли ли уже бонусы
+ existing_bonus_result = await db.execute(
+ select(ReferralEarning)
+ .where(
+ ReferralEarning.user_id == referrer.id,
+ ReferralEarning.referral_id == user.id,
+ ReferralEarning.reason == 'referral_first_topup',
+ )
+ .limit(1)
+ )
+ existing_bonus = existing_bonus_result.scalar_one_or_none()
+
+ if not existing_bonus:
+ # 3. Начисляем бонус рефералу (приглашённому)
+ # Не проверяем has_made_first_topup — это восстановление потерянного реферала,
+ # он мог пополнить баланс, но бонус не получил т.к. не было referred_by_id
+ if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
+ detail.bonus_to_referral_kopeks = settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
+ report.bonuses_to_referrals += settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
+
+ if apply:
+ await add_user_balance(
+ db,
+ user,
+ settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
+ 'Восстановленный бонус за первое пополнение (потерянный реферал)',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+ user.has_made_first_topup = True
+ logger.info(
+ f'💰 Начислен бонус рефералу {user.telegram_id}: '
+ f'{settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100}₽'
+ )
+
+ # 4. Начисляем бонус рефереру
+ from app.utils.user_utils import get_effective_referral_commission_percent
+
+ commission_percent = get_effective_referral_commission_percent(referrer)
+ commission_amount = int(first_topup.amount_kopeks * commission_percent / 100)
+ inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
+
+ if inviter_bonus > 0:
+ detail.bonus_to_referrer_kopeks = inviter_bonus
+ report.bonuses_to_referrers += inviter_bonus
+
+ if apply:
+ await add_user_balance(
+ db,
+ referrer,
+ inviter_bonus,
+ f'Восстановленный бонус за реферала {user.full_name or user.username or user.telegram_id}',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+
+ # Создаём запись ReferralEarning
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=user.id,
+ amount_kopeks=inviter_bonus,
+ reason='referral_first_topup',
+ )
+
+ logger.info(
+ f'💰 Начислен бонус рефереру {referrer.telegram_id or referrer.id}: '
+ f'{inviter_bonus / 100}₽'
+ )
+
+ # Добавляем в активные конкурсы рефералов
+ await self._add_to_active_contests(db, user, referrer, first_topup.amount_kopeks)
+ else:
+ detail.error = 'Бонусы уже начислены ранее'
+
+ report.details.append(detail)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка исправления реферала {lost.telegram_id}: {e}', exc_info=True)
+ detail.error = str(e)
+ report.errors += 1
+ report.details.append(detail)
+
+ if apply:
+ await db.commit()
+ logger.info(
+ f'✅ Исправлено рефералов: {report.users_fixed}, '
+ f'начислено бонусов: {report.bonuses_to_referrals / 100}₽ + {report.bonuses_to_referrers / 100}₽'
+ )
+ else:
+ logger.info(f'📋 Предпросмотр: {report.users_fixed} рефералов будут исправлены')
+
+ return report
+
+ async def check_missing_bonuses(self, db: AsyncSession) -> MissingBonusReport:
+ """
+ Проверяет по БД: всем ли рефералам и рефереерам начислены бонусы.
+
+ Находит пользователей которые:
+ 1. Имеют referred_by_id (пришли по реф-ссылке)
+ 2. Сделали первое пополнение >= минимума
+ 3. Но бонусы не были начислены (нет ReferralEarning)
+
+ Returns:
+ MissingBonusReport со списком ненначисленных бонусов
+ """
+ from app.database.models import Transaction, TransactionType
+ from app.utils.user_utils import get_effective_referral_commission_percent
+
+ report = MissingBonusReport()
+
+ # 1. Находим всех рефералов (у кого есть referred_by_id)
+ referrals_result = await db.execute(select(User).where(User.referred_by_id.isnot(None)))
+ referrals = referrals_result.scalars().all()
+ report.total_referrals_checked = len(referrals)
+
+ if not referrals:
+ logger.info('📊 Нет рефералов для проверки')
+ return report
+
+ # 2. Собираем ID рефереров
+ referrer_ids = list({r.referred_by_id for r in referrals})
+ referrers_result = await db.execute(select(User).where(User.id.in_(referrer_ids)))
+ referrers_map = {u.id: u for u in referrers_result.scalars().all()}
+
+ # 3. Получаем все ReferralEarning для проверки
+ referral_ids = [r.id for r in referrals]
+ earnings_result = await db.execute(
+ select(ReferralEarning).where(
+ ReferralEarning.referral_id.in_(referral_ids),
+ ReferralEarning.reason == 'referral_first_topup',
+ )
+ )
+ # Множество пар (referrer_id, referral_id) где бонус уже начислен
+ existing_earnings = {(e.user_id, e.referral_id) for e in earnings_result.scalars().all()}
+
+ # 4. Проверяем каждого реферала
+ for referral in referrals:
+ referrer = referrers_map.get(referral.referred_by_id)
+ if not referrer:
+ continue
+
+ # Ищем первое пополнение
+ first_topup_result = await db.execute(
+ select(Transaction)
+ .where(
+ Transaction.user_id == referral.id,
+ Transaction.type == TransactionType.DEPOSIT.value,
+ )
+ .order_by(Transaction.created_at.asc())
+ .limit(1)
+ )
+ first_topup = first_topup_result.scalar_one_or_none()
+
+ # Если нет пополнения или меньше минимума — пропускаем
+ if not first_topup or first_topup.amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
+ continue
+
+ report.referrals_with_topup += 1
+
+ # Проверяем начислен ли бонус
+ bonus_exists = (referrer.id, referral.id) in existing_earnings
+
+ if bonus_exists:
+ # Бонусы уже начислены
+ continue
+
+ # Бонусы НЕ начислены — добавляем в отчёт
+ commission_percent = get_effective_referral_commission_percent(referrer)
+ commission_amount = int(first_topup.amount_kopeks * commission_percent / 100)
+ referrer_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
+
+ missing = MissingBonus(
+ referral_id=referral.id,
+ referral_telegram_id=referral.telegram_id,
+ referral_username=referral.username,
+ referral_full_name=referral.full_name,
+ referrer_id=referrer.id,
+ referrer_telegram_id=referrer.telegram_id,
+ referrer_username=referrer.username,
+ referrer_full_name=referrer.full_name,
+ first_topup_amount_kopeks=first_topup.amount_kopeks,
+ first_topup_date=first_topup.created_at,
+ missing_referral_bonus=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0,
+ missing_referrer_bonus=referrer_bonus > 0,
+ referral_bonus_amount=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
+ referrer_bonus_amount=referrer_bonus,
+ )
+
+ report.missing_bonuses.append(missing)
+ report.total_missing_to_referrals += missing.referral_bonus_amount
+ report.total_missing_to_referrers += missing.referrer_bonus_amount
+
+ logger.info(
+ f'📊 Проверка бонусов: {report.total_referrals_checked} рефералов, '
+ f'{report.referrals_with_topup} с пополнением, '
+ f'{len(report.missing_bonuses)} без бонусов'
+ )
+
+ return report
+
+ async def fix_missing_bonuses(
+ self, db: AsyncSession, missing_bonuses: list[MissingBonus], apply: bool = False
+ ) -> FixReport:
+ """
+ Начисляет пропущенные бонусы.
+
+ Args:
+ db: Database session
+ missing_bonuses: Список пропущенных бонусов
+ apply: Если False — только предпросмотр
+
+ Returns:
+ FixReport с деталями
+ """
+ from app.database.models import TransactionType
+
+ report = FixReport()
+
+ if not missing_bonuses:
+ return report
+
+ # Загружаем пользователей
+ referral_ids = [mb.referral_id for mb in missing_bonuses]
+ referrer_ids = [mb.referrer_id for mb in missing_bonuses]
+
+ users_result = await db.execute(select(User).where(User.id.in_(referral_ids + referrer_ids)))
+ users_map = {u.id: u for u in users_result.scalars().all()}
+
+ for missing in missing_bonuses:
+ referral = users_map.get(missing.referral_id)
+ referrer = users_map.get(missing.referrer_id)
+
+ detail = FixDetail(
+ telegram_id=missing.referral_telegram_id,
+ username=missing.referral_username,
+ full_name=missing.referral_full_name,
+ referred_by_set=False, # referred_by уже установлен
+ referrer_id=missing.referrer_id,
+ referrer_name=missing.referrer_full_name,
+ had_first_topup=True,
+ topup_amount_kopeks=missing.first_topup_amount_kopeks,
+ )
+
+ if not referral or not referrer:
+ detail.error = 'Пользователь не найден'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ try:
+ # Начисляем бонус рефералу
+ if missing.missing_referral_bonus and missing.referral_bonus_amount > 0:
+ detail.bonus_to_referral_kopeks = missing.referral_bonus_amount
+ report.bonuses_to_referrals += missing.referral_bonus_amount
+
+ if apply:
+ from app.database.models import TransactionType
+
+ await add_user_balance(
+ db,
+ referral,
+ missing.referral_bonus_amount,
+ 'Восстановленный бонус за первое пополнение',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+ referral.has_made_first_topup = True
+ logger.info(
+ f'💰 Начислен бонус рефералу {referral.telegram_id}: {missing.referral_bonus_amount / 100}₽'
+ )
+
+ # Начисляем бонус рефереру
+ if missing.missing_referrer_bonus and missing.referrer_bonus_amount > 0:
+ detail.bonus_to_referrer_kopeks = missing.referrer_bonus_amount
+ report.bonuses_to_referrers += missing.referrer_bonus_amount
+
+ if apply:
+ await add_user_balance(
+ db,
+ referrer,
+ missing.referrer_bonus_amount,
+ f'Восстановленный бонус за реферала {referral.full_name or referral.username or referral.telegram_id}',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+
+ # Создаём ReferralEarning чтобы не начислять повторно
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=missing.referrer_bonus_amount,
+ reason='referral_first_topup',
+ )
+ logger.info(
+ f'💰 Начислен бонус рефереру {referrer.telegram_id}: {missing.referrer_bonus_amount / 100}₽'
+ )
+
+ # Добавляем в активные конкурсы рефералов
+ await self._add_to_active_contests(db, referral, referrer, missing.first_topup_amount_kopeks)
+
+ report.users_fixed += 1
+ report.details.append(detail)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка начисления бонуса: {e}', exc_info=True)
+ detail.error = str(e)
+ report.errors += 1
+ report.details.append(detail)
+
+ if apply:
+ await db.commit()
+ logger.info(
+ f'✅ Начислено бонусов: {report.bonuses_to_referrals / 100}₽ рефералам + '
+ f'{report.bonuses_to_referrers / 100}₽ рефереерам'
+ )
+
+ return report
+
+
+# Глобальный экземпляр сервиса
+referral_diagnostics_service = ReferralDiagnosticsService()
diff --git a/app/services/user_service.py b/app/services/user_service.py
index bce7ac74..db64ff95 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from typing import Any
from aiogram import Bot, types
-from sqlalchemy import delete, func, select, update
+from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -345,6 +345,72 @@ class UserService:
'total_count': 0,
}
+ async def get_potential_customers(
+ self,
+ db: AsyncSession,
+ min_balance_kopeks: int,
+ page: int = 1,
+ limit: int = 10,
+ ) -> dict[str, Any]:
+ """Возвращает пользователей без активной подписки с достаточным балансом."""
+ try:
+ offset = (page - 1) * limit
+
+ # Фильтры: нет активной подписки И баланс >= порога
+ base_filters = [
+ User.balance_kopeks >= min_balance_kopeks,
+ ]
+
+ # Основной запрос с LEFT JOIN для поддержки пользователей без подписки
+ query = (
+ select(User)
+ .options(selectinload(User.subscription))
+ .outerjoin(Subscription, Subscription.user_id == User.id)
+ .where(
+ *base_filters,
+ or_(
+ User.subscription == None,
+ ~Subscription.status.in_(['active', 'trial']),
+ ),
+ )
+ .order_by(User.balance_kopeks.desc(), User.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await db.execute(query)
+ users = result.scalars().unique().all()
+
+ # Запрос для подсчета общего количества
+ count_query = (
+ select(func.count(User.id))
+ .outerjoin(Subscription, Subscription.user_id == User.id)
+ .where(
+ *base_filters,
+ or_(
+ User.subscription == None,
+ ~Subscription.status.in_(['active', 'trial']),
+ ),
+ )
+ )
+ total_count = (await db.execute(count_query)).scalar() or 0
+ total_pages = (total_count + limit - 1) // limit if total_count else 0
+
+ return {
+ 'users': users,
+ 'current_page': page,
+ 'total_pages': total_pages,
+ 'total_count': total_count,
+ }
+
+ except Exception as e:
+ logger.error(f'Ошибка получения потенциальных клиентов: {e}')
+ return {
+ 'users': [],
+ 'current_page': 1,
+ 'total_pages': 1,
+ 'total_count': 0,
+ }
+
async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]:
try:
return await get_users_spending_stats(db, user_ids)
diff --git a/app/states.py b/app/states.py
index 351880d0..34794bd3 100644
--- a/app/states.py
+++ b/app/states.py
@@ -118,6 +118,9 @@ class AdminStates(StatesGroup):
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
+ # Массовое создание виртуальных участников (массовка)
+ adding_mass_virtual_count = State() # Сколько призраков создать
+ adding_mass_virtual_referrals = State() # По сколько рефералов у каждого
editing_daily_contest_field = State()
editing_daily_contest_value = State()
@@ -132,6 +135,10 @@ class AdminStates(StatesGroup):
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
+ # Диагностика рефералов
+ referral_diagnostics_period = State()
+ waiting_for_log_file = State()
+
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
@@ -173,12 +180,9 @@ class AdminStates(StatesGroup):
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
- viewing_user_from_traffic_list = State()
- viewing_user_from_last_activity_list = State()
- viewing_user_from_spending_list = State()
- viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
+ viewing_user_from_potential_customers_list = State()
# Состояния для управления тарифами
creating_tariff_name = State()
diff --git a/tests/services/test_referral_diagnostics.py b/tests/services/test_referral_diagnostics.py
new file mode 100644
index 00000000..1298d67c
--- /dev/null
+++ b/tests/services/test_referral_diagnostics.py
@@ -0,0 +1,153 @@
+"""
+Тесты для сервиса диагностики реферальной системы.
+"""
+
+import tempfile
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytest
+
+from app.services.referral_diagnostics_service import ReferralDiagnosticsService
+
+
+@pytest.fixture
+def temp_log_file():
+ """Создаёт временный лог-файл для тестов."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
+ yield Path(f.name)
+ # Cleanup
+ Path(f.name).unlink(missing_ok=True)
+
+
+@pytest.fixture
+def sample_log_content():
+ """Пример содержимого лог-файла с реферальными событиями."""
+ today = datetime.now().strftime('%Y-%m-%d')
+ return f"""
+{today} 10:00:00,123 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+{today} 10:00:05,456 - app.handlers.start - INFO - ✅ Реферальный код ABC123 применен для пользователя 123456789
+{today} 10:00:10,789 - app.services.referral_service - INFO - ✅ Реферальная регистрация обработана для 123456789
+{today} 10:00:15,012 - app.services.referral_service - INFO - 💰 Реферал 123456789 получил бонус
+
+{today} 11:00:00,345 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+{today} 11:00:05,678 - app.handlers.start - INFO - ✅ Реферальный код XYZ999 применен для пользователя 987654321
+
+{today} 12:00:00,901 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+
+{today} 13:00:00,234 - unrelated module - INFO - Some other log message
+"""
+
+
+@pytest.mark.asyncio
+async def test_parse_logs_basic(temp_log_file, sample_log_content):
+ """Тест базового парсинга логов."""
+ # Записываем тестовые данные в файл
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ events = await service._parse_logs(today, tomorrow)
+
+ # Проверяем что нашлись все события
+ assert len(events) >= 6, f'Expected at least 6 events, found {len(events)}'
+
+ # Проверяем типы событий
+ event_types = [e.event_type for e in events]
+ assert 'code_found' in event_types
+ assert 'code_applied' in event_types
+ assert 'registration_processed' in event_types
+ assert 'bonus_given' in event_types
+
+
+@pytest.mark.asyncio
+async def test_analyze_period_with_issues(temp_log_file, sample_log_content):
+ """Тест анализа с проблемными случаями."""
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ # Используем None вместо db для базового теста парсинга
+ from unittest.mock import AsyncMock
+
+ mock_db = AsyncMock()
+ mock_db.execute.return_value.scalar_one_or_none.return_value = None
+
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ # Проверяем статистику
+ # Примечание: code_found не имеет telegram_id, поэтому total_link_clicks будет 0
+ # Это нормально - мы считаем только события с telegram_id
+ assert report.total_codes_applied >= 1, 'Should have applied codes'
+
+ # Проверяем что нашлись проблемные случаи
+ # (987654321 применил код, но не завершил регистрацию)
+ assert 987654321 in report.users_applied_no_registration, (
+ f'Expected 987654321 in problems, got: {report.users_applied_no_registration}'
+ )
+
+
+@pytest.mark.asyncio
+async def test_empty_log_file(temp_log_file):
+ """Тест работы с пустым лог-файлом."""
+ temp_log_file.write_text('')
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ from unittest.mock import AsyncMock
+
+ mock_db = AsyncMock()
+
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ # Проверяем что отчёт пустой
+ assert report.total_link_clicks == 0
+ assert report.total_codes_applied == 0
+ assert report.total_registrations == 0
+ assert len(report.events) == 0
+
+
+@pytest.mark.asyncio
+async def test_nonexistent_log_file():
+ """Тест работы с несуществующим лог-файлом."""
+ service = ReferralDiagnosticsService(log_path='/nonexistent/path/to/log.log')
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ from unittest.mock import AsyncMock
+
+ mock_db = AsyncMock()
+
+ # Не должно быть исключений
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ assert report.total_link_clicks == 0
+ assert len(report.events) == 0
+
+
+@pytest.mark.asyncio
+async def test_analyze_today(temp_log_file, sample_log_content):
+ """Тест метода analyze_today."""
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ from unittest.mock import AsyncMock
+
+ mock_db = AsyncMock()
+
+ report = await service.analyze_today(mock_db)
+
+ # Проверяем что период установлен корректно
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ assert report.analysis_period_start.date() == today.date()