diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index d63c3107..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -59,15 +59,23 @@ async def create_promo_group( traffic_discount_percent: int, device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) + auto_assign_total_spent_kopeks = ( + max(0, auto_assign_total_spent_kopeks) + if auto_assign_total_spent_kopeks is not None + else None + ) + promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, + auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, is_default=False, ) @@ -76,12 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, + (auto_assign_total_spent_kopeks or 0) / 100, ) return promo_group @@ -96,6 +105,7 @@ async def update_promo_group( traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -108,6 +118,8 @@ async def update_promo_group( if period_discounts is not None: normalized_period_discounts = _normalize_period_discounts(period_discounts) group.period_discounts = normalized_period_discounts or None + if auto_assign_total_spent_kopeks is not None: + group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) await db.commit() await db.refresh(group) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..11a2827e 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -37,6 +37,20 @@ async def create_transaction( await db.refresh(transaction) logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + + try: + from app.services.promo_group_assignment import ( + maybe_assign_promo_group_by_total_spent, + ) + + await maybe_assign_promo_group_by_total_spent(db, user_id) + except Exception as exc: + logger.debug( + "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + user_id, + exc, + ) + return transaction @@ -98,8 +112,26 @@ async def get_user_transactions_count( return result.scalar() +async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.user_id == user_id, + Transaction.is_completed.is_(True), + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), + ) + ) + ) + return int(result.scalar_one()) + + async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: - + transaction.is_completed = True transaction.completed_at = datetime.utcnow() diff --git a/app/database/models.py b/app/database/models.py index 9cdeaa86..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -271,6 +271,7 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) + auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -335,7 +336,7 @@ class PromoGroup(Base): class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -358,6 +359,7 @@ class User(Base): transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) + auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ce270f13..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -721,6 +721,39 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка promo_groups.period_discounts") + auto_assign_column_exists = await check_column_exists( + "promo_groups", "auto_assign_total_spent_kopeks" + ) + + if not auto_assign_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -736,6 +769,37 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.promo_group_id") + auto_promo_flag_exists = await check_column_exists( + "users", "auto_promo_group_assigned" + ) + + if not auto_promo_flag_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}" + ) + return False + + logger.info("Добавлена колонка users.auto_promo_group_assigned") + index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -1585,6 +1649,8 @@ async def check_migration_status(): "promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, + "promo_groups_auto_assign_column": False, + "users_auto_promo_group_assigned_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1598,6 +1664,8 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') + status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1631,6 +1699,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 0546550e..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,6 +1,6 @@ import logging -import logging -from typing import Dict, Optional +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext @@ -161,6 +161,214 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) +def _format_rubles(amount_kopeks: int) -> str: + if amount_kopeks <= 0: + return "0" + + rubles = Decimal(amount_kopeks) / Decimal(100) + if rubles == rubles.to_integral_value(): + formatted = f"{rubles:,.0f}" + else: + formatted = f"{rubles:,.2f}" + + return formatted.replace(",", " ") + + +def _format_auto_assign_line(texts, group: PromoGroup) -> str: + threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 + + if threshold <= 0: + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", + "Автовыдача по суммарным тратам: отключена", + ) + + amount = _format_rubles(threshold) + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", + "Автовыдача по суммарным тратам: от {amount} ₽", + ).format(amount=amount) + + +def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: + if not value_kopeks or value_kopeks <= 0: + return "0" + + rubles = Decimal(value_kopeks) / Decimal(100) + quantized = ( + rubles.quantize(Decimal("1")) + if rubles == rubles.to_integral_value() + else rubles.quantize(Decimal("0.01")) + ) + return str(quantized) + + +def _parse_auto_assign_threshold_input(value: str) -> int: + cleaned = (value or "").strip() + + if not cleaned or cleaned in {"0", "-", "off", "нет"}: + return 0 + + normalized = cleaned.replace(" ", "").replace(",", ".") + + try: + amount = Decimal(normalized) + except InvalidOperation: + raise ValueError + + if amount < 0: + raise ValueError + + kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + return max(0, kopeks) + + +async def _prompt_for_auto_assign_threshold( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + +def _build_edit_menu_content( + texts, + group: PromoGroup, + language: str, +) -> Tuple[str, types.InlineKeyboardMarkup]: + header = texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE", + "✏️ Настройки промогруппы «{name}»", + ).format(name=group.name) + + lines = [ + header, + _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), + ] + + period_lines = _format_period_discounts_lines(texts, group, language) + lines.extend(period_lines) + + lines.append( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT", + "Выберите параметр для изменения:", + ) + ) + + text = "\n".join(line for line in lines if line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME", + "✏️ Изменить название", + ), + callback_data=f"promo_group_edit_field_{group.id}_name", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC", + "🌐 Скидка на трафик", + ), + callback_data=f"promo_group_edit_field_{group.id}_traffic", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS", + "🖥 Скидка на серверы", + ), + callback_data=f"promo_group_edit_field_{group.id}_servers", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES", + "📱 Скидка на устройства", + ), + callback_data=f"promo_group_edit_field_{group.id}_devices", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS", + "⏳ Скидки по периодам", + ), + callback_data=f"promo_group_edit_field_{group.id}_periods", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN", + "🤖 Автовыдача по тратам", + ), + callback_data=f"promo_group_edit_field_{group.id}_auto", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_manage_{group.id}", + ) + ], + ] + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return text, keyboard + + +def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup: + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_edit_{group_id}", + ) + ] + ] + ) + + +async def _send_edit_menu_after_update( + message: types.Message, + texts, + group: PromoGroup, + language: str, + success_message: Optional[str] = None, +): + menu_text, keyboard = _build_edit_menu_content(texts, group, language) + parts = [part for part in [success_message, menu_text] if part] + + await message.answer( + "\n\n".join(parts), + reply_markup=keyboard, + parse_mode="HTML", + ) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -191,6 +399,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", @@ -265,6 +474,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", @@ -464,6 +674,39 @@ async def process_create_group_period_discounts( ) return + await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_auto_assign) + + await _prompt_for_auto_assign_threshold( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + ) + + +@admin_required +@error_handler +async def process_create_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + try: group = await create_promo_group( db, @@ -471,7 +714,8 @@ async def process_create_group_period_discounts( traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("new_group_period_discounts"), + auto_assign_total_spent_kopeks=auto_assign_kopeks, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -513,73 +757,190 @@ async def start_edit_promo_group( return texts = get_texts(db_user.language) - await state.set_state(AdminStates.editing_promo_group_name) await state.update_data(edit_group_id=group.id, language=db_user.language) + await state.set_state(AdminStates.editing_promo_group_menu) + text, keyboard = _build_edit_menu_content(texts, group, db_user.language) await callback.message.edit_text( - texts.t( - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", - "Введите новое название промогруппы (текущее: {name}):", - ).format(name=group.name), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")] - ] - ), + text, + reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() -async def process_edit_group_name(message: types.Message, state: FSMContext): +@admin_required +@error_handler +async def prompt_edit_promo_group_field( + callback: types.CallbackQuery, + db_user, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + if len(parts) < 6: + await callback.answer("❌ Неверная команда", show_alert=True) + return + + group_id = int(parts[4]) + field = parts[5] + + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return + + await state.update_data(edit_group_id=group.id, language=db_user.language) + + texts = get_texts(db_user.language) + reply_markup = _get_edit_prompt_keyboard(group.id, texts) + + if field == "name": + await state.set_state(AdminStates.editing_promo_group_name) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", + "Введите новое название промогруппы (текущее: {name}):", + ).format(name=group.name) + elif field == "traffic": + await state.set_state(AdminStates.editing_promo_group_traffic_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", + "Введите новую скидку на трафик (текущее значение: {current}%):", + ).format(current=group.traffic_discount_percent) + elif field == "servers": + await state.set_state(AdminStates.editing_promo_group_server_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", + "Введите новую скидку на серверы (текущее значение: {current}%):", + ).format(current=group.server_discount_percent) + elif field == "devices": + await state.set_state(AdminStates.editing_promo_group_device_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", + "Введите новую скидку на устройства (текущее значение: {current}%):", + ).format(current=group.device_discount_percent) + elif field == "periods": + await state.set_state(AdminStates.editing_promo_group_period_discount) + current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", + "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + ).format(current=_format_period_discounts_value(current_discounts)) + elif field == "auto": + await state.set_state(AdminStates.editing_promo_group_auto_assign) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + ).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks)) + else: + await callback.answer("❌ Неизвестный параметр", show_alert=True) + return + + await callback.message.edit_text(prompt, reply_markup=reply_markup) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_group_name( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + name = message.text.strip() if not name: - texts = get_texts((await state.get_data()).get("language", "ru")) await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) return - await state.update_data(edit_group_name=name) - await state.set_state(AdminStates.editing_promo_group_traffic_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, name=name) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", - "Введите новую скидку на трафик (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_traffic(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_traffic( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_traffic=value) - await state.set_state(AdminStates.editing_promo_group_server_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, traffic_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", - "Введите новую скидку на серверы (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_servers(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_servers( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_servers=value) - await state.set_state(AdminStates.editing_promo_group_device_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, server_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", - "Введите новую скидку на устройства (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -600,22 +961,21 @@ async def process_edit_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) + group = await get_promo_group_by_id(db, data.get("edit_group_id")) if not group: await message.answer("❌ Промогруппа не найдена") await state.clear() return - await state.update_data(edit_group_devices=devices_discount) - await state.set_state(AdminStates.editing_promo_group_period_discount) + group = await update_promo_group(db, group, device_discount_percent=devices_discount) + await state.set_state(AdminStates.editing_promo_group_menu) - current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) - await _prompt_for_period_discounts( + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", - "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - current_value=_format_period_discounts_value(current_discounts), + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -641,25 +1001,65 @@ async def process_edit_group_period_discounts( ) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) + group = await get_promo_group_by_id(db, data.get("edit_group_id")) if not group: await message.answer("❌ Промогруппа не найдена") await state.clear() return - await update_promo_group( - db, + group = await update_promo_group(db, group, period_discounts=period_discounts) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, group, - name=data["edit_group_name"], - traffic_discount_percent=data["edit_group_traffic"], - server_discount_percent=data["edit_group_servers"], - device_discount_percent=data["edit_group_devices"], - period_discounts=period_discounts, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) - await state.clear() - await message.answer( - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name) + +@admin_required +@error_handler +async def process_edit_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + auto_assign_total_spent_kopeks=auto_assign_kopeks, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -796,7 +1196,14 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create") - dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_")) + dp.callback_query.register( + prompt_edit_promo_group_field, + F.data.startswith("promo_group_edit_field_"), + ) + dp.callback_query.register( + start_edit_promo_group, + F.data.regexp(r"^promo_group_edit_\d+$"), + ) dp.callback_query.register( request_delete_promo_group, F.data.startswith("promo_group_delete_") @@ -828,6 +1235,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_auto_assign, + AdminStates.creating_promo_group_auto_assign, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -846,3 +1257,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_auto_assign, + AdminStates.editing_promo_group_auto_assign, + ) diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py new file mode 100644 index 00000000..a7437c3c --- /dev/null +++ b/app/services/promo_group_assignment.py @@ -0,0 +1,92 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.transaction import get_user_total_spent_kopeks +from app.database.models import PromoGroup, User + +logger = logging.getLogger(__name__) + + +async def _get_best_group_for_spending( + db: AsyncSession, + total_spent_kopeks: int, +) -> Optional[PromoGroup]: + if total_spent_kopeks <= 0: + return None + + result = await db.execute( + select(PromoGroup) + .where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None)) + .where(PromoGroup.auto_assign_total_spent_kopeks > 0) + .order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc()) + ) + groups = result.scalars().all() + + for group in groups: + threshold = group.auto_assign_total_spent_kopeks or 0 + if threshold and total_spent_kopeks >= threshold: + return group + + return None + + +async def maybe_assign_promo_group_by_total_spent( + db: AsyncSession, + user_id: int, +) -> Optional[PromoGroup]: + user = await db.get(User, user_id) + if not user: + logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) + return None + + if user.auto_promo_group_assigned: + logger.debug( + "Пользователь %s уже получал промогруппу автоматически, пропускаем", user.telegram_id + ) + return None + + total_spent = await get_user_total_spent_kopeks(db, user_id) + if total_spent <= 0: + return None + + target_group = await _get_best_group_for_spending(db, total_spent) + if not target_group: + return None + + try: + previous_group_id = user.promo_group_id + user.auto_promo_group_assigned = True + user.updated_at = datetime.utcnow() + + if target_group.id != previous_group_id: + user.promo_group_id = target_group.id + user.promo_group = target_group + logger.info( + "🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽", + user.telegram_id, + target_group.name, + total_spent / 100, + ) + else: + logger.info( + "🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение", + user.telegram_id, + target_group.name, + ) + + await db.commit() + await db.refresh(user) + + return target_group + except Exception as exc: + logger.error( + "Ошибка при автоматическом назначении промогруппы пользователю %s: %s", + user_id, + exc, + ) + await db.rollback() + return None diff --git a/app/states.py b/app/states.py index 0073a4d4..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -69,12 +69,15 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_auto_assign = State() + editing_promo_group_menu = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_auto_assign = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 419dbe95..318249be 100644 --- a/locales/en.json +++ b/locales/en.json @@ -231,11 +231,24 @@ "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/locales/ru.json b/locales/ru.json index e69b9d92..8a71c225 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -109,11 +109,24 @@ "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",