mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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}, "
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user