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 area | agent (JWT агента) |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/create-booking.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Агент); Operator (Менеджер ПИН — platform-scope, от имени агента) |
| Auth policy / Permissions | crm.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 |
| Audit | create (CreatedByUserId = actor.UserId); RequisiteSetId — только ссылка (PII реквизитов — в profile) |
Кросс-tenant запись санкционирована доменной моделью: бронь создаётся в tenant'е застройщика объекта
(TenantId = DeveloperCompanyId), а актор проверяется не по своему tenant, а record-level предикатом по
сделке — deal.ExecutorAgentUserId (обычно) либо deal.OriginAgencyCompanyId (руководитель агентства) /
Менеджер ПИН.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/agent/crm/deals/{dealId}/bookings |
| Success status | 201 (создано) |
| Idempotency header | не применяется (бизнес-защита от дублей — INV-BKG-001 → 409) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty; существующая сделка Status ∈ {Application, Booking}, доступна актору | — |
ProgramId | body | Guid | Да | != Guid.Empty; живая программа бронирования TenantId = deal.TenantId | — |
RequisiteSetId | body | Guid? | Нет | логическая ссылка на profile (шаблон реквизитов); синхронно не проверяется; обычно заполняется позже инвестором | — |
ActorUserId | identity claims | Guid | Да | JWT агента; = deal.ExecutorAgentUserId (кроме подачи Менеджером ПИН) | — |
ActorAgencyCompanyId | identity claims | Guid | Да | tenant агентства актора — record-level по сделке | Tenant isolation |
Модель ответа BookingCardModel
Проекция созданной брони (дельта дома booking.md); агент видит блок «Резерв» с таймером в карточке сделки. Маскирование не требуется — платёжных реквизитов по чеку в модели нет.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
BookingId | Guid | Да | BookingEntity.Id | — | Идентификатор брони. |
DealId | Guid | Да | DealId | — | Сделка-владелец. |
PropertyObjectId | Guid | Да | PropertyObjectId (из сделки) | — | Объект в шахматке (chessboard). |
Status | string (enum BookingStatus) | Да | Status | — | Всегда Reserved на создании. |
InitiatedBy | string (enum BookingInitiator) | Да | InitiatedBy | — | Всегда Agent. |
ProgramId | Guid | Да | ProgramId | — | Выбранная программа бронирования. |
ProgramTerms | BookingProgramTerms (owned VO — booking.md) | Да | ProgramTerms | — | Снимок условий (платность/сумма/длительности). |
ReserveExpiresAt | DateTimeOffset | Да | ReserveExpiresAt | — | Дедлайн резерва (таймер). |
PaymentStatus | string (enum BookingPaymentStatus) | Да | PaymentStatus | — | NotRequired (бесплатная) / Pending (платная). |
PaymentAmount | Money? (owned VO — deal.md) | Нет | PaymentAmount | — | Сумма платной брони или null. |
RequisiteSetId | Guid? | Нет | RequisiteSetId | — | Выбранные реквизиты или null. |
CreatedAt | DateTimeOffset | Да | CreatedAt | — | Момент старта резерва. |
Вложенные ProgramTerms/PaymentAmount — typed owned VO; поля не дублируются (дом — booking.md / deal.md).
Алгоритм
- Контекст и доступ. Операцию выполняет агент (permission
crm.bookings.manage); либо Менеджер ПИН от имени агента. - Проверка входа и предусловий.
- формат:
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.
- формат:
- Что читаем. Сделку (родитель процесса — источник объекта и tenant, сериализует параллельные попытки
создать бронь на ту же сделку) и программу (источник snapshot условий). INV-BKG-001: убедиться, что на
объекте нет активного процесса брони (
Status ∈ {Reserved, AwaitingPayment, Booked}) — иначеCRM_BOOKING_PROPERTY_OBJECT_BUSY; и что у сделки нет активной брони — иначеCRM_BOOKING_DEAL_ACTIVE_EXISTS. - Бизнес-правила и переходы. Собрать
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 == Application→Booking(денормализуется группа этапаBooking); приStatus == Bookingстатус не меняется (событие стадии не шлётся). - Что меняется. Появляется бронь; сделка получает
BookingIdи (при переходе) статусBookingс записью таймлайна — в одном согласованном изменении. При гонке уникальности активной брони на объект/сделку (INV-BKG-001) —CRM_BOOKING_PROPERTY_OBJECT_BUSY/CRM_BOOKING_DEAL_ACTIVE_EXISTS. - События.
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.
- Результат. Карточка созданной брони → 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; при Application → Booking
(+ строка таймлайна). Опубликован BookingCreatedEvent (всегда, key BookingId) — потребители chessboard
(объект → «Резерв» + таймер), messenger (карточка брони в чат); DealStageChangedEvent — только при реальном
переходе Application → Booking. Активирован таймаут-драйвер: worker booking-expiration истечёт резерв в
ReserveExpiresAt, если бронь не подтвердить.
Объект в шахматке становится «Резерв» асинхронно — до долёта события витрина показывает прежний статус, а
бронь в CRM уже Reserved.
Постусловие (отказ). Ни брони, ни мутации сделки (BookingId/статус), ни событий.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | BookingCreatedEvent → pin.crm.booking-created.v1 (всегда); DealStageChangedEvent → pin.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
На объекте недвижимости и у сделки одновременно допускается не более одного активного процесса брони
(Status ∈ {Reserved, AwaitingPayment, Booked}). Нарушение при гонке параллельных запросов даёт 409 CRM_BOOKING_PROPERTY_OBJECT_BUSY / CRM_BOOKING_DEAL_ACTIVE_EXISTS.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | формат DealId/ProgramId | «Проверьте корректность полей запроса» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка не найдена | «Сделка не найдена» | no retry |
CRM_DEAL_FORBIDDEN | 403 | сделка другого агентства/агента | «Нет доступа к сделке» | no retry |
CRM_DEAL_INVALID_STATE | 409 | deal.Status ∉ {Application, Booking} (INV-BKG-2) | «Бронь недоступна в текущем статусе сделки» | no retry |
CRM_BOOKING_PROGRAM_NOT_FOUND | 404 | программа не найдена/архивирована/чужой tenant | «Программа бронирования недоступна» | no retry |
CRM_BOOKING_PROPERTY_OBJECT_BUSY | 409 | на объекте недвижимости уже есть активный процесс брони (INV-BKG-001) | «Объект уже забронирован» | no retry (перечитать состояние) |
CRM_BOOKING_DEAL_ACTIVE_EXISTS | 409 | у сделки уже есть активная бронь (INV-BKG-001) | «У сделки уже есть активная бронь» | no retry (перечитать состояние) |
Совместимость и наблюдаемость
RequisiteSetIdи поляBookingCardModel— additive optional без breaking change; удаление/переименование/ смена типа — breaking (migration + rollout). EnumBookingStatus/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; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path (бесплатная). Given сделка
Application+ свободный объект + бесплатная программа; WhenPOST; Then201,Reserved,PaymentStatus = NotRequired, опубликованыbooking-created+deal-stage-changed(Application → Booking). - Happy path (платная). Given платная программа; When
POST; Then201,PaymentStatus = Pending,PaymentAmount = Price. - Бронь на сделке уже в Booking. Given сделка
Bookingбез активной брони (прошлая отменена/истекла); WhenPOST; Then201,booking-createdуходит,deal-stage-changedне уходит (статус не сменился). - Активная бронь у сделки (INV-BKG-001). Given у сделки уже есть активная бронь; When
POST; Then409 CRM_BOOKING_DEAL_ACTIVE_EXISTS. - Объект занят (INV-BKG-001). Given на объекте активный процесс брони от другой сделки; When
POST; Then409 CRM_BOOKING_PROPERTY_OBJECT_BUSY. - Неверный статус сделки. Given
deal.Status ∈ {Sale, Completed, Cancelled}; WhenPOST; Then409 CRM_DEAL_INVALID_STATE. - Программа недоступна. Given программа архивирована/чужого застройщика; When
POST; Then404 CRM_BOOKING_PROGRAM_NOT_FOUND. - Гонка. Given два параллельных
POSTна объект/сделку; Then появляется ровно одна бронь, второй —409.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): POST /api/public/crm/booking-requests — Заявка на бронирование объекта (витрина), PUT /api/investor/crm/bookings/{bookingId}/requisites — Выбрать реквизиты для брони
- Use Cases (3): UC-BKG-001. Инвестор инициирует бронирование с витрины, UC-BKG-002. Бронирование Резерв → Бронь, UC-BKG-003. Истечение резерва/брони и продление
- События (1): Event Contract: pin.crm.booking-created (BookingCreatedEvent)
- Саги (1): Saga «Booking» (Резерв → Бронь → передача в продажу)
- scenarios (1): UC-OVR-003. Бронирование объекта у агента (+3a)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: booking.md (агрегат, INV-BKG-001/2, VO
BookingProgramTerms, enum-ы), booking-program.md (источник условий), deal.md (родитель,OnBookingCreated, VOMoney). - Точка входа с витрины: create-booking-request.md; карта области — README.
- События: booking-created.md, deal-stage-changed.md.
- ADR: 0052 tenancy, 0056 simplicity-first.