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







