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







