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

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка системи онлайн-запису
Середня
від 1 тижня до 3 місяців
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

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

Онлайн-запис — це не просто форма «виберіть дату та час». Це управління розписанням спеціалістів, буферами між записами, правилами бронювання, сповіщеннями та скасуваннями. Недооцінка цієї складності призводить до подвійних записів, порожніх слотів та недовільних клієнтів.

Модель даних

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 тижні.