Разработка системы онлайн-записи

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка системы онлайн-записи
Средняя
от 1 недели до 3 месяцев
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Разработка системы онлайн-записи

Онлайн-запись — это не просто форма «выберите дату и время». Это управление расписанием специалистов, буферами между записями, правилами бронирования, уведомлениями и отменами. Недооценка этой сложности приводит к двойным записям, пустым слотам и недовольным клиентам.

Модель данных

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);

Алгоритм генерации свободных слотов

Ключевая логика системы — генерация доступных временных окон. Алгоритм:

  1. Берём рабочие часы специалиста на дату
  2. Вычитаем уже занятые интервалы (confirmed + pending записи)
  3. Разбиваем оставшееся время на слоты с шагом, равным длительности услуги + буфер
  4. Фильтруем слоты, которые начинаются в прошлом или нарушают минимальное 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 недели.