Require club cards in cashier enrollment and restore ticket owners
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
- «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке.
|
- «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке.
|
||||||
- «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`.
|
- «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`.
|
||||||
- Кассир может добавлять счета через `/cashier`; все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода.
|
- Кассир добавляет счета через `/cashier` только в формате `КАРТА СЧЁТ`; карта обязательна в каждой строке. Все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода.
|
||||||
- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
|
- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
|
||||||
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
|
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
|
|
||||||
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
|
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
|
||||||
|
|
||||||
|
Для восстановления связи ранее добавленного счёта в открытом розыгрыше отправьте его повторно с правильной картой. Бот привяжет существующее участие без создания второго билета. Перепривязка участия другого владельца отклоняется. Через кассу добавление без карты запрещено.
|
||||||
|
|
||||||
## Проведение и выдача призов
|
## Проведение и выдача призов
|
||||||
|
|
||||||
1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.
|
1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.
|
||||||
|
|||||||
@@ -28,21 +28,23 @@
|
|||||||
## Добавление счетов в розыгрыш
|
## Добавление счетов в розыгрыш
|
||||||
|
|
||||||
1. `/cashier` → выберите нужное название открытого розыгрыша.
|
1. `/cashier` → выберите нужное название открытого розыгрыша.
|
||||||
2. Отправьте счета, каждый с новой строки:
|
2. Отправьте клубную карту и счёт клиента, по одной паре на строку. Карта обязательна для каждого счёта, включая уже известные боту:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
11-22-33-44-55-66-77
|
0007 11-22-33-44-55-66-77
|
||||||
88-99-00-11-22-33-44
|
0008 88-99-00-11-22-33-44
|
||||||
```
|
```
|
||||||
|
|
||||||
3. За один ввод допускается до 1000 распознанных счетов.
|
3. За один ввод допускается до 1000 распознанных счетов.
|
||||||
4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
|
4. Проверьте количества «Добавлено», «Привязано существующих участий», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
|
||||||
|
|
||||||
Чтобы сразу привязать новый счёт клиенту, отправьте строку `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
|
Карта должна быть известна боту. Создание нового счёта и добавление участия сохраняются вместе. Ведущие нули карты сохраняются. Если хотя бы одна строка не содержит карты, весь список возвращается на исправление без добавления его строк; отправьте исправленный список в том же диалоге.
|
||||||
|
|
||||||
|
Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
|
||||||
|
|
||||||
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
|
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
|
||||||
|
|
||||||
Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю.
|
Если счёт раньше попал в открытый розыгрыш без клиента, повторите ввод с правильной картой. Бот восстановит связь существующего участия с клиентом, сохранив номер билета и время добавления. Это позволит клиенту видеть счёт, получать уведомление о выигрыше и подтверждать выдачу своим кодом. Владелец уже привязанного участия не заменяется.
|
||||||
|
|
||||||
## Исправление счёта
|
## Исправление счёта
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n"
|
text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n"
|
||||||
text += f"✅ Добавлено: {results['added']}\n"
|
text += f"✅ Добавлено: {results['added']}\n"
|
||||||
|
if results['linked']:
|
||||||
|
text += f"✅ Привязано существующих участий: {results['linked']}\n"
|
||||||
text += f"⚠️ Пропущено: {results['skipped']}\n\n"
|
text += f"⚠️ Пропущено: {results['skipped']}\n\n"
|
||||||
|
|
||||||
if results['details']:
|
if results['details']:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Сервис для работы с участием счетов в розыгрышах (без привязки к пользователям)
|
Сервис участия счетов: привязка к клиентам и проверки открытого розыгрыша.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete, func
|
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 ..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 typing import List, Optional, Dict, Any
|
||||||
from ..core.transactions import lock_open_lottery
|
from ..core.transactions import lock_open_lottery
|
||||||
|
from ..utils.account_input import card_account_entry
|
||||||
|
|
||||||
|
|
||||||
class AccountParticipationService:
|
class AccountParticipationService:
|
||||||
@@ -16,15 +17,22 @@ class AccountParticipationService:
|
|||||||
async def add_account_to_lottery(
|
async def add_account_to_lottery(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_number: str
|
account_number: str,
|
||||||
|
*, require_card: bool = False
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Добавить счет в розыгрыш.
|
Добавить счет в розыгрыш.
|
||||||
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
Поддерживает форматы: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||||
|
В кассе require_card=True запрещает участие без явно указанной карты.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict с ключами: success, message, account_number
|
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()
|
parts = account_number.split()
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
@@ -69,7 +77,8 @@ class AccountParticipationService:
|
|||||||
Participation.account_number == formatted_account
|
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()
|
await session.rollback()
|
||||||
card_info = f" (карта: {card_number})" if card_number else ""
|
card_info = f" (карта: {card_number})" if card_number else ""
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +110,21 @@ class AccountParticipationService:
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
return {"success": False, "message": "Карта не соответствует владельцу счета", "account_number": formatted_account}
|
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(
|
participation = Participation(
|
||||||
lottery_id=lottery_id,
|
lottery_id=lottery_id,
|
||||||
@@ -122,13 +146,15 @@ class AccountParticipationService:
|
|||||||
async def add_accounts_bulk(
|
async def add_accounts_bulk(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
lottery_id: int,
|
lottery_id: int,
|
||||||
account_numbers: List[str]
|
account_numbers: List[str],
|
||||||
|
*, require_card: bool = False
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Массовое добавление счетов в розыгрыш
|
Массовое добавление счетов в розыгрыш
|
||||||
"""
|
"""
|
||||||
results = {
|
results = {
|
||||||
"added": 0,
|
"added": 0,
|
||||||
|
"linked": 0,
|
||||||
"skipped": 0,
|
"skipped": 0,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"details": [],
|
"details": [],
|
||||||
@@ -136,12 +162,24 @@ class AccountParticipationService:
|
|||||||
"skipped_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:
|
for account in account_numbers:
|
||||||
result = await AccountParticipationService.add_account_to_lottery(
|
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"] += 1
|
||||||
results["added_accounts"].append(result["account_number"])
|
results["added_accounts"].append(result["account_number"])
|
||||||
results["details"].append(f"✅ {result['account_number']}")
|
results["details"].append(f"✅ {result['account_number']}")
|
||||||
|
|||||||
@@ -359,7 +359,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
lottery_title = lottery.title
|
lottery_title = lottery.title
|
||||||
from .account_services import AccountParticipationService
|
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']
|
success_count = result['added']
|
||||||
errors = result['errors']
|
errors = result['errors']
|
||||||
|
|
||||||
@@ -368,6 +369,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
if success_count:
|
if success_count:
|
||||||
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
||||||
|
if result['linked']:
|
||||||
|
text += f"✅ Привязано существующих участий: {result['linked']}\n\n"
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
text += f"⚠️ Ошибки: {len(errors)}\n"
|
text += f"⚠️ Ошибки: {len(errors)}\n"
|
||||||
|
|||||||
@@ -2056,6 +2056,8 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
|||||||
text = f"🏦 Результат массового добавления по счетам\n\n"
|
text = f"🏦 Результат массового добавления по счетам\n\n"
|
||||||
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
|
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
|
||||||
text += f"✅ Добавлено: {results['added']}\n"
|
text += f"✅ Добавлено: {results['added']}\n"
|
||||||
|
if results['linked']:
|
||||||
|
text += f"✅ Привязано существующих участий: {results['linked']}\n"
|
||||||
text += f"⚠️ Уже участвуют: {results['skipped']}\n"
|
text += f"⚠️ Уже участвуют: {results['skipped']}\n"
|
||||||
text += f"🚫 Неверных форматов: {len(results['invalid_accounts'])}\n"
|
text += f"🚫 Неверных форматов: {len(results['invalid_accounts'])}\n"
|
||||||
text += f"❌ Ошибок: {len(results['errors'])}\n\n"
|
text += f"❌ Ошибок: {len(results['errors'])}\n\n"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from src.core.database import async_session_maker
|
|||||||
from src.core.services import LotteryService
|
from src.core.services import LotteryService
|
||||||
from src.handlers.account_services import AccountParticipationService
|
from src.handlers.account_services import AccountParticipationService
|
||||||
from src.middlewares.access import AccessMiddleware
|
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 = Router(name="cashier")
|
||||||
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
||||||
@@ -33,7 +33,7 @@ async def cashier_menu(message: Message, state: FSMContext):
|
|||||||
for lottery in lotteries]
|
for lottery in lotteries]
|
||||||
buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
|
buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
|
"💼 Касса\n\nВыберите розыгрыш. Для каждого счёта обязательна клубная карта клиента.\n\n"
|
||||||
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
|
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
|
||||||
"/remove_account СЧЕТ — деактивировать счет\n"
|
"/remove_account СЧЕТ — деактивировать счет\n"
|
||||||
"/verify_winner КОД ID_РОЗЫГРЫША — подтвердить выдачу приза\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.update_data(lottery_id=lottery_id)
|
||||||
await state.set_state(CashierStates.accounts)
|
await state.set_state(CashierStates.accounts)
|
||||||
await callback.answer()
|
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())
|
@cashier_router.message(CashierStates.accounts, F.text, NotCommand())
|
||||||
async def add_accounts(message: Message, state: FSMContext):
|
async def add_accounts(message: Message, state: FSMContext):
|
||||||
accounts, errors = parse_account_records(message.text)
|
accounts, errors = parse_cashier_records(message.text)
|
||||||
if errors:
|
if errors:
|
||||||
await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
||||||
return
|
return
|
||||||
if not accounts or len(accounts) > 1000:
|
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
|
return
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
async with async_session_maker() as session:
|
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 state.clear()
|
||||||
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
|
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
|
||||||
|
+ (f"Привязано существующих участий: {result['linked']}.\n" if result['linked'] else "")
|
||||||
+ "\n".join(result["errors"][:10]))
|
+ "\n".join(result["errors"][:10]))
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ ACCOUNT = re.compile(r"(?<![\w-])(?:[0-9]{2}(?:[- ][0-9]{2}){6}|[0-9]{14})(?![\w
|
|||||||
CARD = re.compile(r"[0-9]{1,50}")
|
CARD = re.compile(r"[0-9]{1,50}")
|
||||||
|
|
||||||
|
|
||||||
|
def card_account_entry(value):
|
||||||
|
"""Validate an explicit card/account pair, including a 14-digit card."""
|
||||||
|
from .account_utils import format_account_number
|
||||||
|
parts = value.strip().split(maxsplit=1)
|
||||||
|
if len(parts) != 2 or not CARD.fullmatch(parts[0]) or not ACCOUNT.fullmatch(parts[1]):
|
||||||
|
raise ValueError("Для каждого счёта обязательна клубная карта. Формат: КАРТА СЧЁТ, например 0007 11-22-33-44-55-66-77.")
|
||||||
|
return f"{parts[0]} {format_account_number(parts[1])}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cashier_records(text):
|
||||||
|
# Keep the existing table/Viposnova formats, but never accept an unbound row.
|
||||||
|
entries, errors = [], []
|
||||||
|
if not any(line.strip().lower().startswith("viposnova") for line in text.splitlines()):
|
||||||
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
try:
|
||||||
|
entries = [card_account_entry(line) for line in lines]
|
||||||
|
except ValueError:
|
||||||
|
entries = []
|
||||||
|
if not entries:
|
||||||
|
entries, errors = parse_account_records(text)
|
||||||
|
checked = []
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
pair = card_account_entry(entry)
|
||||||
|
except ValueError as error:
|
||||||
|
errors.append(str(error))
|
||||||
|
continue
|
||||||
|
if pair not in checked:
|
||||||
|
checked.append(pair)
|
||||||
|
return checked, list(dict.fromkeys(errors))
|
||||||
|
|
||||||
|
|
||||||
def parse_account_records(text):
|
def parse_account_records(text):
|
||||||
from .account_utils import format_account_number
|
from .account_utils import format_account_number
|
||||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
|||||||
148
tests/test_cashier_cards.py
Normal file
148
tests/test_cashier_cards.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import Account, Participation, User, Winner
|
||||||
|
from src.core.services import LotteryService
|
||||||
|
from src.handlers.account_services import AccountParticipationService
|
||||||
|
from src.handlers.cashier_handlers import CashierStates
|
||||||
|
from src.utils.account_input import parse_cashier_records
|
||||||
|
from test_operator_scenarios import ACCOUNT, ADMIN, CLIENT, assert_clean, output, seed, send
|
||||||
|
from test_text_inputs import context
|
||||||
|
|
||||||
|
NEW_ACCOUNT = "01-02-03-04-05-06-07"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [ACCOUNT, NEW_ACCOUNT, f"0007 {NEW_ACCOUNT}\n{ACCOUNT}"])
|
||||||
|
async def test_cashier_requires_card_for_every_row_without_partial_writes(text, caplog):
|
||||||
|
lottery_id, _ = await seed(participants=False)
|
||||||
|
await send(900002, callback=f"cash_add:{lottery_id}")
|
||||||
|
calls = await send(900002, text=text)
|
||||||
|
assert "обязательна клубная карта" in output(calls)
|
||||||
|
assert await context(900002).get_state() == CashierStates.accounts.state
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 0
|
||||||
|
assert await session.scalar(select(func.count(Account.id))) == 2
|
||||||
|
await send(900002, text=f"0007 {NEW_ACCOUNT}")
|
||||||
|
assert await context(900002).get_state() is None
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 1
|
||||||
|
assert_clean(caplog)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_strict_service_rejects_missing_card_before_any_writes():
|
||||||
|
lottery_id, _ = await seed(participants=False)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await AccountParticipationService.add_account_to_lottery(session, lottery_id, ACCOUNT, require_card=True)
|
||||||
|
assert not result["success"]
|
||||||
|
result = await AccountParticipationService.add_accounts_bulk(
|
||||||
|
session, lottery_id, [f"0007 {NEW_ACCOUNT}", ACCOUNT], require_card=True
|
||||||
|
)
|
||||||
|
assert result["added"] == result["linked"] == 0 and result["errors"]
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 0
|
||||||
|
assert await session.scalar(select(func.count(Account.id))) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("existing_account", [False, True])
|
||||||
|
async def test_cashier_restores_anonymous_participation_and_client_interaction(existing_account, caplog):
|
||||||
|
lottery_id, owner_id = await seed(participants=False)
|
||||||
|
account_number = ACCOUNT if existing_account else NEW_ACCOUNT
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
ticket = Participation(lottery_id=lottery_id, account_number=account_number)
|
||||||
|
session.add(ticket)
|
||||||
|
await session.commit()
|
||||||
|
ticket_id, created_at = ticket.id, ticket.created_at
|
||||||
|
await send(900002, callback=f"cash_add:{lottery_id}")
|
||||||
|
calls = await send(900002, text=f"0007 {account_number}")
|
||||||
|
assert "Привязано существующих участий: 1" in output(calls)
|
||||||
|
assert account_number in output(await send(CLIENT, text="/my_accounts"))
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
ticket = await session.get(Participation, ticket_id)
|
||||||
|
account = await session.scalar(select(Account).where(Account.account_number == account_number))
|
||||||
|
assert ticket.user_id == account.owner_id == owner_id and ticket.account_id == account.id
|
||||||
|
assert ticket.created_at.replace(tzinfo=None) == created_at.replace(tzinfo=None)
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 1
|
||||||
|
await send(900002, callback=f"cash_add:{lottery_id}")
|
||||||
|
calls = await send(900002, text=f"0007 {account_number}")
|
||||||
|
assert "уже участвует" in output(calls)
|
||||||
|
notifications = await send(ADMIN, callback=f"admin_conduct_confirmed_{lottery_id}")
|
||||||
|
assert any(call.__api_method__ == "sendMessage" and getattr(call, "chat_id", None) == CLIENT
|
||||||
|
and "Вы выиграли" in (getattr(call, "text", "") or "") for call in notifications)
|
||||||
|
await send(900002, text=f"/verify_winner TEST1234 {lottery_id}")
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
winner = await session.scalar(select(Winner))
|
||||||
|
assert winner.is_claimed and winner.user_id == owner_id and winner.account_number == account_number
|
||||||
|
assert_clean(caplog)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_card_cannot_replace_existing_participant_owner(caplog):
|
||||||
|
lottery_id, _ = await seed(participants=False)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
other = User(telegram_id=960005, club_card_number="0008")
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
other_id = other.id
|
||||||
|
session.add(Participation(lottery_id=lottery_id, account_number=NEW_ACCOUNT, user_id=other_id))
|
||||||
|
await session.commit()
|
||||||
|
await send(900002, callback=f"cash_add:{lottery_id}")
|
||||||
|
calls = await send(900002, text=f"0007 {NEW_ACCOUNT}")
|
||||||
|
assert "другим владельцем" in output(calls)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(Participation.user_id)) == other_id
|
||||||
|
assert not await session.scalar(select(Account.id).where(Account.account_number == NEW_ACCOUNT))
|
||||||
|
assert_clean(caplog)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_cashiers_repair_once_and_closed_draw_cannot_be_relinked():
|
||||||
|
lottery_id, owner_id = await seed(participants=False)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
session.add(Participation(lottery_id=lottery_id, account_number=NEW_ACCOUNT))
|
||||||
|
await session.commit()
|
||||||
|
async def link():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
return await AccountParticipationService.add_account_to_lottery(
|
||||||
|
session, lottery_id, f"0007 {NEW_ACCOUNT}", require_card=True
|
||||||
|
)
|
||||||
|
results = await asyncio.gather(link(), link())
|
||||||
|
assert sum(bool(item.get("linked")) for item in results) == 1
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 1
|
||||||
|
assert await session.scalar(select(Participation.user_id)) == owner_id
|
||||||
|
await LotteryService.conduct_draw(session, lottery_id)
|
||||||
|
assert not (await link())["success"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_shared_add_account_callback_cannot_drop_card_from_state(caplog):
|
||||||
|
lottery_id, _ = await seed(participants=False)
|
||||||
|
await context(900002).update_data(accounts=[{"account_number": ACCOUNT}])
|
||||||
|
calls = await send(900002, callback=f"add_to_lottery_{lottery_id}")
|
||||||
|
assert "обязательна клубная карта" in output(calls)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(func.count(Participation.id))) == 0
|
||||||
|
await context(900002).update_data(accounts=[{"club_card": "0007", "account_number": ACCOUNT}])
|
||||||
|
await send(900002, callback=f"add_to_lottery_{lottery_id}")
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
ticket = await session.scalar(select(Participation))
|
||||||
|
assert ticket.user_id and ticket.account_id
|
||||||
|
assert_clean(caplog)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("card", ["0007", "01234567890123", "0" * 49 + "1"])
|
||||||
|
async def test_cashier_preserves_card_lengths_and_leading_zeroes(card, caplog):
|
||||||
|
lottery_id, owner_id = await seed(participants=False)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await session.get(User, owner_id)
|
||||||
|
user.club_card_number = card
|
||||||
|
await session.commit()
|
||||||
|
await send(900002, callback=f"cash_add:{lottery_id}")
|
||||||
|
await send(900002, text=f"{card}\t{NEW_ACCOUNT}")
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
assert await session.scalar(select(Participation.user_id)) == owner_id
|
||||||
|
assert_clean(caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cashier_parser_keeps_table_and_viposnova_card_pairs():
|
||||||
|
for text in (f"0007\t{ACCOUNT}", f"{ACCOUNT}\n0007", f"Viposnova\n{ACCOUNT}\n0007"):
|
||||||
|
assert parse_cashier_records(text) == ([f"0007 {ACCOUNT}"], [])
|
||||||
@@ -101,12 +101,12 @@ async def test_duplicate_account_reports_and_finishes_dialog(route, caplog):
|
|||||||
await send(callback="account_action:add_to_lottery")
|
await send(callback="account_action:add_to_lottery")
|
||||||
calls = await send(callback=f"add_accounts_to:{lottery_id}")
|
calls = await send(callback=f"add_accounts_to:{lottery_id}")
|
||||||
elif route == "command":
|
elif route == "command":
|
||||||
await context(actor).update_data(accounts=[{"account_number": ACCOUNT}])
|
await context(actor).update_data(accounts=[{"club_card": "0007", "account_number": ACCOUNT}])
|
||||||
calls = await send(callback=f"add_to_lottery_{lottery_id}")
|
calls = await send(callback=f"add_to_lottery_{lottery_id}")
|
||||||
elif route == "cashier":
|
elif route == "cashier":
|
||||||
await send(actor, text="/cashier")
|
await send(actor, text="/cashier")
|
||||||
await send(actor, callback=f"cash_add:{lottery_id}")
|
await send(actor, callback=f"cash_add:{lottery_id}")
|
||||||
calls = await send(actor, text=ACCOUNT)
|
calls = await send(actor, text=f"0007 {ACCOUNT}")
|
||||||
else:
|
else:
|
||||||
await send(callback=f"admin_bulk_add_accounts_to_{lottery_id}")
|
await send(callback=f"admin_bulk_add_accounts_to_{lottery_id}")
|
||||||
calls = await send(text=ACCOUNT)
|
calls = await send(text=ACCOUNT)
|
||||||
@@ -182,7 +182,7 @@ async def test_two_cashiers_share_draw_without_duplicate_tickets(caplog):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
for actor in (900002, 960002):
|
for actor in (900002, 960002):
|
||||||
await send(actor, callback=f"cash_add:{lottery_id}")
|
await send(actor, callback=f"cash_add:{lottery_id}")
|
||||||
calls = await asyncio.gather(send(900002, text=ACCOUNT), send(960002, text=ACCOUNT), send(CLIENT, text="/start"))
|
calls = await asyncio.gather(send(900002, text=f"0007 {ACCOUNT}"), send(960002, text=f"0007 {ACCOUNT}"), send(CLIENT, text="/start"))
|
||||||
assert_clean(caplog)
|
assert_clean(caplog)
|
||||||
assert sum("Добавлено: 1" in output(result) for result in calls) == 1
|
assert sum("Добавлено: 1" in output(result) for result in calls) == 1
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
|
|||||||
Reference in New Issue
Block a user