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 |
| Consumer | Calendar CRM Booking Sync |
| Обрабатываемые события | pin.crm.booking-created, pin.crm.booking-confirmed, pin.crm.booking-paid, pin.crm.booking-expired, pin.crm.booking-cancelled |
| Topics | pin.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 порядок внутри топика; кросс-топик порядок не гарантирован) |
| ContractVersion | 1 у всех пяти; при несовпадении — warn-and-proceed (метрика calendar_crm_booking_sync_contract_mismatch_total) |
| Consumer group | calendar-crm-booking-events |
| Producer | crm (жизненный цикл брони, booking.md) |
| Эффект (событие → что меняется) | Создание/завершение/перенос/отмена событий Kind = BookingDeadline (Origin = CrmBooking) + ведение проекции-связки BookingDeadlineLinkEntity |
Границы scope. Консюмер ведёт только дедлайн-события и связки «бронь → событие календаря». Условия
программы (ProgramTerms), суммы оплаты, статусы объекта недвижимости календарю не нужны и не
реплицируются. Маршрутизация «в чей календарь класть дедлайн» — по реплике сделок
(crm-deal-events.md): владелец — ExecutorAgentUserId (текущий исполнитель),
tenant — AgencyCompanyId. Отмена
дедлайнов идемпотентно пересекается с отменой тех же событий по терминальным deal-событиям
(crm-deal-events.md) — кто успел первым, второй получает no-op.
Проекция-связка BookingDeadlineLinkEntity (calendar.booking_deadline_links)
Consumer-owned проекция; дом полей — этот документ. Назначение: business-идемпотентность (не более одного
дедлайн-события на пару «бронь + фаза»), адресация события по booking-событиям без сканирования
calendar_events и выборка «открытые дедлайны сделки» для консюмера deal-событий. Собственных
API-операций нет.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
BookingId | Guid | Да | PK (составной с Phase) | payload | Бронь CRM. |
Phase | BookingDeadlinePhase | Да | PK (составной); enum (хранится как text), varchar(16) | тип события | Фаза дедлайна, см. enum ниже. |
DealId | Guid | Да | index ix_booking_deadline_links_deal_id | payload | Сделка CRM — по этому индексу консюмер deal-событий находит открытые дедлайны закрываемой сделки. |
TenantId | Guid? | Нет | — | CrmDealReplica.AgencyCompanyId | Tenant владельца календаря (агентство агента); null у tombstone-связки, созданной без маршрутизации. |
CalendarEventId | Guid? | Условно | unique partial ux_booking_deadline_links_event_id where not null | consumer | Порождённое дедлайн-событие; null у tombstone-связки (событие не создавалось). |
Status | DeadlineLinkStatus | Да | enum (хранится как text), varchar(16) | consumer | Состояние связки, см. enum ниже. |
CreatedAt | DateTimeOffset | Да | — | consumer | Создание связки. |
UpdatedAt | DateTimeOffset | Да | — | 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.md | BookingId, DealId, PropertyObjectId, ReserveExpiresAt | Создание дедлайн-события фазы Reserve (таблица маппинга ниже). |
| booking-confirmed.md | BookingId, DealId, PropertyObjectId, BookingExpiresAt | Завершение резерв-дедлайна + upsert дедлайн-события фазы Booking; повтор с новым BookingExpiresAt = перенос (продление брони). |
| booking-paid.md | BookingId | Завершение резерв-дедлайна (оплата получена); новых событий не создаёт. |
| booking-expired.md | BookingId, Phase | Отмена всех открытых дедлайнов брони; причина по Phase: «Резерв истёк» / «Бронь истекла» (unknown Phase — fail-safe «Бронирование истекло»). |
| booking-cancelled.md | BookingId | Отмена всех открытых дедлайнов брони, причина «Бронь отменена»; CancelReason payload-а в календарь не копируется. |
Маппинг: booking-событие → дедлайн-событие календаря
Дом полей события — calendar-event.md; создание проходит его инварианты
(INV-CAL-1/5: точечное событие, BookingId + DealId обязательны).
| Поле события | Phase = Reserve (booking-created) | Phase = Booking (booking-confirmed) |
|---|---|---|
TenantId / OwnerUserId | CrmDealReplica.AgencyCompanyId / CrmDealReplica.ExecutorAgentUserId (реплика сделок, crm-deal-events.md) | те же |
Kind / Origin / Status | BookingDeadline / CrmBooking / Planned | те же |
Priority | Critical (дедлайн с оплатой) | Critical |
StartAt / EndAt | ReserveExpiresAt / null (точечный дедлайн) | BookingExpiresAt / null |
TimeZone | Europe/Moscow — default платформы (допущение v1, см. «Допущения») | та же |
Title | «Оплата резерва до {StartAt}» | «Дедлайн брони: подтвердить/продлить до {StartAt}» |
DealId / BookingId / PropertyObjectId | из payload | из payload |
ClientUserId / LeadId / ProjectId / CreatedByUserId | null (в booking-событиях нет) | null |
ReminderOffsetsMinutes | {30} (резерв короткий, default-офсеты были бы в прошлом) | {1440, 60} (default) |
Напоминания материализуются по правилам event-reminder.md (офсеты с DueAt
в прошлом — сразу Skipped); создание публикует штатное pin.calendar.event-created.v1.
Алгоритм
- Контекст события. Восстановить метаданные (идентификатор сообщения,
BookingId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключуcalendar-crm-booking-events:{MessageId}: уже обработано → пропустить как дубль (ack). - Проверка входа. Обязательные поля контракта присутствуют (
BookingId; дляbooking-created—DealId,PropertyObjectId,ReserveExpiresAt; дляbooking-confirmed—DealId,BookingExpiresAt). Нарушение →CAL_BOOKING_SYNC_INVALID_PAYLOAD(non-retriable → DLQ). 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), связка — tombstoneCancelled, логdeadline-in-past, ack;- иначе — создать дедлайн-событие фазы
Reserveпо таблице маппинга, материализовать напоминания, записать связкуOpenсCalendarEventId.
- реплика по
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 (поздняя доставка после истечения/отмены).
- завершение резерва: связка (
booking-paid. Завершение резерв-дедлайна — как в шаге 4, первая часть (оплата получена). Гонка сbooking-confirmedза один переход — идемпотентна: победитель завершает, второй получает no-op. Новых событий не создаётся; дедлайн фазыBookingпоявится поbooking-confirmed.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-событиям).
- каждая связка
- Согласованность. Изменение событий и напоминаний, upsert связок, исходящие события жизненного цикла
(
event-created/event-rescheduled/event-status-changed) и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации. - Результат. Успех → 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
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Реплика сделки не найдена / маршрутизация не заполнена (booking-* обогнал deal-created) | CAL_BOOKING_SYNC_DEAL_REPLICA_NOT_FOUND | Exponential backoff до 24 ч | После исчерпания + алерт | Проверить публикацию deal-created; replay deal-топика |
| Отсутствуют обязательные поля / нераспознаваемый payload | CAL_BOOKING_SYNC_INVALID_PAYLOAD | Нет | Да + алерт | Разбор контракта с producer-ом crm |
| Конкурентная правка (гонка с консюмером deal-событий / диспетчером напоминаний за одно событие) | — | 3 попытки немедленно (перечитать состояние) | После исчерпания | Расследование гонки (ожидаемый исход — no-op у проигравшего) |
| ContractVersion mismatch | — | warn-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. Тогда: связки — tombstoneCancelled; поздний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. Тогда: событие не создаётся, связка — tombstoneCancelled, ack (deal-terminal-skip). - AC-9 (дубль). Дано: уже обработанный
MessageId. Когда: повторная доставка. Тогда:skipped-duplicate, без изменений, ack.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя
- События (1): Event Contract: pin.crm.booking-created (BookingCreatedEvent)
- Консюмеры (1): Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events)
- scenarios (1): UC-OVR-003. Бронирование объекта у агента (+3a)
- registries (1): Реестр сущностей PIN
Связанные документы
- Контракты входных событий: booking-created.md, booking-confirmed.md, booking-paid.md, booking-expired.md, booking-cancelled.md; агрегат-источник — booking.md.
- Целевой агрегат: calendar-event.md (INV-CAL-4, матрица kind-обязательностей); напоминания: event-reminder.md; маршрутизация: crm-deal-events.md; display-реплика: identity-user-upserted.md.
- Шаблон consumer: consumer-template.md.
- ADR: 0064 контекст calendar (п.4 — событийная интеграция с CRM), 0052 tenancy, 0056 simplicity-first.