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 area | public (гейт витрины: JWT инвестора или реферальная cookie) |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/public/create-booking-request.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (гейт витрины: валидная реф-cookie referralUrl) или User (Инвестор с JWT) |
| Auth policy / Permissions | none (публичный метод); rate limit по IP+телефону |
| Scope (RBAC) | не применяется (актор — вне tenant) |
| Record-level | не применяется |
| Tenant isolation | TenantId целевого лида = AgencyCompanyId, вычисляется правилами привязки (шаг 4); запрос выполняется в system-scope |
| Audit | create (CreatedByUserId = null для анонима / UserId инвестора); PII формы в логи не пишется |
Метод публичный: доступен анонимному пользователю (гейт — валидная реф-cookie referralUrl) или
авторизованному инвестору с JWT; без permission-проверки, защита — rate limit по IP+телефону. TenantId
целевого лида не выбирается актором, а вычисляется правилами привязки агента (шаг 4, контекст referral);
запрос выполняется в system-scope.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/public/crm/booking-requests |
| Success status | 201 (создано) / 200 (идемпотентный повтор, шаг 2) |
| Idempotency header | не применяется (бизнес-дедуп по телефон+объект+24 ч, шаг 2) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
LastName | body | string | Да | 1..128, не пустое после trim | PII: в логи не пишется |
FirstName | body | string | Да | 1..128, не пустое после trim | PII |
MiddleName | body | string? | Нет | 0..128 (отчество опционально) | PII |
PhoneNumber | body | string | Да | нормализация и формат E.164 (^\+[1-9]\d{9,14}$), max 32 | PII; маска +7***NNNN в логах |
PropertyObjectId | body | Guid | Да | существующий объект недвижимости chessboard (шаг 2) | — |
SelectionId | body | Guid? | Нет | если задан — логическая ссылка на подборку (selections), синхронно не проверяется | — |
ReferralCode | cookie referralUrl | string? | Нет | валидируется контекстом referral (шаг 4) | — |
ActorUserId | identity claims | Guid? | Нет | JWT инвестора, если авторизован | — |
Модель ответа BookingRequestResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
RequestId | Guid | Да | идентификатор события BookingRequested в ленте лида | — | Идентификатор принятой заявки. |
ReceivedAt | DateTimeOffset | Да | момент приёма события ленты | — | Момент приёма. |
IsDuplicate | bool | Да | вычисление (шаг 2) | — | true — идемпотентный повтор (существующая необработанная заявка). |
Идентификаторы лида/клиента наружу не возвращаются (внутренние данные агентства, client.md).
Алгоритм
- Контекст и доступ. Публичный метод; актор
Anonymous/User(JWT опционален), гейт витрины — JWT инвестора или реф-cookie; rate limit по IP+телефону. - Проверка входа и предусловий.
- бизнес-идемпотентность (дедуп): нормализовать
PhoneNumber→ E.164; если у живого клиента с этим телефоном есть событиеBookingRequestedпо тому жеPropertyObjectIdза последние 24 ч в активном лиде (Status = Active) → вернуть200c существующимRequestId,IsDuplicate = true(дальнейшие шаги не выполняются); - формат: длины ФИО, формат телефона,
PropertyObjectId != Guid.Empty→ иначеVALIDATION_FAILED; - существование объекта: запрос в
chessboardпоPropertyObjectId→ нет →CRM_PROPERTY_OBJECT_NOT_FOUND; из ответа берёмDeveloperCompanyId,ProjectId, статус объекта; - допустимость состояния: статус объекта =
Свободен; иначеCRM_PROPERTY_OBJECT_NOT_AVAILABLE— заявка на занятый объект бессмысленна.
- бизнес-идемпотентность (дедуп): нормализовать
- Lazy-регистрация инвестора. Вызов
identity«get-or-create user by phone» (RegistrationSource = LazyRegistration; имя записывается, если у пользователя пустое) →PrimaryInvestorUserId. Если JWT-инвестор авторизован и его телефон совпадает — используетсяActorUserIdбез вызова identity. - Бизнес-правила и переходы. Определить агентскую сторону (правила привязки — контекст
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(payloadLeadBookingIntentRefPayload { IntentId, PropertyObjectId, DeveloperCompanyId }); есть активное → идемпотентно переиспользуется (заявка на тот же объект намерение не дублирует), новое событие ленты не добавляется.
- Что меняется. Клиент (создан/обновлён), лид (создан/переиспользован), намерение по объекту (создано/переиспользовано, INV-INTENT-1) и события ленты изменяются в одной согласованной записи.
- Событие. Если лид создан — публикуется интеграционное
LeadCreatedEvent→ topicpin.crm.lead-created.v1(ContractVersion = 1), payload{ LeadId, TenantId, OriginAgentUserId, ExecutorAgentUserId, PrimaryInvestorUserId, Source = BookingRequest, CreatedAt }(контракт lead-created.md). Существующий (переиспользованный) лид интеграционных событий не порождает — уведомление агенту о новой заявке делаетnotificationsпоlead-created, либо агент видитLastEventAtв списке. - Результат.
{ RequestId, ReceivedAt, IsDuplicate = false }→ HTTP201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | LeadCreatedEvent → pin.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_FAILED | 400 | формат ФИО/телефона/PropertyObjectId | «Проверьте корректность полей формы» | no retry |
CRM_PROPERTY_OBJECT_NOT_FOUND | 404 | объект не найден в chessboard | «Объект не найден» | no retry |
CRM_PROPERTY_OBJECT_NOT_AVAILABLE | 409 | статус объекта не Свободен | «Объект недоступен для бронирования» | no retry |
CRM_CLIENT_PHONE_DUPLICATE | 409 | гонка INV-CLI-1 (конфликт уникальности при вставке клиента) | «Повторите попытку» | retry (перечитка клиента) |
IDENTITY_UNAVAILABLE | 503 | identity недоступен на шаге 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; dashboardpin-crm.
Постусловия
После 201 гарантированно: существует зарегистрированный пользователь-инвестор (lazy) с этим телефоном;
существует живой клиент в базе агентской стороны (TenantId) с этим телефоном; существует активный лид пары
(ExecutorAgentUserId, PrimaryInvestorUserId) с Status = Active; в ленте лида есть событие BookingRequested; существует
активное намерение по объекту (Source = InvestorAdded, ConfirmedByInvestor = true) — новое или
переиспользованное (INV-INTENT-1); у клиента обновлены LastActivityAt и ActiveLeadId. Если лид создан
впервые — опубликовано LeadCreatedEvent.
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), заявка не принята.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (21): GET /api/agent/crm/clients/{clientId} — Карточка клиента, GET /api/agent/crm/deals — Сделки агента (список/поиск), GET /api/agent/crm/fixations — Список фиксаций агентства (+ поиск), GET /api/agent/crm/fixations/{fixationId} — Карточка фиксации (агент), GET /api/agent/crm/leads — Список лидов агента, GET /api/agent/crm/leads/{leadId} — Карточка лида, GET /api/agent/crm/leads/{leadId}/events — Лента событий лида, GET /api/investor/crm/leads/{leadId} — Карточка лида (инвестор), POST /api/agent/crm/bookings/{bookingId}/cancel — Отменить бронь, POST /api/agent/crm/bookings/{bookingId}/confirm — Подтвердить бронь (бесплатная программа), POST /api/agent/crm/clients — Создать клиента вручную, POST /api/agent/crm/clients/{clientId}/archive — Архивировать клиента (+9)
- Use Cases (2): UC-BKG-001. Инвестор инициирует бронирование с витрины, UC-BKG-002. Бронирование Резерв → Бронь
- scenarios (1): UC-OVR-003. Бронирование объекта у агента (+3a)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: 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 контекста.