435 lines
16 KiB
Python
435 lines
16 KiB
Python
"""Booking handler - full FSM flow for service reservation."""
|
|
import re
|
|
|
|
from aiogram import Router, F
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.filters import StateFilter
|
|
from aiogram.types import Message, CallbackQuery, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton
|
|
from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
|
|
from app.keyboards.masters import get_masters_keyboard
|
|
from app.states.booking import BookingState
|
|
from app.config import SERVICES as ALL_SERVICES, ADMIN_CHAT_ID
|
|
from app.services.booking_service import (
|
|
create_booking,
|
|
update_booking,
|
|
get_busy_times,
|
|
is_slot_busy,
|
|
cleanup_cancelled
|
|
)
|
|
from app.keyboards.menu import menu, cancel_menu
|
|
from app.keyboards.calendar import get_dates_keyboard
|
|
from app.keyboards.time_slots import get_time_keyboard
|
|
from app.keyboards.confirm_booking import get_confirm_booking_keyboard
|
|
from app.logger import logger
|
|
|
|
router = Router()
|
|
|
|
|
|
def _services_to_str(data):
|
|
"""Combine main + extra services into one string."""
|
|
main = data.get("service", "?")
|
|
extra = data.get("extra_services", [])
|
|
if extra:
|
|
return main + ", " + ", ".join(extra)
|
|
return main
|
|
|
|
|
|
def _make_service_card(key):
|
|
"""Recreate the service card text for 'back' navigation from a photo card."""
|
|
svc = ALL_SERVICES[key]
|
|
return (
|
|
svc["emoji"] + " " + svc["name"] + "\n\n"
|
|
+ svc["desc"] + "\n\n"
|
|
+ "\u23f1 Продолжительность: " + str(svc["duration"]) + " мин\n"
|
|
+ "\U0001f4b0 Цена: " + str(svc["price"]) + "\u20bd"
|
|
)
|
|
|
|
|
|
# отмена записи на любом шаге
|
|
@router.message(F.text.lower() == "отмена")
|
|
async def cancel(message: Message, state: FSMContext):
|
|
await state.clear()
|
|
await message.answer(
|
|
"❌ Запись отменена",
|
|
reply_markup=menu
|
|
)
|
|
|
|
|
|
# старт записи
|
|
@router.message(F.text == "📅 Записаться")
|
|
async def start_booking(message: Message, state: FSMContext):
|
|
await state.clear()
|
|
await state.set_state(BookingState.service)
|
|
await message.answer(
|
|
"Выберите услугу:",
|
|
reply_markup=get_services_keyboard()
|
|
)
|
|
|
|
|
|
# выбор первой услуги (через кнопку "📅 Записаться")
|
|
@router.callback_query(
|
|
StateFilter(BookingState.service),
|
|
F.data.startswith("service_")
|
|
)
|
|
async def choose_service(callback: CallbackQuery, state: FSMContext):
|
|
svc_data = ALL_SERVICES[callback.data]
|
|
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
|
|
|
|
await state.update_data(service=service_name, extra_services=[])
|
|
await state.set_state(BookingState.extra_services)
|
|
|
|
await callback.message.edit_text(
|
|
"✅ Выбрано: " + service_name + "\n\n"
|
|
"Хотите добавить ещё услугу?\n"
|
|
"Нажмите нужную или «Готово»:",
|
|
reply_markup=get_extra_services_keyboard()
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
# выбор дополнительных услуг
|
|
@router.callback_query(
|
|
StateFilter(BookingState.extra_services),
|
|
F.data.startswith("extra_")
|
|
)
|
|
async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
|
|
if callback.data == "extra_done":
|
|
await state.set_state(BookingState.master)
|
|
await callback.message.edit_text(
|
|
"👤 Выберите специалиста:",
|
|
reply_markup=get_masters_keyboard()
|
|
)
|
|
await callback.answer()
|
|
return
|
|
|
|
actual_key = callback.data.replace("extra_", "", 1)
|
|
svc_data = ALL_SERVICES.get(actual_key)
|
|
if not svc_data:
|
|
await callback.answer("Услуга не найдена")
|
|
return
|
|
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
|
|
data = await state.get_data()
|
|
extra = data.get("extra_services", [])
|
|
|
|
if service_name in extra:
|
|
extra.remove(service_name)
|
|
logger.debug("User {uid} removed extra service: {srv}", uid=callback.from_user.id, srv=service_name)
|
|
else:
|
|
extra.append(service_name)
|
|
logger.debug("User {uid} added extra service: {srv}", uid=callback.from_user.id, srv=service_name)
|
|
|
|
await state.update_data(extra_services=extra)
|
|
|
|
data = await state.get_data()
|
|
services_text = _services_to_str(data)
|
|
|
|
await callback.message.edit_text(
|
|
"✅ Выбрано: " + services_text + "\n\n"
|
|
"Хотите добавить ещё услугу?\n"
|
|
"Нажмите нужную или «Готово»:",
|
|
reply_markup=get_extra_services_keyboard(extra)
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
# выбор мастера
|
|
@router.callback_query(
|
|
StateFilter(BookingState.master),
|
|
F.data.startswith("master_")
|
|
)
|
|
async def choose_master(callback: CallbackQuery, state: FSMContext):
|
|
from app.config import MASTERS
|
|
|
|
master_data = MASTERS[callback.data]
|
|
master_name = master_data["name"] if isinstance(master_data, dict) else master_data
|
|
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
|
|
|
|
await state.update_data(master=master_name)
|
|
await state.set_state(BookingState.date)
|
|
|
|
await callback.message.edit_text(
|
|
"📅 Выберите дату:",
|
|
reply_markup=get_dates_keyboard()
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
# выбор даты
|
|
@router.callback_query(
|
|
StateFilter(BookingState.date),
|
|
F.data.startswith("date_")
|
|
)
|
|
async def choose_date(callback: CallbackQuery, state: FSMContext):
|
|
selected_date = callback.data.replace("date_", "")
|
|
|
|
await state.update_data(date=selected_date)
|
|
logger.debug("User {uid} selected date: {d}", uid=callback.from_user.id, d=selected_date)
|
|
|
|
data = await state.get_data()
|
|
busy_times = await get_busy_times(master=data.get("master", "?"), date=selected_date)
|
|
|
|
await state.set_state(BookingState.time)
|
|
|
|
await callback.message.edit_text(
|
|
"🕒 Выберите время:",
|
|
reply_markup=get_time_keyboard(busy_times)
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
# выбор времени
|
|
@router.callback_query(
|
|
StateFilter(BookingState.time),
|
|
F.data.startswith("time_")
|
|
)
|
|
async def choose_time(callback: CallbackQuery, state: FSMContext):
|
|
selected_time = callback.data.replace("time_", "")
|
|
|
|
await state.update_data(time=selected_time)
|
|
logger.debug("User {uid} selected time: {t}", uid=callback.from_user.id, t=selected_time)
|
|
|
|
data = await state.get_data()
|
|
services_str = _services_to_str(data)
|
|
|
|
await state.set_state(BookingState.confirm)
|
|
|
|
await callback.message.edit_text(
|
|
"📋 Проверьте запись:\n\n"
|
|
f"💆 Услуги: {services_str}\n"
|
|
f"📅 Дата: {data.get('date', '?')}\n"
|
|
f"👤 Мастер: {data.get('master', '?')}\n"
|
|
f"🕒 Время: {selected_time}",
|
|
reply_markup=get_confirm_booking_keyboard()
|
|
)
|
|
await callback.answer()
|
|
|
|
|
|
# подтверждение
|
|
@router.callback_query(
|
|
StateFilter(BookingState.confirm),
|
|
F.data == "confirm_booking"
|
|
)
|
|
async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|
data = await state.get_data()
|
|
logger.info("User {uid} confirmed booking: {data}", uid=callback.from_user.id, data=data)
|
|
|
|
services_str = _services_to_str(data)
|
|
editing_booking_id = data.get("editing_booking_id")
|
|
|
|
if editing_booking_id:
|
|
await update_booking(
|
|
booking_id=editing_booking_id,
|
|
service=services_str,
|
|
master=data.get("master", "?"),
|
|
date=data.get("date", "?"),
|
|
time=data.get("time", "?")
|
|
)
|
|
result_text = "✅ Запись успешно обновлена"
|
|
else:
|
|
master = data.get("master", "?")
|
|
date = data.get("date", "?")
|
|
time = data.get("time", "?")
|
|
|
|
from app.db.postgres import PostgresDB as _PG
|
|
from app.config import USE_POSTGRES as _UPG
|
|
if _UPG:
|
|
_u = await _PG.get_user(callback.from_user.id)
|
|
if not _u:
|
|
_n = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
|
|
await _PG.set_user(callback.from_user.id, _n)
|
|
|
|
if await is_slot_busy(master, date, time):
|
|
logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time)
|
|
await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.")
|
|
await state.clear()
|
|
await callback.answer()
|
|
return
|
|
|
|
await create_booking(
|
|
user_id=callback.from_user.id,
|
|
service=services_str,
|
|
master=master,
|
|
date=date,
|
|
time=time,
|
|
user_name=callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
|
|
)
|
|
cleanup_cancelled()
|
|
result_text = "✅ Запись успешно создана"
|
|
|
|
# Notify admin about new booking
|
|
try:
|
|
if ADMIN_CHAT_ID:
|
|
from aiogram import Bot
|
|
from app.config import BOT_TOKEN
|
|
admin_bot = Bot(token=BOT_TOKEN)
|
|
user_str = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
|
|
await admin_bot.send_message(
|
|
ADMIN_CHAT_ID,
|
|
"🆕 Новая запись!\n\n"
|
|
f"👤 Клиент: {user_str} (id={callback.from_user.id})\n"
|
|
f"💆 Услуги: {services_str}\n"
|
|
f"👤 Мастер: {master}\n"
|
|
f"📅 Дата: {date}\n"
|
|
f"🕒 Время: {time}"
|
|
)
|
|
await admin_bot.session.close()
|
|
except Exception as e:
|
|
logger.warning("Failed to notify admin: {error}", error=e)
|
|
|
|
await state.clear()
|
|
await callback.message.edit_text(result_text)
|
|
await callback.answer()
|
|
|
|
|
|
@router.callback_query(
|
|
StateFilter(BookingState.confirm),
|
|
F.data == "cancel_booking"
|
|
)
|
|
async def cancel_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|
await state.clear()
|
|
await callback.message.edit_text("❌ Запись отменена")
|
|
await callback.answer()
|
|
|
|
|
|
@router.callback_query(
|
|
StateFilter(BookingState.confirm),
|
|
F.data == "edit_booking"
|
|
)
|
|
async def edit_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|
old_data = await state.get_data()
|
|
await state.clear()
|
|
await state.update_data(service=old_data.get("service"))
|
|
await state.set_state(BookingState.service)
|
|
|
|
await callback.message.edit_text(
|
|
"Выберите услугу:",
|
|
reply_markup=get_services_keyboard()
|
|
)
|
|
|
|
|
|
# прямая запись с карточки услуги (без предварительного выбора)
|
|
@router.callback_query(F.data.startswith("service_"))
|
|
async def quick_book_service(callback: CallbackQuery, state: FSMContext):
|
|
svc_data = ALL_SERVICES[callback.data]
|
|
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
svc_key = callback.data
|
|
logger.info("User {uid} quick book from card: {srv}", uid=callback.from_user.id, srv=service_name)
|
|
|
|
await state.clear()
|
|
await state.update_data(
|
|
service=service_name,
|
|
extra_services=[],
|
|
_from_card=True,
|
|
_card_key=svc_key
|
|
)
|
|
await state.set_state(BookingState.extra_services)
|
|
|
|
text = (
|
|
"✅ Выбрано: " + service_name + "\n\n"
|
|
"Хотите добавить ещё услугу?\n"
|
|
"Нажмите нужную или «Готово»:"
|
|
)
|
|
|
|
# Карточка с фото — удаляем и отправляем новое сообщение
|
|
try:
|
|
await callback.message.delete()
|
|
await callback.message.answer(text, reply_markup=get_extra_services_keyboard([]))
|
|
except Exception:
|
|
await callback.message.edit_text(text, reply_markup=get_extra_services_keyboard([]))
|
|
|
|
await callback.answer()
|
|
|
|
|
|
@router.callback_query(F.data == "go_back")
|
|
async def go_back_handler(callback: CallbackQuery, state: FSMContext):
|
|
current_state = await state.get_state()
|
|
|
|
if current_state == BookingState.extra_services:
|
|
# Назад к списку услуг — проверяем, с карточки пришёл или из меню
|
|
await state.set_state(BookingState.service)
|
|
data = await state.get_data()
|
|
data.pop("extra_services", None)
|
|
data.pop("service", None)
|
|
from_card = data.pop("_from_card", False)
|
|
card_key = data.pop("_card_key", None)
|
|
await state.set_data(data)
|
|
|
|
if from_card and card_key and card_key in ALL_SERVICES:
|
|
# Возвращаем карточку с фото
|
|
svc = ALL_SERVICES[card_key]
|
|
card_text = _make_service_card(card_key)
|
|
book_btn = InlineKeyboardMarkup(inline_keyboard=[[
|
|
InlineKeyboardButton(text="\U0001f4c5 Записаться", callback_data=card_key)
|
|
]])
|
|
try:
|
|
img = FSInputFile(svc["image"])
|
|
await callback.message.answer_photo(photo=img, caption=card_text, reply_markup=book_btn)
|
|
try:
|
|
await callback.message.delete()
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
await callback.message.edit_text(
|
|
"Выберите услугу:",
|
|
reply_markup=get_services_keyboard()
|
|
)
|
|
else:
|
|
await callback.message.edit_text(
|
|
"Выберите услугу:",
|
|
reply_markup=get_services_keyboard()
|
|
)
|
|
|
|
elif current_state == BookingState.master:
|
|
# Назад к доп. услугам
|
|
await state.set_state(BookingState.extra_services)
|
|
data = await state.get_data()
|
|
extra = data.get("extra_services", [])
|
|
services_text = _services_to_str(data)
|
|
await callback.message.edit_text(
|
|
"✅ Выбрано: " + services_text + "\n\n"
|
|
"Хотите добавить ещё услугу?\n"
|
|
"Нажмите нужную или «Готово»:",
|
|
reply_markup=get_extra_services_keyboard(extra)
|
|
)
|
|
|
|
elif current_state == BookingState.date:
|
|
await state.set_state(BookingState.master)
|
|
data = await state.get_data()
|
|
data.pop("date", None)
|
|
data.pop("time", None)
|
|
await state.set_data(data)
|
|
await callback.message.edit_text(
|
|
"👤 Выберите специалиста:",
|
|
reply_markup=get_masters_keyboard()
|
|
)
|
|
|
|
elif current_state == BookingState.time:
|
|
await state.set_state(BookingState.date)
|
|
data = await state.get_data()
|
|
data.pop("time", None)
|
|
await state.set_data(data)
|
|
await callback.message.edit_text(
|
|
"📅 Выберите дату:",
|
|
reply_markup=get_dates_keyboard()
|
|
)
|
|
|
|
elif current_state == BookingState.confirm:
|
|
await state.set_state(BookingState.time)
|
|
data = await state.get_data()
|
|
busy_times = await get_busy_times(
|
|
master=data.get("master", "?"),
|
|
date=data.get("date", "?")
|
|
)
|
|
await callback.message.edit_text(
|
|
"🕒 Выберите время:",
|
|
reply_markup=get_time_keyboard(busy_times)
|
|
)
|
|
|
|
else:
|
|
await state.clear()
|
|
await callback.message.edit_text("Отмена записи")
|
|
|
|
await callback.answer()
|