fix(contests): исправление статистики реферальных конкурсов

Основные исправления:
  - Фильтрация событий по дате регистрации реферала (occurred_at)
    в период конкурса (start_at - end_at)
  - Лидерборд теперь показывает правильные числа (было 21, стало 11)
  - Разделение DEPOSIT и SUBSCRIPTION_PAYMENT в статистике:
    - Основная метрика: покупки подписок (SUBSCRIPTION_PAYMENT)
    - Информационно: пополнения баланса (DEPOSIT)

  Новый функционал:
  - Кнопка "🔍 Отладка" для просмотра транзакций конкурса
  - Разбивка сумм по типам в детальной статистике
  - Кнопки "Назад" в синхронизации и отладке
  - Логирование дат фильтрации в синхронизации

  Также исправлено:
  - NaloGO: защита от дублирования чеков в очереди
    (проверка nalogo:created и nalogo:queued в Redis)
This commit is contained in:
gy9vin
2025-12-30 02:08:23 +03:00
parent ac94d5d708
commit 2a2a3daaae
6 changed files with 853 additions and 12 deletions

View File

@@ -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

View File

@@ -688,10 +688,15 @@ async def show_detailed_stats(
"📈 <b>Статистика конкурса</b>",
f"🏆 {contest.title}",
"",
f"👥 Участников: <b>{stats['total_participants']}</b>",
f"👥 Участников (рефереров): <b>{stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
f"Не оплатили: <b>{stats['total_unpaid']}</b>",
"",
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
"",
"<b>💰 СУММЫ:</b>",
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
]
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 = [
"✅ <b>Синхронизация завершена!</b>",
"",
f"📊 <b>Конкурс:</b> {contest.title}",
f"📅 <b>Период:</b> {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
f"🔍 <b>Фильтр транзакций:</b>",
f" <code>{start_str}</code>",
f" <code>{end_str}</code>",
"",
f"📝 Рефералов в периоде: <b>{stats.get('total_events', 0)}</b>",
f"⚠️ Отфильтровано (вне периода): <b>{stats.get('filtered_out_events', 0)}</b>",
f"📊 Всего событий в БД: <b>{stats.get('total_all_events', 0)}</b>",
"",
f"🔄 Обновлено сумм: <b>{stats.get('updated', 0)}</b>",
f"⏭ Без изменений: <b>{stats.get('skipped', 0)}</b>",
"",
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
"",
"<b>💰 СУММЫ:</b>",
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
]
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"🏆 <b>{contest.title}</b>",
f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
"",
f"👥 Участников (рефереров): <b>{detailed_stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{detailed_stats['total_invited']}</b>",
"",
f"💳 Рефералов оплатили: <b>{detailed_stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{detailed_stats.get('unpaid_count', 0)}</b>",
f"🛒 Покупки подписок: <b>{detailed_stats['total_paid_amount'] // 100} руб.</b>",
]
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 = [
"🔍 <b>Отладка транзакций конкурса</b>",
"",
f"📊 <b>Конкурс:</b> {contest.title}",
f"📅 <b>Период фильтрации:</b>",
f" Начало: <code>{debug_data.get('contest_start')}</code>",
f" Конец: <code>{debug_data.get('contest_end')}</code>",
f"👥 <b>Рефералов в периоде:</b> {debug_data.get('referral_count', 0)}",
f"⚠️ <b>Отфильтровано (вне периода):</b> {debug_data.get('filtered_out', 0)}",
f"📊 <b>Всего событий в БД:</b> {debug_data.get('total_all_events', 0)}",
"",
"<b>💰 РАЗБИВКА ПО ТИПАМ ТРАНЗАКЦИЙ:</b>",
f" 📥 Пополнения баланса (DEPOSIT): <b>{deposit_total}</b> руб.",
f" 🛒 Прямые покупки (SUBSCRIPTION): <b>{subscription_total}</b> руб.",
f" 📊 ИТОГО: <b>{grand_total}</b> руб.",
"",
f"💸 <b>Сумма вне периода:</b> {debug_data.get('total_outside_period_kopeks', 0) // 100} руб.",
"",
]
# Показываем транзакции В периоде
txs_in = debug_data.get('transactions_in_period', [])
if txs_in:
lines.append(f"✅ <b>Транзакции в периоде</b> (первые {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("✅ <b>Транзакций в периоде:</b> 0")
lines.append("")
# Показываем транзакции ВНЕ периода
txs_out = debug_data.get('transactions_outside_period', [])
if txs_out:
lines.append(f"❌ <b>Транзакции вне периода</b> (первые {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("❌ <b>Транзакций вне периода:</b> 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"]))

View File

@@ -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:

View File

@@ -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})"

View File

@@ -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}, "

View File

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