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

Saga «Booking» (Резерв → Бронь → передача в продажу)

Назначение

Saga Booking координирует cross-service процесс закрепления объекта недвижимости за сделкой — Резерв → Бронь → передача в продажу — без распределённой транзакции. Оркестрируется контекстом crm: сам агрегат BookingEntity является инстансом и состоянием саги (Status + таймеры ReserveExpiresAt/BookingExpiresAt), фоновый worker booking-expirationдрайвер таймаутов (INV-BKG-5), а транзакционный outbox — механизм гарантированной доставки переходов как интеграционных событий.

Отдельного оркестратора-сервиса нет (ADR-0056, simplicity-first): crm владеет состоянием саги, а chessboard/messenger/notifications/profile реагируют хореографией + компенсацией по событиям семейства pin.crm.booking-*. Смену статуса объекта в шахматке (Свободен → Резерв → Бронь → Продан и release-ы) выполняет консюмер chessboard — CRM чужие таблицы не пишет (service-boundaries, решение 5 README).

Сквозные сценарии, частью которых является сага: UC-OVR-006 (бронь → регистрация) и UC-OVR-004 (от ссылки до объекта у собственника, фазы 6–8).

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Platform (CRM squad)
ТипOrchestration-by-aggregate (crm-owned) + choreography-компенсация; без отдельного оркестратора (ADR-0056)
Область действияагрегат BookingEntity, worker booking-expiration, топики pin.crm.booking-created/confirmed/paid/expired/cancelled.v1
Saga instance / correlationBookingId (= Kafka key booking-событий, AggregateId)
Assumptions / ограниченияэквайринг отложен (решение 9 ТЗ) — денежного отката нет, компенсация = release объекта; целевая нагрузка 1–10k активных броней (ADR-0056); один активный процесс брони на объект (INV-BKG-001); бронь стартует только по вовлечённой сделке (Application+) — Draft-сделка (застройщик не вовлечён) в саге не участвует
ЗависимостиPostgreSQL (crm.bookings, outbox), Kafka; консюмер chessboard + его страховочный worker property-object-hold-expiration; messenger (ADR-0055), notifications, profile (реквизиты)
Тип документаgreenfield — первый saga-документ PIN

Участники

ServiceResponsibilityOwned State
crmОркестратор: методы Create/ConfirmBooking/ConfirmPayment/Extend/Cancel агрегата и worker booking-expiration; публикует переходы в outboxBookingEntity.Status (Reserved/AwaitingPayment/Booked/Completed/ReserveExpired/BookingExpired/Cancelled), таймеры ReserveExpiresAt/BookingExpiresAt, PaymentStatus; проекция DealEntity.Status (Application ↔ Booking)
chessboardПроекция статуса продажи объекта: Reserve/Book/Release/MarkSold по booking/deal-событиям (консюмер)PropertyObject.Status, HoldDealId (INV-CHS-003), ReservedUntil/BookedUntil
messengerСистемные карточки в чат сделки (создание брони, «договор оплачен», «время истекло — подтвердите/продлите») — ADR-0055сообщения чата сделки
notificationsУведомления участникам сделки о переходах брони
profileРеквизиты/паспортные данные для брони (RequisiteSetId — только ссылка)шаблоны реквизитов (PII)

State Machine

Дом полей/инвариантов/stateDiagram агрегата — booking.md; ниже — саговый срез (триггер, условие, side effects по границам сервисов). Все переходы crm коммитятся атомарно с outbox.

FromToTriggerConditionSide Effects
[*]ReservedCreateBooking (агент/инвестор/Менеджер ПИН — create-booking)сделка Status ∈ {Application, Booking} (INV-BKG-2) — застройщик вовлечён; Draft (застройщик не вовлечён) исключён: бронь возможна только после engage-developer; объект свободен (INV-BKG-001)booking-createdchessboard Reserve + таймер ReserveExpiresAt; messenger карточка брони; Deal.OnBookingCreated (Application → Booking)
ReservedAwaitingPaymentзастройщик загрузил договор брониProgramTerms.IsPaid = trueклиент скачивает/оплачивает/прикрепляет чек (kafka-события нет — внутренний переход)
ReservedBookedConfirmBooking (агент — confirm-booking)бесплатная программа; now ≤ ReserveExpiresAt (INV-BKG-3)booking-confirmedchessboard Book + таймер BookingExpiresAt; catalog/notifications
AwaitingPaymentBookedConfirmPayment + ConfirmBooking менеджером застройщика (confirm-booking-payment)чек Uploaded+; PaymentStatus → Paid (INV-BKG-4)booking-paidmessenger «договор оплачен» (US-INV-16), rewards; и booking-confirmedchessboard Book
BookedBookedExtend (менеджер — extend-booking)Status = Booked (INV-BKG-6)BookingExpiresAt += BookingDurationHours; таймлайн; интеграционных событий нет (объект остаётся «Бронь»)
BookedCompletedDeal.StartSale (передача в продажу)бронь Booked, одной транзакцией с Deal (INV-BKG-7)бронь закрыта; сделка Booking → Sale; далее — deal-completion-saga
Reserved/AwaitingPaymentReserveExpiredworker booking-expirationnow > ReserveExpiresAt (INV-BKG-5)компенсация: booking-expired (Phase = Reserve) → chessboard Release; messenger «время резерва истекло, подтвердите бронь»
BookedBookingExpiredworker booking-expirationnow > BookingExpiresAt, менеджер не успел Extendкомпенсация: booking-expired (Phase = Booking) → chessboard Release; messenger «подтвердите/продлите»
Reserved/AwaitingPayment/BookedCancelledCancel (актор — cancel-booking) или каскад Deal.Cancelактивный статус (INV-BKG-8)компенсация: booking-cancelledchessboard Release; notifications

Commands и Events

Направление — относительно оркестратора crm. Все Send идут через транзакционный outbox (key = BookingId, порядок per-booking); все downstream-консюмеры идемпотентны по MessageId + бизнес-ключу.

DirectionContractOwnerIdempotency
Sendpin.crm.booking-created.v1crmkey BookingId; INV-BKG-001 (ux_bookings_active_property_object)
Sendpin.crm.booking-confirmed.v1crmkey BookingId; оптимистичная блокировка по версии vs worker
Sendpin.crm.booking-paid.v1crmkey BookingId (только платная программа)
Sendpin.crm.booking-expired.v1crm (worker)key BookingId; idempotent by state (INV-BKG-5)
Sendpin.crm.booking-cancelled.v1crmkey BookingId
Consumechessboard Reserve/Book/Release (crm-property-object-status)chessboardMessageId + hold-guard HoldDealId == DealId (INV-CHS-003)
Consumemessenger карточки чата (booking-paid/booking-expired)messengerMessageId
Consumenotifications уведомления участникамnotificationsMessageId
Consumerewards учёт факта оплаты брони (booking-paid)rewardsMessageId

Сквозной поток «producer → topic → consumer» по фазам саги (ветвление по бесплатной/платной программе, компенсация по таймауту/отмене — без новых узлов сверх участников из таблицы выше):

Transaction Boundaries

Каждый сервис выполняет только свою локальную транзакцию; saga state, outbox, idempotency и таймлайн сохраняются атомарно внутри owning-сервиса.

  • crm (команда): мутация BookingEntity (+ DealEntity/строка таймлайна где применимо) + outbox-запись события — один коммит; Kafka-публикация — после коммита (at-least-once).
  • crm (worker booking-expiration): граница — на батч; переходы → ReserveExpired/BookingExpired
    • outbox коммитятся вместе; обрабатываемые строки блокируются до коммита, конкурентные воркеры их пропускают.
  • chessboard (консюмер): статус PropertyObject + отметка идемпотентности + собственный outbox (property-object-status-changed) — атомарно; offset коммитится после фиксации.
  • Двухфазного коммита нет; согласованность — eventual (SLA применения статуса объекта < 1 мин).

Промежуточные состояния и видимость (eventual consistency)

Между локальным коммитом crm и применением статуса консюмером chessboard есть окно (< 1 мин, at-least-once):

Что видит пользователь

Сразу после create-booking/confirm-booking карточка сделки и бронь в crm показывают целевой статус (Reserved/Booked) — это source of truth. Витрина catalog/шахматка отражают «Резерв»/«Бронь» с задержкой окна; до применения объект может ещё показываться в прежнем статусе. Симметрично при истечении/отмене: объект остаётся «занятым» на витрине, пока release не применён консюмером.

Защита от двойной брони в окне

Единственность процесса брони гарантируется на стороне crm (INV-BKG-001, ux_bookings_active_property_object), а не витриной: даже если объект на витрине ещё «Свободен», повторная create-booking на него отклоняется (CRM_BOOKING_PROPERTY_OBJECT_BUSY). Публичная заявка с витрины (UC-BKG-001) читает статус объекта синхронно в chessboard, поэтому в окне «Резерв»/«Бронь» может кратко видеться как «Свободен» — реальную бронь всё равно создаёт агент, где INV-BKG-001 отсекает гонку.

Порядок событий

Внутри одного топика порядок per-booking гарантируется ключом BookingId. Для платной ветки booking-paid и booking-confirmed кладутся в один коммит, но в разные топики — межтопикового порядка нет; консюмеры независимы (chessboard реагирует на booking-confirmed/release-события, messenger/rewards — на booking-paid), поэтому кросс-топиковая гонка на целостность не влияет.

Compensation

Компенсация процесса брони — release объекта недвижимости (денежного отката нет: эквайринг отложен, решение 9 ТЗ). Все компенсирующие переходы crm — терминальные и иммутабельные (INV-BKG-8), выполняются атомарно с публикацией события; chessboard освобождает объект идемпотентно.

Failed StepCompensationOwnerAudit
Резерв не подтверждён в срок (ReserveExpired)booking-expired (Phase = Reserve) → chessboard Release (Резерв → Свободен); messenger карточка «время резерва истекло, подтвердите бронь»; notificationscrm (worker) + chessboard/messengerда
Бронь не сыграла в срок (BookingExpired)booking-expired (Phase = Booking) → chessboard Release (Бронь → Свободен); messenger «подтвердите/продлите»crm (worker) + chessboard/messengerда
Отмена актором (Cancelled) — клиент передумал / смена объекта / отказ сделкиbooking-cancelledchessboard Release; notifications; каскад от deal-cancelled при отмене сделкиcrm + chessboardда
Потерянное release-событие (chessboard не получил booking-expired/booking-cancelled)независимая страховка worker property-object-hold-expiration (grace 10 мин) освобождает «залипший» холд; каждое срабатывание = потерянный release (warning + метрика)chessboardда (warning-лог)
Двойная бронь / нарушение единственностиINV-BKG-001 (unique partial ux_bookings_active_property_object, один активный процесс на объект) на стороне crm; hold-guard INV-CHS-003 на стороне chessboardCHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (DLQ + алерт)crm + chessboardда

Timeout и Reconciliation

  • Timeout: ReserveExpiresAt = CreatedAt + ProgramTerms.ReserveDurationMinutes (default 60 мин); BookingExpiresAt = BookedAt + ProgramTerms.BookingDurationHours. Таймеры — параметры программы бронирования (booking-program); TTL/Redis не используются (ADR-0056).
  • Драйвер: worker booking-expiration — цикл 60 с, отбор по истёкшим срокам с блокировкой обрабатываемых строк, идемпотентно by state (INV-BKG-5). SLO: бронь с дедлайном t истекает не позже t + 60 с + DurationCycle; объект свободен в chessboard < 1 мин.
  • Retry policy: гонка «worker истекает / менеджер подтверждает или продлевает бронь» разрешается оптимистичной блокировкой по версии — проигравший повторяет операцию с перечиткой; событие публикуется ровно для победившего перехода.
  • Reconciliation job: chessboard/property-object-hold-expiration (grace 10 мин) — независимая сверка на случай потерянных release-событий; метрика chessboard_property_object_hold_expiration_items_total в норме ≈ 0.
  • Manual review: CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (INV-CHS-003) → DLQ + runbook «ручной release залипшего холда»; сделка «зависла в Бронь» (UC-OVR-006 guardrail) → сверка crm ↔ chessboard.

Observability

  • Metrics (saga): длительность саги Reserved → Booked → Completed и time-in-phase; gauge «застряло в Reserved»; счётчики компенсаций/истечений — crm_booking_expiration_items_total{phase}, crm_booking_cancel_total{result}; crm_bookings_created_total{initiated_by}, crm_booking_confirm_total{result}; потерянные release — chessboard_property_object_hold_expiration_items_total, chessboard_crm_hold_mismatch_total; outbox lag.
  • Logs/traces: сквозной correlation id; saga id = BookingId; causation id = MessageId; trace path crm (command/worker) → outbox → chessboard/messenger/notifications.
  • Alerts: объект «завис» в Резерв/Бронь (рост hold-expiration); лаг консюмеров > 5 мин; outbox backlog > 1000; рост hold_mismatch_total.
  • Runbook: booking-expiration (Связанный Runbook), chessboard/property-object-hold-expiration, crm/runbooks/outbox-backlog (будущий путь).

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

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

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