diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 20b94a8..d54eed6 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -36,7 +36,7 @@ - «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке. - «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`. -- Кассир может добавлять счета через `/cashier`; все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода. +- Кассир добавляет счета через `/cashier` только в формате `КАРТА СЧЁТ`; карта обязательна в каждой строке. Все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода. - Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов. - Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет. @@ -44,6 +44,8 @@ Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи. +Для восстановления связи ранее добавленного счёта в открытом розыгрыше отправьте его повторно с правильной картой. Бот привяжет существующее участие без создания второго билета. Перепривязка участия другого владельца отклоняется. Через кассу добавление без карты запрещено. + ## Проведение и выдача призов 1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении. diff --git a/docs/CASHIER_GUIDE.md b/docs/CASHIER_GUIDE.md index 3c6bcf7..f5cc301 100644 --- a/docs/CASHIER_GUIDE.md +++ b/docs/CASHIER_GUIDE.md @@ -28,21 +28,23 @@ ## Добавление счетов в розыгрыш 1. `/cashier` → выберите нужное название открытого розыгрыша. -2. Отправьте счета, каждый с новой строки: +2. Отправьте клубную карту и счёт клиента, по одной паре на строку. Карта обязательна для каждого счёта, включая уже известные боту: ```text - 11-22-33-44-55-66-77 - 88-99-00-11-22-33-44 + 0007 11-22-33-44-55-66-77 + 0008 88-99-00-11-22-33-44 ``` 3. За один ввод допускается до 1000 распознанных счетов. -4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия. +4. Проверьте количества «Добавлено», «Привязано существующих участий», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия. -Чтобы сразу привязать новый счёт клиенту, отправьте строку `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору. +Карта должна быть известна боту. Создание нового счёта и добавление участия сохраняются вместе. Ведущие нули карты сохраняются. Если хотя бы одна строка не содержит карты, весь список возвращается на исправление без добавления его строк; отправьте исправленный список в том же диалоге. + +Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору. Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш. -Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю. +Если счёт раньше попал в открытый розыгрыш без клиента, повторите ввод с правильной картой. Бот восстановит связь существующего участия с клиентом, сохранив номер билета и время добавления. Это позволит клиенту видеть счёт, получать уведомление о выигрыше и подтверждать выдачу своим кодом. Владелец уже привязанного участия не заменяется. ## Исправление счёта diff --git a/src/handlers/account_handlers.py b/src/handlers/account_handlers.py index 6bed61f..9d9251f 100644 --- a/src/handlers/account_handlers.py +++ b/src/handlers/account_handlers.py @@ -251,6 +251,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): await state.clear() text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n" text += f"✅ Добавлено: {results['added']}\n" + if results['linked']: + text += f"✅ Привязано существующих участий: {results['linked']}\n" text += f"⚠️ Пропущено: {results['skipped']}\n\n" if results['details']: diff --git a/src/handlers/account_services.py b/src/handlers/account_services.py index 829646c..252b54a 100644 --- a/src/handlers/account_services.py +++ b/src/handlers/account_services.py @@ -1,5 +1,5 @@ """ -Сервис для работы с участием счетов в розыгрышах (без привязки к пользователям) +Сервис участия счетов: привязка к клиентам и проверки открытого розыгрыша. """ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, func @@ -7,6 +7,7 @@ from ..core.models import Account, Lottery, Participation, User, Winner from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern from typing import List, Optional, Dict, Any from ..core.transactions import lock_open_lottery +from ..utils.account_input import card_account_entry class AccountParticipationService: @@ -16,15 +17,22 @@ class AccountParticipationService: async def add_account_to_lottery( session: AsyncSession, lottery_id: int, - account_number: str + account_number: str, + *, require_card: bool = False ) -> Dict[str, Any]: """ Добавить счет в розыгрыш. Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ" + В кассе require_card=True запрещает участие без явно указанной карты. Returns: Dict с ключами: success, message, account_number """ + if require_card: + try: + account_number = card_account_entry(account_number) + except ValueError as error: + return {"success": False, "message": str(error), "account_number": account_number} # Разделяем по пробелу если есть номер карты parts = account_number.split() if len(parts) == 2: @@ -69,7 +77,8 @@ class AccountParticipationService: Participation.account_number == formatted_account ) ) - if existing.scalar_one_or_none(): + existing = existing.scalar_one_or_none() + if existing and not card_number: await session.rollback() card_info = f" (карта: {card_number})" if card_number else "" return { @@ -100,6 +109,21 @@ class AccountParticipationService: if card_number and (not user or user.club_card_number != card_number): await session.rollback() return {"success": False, "message": "Карта не соответствует владельцу счета", "account_number": formatted_account} + + if existing: + if user and (existing.user_id not in (None, user.id) or existing.account_id not in (None, account_record.id)): + await session.rollback() + return {"success": False, "message": "Участие связано с другим владельцем. Обратитесь к администратору.", + "account_number": formatted_account} + if user and (existing.user_id is None or existing.account_id is None): + existing.user_id = user.id + existing.account_id = account_record.id + await session.commit() + return {"success": True, "linked": True, "account_number": formatted_account, + "message": f"Счет {formatted_account} привязан к карте {card_number}; новое участие не создавалось"} + await session.rollback() + return {"success": False, "message": f"Счет {formatted_account} уже участвует в розыгрыше", + "account_number": formatted_account} # Добавляем участие с полными данными participation = Participation( @@ -122,26 +146,40 @@ class AccountParticipationService: async def add_accounts_bulk( session: AsyncSession, lottery_id: int, - account_numbers: List[str] + account_numbers: List[str], + *, require_card: bool = False ) -> Dict[str, Any]: """ Массовое добавление счетов в розыгрыш """ results = { "added": 0, + "linked": 0, "skipped": 0, "errors": [], "details": [], "added_accounts": [], "skipped_accounts": [] } + + if require_card: + try: + account_numbers = [card_account_entry(account) for account in account_numbers] + except ValueError as error: + results["skipped"] = len(account_numbers) + results["skipped_accounts"] = list(account_numbers) + results["errors"].append(str(error)) + return results for account in account_numbers: result = await AccountParticipationService.add_account_to_lottery( - session, lottery_id, account + session, lottery_id, account, require_card=require_card ) - if result["success"]: + if result.get("linked"): + results["linked"] += 1 + results["details"].append(f"✅ {result['message']}") + elif result["success"]: results["added"] += 1 results["added_accounts"].append(result["account_number"]) results["details"].append(f"✅ {result['account_number']}") diff --git a/src/handlers/admin_account_handlers.py b/src/handlers/admin_account_handlers.py index 1a0c612..ae150b1 100644 --- a/src/handlers/admin_account_handlers.py +++ b/src/handlers/admin_account_handlers.py @@ -359,7 +359,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): lottery_title = lottery.title from .account_services import AccountParticipationService - result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts]) + entries = [f"{a.get('club_card') or ''} {a['account_number']}".strip() for a in accounts] + result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, entries, require_card=True) success_count = result['added'] errors = result['errors'] @@ -368,6 +369,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): if success_count: text += f"✅ Добавлено счетов: {success_count}\n\n" + if result['linked']: + text += f"✅ Привязано существующих участий: {result['linked']}\n\n" if errors: text += f"⚠️ Ошибки: {len(errors)}\n" diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index a3340fb..7dade42 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -2056,6 +2056,8 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext): text = f"🏦 Результат массового добавления по счетам\n\n" text += f"🎯 Розыгрыш: {lottery.title}\n\n" text += f"✅ Добавлено: {results['added']}\n" + if results['linked']: + text += f"✅ Привязано существующих участий: {results['linked']}\n" text += f"⚠️ Уже участвуют: {results['skipped']}\n" text += f"🚫 Неверных форматов: {len(results['invalid_accounts'])}\n" text += f"❌ Ошибок: {len(results['errors'])}\n\n" diff --git a/src/handlers/cashier_handlers.py b/src/handlers/cashier_handlers.py index a0aa9d3..bd5507c 100644 --- a/src/handlers/cashier_handlers.py +++ b/src/handlers/cashier_handlers.py @@ -12,7 +12,7 @@ from src.core.database import async_session_maker from src.core.services import LotteryService from src.handlers.account_services import AccountParticipationService from src.middlewares.access import AccessMiddleware -from src.utils.account_input import parse_account_records +from src.utils.account_input import parse_cashier_records cashier_router = Router(name="cashier") cashier_router.message.middleware(AccessMiddleware(allow_cashier=True)) @@ -33,7 +33,7 @@ async def cashier_menu(message: Message, state: FSMContext): for lottery in lotteries] buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")]) await message.answer( - "💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n" + "💼 Касса\n\nВыберите розыгрыш. Для каждого счёта обязательна клубная карта клиента.\n\n" "/add_account КАРТА СЧЕТ — привязать счет клиенту\n" "/remove_account СЧЕТ — деактивировать счет\n" "/verify_winner КОД ID_РОЗЫГРЫША — подтвердить выдачу приза\n" @@ -62,21 +62,27 @@ async def choose_draw(callback: CallbackQuery, state: FSMContext): await state.update_data(lottery_id=lottery_id) await state.set_state(CashierStates.accounts) await callback.answer() - await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.") + await callback.message.answer( + "Отправьте клубную карту и счёт клиента, по одной паре на строку:\n" + "0007 11-22-33-44-55-66-77\n" + "0008 88-99-00-11-22-33-44\n\n" + "Карта обязательна для каждого счёта. /cancel — отмена." + ) @cashier_router.message(CashierStates.accounts, F.text, NotCommand()) async def add_accounts(message: Message, state: FSMContext): - accounts, errors = parse_account_records(message.text) + accounts, errors = parse_cashier_records(message.text) if errors: await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10])) return if not accounts or len(accounts) > 1000: - await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.") + await message.answer("Введите от 1 до 1000 строк в формате КАРТА СЧЁТ: 0007 11-22-33-44-55-66-77.") return data = await state.get_data() async with async_session_maker() as session: - result = await AccountParticipationService.add_accounts_bulk(session, data["lottery_id"], accounts) + result = await AccountParticipationService.add_accounts_bulk(session, data["lottery_id"], accounts, require_card=True) await state.clear() await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n" + + (f"Привязано существующих участий: {result['linked']}.\n" if result['linked'] else "") + "\n".join(result["errors"][:10])) diff --git a/src/utils/account_input.py b/src/utils/account_input.py index 1d4b2e8..35a44c2 100644 --- a/src/utils/account_input.py +++ b/src/utils/account_input.py @@ -5,6 +5,38 @@ ACCOUNT = re.compile(r"(?