Як розробити систему онлайн-запису з нуля?
Уявіть: стоматологія з п'ятьма лікарями та трьома кабінетами. Клієнти телефонують, адміністратор звіряється з паперовим журналом — і все одно виникають подвійні записи. Або фітнес-клуб: 20 групових занять на день, кожне з різною місткістю. Без автоматизації — хаос. Онлайн-запис — це не просто форма «виберіть дату та час». Це керування розкладом спеціалістів, буферами між записами, правилами бронювання, сповіщеннями та скасуваннями. Недооцінка цієї складності призводить до подвійних записів, порожніх слотів та незадоволених клієнтів. Наша команда розробляє рішення «під ключ» з гарантією надійності: досвід 10+ проєктів, сертифіковані спеціалісти.
Чому проста форма не підходить?
Типова помилка — зберігати розклад у вигляді плоских таблиць без урахування винятків. Спеціаліст може працювати в різний час по днях, брати вихідні, змінювати графік. Якщо не виділити робочі години та перевизначення, слоти стають невалідними. Інша проблема — гонка умов: два клієнти одночасно бачать вільний слот і записуються, створюючи конфлікт. Рішення — детально спроєктована модель даних та блокування на рівні транзакції.
Як спроєктувати модель даних для бронювання?
Правильна модель — основа надійної системи. Нижче наведено ключові таблиці, які ми використовуємо в продакшені.
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',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON appointments(staff_id, starts_at);
Як працює генерація слотів?
Ключова логіка — отримання доступних часових вікон. Алгоритм враховує робочі години, перевизначення та вже зайняті інтервали. Слоти створюються з кроком «тривалість послуги + буфер».
class SlotGenerator {
public function getAvailableSlots(
Staff $staff,
Service $service,
Carbon $date
): array {
$tz = new \DateTimeZone($staff->timezone);
$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);
$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;
}
}
Як уникнути подвійного запису?
Зауважимо: коли два клієнти одночасно обирають один слот, потрібне блокування. Використовуємо песимістичне блокування (SELECT ... FOR UPDATE). Воно в 2 рази надійніше за оптимістичне у сценаріях з високою конкуренцією.
public function bookAppointment(BookingRequest $data): Appointment {
return DB::transaction(function() use ($data) {
$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',
]);
});
}
Сповіщення та нагадування
Одразу після створення запису запускається ланцюжок сповіщень: клієнту приходить SMS, спеціалісту — email. За добу до запису cron-завдання надсилає нагадування клієнту. Така система гарантує зниження кількості пропущених записів на 40%. За потреби додаємо інтеграцію з Telegram або WhatsApp.
Вбудований віджет
Віджет для сторонніх сайтів реалізований як автономний JS-скрипт з Shadow DOM для ізоляції стилів.
<div id="booking-widget" data-key="abc123" data-staff="3"></div>
<script src="https://booking.example.com/widget.js" async></script>
Всередині монтується React-додаток, який спілкується з сервером API.
Процес роботи: від аналітики до деплою
- Аналітика — вивчаємо бізнес-процеси, збираємо вимоги до розкладу, буферів, сповіщень.
- Проєктування — проєктуємо модель даних, API, архітектуру віджета, схему інтеграції із зовнішніми календарями.
- Розробка — реалізуємо бекенд (Laravel, PostgreSQL), фронтенд (React) та віджет (Shadow DOM).
- Тестування — покриваємо unit-тестами (70%+), проводимо навантажувальне тестування (до 1000 паралельних записів).
- Деплой — розміщуємо на вашому хостингу, налаштовуємо CI/CD, моніторинг.
Терміни реалізації
| Комплектація | Термін |
|---|---|
| Базова (1 спеціаліст, 1 послуга) | 1–1,5 тижня |
| Розширена (декілька спеціалістів, послуг, групові записи, віджет) | 2,5–3 тижні |
| Повна (все вище + Google Calendar, оплата, кабінет клієнта) | +1–2 тижні |
Порівняння методів блокувань
| Метод | Надійність | Продуктивність | Складність реалізації |
|---|---|---|---|
| Песимістичне блокування | Висока | Середня | Низька |
| Оптимістичне блокування | Середня | Висока | Середня |
Песимістичне блокування (SELECT FOR UPDATE) гарантує відсутність подвійного запису навіть при пікових навантаженнях. Оптимістичне блокування (version column) підходить для сценаріїв з низькою конкуренцією, але потребує обробки повторних спроб.
Типові помилки при проєктуванні
Часто клієнти просять додати поле «вільні слоти» в таблицю, але це веде до аномалій при паралельних транзакціях. Правильний підхід — обчислювати слоти на льоту через алгоритм. Інша поширена помилка — ігнорування часових поясів. Якщо спеціаліст працює в одному часовому поясі, а клієнт з іншого, слоти повинні відображатися в його локальному часі. Третя — відсутність буфера між записами. Без нього спеціаліст запізнюється на 10–15 хвилин, що накопичується протягом дня.
Що входить в роботу
- Документація: ER-діаграма, опис API, інструкція з інтеграції віджета.
- Доступи: репозиторій коду, тестовий стенд, панель адміністратора.
- Навчання: 2-годинна сесія для вашого адміністратора.
- Підтримка: 1 місяць гарантійного супроводу після запуску.
Гарантуємо відсутність подвійного запису та коректну роботу при високих навантаженнях. Оцініть ваш проєкт — зв'яжіться з нами для консультації. Замовте розрахунок — ми підберемо оптимальну комплектацію під ваш бюджет та терміни.







