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

Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events)

Назначение

Консюмер материализует дедлайны броней CRM как автособытия календаря агента сделки: по booking-created в календаре появляется событие Kind = BookingDeadline «Оплата резерва до ReserveExpiresAt», по booking-confirmed резерв-дедлайн завершается и появляется дедлайн фазы «Бронь» (до BookingExpiresAt), по booking-paid резерв-дедлайн завершается (оплата получена), по booking-expired/booking-cancelled открытые дедлайны брони отменяются. Так агент видит таймеры резерва/брони прямо в рабочем календаре (calendar-event.md), а напоминания о них уходят штатным механизмом (event-reminder.md). Все порождаемые события имеют Origin = CrmBooking и управляются только этим консюмером (INV-CAL-4); собственных интеграционных событий сверх штатного жизненного цикла события календаря консюмер не вводит.

Owning-сервис и триггер

ПолеЗначение
Сервис/контекстcalendar
ConsumerCalendar CRM Booking Sync
Обрабатываемые событияpin.crm.booking-created, pin.crm.booking-confirmed, pin.crm.booking-paid, pin.crm.booking-expired, pin.crm.booking-cancelled
Topicspin.crm.booking-created.v1, pin.crm.booking-confirmed.v1, pin.crm.booking-paid.v1, pin.crm.booking-expired.v1, pin.crm.booking-cancelled.v1 (key = BookingId в каждом — per-booking порядок внутри топика; кросс-топик порядок не гарантирован)
ContractVersion1 у всех пяти; при несовпадении — warn-and-proceed (метрика calendar_crm_booking_sync_contract_mismatch_total)
Consumer groupcalendar-crm-booking-events
Producercrm (жизненный цикл брони, booking.md)
Эффект (событие → что меняется)Создание/завершение/перенос/отмена событий Kind = BookingDeadline (Origin = CrmBooking) + ведение проекции-связки BookingDeadlineLinkEntity

Границы scope. Консюмер ведёт только дедлайн-события и связки «бронь → событие календаря». Условия программы (ProgramTerms), суммы оплаты, статусы объекта недвижимости календарю не нужны и не реплицируются. Маршрутизация «в чей календарь класть дедлайн» — по реплике сделок (crm-deal-events.md): владелец — ExecutorAgentUserId (текущий исполнитель), tenant — AgencyCompanyId. Отмена дедлайнов идемпотентно пересекается с отменой тех же событий по терминальным deal-событиям (crm-deal-events.md) — кто успел первым, второй получает no-op.

Consumer-owned проекция; дом полей — этот документ. Назначение: business-идемпотентность (не более одного дедлайн-события на пару «бронь + фаза»), адресация события по booking-событиям без сканирования calendar_events и выборка «открытые дедлайны сделки» для консюмера deal-событий. Собственных API-операций нет.

ПолеТипОбязательностьОграниченияИсточникОписание
BookingIdGuidДаPK (составной с Phase)payloadБронь CRM.
PhaseBookingDeadlinePhaseДаPK (составной); enum (хранится как text), varchar(16)тип событияФаза дедлайна, см. enum ниже.
DealIdGuidДаindex ix_booking_deadline_links_deal_idpayloadСделка CRM — по этому индексу консюмер deal-событий находит открытые дедлайны закрываемой сделки.
TenantIdGuid?НетCrmDealReplica.AgencyCompanyIdTenant владельца календаря (агентство агента); null у tombstone-связки, созданной без маршрутизации.
CalendarEventIdGuid?Условноunique partial ux_booking_deadline_links_event_id where not nullconsumerПорождённое дедлайн-событие; null у tombstone-связки (событие не создавалось).
StatusDeadlineLinkStatusДаenum (хранится как text), varchar(16)consumerСостояние связки, см. enum ниже.
CreatedAtDateTimeOffsetДаconsumerСоздание связки.
UpdatedAtDateTimeOffsetДаconsumerПоследнее применённое событие.

Enum BookingDeadlinePhase

ЗначениеДедлайнИсточник момента
Reserve = 1Оплата/подтверждение резерваbooking-created.ReserveExpiresAt
Booking = 2Действие брони (подтвердить/продлить)booking-confirmed.BookingExpiresAt

Enum DeadlineLinkStatus

ЗначениеОписание
Open = 1Дедлайн-событие создано и открыто (Planned/InProgress).
Completed = 2Фаза завершилась штатно (booking-confirmed/booking-paid) — событие Completed. Терминальный.
Cancelled = 3Фаза снята (booking-expired/booking-cancelled/терминал сделки), либо tombstone без события (поздний/просроченный вход). Терминальный.

Входные данные / payload событий

Контракты — дома producer-а; здесь только читаемые поля и маппинг. ProgramTerms, PaymentAmount, InitiatedBy, CancelledByUserId, CancelReason (free text) и прочие поля payload консюмер не читает.

КонтрактЧитаемые поляЭффект
booking-created.mdBookingId, DealId, PropertyObjectId, ReserveExpiresAtСоздание дедлайн-события фазы Reserve (таблица маппинга ниже).
booking-confirmed.mdBookingId, DealId, PropertyObjectId, BookingExpiresAtЗавершение резерв-дедлайна + upsert дедлайн-события фазы Booking; повтор с новым BookingExpiresAt = перенос (продление брони).
booking-paid.mdBookingIdЗавершение резерв-дедлайна (оплата получена); новых событий не создаёт.
booking-expired.mdBookingId, PhaseОтмена всех открытых дедлайнов брони; причина по Phase: «Резерв истёк» / «Бронь истекла» (unknown Phase — fail-safe «Бронирование истекло»).
booking-cancelled.mdBookingIdОтмена всех открытых дедлайнов брони, причина «Бронь отменена»; CancelReason payload-а в календарь не копируется.

Маппинг: booking-событие → дедлайн-событие календаря

Дом полей события — calendar-event.md; создание проходит его инварианты (INV-CAL-1/5: точечное событие, BookingId + DealId обязательны).

Поле событияPhase = Reserve (booking-created)Phase = Booking (booking-confirmed)
TenantId / OwnerUserIdCrmDealReplica.AgencyCompanyId / CrmDealReplica.ExecutorAgentUserId (реплика сделок, crm-deal-events.md)те же
Kind / Origin / StatusBookingDeadline / CrmBooking / Plannedте же
PriorityCritical (дедлайн с оплатой)Critical
StartAt / EndAtReserveExpiresAt / null (точечный дедлайн)BookingExpiresAt / null
TimeZoneEurope/Moscow — default платформы (допущение v1, см. «Допущения»)та же
Title«Оплата резерва до {StartAt}»«Дедлайн брони: подтвердить/продлить до {StartAt}»
DealId / BookingId / PropertyObjectIdиз payloadиз payload
ClientUserId / LeadId / ProjectId / CreatedByUserIdnull (в booking-событиях нет)null
ReminderOffsetsMinutes{30} (резерв короткий, default-офсеты были бы в прошлом){1440, 60} (default)

Напоминания материализуются по правилам event-reminder.md (офсеты с DueAt в прошлом — сразу Skipped); создание публикует штатное pin.calendar.event-created.v1.

Алгоритм

  1. Контекст события. Восстановить метаданные (идентификатор сообщения, BookingId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключу calendar-crm-booking-events:{MessageId}: уже обработано → пропустить как дубль (ack).
  2. Проверка входа. Обязательные поля контракта присутствуют (BookingId; для booking-createdDealId, PropertyObjectId, ReserveExpiresAt; для booking-confirmedDealId, BookingExpiresAt). Нарушение → CAL_BOOKING_SYNC_INVALID_PAYLOAD (non-retriable → DLQ).
  3. booking-created. Связка (BookingId, Reserve) уже существует → business-дубль или поздняя доставка после терминала: no-op, ack. Иначе — маршрутизация из реплики сделок:
    • реплика по DealId не найдена, либо AgencyCompanyId/ExecutorAgentUserId не заполнены → CAL_BOOKING_SYNC_DEAL_REPLICA_NOT_FOUND (retriable: deal-created идёт другим топиком и может отставать);
    • реплика IsTerminal = true → сделка закрыта: событие не создаётся, связка пишется tombstone-ом (Cancelled, CalendarEventId = null), лог deal-terminal-skip, ack;
    • ReserveExpiresAt ≤ now → дедлайн уже в прошлом (лаг консюмера/replay): событие не создаётся (следом придёт booking-expired), связка — tombstone Cancelled, лог deadline-in-past, ack;
    • иначе — создать дедлайн-событие фазы Reserve по таблице маппинга, материализовать напоминания, записать связку Open с CalendarEventId.
  4. booking-confirmed. Два независимых эффекта в одном согласованном изменении:
    • завершение резерва: связка (BookingId, Reserve) в Open → событие → Completed (резерв завершился подтверждением), связка → Completed; связки нет или уже терминальна → no-op (out-of-order fail-safe: резерв-дедлайн задним числом не создаётся);
    • upsert дедлайна брони: связка (BookingId, Booking) отсутствует → создать событие фазы Booking (маршрутизация и guard-ы — как в шаге 3, момент — BookingExpiresAt); связка Open и StartAt события ≠ BookingExpiresAtперенос (продление брони, INV-CAL-4): StartAt ← новый BookingExpiresAt, напоминания пересоздаются, публикуется штатное pin.calendar.event-rescheduled.v1; связка Open и момент совпал → no-op; связка терминальна → no-op (поздняя доставка после истечения/отмены).
  5. booking-paid. Завершение резерв-дедлайна — как в шаге 4, первая часть (оплата получена). Гонка с booking-confirmed за один переход — идемпотентна: победитель завершает, второй получает no-op. Новых событий не создаётся; дедлайн фазы Booking появится по booking-confirmed.
  6. booking-expired / booking-cancelled. По BookingId найти связки обеих фаз:
    • каждая связка Open → событие → Cancelled (CancelReason по таблице payload-маппинга), Pending-напоминания → Skipped каскадом события (event-reminder.md, правило 3), связка → Cancelled; публикуется штатное pin.calendar.event-status-changed.v1;
    • отсутствующие связки дозаписываются tombstone-ами Cancelled (CalendarEventId = null) — guard от поздних booking-created/booking-confirmed (out-of-order fail-safe);
    • уже терминальные связки/события — no-op (в т.ч. гонка с отменой по терминальным deal-событиям).
  7. Согласованность. Изменение событий и напоминаний, upsert связок, исходящие события жизненного цикла (event-created/event-rescheduled/event-status-changed) и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации.
  8. Результат. Успех → ack (включая все no-op/skip-ветки); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт.

Диаграмма

Идемпотентность и replay

  • На уровне консюмера: ключ calendar-crm-booking-events:{MessageId} — повторная доставка не меняет состояние.
  • На бизнес-уровне: unique (BookingId, Phase) связки — не более одного дедлайн-события на фазу брони независимо от числа доставок; завершение/отмена идемпотентны по терминальности связки и события; перенос идемпотентен по сравнению StartAt с BookingExpiresAt.
  • Кросс-топик порядок. Пять топиков взаимно не упорядочены. Fail-safe-правила: терминальные события пишут tombstone-связки обеих фаз — поздние booking-created/booking-confirmed получают no-op и не воскрешают дедлайны; booking-confirmed/booking-paid без резерв-связки не создают её задним числом; booking-created после уже созданной связки — no-op.
  • Replay: да. Полный повторный проход всех пяти топиков с offset 0 безопасен: unique-связки + идемпотентность по состоянию + guard «дедлайн в прошлом» сходятся к текущему снимку без дублей событий и напоминаний. Runbook — «пересинхронизация дедлайнов календаря» (docs/06-services/calendar/runbooks/, будущий путь по конвенции).

Retry и DLQ

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook: восстановить зависимость
Реплика сделки не найдена / маршрутизация не заполнена (booking-* обогнал deal-created)CAL_BOOKING_SYNC_DEAL_REPLICA_NOT_FOUNDExponential backoff до 24 чПосле исчерпания + алертПроверить публикацию deal-created; replay deal-топика
Отсутствуют обязательные поля / нераспознаваемый payloadCAL_BOOKING_SYNC_INVALID_PAYLOADНетДа + алертРазбор контракта с producer-ом crm
Конкурентная правка (гонка с консюмером deal-событий / диспетчером напоминаний за одно событие)3 попытки немедленно (перечитать состояние)После исчерпанияРасследование гонки (ожидаемый исход — no-op у проигравшего)
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта producer-а

Наблюдаемость и безопасность

  • Logs (structured): consumed / skipped-duplicate / deadline-created / deadline-completed / deadline-rescheduled / deadline-cancelled (число) / deal-terminal-skip / deadline-in-past / failed — с MessageId, BookingId, DealId, Phase, топиком. PII в payload и логах нет (только идентификаторы и моменты времени).
  • Metrics: calendar_crm_booking_sync_consume_duration_ms, calendar_crm_booking_sync_deadlines_created_total{phase}, calendar_crm_booking_sync_deadlines_completed_total, calendar_crm_booking_sync_deadlines_rescheduled_total, calendar_crm_booking_sync_deadlines_cancelled_total, calendar_crm_booking_sync_skipped_total{reason}, calendar_crm_booking_sync_replica_not_found_total, calendar_crm_booking_sync_dlq_total, calendar_crm_booking_sync_contract_mismatch_total, calendar_crm_booking_sync_lag_seconds.
  • Traces: span приёма → span обработки → spans записи; корреляция из метаданных события.
  • Alerts: лаг консюмера > 5 мин (дедлайн в календаре должен появляться быстрее, чем истекает резерв); DLQ > 0; устойчивый рост replica_not_found_total (рассинхрон с deal-топиками).
  • Безопасность: system-actor; запись — только в схему calendar (события своего контекста + связки); топики pin.crm.booking-* — m2m ACL Kafka. Payload не несёт ФИО/телефонов; display-данные агента календарь берёт из реплики identity (identity-user-upserted.md). TenantId дедлайн-события — агентство агента (не застройщик из payload): tenant владельца календаря, ADR-0052.

Допущения и ограничения (v1)

  • Таймзона дедлайн-событий — default платформы Europe/Moscow: booking-события таймзону агента не несут, синхронного чтения профиля консюмер не делает. Момент дедлайна хранится в UTC и корректен; локальное отображение — забота клиента.
  • Продление брони (Extend, INV-BKG-6 в booking.md) отдельным контрактом не публикуется. Консюмер обрабатывает перенос через повторный booking-confirmed с новым BookingExpiresAt (шаг 4); требование к producer-у crm: публиковать booking-confirmed и при продлении (additive-совместимо, payload не меняется). Пока дельта не внедрена, продлённый дедлайн в календаре остаётся на прежний момент — при фактическом истечении его снимет booking-expired.
  • Смена исполнителя / уход агента сделки переносит открытые дедлайн-события на нового исполнителя — переназначение владельца выполняет консюмер deal-событий (crm-deal-events.md, шаг 4a, INV-CAL-8); этот консюмер лишь маршрутизирует новые дедлайны по текущему ExecutorAgentUserId реплики.

Acceptance Criteria

  • AC-1 (резерв-дедлайн создан). Дано: реплика сделки с маршрутизацией, booking-created (ReserveExpiresAt через 60 мин). Когда: обработка. Тогда: событие BookingDeadline (Origin = CrmBooking, Planned, Priority = Critical, StartAt = ReserveExpiresAt, EndAt = null) в календаре ExecutorAgentUserId, напоминание с офсетом 30 мин Pending, связка (BookingId, Reserve) = Open, опубликован event-created.
  • AC-2 (подтверждение). Дано: открытый резерв-дедлайн. Когда: booking-confirmed (BookingExpiresAt через 10 дней). Тогда: резерв-событие Completed (связка Completed), создано событие фазы Booking (StartAt = BookingExpiresAt, офсеты {1440, 60}), связка (BookingId, Booking) = Open.
  • AC-3 (оплата завершает резерв). Дано: открытый резерв-дедлайн. Когда: booking-paid. Тогда: резерв-событие Completed; последующий booking-confirmed по резерв-части — no-op, дедлайн фазы Booking создаётся штатно.
  • AC-4 (продление = перенос). Дано: связка (BookingId, Booking) Open, StartAt = T. Когда: повторный booking-confirmed с BookingExpiresAt = T + 240 ч. Тогда: событие перенесено (StartAt = T + 240 ч), напоминания пересозданы, опубликован event-rescheduled; событие с тем же моментом — no-op.
  • AC-5 (истечение/отмена). Дано: открытые дедлайны одной или обеих фаз. Когда: booking-expired (Phase = Booking) или booking-cancelled. Тогда: все открытые дедлайн-события брони Cancelled с причиной по таблице, Pending-напоминания Skipped, связки терминальны (+ tombstone для отсутствовавших фаз), опубликованы event-status-changed.
  • AC-6 (out-of-order). Дано: booking-cancelled обработан раньше booking-created. Тогда: связки — tombstone Cancelled; поздний booking-created — no-op, событие не создаётся.
  • AC-7 (booking- раньше deal-created).* Дано: booking-created для сделки без реплики. Тогда: CAL_BOOKING_SYNC_DEAL_REPLICA_NOT_FOUND, retry с backoff; после прихода deal-created повтор успешен.
  • AC-8 (терминальная сделка). Дано: реплика сделки IsTerminal = true. Когда: booking-created. Тогда: событие не создаётся, связка — tombstone Cancelled, ack (deal-terminal-skip).
  • AC-9 (дубль). Дано: уже обработанный MessageId. Когда: повторная доставка. Тогда: skipped-duplicate, без изменений, ack.

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

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

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