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

POST /api/public/crm/booking-requests — Заявка на бронирование объекта (витрина)

Назначение

Публичная точка входа брони «инвестор с карточки объекта недвижимости» (UC-3): принимает с витрины (карточка объекта /lots/[id], кнопка «Забронировать») форму «Фамилия, Имя, Отчество (опционально), Телефон» + объект и заводит/обновляет воронку агента: клиент → лид → событие ленты BookingRequested. Заявка не создаёт BookingEntity напрямую: агрегат брони требует сделку (INV-BKG-2, booking.md), а сделку создаёт агент или Менеджер ПИН. Результат заявки — lazy-регистрация инвестора, карточка клиента, лид, событие ленты BookingRequested (lead.md) и активное намерение по объекту LeadBookingIntentEntity со стороной инвестора (Source = InvestorAdded, ConfirmedByInvestor = true — инвестор сам выбрал объект с витрины); намерение — основание для будущей сделки по этому объекту (lead.md). Процессный «Резерв 1 час» стартует позже, в момент создания BookingEntity командой create-booking.md (таймер — параметр программы, ReserveDurationMinutes default 60).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areapublic (гейт витрины: JWT инвестора или реферальная cookie)
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/public/create-booking-request.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (гейт витрины: валидная реф-cookie referralUrl) или User (Инвестор с JWT)
Auth policy / Permissionsnone (публичный метод); rate limit по IP+телефону
Scope (RBAC)не применяется (актор — вне tenant)
Record-levelне применяется
Tenant isolationTenantId целевого лида = AgencyCompanyId, вычисляется правилами привязки (шаг 4); запрос выполняется в system-scope
Auditcreate (CreatedByUserId = null для анонима / UserId инвестора); PII формы в логи не пишется
Доступ

Метод публичный: доступен анонимному пользователю (гейт — валидная реф-cookie referralUrl) или авторизованному инвестору с JWT; без permission-проверки, защита — rate limit по IP+телефону. TenantId целевого лида не выбирается актором, а вычисляется правилами привязки агента (шаг 4, контекст referral); запрос выполняется в system-scope.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/public/crm/booking-requests
Success status201 (создано) / 200 (идемпотентный повтор, шаг 2)
Idempotency headerне применяется (бизнес-дедуп по телефон+объект+24 ч, шаг 2)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
LastNamebodystringДа1..128, не пустое после trimPII: в логи не пишется
FirstNamebodystringДа1..128, не пустое после trimPII
MiddleNamebodystring?Нет0..128 (отчество опционально)PII
PhoneNumberbodystringДанормализация и формат E.164 (^\+[1-9]\d{9,14}$), max 32PII; маска +7***NNNN в логах
PropertyObjectIdbodyGuidДасуществующий объект недвижимости chessboard (шаг 2)
SelectionIdbodyGuid?Нетесли задан — логическая ссылка на подборку (selections), синхронно не проверяется
ReferralCodecookie referralUrlstring?Нетвалидируется контекстом referral (шаг 4)
ActorUserIdidentity claimsGuid?НетJWT инвестора, если авторизован

Модель ответа BookingRequestResult

ПолеТипОбязательностьИсточникMaskingОписание
RequestIdGuidДаидентификатор события BookingRequested в ленте лидаИдентификатор принятой заявки.
ReceivedAtDateTimeOffsetДамомент приёма события лентыМомент приёма.
IsDuplicateboolДавычисление (шаг 2)true — идемпотентный повтор (существующая необработанная заявка).
Важно

Идентификаторы лида/клиента наружу не возвращаются (внутренние данные агентства, client.md).

Алгоритм

  1. Контекст и доступ. Публичный метод; актор Anonymous/User (JWT опционален), гейт витрины — JWT инвестора или реф-cookie; rate limit по IP+телефону.
  2. Проверка входа и предусловий.
    • бизнес-идемпотентность (дедуп): нормализовать PhoneNumber → E.164; если у живого клиента с этим телефоном есть событие BookingRequested по тому же PropertyObjectId за последние 24 ч в активном лиде (Status = Active) → вернуть 200 c существующим RequestId, IsDuplicate = true (дальнейшие шаги не выполняются);
    • формат: длины ФИО, формат телефона, PropertyObjectId != Guid.Empty → иначе VALIDATION_FAILED;
    • существование объекта: запрос в chessboard по PropertyObjectId → нет → CRM_PROPERTY_OBJECT_NOT_FOUND; из ответа берём DeveloperCompanyId, ProjectId, статус объекта;
    • допустимость состояния: статус объекта = Свободен; иначе CRM_PROPERTY_OBJECT_NOT_AVAILABLE — заявка на занятый объект бессмысленна.
  3. Lazy-регистрация инвестора. Вызов identity «get-or-create user by phone» (RegistrationSource = LazyRegistration; имя записывается, если у пользователя пустое) → PrimaryInvestorUserId. Если JWT-инвестор авторизован и его телефон совпадает — используется ActorUserId без вызова identity.
  4. Бизнес-правила и переходы. Определить агентскую сторону (правила привязки — контекст referral): валидная реф-ссылка с владельцем-агентом → ExecutorAgentUserId = владелец (он же снапшотится в иммутабельный OriginAgentUserId), TenantId = его AgencyCompanyId; иначе (ссылки нет / ссылка инвестора) → TenantId = компания ПИН, ExecutorAgentUserId = случайный активный сотрудник ПИН (OriginAgentUserId = он же) (UC-2). Далее get-or-create цепочки:
    • ClientEntity по паре (TenantId, PhoneNumber) (unique, INV-CLI-1); нет → создаётся с Source = BookingRequest, ФИО {LastName, FirstName, MiddleName}; есть, но PrimaryInvestorUserId = null — проставляется (INV-CLI-3);
    • активный LeadEntity по паре (ExecutorAgentUserId, PrimaryInvestorUserId, Status = Active) (unique, INV-CRM-001); нет → создаётся с Source = BookingRequest, ExpiresAt = now + 1 месяц, пустыми хотелками, ReferralLinkId из реф-cookie (если была);
    • в ленту лида добавляется событие BookingRequested (Payload = LeadBookingRequestedPayload { PropertyObjectId, SelectionId?, DeveloperCompanyId }, INV-LEAD-2); у клиента обновляются LastActivityAt и привязка активного лида (INV-CLI-2);
    • активное намерение LeadBookingIntentEntity по паре (LeadId, PropertyObjectId) (unique, INV-INTENT-1); нет → создаётся со Source = InvestorAdded, ConfirmedByInvestor = true, ConfirmedAt = now, Status = Active, снимок ProjectId/DeveloperCompanyId из chessboard, TenantId = lead.TenantId, AddedByUserId = PrimaryInvestorUserId, и в ленту лида добавляется LeadEventType.BookingIntentAdded (payload LeadBookingIntentRefPayload { IntentId, PropertyObjectId, DeveloperCompanyId }); есть активное → идемпотентно переиспользуется (заявка на тот же объект намерение не дублирует), новое событие ленты не добавляется.
  5. Что меняется. Клиент (создан/обновлён), лид (создан/переиспользован), намерение по объекту (создано/переиспользовано, INV-INTENT-1) и события ленты изменяются в одной согласованной записи.
  6. Событие. Если лид создан — публикуется интеграционное LeadCreatedEvent → topic pin.crm.lead-created.v1 (ContractVersion = 1), payload { LeadId, TenantId, OriginAgentUserId, ExecutorAgentUserId, PrimaryInvestorUserId, Source = BookingRequest, CreatedAt } (контракт lead-created.md). Существующий (переиспользованный) лид интеграционных событий не порождает — уведомление агенту о новой заявке делает notifications по lead-created, либо агент видит LastEventAt в списке.
  7. Результат. { RequestId, ReceivedAt, IsDuplicate = false } → HTTP 201.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияLeadCreatedEventpin.crm.lead-created.v1 (только если лид новый)
Внешние вызовыidentity get-or-create по телефону (timeout 3 c, retry 1); chessboard lookup объекта (timeout 2 c); referral — валидация кода привязки
Проекции / поискпоисковая проекция клиентов обновляется (ADR-0059)
Уведомленияопосредованно: notifications — консюмер lead-created (уведомление агенту)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат ФИО/телефона/PropertyObjectId«Проверьте корректность полей формы»no retry
CRM_PROPERTY_OBJECT_NOT_FOUND404объект не найден в chessboard«Объект не найден»no retry
CRM_PROPERTY_OBJECT_NOT_AVAILABLE409статус объекта не Свободен«Объект недоступен для бронирования»no retry
CRM_CLIENT_PHONE_DUPLICATE409гонка INV-CLI-1 (конфликт уникальности при вставке клиента)«Повторите попытку»retry (перечитка клиента)
IDENTITY_UNAVAILABLE503identity недоступен на шаге 3«Сервис временно недоступен»retry

Совместимость и наблюдаемость

  • Обязательные поля формы (ФИО, телефон, объект) — стабильный публичный контракт; SelectionId — additive optional. Удаление/переименование полей — breaking (migration + rollout).
  • Logs: booking-request received/deduplicated/created (RequestId, PropertyObjectId, tenant; телефон — маской); metrics: crm_booking_requests_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm.

Постусловия

После 201 гарантированно: существует зарегистрированный пользователь-инвестор (lazy) с этим телефоном; существует живой клиент в базе агентской стороны (TenantId) с этим телефоном; существует активный лид пары (ExecutorAgentUserId, PrimaryInvestorUserId) с Status = Active; в ленте лида есть событие BookingRequested; существует активное намерение по объекту (Source = InvestorAdded, ConfirmedByInvestor = true) — новое или переиспользованное (INV-INTENT-1); у клиента обновлены LastActivityAt и ActiveLeadId. Если лид создан впервые — опубликовано LeadCreatedEvent.

Eventual consistency

BookingEntity не создана и «Резерв 1 час» не запущен — это происходит позже, в create-booking.md. Инвестор получает 201 немедленно; агент видит новую заявку в списке лидов по LastEventAt сразу после commit, а push/уведомление — после обработки lead-created консюмером notifications (для нового лида).

Acceptance Criteria

  • AC-1 (happy — новый лид). Дано: клиента/лида с этим телефоном нет, объект в статусе Свободен, валидная реф-cookie агента. Когда: заявка на бронирование. Тогда: lazy-регистрация инвестора, создан клиент (Source = BookingRequest), создан лид (Source = BookingRequest, TenantId = AgencyCompanyId владельца ссылки, ReferralLinkId заполнен), событие ленты BookingRequested, создано активное намерение (Source = InvestorAdded, ConfirmedByInvestor = true) + событие ленты BookingIntentAdded, публикуется LeadCreatedEvent, ответ 201 с IsDuplicate = false.
  • AC-2 (идемпотентный повтор). Дано: у живого клиента уже есть BookingRequested по тому же PropertyObjectId за последние 24 ч в активном лиде. Когда: повторная заявка. Тогда: ответ 200 с существующим RequestId и IsDuplicate = true; новый лид/событие не создаются.
  • AC-3 (объект недоступен). Дано: статус объекта в chessboard не Свободен. Когда: заявка. Тогда: отказ CRM_PROPERTY_OBJECT_NOT_AVAILABLE (409).
  • AC-4 (объект не найден). Дано: PropertyObjectId отсутствует в chessboard. Когда: заявка. Тогда: отказ CRM_PROPERTY_OBJECT_NOT_FOUND (404).
  • AC-5 (без реф-ссылки — сторона ПИН). Дано: реф-cookie нет либо ссылка принадлежит инвестору. Когда: заявка. Тогда: TenantId = компания ПИН, ExecutorAgentUserId (= OriginAgentUserId) = активный сотрудник ПИН; лид создан.
  • AC-6 (переиспользование лида). Дано: активный лид пары уже существует. Когда: заявка. Тогда: событие ленты BookingRequested добавлено в существующий лид, LeadCreatedEvent не публикуется; намерение по объекту создаётся, если его ещё не было, иначе переиспользуется идемпотентно (INV-INTENT-1), без дубля события ленты BookingIntentAdded.
  • AC-7 (identity недоступен). Дано: identity не отвечает на lazy-регистрации. Когда: заявка. Тогда: отказ IDENTITY_UNAVAILABLE (503, retriable), заявка не принята.

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

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

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

  • Сущности: client.md, lead.md (payload LeadBookingRequestedPayload), lead.md (намерение по объекту, INV-INTENT-1, LeadBookingIntentRefPayload), booking.md (процесс после конвертации).
  • Управление намерением инвестором: add-booking-intent.md, remove-booking-intent.md.
  • Продолжение flow: create-booking.md (Резерв 1 ч в сделке).
  • Событие: lead-created.md; сценарий UC-3 — docs/02-architecture/scenarios/.
  • Смежные входы воронки: create-consultation-request.md (заявка на консультацию); запись на показ объекта в календарь агента — book-slot.md (calendar, слот Kind = Showing: время показа гарантируется слотом, CRM получает клиента/лид и ленту по событиям pin.calendar.appointment-*.v1, консюмер calendar-appointments.md); карта областей — README контекста.