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

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

View File

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

View File

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

View File

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

View File

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

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}")
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):
from .account_utils import format_account_number
lines = [line.strip() for line in text.splitlines() if line.strip()]