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 |
| Consumer | Calendar CRM Deal Sync |
| Обрабатываемые события | pin.crm.deal-created, pin.crm.deal-members-changed, pin.crm.deal-completed, pin.crm.deal-cancelled |
| Topics | pin.crm.deal-created.v1, pin.crm.deal-members-changed.v1, pin.crm.deal-completed.v1, pin.crm.deal-cancelled.v1 (key = DealId в каждом — per-deal порядок внутри топика; кросс-топик порядок не гарантирован) |
| ContractVersion | 1 у всех четырёх; при несовпадении — warn-and-proceed (метрика calendar_crm_deal_sync_contract_mismatch_total) |
| Consumer group | calendar-crm-deal-events |
| Producer | crm (жизненный цикл сделки, 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-событий.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
DealId | Guid | Да | PK | payload | Сделка CRM. |
TenantId | Guid | Да | — | payload | Tenant CRM-событий — застройщик объекта (DeveloperCompanyId, ADR-0052). |
AgencyCompanyId | Guid? | Нет | — | deal-created.AgencyCompanyId | Компания агентов — tenant владельца календаря для дедлайн-событий; null у terminal-tombstone, созданного из deal-cancelled (поля в payload нет). |
ExecutorAgentUserId | Guid? | Нет | — | deal-created.ExecutorAgentUserId / ActiveMembers (Role = Executor) | Явный маркер текущего исполнителя — владелец календаря для маршрутизации новых дедлайнов и цель переназначения открытых событий; null у terminal-tombstone. |
AgentUserIds | Guid[] | Да | default {}; значения уникальны | deal-created.Members / ActiveMembers (агентские роли) | Множество активных агентов-участников агентства (UserId участников с ролями Executor / Responsible / CoAgent / Assistant / Lawyer) — по нему вычисляются ушедшие агенты при реассайне/увольнении для переназначения их открытых событий. |
IsTerminal | bool | Да | default false | deal-completed / deal-cancelled | Сделка закрыта: новые дедлайн-события по ней не создаются. |
ClosedAt | DateTimeOffset? | Условно | заполнен при IsTerminal = true | CompletedAt / CancelledAt | Момент терминального перехода сделки. |
MembersVersion | long? | Нет | — | deal-members-changed.Version | Монотонный guard применённых изменений состава (в deal-created версии нет — null до первого members-события). |
CreatedAt | DateTimeOffset | Да | — | consumer | Создание строки реплики. |
UpdatedAt | DateTimeOffset | Да | — | consumer | Последнее применённое событие. |
Входные данные / payload событий
Контракты — дома producer-а; здесь только читаемые поля и маппинг.
| Контракт | Читаемые поля | Маппинг |
|---|---|---|
| deal-created.md | DealId, TenantId, AgencyCompanyId, ExecutorAgentUserId, Members[] (UserId, Role) | Создание строки реплики (upsert; идемпотентно при повторе): ExecutorAgentUserId ← payload; AgentUserIds ← UserId участников Members c агентскими ролями. Остальные поля (StepId, Title, PaymentMethod, InvestorUserId, инвесторы/SellManager/PinManager в Members…) не читаются. |
| deal-members-changed.md | DealId, ActiveMembers[] (UserId, Role), Members[] (ChangeType), Version | ExecutorAgentUserId ← UserId участника с Role = Executor из ActiveMembers; AgentUserIds ← участники ActiveMembers c агентскими ролями (set-снимок самодостаточен); дельта Members (ChangeType = ExecutorChanged / Removed) — сигнал к переназначению владельца открытых событий (алгоритм, шаг 4a); MembersVersion ← Version (guard). |
| deal-completed.md | DealId, TenantId, CompletedAt | IsTerminal = true, ClosedAt ← CompletedAt; отмена открытых дедлайнов с причиной «Сделка завершена». CommissionRecipientUserId/OriginAgentUserId и финансовый snapshot не читаются (маршрутизация на терминале уже не нужна). |
| deal-cancelled.md | DealId, TenantId, CancelledAt | IsTerminal = true, ClosedAt ← CancelledAt; отмена открытых дедлайнов с причиной «Сделка отменена». CancelReason payload-а (free text) в календарь не копируется. |
Алгоритм
- Контекст события. Восстановить метаданные (идентификатор сообщения,
DealId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключуcalendar-crm-deal-events:{MessageId}: уже обработано → пропустить как дубль (ack). - Проверка входа. Обязательные поля контракта присутствуют (
DealId,TenantId; дляdeal-members-changed— непустойActiveMembersиVersion ≥ 1). Нарушение →CAL_DEAL_SYNC_INVALID_PAYLOAD(non-retriable → DLQ). Неизвестное значениеRoleвActiveMembers/Members— не ошибка: участник с неопознанной ролью в множество агентов не включается; если в снимке нет распознанногоExecutor—ExecutorAgentUserIdне изменяется, warn-лог (fail-safe: маршрутизация и переназначение остаются на прежнем исполнителе). deal-created. Upsert строки реплики (TenantId,AgencyCompanyId,ExecutorAgentUserId← payload,AgentUserIds← агентские ролиMembers). Если строка уже существует (создана ранее terminal-tombstone-ом или повтором) — заполняются недостающие поля маршрутизации,IsTerminalне сбрасывается.deal-members-changed. Строка реплики не найдена →CAL_DEAL_SYNC_REPLICA_NOT_FOUND(retriable:deal-createdидёт другим топиком и может отставать). Version-guard:Version ≤ MembersVersion→skipped-stale, ack. Иначе: запомнить прежниеExecutorAgentUserId/AgentUserIds, затем пересчитать изActiveMembers(ExecutorAgentUserId← участник сRole = Executor;AgentUserIds← участники с агентскими ролями),MembersVersion←Version.- Переназначение владельца открытых событий (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 по событию.
- дедлайны брони (
- Переназначение владельца открытых событий (4a). Если исполнитель сменился
(
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.- Какие факты фиксируются. По каждому отменённому дедлайн-событию публикуется штатное
pin.calendar.event-status-changed.v1(Status = Cancelled,CancelReason); по каждому переназначенному событию — штатноеpin.calendar.event-owner-changed.v1(PreviousOwnerUserId→OwnerUserId) — потребитель обоихnotifications. Собственных событий консюмер не вводит. - Согласованность. Обновление реплики, переназначение владельцев, отмена событий, закрытие связок и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации.
- Результат. Успех → 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; на одинDealIdproducer эмитит терминальное событие не более одного раза). - Кросс-топик порядок. Четыре топика взаимно не упорядочены. 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
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Реплика сделки не найдена (deal-members-changed обогнал deal-created) | CAL_DEAL_SYNC_REPLICA_NOT_FOUND | Exponential backoff до 24 ч | После исчерпания + алерт | Проверить публикацию deal-created; replay топика |
| Отсутствуют обязательные поля / нераспознаваемый payload | CAL_DEAL_SYNC_INVALID_PAYLOAD | Нет | Да + алерт | Разбор контракта с producer-ом crm |
| Конкурентная правка (гонка с консюмером booking-событий за одно дедлайн-событие) | — | 3 попытки немедленно (перечитать состояние) | После исчерпания | Расследование гонки (ожидаемый исход — no-op у проигравшего) |
| ContractVersion mismatch | — | warn-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-changedcRemoved: [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-guardskipped-stale,OwnerUserIdсобытий не двигается,event-owner-changedне публикуется, ack. - AC-9 (запись не переназначается). Дано: у сделки открытое событие
Origin = Appointment(владелецA) и открытый дедлайн (владелецA). Когда:ExecutorChangedнаB. Тогда: дедлайн →B, событие записи остаётся уA(INV-CAL-3),event-owner-changed— только по дедлайну.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-CAL-007. Дедлайны брони CRM в календаре агента, UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя
- События (2): Event Contract: pin.calendar.event-owner-changed (CalendarEventOwnerChangedEvent), Event Contract: pin.calendar.event-status-changed (CalendarEventStatusChangedEvent)
- Консюмеры (1): Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events)
- index.md (1): Calendar Service
- registries (1): Реестр сущностей PIN
Связанные документы
- Контракты входных событий: deal-created.md,
deal-members-changed.md (
ChangeType,ParticipantRole,ActiveMembers), deal-completed.md, deal-cancelled.md; агрегат-источник — deal.md; каскад увольнения агента — agent-offboarding.md. - Читатель реплики и дом проекции-связки дедлайнов: crm-booking-events.md;
целевые события календаря: calendar-event.md (INV-CAL-4/INV-CAL-8,
ix_calendar_events_deal_id), event-reminder.md; контракт сигнала переназначения: event-owner-changed.md. - Эталон display-реплики: identity-user-upserted.md.
- ADR: 0064 контекст calendar (п.4 — событийная интеграция с CRM), 0052 tenancy, 0056 simplicity-first.