Require club cards in cashier enrollment and restore ticket owners
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-14 21:30:38 +09:00
parent 9b19b82caf
commit 8d4baeb720
10 changed files with 258 additions and 23 deletions

View File

@@ -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. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.

View File

@@ -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`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору. Карта должна быть известна боту. Создание нового счёта и добавление участия сохраняются вместе. Ведущие нули карты сохраняются. Если хотя бы одна строка не содержит карты, весь список возвращается на исправление без добавления его строк; отправьте исправленный список в том же диалоге.
Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш. Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю. Если счёт раньше попал в открытый розыгрыш без клиента, повторите ввод с правильной картой. Бот восстановит связь существующего участия с клиентом, сохранив номер билета и время добавления. Это позволит клиенту видеть счёт, получать уведомление о выигрыше и подтверждать выдачу своим кодом. Владелец уже привязанного участия не заменяется.
## Исправление счёта ## Исправление счёта

View File

@@ -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']:

View File

@@ -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']}")

View File

@@ -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"

View File

@@ -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"

View File

@@ -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]))

View File

@@ -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
View 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}"], [])

View File

@@ -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: