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

POST /api/investor/crm/leads/{leadId}/booking-intents — Инвестор добавляет объект-намерение

Инвестор помечает объект недвижимости как «хочу купить/забронировать» в рамках своего лида. Намерение — основание для будущей сделки по объекту этого застройщика; когда есть подтверждённое намерение и одобренная фиксация для пары (инвестор, застройщик), сделка сразу заводится в развёрнутой стадии (Flow A). Дом полей — lead.md.

Назначение

Принять от инвестора объект (PropertyObjectId) в контексте его лида и завести активное намерение LeadBookingIntentEntity со стороной инвестора (Source = InvestorAdded, ConfirmedByInvestor = true сразу — инвестор сам выразил намерение). Намерение фиксирует объект и его застройщика, но не создаёт сделку и не вовлекает застройщика. Если по этому объекту в лиде уже есть предложенное агентом неподтверждённое намерение (Source = AgentProposed) — операция его подтверждает (эквивалент confirm-booking-intent.md).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areainvestor (JWT инвестора)
Feature groupbooking-intents
Статусdraft
Документdocs/06-services/crm/api/investor/add-booking-intent.md

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

ПроверкаЗначение
Actor typeUser (Инвестор)
Auth policy / Permissionscrm.leads.participate (инвесторская сторона лида)
Scope (RBAC)не применяется: инвестор — вне agency-tenant (кросс-tenant участник, ADR-0052)
Record-levellead.PrimaryInvestorUserId = actor.UserId (инвестор — сторона лида); форсится сервером
Tenant isolationнамерение TenantId = lead.TenantId = AgencyCompanyId; инвестор кросс-tenant → изоляция record-level по лиду (scope-bypass agency-tenant с явным предикатом)
Auditcreate (AddedByUserId = actor.UserId)
Доступ

Выполнить операцию может только инвестор — сторона лида (lead.PrimaryInvestorUserId = actor.UserId); проверка форсится сервером, а не телом запроса. Инвестор — кросс-tenant участник (ADR-0052): scope-based RBAC не применяется, изоляция — record-level по лиду (TenantId намерения = TenantId лида = AgencyCompanyId агентства).

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/investor/crm/leads/{leadId}/booking-intents
Success status201 (намерение создано) / 200 (идемпотентный повтор или подтверждение существующего предложенного)
Idempotency headerне применяется (бизнес-дедуп по INV-INTENT-1: одно активное намерение на пару LeadId+PropertyObjectId)
Correlationсквозной trace через заголовок traceparent
подсказка

Отдельного idempotency-ключа нет: повторный вызов с тем же PropertyObjectId — не ошибка, а идемпотентный возврат существующей карточки (200) или подтверждение предложенного агентом намерения (200); новое активное намерение по паре LeadId+PropertyObjectId создаётся только один раз (INV-INTENT-1; CRM_BOOKING_INTENT_ALREADY_EXISTS — только при гонке одновременных запросов).

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
LeadIdrouteGuidДа!= Guid.Empty; существующий лид Status = Active, инвестор — его сторона
PropertyObjectIdbodyGuidДа!= Guid.Empty; существующий объект недвижимости chessboard (шаг 2); из него берутся ProjectId, DeveloperCompanyId, статус
ActorUserIdidentity claimsGuidДаJWT инвестора; record-level = lead.PrimaryInvestorUserId (форсится сервером)scope isolation

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

Проекция намерения (дельта дома lead.md).

ПолеТипОбязательностьИсточникMaskingОписание
IntentIdGuidДаLeadBookingIntentEntity.IdИдентификатор намерения.
LeadIdGuidДаLeadIdЛид-контекст.
PropertyObjectIdGuidДаPropertyObjectIdОбъект намерения.
ProjectIdGuidДаProjectId (снимок из chessboard)Проект объекта.
DeveloperCompanyIdGuidДаDeveloperCompanyId (снимок из chessboard)Застройщик объекта.
Sourcestring (enum BookingIntentSource)ДаSourceInvestorAdded / AgentProposed.
ConfirmedByInvestorboolДаConfirmedByInvestortrue — инвестор подтвердил намерение.
ConfirmedAtDateTimeOffset?НетConfirmedAtМомент подтверждения инвестором.
Statusstring (enum BookingIntentStatus)ДаStatusActive / Cancelled.
DealIdGuid?НетDealIdСделка, созданная по намерению (если уже есть).
CreatedAtDateTimeOffsetДаCreatedAtМомент добавления.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (permission crm.leads.participate); ActorUserId — из токена (из тела не принимается); LeadId из route, PropertyObjectId из body.

  2. Проверка входа и предусловий.

    • формат: LeadId/PropertyObjectId != Guid.Empty → иначе VALIDATION_FAILED;
    • лид существует → иначе CRM_LEAD_NOT_FOUND;
    • инвестор — сторона лида (lead.PrimaryInvestorUserId == ActorUserId) → иначе CRM_LEAD_FORBIDDEN;
    • лид активен (Status = Active) → иначе CRM_LEAD_NOT_ACTIVE;
    • существование объекта: запрос в chessboard по PropertyObjectId → нет → CRM_PROPERTY_OBJECT_NOT_FOUND; из ответа берём ProjectId, DeveloperCompanyId, статус объекта;
    • допустимость состояния: статус объекта = Свободен; иначе CRM_PROPERTY_OBJECT_NOT_AVAILABLE — намерение на занятый/снятый объект бессмысленно.
  3. Что читаем. Активное намерение лида по паре (LeadId, PropertyObjectId) — для дедупа (INV-INTENT-1).

  4. Бизнес-правила и переходы (INV-INTENT-1). Если по паре (LeadId, PropertyObjectId) уже есть активное намерение:

    • ConfirmedByInvestor = true → идемпотентный повтор: вернуть существующую карточку (200), ничего не менять;
    • Source = AgentProposed, ConfirmedByInvestor = false → подтвердить: ConfirmedByInvestor = true, ConfirmedAt = now; в ленту лида добавить LeadEventType.BookingIntentConfirmed; вернуть карточку (200).

    Иначе создать новое намерение: Source = InvestorAdded, ConfirmedByInvestor = true, ConfirmedAt = now, Status = Active, снимок ProjectId/DeveloperCompanyId из chessboard, TenantId = lead.TenantId, AddedByUserId = ActorUserId, DealId = null. В ленту лида добавить LeadEventType.BookingIntentAdded (payload LeadBookingIntentRefPayload { IntentId, PropertyObjectId, DeveloperCompanyId }) и обновить LastEventAt. Маппинг ответа поле-в-поле — по таблице выше.

  5. Что меняется. Появляется (или подтверждается) запись намерения и новое событие ленты лида — в одном согласованном изменении.

  6. Результат. Карточка намерения → 201 (создано) / 200 (идемпотентный повтор или подтверждение предложенного). При гонке уникальной пары (INV-INTENT-1) — CRM_BOOKING_INTENT_ALREADY_EXISTS.

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

ТипДетали
Интеграционные событияnone (застройщик в намерении не участвует; developer-facing события возникают только при переходе сделки в Application)
Внешние вызовыchessboard lookup объекта (timeout 2 c): существование, статус, ProjectId/DeveloperCompanyId
Проекции / поискnone
Уведомленияопосредованно: агент видит новое намерение в ленте лида (LastEventAt); прямого push нет

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400LeadId/PropertyObjectId == Guid.Empty«Проверьте корректность запроса»no retry
UNAUTHORIZED401нет/невалидный JWT«Требуется авторизация»no retry
CRM_LEAD_NOT_FOUND404лид не найден«Лид не найден»no retry
CRM_LEAD_FORBIDDEN403инвестор не сторона лида«Нет доступа к лиду»no retry
CRM_LEAD_NOT_ACTIVE409lead.Status != Active«Лид неактивен»no retry
CRM_PROPERTY_OBJECT_NOT_FOUND404объект не найден в chessboard«Объект не найден»no retry
CRM_PROPERTY_OBJECT_NOT_AVAILABLE409статус объекта не Свободен«Объект недоступен»no retry
CRM_BOOKING_INTENT_ALREADY_EXISTS409гонка INV-INTENT-1 (конфликт уникальности активного намерения по паре)«Повторите попытку»retry (перечитать намерение)
Ошибки

При гонке двух параллельных вызовов на одну и ту же пару (LeadId, PropertyObjectId) второй вызов может получить 409 CRM_BOOKING_INTENT_ALREADY_EXISTS, если первый уже создал намерение к моменту проверки дедупа. Клиенту следует перечитать карточку намерения, а не слепо повторять запрос.

Acceptance Criteria

  • AC-1 (happy — новое намерение). Дано: активный лид инвестора, объект Свободен, намерения по объекту нет. Когда: POST. Тогда: 201, намерение Source = InvestorAdded, ConfirmedByInvestor = true, ConfirmedAt заполнен, в ленте лида BookingIntentAdded.
  • AC-2 (идемпотентный повтор). Дано: у лида уже есть активное подтверждённое намерение по объекту. Когда: повтор POST. Тогда: 200 с существующей карточкой, новое намерение/событие не создаются.
  • AC-3 (подтверждение предложенного). Дано: по объекту есть активное AgentProposed-намерение (ConfirmedByInvestor = false). Когда: POST. Тогда: 200, ConfirmedByInvestor = true, ConfirmedAt заполнен, в ленте лида BookingIntentConfirmed.
  • AC-4 (объект недоступен). Дано: статус объекта не Свободен. Когда: POST. Тогда: 409 CRM_PROPERTY_OBJECT_NOT_AVAILABLE.
  • AC-5 (чужой лид). Дано: lead.PrimaryInvestorUserId != actor. Когда: POST. Тогда: 403 CRM_LEAD_FORBIDDEN.
  • AC-6 (неактивный лид). Дано: lead.Status != Active. Когда: POST. Тогда: 409 CRM_LEAD_NOT_ACTIVE.

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

  • Route и тело стабильны; новые optional-поля BookingIntentCardModel — additive; удаление/переименование полей или сужение enum BookingIntentSource/BookingIntentStatus — breaking (migration + rollout).
  • Logs: booking-intent added/confirmed (IntentId, LeadId, PropertyObjectId, Source); metrics: crm_booking_intents_total{source,result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm.

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

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

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