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

Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events)

Назначение

Консюмер поддерживает в контексте calendar реплику сделок CRM — минимальный набор «сделка → агенты» (сделка, tenant агентства, множество активных агентов-участников и явный маркер текущего исполнителя ExecutorAgentUserId), по которому консюмер booking-событий (crm-booking-events.md) маршрутизирует автособытия «дедлайн брони» (Kind = BookingDeadline) в календарь исполнителя. Сделку теперь ведёт несколько агентов (исполнитель, со-агенты, ассистенты, юрист), поэтому реплика хранит их множество, а маршрутизация и переназначение опираются на явный маркер исполнителя. При смене исполнителя (deal-members-changed ChangeType = ExecutorChanged) или уходе агента из сделки (Removed — в т.ч. увольнение через CRM-offboarding, agent-offboarding.md) консюмер переназначает владельца (OwnerUserId) открытых событий сделки — дедлайнов брони и встреч — на нового исполнителя, чтобы открытые обязательства не осиротели в календаре ушедшего агента. Терминальные события сделки (deal-completed / deal-cancelled) переводят реплику в терминальное состояние и отменяют открытые дедлайн-события этой сделки — в календаре агента не остаются дедлайны по закрытой сделке. Собственных интеграционных событий консюмер не вводит; отмена дедлайн-событий публикует штатное pin.calendar.event-status-changed.v1, переназначение владельца — штатное pin.calendar.event-owner-changed.v1 жизненного цикла события.

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

ПолеЗначение
Сервис/контекстcalendar
ConsumerCalendar CRM Deal Sync
Обрабатываемые событияpin.crm.deal-created, pin.crm.deal-members-changed, pin.crm.deal-completed, pin.crm.deal-cancelled
Topicspin.crm.deal-created.v1, pin.crm.deal-members-changed.v1, pin.crm.deal-completed.v1, pin.crm.deal-cancelled.v1 (key = DealId в каждом — per-deal порядок внутри топика; кросс-топик порядок не гарантирован)
ContractVersion1 у всех четырёх; при несовпадении — warn-and-proceed (метрика calendar_crm_deal_sync_contract_mismatch_total)
Consumer groupcalendar-crm-deal-events
Producercrm (жизненный цикл сделки, deal.md)
Эффект (событие → что меняется)Upsert реплики CrmDealReplica (множество агентов + маркер ExecutorAgentUserId); при смене исполнителя / уходе агента — переназначение OwnerUserId открытых событий сделки на нового исполнителя; на терминальных событиях — отмена открытых событий Kind = BookingDeadline сделки

Границы scope. Консюмер ведёт только данные, нужные календарю: маршрутизацию дедлайнов, переназначение владельца открытых событий и терминальный guard. Реплицируется множество агентов-участников агентства (роли Executor / Responsible / CoAgent / Assistant / Lawyer) и маркер исполнителя; инвесторы (InvestorPrimary / InvestorSecondary), сторона застройщика (SellManager) и PinManager в множество агентов не входят (владелец рабочего календаря дедлайнов/встреч — агент агентства). Этапы канбана (deal-stage-changed), цены и документы сделки календарю не нужны и не реплицируются. Отмена дедлайн-событий и переназначение их владельца — санкционированные автоматические каналы управления событиями Origin = CrmBooking / Origin = Manual (INV-CAL-4/INV-CAL-8, calendar-event.md); отмена идемпотентно пересекается с отменой тех же событий по booking-cancelled/booking-expired (crm-booking-events.md) — кто успел первым, второй получает no-op.

Проекция CrmDealReplica (calendar.crm_deal_replicas)

Consumer-owned проекция; дом полей — этот документ. Собственных API-операций нет; читатель — консюмер booking-событий.

ПолеТипОбязательностьОграниченияИсточникОписание
DealIdGuidДаPKpayloadСделка CRM.
TenantIdGuidДаpayloadTenant CRM-событий — застройщик объекта (DeveloperCompanyId, ADR-0052).
AgencyCompanyIdGuid?Нетdeal-created.AgencyCompanyIdКомпания агентов — tenant владельца календаря для дедлайн-событий; null у terminal-tombstone, созданного из deal-cancelled (поля в payload нет).
ExecutorAgentUserIdGuid?Нетdeal-created.ExecutorAgentUserId / ActiveMembers (Role = Executor)Явный маркер текущего исполнителя — владелец календаря для маршрутизации новых дедлайнов и цель переназначения открытых событий; null у terminal-tombstone.
AgentUserIdsGuid[]Даdefault {}; значения уникальныdeal-created.Members / ActiveMembers (агентские роли)Множество активных агентов-участников агентства (UserId участников с ролями Executor / Responsible / CoAgent / Assistant / Lawyer) — по нему вычисляются ушедшие агенты при реассайне/увольнении для переназначения их открытых событий.
IsTerminalboolДаdefault falsedeal-completed / deal-cancelledСделка закрыта: новые дедлайн-события по ней не создаются.
ClosedAtDateTimeOffset?Условнозаполнен при IsTerminal = trueCompletedAt / CancelledAtМомент терминального перехода сделки.
MembersVersionlong?Нетdeal-members-changed.VersionМонотонный guard применённых изменений состава (в deal-created версии нет — null до первого members-события).
CreatedAtDateTimeOffsetДаconsumerСоздание строки реплики.
UpdatedAtDateTimeOffsetДаconsumerПоследнее применённое событие.

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

Контракты — дома producer-а; здесь только читаемые поля и маппинг.

КонтрактЧитаемые поляМаппинг
deal-created.mdDealId, TenantId, AgencyCompanyId, ExecutorAgentUserId, Members[] (UserId, Role)Создание строки реплики (upsert; идемпотентно при повторе): ExecutorAgentUserId ← payload; AgentUserIdsUserId участников Members c агентскими ролями. Остальные поля (StepId, Title, PaymentMethod, InvestorUserId, инвесторы/SellManager/PinManager в Members…) не читаются.
deal-members-changed.mdDealId, ActiveMembers[] (UserId, Role), Members[] (ChangeType), VersionExecutorAgentUserIdUserId участника с Role = Executor из ActiveMembers; AgentUserIds ← участники ActiveMembers c агентскими ролями (set-снимок самодостаточен); дельта Members (ChangeType = ExecutorChanged / Removed) — сигнал к переназначению владельца открытых событий (алгоритм, шаг 4a); MembersVersionVersion (guard).
deal-completed.mdDealId, TenantId, CompletedAtIsTerminal = true, ClosedAtCompletedAt; отмена открытых дедлайнов с причиной «Сделка завершена». CommissionRecipientUserId/OriginAgentUserId и финансовый snapshot не читаются (маршрутизация на терминале уже не нужна).
deal-cancelled.mdDealId, TenantId, CancelledAtIsTerminal = true, ClosedAtCancelledAt; отмена открытых дедлайнов с причиной «Сделка отменена». CancelReason payload-а (free text) в календарь не копируется.

Алгоритм

  1. Контекст события. Восстановить метаданные (идентификатор сообщения, DealId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключу calendar-crm-deal-events:{MessageId}: уже обработано → пропустить как дубль (ack).
  2. Проверка входа. Обязательные поля контракта присутствуют (DealId, TenantId; для deal-members-changed — непустой ActiveMembers и Version ≥ 1). Нарушение → CAL_DEAL_SYNC_INVALID_PAYLOAD (non-retriable → DLQ). Неизвестное значение Role в ActiveMembers / Members — не ошибка: участник с неопознанной ролью в множество агентов не включается; если в снимке нет распознанного ExecutorExecutorAgentUserId не изменяется, warn-лог (fail-safe: маршрутизация и переназначение остаются на прежнем исполнителе).
  3. deal-created. Upsert строки реплики (TenantId, AgencyCompanyId, ExecutorAgentUserId ← payload, AgentUserIds ← агентские роли Members). Если строка уже существует (создана ранее terminal-tombstone-ом или повтором) — заполняются недостающие поля маршрутизации, IsTerminal не сбрасывается.
  4. deal-members-changed. Строка реплики не найдена → CAL_DEAL_SYNC_REPLICA_NOT_FOUND (retriable: deal-created идёт другим топиком и может отставать). Version-guard: Version ≤ MembersVersionskipped-stale, ack. Иначе: запомнить прежние ExecutorAgentUserId/AgentUserIds, затем пересчитать из ActiveMembers (ExecutorAgentUserId ← участник с Role = Executor; AgentUserIds ← участники с агентскими ролями), MembersVersionVersion.
    1. Переназначение владельца открытых событий (4a). Если исполнитель сменился (ExecutorAgentUserId ≠ прежнего, либо в дельте Members есть ChangeType = ExecutorChanged) или из сделки ушли агенты (departed = прежние AgentUserIds − новые AgentUserIds, наполняется при Removed/увольнении) — переназначить владельца открытых (Status ∈ {Planned, InProgress}) событий сделки на нового ExecutorAgentUserId:
      • дедлайны брони (Kind = BookingDeadline, Origin = CrmBooking) — всегда следуют за исполнителем: у всех открытых дедлайнов сделки (по проекции-связке дедлайнов, DealId, crm-booking-events.md) OwnerUserId ← новый исполнитель;
      • встречи и прочие deal-привязанные события ушедших агентов (DealId = сделка, OwnerUserId ∈ departed, Origin = Manual, Kind ∈ {Meeting, Showing, Consultation, Task}) — OwnerUserId ← новый исполнитель (индекс ix_calendar_events_deal_id);
      • события Origin = Appointment не переназначаются (управляются жизненным циклом записи, INV-CAL-3); TenantId не меняется (новый исполнитель — тот же AgencyCompanyId);
      • для каждого переназначенного события публикуется pin.calendar.event-owner-changed.v1; напоминания не пересоздаются (моменты DueAt не меняются — сместился только владелец). Guard: ExecutorAgentUserId не распознан (null) → переназначение пропускается, события остаются у прежних владельцев, warn-лог (fail-safe; в норме INV-DEAL-3 гарантирует ровно одного Executor). Совпадение источника и цели (OwnerUserId уже = новый исполнитель) — no-op по событию.
  5. deal-completed / deal-cancelled. Upsert реплики: IsTerminal = true, ClosedAt из payload (отсутствующая строка создаётся terminal-tombstone-ом — guard от поздних booking-событий). Затем — отмена дедлайнов: по проекции-связке дедлайнов сделки (DealId, дом — crm-booking-events.md) найти открытые события Kind = BookingDeadline (Status ∈ {Planned, InProgress}) и перевести каждое в Cancelled с CancelReason = «Сделка завершена» / «Сделка отменена»; связки закрываются. Pending-напоминания отменённых событий переводятся в Skipped каскадом события (event-reminder.md, правило 3). Уже терминальное событие — no-op.
  6. Какие факты фиксируются. По каждому отменённому дедлайн-событию публикуется штатное pin.calendar.event-status-changed.v1 (Status = Cancelled, CancelReason); по каждому переназначенному событию — штатное pin.calendar.event-owner-changed.v1 (PreviousOwnerUserIdOwnerUserId) — потребитель обоих notifications. Собственных событий консюмер не вводит.
  7. Согласованность. Обновление реплики, переназначение владельцев, отмена событий, закрытие связок и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации.
  8. Результат. Успех → ack (включая skipped-stale/no-op); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт.

Диаграмма

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

  • На уровне консюмера: ключ calendar-crm-deal-events:{MessageId} — повторная доставка не меняет состояние.
  • На бизнес-уровне: deal-created — идемпотентный upsert (повтор не затирает более свежие поля); deal-members-changed — монотонный MembersVersion-guard (защита от out-of-order и replay); переназначение владельца идемпотентно по цели (OwnerUserId уже = текущий исполнитель → no-op), поэтому повтор устаревшего members-события ничего не двигает (отсекается version-guard-ом ещё до шага 4a); deal-completed/deal-cancelled — идемпотентны по состоянию (IsTerminal уже true, события уже терминальны → no-op; на один DealId producer эмитит терминальное событие не более одного раза).
  • Кросс-топик порядок. Четыре топика взаимно не упорядочены. Fail-safe-правила: terminal-tombstone (шаг 5) блокирует эффекты поздних событий; поздний deal-created после терминального — дозаполняет маршрутизацию, не воскрешая сделку; deal-members-changed до deal-created — retriable-ожидание.
  • Replay: да. Полный повторный проход всех четырёх топиков с offset 0 безопасен: upsert + version-guard + идемпотентность по состоянию сходятся к последнему снимку. Runbook — «пересинхронизация реплики сделок календаря» (docs/06-services/calendar/runbooks/, будущий путь по конвенции).

Retry и DLQ

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

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

  • Logs (structured): consumed / skipped-duplicate / skipped-stale / replica-upserted / deal-terminal (DealId, число отменённых дедлайнов) / executor-changed (DealId, прежний/новый ExecutorAgentUserId, число переназначенных событий) / agents-departed (DealId, число ушедших) / failed — с MessageId, DealId, топиком. PII в payload и логах нет (только идентификаторы).
  • Metrics: calendar_crm_deal_sync_consume_duration_ms, calendar_crm_deal_sync_replicas_upserted_total, calendar_crm_deal_sync_deadlines_cancelled_total, calendar_crm_deal_sync_events_reassigned_total, calendar_crm_deal_sync_stale_total, calendar_crm_deal_sync_replica_not_found_total, calendar_crm_deal_sync_dlq_total, calendar_crm_deal_sync_contract_mismatch_total, calendar_crm_deal_sync_lag_seconds.
  • Traces: span приёма → span обработки → spans записи; корреляция из метаданных события.
  • Alerts: лаг консюмера > 5 мин; DLQ > 0; устойчивый рост replica_not_found_total (рассинхрон топиков).
  • Безопасность: system-actor; запись — только в схему calendar (реплика + события календаря своего контекста); топики pin.crm.deal-* — m2m ACL Kafka. Payload не несёт ФИО/телефонов; display-данные агента календарь берёт из реплики identity (identity-user-upserted.md).

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

  • Смена исполнителя / уход агента переносят открытые события. При deal-members-changed (ExecutorChanged или Removed/увольнение) открытые дедлайны брони следуют за новым исполнителем, а открытые встречи и прочие deal-привязанные события ушедших агентов переназначаются на нового исполнителя (шаг 4a). Прежнее ограничение v1 «смена агента не переносит открытые дедлайны» снято.
  • Переназначаются только Origin ∈ {CrmBooking, Manual}. События Origin = Appointment (записи инвесторов на слот) остаются у своего агента — они привязаны к конкретному слоту и управляются жизненным циклом записи (INV-CAL-3); их переназначение — вне scope.
  • Реплика агентов — только агентства. Инвесторы, SellManager, PinManager в AgentUserIds не входят; их уход/смена событий календаря агента не переназначает.
  • deal-stage-changed не потребляется: этапы канбана на календарь не влияют.

Acceptance Criteria

  • AC-1 (реплика создана). Дано: deal-created (DealId, AgencyCompanyId, ExecutorAgentUserId, Members с исполнителем и со-агентом). Когда: обработка. Тогда: строка CrmDealReplica создана — ExecutorAgentUserId из payload, AgentUserIds = {исполнитель, со-агент}, IsTerminal = false, ack.
  • AC-2 (смена исполнителя переносит открытые события). Дано: реплика (MembersVersion = 3, ExecutorAgentUserId = A) и у сделки два открытых BookingDeadline (владелец A) и одна открытая встреча (Meeting, DealId, владелец A). Когда: deal-members-changed (Version = 5, ChangeType = ExecutorChanged, новый Executor = B, A удалён). Тогда: ExecutorAgentUserId = B, MembersVersion = 5; OwnerUserId обоих дедлайнов и встречи = B; по каждому опубликован event-owner-changed; событие с Version ≤ 5, пришедшее позже, — skipped-stale.
  • AC-2a (уход со-агента переносит его встречи). Дано: реплика AgentUserIds = {Executor E, CoAgent C}, открытая встреча (Meeting, DealId, владелец C). Когда: deal-members-changed c Removed: [C] (увольнение/offboarding), исполнитель не менялся. Тогда: AgentUserIds = {E}, OwnerUserId встречи = E, опубликован event-owner-changed; дедлайны брони (владелец E) не трогаются.
  • AC-3 (терминал отменяет дедлайны). Дано: у сделки два открытых события BookingDeadline (Planned). Когда: deal-cancelled. Тогда: реплика IsTerminal = true, оба события Cancelled (CancelReason = «Сделка отменена»), их Pending-напоминания Skipped, опубликованы два event-status-changed, связки закрыты.
  • AC-4 (идемпотентность терминала). Дано: сделка уже терминальна, дедлайны отменены. Когда: повторная доставка deal-completed. Тогда: no-op (дубль по MessageId либо идемпотентность по состоянию), ack.
  • AC-5 (out-of-order). Дано: deal-cancelled пришёл раньше deal-created. Тогда: создан terminal-tombstone (IsTerminal = true); поздний deal-created дозаполняет маршрутизацию, но IsTerminal не сбрасывает; консюмер booking-событий дедлайны по этой сделке не создаёт.
  • AC-6 (members раньше created). Дано: deal-members-changed для неизвестной сделки. Тогда: CAL_DEAL_SYNC_REPLICA_NOT_FOUND, retry с backoff; после прихода deal-created повтор успешен.
  • AC-7 (неизвестная роль). Дано: в ActiveMembers роль вне известного набора и нет распознанного Executor. Тогда: ExecutorAgentUserId не изменён, переназначение пропущено, warn-лог, ack (без DLQ).
  • AC-8 (переназначение идемпотентно). Дано: исполнитель уже переведён на B, события — у B. Когда: повторная доставка того же deal-members-changed. Тогда: version-guard skipped-stale, OwnerUserId событий не двигается, event-owner-changed не публикуется, ack.
  • AC-9 (запись не переназначается). Дано: у сделки открытое событие Origin = Appointment (владелец A) и открытый дедлайн (владелец A). Когда: ExecutorChanged на B. Тогда: дедлайн → B, событие записи остаётся у A (INV-CAL-3), event-owner-changed — только по дедлайну.

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

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

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