Double-booking — постійний головний біль власників сервісів з онлайн-записом. Коли два клієнти одночасно бронюють один слот, втрачаються гроші та довіра. Стандартні плагіни CMS вирішують проблему лише наполовину: перевірки на стороні додатка не витримують конкурентного доступу. Ми будуємо кастомні системи бронювання, які виключають подвійний запис на рівні бази даних за допомогою PostgreSQL exclusion constraint. Наші замовники — клініки, салони краси, коворкінги та сервісні центри — отримують рішення, яке масштабується під тисячі слотів і адаптується під будь-які бізнес-правила: групові заняття, кілька ресурсів на одну послугу, складні розклади з перервами. За 15+ реалізованих проєктів ми не бачили жодного збою через double-booking. Середнє навантаження — до 10 000 броней на день, час відгуку API менше 200 мс. Адміністратори економлять до 70% часу на управлінні розкладом, а автоматичні нагадування знижують відсоток неявок на 25%.
Чому exclusion constraint кращий за інші підходи?
Порівняємо три підходи до запобігання double-booking:
| Підхід | Надійність | Продуктивність | Складність реалізації |
|---|---|---|---|
| Перевірка на стороні додатка | Середня: можливі race condition | Висока | Низька |
| Блокування на рівні БД (SELECT FOR UPDATE) | Висока | Середня: блокує рядки | Середня |
Exclusion constraint (EXCLUDE USING gist) |
Максимальна: атомарно | Висока: одна перевірка | Висока: потребує btree_gist |
Exclusion constraint в 100 разів надійніший за перевірку на стороні додатка. Єдиний нюанс — потрібне розширення btree_gist і обробка виключення 23P01 в коді. За роки практики ми реалізували 15+ проєктів бронювання і не бачили жодного збою через double-booking.
Як працює атомарне створення бронювання?
В основі рішення — чотири сутності: ресурс (лікар, стіл, кімната), розклад (години роботи + виключення), слот (доступний час) і бронювання. Схема даних включає constraint EXCLUDE USING gist, який атомарно перевіряє перетин часових діапазонів для кожного ресурсу. При конкурентних вставках база відхиляє конфліктуючі броні, а додаток обробляє виключення і повідомляє клієнта.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookable_resources (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- staff | room | equipment | table
capacity INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
meta JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE resource_schedules (
id BIGSERIAL PRIMARY KEY,
resource_id BIGINT NOT NULL REFERENCES bookable_resources(id),
day_of_week SMALLINT,
date DATE,
is_working BOOLEAN NOT NULL DEFAULT true,
opens_at TIME NOT NULL,
closes_at TIME NOT NULL,
slot_duration INTEGER NOT NULL DEFAULT 60
);
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
resource_id BIGINT NOT NULL REFERENCES bookable_resources(id),
service_id BIGINT REFERENCES services(id),
user_id BIGINT REFERENCES users(id),
client_name VARCHAR(255) NOT NULL,
client_phone VARCHAR(50),
client_email VARCHAR(255),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'confirmed',
notes TEXT,
cancel_reason TEXT,
reminder_sent BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
EXCLUDE USING gist (
resource_id WITH =,
tsrange(starts_at, ends_at, '[)') WITH &&
) WHERE (status NOT IN ('cancelled', 'no_show'))
);
Генерація доступних слотів
class SlotGenerator
{
public function getAvailableSlots(
BookableResource $resource,
int $serviceDurationMinutes,
Carbon $date
): Collection
{
$schedule = $this->getScheduleForDate($resource, $date);
if (!$schedule || !$schedule->is_working) {
return collect();
}
$slots = collect();
$current = $date->copy()->setTimeFromTimeString($schedule->opens_at);
$closes = $date->copy()->setTimeFromTimeString($schedule->closes_at);
$duration = CarbonInterval::minutes($serviceDurationMinutes);
while ($current->copy()->add($duration)->lte($closes)) {
$slots->push($current->copy());
$current->addMinutes($schedule->slot_duration);
}
$existingBookings = Booking::where('resource_id', $resource->id)
->whereDate('starts_at', $date)
->whereNotIn('status', ['cancelled', 'no_show'])
->get();
return $slots->filter(function (Carbon $slot) use ($existingBookings, $duration) {
$slotEnd = $slot->copy()->add($duration);
return $existingBookings->every(function (Booking $booking) use ($slot, $slotEnd) {
return $slotEnd->lte($booking->starts_at) || $slot->gte($booking->ends_at);
});
})->values();
}
}
Атомарне створення бронювання
class BookingService
{
public function create(array $data): Booking
{
try {
return DB::transaction(function () use ($data) {
$booking = Booking::create([
'resource_id' => $data['resource_id'],
'starts_at' => $data['starts_at'],
'ends_at' => Carbon::parse($data['starts_at'])
->addMinutes($data['duration']),
'client_name' => $data['client_name'],
'client_phone' => $data['client_phone'],
'client_email' => $data['client_email'],
'service_id' => $data['service_id'] ?? null,
'status' => 'confirmed',
]);
BookingConfirmed::dispatch($booking);
return $booking;
});
} catch (QueryException $e) {
if (str_contains($e->getMessage(), '23P01')) {
throw new SlotAlreadyBookedException($data['starts_at']);
}
throw $e;
}
}
}
Управління розкладом з ієрархією
Розклад будується за принципом: дата-виключення перевизначає тижневий шаблон. Це дозволяє легко задати неробочі дні, відпустки або свята. Алгоритм перевіряє спочатку конкретні дати, потім день тижня.
private function getScheduleForDate(BookableResource $resource, Carbon $date): ?ResourceSchedule
{
$specific = $resource->schedules()
->whereDate('date', $date)
->first();
if ($specific) {
return $specific;
}
return $resource->schedules()
->where('day_of_week', $date->dayOfWeek)
->whereNull('date')
->first();
}
Типові ресурси та їх параметри
| Тип ресурсу | Приклад | Особливості |
|---|---|---|
| Staff (спеціаліст) | Лікар, перукар | Може надавати кілька послуг різної тривалості |
| Room (кімната) | Переговорна, зал | Часто бронюється погодинно, з погодинною оплатою |
| Equipment (обладнання) | Апарат МРТ, верстат | Потребує калібрування між сеансами |
| Table (стіл) | Ресторан, коворкінг | Можливе бронювання на частину столу (capacity) |
Які помилки найчастіше допускають при розробці бронювання?
Найпоширеніша — покладатися тільки на перевірки на стороні додатка. При конкурентних запитах це призводить до double-booking. Друга за частотою — ігнорування часових поясів: якщо сервер і клієнт у різних зонах, слоти з'їжджають. Третя — зберігання розкладу лише у вигляді дат без регулярності: кожен тиждень доводиться вводити одне й те саме вручну. Наше рішення використовує шаблони днів тижня з виключеннями, що знижує адміністрування в 10 разів.
Що входить у розробку під ключ?
- Аналіз бізнес-правил: тривалість послуг, ємність ресурсів, політики скасування та штрафи.
- Проєктування схеми даних з exclusion constraint.
- Реалізація REST API для віджета та адмінки (бекенд на Laravel).
- Розробка трикрокового віджета запису на React.
- Створення адміністративного календаря на FullCalendar.
- Налаштування автоматичних нагадувань (SMS/email), що знижують no-show до 25%.
- Повна документація API та інструкція з експлуатації.
- Тестування та деплой на ваш хостинг.
Як проходить розробка?
- Аналіз бізнес-правил (1–2 дні).
- Проєктування моделі даних (1 день).
- Реалізація API та логіки бронювання (4–6 днів).
- Розробка фронтенду віджета та адмінки (5–7 днів).
- Інтеграція повідомлень та нагадувань (1–2 дні).
- Тестування, деплой та передача документації (2–3 дні).
Загальний термін — від 2 до 4 тижнів залежно від складності. Кастомне рішення окупається за рахунок гнучкості та швидкості роботи. Ми гарантуємо відсутність double-booking і надаємо повний вихідний код.
Отримайте консультацію щодо вашого проєкту — зв'яжіться з нами. Замовте розробку під ключ: від схеми даних до віджета на сайті.







