Брони и slot-engine
Статусы броней, переходы, public-флоу с cancel-токеном, оплачиваемые брони, concurrency-safe createBooking.
Брони и slot-engine
Запись клиента на услугу — это booking. Брони идут через concurrency-safe createBooking: SELECT FOR UPDATE на пересекающихся бронях того же ресурса + re-check time-off внутри транзакции. Это защищает от ситуации «два клиента одновременно нажали "записаться" на один слот».
Partial-unique-index на (resourceId, startAt) не используется, потому что легальные cancelled/completed-брони перекрываются с новыми, и это сломало бы constraint. Защита — на app-уровне в транзакции.
Статусы брони
| Статус | Что значит |
|---|---|
pending_payment | Создана, ждёт оплату или подтверждения от бизнеса. Слот залочен. |
confirmed | Подтверждена (оплачена или бесплатная — принята). |
cancelled | Отменена клиентом или бизнесом. Слот освобождён. |
completed | Услуга оказана. Финальное состояние. |
no_show | Клиент не пришёл. Финальное состояние. |
Переходы (в админке /account/bookings):
pending_payment ─→ confirmed ─┬─→ completed
└─→ no_show
└─→ cancelled
pending_payment ─→ cancelled
Бесплатные и платные брони
| Услуга | Что происходит при бронировании |
|---|---|
requiresPayment=false (бесплатная) | Сразу создаётся бронь со статусом confirmed (если бизнес не настроил ручное подтверждение). Никакого мерчанта/чекаута. |
requiresPayment=true, prepaymentPercent=100 | Создаётся commerce_order на полную сумму, бронь — pending_payment. Webhook оплаты → confirmed. |
requiresPayment=true, prepaymentPercent<100 | Создаётся commerce_order на сумму предоплаты, остаток платится на месте. Бронь идёт через тот же чекаут и тот же merchant_account, что и заказы товаров. |
Бронь и заказ связаны через booking.orderId ↔ commerce_order.id. Webhook commerce_order.paid триггерит booking.confirmed.
Public-флоу записи
Клиенту не нужен аккаунт Framix. Он:
- Открывает страницу услуги (
/s/<slug>) или общается с агентом. - Выбирает дату/время из slot-picker'а (
AppBookingWidget/ day-picker в чате / inline-keyboard в TG/VK). - Заполняет имя + email и/или телефон.
- Если услуга платная — попадает на чекаут (
AppCheckoutWidget), оплачивает. - Получает email с подтверждением и cancel-токеном.
Cancel-токен
В email — ссылка вида /api/bookings/public/<cancel_token>. По этой ссылке клиент:
- видит свою бронь без логина (
GET /api/bookings/public/[token]); - может отменить (
POST /api/bookings/public/cancel).
После отмены токен инвалидируется. Поздняя отмена (после cancellationPolicyHours) помечается [late_cancel] в cancelReason — Framix только фиксирует факт, политика возврата на стороне бизнеса.
Public-endpoints
| Endpoint | Назначение |
|---|---|
GET /api/bookings/public/available-slots | Свободные слоты по serviceId или (serviceSlug + projectId). Учитывает visibility. Max 14-дневный диапазон, до 30 слотов в ответе. |
POST /api/bookings/public/book | Создание брони, возвращает cancelToken и (для платных) ссылку на чекаут. |
GET /api/bookings/public/[token] | Просмотр своей брони по cancel-token. |
POST /api/bookings/public/cancel | Отмена по token. |
Owner-управление
/account/bookings или /account/services → вкладка «Записи».
- Список с фильтрами (статус, услуга, поиск по клиенту).
- Группировка по дням.
- Drawer-деталка с переходами статусов и контактами клиента.
- Кнопка «Открыть карточку клиента» — переход в единый клиент workspace.
Cancel-токен не отображается в owner-UI — это секрет, выданный клиенту по email.
Уведомления
Триггеры dispatchNotification:
| Событие | Кому |
|---|---|
| Новая бронь | владельцу — in-app + email + (опционально) Telegram-DM / VK-DM |
| Бронь подтверждена / оплачена | клиенту — email |
| Отмена брони (клиент или бизнес) | владельцу |
| Напоминание о завтрашней брони | клиенту (cron, поле reminderSentAt защищает от дублей) |
Настройки доставки — /account/notifications/settings.
Что хранится
| Таблица | Что |
|---|---|
booking | Запись клиента: ресурс, услуга, время, контакты, статус, токен отмены |
Связь с другими сущностями
booking.orderId→commerce_order(для платных броней).booking.collectionId→project_collection(лид в CRM, автоматически).booking.clientId→workspace_client(единый клиент workspace, через upsert по email/phone).booking.agentId/booking.sessionId→ai_agents/ai_chat_session(если запись пришла из чата агента).
Виджет на сайте
Чтобы клиенты могли записываться прямо со страницы услуги:
- В редакторе сайта добавьте блок «Запись на услугу» из палитры (раздел «Услуги»).
- В свойствах блока выберите услугу из каталога.
- На опубликованной странице появится календарь с днями и slot-picker для выбранного дня.
Альтернативно — на странице услуги (/s/<slug>) есть встроенный slot-picker, и можно дать на неё прямую ссылку из лендинга.
Если slot-engine ничего не отдаёт
Проверьте по порядку:
- Услуга активна (
isActive=true). - Ресурс активен (если есть).
- Привязка услуга ↔ ресурс есть (в карточке услуги отмечен мастер).
- Есть availability_rule для этого ресурса (или для всех — если ресурса нет).
- Нет глобального time-off, накрывающего запрашиваемый диапазон.
- Lead-часы / horizon-дни не отрезают всё (если
bookingLeadHours=2, слоты на «прямо сейчас» не покажутся). - На вкладке «Расписание» в превью-сетке должны быть зелёные дни.
Если всё ОК и слотов всё равно нет — посмотрите Network в браузере, эндпоинт GET /api/bookings/public/available-slots вернёт код ошибки или пустой массив с диагностикой.