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







