diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 7ff57379..4790a979 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -179,10 +179,20 @@ async def get_contest_leaderboard( *, limit: Optional[int] = None, ) -> Sequence[Tuple[User, int, int]]: + """Получить лидерборд конкурса. + + Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. + """ contest = await get_referral_contest(db, contest_id) if not contest: return [] + # Нормализуем границы дат + contest_start = contest.start_at + contest_end = contest.end_at + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + query = ( select( User, @@ -190,7 +200,13 @@ async def get_contest_leaderboard( func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"), ) .join(User, User.id == ReferralContestEvent.referrer_id) - .where(ReferralContestEvent.contest_id == contest_id) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) .group_by(User.id) .order_by(desc("referral_count"), desc("total_amount"), User.id) ) @@ -206,10 +222,29 @@ async def get_contest_participants( db: AsyncSession, contest_id: int, ) -> Sequence[Tuple[User, int]]: + """Получить участников конкурса. + + Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. + """ + contest = await get_referral_contest(db, contest_id) + if not contest: + return [] + + contest_start = contest.start_at + contest_end = contest.end_at + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + result = await db.execute( select(User, func.count(ReferralContestEvent.id).label("referral_count")) .join(User, User.id == ReferralContestEvent.referrer_id) - .where(ReferralContestEvent.contest_id == contest_id) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) .group_by(User.id) ) return result.all() @@ -297,3 +332,512 @@ async def delete_referral_contest( ) -> None: await db.delete(contest) await db.commit() + + +async def get_contest_payment_stats( + db: AsyncSession, + contest_id: int, +) -> dict: + """Получить статистику оплат по конкурсу. + + Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. + + Returns: + dict: { + "paid_count": int, # Рефералов с платежами > 0 + "unpaid_count": int, # Рефералов без платежей + "total_amount": int, # Общая сумма платежей + } + """ + # Получаем даты конкурса для фильтрации + contest = await get_referral_contest(db, contest_id) + if not contest: + return {"paid_count": 0, "unpaid_count": 0, "total_amount": 0} + + contest_start = contest.start_at + contest_end = contest.end_at + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Считаем рефералов с платежами (только зарегистрированных в период конкурса) + paid_result = await db.execute( + select(func.count(ReferralContestEvent.id)) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.amount_kopeks > 0, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + paid_count = int(paid_result.scalar_one() or 0) + + # Считаем рефералов без платежей (только зарегистрированных в период конкурса) + unpaid_result = await db.execute( + select(func.count(ReferralContestEvent.id)) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.amount_kopeks == 0, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + unpaid_count = int(unpaid_result.scalar_one() or 0) + + # Общая сумма (только за рефералов зарегистрированных в период конкурса) + total_result = await db.execute( + select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0)) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + total_amount = int(total_result.scalar_one() or 0) + + return { + "paid_count": paid_count, + "unpaid_count": unpaid_count, + "total_amount": total_amount, + } + + +async def get_contest_transaction_breakdown( + db: AsyncSession, + contest_id: int, +) -> dict: + """Получить разбивку транзакций по типам для конкурса. + + Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. + + Returns: + dict: { + "subscription_total": int, # Сумма покупок подписок (копейки) + "deposit_total": int, # Сумма пополнений баланса (копейки) + } + """ + contest = await get_referral_contest(db, contest_id) + if not contest: + return {"subscription_total": 0, "deposit_total": 0} + + contest_start = contest.start_at + contest_end = contest.end_at + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Получаем referral_id только из событий в период конкурса + events_result = await db.execute( + select(ReferralContestEvent.referral_id) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + referral_ids = [r[0] for r in events_result.fetchall()] + + if not referral_ids: + return {"subscription_total": 0, "deposit_total": 0} + + # Сумма покупок подписок + subscription_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + subscription_total = int(subscription_result.scalar_one() or 0) + + # Сумма пополнений баланса + deposit_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + deposit_total = int(deposit_result.scalar_one() or 0) + + return { + "subscription_total": subscription_total, + "deposit_total": deposit_total, + } + + +async def upsert_contest_event( + db: AsyncSession, + *, + contest_id: int, + referrer_id: int, + referral_id: int, + amount_kopeks: int = 0, + event_type: str = "subscription_purchase", +) -> Tuple[ReferralContestEvent, bool]: + """Создать или обновить событие конкурса. + + Returns: + Tuple[ReferralContestEvent, bool]: (событие, создано_новое) + """ + result = await db.execute( + select(ReferralContestEvent).where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.referral_id == referral_id, + ) + ) + ) + existing = result.scalar_one_or_none() + + if existing: + # Обновляем сумму если она изменилась + if existing.amount_kopeks != amount_kopeks: + existing.amount_kopeks = amount_kopeks + await db.commit() + await db.refresh(existing) + return existing, False + + event = ReferralContestEvent( + contest_id=contest_id, + referrer_id=referrer_id, + referral_id=referral_id, + amount_kopeks=amount_kopeks, + event_type=event_type, + occurred_at=datetime.utcnow(), + ) + db.add(event) + await db.commit() + await db.refresh(event) + return event, True + + +async def debug_contest_transactions( + db: AsyncSession, + contest_id: int, + limit: int = 20, +) -> dict: + """Показать транзакции которые учитываются в конкурсе для отладки. + + Возвращает информацию о транзакциях рефералов конкурса, + чтобы понять какие именно платежи считаются. + """ + contest = await get_referral_contest(db, contest_id) + if not contest: + return {"error": "Contest not found"} + + # Нормализуем границы дат + contest_start = contest.start_at + contest_end = contest.end_at + + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Получаем referral_id ТОЛЬКО из событий которые произошли в период конкурса + events_result = await db.execute( + select(ReferralContestEvent.referral_id) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + referral_ids = [r[0] for r in events_result.fetchall()] + + # Также считаем сколько всего событий для сравнения + all_events_result = await db.execute( + select(func.count(ReferralContestEvent.id)) + .where(ReferralContestEvent.contest_id == contest_id) + ) + total_all_events = int(all_events_result.scalar_one() or 0) + + if not referral_ids: + return { + "contest_start": contest_start.isoformat(), + "contest_end": contest_end.isoformat(), + "referral_count": 0, + "total_all_events": total_all_events, + "transactions": [], + } + + # Получаем транзакции этих рефералов ЗА период конкурса + transactions_in_period = await db.execute( + select(Transaction) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type.in_([ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ]), + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + .order_by(desc(Transaction.created_at)) + .limit(limit) + ) + txs_in = transactions_in_period.scalars().all() + + # Также получаем транзакции ВНЕ периода для сравнения + transactions_outside = await db.execute( + select(Transaction) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type.in_([ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ]), + func.not_( + and_( + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ), + ) + ) + .order_by(desc(Transaction.created_at)) + .limit(limit) + ) + txs_out = transactions_outside.scalars().all() + + # Подсчёт общих сумм ПО ТИПАМ + deposit_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value) + subscription_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value) + total_in_period = deposit_in_period + subscription_in_period + total_outside = sum(tx.amount_kopeks for tx in txs_out) + + # Подсчёт ПОЛНЫХ сумм (не только sample) + full_deposit_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + full_deposit_total = int(full_deposit_result.scalar_one() or 0) + + full_subscription_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id.in_(referral_ids), + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + full_subscription_total = int(full_subscription_result.scalar_one() or 0) + + return { + "contest_start": contest_start.isoformat(), + "contest_end": contest_end.isoformat(), + "referral_count": len(referral_ids), + "total_all_events": total_all_events, + "filtered_out": total_all_events - len(referral_ids), + "transactions_in_period": [ + { + "id": tx.id, + "user_id": tx.user_id, + "type": tx.type, + "amount_kopeks": tx.amount_kopeks, + "created_at": tx.created_at.isoformat() if tx.created_at else None, + "payment_method": tx.payment_method, + } + for tx in txs_in + ], + "transactions_outside_period": [ + { + "id": tx.id, + "user_id": tx.user_id, + "type": tx.type, + "amount_kopeks": tx.amount_kopeks, + "created_at": tx.created_at.isoformat() if tx.created_at else None, + "payment_method": tx.payment_method, + } + for tx in txs_out + ], + "total_in_period_kopeks": total_in_period, + "total_outside_period_kopeks": total_outside, + "deposit_total_kopeks": full_deposit_total, + "subscription_total_kopeks": full_subscription_total, + "sample_size": limit, + } + + +async def sync_contest_events( + db: AsyncSession, + contest_id: int, +) -> dict: + """Синхронизировать события конкурса с реальными данными. + + Обновляет ВСЕ существующие события конкурса, пересчитывая платежи + каждого реферала СТРОГО за период конкурса (start_at - end_at). + + Returns: + dict: { + "updated": int, # Событий обновлено + "skipped": int, # Пропущено (нет изменений) + "total_events": int, # Всего событий проверено + "total_amount": int, # Общая сумма платежей + "paid_count": int, # Рефералов с платежами + "unpaid_count": int, # Рефералов без платежей + } + """ + contest = await get_referral_contest(db, contest_id) + if not contest: + return {"error": "Contest not found"} + + # Нормализуем границы дат для СТРОГОЙ фильтрации + # start_at должен быть началом дня (00:00:00) + # end_at должен быть концом дня (23:59:59.999999) + contest_start = contest.start_at + contest_end = contest.end_at + + # Если start_at содержит только дату (время 00:00), оставляем как есть + # Если end_at содержит только дату, добавляем время до конца дня + if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: + # Конец дня: 23:59:59.999999 + contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) + + logger.info( + "Синхронизация конкурса %s: период с %s по %s", + contest_id, contest_start, contest_end + ) + + stats = { + "updated": 0, + "skipped": 0, + "total_events": 0, + "total_amount": 0, + "paid_count": 0, + "unpaid_count": 0, + "contest_start": contest_start.isoformat(), + "contest_end": contest_end.isoformat(), + } + + # Получаем события конкурса ТОЛЬКО те, что произошли в период конкурса + # (реферал зарегистрировался в период проведения конкурса) + events_result = await db.execute( + select(ReferralContestEvent) + .where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.occurred_at >= contest_start, + ReferralContestEvent.occurred_at <= contest_end, + ) + ) + ) + events = events_result.scalars().all() + + # Также считаем сколько всего событий (для отладки) + all_events_result = await db.execute( + select(func.count(ReferralContestEvent.id)) + .where(ReferralContestEvent.contest_id == contest_id) + ) + total_all_events = int(all_events_result.scalar_one() or 0) + + stats["total_events"] = len(events) + stats["total_all_events"] = total_all_events + stats["filtered_out_events"] = total_all_events - len(events) + + stats["deposit_total"] = 0 + stats["subscription_total"] = 0 + + for event in events: + # Считаем ТОЛЬКО покупки подписок (реальные траты на подписки) + subscription_query = ( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id == event.referral_id, + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + sub_result = await db.execute(subscription_query) + subscription_paid = int(sub_result.scalar_one() or 0) + + # Также считаем пополнения баланса (для информации) + deposit_query = ( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id == event.referral_id, + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, + ) + ) + ) + dep_result = await db.execute(deposit_query) + deposit_paid = int(dep_result.scalar_one() or 0) + + stats["subscription_total"] += subscription_paid + stats["deposit_total"] += deposit_paid + + # Основная метрика — покупки подписок + total_paid = subscription_paid + + # Считаем статистику + if total_paid > 0: + stats["total_amount"] += total_paid + stats["paid_count"] += 1 + else: + stats["unpaid_count"] += 1 + + # Обновляем сумму если изменилась + if event.amount_kopeks != total_paid: + old_amount = event.amount_kopeks + event.amount_kopeks = total_paid + stats["updated"] += 1 + # Логируем значительные изменения + if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы + logger.debug( + "Событие %s (реферал %s): %s -> %s коп.", + event.id, event.referral_id, old_amount, total_paid + ) + else: + stats["skipped"] += 1 + + # Сохраняем изменения + await db.commit() + + logger.info( + "Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.", + contest_id, stats["updated"], stats["skipped"], stats["total_amount"] + ) + + return stats diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index e024045e..22a35595 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -688,10 +688,15 @@ async def show_detailed_stats( "📈 Статистика конкурса", f"🏆 {contest.title}", "", - f"👥 Участников: {stats['total_participants']}", + f"👥 Участников (рефереров): {stats['total_participants']}", f"📨 Приглашено рефералов: {stats['total_invited']}", - f"💰 Оплатили подписок: {stats['total_paid_amount'] // 100} руб.", - f"❌ Не оплатили: {stats['total_unpaid']}", + "", + f"💳 Рефералов оплатили: {stats.get('paid_count', 0)}", + f"❌ Рефералов не оплатили: {stats.get('unpaid_count', 0)}", + "", + "💰 СУММЫ:", + f" 🛒 Покупки подписок: {stats.get('subscription_total', 0) // 100} руб.", + f" 📥 Пополнения баланса: {stats.get('deposit_total', 0) // 100} руб.", ] await callback.message.edit_text( @@ -760,6 +765,202 @@ async def show_detailed_stats_page( await callback.answer() +@admin_required +@error_handler +async def sync_contest( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + """Синхронизировать события конкурса с реальными платежами.""" + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + + if not contest: + await callback.answer("Конкурс не найден.", show_alert=True) + return + + await callback.answer("🔄 Синхронизация запущена...", show_alert=False) + + from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.sync_contest(db, contest_id) + + if "error" in stats: + await callback.message.answer( + f"❌ Ошибка синхронизации:\n{stats['error']}", + ) + return + + # Формируем сообщение о результатах + # Показываем точные даты которые использовались для фильтрации + start_str = stats.get('contest_start', contest.start_at.isoformat()) + end_str = stats.get('contest_end', contest.end_at.isoformat()) + + lines = [ + "✅ Синхронизация завершена!", + "", + f"📊 Конкурс: {contest.title}", + f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}", + f"🔍 Фильтр транзакций:", + f" {start_str}", + f" {end_str}", + "", + f"📝 Рефералов в периоде: {stats.get('total_events', 0)}", + f"⚠️ Отфильтровано (вне периода): {stats.get('filtered_out_events', 0)}", + f"📊 Всего событий в БД: {stats.get('total_all_events', 0)}", + "", + f"🔄 Обновлено сумм: {stats.get('updated', 0)}", + f"⏭ Без изменений: {stats.get('skipped', 0)}", + "", + f"💳 Рефералов оплатили: {stats.get('paid_count', 0)}", + f"❌ Рефералов не оплатили: {stats.get('unpaid_count', 0)}", + "", + "💰 СУММЫ:", + f" 🛒 Покупки подписок: {stats.get('subscription_total', 0) // 100} руб.", + f" 📥 Пополнения баланса: {stats.get('deposit_total', 0) // 100} руб.", + ] + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + back_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")] + ]) + + await callback.message.answer( + "\n".join(lines), + parse_mode="HTML", + reply_markup=back_keyboard, + ) + + # Обновляем основное сообщение с новой статистикой + detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + general_lines = [ + f"🏆 {contest.title}", + f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}", + "", + f"👥 Участников (рефереров): {detailed_stats['total_participants']}", + f"📨 Приглашено рефералов: {detailed_stats['total_invited']}", + "", + f"💳 Рефералов оплатили: {detailed_stats.get('paid_count', 0)}", + f"❌ Рефералов не оплатили: {detailed_stats.get('unpaid_count', 0)}", + f"🛒 Покупки подписок: {detailed_stats['total_paid_amount'] // 100} руб.", + ] + + await callback.message.edit_text( + "\n".join(general_lines), + reply_markup=get_referral_contest_manage_keyboard( + contest_id, is_active=contest.is_active, language=db_user.language + ), + ) + + +@admin_required +@error_handler +async def debug_contest_transactions( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + """Показать транзакции рефералов конкурса для отладки.""" + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + + if not contest: + await callback.answer("Конкурс не найден.", show_alert=True) + return + + await callback.answer("🔍 Загружаю данные...", show_alert=False) + + from app.database.crud.referral_contest import debug_contest_transactions as debug_txs + debug_data = await debug_txs(db, contest_id, limit=10) + + if "error" in debug_data: + await callback.message.answer(f"❌ Ошибка: {debug_data['error']}") + return + + deposit_total = debug_data.get('deposit_total_kopeks', 0) // 100 + subscription_total = debug_data.get('subscription_total_kopeks', 0) // 100 + grand_total = deposit_total + subscription_total + + lines = [ + "🔍 Отладка транзакций конкурса", + "", + f"📊 Конкурс: {contest.title}", + f"📅 Период фильтрации:", + f" Начало: {debug_data.get('contest_start')}", + f" Конец: {debug_data.get('contest_end')}", + f"👥 Рефералов в периоде: {debug_data.get('referral_count', 0)}", + f"⚠️ Отфильтровано (вне периода): {debug_data.get('filtered_out', 0)}", + f"📊 Всего событий в БД: {debug_data.get('total_all_events', 0)}", + "", + "💰 РАЗБИВКА ПО ТИПАМ ТРАНЗАКЦИЙ:", + f" 📥 Пополнения баланса (DEPOSIT): {deposit_total} руб.", + f" 🛒 Прямые покупки (SUBSCRIPTION): {subscription_total} руб.", + f" 📊 ИТОГО: {grand_total} руб.", + "", + f"💸 Сумма вне периода: {debug_data.get('total_outside_period_kopeks', 0) // 100} руб.", + "", + ] + + # Показываем транзакции В периоде + txs_in = debug_data.get('transactions_in_period', []) + if txs_in: + lines.append(f"✅ Транзакции в периоде (первые {len(txs_in)}):") + for tx in txs_in[:5]: # Показываем максимум 5 + lines.append( + f" • {tx['created_at'][:10]} | " + f"{tx['type']} | " + f"{tx['amount_kopeks'] // 100}₽ | " + f"user={tx['user_id']}" + ) + if len(txs_in) > 5: + lines.append(f" ... и ещё {len(txs_in) - 5}") + else: + lines.append("✅ Транзакций в периоде: 0") + + lines.append("") + + # Показываем транзакции ВНЕ периода + txs_out = debug_data.get('transactions_outside_period', []) + if txs_out: + lines.append(f"❌ Транзакции вне периода (первые {len(txs_out)}):") + for tx in txs_out[:5]: + lines.append( + f" • {tx['created_at'][:10]} | " + f"{tx['type']} | " + f"{tx['amount_kopeks'] // 100}₽ | " + f"user={tx['user_id']}" + ) + if len(txs_out) > 5: + lines.append(f" ... и ещё {len(txs_out) - 5}") + else: + lines.append("❌ Транзакций вне периода: 0") + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + back_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")] + ]) + + await callback.message.answer( + "\n".join(lines), + parse_mode="HTML", + reply_markup=back_keyboard, + ) + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "admin_contests") dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") @@ -772,6 +973,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_")) dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_")) dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_")) + dp.callback_query.register(sync_contest, F.data.startswith("admin_contest_sync_")) + dp.callback_query.register(debug_contest_transactions, F.data.startswith("admin_contest_debug_")) dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create") dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"])) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 07bce5e3..afc12b52 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -628,6 +628,16 @@ def get_referral_contest_manage_keyboard( callback_data=f"admin_contest_edit_times_{contest_id}", ), ], + [ + InlineKeyboardButton( + text="🔄 Синхронизация", + callback_data=f"admin_contest_sync_{contest_id}", + ), + InlineKeyboardButton( + text="🔍 Отладка", + callback_data=f"admin_contest_debug_{contest_id}", + ), + ], ] if can_delete: diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py index 81574a73..b26000a6 100644 --- a/app/services/nalogo_queue_service.py +++ b/app/services/nalogo_queue_service.py @@ -15,6 +15,7 @@ from aiogram import Bot from app.config import settings from app.services.nalogo_service import NaloGoService +from app.utils.cache import cache logger = logging.getLogger(__name__) @@ -205,6 +206,12 @@ class NalogoQueueService: if receipt_uuid: processed += 1 total_processed_amount += amount + + # Удаляем метку "в очереди" (чек создан успешно) + if payment_id: + queued_key = f"nalogo:queued:{payment_id}" + await cache.delete(queued_key) + logger.info( f"Чек из очереди успешно создан: {receipt_uuid} " f"(payment_id={payment_id}, попытка {attempts + 1})" diff --git a/app/services/nalogo_service.py b/app/services/nalogo_service.py index 92eef4e8..8a182ba6 100644 --- a/app/services/nalogo_service.py +++ b/app/services/nalogo_service.py @@ -88,6 +88,26 @@ class NaloGoService: amount_kopeks: Optional[int] = None, ) -> bool: """Добавить чек в очередь для отложенной отправки.""" + if payment_id: + # Защита от дубликатов: проверяем не был ли чек уже создан + created_key = f"nalogo:created:{payment_id}" + already_created = await cache.get(created_key) + if already_created: + logger.info( + f"Чек для payment_id={payment_id} уже создан ({already_created}), " + "не добавляем в очередь" + ) + return False + + # Проверяем не в очереди ли уже + queued_key = f"nalogo:queued:{payment_id}" + already_queued = await cache.get(queued_key) + if already_queued: + logger.info( + f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат" + ) + return False + receipt_data = { "name": name, "amount": amount, @@ -101,6 +121,11 @@ class NaloGoService: } success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data) if success: + # Помечаем что чек в очереди (TTL 7 дней) + if payment_id: + queued_key = f"nalogo:queued:{payment_id}" + await cache.set(queued_key, "queued", expire=7 * 24 * 3600) + queue_len = await cache.llen(NALOGO_QUEUE_KEY) logger.info( f"Чек добавлен в очередь (payment_id={payment_id}, " diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 15b5f886..2ec9e435 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -391,7 +391,12 @@ class ReferralContestService: return "\n".join(lines) async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict: - from app.database.crud.referral_contest import get_contest_leaderboard, get_referral_contest + from app.database.crud.referral_contest import ( + get_contest_leaderboard, + get_referral_contest, + get_contest_payment_stats, + get_contest_transaction_breakdown, + ) contest = await get_referral_contest(db, contest_id) if not contest: @@ -400,24 +405,39 @@ class ReferralContestService: 'total_invited': 0, 'total_paid_amount': 0, 'total_unpaid': 0, + 'paid_count': 0, + 'unpaid_count': 0, + 'subscription_total': 0, + 'deposit_total': 0, 'participants': [], } # Get leaderboard - already includes User objects leaderboard = await get_contest_leaderboard(db, contest_id) + + # Получаем статистику оплат + payment_stats = await get_contest_payment_stats(db, contest_id) + + # Получаем разбивку по типам транзакций + breakdown = await get_contest_transaction_breakdown(db, contest_id) + if not leaderboard: return { 'total_participants': 0, 'total_invited': 0, - 'total_paid_amount': 0, - 'total_unpaid': 0, + 'total_paid_amount': payment_stats['total_amount'], + 'total_unpaid': payment_stats['unpaid_count'], + 'paid_count': payment_stats['paid_count'], + 'unpaid_count': payment_stats['unpaid_count'], + 'subscription_total': breakdown['subscription_total'], + 'deposit_total': breakdown['deposit_total'], 'participants': [], } total_participants = len(leaderboard) total_invited = sum(score for _, score, _ in leaderboard) - total_paid_amount = sum(amount for _, _, amount in leaderboard) - total_unpaid = 0 + total_paid_amount = payment_stats['total_amount'] + total_unpaid = payment_stats['unpaid_count'] # Build participants stats directly from leaderboard (already has User objects) participants_stats = [] @@ -426,8 +446,8 @@ class ReferralContestService: 'referrer_id': user.id, 'full_name': user.full_name, 'total_referrals': score, - 'paid_referrals': score, - 'unpaid_referrals': 0, + 'paid_referrals': score if amount > 0 else 0, + 'unpaid_referrals': 0 if amount > 0 else score, 'total_paid_amount': amount, }) @@ -436,6 +456,10 @@ class ReferralContestService: 'total_invited': total_invited, 'total_paid_amount': total_paid_amount, 'total_unpaid': total_unpaid, + 'paid_count': payment_stats['paid_count'], + 'unpaid_count': payment_stats['unpaid_count'], + 'subscription_total': breakdown['subscription_total'], + 'deposit_total': breakdown['deposit_total'], 'participants': participants_stats, } @@ -558,4 +582,32 @@ class ReferralContestService: except Exception as exc: # noqa: BLE001 logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc) + async def sync_contest( + self, + db: AsyncSession, + contest_id: int, + ) -> dict: + """Синхронизировать события конкурса с реальными данными. + + Проверяет всех рефералов и их платежи за период конкурса. + Учитывает ВСЕ платёжные системы (Stars, YooKassa, Platega, CryptoBot и др.). + """ + from app.database.crud.referral_contest import sync_contest_events + + try: + stats = await sync_contest_events(db, contest_id) + if "error" not in stats: + logger.info( + "Синхронизация конкурса %s: создано %s, обновлено %s, пропущено %s", + contest_id, + stats.get("created", 0), + stats.get("updated", 0), + stats.get("skipped", 0), + ) + return stats + except Exception as exc: + logger.error("Ошибка синхронизации конкурса %s: %s", contest_id, exc) + return {"error": str(exc)} + + referral_contest_service = ReferralContestService()