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

UC-CHS-006. Резерв/бронь/продажа меняет статус объекта (system, события CRM)

Назначение

Системный сценарий применения статусов продажи объекта недвижимости: модуль бронирования и сделок CRM публикует события pin.crm.booking-created/confirmed/expired/cancelled.v1 и pin.crm.deal-completed/cancelled.v1 (маппинг «событие CRM → переход Reserve/Book/Release/MarkSold» — ../consumers/crm-property-object-status.md), consumer шахматки выполняет переходы Free → Reserved → Booked → Sold (и releases обратно в Free) с таймерами ReservedUntil/BookedUntil и guard-ами INV-CHS-001/002/003, после чего публикует pin.chessboard.property-object-status-changed.v1 для витрины и уведомлений. Страховочный job property-object-hold-expiration возвращает залипшие холды. Сценарий относится к контексту chessboard.

Metadata

ПолеЗначение
IDUC-CHS-006
Контекстchessboard (см. docs/06-services/chessboard/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольSystem (consumer статусов CRM, consumer-группа pin-chessboard-crm-property-object-status)
Permissions / scopePermissions не применяются: m2m ACL Kafka на топики pin.crm.booking-*/pin.crm.deal-*; мутации — под system-actor (StatusSource = Crm). Единственный путь переходов Резерв/Бронь/Продан — эти события (INV-CHS-001); HTTP-API таких переходов не имеет
TenantКросс-tenant; объект недвижимости уже несёт TenantId
Record-levelНе применяется

Предусловие

  • Объект недвижимости существует в шахматке (UC-CHS-002/005); контракты CRM несут обязательный минимум: PropertyObjectId, DealId, ReservedUntil/BookedUntil/SoldAt (сами контракты — ../consumers/crm-property-object-status.md).
  • Таймеры рассчитаны CRM по программе бронирования застройщика (Резерв 1 час; бронь 4 часа / 10 дней и т.п.); шахматка значения не пересчитывает, только хранит и отображает.

Триггер

ПолеЗначение
ИсточникKafka event
Триггерящий элементТопики pin.crm.booking-created.v1, pin.crm.booking-confirmed.v1, pin.crm.booking-expired.v1, pin.crm.booking-cancelled.v1, pin.crm.deal-completed.v1, pin.crm.deal-cancelled.v1 (маппинг «топик → переход Reserve/Book/Release/MarkSold» — ../consumers/crm-property-object-status.md); страховка — job property-object-hold-expiration (раз в 1 мин)

Основной Поток

Резерв (Free → Reserved)

  1. Инвестор/агент создал заявку на бронирование (контекст crm, вне границ этого UC); CRM публикует PropertyObjectReservedEvent (PropertyObjectId, DealId, ReservedUntil = now + 1 час).
  2. Consumer применяет резерв:
    • Идемпотентность. Повторная доставка того же EventId → ack без эффекта; бизнес-идемпотентность — Status = Reserved && HoldDealId == event.DealId → skip + ack (событие уже применено другим путём).
    • Проверки. Объект недвижимости должен существовать (иначе retriable fail CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND_FOR_HOLD — объект мог ещё не доехать синком; retry backoff → DLQ); допустимость перехода — Status = Free (матрица INV-CHS-001; Reserved с чужим DealId — A2); event.ReservedUntil > OccurredAt.
    • Переход Free → Reserved. Status = Reserved, ReservedUntil = event.ReservedUntil, BookedUntil = null (INV-CHS-002), HoldDealId = event.DealId, StatusChangedAt = OccurredAt, StatusSource = Crm; поднимаются PropertyObjectStatusChangedEvent и ProjectAggregatesRecalcRequestedEvent (счётчик PropertyObjectsFreeCount).
    • Конкурентный доступ. Конфликт RowVersion (гонка с правкой застройщика) → retry consumer-а с перечитыванием (UC-CHS-003 A5).
    • Публикация. В одном согласованном изменении с мутацией публикуется PropertyObjectStatusChangedKafkaEvent (ContractVersion = 1, топик pin.chessboard.property-object-status-changed.v1, key = PropertyObjectId): PropertyObjectId, TenantId, ProjectId, BuildingId, FromStatus = Free, ToStatus = Reserved, StatusSource = Crm, ReservedUntil, BookedUntil = null, HoldDealId, SoldAt = null, StatusChangedAt, Version. Ack offset-а.
  3. catalog перекрашивает ячейку шахматки в жёлтый и показывает обратный отсчёт ReservedUntil; favorites/selections помечают объект; notifications шлёт подписчикам.

Бронь (Reserved → Booked)

  1. CRM подтверждает бронь (оплата/подтверждение по программе) → PropertyObjectBookedEvent (PropertyObjectId, DealId, BookedUntil по программе). Guard Status = Reserved && event.DealId == HoldDealId (INV-CHS-003) → переход Reserved → Booked, ReservedUntil = null, BookedUntil = event.BookedUntil (INV-CHS-002); публикуется status-событие.

Продажа (Booked → Sold)

  1. Сделка завершена → PropertyObjectSoldEvent (PropertyObjectId, DealId, SoldAt). Guard Status = Booked && event.DealId == HoldDealId → переход Booked → Sold (терминальный), таймеры null, HoldDealId сохраняется в payload события для трассировки; публикуется status-событие; recalc (PropertyObjectsFreeCount, Price).

Release (Reserved/Booked → Free)

  1. Отказ/истечение: CRM публикует PropertyObjectReleasedEvent (PropertyObjectId, DealId, причина). Guard Status in (Reserved, Booked) && event.DealId == HoldDealId → возврат в Free, оба таймера и HoldDealId = null; публикуется status-событие. Сообщения «время истекло, подтвердите бронь» в чат — ответственность CRM/messenger, не шахматки.

Альтернативные Потоки

A1. Недопустимый переход (объект недвижимости не в ожидаемом статусе)

  1. property-object-reserved при Status != Free (например, Hidden или уже Sold) → CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION (INV-CHS-001) → DLQ + алерт. Гонка «два резерва на один объект недвижимости» разрешается в пользу первого события; CRM по DLQ-разбору отменяет проигравшую заявку.

A2. Чужой DealId (защита холда)

  1. property-object-booked/property-object-released/property-object-sold c event.DealId != HoldDealIdCHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (INV-CHS-003) → DLQ + алерт CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (README Alerts). Ручной разбор runbook-ом (release залипшего холда).

A3. Событие про несуществующий объект недвижимости

  1. Объект недвижимости не найден (лаг синка Talent) → retriable fail с exponential backoff → DLQ. После появления объекта недвижимости replay применяет событие.

A4. Страховочное истечение холда (потерян release CRM)

  1. Job property-object-hold-expiration (раз в 1 мин) находит холды Status in (Reserved, Booked) && coalesce(ReservedUntil, BookedUntil) + grace(10 мин) < now по индексам ix_property_objects_reserved_until/ix_property_objects_booked_until (с блокировкой обрабатываемых строк, чтобы параллельные циклы не пересекались).
  2. Для каждого: возврат в Free (StatusSource = ExpirationJob); публикуется PropertyObjectStatusChangedKafkaEvent; warning-лог (release-событие CRM потеряно) + метрика chessboard_property_object_hold_expired_total; алерт при превышении порога.
  3. Таймерная логика самой брони (напоминания, продление — решает менеджер застройщика) остаётся в CRM; job — только страховка консистентности шахматки.

A5. Гонка с правкой застройщика (RowVersion)

  1. Конфликт RowVersion при записи → consumer перечитывает агрегат и повторяет переход; HTTP-сторона получает свой 409. Потерянных обновлений нет.

A6. Продление брони

  1. CRM публикует повторный property-object-booked с тем же DealId и новым BookedUntil (решение менеджера застройщика). Бизнес-идемпотентность видит смену значения таймера → BookedUntil обновляется без смены статуса (Booked → Booked допускается как timer-update); публикуется status-событие с новым таймером.

Кросс-сервисный контракт и согласованность

Что шахматка ожидает от CRM (входные гарантии).

  • Обязательный минимум полей на событие: PropertyObjectId, DealId, соответствующий таймер (ReservedUntil/BookedUntil) или SoldAt; отсутствие или таймер в прошлом → CHESSBOARD_PROPERTY_OBJECT_TIMER_STATE_INVALID (DLQ, дефект контракта CRM).
  • DealId стабилен на протяжении жизни сделки — это ключ владения холдом (INV-CHS-003) и защита от чужого release/book/sold.
  • Таймеры рассчитывает CRM по программе бронирования застройщика; шахматка их только хранит и отображает, не пересчитывает.
  • Причинный порядок событий одной сделки (reserved → booked → sold/released) обеспечивается общим ключом партиции CRM; шахматка полагается на это. Нарушение порядка (reorder/redelivery) проявляется как недопустимый переход (A1) или mismatch чужого DealId (A2) → DLQ + алерт — это сигнал нарушения контракта, а не тихая порча состояния.
  • EventId уникален — ключ технической идемпотентности (повтор → ack без эффекта); поверх — бизнес- идемпотентность по (Status, HoldDealId) и значению таймера (продление брони A6 — Booked → Booked).

Что шахматка предоставляет downstream. PropertyObjectStatusChangedKafkaEvent.v1 (key = PropertyObjectId, ContractVersion = 1, at-least-once, атомарно с мутацией): переходы одного объекта недвижимости упорядочены общим ключом. Потребители: catalog (цвет ячейки + обратный отсчёт), favorites/selections (пометка «продан/в брони»), notifications (подписчикам). Потребители обязаны быть идемпотентны по (PropertyObjectId, Version); эволюция payload — additive для optional-полей.

Окно согласованности (что видит пользователь). Цепочка асинхронна: заявка CRM → (лаг consumer-а этого UC) → статус в шахматке → (лаг consumer-а catalog) → цвет ячейки в витрине. В промежутке ячейка ещё показывает прежний статус; после применения — жёлтая с ReservedUntil. Окно потерянного release закрывает страховочный job: залипший холд возвращается в Free не позднее таймер + grace(10 мин) + 1 мин цикла (постусловие). Недоступность downstream (catalog/notifications) не блокирует переход — событие остаётся в очереди и будет доставлено (at-least-once).

Постусловие

  • PropertyObject.Status соответствует последнему применённому событию CRM; таймеры консистентны статусу (INV-CHS-002); HoldDealId заполнен ⇔ объект недвижимости под холдом; StatusSource/StatusChangedAt отражают переход.
  • pin.chessboard.property-object-status-changed.v1 доставлен: catalog (цвет ячейки + отсчёт), favorites/selections (пометки), notifications.
  • Денормализации (PropertyObjectsFreeCount, Price при Sold) пересчитаны job-ом recalc.
  • Залипшие холды (потерянный release) возвращены в Free не позднее таймер + 10 мин + 1 мин цикла.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION— (DLQ)Переход вне матрицы INV-CHS-001 (A1)DLQ + алерт; runbook разбора гонки
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH— (DLQ)event.DealId != HoldDealId (A2, INV-CHS-003)DLQ + алерт; runbook ручного release
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND_FOR_HOLD— (retriable)Объект недвижимости ещё не реплицирован (A3)retry backoff → DLQ + replay
CHESSBOARD_PROPERTY_OBJECT_TIMER_STATE_INVALID— (DLQ)Событие без обязательного таймера / таймер в прошлом (INV-CHS-002)DLQ; дефект контракта CRM

HTTP-кодов нет — сценарий полностью событийный; «клиент» — retry/DLQ-политика consumer-а и job.

Доменные События

СобытиеКогда поднимаетсяSubscribers
PropertyObjectStatusChangedEventКаждый переход Reserve/Book/MarkSold/Releasein-proc → ProjectAggregatesRecalcRequestedEvent
ProjectAggregatesRecalcRequestedEventКаждый статусный переходjob project-aggregates-recalc (PropertyObjectsFreeCount, Price)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
PropertyObjectStatusChangedKafkaEvent.v1pin.chessboard.property-object-status-changed.v1Каждый применённый переход (Reserve/Book/Sold/Release, в т.ч. job-ом A4 и продление A6)catalog (индикация шахматки/витрины), favorites/selections (пометка «продан/в брони»), notifications
ProjectUpsertedKafkaEvent.v1pin.chessboard.project-upserted.v1Recalc изменил PropertyObjectsFreeCount/Pricecatalog, dev-stats, identity, news
Consumed (семантический эффект): PropertyObjectReservedEvent (Reserve) / PropertyObjectBookedEvent (Book) / PropertyObjectReleasedEvent (Release) / PropertyObjectSoldEvent (MarkSold)Reserve ← pin.crm.booking-created.v1; Book ← pin.crm.booking-confirmed.v1; Release ← pin.crm.booking-expired.v1 / pin.crm.booking-cancelled.v1 / pin.crm.deal-cancelled.v1; MarkSold ← pin.crm.deal-completed.v1 (маппинг — ../consumers/crm-property-object-status.md)Публикует crm (booking/deal); контракты — ../consumers/crm-property-object-status.mdchessboard (этот UC), catalog

Acceptance Criteria

#КритерийПроверка
AC1Полный цикл Free → Reserved → Booked → Sold применяется событиями с корректными таймерамиe2e-тест последовательности событий
AC2Release из Reserved и из Booked возвращает Free, чистит таймеры и HoldDealIdтест INV-CHS-002
AC3Событие с чужим DealId уходит в DLQ с алертом, объект недвижимости не мутирует (A2)тест INV-CHS-003
AC4Повторная доставка любого события не даёт двойного эффектатест идемпотентности (EventId + бизнес-guard)
AC5Недопустимый переход (резерв Sold-объекта недвижимости) — DLQ, состояние нетронуто (A1)тест матрицы INV-CHS-001
AC6Потерянный release: job возвращает объект недвижимости в Free через таймер+grace, с warning и событием (A4)тест job-а с истёкшим таймером
AC7Каждый переход публикует status-событие атомарно с мутациейoutbox-тест
AC8Гонка «CRM-событие vs правка застройщика» не теряет обновлений (A5)параллельный тест RowVersion
AC9Продление брони обновляет BookedUntil без смены статуса (A6)тест timer-update
AC10Переходы одного объекта недвижимости доставляются downstream в причинном порядке (общий ключ PropertyObjectId); reorder событий CRM даёт DLQ-сигнал, не тихую порчутест порядка/ключа партиции

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

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

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