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

POST /api/agent/crm/deals/{dealId}/bookings — Создать бронь (Резерв) от имени клиента

Агент из карточки сделки запускает процесс бронирования — фазу Резерв (короткая блокировка объекта с таймером; US-INV-06/21). Это продолжение публичной заявки create-booking-request.md: заявка с витрины рождает лид и событие BookingRequested, но агрегат брони требует сделку (INV-BKG-2), поэтому сам BookingEntity создаёт агент этой операцией. Дом полей — booking.md; условия — booking-program.md.

Назначение

Принять от агента сделку, программу бронирования и опциональный набор реквизитов, снять иммутабельный snapshot условий выбранной программы застройщика в BookingProgramTerms и завести бронь (BookingEntity) в статусе Reserved с дедлайном ReserveExpiresAt = CreatedAt + ProgramTerms.ReserveDurationMinutes (default 60). Той же операцией сделка переводится в статус Booking (INV-BKG-2). Объект и tenant берутся из сделки; InitiatedBy = Agent.

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent (JWT агента)
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/agent/create-booking.md

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

ПроверкаЗначение
Actor typeUser (Агент); Operator (Менеджер ПИН — platform-scope, от имени агента)
Auth policy / Permissionscrm.bookings.manage (агентская сторона — создание, US-INV-21)
Scope (RBAC)организация (агентство): record-level по сделке
Record-levelАктор — активный участник сделки (обычно Executor: deal.ExecutorAgentUserId = actor.UserId) либо руководитель компании-владельца (deal.OriginAgencyCompanyId) / Менеджер ПИН
Tenant isolationбронь/сделка TenantId = DeveloperCompanyId (застройщик объекта, ADR-0052) — кросс-tenant запись санкционирована доменной моделью (бронь — процесс на объекте застройщика); агентская сторона — явный record-level предикат по deal.ExecutorAgentUserId/deal.OriginAgencyCompanyId
Auditcreate (CreatedByUserId = actor.UserId); RequisiteSetId — только ссылка (PII реквизитов — в profile)
Доступ

Кросс-tenant запись санкционирована доменной моделью: бронь создаётся в tenant'е застройщика объекта (TenantId = DeveloperCompanyId), а актор проверяется не по своему tenant, а record-level предикатом по сделке — deal.ExecutorAgentUserId (обычно) либо deal.OriginAgencyCompanyId (руководитель агентства) / Менеджер ПИН.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/agent/crm/deals/{dealId}/bookings
Success status201 (создано)
Idempotency headerне применяется (бизнес-защита от дублей — INV-BKG-001 → 409)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DealIdrouteGuidДа!= Guid.Empty; существующая сделка Status ∈ {Application, Booking}, доступна актору
ProgramIdbodyGuidДа!= Guid.Empty; живая программа бронирования TenantId = deal.TenantId
RequisiteSetIdbodyGuid?Нетлогическая ссылка на profile (шаблон реквизитов); синхронно не проверяется; обычно заполняется позже инвестором
ActorUserIdidentity claimsGuidДаJWT агента; = deal.ExecutorAgentUserId (кроме подачи Менеджером ПИН)
ActorAgencyCompanyIdidentity claimsGuidДаtenant агентства актора — record-level по сделкеTenant isolation

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

Проекция созданной брони (дельта дома booking.md); агент видит блок «Резерв» с таймером в карточке сделки. Маскирование не требуется — платёжных реквизитов по чеку в модели нет.

ПолеТипОбязательностьИсточникMaskingОписание
BookingIdGuidДаBookingEntity.IdИдентификатор брони.
DealIdGuidДаDealIdСделка-владелец.
PropertyObjectIdGuidДаPropertyObjectId (из сделки)Объект в шахматке (chessboard).
Statusstring (enum BookingStatus)ДаStatusВсегда Reserved на создании.
InitiatedBystring (enum BookingInitiator)ДаInitiatedByВсегда Agent.
ProgramIdGuidДаProgramIdВыбранная программа бронирования.
ProgramTermsBookingProgramTerms (owned VO — booking.md)ДаProgramTermsСнимок условий (платность/сумма/длительности).
ReserveExpiresAtDateTimeOffsetДаReserveExpiresAtДедлайн резерва (таймер).
PaymentStatusstring (enum BookingPaymentStatus)ДаPaymentStatusNotRequired (бесплатная) / Pending (платная).
PaymentAmountMoney? (owned VO — deal.md)НетPaymentAmountСумма платной брони или null.
RequisiteSetIdGuid?НетRequisiteSetIdВыбранные реквизиты или null.
CreatedAtDateTimeOffsetДаCreatedAtМомент старта резерва.

Вложенные ProgramTerms/PaymentAmount — typed owned VO; поля не дублируются (дом — booking.md / deal.md).

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент (permission crm.bookings.manage); либо Менеджер ПИН от имени агента.
  2. Проверка входа и предусловий.
    • формат: DealId/ProgramId != Guid.Empty — иначе ошибка валидации;
    • сделка существует — иначе CRM_DEAL_NOT_FOUND;
    • сделка принадлежит агентству актора и агент — активный участник (обычно Executor; или руководитель агентства / Менеджер ПИН) — иначе CRM_DEAL_FORBIDDEN;
    • сделка в допустимом статусе Status ∈ {Application, Booking} (INV-BKG-2) — иначе CRM_DEAL_INVALID_STATE;
    • программа бронирования существует, живая и того же застройщика (TenantId = deal.TenantId) — иначе CRM_BOOKING_PROGRAM_NOT_FOUND.
  3. Что читаем. Сделку (родитель процесса — источник объекта и tenant, сериализует параллельные попытки создать бронь на ту же сделку) и программу (источник snapshot условий). INV-BKG-001: убедиться, что на объекте нет активного процесса брони (Status ∈ {Reserved, AwaitingPayment, Booked}) — иначе CRM_BOOKING_PROPERTY_OBJECT_BUSY; и что у сделки нет активной брони — иначе CRM_BOOKING_DEAL_ACTIVE_EXISTS.
  4. Бизнес-правила и переходы. Собрать ProgramTerms из программы (Name, IsPaid, цена, длительности резерва/брони, политика возврата). Создать бронь: Status = Reserved, InitiatedBy = Agent, ReserveExpiresAt = now + ReserveDurationMinutes, PaymentStatus = IsPaid ? Pending : NotRequired, PaymentAmount = IsPaid ? Price : null, объект и tenant из сделки. Затем перевод сделки (INV-BKG-2): BookingId = booking.Id; при Status == ApplicationBooking (денормализуется группа этапа Booking); при Status == Booking статус не меняется (событие стадии не шлётся).
  5. Что меняется. Появляется бронь; сделка получает BookingId и (при переходе) статус Booking с записью таймлайна — в одном согласованном изменении. При гонке уникальности активной брони на объект/сделку (INV-BKG-001) — CRM_BOOKING_PROPERTY_OBJECT_BUSY / CRM_BOOKING_DEAL_ACTIVE_EXISTS.
  6. События.
    • BookingCreatedEvent → топик pin.crm.booking-created.v1 (ContractVersion = 1, AggregateId = BookingId, AggregateType = "Booking"), payload {BookingId, TenantId, DealId, PropertyObjectId, InitiatedBy = Agent, ReserveExpiresAt, ProgramTerms{Name, IsPaid, PriceAmount?, PriceCurrencyId, PriceCurrency (CurrencySnapshot), ReserveDurationMinutes, BookingDurationHours}, CreatedAt} — контракт booking-created.md (консюмеры: chessboard → «Резерв» + таймер; messenger → карточка брони в чат сделки);
    • DealStageChangedEvent → топик pin.crm.deal-stage-changed.v1 (ContractVersion = 1, AggregateId = DealId, AggregateType = "Deal"), payload {DealId, TenantId, FromStepId?, ToStepId, FromStepGroup?, ToStepGroup = Booking, FromStatus = Application, ToStatus = Booking, ChangedByUserId = ActorUserId, ChangedAt}только если статус реально сменился (Application → Booking); контракт deal-stage-changed.md.
  7. Результат. Карточка созданной брони → HTTP 201.

Диаграмма

Предусловия и постусловия

Предусловия. Актор — владелец сделки / Менеджер ПИН с crm.bookings.manage. Сделка существует, Status ∈ {Application, Booking}, доступна актору. Программа бронирования живая, того же застройщика (TenantId = deal.TenantId). На объекте нет активного процесса брони (Status ∈ {Reserved, AwaitingPayment, Booked}) и у сделки нет активной брони (INV-BKG-001).

Постусловие (успех). Создана BookingEntity: Status = Reserved, InitiatedBy = Agent, ReserveExpiresAt = now + ReserveDurationMinutes, иммутабельный snapshot ProgramTerms, PaymentStatus = Pending (платная) / NotRequired (бесплатная), объект/tenant из сделки. Сделка получает BookingId; при ApplicationBooking (+ строка таймлайна). Опубликован BookingCreatedEvent (всегда, key BookingId) — потребители chessboard (объект → «Резерв» + таймер), messenger (карточка брони в чат); DealStageChangedEvent — только при реальном переходе Application → Booking. Активирован таймаут-драйвер: worker booking-expiration истечёт резерв в ReserveExpiresAt, если бронь не подтвердить.

Eventual consistency

Объект в шахматке становится «Резерв» асинхронно — до долёта события витрина показывает прежний статус, а бронь в CRM уже Reserved.

Постусловие (отказ). Ни брони, ни мутации сделки (BookingId/статус), ни событий.

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

ТипДетали
Интеграционные событияBookingCreatedEventpin.crm.booking-created.v1 (всегда); DealStageChangedEventpin.crm.deal-stage-changed.v1 (только при Application → Booking); transactional outbox
Внешние вызовыnone синхронных: объект/цена уже snapshot-нуты на сделке; программа читается из своей БД; RequisiteSetId — логическая ссылка на profile, не проверяется
Проекции / поискnone (у брони нет FTS); Deal.StepGroup/BookingId денормализуются в агрегате
Уведомленияопосредованно: консюмеры booking-created (chessboard → «Резерв» + таймер) и deal-stage-changed (messenger карточка в чат сделки)

Ошибки и failure modes

Ограничение INV-BKG-001

На объекте недвижимости и у сделки одновременно допускается не более одного активного процесса брони (Status ∈ {Reserved, AwaitingPayment, Booked}). Нарушение при гонке параллельных запросов даёт 409 CRM_BOOKING_PROPERTY_OBJECT_BUSY / CRM_BOOKING_DEAL_ACTIVE_EXISTS.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат DealId/ProgramId«Проверьте корректность полей запроса»no retry
CRM_DEAL_NOT_FOUND404сделка не найдена«Сделка не найдена»no retry
CRM_DEAL_FORBIDDEN403сделка другого агентства/агента«Нет доступа к сделке»no retry
CRM_DEAL_INVALID_STATE409deal.Status ∉ {Application, Booking} (INV-BKG-2)«Бронь недоступна в текущем статусе сделки»no retry
CRM_BOOKING_PROGRAM_NOT_FOUND404программа не найдена/архивирована/чужой tenant«Программа бронирования недоступна»no retry
CRM_BOOKING_PROPERTY_OBJECT_BUSY409на объекте недвижимости уже есть активный процесс брони (INV-BKG-001)«Объект уже забронирован»no retry (перечитать состояние)
CRM_BOOKING_DEAL_ACTIVE_EXISTS409у сделки уже есть активная бронь (INV-BKG-001)«У сделки уже есть активная бронь»no retry (перечитать состояние)

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

  • RequisiteSetId и поля BookingCardModel — additive optional без breaking change; удаление/переименование/ смена типа — breaking (migration + rollout). Enum BookingStatus/BookingInitiator/BookingPaymentStatus — новые значения additive.
  • Logs: booking created (BookingId, DealId, PropertyObjectId, ProgramId, InitiatedBy, ReserveExpiresAt; без PII/реквизитов); metrics: crm_bookings_created_total{initiated_by}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path (бесплатная). Given сделка Application + свободный объект + бесплатная программа; When POST; Then 201, Reserved, PaymentStatus = NotRequired, опубликованы booking-created + deal-stage-changed (Application → Booking).
  • Happy path (платная). Given платная программа; When POST; Then 201, PaymentStatus = Pending, PaymentAmount = Price.
  • Бронь на сделке уже в Booking. Given сделка Booking без активной брони (прошлая отменена/истекла); When POST; Then 201, booking-created уходит, deal-stage-changed не уходит (статус не сменился).
  • Активная бронь у сделки (INV-BKG-001). Given у сделки уже есть активная бронь; When POST; Then 409 CRM_BOOKING_DEAL_ACTIVE_EXISTS.
  • Объект занят (INV-BKG-001). Given на объекте активный процесс брони от другой сделки; When POST; Then 409 CRM_BOOKING_PROPERTY_OBJECT_BUSY.
  • Неверный статус сделки. Given deal.Status ∈ {Sale, Completed, Cancelled}; When POST; Then 409 CRM_DEAL_INVALID_STATE.
  • Программа недоступна. Given программа архивирована/чужого застройщика; When POST; Then 404 CRM_BOOKING_PROGRAM_NOT_FOUND.
  • Гонка. Given два параллельных POST на объект/сделку; Then появляется ровно одна бронь, второй — 409.

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

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

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