Розроблення системи онлайн-бронювання для веб-сайту
Система бронювання — це не просто форма з вибором дати. Це управління слотами, перевірка доступності в реальному часі, утримання брони, обробка одночасних запитів і ланцюг сповіщень. Неправильна реалізація призводить до подвійних бронювань або «завислих» слотів.
Ключові сутності
-- Ресурс бронювання (зал, спеціаліст, номер, стіл тощо)
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'))
);
Обмеження 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, можлива гонка: два запити перевіряють доступність одночасно, обидва бачать слот вільним. Обмеження впіймає другий INSERT і викине ExclusionViolationError. Додаток повинен це обробити:
try:
booking = create_booking(data)
except ExclusionViolationError:
raise BookingConflict("Цей слот щойно забронював. Виберіть інший час.")
Сповіщення
| Подія | Кому | Канал |
|---|---|---|
| Бронювання створено | Клієнт | Email + SMS |
| Бронювання підтверджено | Клієнт | |
| Нагадування за 24 години | Клієнт | Email + SMS |
| Нагадування за 1 годину | Клієнт | SMS |
| Нове бронювання | Адміністратор | |
| Скасування | Клієнт + Адміністратор |
Нагадування відправляються через запланізовані завдання — cron щогодини вибирає брони, у яких starts_at через 24 години або через 1 годину, і відповідне сповіщення не було відправлено.
Правила скасування та змін
Гнучка система політик скасування:
{
"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 робочих днів.







