Consumer: CRM PropertyObject Status (pin-chessboard-crm-property-object-status)
Консолидированный документ Kafka-consumer-ов статусов продажи объекта недвижимости: контекст chessboard применяет
переходы Свободен → Резерв → Бронь → Продан (и обратные release-ы) только по интеграционным
событиям CRM — модуль бронирования и жизненный цикл сделки. Один consumer group = шесть обрабатываемых
событий CRM. Дом полей и state-машина объекта недвижимости — property-object.md
(INV-CHS-001/002/003); контракты входных событий — контекст crm: booking.md и
deal.md (детальные версионируемые контракты каждого события — раздел «Входные
данные / payload событий» ниже).
В property-object.md и README chessboard ранее фигурировали
assumption-имена pin.crm.property-object-reserved/booked/released/sold.v1. Актуальны реальные контракты
CRM; действует таблица соответствия:
| Assumption (property-object.md, семантика перехода) | Реальный контракт CRM (source of truth) |
|---|
pin.crm.property-object-reserved.v1 (Reserve) | pin.crm.booking-created.v1 (BookingCreatedEvent) |
pin.crm.property-object-booked.v1 (Book) | pin.crm.booking-confirmed.v1 (BookingConfirmedEvent) |
pin.crm.property-object-released.v1 (Release) | pin.crm.booking-expired.v1 (BookingExpiredEvent), pin.crm.booking-cancelled.v1 (BookingCancelledEvent), pin.crm.deal-cancelled.v1 (DealCancelledEvent) |
pin.crm.property-object-sold.v1 (MarkSold) | pin.crm.deal-completed.v1 (DealCompletedEvent) |
Назначение
Consumer group pin-chessboard-crm-property-object-status — единственный штатный механизм переходов
статусов продажи PropertyObjectEntity (StatusSource = Crm; INV-CHS-001: Developers API может лишь
Free ↔ Hidden, синк Talent — Free → Sold). Каждое событие CRM транслируется в переход объекта
(Reserve / Book / Release / MarkSold), который атомарно ставит/чистит таймеры
ReservedUntil/BookedUntil (INV-CHS-002) и HoldDealId (INV-CHS-003) и переопубликуется
событием pin.chessboard.property-object-status-changed.v1 для catalog/favorites/selections/
notifications. Таймерная логика (обратный отсчёт, продление, сообщения в чат «время истекло») —
ответственность CRM/messenger; шахматка хранит моменты истечения для отображения и страхуется джобом
property-object-hold-expiration от потерянных
release-событий.
Upstream-гарантия CRM INV-BKG-001 (unique partial index ux_bookings_active_property_object: не более
одного активного процесса брони на PropertyObjectId, booking.md) — основание
инварианта INV-CHS-003: у объекта недвижимости в каждый момент максимум один «владелец холда» (HoldDealId), и все
release/confirm-события обязаны предъявить тот же DealId.
Owning-сервис и триггер
| Поле | Значение |
|---|
| Сервис/контекст | chessboard (проект Pin.Chessboard.Background) |
| Consumer group | pin-chessboard-crm-property-object-status |
| Producer | crm (Pin.Crm): модуль бронирования (BookingEntity) и сделки (DealEntity) |
| Топики | 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 |
| ContractVersion | 1 у всех событий CRM; envelope.Version != 1 → warn-and-proceed (метрика chessboard_crm_contract_mismatch_total) |
| Порядок | key событий booking-* = BookingId, deal-* = DealId → порядок гарантирован только внутри одного агрегата CRM и одного топика; кросс-топикового порядка нет — каждый переход защищён guard-ами INV-CHS-001/003 и правилами идемпотентного пропуска (см. алгоритмы) |
| SLA | применение события p99 < 5 с (README chessboard) |
| Событие (топик) | Переход объекта | Эффект (событие → что меняется) |
|---|
BookingCreatedEvent (pin.crm.booking-created.v1) | Free → Reserved | Reserve: ставит ReservedUntil и HoldDealId, переопубликует статус |
BookingConfirmedEvent (pin.crm.booking-confirmed.v1) | Reserved → Booked | Book: ставит BookedUntil, чистит ReservedUntil |
BookingExpiredEvent (pin.crm.booking-expired.v1) | Reserved|Booked → Free | Release: чистит таймеры и HoldDealId |
BookingCancelledEvent (pin.crm.booking-cancelled.v1) | Reserved|Booked → Free | Release (то же) |
DealCompletedEvent (pin.crm.deal-completed.v1) | Booked → Sold | MarkSold: ставит SoldAt, снимает холд |
DealCancelledEvent (pin.crm.deal-cancelled.v1) | Reserved|Booked → Free (условно) | условный Release — только если холд принадлежит сделке |
Контекст события
- Из метаданных события доступны:
MessageId (Guid v7), CreatedAt, Version, AggregateId
(BookingId/DealId), AggregateType (Booking/Deal).
- Actor — system-actor консюмера CRM:
UpdatedByUserId объекта недвижимости штампуется им; работа в
platform-scope с явной сверкой event.TenantId == propertyObject.TenantId (fail closed, см. алгоритмы).
- Correlation: сквозной trace восстанавливается из метаданных события и пробрасывается в исходящие
pin.chessboard.property-object-status-changed.v1.
- Идемпотентность на уровне доставки — по
MessageId, ключ
crm-property-object-status:{EventType}:{MessageId}; бизнес-идемпотентность — по целевому состоянию
(Status + HoldDealId + таймер): повторное событие того же перехода → no-op skip.
Входные данные / payload событий
Контракты — дом crm (booking.md раздел «События»,
deal.md); детальные версионируемые контракты — документы событий, на которые
ссылаются заголовки подразделов ниже. Здесь — только поля, которые consumer реально читает.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости по id (id объекта недвижимости PIN, CRM хранит его из шахматки) |
DealId | Guid | Да | → PropertyObject.HoldDealId (владелец холда, INV-CHS-003) |
TenantId | Guid | Да | Сверка event.TenantId == propertyObject.TenantId (fail closed) |
ReserveExpiresAt | DateTimeOffset | Да | → PropertyObject.ReservedUntil (таймер резерва; source of truth таймера — программа бронирования CRM) |
BookingId | Guid | Да | Логи/трейсы (в состояние объекта недвижимости не пишется) |
| Поле payload | Тип | Обязательность | Как используется |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости |
DealId | Guid | Да | Guard DealId == propertyObject.HoldDealId (INV-CHS-003) |
TenantId | Guid | Да | Сверка tenant |
BookingExpiresAt | DateTimeOffset | Да | → PropertyObject.BookedUntil (таймер брони по программе застройщика); ReservedUntil очищается (INV-CHS-002) |
| Поле payload | Тип | Обязательность | Как используется |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости |
DealId | Guid | Да | Guard DealId == propertyObject.HoldDealId |
TenantId | Guid | Да | Сверка tenant |
Phase | string: Reserve/Booking | Да | Диагностика (ожидаемый текущий статус объекта недвижимости: Reserved при Reserve, Booked при Booking); расхождение → warn, release всё равно выполняется |
ExpiredAt | DateTimeOffset | Да | → PropertyObject.StatusChangedAt перехода |
| Поле payload | Тип | Обязательность | Как используется |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости |
DealId | Guid | Да | Guard DealId == propertyObject.HoldDealId |
TenantId | Guid | Да | Сверка tenant |
CancelReason | string | Нет | Только логи (в состояние объекта недвижимости не пишется) |
| Поле payload | Тип | Обязательность | Как используется |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости |
DealId | Guid | Да | Guard DealId == propertyObject.HoldDealId |
TenantId | Guid | Да | Сверка tenant |
CompletedAt | DateTimeOffset | Да | → PropertyObject.SoldAt |
Price | Money (Amount, CurrencyId + CurrencySnapshot — snapshots.md) | Да | Не перезаписывает PropertyObject.Price (цена сделки фиксируется CRM отдельно; паспортная цена объекта недвижимости меняется только Developers API/синком — INV-CHS-008); только лог для сверки (сумму в логах не печатать — финансовое поле) |
| Поле payload | Тип | Обязательность | Как используется |
|---|
PropertyObjectId | Guid | Да | Адресация объекта недвижимости |
DealId | Guid | Да | Условие release: propertyObject.HoldDealId == DealId (иначе холд принадлежит другой сделке — no-op) |
TenantId | Guid | Да | Сверка tenant |
CancelledAt | DateTimeOffset | Да | → PropertyObject.StatusChangedAt перехода |
Алгоритм
Шаги 1, 3, 7, 8 общие для всех шести обрабатываемых событий; шаги 2, 4–6 различаются по переходу.
Общие шаги
- Контекст события. Восстановить метаданные события;
Version != 1 → warn-and-proceed; проверить
идемпотентность по ключу crm-property-object-status:{eventType}:{MessageId} — дубль → лог
skipped-duplicate, ack.
- Проверка входа (общая часть).
PropertyObjectId, DealId, TenantId заданы; таймеры/моменты не
пустые. Существование объекта: объект по PropertyObjectId не найден →
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND — retriable (объект недвижимости мог ещё не доехать синком Talent;
после исчерпания — DLQ + алерт: CRM забронировал несуществующий в шахматке объект недвижимости).
Tenant fail closed: payload.TenantId != propertyObject.TenantId →
CHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH — non-retriable (DLQ + алерт: рассогласование границ данных).
- Что читаем. Достаточно самого объекта недвижимости (шаг 2); дополнительные агрегаты не нужны — все
guard-ы инкапсулированы в объекте.
- Согласованность. Переход объекта, отметка идемпотентности и исходящее событие фиксируются в одном
согласованном изменении; offset подтверждается после успешной фиксации.
- Результат. Успех → ack; retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Метрики/логи
— «Наблюдаемость».
A. BookingCreatedEvent → Reserve (Free → Reserved)
- Бизнес-правила и переходы.
- Идемпотентный повтор: объект
Reserved и холд той же сделки → обновить только ReservedUntil, если
отличается (CRM мог продлить резерв повторным событием), иначе no-op skip.
- Гонка/просрочка: объект
Booked и холд той же сделки — событие booking-created пришло после
booking-confirmed (кросс-топиковый беспорядок) → stale, no-op skip + метрика
chessboard_crm_out_of_order_total.
- Занято другим холдом: объект
Reserved/Booked, холд другой сделки →
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH — non-retriable (DLQ + алерт: нарушение INV-BKG-001 upstream
либо потерянный release).
- Недопустимый статус: объект
Sold/Hidden →
CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION — non-retriable (DLQ + алерт: CRM резервирует
проданный/скрытый объект недвижимости).
- Штатный переход:
Reserve (guard INV-CHS-001 Free → Reserved); маппинг: Status = Reserved,
ReservedUntil = payload.ReserveExpiresAt, BookedUntil = null, HoldDealId = payload.DealId,
StatusChangedAt = event.CreatedAt, StatusSource = Crm (INV-CHS-002 — атомарно). Поднимает
PropertyObjectStatusChangedEvent(Free → Reserved) + триггер ProjectAggregatesRecalcRequestedEvent
(пересчёт PropertyObjectsFreeCount).
- Что меняется. Объект переходит в
Reserved; гонка «CRM-событие vs правка застройщика» разрешается
оптимистической блокировкой: конфликт → немедленный retry с перечитыванием (property-object.md).
- Какие события фиксируются.
PropertyObjectStatusChangedKafkaEvent
(pin.chessboard.property-object-status-changed.v1, key = объект, AggregateType = PropertyObject) — payload
по контракту: FromStatus = Free, ToStatus = Reserved,
ReservedUntil, HoldDealId и т.д.
B. BookingConfirmedEvent → Book (Reserved → Booked)
- Бизнес-правила и переходы.
- Идемпотентный повтор: объект
Booked и холд той же сделки → обновить BookedUntil, если отличается
(продление брони), иначе skip.
- Hold-guard (INV-CHS-003): холд не принадлежит сделке события →
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH — non-retriable, DLQ + алерт.
- Пропущенный резерв: объект
Free (release уже прошёл — job истечения успел раньше confirm-а) →
применяется восстановление холда: Reserve + Book одним проходом c warning
chessboard_crm_out_of_order_total (бронь подтверждена CRM — источником истины по холдам; объект не должен
остаться свободным при активной брони).
- Недопустимый статус (
Sold/Hidden) → CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION (DLQ).
- Штатный переход:
Book (guard Reserved → Booked + INV-CHS-003); маппинг: Status = Booked,
BookedUntil = payload.BookingExpiresAt, ReservedUntil = null (INV-CHS-002), StatusChangedAt,
StatusSource = Crm.
- Что меняется. Объект переходит в
Booked.
- Какие события фиксируются.
PropertyObjectStatusChangedKafkaEvent (Reserved → Booked, BookedUntil).
C/D. BookingExpiredEvent / BookingCancelledEvent → Release (Reserved|Booked → Free)
- Бизнес-правила и переходы.
- Идемпотентный повтор: объект
Free и HoldDealId == null → no-op skip (release уже применён — этим же
консюмером, парным событием или джобом property-object-hold-expiration; двойной release штатен).
- Hold-guard (INV-CHS-003): холд задан и не принадлежит сделке события →
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH — «чужой» release не освобождает объект недвижимости (DLQ + алерт
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH).
- Объект
Sold → no-op skip + warn (сделка уже завершена; поздний expired/cancelled — хвост процесса CRM).
- Для
BookingExpiredEvent: сверка payload.Phase с текущим статусом (Reserve↔Reserved,
Booking↔Booked) — расхождение только warn.
- Штатный переход:
Release (guard Reserved|Booked → Free); маппинг: Status = Free,
ReservedUntil = null, BookedUntil = null, HoldDealId = null (INV-CHS-002), StatusChangedAt,
StatusSource = Crm. Сообщение в чат «время истекло, подтвердите/продлите» — ответственность
CRM/messenger, шахматка его не шлёт.
- Что меняется. Объект переходит в
Free.
- Какие события фиксируются.
PropertyObjectStatusChangedKafkaEvent (Reserved|Booked → Free).
E. DealCompletedEvent → MarkSold (Booked → Sold)
- Бизнес-правила и переходы.
- Идемпотентный повтор: объект
Sold → no-op skip.
- Hold-guard (INV-CHS-003): холд не принадлежит сделке события →
CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH — non-retriable (продажа по чужому холду), DLQ + алерт.
- Продажа без брони: объект
Free/Reserved — сделка завершена в обход штатной цепочки (менеджер ПИН
закрыл сделку без confirm-а брони) → переход применяется (MarkSold) c warning
chessboard_crm_out_of_order_total: терминальный факт продажи важнее промежуточных состояний.
- Объект
Hidden → CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION (DLQ + алерт).
- Штатный переход:
MarkSold; маппинг: Status = Sold (терминальный), SoldAt = payload.CompletedAt,
ReservedUntil = null, BookedUntil = null, HoldDealId = null (холд снят — сделка завершена; история
связи объект недвижимости↔сделка — в CRM и в событиях), StatusChangedAt, StatusSource = Crm.
PropertyObject.Price не обновляется ценой сделки (INV-CHS-008).
- Что меняется. Объект переходит в
Sold.
- Какие события фиксируются.
PropertyObjectStatusChangedKafkaEvent (Booked → Sold, SoldAt).
F. DealCancelledEvent → условный Release
- Бизнес-правила и переходы.
- Холд не принадлежит сделке события (включая
null) → no-op skip (штатно: активной брони по этой
сделке нет — она уже снята booking-cancelled/booking-expired, либо холда не было вовсе; deal-cancelled —
страховочный release, лог skipped-no-hold).
- Объект
Reserved/Booked и холд принадлежит сделке события → Release (как C/D).
- Объект
Sold → no-op skip + warn (отмена завершённой сделки — процесс CRM, статус объекта не
откатывается автоматически; возврат в продажу — admin-операция).
- Что меняется. Объект переходит в
Free — только при выполненном release.
- Какие события фиксируются.
PropertyObjectStatusChangedKafkaEvent — только при выполненном release.
Диаграмма
Идемпотентность и replay
- На уровне доставки: по
MessageId (crm-property-object-status:{eventType}:{MessageId}).
- Business-level: идемпотентность по целевому состоянию — повторное событие того же перехода с
тем же
DealId и тем же таймером → no-op; с новым таймером → обновление таймера (продление).
Двойной release безопасен (Free + HoldDealId == null → skip).
- Replay: да, с оговоркой. Re-read топиков воспроизводит переходы; устаревшие события
отсеиваются guard-ами по текущему состоянию (терминальный
Sold не откатывается; release чужого
холда блокируется INV-CHS-003). Точечный replay DLQ — после ручного разбора (runbook «ручной
release залипшего холда», README chessboard). Полный пересчёт истории статусов не нужен: история
восстанавливается из pin.chessboard.property-object-status-changed.v1.
Retry и DLQ
DLQ-топик группы: pin.chessboard.crm-property-object-status-dlq.v1 (envelope исходного события + заголовки
x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|
| Инфраструктурный (PostgreSQL/Kafka, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Восстановить зависимость (runbook chessboard-overview) |
| Объект недвижимости не найден (синк Talent отстал) | CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND | 6 попыток, backoff 30s → 1m → 2m → 4m → 8m → 16m | Да, после исчерпания | Проверить синк объекта недвижимости; replay из DLQ |
| Tenant рассогласован | CHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH | Нет | Да + алерт | Разбор данных CRM↔chessboard |
| Чужой холд (INV-CHS-003) | CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH | Нет | Да + алерт CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH | Runbook «ручной release залипшего холда»; сверка bookings CRM |
| Недопустимый переход (INV-CHS-001) | CHESSBOARD_PROPERTY_OBJECT_INVALID_STATUS_TRANSITION | Нет | Да + алерт | Разбор: резерв проданного/скрытого объекта недвижимости — данные CRM |
| Конфликт версии при гонке с правкой застройщика/job-ом | — (конфликт) | 3 попытки немедленно (перечитать объект недвижимости; guard-ы по состоянию отсеют применённое) | После исчерпания | Расследование гонки |
Acceptance Criteria
- Given объект
Free, When приходит BookingCreatedEvent с валидными DealId/TenantId/ReserveExpiresAt,
Then объект → Reserved, проставлены ReservedUntil и HoldDealId, опубликован
PropertyObjectStatusChangedKafkaEvent(Free → Reserved), поднят ProjectAggregatesRecalcRequestedEvent, offset ack.
- Given объект уже
Reserved тем же DealId с тем же таймером, When повторный BookingCreatedEvent
(та же MessageId или повтор перехода), Then no-op skip, событие не публикуется, ack; с бо́льшим
ReserveExpiresAt — обновляется только ReservedUntil (продление).
- Given объект
Booked тем же DealId, When приходит BookingCreatedEvent (после booking-confirmed,
кросс-топиковый беспорядок), Then stale no-op skip + chessboard_crm_out_of_order_total, статус не откатывается.
- Given объект
Reserved/Booked холдом сделки A, When приходит confirm/release/complete по сделке B,
Then CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH → DLQ + алерт, переход не выполняется (INV-CHS-003).
- Given объект
Free и HoldDealId = null, When приходит BookingExpiredEvent/BookingCancelledEvent,
Then double-release no-op skip, ack (штатно: release уже применён консюмером/парным событием/job-ом).
- Given объект
Free/Reserved (бронь не подтверждена), When DealCompletedEvent с холдом сделки,
Then MarkSold применяется + warning out_of_order (терминальный факт продажи важнее промежуточных состояний).
- Given объекта по
PropertyObjectId нет в реплике, When любое событие CRM, Then
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND → retry по политике → DLQ + алерт после исчерпания (синк Talent отстал).
- Given
event.TenantId != propertyObject.TenantId, When любое событие, Then
CHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH → DLQ + алерт (fail closed, без перехода).
- Given объект
Sold, When приходит поздний expired/cancelled/deal-cancelled, Then no-op skip + warn
(терминальный статус не откатывается; возврат в продажу — admin-операция).
Наблюдаемость и безопасность
- Logs (structured):
consumed / skipped-duplicate / skipped-no-hold / applied /
timer-extended / hold-mismatch / dead-lettered с полями EventType, PropertyObjectId, DealId,
FromStatus, ToStatus, таймеры. PII нет.
- Metrics:
chessboard_crm_consume_duration_ms, chessboard_crm_lag_seconds,
chessboard_crm_transitions_total{from,to}, chessboard_crm_out_of_order_total,
chessboard_crm_hold_mismatch_total, chessboard_crm_retries_total, chessboard_crm_dlq_total,
chessboard_crm_contract_mismatch_total.
- Traces: span приёма события → span обработки → span обращения к хранилищу; correlation из метаданных
события — в исходящее
property-object-status-changed.
- Alerts: DLQ > 0;
chessboard_crm_hold_mismatch_total > 0 (CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH,
README chessboard); лаг применения > SLA 5 с (p99).
- Dashboard / Runbook: chessboard-overview; runbooks «replay DLQ CRM», «ручной release
залипшего холда».
- Безопасность: system-actor; переходы статусов продажи доступны только этому consumer-у и
job-у истечения (permission-модель README chessboard: «смена статусов — только system-actor»);
tenant mismatch → fail closed; payload PII не содержит (участники сделки остаются в CRM).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы