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 team | PIN 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 / correlation | BookingId (= 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 |
Участники
| Service | Responsibility | Owned State |
|---|---|---|
crm | Оркестратор: методы Create/ConfirmBooking/ConfirmPayment/Extend/Cancel агрегата и worker booking-expiration; публикует переходы в outbox | BookingEntity.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.
| From | To | Trigger | Condition | Side Effects |
|---|---|---|---|---|
[*] | Reserved | CreateBooking (агент/инвестор/Менеджер ПИН — create-booking) | сделка Status ∈ {Application, Booking} (INV-BKG-2) — застройщик вовлечён; Draft (застройщик не вовлечён) исключён: бронь возможна только после engage-developer; объект свободен (INV-BKG-001) | booking-created → chessboard Reserve + таймер ReserveExpiresAt; messenger карточка брони; Deal.OnBookingCreated (Application → Booking) |
Reserved | AwaitingPayment | застройщик загрузил договор брони | ProgramTerms.IsPaid = true | клиент скачивает/оплачивает/прикрепляет чек (kafka-события нет — внутренний переход) |
Reserved | Booked | ConfirmBooking (агент — confirm-booking) | бесплатная программа; now ≤ ReserveExpiresAt (INV-BKG-3) | booking-confirmed → chessboard Book + таймер BookingExpiresAt; catalog/notifications |
AwaitingPayment | Booked | ConfirmPayment + ConfirmBooking менеджером застройщика (confirm-booking-payment) | чек Uploaded+; PaymentStatus → Paid (INV-BKG-4) | booking-paid → messenger «договор оплачен» (US-INV-16), rewards; и booking-confirmed → chessboard Book |
Booked | Booked | Extend (менеджер — extend-booking) | Status = Booked (INV-BKG-6) | BookingExpiresAt += BookingDurationHours; таймлайн; интеграционных событий нет (объект остаётся «Бронь») |
Booked | Completed | Deal.StartSale (передача в продажу) | бронь Booked, одной транзакцией с Deal (INV-BKG-7) | бронь закрыта; сделка Booking → Sale; далее — deal-completion-saga |
Reserved/AwaitingPayment | ReserveExpired | worker booking-expiration | now > ReserveExpiresAt (INV-BKG-5) | компенсация: booking-expired (Phase = Reserve) → chessboard Release; messenger «время резерва истекло, подтвердите бронь» |
Booked | BookingExpired | worker booking-expiration | now > BookingExpiresAt, менеджер не успел Extend | компенсация: booking-expired (Phase = Booking) → chessboard Release; messenger «подтвердите/продлите» |
Reserved/AwaitingPayment/Booked | Cancelled | Cancel (актор — cancel-booking) или каскад Deal.Cancel | активный статус (INV-BKG-8) | компенсация: booking-cancelled → chessboard Release; notifications |
Commands и Events
Направление — относительно оркестратора crm. Все Send идут через транзакционный outbox (key =
BookingId, порядок per-booking); все downstream-консюмеры идемпотентны по MessageId + бизнес-ключу.
| Direction | Contract | Owner | Idempotency |
|---|---|---|---|
| Send | pin.crm.booking-created.v1 | crm | key BookingId; INV-BKG-001 (ux_bookings_active_property_object) |
| Send | pin.crm.booking-confirmed.v1 | crm | key BookingId; оптимистичная блокировка по версии vs worker |
| Send | pin.crm.booking-paid.v1 | crm | key BookingId (только платная программа) |
| Send | pin.crm.booking-expired.v1 | crm (worker) | key BookingId; idempotent by state (INV-BKG-5) |
| Send | pin.crm.booking-cancelled.v1 | crm | key BookingId |
| Consume | chessboard Reserve/Book/Release (crm-property-object-status) | chessboard | MessageId + hold-guard HoldDealId == DealId (INV-CHS-003) |
| Consume | messenger карточки чата (booking-paid/booking-expired) | messenger | MessageId |
| Consume | notifications уведомления участникам | notifications | MessageId |
| Consume | rewards учёт факта оплаты брони (booking-paid) | rewards | MessageId |
Сквозной поток «producer → topic → consumer» по фазам саги (ветвление по бесплатной/платной программе, компенсация по таймауту/отмене — без новых узлов сверх участников из таблицы выше):
Transaction Boundaries
Каждый сервис выполняет только свою локальную транзакцию; saga state, outbox, idempotency и таймлайн сохраняются атомарно внутри owning-сервиса.
crm(команда): мутацияBookingEntity(+DealEntity/строка таймлайна где применимо) + outbox-запись события — один коммит; Kafka-публикация — после коммита (at-least-once).crm(workerbooking-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 Step | Compensation | Owner | Audit |
|---|---|---|---|
Резерв не подтверждён в срок (ReserveExpired) | booking-expired (Phase = Reserve) → chessboard Release (Резерв → Свободен); messenger карточка «время резерва истекло, подтвердите бронь»; notifications | crm (worker) + chessboard/messenger | да |
Бронь не сыграла в срок (BookingExpired) | booking-expired (Phase = Booking) → chessboard Release (Бронь → Свободен); messenger «подтвердите/продлите» | crm (worker) + chessboard/messenger | да |
Отмена актором (Cancelled) — клиент передумал / смена объекта / отказ сделки | booking-cancelled → chessboard 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 на стороне chessboard → CHESSBOARD_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(grace10 мин) — независимая сверка на случай потерянных 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 pathcrm(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(будущий путь).
Обратные ссылки
Автоссылки: где используется этот документ.
- Саги (1): Saga «Deal Completion» (fan-out завершения сделки)
- scenarios (1): UC-OVR-006. Сделка: от брони до регистрации (инвестор-инициатор)
- index.md (1): CRM Service
Связанные документы
- Агрегат-инстанс саги: booking.md (INV-BKG-001/2/3/4/5/6/7/8, stateDiagram); родительский процесс и передача в продажу: deal.md; условия/таймеры: booking-program.md.
- Драйвер таймаута: background/booking-expiration.md; страховка release: chessboard/property-object-hold-expiration.md; консюмер статусов объекта: chessboard/crm-property-object-status.md.
- События: booking-created, booking-confirmed, booking-paid, booking-expired, booking-cancelled.
- Операции: create-booking, confirm-booking, confirm-booking-payment, extend-booking, cancel-booking.
- Сквозные сценарии: UC-OVR-006, UC-OVR-004; передача в продажу — deal-completion-saga.md.
- ADR: 0056 simplicity-first, 0052 tenancy, 0055 мини-мессенджер.