From 3930564f9bd5235459cf1019bd776fdcb9319fb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:49:16 +0300 Subject: [PATCH] Fix duplicate campaign registration assignment --- app/database/crud/campaign.py | 130 ++++++++++++++++++++++++++++++- app/handlers/admin/campaigns.py | 30 +++++++ app/handlers/admin/users.py | 65 ++++++++++++++-- app/handlers/referral.py | 11 ++- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/services/user_service.py | 7 +- app/utils/user_utils.py | 13 +++- locales/en.json | 1 + locales/ru.json | 1 + 10 files changed, 245 insertions(+), 15 deletions(-) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 40313434..137b6c09 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,6 +9,10 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, + Transaction, + TransactionType, + Subscription, + User, ) logger = logging.getLogger(__name__) @@ -197,7 +201,7 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: - result = await db.execute( + aggregate_result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), func.coalesce( @@ -206,7 +210,7 @@ async def get_campaign_statistics( func.max(AdvertisingCampaignRegistration.created_at), ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) - count, total_balance, last_registration = result.one() + registrations_count, total_balance, last_registration = aggregate_result.one() subscription_count_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( @@ -217,14 +221,134 @@ async def get_campaign_statistics( ) ) + campaign_users_subquery = ( + select(AdvertisingCampaignRegistration.user_id) + .where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + + revenue_filter = and_( + Transaction.user_id.in_(campaign_users_subquery), + Transaction.is_completed.is_(True), + Transaction.amount_kopeks > 0, + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), + ) + + revenue_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + revenue_filter + ) + ) + revenue_kopeks = revenue_result.scalar() or 0 + + transactions_count_result = await db.execute( + select(func.count(Transaction.id)).where(revenue_filter) + ) + transactions_count = transactions_count_result.scalar() or 0 + + paying_users_result = await db.execute( + select(func.count(func.distinct(Transaction.user_id))).where( + revenue_filter + ) + ) + paying_users = paying_users_result.scalar() or 0 + + trial_users_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + and_( + Subscription.user_id.in_(campaign_users_subquery), + Subscription.is_trial.is_(True), + ) + ) + ) + trial_users = trial_users_result.scalar() or 0 + + paid_flag_users_result = await db.execute( + select(func.count(func.distinct(User.id))).where( + and_( + User.id.in_(campaign_users_subquery), + User.has_had_paid_subscription.is_(True), + ) + ) + ) + paid_flag_users = paid_flag_users_result.scalar() or 0 + + registrations = registrations_count or 0 + conversion_base = registrations if registrations > 0 else None + effective_paying_users = max(paying_users, paid_flag_users or 0) + + conversion_rate = ( + round((effective_paying_users / registrations) * 100, 1) + if conversion_base + else 0.0 + ) + + trial_conversion_rate = ( + round((effective_paying_users / trial_users) * 100, 1) + if trial_users + else 0.0 + ) + + avg_revenue_per_user = ( + int(round(revenue_kopeks / registrations)) if registrations else 0 + ) + avg_revenue_per_paying_user = ( + int(round(revenue_kopeks / effective_paying_users)) + if effective_paying_users + else 0 + ) + return { - "registrations": count or 0, + "registrations": registrations, "balance_issued": total_balance or 0, "subscription_issued": subscription_count_result.scalar() or 0, "last_registration": last_registration, + "revenue_kopeks": revenue_kopeks, + "transactions_count": transactions_count, + "paying_users": effective_paying_users, + "trial_users": trial_users, + "conversion_rate": conversion_rate, + "trial_conversion_rate": trial_conversion_rate, + "avg_revenue_per_user": avg_revenue_per_user, + "avg_revenue_per_paying_user": avg_revenue_per_paying_user, } +async def get_campaign_registration_by_user( + db: AsyncSession, + user_id: int, +) -> Optional[AdvertisingCampaignRegistration]: + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .order_by(AdvertisingCampaignRegistration.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_campaign_registrations_for_users( + db: AsyncSession, + user_ids: List[int], +) -> Dict[int, AdvertisingCampaignRegistration]: + if not user_ids: + return {} + + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id.in_(user_ids)) + ) + + registrations = result.scalars().all() + return {registration.user_id: registration for registration in registrations} + + async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: total = await get_campaigns_count(db) active = await get_campaigns_count(db, is_active=True) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 22ff8aac..3cc8f0c2 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,6 +333,24 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") + text.append(f"• Транзакций: {stats['transactions_count']}") + text.append( + f"• Доход кампании: {texts.format_price(stats['revenue_kopeks'])}" + ) + text.append( + f"• Платящих пользователей: {stats['paying_users']}" + ) + text.append(f"• Взяли триал: {stats['trial_users']}") + text.append(f"• Конверсия в оплату: {stats['conversion_rate']}%") + text.append( + f"• Конверсия триала: {stats['trial_conversion_rate']}%" + ) + text.append( + f"• Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" + ) + text.append( + f"• Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" + ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" @@ -1176,6 +1194,18 @@ async def show_campaign_stats( text.append(f"Регистраций: {stats['registrations']}") text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") text.append(f"Выдано подписок: {stats['subscription_issued']}") + text.append(f"Транзакций: {stats['transactions_count']}") + text.append(f"Доход кампании: {texts.format_price(stats['revenue_kopeks'])}") + text.append(f"Платящих пользователей: {stats['paying_users']}") + text.append(f"Взяли триал: {stats['trial_users']}") + text.append(f"Конверсия в оплату: {stats['conversion_rate']}%") + text.append(f"Конверсия триала: {stats['trial_conversion_rate']}%") + text.append( + f"Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" + ) + text.append( + f"Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" + ) if stats["last_registration"]: text.append( f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 620e4001..8f7e640a 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -17,6 +17,7 @@ from app.keyboards.admin import ( from app.localization.texts import get_texts from app.services.user_service import UserService from app.database.crud.promo_group import get_promo_groups_with_counts +from app.database.crud.campaign import get_campaign_registrations_for_users from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService @@ -427,6 +428,7 @@ async def _render_user_subscription_overview( user = profile["user"] subscription = profile["subscription"] + campaign_registration = profile.get("campaign_registration") text = "📱 Подписка и настройки пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1236,16 +1238,49 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - + + registration_lines = [] + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: - text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" + registration_lines.append( + f"• Пришел по реферальной ссылке от {referrer.full_name}" + ) else: - text += f"• Пришел по реферальной ссылке (реферер не найден)\n" - else: - text += f"• Прямая регистрация\n" - + registration_lines.append( + "• Пришел по реферальной ссылке (реферер не найден)" + ) + + if campaign_registration and campaign_registration.campaign: + campaign = campaign_registration.campaign + registration_lines.append( + f"• Пришел через рекламную кампанию {campaign.name} (не прямая регистрация)" + ) + registration_lines.append( + f"• Участие в кампании: {format_datetime(campaign_registration.created_at)}" + ) + + if campaign_registration.bonus_type == "balance": + registration_lines.append( + f"• Бонус кампании: {settings.format_price(campaign_registration.balance_bonus_kopeks)} на баланс" + ) + elif campaign_registration.bonus_type == "subscription": + bonus_parts = [] + if campaign_registration.subscription_duration_days: + bonus_parts.append( + f"{campaign_registration.subscription_duration_days} дн." + ) + registration_lines.append( + "• Бонус кампании: Подписка" + + (f" ({', '.join(bonus_parts)})" if bonus_parts else "") + ) + + if not registration_lines: + registration_lines.append("• Прямая регистрация") + + text += "\n".join(registration_lines) + "\n" + text += f"• Реферальный код: {user.referral_code}\n\n" if referral_stats['invited_count'] > 0: @@ -1257,11 +1292,15 @@ async def show_user_statistics( if referral_stats['referrals_detail']: text += f"\nДетали по рефералам:\n" - for detail in referral_stats['referrals_detail'][:5]: + for detail in referral_stats['referrals_detail'][:5]: referral_name = detail['referral_name'] earned = settings.format_price(detail['total_earned_kopeks']) status = "🟢" if detail['is_active'] else "🔴" text += f"• {status} {referral_name}: {earned}\n" + if detail.get('campaign_name'): + text += ( + f" 📣 Кампания: {detail['campaign_name']} (не прямая регистрация)\n" + ) if len(referral_stats['referrals_detail']) > 5: text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n" @@ -1292,6 +1331,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referrals_result = await db.execute(referrals_query) referrals = referrals_result.scalars().all() + + referral_ids = [referral.id for referral in referrals] + campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) earnings_by_referral = {} all_earnings = await get_referral_earnings_by_user(db, user_id) @@ -1316,6 +1358,11 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referral.subscription.end_date > current_time ) + registration = campaign_registrations.get(referral.id) + campaign_name = None + if registration and registration.campaign: + campaign_name = registration.campaign.name + referrals_detail.append({ 'referral_id': referral.id, 'referral_name': referral.full_name, @@ -1323,7 +1370,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: 'total_earned_kopeks': earned, 'is_active': is_active, 'registration_date': referral.created_at, - 'has_subscription': bool(referral.subscription) + 'has_subscription': bool(referral.subscription), + 'campaign_name': campaign_name, + 'registration_source': 'campaign' if campaign_name else 'referral' }) referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index fe3322a8..d1f94a06 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -297,7 +297,16 @@ async def show_detailed_referral_list( "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO", " 🕐 Активность: давно", ) + "\n" - + + if ( + referral.get('registration_source') == 'campaign' + and referral.get('campaign_name') + ): + text += texts.t( + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN", + " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", + ).format(name=referral['campaign_name']) + "\n" + text += "\n" keyboard = [] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 49417771..655c0662 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -368,6 +368,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 1dc7de10..037ea386 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -368,6 +368,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", diff --git a/app/services/user_service.py b/app/services/user_service.py index dedd672a..e7a884d0 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -10,6 +10,7 @@ from app.database.crud.user import ( get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user ) +from app.database.crud.campaign import get_campaign_registration_by_user from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count from app.database.crud.subscription import get_subscription_by_user_id @@ -91,13 +92,15 @@ class UserService: subscription = await get_subscription_by_user_id(db, user_id) transactions_count = await get_user_transactions_count(db, user_id) - + campaign_registration = await get_campaign_registration_by_user(db, user_id) + return { "user": user, "subscription": subscription, "transactions_count": transactions_count, "is_admin": settings.is_admin(user.telegram_id), - "registration_days": (datetime.utcnow() - user.created_at).days + "registration_days": (datetime.utcnow() - user.created_at).days, + "campaign_registration": campaign_registration, } except Exception as e: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 8292f5e1..f663b866 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import User, ReferralEarning, Transaction, TransactionType +from app.database.crud.campaign import get_campaign_registrations_for_users logger = logging.getLogger(__name__) @@ -164,6 +165,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int .limit(limit) ) referrals = referrals_result.scalars().all() + + referral_ids = [referral.id for referral in referrals] + campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) total_count_result = await db.execute( select(func.count(User.id)).where(User.referred_by_id == user_id) @@ -201,6 +205,11 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int if referral.last_activity: days_since_activity = (datetime.utcnow() - referral.last_activity).days + registration = campaign_registrations.get(referral.id) + campaign_name = None + if registration and registration.campaign: + campaign_name = registration.campaign.name + detailed_referrals.append({ 'id': referral.id, 'telegram_id': referral.telegram_id, @@ -214,7 +223,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int 'topups_count': topups_count, 'days_since_registration': days_since_registration, 'days_since_activity': days_since_activity, - 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive' + 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive', + 'campaign_name': campaign_name, + 'registration_source': 'campaign' if campaign_name else 'referral' }) return { diff --git a/locales/en.json b/locales/en.json index e72fd5e4..0cd0cb46 100644 --- a/locales/en.json +++ b/locales/en.json @@ -414,6 +414,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/locales/ru.json b/locales/ru.json index 2c6c839d..f41d8cc3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -414,6 +414,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов",