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()