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 і надаємо повний вихідний код.
Отримайте консультацію щодо вашого проєкту — зв'яжіться з нами. Замовте розробку під ключ: від схеми даних до віджета на сайті.







