Перейти к основному содержимому

UC-CAL-003. Инвестор бронирует слот с витрины

Назначение

Описывает самозапись: инвестор (или аноним, регистрируемый по телефону на лету) выбирает на витрине свободный слот агента и бронирует его. Бронирование атомарно связывает три сущности — слот переходит Available → Booked, создаётся запись (Pending или сразу Confirmed по AutoConfirm слота) и календарное событие агента (Planned, Origin = Appointment). CRM узнаёт о записи только по событию appointment-created (get-or-create клиента/лида + лента лида), уведомления доставляет notifications. Связан с контекстом calendar.

Metadata

ПолеЗначение
IDUC-CAL-003
Контекстcalendar (см. docs/06-services/calendar/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (авторизованный) или Гость (аноним — lazy-регистрация по телефону, как в create-consultation-request)
Permissions / scopeПросмотр витрины и бронирование — публичные (rate limit по IP+телефону); permission-модель tenant-а не применяется
Tenantnull — инвестор вне tenant (ADR-0052); запись создаётся в tenant компании агента
Record-levelСозданная запись доступна инвестору по InvestorUserId (../api/investor/list-my-appointments.md)

Предусловие

  • Агент опубликовал слоты (UC-CAL-002); интересующий слот Available и до его начала больше MinLeadTimeMinutes.
  • У анонима есть телефон для lazy-регистрации; авторизованный инвестор действует под своим JWT.
  • Сервис identity доступен (для lazy-регистрации анонима).

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементВитрина (страница агента / проекта / объекта) → «Записаться на показ/консультацию»

Основной Поток

  1. Посетитель открывает витрину свободных слотов (../api/public/list-available-slots.md, аноним): фильтры — агент, проект, объект недвижимости, вид встречи (Kind), диапазон дат. Система возвращает Available-слоты с временем (StartAt/EndAt/TimeZone), видом, привязками (ProjectId/PropertyObjectId/Location) и display-данными агента (ФИО из реплики identity); PII клиентов витрина не отдаёт.
  2. Посетитель выбирает слот и заполняет форму записи: телефон и ФИО (аноним) либо ничего дополнительно (авторизованный инвестор), опционально Comment («интересует 2-комнатная…», ≤ 1024); клиент вызывает ../api/public/book-slot.md.
  3. Система проверяет вход (формат телефона E.164, длина Comment) и rate limit по IP+телефону — превышение → CAL_RATE_LIMITED.
  4. Для анонима система выполняет lazy-регистрацию через identity (get-or-create пользователя-инвестора по телефону); недоступность identity → IDENTITY_UNAVAILABLE (503, A5). Авторизованный инвестор идентифицируется JWT.
  5. Система проверяет доступность слота: статус Available (иначе CAL_SLOT_NOT_AVAILABLE; проигранная гонка — SLOT_ALREADY_BOOKED, A1), lead time now ≤ StartAt − MinLeadTimeMinutes (иначе CAL_SLOT_LEAD_TIME_VIOLATION, A3) и антиспам-лимиты инвестора (INV-APT-2: не более 1 активной записи на пару «инвестор + агент + вид», не более 3 активных записей суммарно — иначе APPOINTMENT_DUPLICATE, A2).
  6. В одном согласованном изменении система: переводит слот Available → Booked (условный переход — выигрывает ровно один запрос, INV-SLT-3); создаёт запись со снапшотом времени слота (StartAt/EndAt/TimeZone/Kind иммутабельны, INV-APT-5) — статус Confirmed при AutoConfirm = trueConfirmedAt) либо Pending с дедлайном решения агента PendingExpiresAt = min(CreatedAt + 24 ч, StartAt − MinLeadTimeMinutes); создаёт календарное событие агента (Planned, Origin = Appointment, время/вид/место из слота) и материализует напоминания по офсетам события (event-reminder.md); публикует pin.calendar.appointment-created.v1 (при auto-confirm — также pin.calendar.appointment-confirmed.v1).
  7. Система возвращает результат: идентификатор записи, статус (Pending/Confirmed), снапшот времени, display-данные агента. Инвестор видит подтверждение («ожидает решения агента» / «встреча подтверждена»).
  8. Асинхронно (< 1 мин штатно): crm по appointment-created делает get-or-create клиента/лида по телефону и пишет ленту лида, включая Comment инвестора (../../crm/consumers/calendar-appointments.md); notifications уведомляет агента о новой записи и инвестора о статусе (../../notifications/consumers/calendar-events.md). Запись в календаре агента видна сразу — лаг касается только CRM-проекции и уведомлений.

Альтернативные Потоки

A1. Гонка за слот

  1. Два посетителя одновременно бронируют один слот: условный переход Available → Booked выигрывает ровно один запрос; проигравший получает SLOT_ALREADY_BOOKED (409), состояние консистентно — вторая запись не создана даже частично.
  2. Клиент проигравшего обновляет витрину и предлагает выбрать другой слот. Завершается или возвращается к шагу 1.

A2. Антиспам-лимиты

  1. У инвестора уже есть активная запись к этому агенту того же вида, либо активных записей уже 3 → отказ APPOINTMENT_DUPLICATE (409). Клиент показывает существующие записи инвестора (../api/investor/list-my-appointments.md) и предлагает отменить/перенести лишнюю (UC-CAL-005).

A3. Слот стал недоступен

  1. Слот истёк или заархивирован между просмотром витрины и бронированием → CAL_SLOT_NOT_AVAILABLE; до начала меньше MinLeadTimeMinutesCAL_SLOT_LEAD_TIME_VIOLATION. Клиент обновляет список слотов.

A4. Повтор запроса бронирования

  1. Повторный book-slot того же инвестора на тот же слот (double-click, retry) отклоняется структурно: на слот не более одной активной записи (INV-APT-1), на пару «инвестор + агент + вид» — не более одной (INV-APT-2). Дублей записи не возникает; клиент показывает уже созданную запись.

A5. Identity недоступен

  1. Lazy-регистрация анонима не выполнена → IDENTITY_UNAVAILABLE (503); бронирование отклонено целиком, слот остаётся Available. Витрина продолжает работать read-only; посетитель повторяет попытку позже.

A6. Дальше — no-slot путь

  1. Посетителю не подходит ни один слот («время обсудим») — он оставляет заявку на консультацию без слота: create-consultation-request (мягкий PreferredAt-хинт). Оба входа воронки сходятся в CRM в одном клиенте/лиде — get-or-create по телефону на стороне CRM-консюмера. Завершается вне календаря.

Постусловие

  • Слот Booked; запись PendingPendingExpiresAt) или ConfirmedConfirmedAt) со снапшотом времени слота; календарное событие агента Planned (Origin = Appointment, 1:1 с записью); reminder-строки материализованы — всё в одном согласованном изменении.
  • Опубликовано appointment-created (+ appointment-confirmed при auto-confirm); CRM создала/нашла клиента и лида, лента лида пополнена; уведомления доставлены.
  • Аноним получил учётную запись инвестора (lazy-регистрация) и видит запись в «Мои записи».

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400Формат телефона/ФИО/CommentПодсветить поля формы
CAL_SLOT_NOT_FOUND404Слот не существуетОбновить витрину
SLOT_ALREADY_BOOKED409Гонка за слот проиграна (A1)Предложить другой слот
CAL_SLOT_NOT_AVAILABLE409Слот Expired/Archived (A3)Обновить витрину
CAL_SLOT_LEAD_TIME_VIOLATION409До начала меньше MinLeadTimeMinutes (A3)Предложить более поздний слот
APPOINTMENT_DUPLICATE409Антиспам INV-APT-2 (A2)Показать активные записи инвестора
CAL_RATE_LIMITED429Превышен rate limit по IP+телефону (шаг 3)Повторить позже (retry с backoff)
IDENTITY_UNAVAILABLE503Identity недоступен при lazy-регистрации (A5)Повторить позже

Полная таблица ошибок бронирования — ../api/public/book-slot.md.

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
AppointmentCreatedEvent.v1pin.calendar.appointment-created.v1Создание записи (шаг 6)crm (get-or-create клиента/лида + лента), notifications (AppointmentCreated)
AppointmentConfirmedEvent.v1pin.calendar.appointment-confirmed.v1Auto-confirm при бронировании (шаг 6)crm (лента лида), notifications (AppointmentConfirmed)
CalendarEventCreatedEvent.v1pin.calendar.event-created.v1Создание порождённого события агента (шаг 6)notifications

Ключ партиционирования — AppointmentId (порядок событий одной записи гарантирован). Payload — без PII; исключение — Comment инвестора в appointment-created (класс Internal, маскирование в логах): он нужен CRM для ленты лида.

Acceptance Criteria

#КритерийПроверка
AC1Бронирование Available-слота с AutoConfirm = true атомарно даёт: слот Booked, запись Confirmed, событие Planned (Origin = Appointment), reminder-строки, appointment-created + appointment-confirmedинтеграционный тест auto-confirm
AC2Бронирование слота с AutoConfirm = false даёт запись Pending с PendingExpiresAt = min(CreatedAt + 24 ч, StartAt − MinLeadTimeMinutes)тест ручного подтверждения
AC3Два одновременных book-slot на один слот → ровно одна запись; второй запрос получает SLOT_ALREADY_BOOKED, частичных данных неттест гонки (A1)
AC4Вторая активная запись на пару «инвестор + агент + вид» и четвёртая активная запись инвестора → APPOINTMENT_DUPLICATEнегативные тесты антиспама (A2)
AC5Бронирование за 30 минут до начала при MinLeadTimeMinutes = 60CAL_SLOT_LEAD_TIME_VIOLATIONнегативный тест lead time (A3)
AC6Аноним с новым телефоном получает учётную запись (lazy-регистрация) и созданная запись привязана к ней; повторное бронирование с тем же телефоном использует существующего пользователятест lazy-регистрации
AC7При недоступности identity бронирование анонима отклоняется IDENTITY_UNAVAILABLE, слот остаётся Available (A5)тест отказа зависимости
AC8По appointment-created CRM делает get-or-create клиента/лида по телефону и пишет ленту с Comment; слотовая запись и no-slot заявка с одним телефоном сходятся в одном клиентеинтеграционный тест воронки (A6)
AC9Снапшот времени записи неизменяем: последующая архивация/изменение параметров батча слота не меняет StartAt/EndAt записитест иммутабельности снапшота

Наблюдаемость

  • Логи (без PII). slot-booked (slotId, appointmentId, agentUserId, investorUserId, tenantId, kind, autoConfirm); телефон — маска +7***NNNN; Comment в логи не пишется.
  • Метрики. calendar_bookings_total{result} (доля SLOT_ALREADY_BOOKED — интенсивность гонок); calendar_booking_rate_limited_total; calendar_lazy_registrations_total; лаг CRM/notifications консюмеров < 1 мин (alert > 5 мин).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы

  • Domain: availability-slot.md (гонка Available → Booked, INV-SLT-3/5), appointment.md (снапшот, INV-APT-1..5, PendingExpiresAt), calendar-event.md (порождённое событие, INV-CAL-3), event-reminder.md.
  • API: ../api/public/list-available-slots.md, ../api/public/book-slot.md, ../api/investor/list-my-appointments.md, ../api/investor/get-my-appointment.md.
  • События: ../events/appointment-created.md, ../events/appointment-confirmed.md, event-created.md.
  • CRM-сторона: create-consultation-request (no-slot путь, lazy-регистрация, rate limit), consumer записей — ../../crm/consumers/calendar-appointments.md.
  • Сценарии: UC-OVR-001, UC-OVR-002.
  • Связанные UC: UC-CAL-002 (публикация слотов), UC-CAL-004 (решение агента по Pending), UC-CAL-005 (отмена/перенос).