Реалізація системи бронювання та запису на веб-сайті
Система бронювання — це управління часовими слотами, які неможливо продати двічі. Клініка, перукарня, коворкінг, прокат обладнання, ресторан — скрізь одна логіка: ресурс (фахівець, стіл, переговірна, автомобіль) доступний у конкретний час, і цей слот повинен бути займаний рівно одним бронюванням. Технічно завдання близьке до обліку складських залишків, але з часовим виміром замість кількісного.
Концептуальна модель
Чотири сутності:
Resource — те, що бронюється (майстер, кабінет, інвентарна одиниця) Schedule — робочий розклад ресурсу (години роботи, вихідні, винятки) Slot — часовий відрізок доступності (може генеруватися з розпису або задаватися явно) Booking — бронювання слота під конкретного клієнта
Схема даних
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, -- 0=нд, 1=пн ... 6=сб; NULL = конкретна дата
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',
-- pending | confirmed | cancelled | no_show | completed
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'))
);
EXCLUDE USING gist — обмеження PostgreSQL на перетин часових діапазонів. Вимагає розширення btree_gist. Це єдиний надійний спосіб виключити подвійне бронювання на рівні бази даних.
CREATE EXTENSION IF NOT EXISTS btree_gist;
Генерація доступних слотів
Слоти не зберігаються в базі — вони обчислюються з розпису мінус існуючі бронювання:
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();
}
}
Атомарне створення бронювання
Обмеження EXCLUDE USING gist ловить конкурентні вставки, але потрібно обробляти винятки PostgreSQL:
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) {
// Код 23P01 — порушення обмеження виключення в PostgreSQL
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();
}
Нагадування
Автоматичні нагадування зменшують відсоток no-show:
// Запускається кожну годину
class SendBookingReminders
{
public function handle(): void
{
$upcoming = Booking::where('status', 'confirmed')
->where('reminder_sent', false)
->whereBetween('starts_at', [
now()->addHours(23),
now()->addHours(25),
])
->get();
foreach ($upcoming as $booking) {
SendBookingReminder::dispatch($booking);
$booking->update(['reminder_sent' => true]);
}
}
}
Нагадування — за 24 години через SMS та email. Можна додати друге нагадування за 2 години. Канал залежить від інфраструктури: SMS через SMSC.ru, Twilio; email через Mailgun, SES, SMTP.
Скасування та перенесення
Політика скасування налаштовується: безплатно за 24+ години, штраф або заборона — менше 2 годин. При скасуванні — автоматичне сповіщення фахівцю та клієнту.
Перенесення — це скасування + нове бронювання. Важливо: при перенесенні старий слот звільняється атомарно зі створенням нового в одній транзакції.
Віджет запису на веб-сайті
Триетапний процес:
- Вибір послуги — список з тривалістю та ціною
- Вибір часу — календар з доступними слотами (завантажується через AJAX)
- Контактні дані — ім'я, телефон, email, примітка
Слоти завантажуються за запитом при виборі дати:
GET /api/bookings/availability?resource_id=1&service_id=3&date=2025-04-15
Відповідь — масив доступних часових міток. Фронтенд відображує кнопки-слоти.
Адміністративний календар
Для адміністратора/фахівця — вид розпису у форматі тижневого календаря. Реалізується через FullCalendar (JS-бібліотека) з користувацьким джерелом подій:
GET /api/admin/bookings/calendar?resource_id=1&start=2025-04-14&end=2025-04-21
Повертає события у форматі FullCalendar, включаючи блокування та перерви.
Терміни реалізації
- Базова модель: ресурси + розпис + бронювання + обмеження конфлікту: 4–6 днів
- Генерація слотів + API для віджета: 2–3 дні
- Віджет запису (фронтенд): 3–4 дні
- Нагадування + сповіщення про скасування: 1–2 дні
- Адміністративний календар: 2–3 дні
- Онлайн-платіж при бронюванні (передплата або депозит): +3–4 дні
Повна система: 2–4 тижні.







