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.
| Поле | Значение |
|---|
| ID | UC-CHS-006 |
| Контекст | chessboard (см. docs/06-services/chessboard/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | System (consumer статусов CRM, consumer-группа pin-chessboard-crm-property-object-status) |
| Permissions / scope | Permissions не применяются: 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)
- Инвестор/агент создал заявку на бронирование (контекст
crm, вне границ этого UC); CRM публикует
PropertyObjectReservedEvent (PropertyObjectId, DealId, ReservedUntil = now + 1 час).
- 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-а.
catalog перекрашивает ячейку шахматки в жёлтый и показывает обратный отсчёт ReservedUntil;
favorites/selections помечают объект; notifications шлёт подписчикам.
Бронь (Reserved → Booked)
- 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)
- Сделка завершена →
PropertyObjectSoldEvent (PropertyObjectId, DealId, SoldAt). Guard
Status = Booked && event.DealId == HoldDealId → переход Booked → Sold (терминальный), таймеры
null, HoldDealId сохраняется в payload события для трассировки; публикуется status-событие; recalc
(PropertyObjectsFreeCount, Price).
Release (Reserved/Booked → Free)
- Отказ/истечение: CRM публикует
PropertyObjectReleasedEvent (PropertyObjectId, DealId, причина).
Guard Status in (Reserved, Booked) && event.DealId == HoldDealId → возврат в Free, оба таймера и
HoldDealId = null; публикуется status-событие. Сообщения «время истекло, подтвердите бронь» в чат —
ответственность CRM/messenger, не шахматки.
Альтернативные Потоки
A1. Недопустимый переход (объект недвижимости не в ожидаемом статусе)
property-object-reserved при Status != Free (например, Hidden или уже Sold) →
CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION (INV-CHS-001) → DLQ + алерт. Гонка «два резерва
на один объект недвижимости» разрешается в пользу первого события; CRM по DLQ-разбору отменяет
проигравшую заявку.
A2. Чужой DealId (защита холда)
property-object-booked/property-object-released/property-object-sold c
event.DealId != HoldDealId → CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (INV-CHS-003) → DLQ + алерт
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (README Alerts). Ручной разбор runbook-ом (release
залипшего холда).
A3. Событие про несуществующий объект недвижимости
- Объект недвижимости не найден (лаг синка Talent) → retriable fail с exponential backoff → DLQ. После
появления объекта недвижимости replay применяет событие.
A4. Страховочное истечение холда (потерян release CRM)
- 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 (с блокировкой обрабатываемых
строк, чтобы параллельные циклы не пересекались).
- Для каждого: возврат в
Free (StatusSource = ExpirationJob); публикуется
PropertyObjectStatusChangedKafkaEvent; warning-лог (release-событие CRM потеряно) + метрика
chessboard_property_object_hold_expired_total; алерт при превышении порога.
- Таймерная логика самой брони (напоминания, продление — решает менеджер застройщика) остаётся в CRM;
job — только страховка консистентности шахматки.
A5. Гонка с правкой застройщика (RowVersion)
- Конфликт
RowVersion при записи → consumer перечитывает агрегат и повторяет переход; HTTP-сторона
получает свой 409. Потерянных обновлений нет.
A6. Продление брони
- 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/Release | in-proc → ProjectAggregatesRecalcRequestedEvent |
ProjectAggregatesRecalcRequestedEvent | Каждый статусный переход | job project-aggregates-recalc (PropertyObjectsFreeCount, Price) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
PropertyObjectStatusChangedKafkaEvent.v1 | pin.chessboard.property-object-status-changed.v1 | Каждый применённый переход (Reserve/Book/Sold/Release, в т.ч. job-ом A4 и продление A6) | catalog (индикация шахматки/витрины), favorites/selections (пометка «продан/в брони»), notifications |
ProjectUpsertedKafkaEvent.v1 | pin.chessboard.project-upserted.v1 | Recalc изменил PropertyObjectsFreeCount/Price | catalog, 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.md | chessboard (этот UC), catalog |
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Полный цикл Free → Reserved → Booked → Sold применяется событиями с корректными таймерами | e2e-тест последовательности событий |
| AC2 | Release из 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-сигнал, не тихую порчу | тест порядка/ключа партиции |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Consumer детально: ../consumers/crm-property-object-status.md —
маппинг «топик CRM → переход», payload каждого события, идемпотентность.
- Background job: ../background/property-object-hold-expiration.md —
страховка потерянных release-событий (A4).
- Domain entity: ../domain/property-object.md (INV-CHS-001/002/003, stateDiagram,
PropertyObjectStatusChangedKafkaEvent).
- События CRM: booking-created.md, booking-confirmed.md,
booking-expired.md, booking-cancelled.md,
deal-completed.md, deal-cancelled.md; программа
бронирования и таймеры — booking.md.
- Связанные UC: UC-CHS-004 (отображение статусов в шахматке), UC-CHS-005 (продажа вне PIN синком),
BKG-сценарии бронирования: UC-BKG-001,
UC-BKG-002,
UC-BKG-003,
UC-BKG-004.