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:
@@ -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']:
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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()]
|
||||
|
||||
Reference in New Issue
Block a user