Запись на услугу - слоты, занятость, защита от двойной брони
Делаем онлайн-запись на услугу: хранение броней, сборка свободных слотов дня и защита от двойной записи на одно время.
Что нужно знать заранее
Бронь - это отрезок времени у конкретного ресурса: мастера, кабинета, машины. Хранят её отдельной записью с началом, концом и ссылкой на ресурс, а не полем в карточке клиента.
Свободные слоты нигде не хранят, а считают на лету при каждом показе формы. График работы и длительность услуги дают весь список слотов дня, а брони вычитают из него занятые отрезки.
Две заявки на один и тот же слот приходят заметно чаще, чем кажется. Проверка занятости и запись брони - это два разных запроса, и между ними успевает вклиниться чужая заявка.
Шаги
- Завести хранилище броней с началом, концом отрезка и ссылкой на ресурс.
- Описать график работы каждого ресурса и длительность оказания каждой услуги.
- Собрать слоты дня и отсеять занятые одним запросом к базе.
- Записывать бронь с уникальным индексом на пару ресурса и времени.
- Отправить клиенту уведомление о записи и напоминание за сутки до приёма.
Решение
Собираем слоты рабочего дня:
$slots = [];$cursor = new \Bitrix\Main\Type\DateTime($day . ' 10:00:00', 'd.m.Y H:i:s');$end = new \Bitrix\Main\Type\DateTime($day . ' 19:00:00', 'd.m.Y H:i:s');while ($cursor < $end) { $slots[] = clone $cursor; $cursor->add('+30 minutes'); // шаг равен длительности услуги}Границы дня берут из графика ресурса, а не пишут числами в коде. Иначе первая же суббота с другим расписанием потребует правки кода вместо правки настройки.
Отсеиваем занятое одним запросом:
$busy = BookingTable::getList(['filter' => ['=RESOURCE_ID' => $resourceId, '><DATE_FROM' => [$dayStart, $dayEnd]], 'select' => ['DATE_FROM']])->fetchAll();$taken = array_flip(array_map(fn($r) => $r['DATE_FROM']->format('H:i'), $busy));$free = array_filter($slots, fn($s) => !isset($taken[$s->format('H:i')]));Занятость проверяют одним запросом на весь день, а не запросом на каждый слот. Двадцать слотов в цикле дают двадцать запросов и заметную задержку на странице записи.
Защищаемся от двойной брони:
ALTER TABLE vendor_booking ADD UNIQUE INDEX ux_resource_time (UF_RESOURCE_ID, UF_DATE_FROM);-- база отклонит вторую запись на то же время, даже если проверка её пропустилаУникальный индекс - единственная надёжная защита от гонки заявок. Проверка в коде нужна для понятного сообщения, а окончательное слово остаётся за базой данных.
Записываем бронь и ловим отказ:
$result = BookingTable::add(['UF_RESOURCE_ID' => $resourceId, 'UF_DATE_FROM' => $slot, 'UF_CLIENT' => $phone]);if (!$result->isSuccess()) { echo 'Это время только что заняли, выберите другое'; // отказ по индексу}Сообщение об отказе пишут человеческим языком и сразу предлагают другое время. Ошибка базы в ответе выглядит для клиента как поломка сайта, а не как занятый слот.
Уведомляем клиента и мастера:
\CEvent::Send('VENDOR_BOOKING', SITE_ID, ['PHONE' => $phone, 'DATE' => $slot->format('d.m.Y H:i'), 'MASTER' => $master]);// напоминание за сутки отправляет отдельный агент по тем же даннымТипичные проблемы
Два клиента записались на одно и то же время.
Проверка занятости и запись брони идут разными запросами, а между ними успевает чужая заявка. Настоящую защиту здесь даёт уникальный индекс на пару ресурса и времени.
Страница выбора времени открывается несколько секунд.
Занятость каждого слота проверяется своим отдельным запросом к базе данных сайта. Все брони дня забирают одним запросом и сравнивают в коде.
Клиент видит время не то, которое выбрал в форме.
Время сохранено в поясе сервера, а показано в поясе посетителя сайта. Для записи на услугу пояс фиксируют настройкой и не пересчитывают по посетителю.
Отменённая запись не освобождает слот.
Отмена меняет признак брони, а фильтр занятости этот признак не учитывает. Отменённые брони либо удаляют, либо явно исключают из выборки занятого времени.
В праздничный день сайт продолжает принимать записи.
Слоты считаются от графика работы без проверки дня по производственному календарю. Нерабочие дни отсеивают до сборки слотов, а не после неё.
Частые вопросы
Где хранить записи на услугу?
В отдельной таблице или highload-блоке с началом, концом и ссылкой на ресурс. Инфоблок здесь избыточен: разделы и права записям на приём не нужны.
Нужно ли хранить свободные слоты?
Нет, их считают на лету из графика работы и длительности услуги. Хранение свободных слотов заводит вторую копию расписания, которая рассинхронизируется.
Как учесть перерыв на обед?
Описать его отрезком в графике ресурса и вычитать так же, как бронь. Отдельной логики для перерывов при этом не появляется.
Что делать с записями на несколько услуг подряд?
Считать длительность как сумму услуг и проверять весь отрезок целиком. Проверка только первого слота пропускает наложение на следующую бронь.
Как напомнить клиенту о записи?
Агентом раз в час: он выбирает брони на завтра и отправляет письмо или сообщение. Признак отправки хранят в самой брони, иначе напоминание уйдёт дважды.
Смежное
- Даты в коде - оглавление подтемы
- Даты в коде: объект, часовой пояс, фильтр по периоду - работа с отрезками времени
- Рабочий календарь: график, праздники, расчёт срока - нерабочие дни и график
- Дата сохранилась не та: разбор причин - когда время уехало на часы
- Highload-блок на практике: создание, поля, привязка к свойству - где держать брони
- Свой агент и задание по расписанию: создание, шаг, защита - напоминания клиенту перед приёмом
- Своё письмо с сайта: тип события, шаблон, поля и отправка - уведомление о записи
- Ядро D7 - устройство ядра и работы с датами целиком