Реалізація бронювання номерів готелю на сайті
Уявіть: гість бронює номер через Booking.com, а адміністратор одночасно продає його напряму. Результат — овербукінг, незадоволений клієнт і зіпсована репутація. Ми вирішуємо цю проблему за допомогою системи, яка синхронізує всі канали в реальному часі. Наш досвід — 10+ років, понад 40 впроваджених проектів. Гарантуємо відсутність подвійних бронювань і коректну синхронізацію із зовнішніми каналами.
Готельне бронювання — одне з найскладніших завдань резервування. Гості приїжджають на кілька ночей, тарифи змінюються залежно від сезону, а правила скасування можуть бути різними. Система має працювати з діапазонами дат, а не з окремими днями. Основні проблеми: витік номерів між різними каналами продажів, невірний розрахунок ціни при зміні тарифу, складне управління різними типами кімнат. Наш підхід вирішує їх за допомогою схеми БД з exclusion constraints і гнучкої системи тарифів.
Чому бронювання готелів — складне завдання?
| Фактор | Складність | Наше рішення |
|---|---|---|
| Динамічні тарифи | Ціна змінюється щодня | Таблиця rate_plans з періодами та пріоритетами |
| Запобігання овербукінгу | Два гості не можуть зайняти один номер | Exclusion constraint daterange з && |
| Інтеграція з PMS/OTA | Різні протоколи та формати | Адаптери під iCal, OTA XML, REST API |
| Основна причина подвійних бронювань | Як усуваємо |
|---|---|
| Ручне управління номерами | Автоматична синхронізація через Channel Manager |
| Затримка оновлення зайнятості | Real-time оновлення через API |
| Різні системи обліку | Єдина база з exclusion constraints |
Чому виняток на рівні БД швидше за перевірку в коді?
Використання exclusion constraint дозволяє базі даних атомарно перевіряти перетини діапазонів. Це виключає race condition і знижує навантаження на застосунок. У PostgreSQL індекс GiST на daterange працює за O(log n).Як уникнути подвійного бронювання?
На практиці ми використовуємо два рівні захисту. По-перше, база даних — exclusion constraint на таблиці бронювань не дає створити пересічні записи. По-друге, застосунок перед створенням броні перевіряє доступність через окремий запит. Це виключає race condition навіть при високому навантаженні. Наприклад, якщо два гості одночасно бронюють останній номер одного типу, система обробить запити послідовно і тільки один підтвердить бронь.
В одному з проектів для мережі готелів ми впровадили подібну систему. До цього вони втрачали до 5% броней через подвійні продажі. Після впровадження винятку на рівні БД та автоматичної синхронізації з Booking.com і Airbnb за місяць не було жодного овербукінгу. Час на обробку кожної броні скоротився з 3 хвилин до 30 секунд.
Як ми реалізуємо проект: покроковий план
- Аудит вимог та інтеграцій. Фіксуємо список OTA, PMS, типи номерів та тарифи.
- Проектування схеми та API. Створюємо модель даних з exclusion constraints та ендпоінти для фронтенду.
- Розробка модулів бронювання та тарифів. Реалізуємо пошук, розрахунок вартості, скасування.
- Інтеграція з OTA через Channel Manager. Налаштовуємо iCal або OTA XML залежно від каналу.
- Тестування та налагодження. Перевіряємо навантажувальне тестування (до 1000 запитів/сек) та сценарії подвійного бронювання.
- Деплой та навчання персоналу. Розгортаємо на сервері, проводимо навчання адміністраторів.
Модель даних
CREATE TABLE room_types (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
max_occupancy SMALLINT NOT NULL,
area_sqm NUMERIC(5,1),
amenities TEXT[],
images JSONB DEFAULT '[]',
base_price NUMERIC(10,2)
);
CREATE TABLE rooms (
id SERIAL PRIMARY KEY,
room_type_id INTEGER REFERENCES room_types(id),
room_number VARCHAR(10) NOT NULL,
floor SMALLINT,
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE rate_plans (
id SERIAL PRIMARY KEY,
room_type_id INTEGER REFERENCES room_types(id),
name VARCHAR(100),
price NUMERIC(10,2),
valid_from DATE NOT NULL,
valid_until DATE NOT NULL,
min_stay_nights SMALLINT DEFAULT 1,
cancellation_hours INTEGER DEFAULT 24,
is_refundable BOOLEAN DEFAULT TRUE,
includes_breakfast BOOLEAN DEFAULT FALSE
);
CREATE TABLE reservations (
id BIGSERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
room_type_id INTEGER,
rate_plan_id INTEGER REFERENCES rate_plans(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
adults SMALLINT DEFAULT 1,
children SMALLINT DEFAULT 0,
guest_name VARCHAR(255) NOT NULL,
guest_email VARCHAR(255) NOT NULL,
guest_phone VARCHAR(50),
total_amount NUMERIC(12,2),
status VARCHAR(20) DEFAULT 'pending',
payment_status VARCHAR(20) DEFAULT 'unpaid',
notes TEXT,
source VARCHAR(30) DEFAULT 'website',
external_id VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT no_room_overlap EXCLUDE USING gist (
room_id WITH =,
daterange(check_in, check_out, '[)') WITH &&
) WHERE (status NOT IN ('cancelled', 'no_show'))
);
Пошук доступних номерів
def search_available_rooms(check_in: date, check_out: date, adults: int, children: int = 0):
nights = (check_out - check_in).days
guests = adults + children
return db.fetchall("""
SELECT
rt.*,
COUNT(r.id) AS available_count,
rp.price AS nightly_price,
rp.price * %(nights)s AS total_price,
rp.is_refundable,
rp.includes_breakfast,
rp.min_stay_nights
FROM room_types rt
JOIN rooms r ON r.room_type_id = rt.id AND r.is_active = TRUE
JOIN rate_plans rp ON rp.room_type_id = rt.id
AND rp.valid_from <= %(check_in)s
AND rp.valid_until >= %(check_out)s
AND rp.min_stay_nights <= %(nights)s
WHERE rt.max_occupancy >= %(guests)s
AND r.id NOT IN (
SELECT room_id FROM reservations
WHERE status NOT IN ('cancelled', 'no_show')
AND daterange(check_in, check_out, '[)') &&
daterange(%(check_in)s, %(check_out)s, '[)')
)
GROUP BY rt.id, rp.id
HAVING COUNT(r.id) > 0
ORDER BY rp.price ASC
""", {'check_in': check_in, 'check_out': check_out,
'nights': nights, 'guests': guests})
Динамічне ціноутворення
Ціна за ніч може змінюватися залежно від дня тижня, завантаженості, сезону. Реалізуємо розрахунок з понічною ітерацією:
def calculate_total_price(room_type_id: int, check_in: date, check_out: date) -> Decimal:
total = Decimal(0)
current = check_in
while current < check_out:
rate = get_rate_for_date(room_type_id, current)
if rate is None:
raise NoRateAvailable(f"No rate for {current}")
total += rate.price
current += timedelta(days=1)
return total
def get_rate_for_date(room_type_id: int, d: date) -> Optional[RatePlan]:
return db.fetchone("""
SELECT * FROM rate_plans
WHERE room_type_id = %s
AND valid_from <= %s AND valid_until >= %s
ORDER BY price DESC
LIMIT 1
""", [room_type_id, d, d])
Інтеграція з Channel Manager / OTA
Для синхронізації з Booking.com, Expedia, Airbnb використовується Channel Manager (TravelLine, Bnovo, Wubook). Стандартний протокол — OTA XML (OpenTravel Alliance) або iCal для простих випадків. Channel Manager — система для управління каналами продажів. Наше рішення обробляє до 1000 запитів на секунду, що вдвічі швидше за типові рішення на WordPress.
iCal-синхронізація для Airbnb:
def generate_ical_feed(room_id: int) -> str:
bookings = get_confirmed_bookings(room_id)
cal = Calendar()
cal.add('prodid', '-//Hotel Booking//EN')
cal.add('version', '2.0')
for b in bookings:
event = Event()
event.add('uid', f"booking-{b.id}@hotel.example.com")
event.add('dtstart', b.check_in)
event.add('dtend', b.check_out)
event.add('summary', 'BLOCKED')
cal.add_component(event)
return cal.to_ical().decode('utf-8')
Скасування та повернення
def cancel_reservation(reservation_id: int, initiator: str) -> dict:
res = get_reservation(reservation_id)
hours_to_arrival = (
datetime.combine(res.check_in, time(14, 0)) - datetime.utcnow()
).total_seconds() / 3600
rate = get_rate_plan(res.rate_plan_id)
if rate.is_refundable and hours_to_arrival >= rate.cancellation_hours:
refund_amount = res.total_amount
refund_type = 'full'
elif not rate.is_refundable:
refund_amount = Decimal(0)
refund_type = 'none'
else:
refund_amount = res.total_amount * Decimal('0.5')
refund_type = 'partial'
process_refund(res.payment_id, refund_amount)
update_reservation_status(reservation_id, 'cancelled', initiator)
send_cancellation_email(res, refund_amount, refund_type)
return {'refund': refund_amount, 'type': refund_type}
Що входить у роботу
- Архітектура та проектування схеми БД (PostgreSQL з exclusion constraints).
- Реалізація модулів: пошук, бронювання, ціноутворення, скасування.
- Інтеграція із зовнішніми системами (PMS, OTA) через iCal або OTA XML.
- Написання тестів (unit, integration) та документації.
- Навчання персоналу роботі з системою.
- Гарантійна підтримка 3 місяці.
Строки реалізації
Базовий модуль без динамічних тарифів і без PMS-інтеграції — 10–13 робочих днів. Динамічне ціноутворення, призначення номерів, iCal-синхронізація, управління тарифними планами, особистий кабінет гостя — 16–22 робочих дні.
Оцініть вигоду інтеграції — зв'яжіться з нами для попереднього аудиту. Замовте консультацію — ми оцінимо ваш проект під ключ і запропонуємо оптимальний стек.







