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

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 grouppin-chessboard-crm-property-object-status
Producercrm (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
ContractVersion1 у всех событий 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 → ReservedReserve: ставит ReservedUntil и HoldDealId, переопубликует статус
BookingConfirmedEvent (pin.crm.booking-confirmed.v1)Reserved → BookedBook: ставит BookedUntil, чистит ReservedUntil
BookingExpiredEvent (pin.crm.booking-expired.v1)Reserved|Booked → FreeRelease: чистит таймеры и HoldDealId
BookingCancelledEvent (pin.crm.booking-cancelled.v1)Reserved|Booked → FreeRelease (то же)
DealCompletedEvent (pin.crm.deal-completed.v1)Booked → SoldMarkSold: ставит 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 реально читает.

BookingCreatedEvent (topic pin.crm.booking-created.v1)

Поле payloadТипОбязательностьКак используется (маппинг)
PropertyObjectIdGuidДаАдресация объекта недвижимости по id (id объекта недвижимости PIN, CRM хранит его из шахматки)
DealIdGuidДаPropertyObject.HoldDealId (владелец холда, INV-CHS-003)
TenantIdGuidДаСверка event.TenantId == propertyObject.TenantId (fail closed)
ReserveExpiresAtDateTimeOffsetДаPropertyObject.ReservedUntil (таймер резерва; source of truth таймера — программа бронирования CRM)
BookingIdGuidДаЛоги/трейсы (в состояние объекта недвижимости не пишется)

BookingConfirmedEvent (topic pin.crm.booking-confirmed.v1)

Поле payloadТипОбязательностьКак используется
PropertyObjectIdGuidДаАдресация объекта недвижимости
DealIdGuidДаGuard DealId == propertyObject.HoldDealId (INV-CHS-003)
TenantIdGuidДаСверка tenant
BookingExpiresAtDateTimeOffsetДаPropertyObject.BookedUntil (таймер брони по программе застройщика); ReservedUntil очищается (INV-CHS-002)

BookingExpiredEvent (topic pin.crm.booking-expired.v1)

Поле payloadТипОбязательностьКак используется
PropertyObjectIdGuidДаАдресация объекта недвижимости
DealIdGuidДаGuard DealId == propertyObject.HoldDealId
TenantIdGuidДаСверка tenant
Phasestring: Reserve/BookingДаДиагностика (ожидаемый текущий статус объекта недвижимости: Reserved при Reserve, Booked при Booking); расхождение → warn, release всё равно выполняется
ExpiredAtDateTimeOffsetДаPropertyObject.StatusChangedAt перехода

BookingCancelledEvent (topic pin.crm.booking-cancelled.v1)

Поле payloadТипОбязательностьКак используется
PropertyObjectIdGuidДаАдресация объекта недвижимости
DealIdGuidДаGuard DealId == propertyObject.HoldDealId
TenantIdGuidДаСверка tenant
CancelReasonstringНетТолько логи (в состояние объекта недвижимости не пишется)

DealCompletedEvent (topic pin.crm.deal-completed.v1)

Поле payloadТипОбязательностьКак используется
PropertyObjectIdGuidДаАдресация объекта недвижимости
DealIdGuidДаGuard DealId == propertyObject.HoldDealId
TenantIdGuidДаСверка tenant
CompletedAtDateTimeOffsetДаPropertyObject.SoldAt
PriceMoney (Amount, CurrencyId + CurrencySnapshotsnapshots.md)ДаНе перезаписывает PropertyObject.Price (цена сделки фиксируется CRM отдельно; паспортная цена объекта недвижимости меняется только Developers API/синком — INV-CHS-008); только лог для сверки (сумму в логах не печатать — финансовое поле)

DealCancelledEvent (topic pin.crm.deal-cancelled.v1)

Поле payloadТипОбязательностьКак используется
PropertyObjectIdGuidДаАдресация объекта недвижимости
DealIdGuidДаУсловие release: propertyObject.HoldDealId == DealId (иначе холд принадлежит другой сделке — no-op)
TenantIdGuidДаСверка tenant
CancelledAtDateTimeOffsetДаPropertyObject.StatusChangedAt перехода

Алгоритм

Шаги 1, 3, 7, 8 общие для всех шести обрабатываемых событий; шаги 2, 4–6 различаются по переходу.

Общие шаги

  1. Контекст события. Восстановить метаданные события; Version != 1 → warn-and-proceed; проверить идемпотентность по ключу crm-property-object-status:{eventType}:{MessageId} — дубль → лог skipped-duplicate, ack.
  2. Проверка входа (общая часть). PropertyObjectId, DealId, TenantId заданы; таймеры/моменты не пустые. Существование объекта: объект по PropertyObjectId не найден → CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND — retriable (объект недвижимости мог ещё не доехать синком Talent; после исчерпания — DLQ + алерт: CRM забронировал несуществующий в шахматке объект недвижимости). Tenant fail closed: payload.TenantId != propertyObject.TenantIdCHESSBOARD_PROPERTY_OBJECT_TENANT_MISMATCH — non-retriable (DLQ + алерт: рассогласование границ данных).
  3. Что читаем. Достаточно самого объекта недвижимости (шаг 2); дополнительные агрегаты не нужны — все guard-ы инкапсулированы в объекте.
  4. Согласованность. Переход объекта, отметка идемпотентности и исходящее событие фиксируются в одном согласованном изменении; offset подтверждается после успешной фиксации.
  5. Результат. Успех → ack; retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — «Наблюдаемость».

A. BookingCreatedEventReserve (Free → Reserved)

  1. Бизнес-правила и переходы.
    • Идемпотентный повтор: объект 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/HiddenCHESSBOARD_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).
  2. Что меняется. Объект переходит в Reserved; гонка «CRM-событие vs правка застройщика» разрешается оптимистической блокировкой: конфликт → немедленный retry с перечитыванием (property-object.md).
  3. Какие события фиксируются. PropertyObjectStatusChangedKafkaEvent (pin.chessboard.property-object-status-changed.v1, key = объект, AggregateType = PropertyObject) — payload по контракту: FromStatus = Free, ToStatus = Reserved, ReservedUntil, HoldDealId и т.д.

B. BookingConfirmedEventBook (Reserved → Booked)

  1. Бизнес-правила и переходы.
    • Идемпотентный повтор: объект 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.
  2. Что меняется. Объект переходит в Booked.
  3. Какие события фиксируются. PropertyObjectStatusChangedKafkaEvent (Reserved → Booked, BookedUntil).

C/D. BookingExpiredEvent / BookingCancelledEventRelease (Reserved|Booked → Free)

  1. Бизнес-правила и переходы.
    • Идемпотентный повтор: объект 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 с текущим статусом (ReserveReserved, BookingBooked) — расхождение только warn.
    • Штатный переход: Release (guard Reserved|Booked → Free); маппинг: Status = Free, ReservedUntil = null, BookedUntil = null, HoldDealId = null (INV-CHS-002), StatusChangedAt, StatusSource = Crm. Сообщение в чат «время истекло, подтвердите/продлите» — ответственность CRM/messenger, шахматка его не шлёт.
  2. Что меняется. Объект переходит в Free.
  3. Какие события фиксируются. PropertyObjectStatusChangedKafkaEvent (Reserved|Booked → Free).

E. DealCompletedEventMarkSold (Booked → Sold)

  1. Бизнес-правила и переходы.
    • Идемпотентный повтор: объект 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: терминальный факт продажи важнее промежуточных состояний.
    • Объект HiddenCHESSBOARD_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).
  2. Что меняется. Объект переходит в Sold.
  3. Какие события фиксируются. PropertyObjectStatusChangedKafkaEvent (Booked → Sold, SoldAt).

F. DealCancelledEvent → условный Release

  1. Бизнес-правила и переходы.
    • Холд не принадлежит сделке события (включая 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-операция).
  2. Что меняется. Объект переходит в Free — только при выполненном release.
  3. Какие события фиксируются. 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).

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетВосстановить зависимость (runbook chessboard-overview)
Объект недвижимости не найден (синк Talent отстал)CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND6 попыток, 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_MISMATCHRunbook «ручной 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).

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

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

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