Разработка системы онлайн-записи
Онлайн-запись — это не просто форма «выберите дату и время». Это управление расписанием специалистов, буферами между записями, правилами бронирования, уведомлениями и отменами. Недооценка этой сложности приводит к двойным записям, пустым слотам и недовольным клиентам.
Модель данных
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 недели.







