Разработка системы онлайн-бронирования для сайта
Система бронирования — это не просто форма с выбором даты. Это управление слотами, проверка доступности в реальном времени, удержание брони, обработка конкурентных запросов и цепочка уведомлений. Неправильная реализация приводит к двойным бронированиям или «зависшим» слотам.
Ключевые сущности
-- Ресурс бронирования (зал, специалист, номер, стол и т.д.)
CREATE TABLE bookable_resources (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL, -- 'specialist', 'room', 'table', 'car'
name VARCHAR(255) NOT NULL,
config JSONB, -- специфичные настройки ресурса
is_active BOOLEAN DEFAULT TRUE
);
-- Расписание доступности ресурса
CREATE TABLE resource_schedules (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES bookable_resources(id),
weekday SMALLINT, -- 0=пн, 6=вс; NULL = конкретная дата
specific_date DATE,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
slot_duration INTERVAL, -- NULL = ресурс не делится на слоты
is_available BOOLEAN DEFAULT TRUE
);
-- Собственно бронирования
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES bookable_resources(id),
user_id INTEGER,
guest_name VARCHAR(255),
guest_email VARCHAR(255),
guest_phone VARCHAR(50),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
-- pending | confirmed | cancelled | no_show | completed
notes TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
confirmed_at TIMESTAMP,
cancelled_at TIMESTAMP,
CONSTRAINT no_overlap EXCLUDE USING gist (
resource_id WITH =,
tsrange(starts_at, ends_at, '[)') WITH &&
) WHERE (status NOT IN ('cancelled'))
);
Constraint EXCLUDE USING gist с tsrange — самый надёжный способ предотвратить двойное бронирование на уровне БД. Он работает атомарно и не зависит от логики приложения.
Алгоритм проверки доступности
def get_available_slots(resource_id: int, date: date) -> list[TimeSlot]:
# 1. Получить расписание ресурса на этот день
schedule = get_schedule(resource_id, date)
if not schedule or not schedule.is_available:
return []
# 2. Сгенерировать все теоретические слоты
all_slots = generate_slots(
start=schedule.start_time,
end=schedule.end_time,
duration=schedule.slot_duration or timedelta(hours=1),
)
# 3. Получить уже занятые интервалы из БД
booked = get_booked_intervals(resource_id, date)
# 4. Отфильтровать занятые
return [
slot for slot in all_slots
if not any(slot.overlaps(b) for b in booked)
]
Временно́е удержание слота (hold)
Между выбором слота и оплатой проходит время. Чтобы слот не заняли в этот момент, реализуется механизм hold:
HOLD_TTL = 600 # 10 минут
def hold_slot(resource_id: int, starts_at: datetime, session_id: str) -> str:
hold_key = f"hold:{resource_id}:{starts_at.isoformat()}"
# SET NX — только если ещё не занято
success = redis.set(hold_key, session_id, nx=True, ex=HOLD_TTL)
if not success:
existing = redis.get(hold_key)
if existing and existing.decode() != session_id:
raise SlotAlreadyHeld("Слот занят другим пользователем")
return hold_key
def confirm_booking(hold_key: str, booking_data: dict) -> Booking:
session_id = redis.get(hold_key)
if not session_id:
raise HoldExpired("Время удержания слота истекло")
with db.transaction():
booking = create_booking(booking_data)
redis.delete(hold_key)
return booking
Обработка конкурентных запросов
Даже с EXCLUDE constraint-ом возможна гонка: два запроса проверяют доступность одновременно, оба видят слот свободным. Constraint поймает второй INSERT и выбросит ExclusionViolationError. Приложение должно это обработать:
try:
booking = create_booking(data)
except ExclusionViolationError:
raise BookingConflict("Этот слот только что был забронирован. Выберите другое время.")
Уведомления
| Событие | Кому | Канал |
|---|---|---|
| Бронирование создано | Клиент | Email + SMS |
| Бронирование подтверждено | Клиент | |
| Напоминание за 24 часа | Клиент | Email + SMS |
| Напоминание за 1 час | Клиент | SMS |
| Новое бронирование | Администратор | |
| Отмена | Клиент + Администратор |
Напоминания отправляются через scheduled jobs — cron каждый час выбирает брони, у которых starts_at через 24h или через 1h, и не было отправлено соответствующее уведомление.
Правила отмены и изменения
Гибкая система политик отмены:
{
"cancellation_policy": {
"free_cancellation_hours": 24,
"partial_refund_hours": 12,
"partial_refund_percent": 50,
"no_refund_hours": 2
}
}
Политика хранится на уровне ресурса или типа бронирования и применяется автоматически при расчёте возврата.
Интеграция с оплатой
Бронирование может требовать предоплату или полную оплату. Интеграция с Stripe:
def create_payment_intent(booking: Booking, amount: int, currency: str):
intent = stripe.PaymentIntent.create(
amount=amount,
currency=currency,
metadata={
'booking_id': str(booking.id),
'resource_id': str(booking.resource_id),
},
capture_method='manual', # автооплата при подтверждении
)
booking.payment_intent_id = intent.id
booking.save()
return intent.client_secret
При capture_method='manual' деньги замораживаются на карте, но списываются только при вызове capture() — это удобно для бронирований с ручным подтверждением.
Сроки реализации
Базовая система с одним типом ресурса, без оплаты — 8–10 рабочих дней. Несколько типов ресурсов, управление расписанием через CMS, интеграция с оплатой, SMS-уведомления, политики отмены, мобильный вид — 14–18 рабочих дней.







