Розробка системи онлайн-запису
Онлайн-запис — це не просто форма «виберіть дату та час». Це управління розписанням спеціалістів, буферами між записами, правилами бронювання, сповіщеннями та скасуваннями. Недооцінка цієї складності призводить до подвійних записів, порожніх слотів та недовільних клієнтів.
Модель даних
CREATE TABLE staff (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
timezone VARCHAR(64) DEFAULT 'Europe/Moscow'
);
-- Робоче розписання спеціаліста (шаблон)
CREATE TABLE working_hours (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
day_of_week SMALLINT NOT NULL, -- 0=Пн, 1=Вт ... 6=Сб
start_time TIME NOT NULL,
end_time TIME NOT NULL
);
-- Виключення: вихідні дні та спеціальні години
CREATE TABLE schedule_overrides (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
date DATE NOT NULL,
is_day_off BOOLEAN DEFAULT FALSE,
start_time TIME,
end_time TIME
);
CREATE TABLE services (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
duration_min INT NOT NULL, -- тривалість послуги в хвилинах
buffer_after INT DEFAULT 0, -- буфер після прийому
capacity INT DEFAULT 1 -- скільки клієнтів одночасно
);
CREATE TABLE appointments (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
service_id BIGINT REFERENCES services(id),
client_id BIGINT REFERENCES clients(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(32) DEFAULT 'pending', -- pending/confirmed/cancelled/completed
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON appointments(staff_id, starts_at);
Алгоритм генерації вільних слотів
Ключова логіка системи — генерація доступних часових вікон. Алгоритм:
- Беремо робочі години спеціаліста на дату
- Вичитуємо вже займяті інтервали (confirmed + pending записи)
- Розбиваємо залишилося час на слоти з кроком, рівним тривалості послуги + буфер
- Фільтруємо слоти, які починаються в минулому або порушують мінімальне advance-час
class SlotGenerator {
public function getAvailableSlots(
Staff $staff,
Service $service,
Carbon $date
): array {
$tz = new \DateTimeZone($staff->timezone);
// Перевіряємо override (вихідний або особливі години)
$override = ScheduleOverride::where('staff_id', $staff->id)
->where('date', $date->toDateString())
->first();
if ($override?->is_day_off) {
return [];
}
// Робоче вікно на цю дату
$dayOfWeek = $date->dayOfWeek;
$workHours = $override ?? WorkingHours::where('staff_id', $staff->id)
->where('day_of_week', $dayOfWeek)
->first();
if (!$workHours) {
return [];
}
$windowStart = Carbon::parse($date->toDateString() . ' ' . $workHours->start_time, $tz);
$windowEnd = Carbon::parse($date->toDateString() . ' ' . $workHours->end_time, $tz);
// Заняті інтервали
$busy = Appointment::where('staff_id', $staff->id)
->whereIn('status', ['pending', 'confirmed'])
->whereBetween('starts_at', [$windowStart, $windowEnd])
->orderBy('starts_at')
->get(['starts_at', 'ends_at'])
->map(fn($a) => [
'start' => Carbon::parse($a->starts_at),
'end' => Carbon::parse($a->ends_at),
])
->toArray();
$slotDuration = $service->duration_min + $service->buffer_after;
$minAdvance = now()->addMinutes(30); // не можна записатися менше ніж за 30 хвилин
$slots = [];
$cursor = clone $windowStart;
while ($cursor->copy()->addMinutes($service->duration_min)->lte($windowEnd)) {
$slotEnd = $cursor->copy()->addMinutes($service->duration_min);
$occupied = collect($busy)->first(fn($b) =>
$cursor->lt($b['end']) && $slotEnd->gt($b['start'])
);
if (!$occupied && $cursor->gt($minAdvance)) {
$slots[] = $cursor->toIso8601String();
}
$cursor->addMinutes($slotDuration);
}
return $slots;
}
}
API для виджета запису
GET /api/booking/slots?staff_id=3&service_id=1&date=2025-04-10
POST /api/booking/appointments
GET /api/booking/appointments/{id}
POST /api/booking/appointments/{id}/cancel
Приклад відповіді /api/booking/slots:
{
"date": "2025-04-10",
"staff": { "id": 3, "name": "Анна Петрова" },
"service": { "id": 1, "name": "Консультація", "duration_min": 60 },
"slots": [
"2025-04-10T09:00:00+03:00",
"2025-04-10T10:00:00+03:00",
"2025-04-10T12:00:00+03:00",
"2025-04-10T15:00:00+03:00"
]
}
Блокування від гонки умов
Коли два клієнти одночасно вибирають один слот — потрібна блокування. Варіант з оптимістичною перевіркою та транзакцією:
public function bookAppointment(BookingRequest $data): Appointment {
return DB::transaction(function() use ($data) {
// Песимістична блокування: SELECT FOR UPDATE
$conflict = Appointment::where('staff_id', $data->staff_id)
->whereIn('status', ['pending', 'confirmed'])
->where('starts_at', '<', $data->ends_at)
->where('ends_at', '>', $data->starts_at)
->lockForUpdate()
->first();
if ($conflict) {
throw new SlotUnavailableException('Слот уже займятий');
}
return Appointment::create([
'staff_id' => $data->staff_id,
'service_id' => $data->service_id,
'client_id' => $data->client_id,
'starts_at' => $data->starts_at,
'ends_at' => $data->ends_at,
'status' => 'pending',
]);
});
}
Сповіщення
Ланцюг сповіщень для запису:
// Одразу після створення
class AppointmentBooked implements ShouldQueue {
public function handle(AppointmentCreated $event): void {
$appt = $event->appointment;
// SMS клієнту
SmsService::send($appt->client->phone, "Запис підтверджена на " . $appt->starts_at->format('d.m у H:i'));
// Email спеціалісту
Mail::to($appt->staff->email)->send(new NewAppointmentMail($appt));
}
}
// Нагадування за добу (cron або scheduled job)
Appointment::confirmed()
->whereBetween('starts_at', [now()->addDay()->startOfDay(), now()->addDay()->endOfDay()])
->each(fn($a) => SmsService::send($a->client->phone, "Нагадуємо про запис завтра у " . $a->starts_at->format('H:i')));
Вбудовуваний віджет
Для вставки на сторонні сайти віджет реалізується як автономний JS-скрипт:
<div id="booking-widget" data-key="abc123" data-staff="3"></div>
<script src="https://booking.example.com/widget.js" async></script>
// widget.js — shadow DOM для ізоляції стилів
class BookingWidget extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const apiKey = this.dataset.key;
const staffId = this.dataset.staff;
// Монтуємо React-додаток усередині shadow root
const root = document.createElement('div');
shadow.appendChild(root);
ReactDOM.createRoot(root).render(
<BookingApp apiKey={apiKey} staffId={staffId} />
);
}
}
customElements.define('booking-widget', BookingWidget);
Терміни реалізації
Базова система для одного спеціаліста та однієї послуги з веб-інтерфейсом та SMS-сповіщеннями: 1–1,5 тижня. Підтримка кількох спеціалістів, послуг, групових записів та вбудовуваного віджета: 2,5–3 тижні. Інтеграція з Google Calendar, оплата брони, кабінет клієнта з історією: плюс 1–2 тижні.







