Files
Fringg 49db5f5eed fix: multi-tariff Stage 3 critical fixes — panel sync UUID, admin grant, wheel
- remnawave_service: sync_users_to_panel uses sub.remnawave_uuid in
  multi-tariff instead of user.remnawave_uuid (was targeting wrong panel user)
- admin/users: admin_buy_subscription_execute saves UUID to
  subscription.remnawave_uuid in multi-tariff (was saving to user)
- wheel_service: _process_days_payment and _apply_prize require subscription
  in multi-tariff mode, fallback converts to balance bonus for prizes
2026-03-26 08:26:09 +03:00

779 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом.
"""
import random
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.user import add_user_balance
from app.database.crud.wheel import (
create_wheel_spin,
get_or_create_wheel_config,
get_user_spins_today,
get_wheel_prizes,
get_wheel_statistics,
)
from app.database.models import (
PromoCode,
PromoCodeType,
Subscription,
User,
WheelConfig,
WheelPrize,
WheelPrizeType,
WheelSpinPaymentType,
)
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
@dataclass
class SpinResult:
"""Результат спина колеса."""
success: bool
prize_id: int | None = None
prize_type: str | None = None
prize_value: int = 0
prize_display_name: str = ''
emoji: str = '🎁'
color: str = '#3B82F6'
rotation_degrees: float = 0.0
message: str = ''
promocode: str | None = None
error: str | None = None
@dataclass
class EligibleSubscription:
"""Подписка, доступная для оплаты колеса днями."""
id: int
tariff_name: str | None
days_left: int
@dataclass
class SpinAvailability:
"""Доступность спина для пользователя."""
can_spin: bool
reason: str | None = None
spins_remaining_today: int = 0
can_pay_stars: bool = False
can_pay_days: bool = False
min_subscription_days: int = 0
user_subscription_days: int = 0
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
eligible_subscriptions: list[EligibleSubscription] | None = None
class FortuneWheelService:
"""Сервис колеса удачи с RTP механикой."""
def __init__(self):
pass
async def check_availability(self, db: AsyncSession, user: User) -> SpinAvailability:
"""Проверить доступность спина для пользователя."""
config = await get_or_create_wheel_config(db)
# Колесо выключено
if not config.is_enabled:
return SpinAvailability(
can_spin=False,
reason='wheel_disabled',
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
spins_remaining = config.daily_spin_limit - spins_today if config.daily_spin_limit > 0 else 999
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
return SpinAvailability(
can_spin=False,
reason='daily_limit_reached',
spins_remaining_today=0,
)
# Проверяем доступные способы оплаты
can_pay_stars = False
can_pay_days = False
user_subscription_days = 0
required_balance_kopeks = 0
# Проверяем оплату Stars (конвертируется в рубли из баланса)
if config.spin_cost_stars_enabled and config.spin_cost_stars > 0:
stars_rate = Decimal(str(settings.get_stars_rate()))
rubles = Decimal(config.spin_cost_stars) * stars_rate
required_balance_kopeks = int(rubles * 100)
# Проверяем достаточно ли средств на балансе
if user.balance_kopeks >= required_balance_kopeks:
can_pay_stars = True
eligible_subs: list[EligibleSubscription] = []
if config.spin_cost_days_enabled:
if settings.is_multi_tariff_enabled():
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
else:
_single = await get_subscription_by_user_id(db, user.id)
active_subs = [_single] if _single else []
min_days_required = config.min_subscription_days_for_day_payment + config.spin_cost_days
for sub in active_subs:
if not sub.is_active:
continue
# Exclude daily tariffs — they can't pay with days
is_daily = sub.tariff and getattr(sub.tariff, 'is_daily', False)
if is_daily:
continue
if sub.days_left >= min_days_required:
tariff_name = sub.tariff.name if sub.tariff else None
eligible_subs.append(
EligibleSubscription(id=sub.id, tariff_name=tariff_name, days_left=sub.days_left)
)
if eligible_subs:
can_pay_days = True
# For backward compat: use best subscription's days
user_subscription_days = max(s.days_left for s in eligible_subs)
if not can_pay_stars and not can_pay_days:
# Определяем причину
reason = 'no_payment_method_available'
if config.spin_cost_stars_enabled and user.balance_kopeks < required_balance_kopeks:
reason = 'insufficient_balance'
return SpinAvailability(
can_spin=False,
reason=reason,
spins_remaining_today=spins_remaining,
can_pay_stars=can_pay_stars,
can_pay_days=can_pay_days,
min_subscription_days=config.min_subscription_days_for_day_payment,
user_subscription_days=user_subscription_days,
user_balance_kopeks=user.balance_kopeks,
required_balance_kopeks=required_balance_kopeks,
eligible_subscriptions=eligible_subs or None,
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
return SpinAvailability(
can_spin=False,
reason='no_prizes_configured',
)
return SpinAvailability(
can_spin=True,
spins_remaining_today=spins_remaining,
can_pay_stars=can_pay_stars,
can_pay_days=can_pay_days,
min_subscription_days=config.min_subscription_days_for_day_payment,
user_subscription_days=user_subscription_days,
user_balance_kopeks=user.balance_kopeks,
required_balance_kopeks=required_balance_kopeks,
eligible_subscriptions=eligible_subs or None,
)
def calculate_prize_probabilities(
self, config: WheelConfig, prizes: list[WheelPrize], spin_cost_kopeks: int
) -> list[tuple[WheelPrize, float]]:
"""
Рассчитать вероятности выпадения призов на основе RTP.
Алгоритм:
1. Целевая средняя выплата = spin_cost * (RTP / 100)
2. Для призов с manual_probability - используем его напрямую
3. Для остальных - рассчитываем веса обратно пропорционально стоимости приза
4. "Nothing" сектор балансирует систему
"""
if not prizes:
return []
target_payout = spin_cost_kopeks * (config.rtp_percent / 100)
# Разделяем призы с ручной вероятностью и автоматической
manual_prizes = []
auto_prizes = []
manual_prob_sum = 0.0
for prize in prizes:
if prize.manual_probability is not None and prize.manual_probability > 0:
manual_prizes.append((prize, prize.manual_probability))
manual_prob_sum += prize.manual_probability
else:
auto_prizes.append(prize)
# Оставшаяся вероятность для авто-призов
remaining_prob = max(0, 1.0 - manual_prob_sum)
if not auto_prizes or remaining_prob <= 0:
# Только ручные призы, нормализуем их
if manual_prizes:
total = sum(p[1] for p in manual_prizes)
return [(p[0], p[1] / total) for p in manual_prizes]
return []
# Рассчитываем веса для авто-призов
# Вес обратно пропорционален стоимости приза (более дорогие выпадают реже)
weights = []
for prize in auto_prizes:
if prize.prize_value_kopeks > 0:
# Чем дороже приз, тем меньше вес
weight = target_payout / prize.prize_value_kopeks
else:
# "Nothing" или нулевой приз - даем базовый вес
weight = 1.0
weights.append((prize, max(weight, 0.01))) # Минимальный вес 1%
# Нормализуем веса авто-призов до remaining_prob
total_weight = sum(w[1] for w in weights)
auto_probabilities = [(prize, (weight / total_weight) * remaining_prob) for prize, weight in weights]
# Объединяем
result = manual_prizes + auto_probabilities
# Финальная нормализация (на случай погрешностей)
total = sum(p[1] for p in result)
if total > 0:
result = [(p[0], p[1] / total) for p in result]
return result
def _select_prize(self, prizes_with_probabilities: list[tuple[WheelPrize, float]]) -> WheelPrize:
"""Выбрать приз на основе вероятностей."""
if not prizes_with_probabilities:
raise ValueError('No prizes to select from')
rand = random.random()
cumulative = 0.0
for prize, probability in prizes_with_probabilities:
cumulative += probability
if rand <= cumulative:
return prize
# Fallback на последний приз
return prizes_with_probabilities[-1][0]
def _calculate_rotation(self, prizes: list[WheelPrize], selected_prize: WheelPrize) -> float:
"""
Рассчитать угол поворота колеса для анимации.
Возвращает градусы для CSS transform.
"""
if not prizes:
return 0.0
# Находим индекс выбранного приза
prize_index = next((i for i, p in enumerate(prizes) if p.id == selected_prize.id), 0)
# Угол одного сектора
sector_angle = 360 / len(prizes)
# Базовый угол до центра сектора (от 12 часов по часовой)
base_angle = prize_index * sector_angle + sector_angle / 2
# Добавляем случайное смещение внутри сектора (не по краям)
offset = random.uniform(-sector_angle * 0.3, sector_angle * 0.3)
# Угол остановки (стрелка сверху, поэтому инвертируем)
stop_angle = 360 - base_angle + offset
# Добавляем несколько полных оборотов для эффекта
full_rotations = random.randint(5, 8) * 360
return full_rotations + stop_angle
async def _process_stars_payment(self, db: AsyncSession, user: User, config: WheelConfig) -> int:
"""
Обработать оплату Stars (списание эквивалента с баланса).
Возвращает стоимость в копейках.
"""
# Конвертируем Stars в рубли
stars_rate = Decimal(str(settings.get_stars_rate()))
rubles = Decimal(config.spin_cost_stars) * stars_rate
kopeks = int(rubles * 100)
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
if user.balance_kopeks < kopeks:
raise ValueError('Недостаточно средств на балансе')
# Списываем с баланса
user.balance_kopeks -= kopeks
logger.info(
'💫 Списано ₽ (⭐) с баланса user_id',
kopeks=round(kopeks / 100, 2),
spin_cost_stars=config.spin_cost_stars,
user_id=user.id,
)
return kopeks
async def _process_days_payment(
self, db: AsyncSession, user: User, config: WheelConfig, subscription: Subscription | None = None
) -> int:
"""
Обработать оплату днями подписки.
Возвращает эквивалент в копейках.
"""
if not subscription:
if settings.is_multi_tariff_enabled():
raise ValueError('Необходимо указать подписку для оплаты днями (мульти-тариф)')
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
raise ValueError('Нет активной подписки')
if subscription.days_left < config.min_subscription_days_for_day_payment + config.spin_cost_days:
raise ValueError('Недостаточно дней подписки')
# Уменьшаем end_date
subscription.end_date -= timedelta(days=config.spin_cost_days)
subscription.updated_at = datetime.now(UTC)
# Оцениваем стоимость в копейках (для статистики)
# Берем цену 30-дневного периода и делим на 30
from app.config import PERIOD_PRICES
price_30_days = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) or 19900
daily_price = price_30_days / 30
kopeks = int(daily_price * config.spin_cost_days)
logger.info('📅 Списано дней подписки у user_id', spin_cost_days=config.spin_cost_days, user_id=user.id)
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
result = await subscription_service.update_remnawave_user(db, subscription)
if result is not None:
logger.info('✅ Списание дней синхронизировано с RemnaWave для user_id', user_id=user.id)
else:
logger.error('⚠️ Не удалось синхронизировать списание дней с RemnaWave', user_id=user.id)
except Exception as e:
logger.error('⚠️ Ошибка синхронизации списания дней с RemnaWave', error=e, user_id=user.id)
return kopeks
async def _apply_prize(
self,
db: AsyncSession,
user: User,
prize: WheelPrize,
config: WheelConfig,
subscription: Subscription | None = None,
) -> str | None:
"""
Применить приз к пользователю.
Возвращает промокод (если приз - промокод), иначе None.
"""
prize_type = prize.prize_type
if prize_type == WheelPrizeType.NOTHING.value:
logger.info('🎰 Пустой приз для user_id', user_id=user.id)
return None
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
# Пополнение баланса
await add_user_balance(
db,
user,
prize.prize_value,
description=f'Выигрыш в колесе удачи: {prize.prize_value / 100:.2f}',
create_transaction=True,
)
logger.info(
'💰 Начислено ₽ на баланс user_id', prize_value=round(prize.prize_value / 100, 2), user_id=user.id
)
return None
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
# Дни подписки — use provided subscription or fallback
if not subscription:
if settings.is_multi_tariff_enabled():
# Multi-tariff: нельзя выбрать произвольную подписку, начисляем на баланс
await add_user_balance(
db,
user,
prize.prize_value_kopeks,
description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс, мульти-тариф)',
create_transaction=True,
)
logger.info(
'Мульти-тариф: дни конвертированы в баланс (подписка не указана)',
prize_value=prize.prize_value,
user_id=user.id,
)
return None
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
# Проверяем суточный тариф - для него конвертируем дни в баланс
is_daily = getattr(subscription, 'is_daily', False) or (
subscription.tariff and getattr(subscription.tariff, 'is_daily', False)
)
if is_daily:
# Для суточных тарифов: дни * суточная_цена = баланс
daily_price = 0
if subscription.tariff and hasattr(subscription.tariff, 'daily_price_kopeks'):
daily_price = subscription.tariff.daily_price_kopeks or 0
if daily_price > 0:
balance_bonus = prize.prize_value * daily_price
await add_user_balance(
db,
user,
balance_bonus,
description=f'Выигрыш в колесе удачи: {prize.prize_value} дней → {balance_bonus / 100:.2f}',
create_transaction=True,
)
logger.info(
'💰 Суточный тариф: дней конвертированы в ₽ для user_id',
prize_value=prize.prize_value,
balance_bonus=round(balance_bonus / 100, 2),
user_id=user.id,
)
else:
# Если нет цены - используем prize_value_kopeks
await add_user_balance(
db,
user,
prize.prize_value_kopeks,
description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)',
create_transaction=True,
)
logger.info('💰 Дни конвертированы в баланс для user_id', user_id=user.id)
else:
# Обычная подписка - добавляем дни и синхронизируем с RemnaWave
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.now(UTC)
logger.info('📅 Начислено дней подписки user_id', prize_value=prize.prize_value, user_id=user.id)
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info('✅ Синхронизировано с RemnaWave для user_id', user_id=user.id)
except Exception as e:
logger.error('⚠️ Ошибка синхронизации с RemnaWave', error=e)
else:
# Если нет подписки - начисляем на баланс эквивалент
await add_user_balance(
db,
user,
prize.prize_value_kopeks,
description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)',
create_transaction=True,
)
logger.info('💰 Дни конвертированы в баланс для user_id', user_id=user.id)
return None
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
# Бонусный трафик — use provided subscription or fallback
if not subscription:
if settings.is_multi_tariff_enabled():
# Multi-tariff: нельзя выбрать произвольную подписку, начисляем на баланс
await add_user_balance(
db,
user,
prize.prize_value_kopeks,
description=f'Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс, мульти-тариф)',
create_transaction=True,
)
logger.info(
'Мульти-тариф: трафик конвертирован в баланс (подписка не указана)',
prize_value=prize.prize_value,
user_id=user.id,
)
return None
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.traffic_limit_gb > 0:
subscription.traffic_limit_gb += prize.prize_value
subscription.updated_at = datetime.now(UTC)
logger.info('📊 Начислено трафика user_id', prize_value=prize.prize_value, user_id=user.id)
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info('✅ Трафик синхронизирован с RemnaWave для user_id', user_id=user.id)
except Exception as e:
logger.error('⚠️ Ошибка синхронизации трафика с RemnaWave', error=e)
else:
# Если безлимит или нет подписки - на баланс
await add_user_balance(
db,
user,
prize.prize_value_kopeks,
description=f'Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)',
create_transaction=True,
)
return None
if prize_type == WheelPrizeType.PROMOCODE.value:
# Генерация промокода
promocode = await self._generate_prize_promocode(db, user, prize, config)
logger.info('🎟️ Сгенерирован промокод для user_id', code=promocode.code, user_id=user.id)
return promocode.code
return None
async def _generate_prize_promocode(
self, db: AsyncSession, user: User, prize: WheelPrize, config: WheelConfig
) -> PromoCode:
"""Сгенерировать уникальный промокод для приза."""
# Генерируем уникальный код
code = f'{config.promo_prefix}{secrets.token_hex(4).upper()}'
# Определяем тип промокода
if prize.promo_subscription_days > 0:
promo_type = PromoCodeType.SUBSCRIPTION_DAYS.value
else:
promo_type = PromoCodeType.BALANCE.value
promocode = PromoCode(
code=code,
type=promo_type,
balance_bonus_kopeks=prize.promo_balance_bonus_kopeks,
subscription_days=prize.promo_subscription_days,
max_uses=1,
valid_until=datetime.now(UTC) + timedelta(days=config.promo_validity_days),
is_active=True,
created_by=user.id,
)
db.add(promocode)
await db.flush()
return promocode
async def spin(
self, db: AsyncSession, user: User, payment_type: str, *, subscription_id: int | None = None
) -> SpinResult:
"""
Выполнить спин колеса.
Шаги:
1. Проверить доступность
2. Обработать оплату
3. Рассчитать вероятности и выбрать приз
4. Применить приз
5. Создать запись WheelSpin
6. Вернуть результат
"""
try:
# 1. Проверяем доступность
availability = await self.check_availability(db, user)
if not availability.can_spin:
return SpinResult(
success=False,
error=availability.reason,
message=self._get_error_message(availability.reason),
)
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
return SpinResult(
success=False,
error='no_prizes',
message='Призы не настроены',
)
# Resolve target subscription for days payment and prize application
target_subscription = None
if subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
target_subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
if not target_subscription or not target_subscription.is_active:
return SpinResult(
success=False,
error='invalid_subscription',
message='Подписка не найдена или неактивна',
)
elif not settings.is_multi_tariff_enabled():
# Single-tariff: auto-resolve
target_subscription = await get_subscription_by_user_id(db, user.id)
# Multi-tariff guard: subscription_id is required for days payment
if (
settings.is_multi_tariff_enabled()
and not target_subscription
and payment_type == WheelSpinPaymentType.SUBSCRIPTION_DAYS.value
):
return SpinResult(
success=False,
error='subscription_required',
message='Выберите подписку для оплаты днями',
)
# 2. Обрабатываем оплату
if payment_type == WheelSpinPaymentType.TELEGRAM_STARS.value:
if not availability.can_pay_stars:
return SpinResult(
success=False,
error='cannot_pay_stars',
message='Оплата Stars недоступна',
)
payment_amount = config.spin_cost_stars
payment_value_kopeks = await self._process_stars_payment(db, user, config)
elif payment_type == WheelSpinPaymentType.SUBSCRIPTION_DAYS.value:
if not availability.can_pay_days:
return SpinResult(
success=False,
error='cannot_pay_days',
message='Оплата днями подписки недоступна',
)
payment_amount = config.spin_cost_days
payment_value_kopeks = await self._process_days_payment(db, user, config, target_subscription)
else:
return SpinResult(
success=False,
error='invalid_payment_type',
message='Неверный способ оплаты',
)
# 3. Рассчитываем вероятности и выбираем приз
prizes_with_probs = self.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
selected_prize = self._select_prize(prizes_with_probs)
# 4. Рассчитываем угол для анимации
rotation = self._calculate_rotation(prizes, selected_prize)
# 5. Применяем приз
generated_promocode = await self._apply_prize(db, user, selected_prize, config, target_subscription)
promocode_id = None
if generated_promocode:
# Получаем ID промокода
from sqlalchemy import text
result = await db.execute(
text('SELECT id FROM promocodes WHERE code = :code'), {'code': generated_promocode}
)
row = result.fetchone()
if row:
promocode_id = row[0]
# 6. Создаем запись спина
await create_wheel_spin(
db=db,
user_id=user.id,
prize_id=selected_prize.id,
payment_type=payment_type,
payment_amount=payment_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
prize_value_kopeks=selected_prize.prize_value_kopeks,
generated_promocode_id=promocode_id,
is_applied=True,
)
await db.commit()
# 7. Формируем результат
message = self._get_prize_message(selected_prize, generated_promocode)
return SpinResult(
success=True,
prize_id=selected_prize.id,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
emoji=selected_prize.emoji,
color=selected_prize.color,
rotation_degrees=rotation,
message=message,
promocode=generated_promocode,
)
except ValueError as e:
await db.rollback()
return SpinResult(
success=False,
error='payment_error',
message=str(e),
)
except Exception as e:
await db.rollback()
logger.exception('Ошибка спина колеса для user_id', user_id=user.id, error=e)
return SpinResult(
success=False,
error='internal_error',
message='Произошла ошибка, попробуйте позже',
)
def _get_error_message(self, reason: str | None) -> str:
"""Получить человекочитаемое сообщение об ошибке."""
messages = {
'wheel_disabled': 'Колесо удачи временно недоступно',
'daily_limit_reached': 'Вы достигли лимита спинов на сегодня',
'no_payment_method_available': 'Нет доступных способов оплаты',
'no_prizes_configured': 'Призы еще не настроены',
'insufficient_balance': 'Недостаточно средств на балансе. Пополните баланс для оплаты спина.',
}
return messages.get(reason, 'Произошла ошибка')
def _get_prize_message(self, prize: WheelPrize, promocode: str | None) -> str:
"""Сформировать сообщение о выигрыше."""
prize_type = prize.prize_type
if prize_type == WheelPrizeType.NOTHING.value:
return 'К сожалению, в этот раз не повезло. Попробуйте еще!'
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
return f'Поздравляем! Вы выиграли {prize.prize_value / 100:.0f}₽ на баланс!'
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
days_word = self._pluralize_days(prize.prize_value)
return f'Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!'
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
return f'Поздравляем! Вы выиграли {prize.prize_value}GB трафика!'
if prize_type == WheelPrizeType.PROMOCODE.value:
return f'Поздравляем! Ваш промокод: {promocode}'
return 'Поздравляем с выигрышем!'
def _pluralize_days(self, n: int) -> str:
"""Склонение слова 'день'."""
if 11 <= n % 100 <= 19:
return 'дней'
if n % 10 == 1:
return 'день'
if 2 <= n % 10 <= 4:
return 'дня'
return 'дней'
async def get_statistics(
self, db: AsyncSession, date_from: datetime | None = None, date_to: datetime | None = None
) -> dict[str, Any]:
"""Получить статистику колеса."""
return await get_wheel_statistics(db, date_from, date_to)
# Глобальный экземпляр сервиса
wheel_service = FortuneWheelService()