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

Consumer: CRM Calendar Appointments Sync (crm-calendar-appointments)

Назначение

CRM-сторона событийной интеграции с контекстом calendar (ADR-0064, п.4): консюмер превращает записи инвесторов на показы и консультации (самозапись на слот агента) в воронку CRM. По appointment-created выполняется get-or-create карточки клиента (ФИО/телефон — из display-реплики identity) и активного лида пары «агент ↔ инвестор», в ленту лида пишется событие о созданной записи — включая комментарий инвестора Comment. По appointment-confirmed / appointment-cancelled / appointment-completed в ленту добавляются факты подтверждения, отмены/отклонения/просрочки и исхода встречи (Completed/NoShow). Календарь чужие таблицы не пишет, CRM в calendar синхронно не ходит — интеграция только событийная. Из собственных интеграционных событий консюмер публикует только штатное pin.crm.lead-created.v1 — когда get-or-create создал новый лид.

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

ПолеЗначение
Сервис/контекстcrm
ConsumerCRM Calendar Appointments Sync
Обрабатываемые событияpin.calendar.appointment-created, pin.calendar.appointment-confirmed, pin.calendar.appointment-cancelled, pin.calendar.appointment-completed
Topicspin.calendar.appointment-created.v1, pin.calendar.appointment-confirmed.v1, pin.calendar.appointment-cancelled.v1, pin.calendar.appointment-completed.v1 (key = AppointmentId в каждом — порядок событий одной записи внутри топика; кросс-топик порядок не гарантирован, контракты самодостаточны)
ContractVersion1 у всех четырёх; при несовпадении — warn-and-proceed (метрика crm_calendar_appointments_contract_mismatch_total)
Consumer groupcrm-calendar-appointments
Producercalendar (жизненный цикл записи, appointment.md)
Эффект (событие → что меняется)Get-or-create ClientEntity + активный LeadEntity пары «агент ↔ инвестор» + append-запись в ленту лида (LeadEventEntity, типы *Appointment*lead.md)
Границы scope

Консюмер ведёт только воронку CRM: клиент, лид, лента. Слоты, календарные события, напоминания и статусы записи в CRM не реплицируются — источник истины записи остаётся в calendar, лента лида хранит append-only факты. Уведомления участникам — ответственность notifications (calendar-events.md), CRM их не дублирует. Агентская сторона (TenantId лида/клиента) берётся из события — компания агента (агентство или ПИН, ADR-0052); правила реферальной привязки здесь не применяются (агента инвестор выбрал сам, бронируя его слот).

Требуемые дельты к домам

  • client.md: значение enum ClientSource.Appointment — «карточка создана консюмером записи на показ/консультацию (pin.calendar.appointment-*.v1)». Дом enum — client.md; здесь значение только используется.
  • lead.md: значение LeadSource.Appointment, типы ленты ShowingAppointmentCreated, ConsultationAppointmentCreated, AppointmentConfirmed, AppointmentCancelled, AppointmentCompleted, AppointmentNoShow и payload-модель LeadAppointmentPayload — объявлены в доме lead.md.
  • identity-user-upserted.md: консюмер синка identity дополнительно к fan-out на карточки клиентов ведёт display-реплику пользователей crm.identity_user_replicas (UserId PK, Name (FullName), PhoneNumber varchar(32) E.164, IdentityUserVersion long — монотонный guard). Настоящий консюмер реплику только читает: из неё берутся ФИО и телефон для get-or-create карточки клиента (в payload pin.calendar.* PII нет по дизайну). Отсутствие строки реплики на момент обработки — retriable: реплика догоняется событием pin.identity.users.v1 (lazy-регистрация анонима при бронировании слота публикует user-upserted, порядок между сервисами не гарантирован).

Контекст события

  • Из метаданных события доступны: идентификатор сообщения MessageId (дедупликация), CreatedAt (время публикации), версия контракта; ключ и агрегат-источник — AppointmentId.
  • Актор — системный контекст консюмера; запись выполняется в tenant из payload события (TenantId = компания агента — владельца слота; для лида/клиента это AgencyCompanyId, ADR-0052 «агентство для лида»).
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Идемпотентность: на уровне консюмера — ключ crm-calendar-appointments:{MessageId} (+ unique SourceMessageId записи ленты, lead.md); на бизнес-уровне — не более одной записи ленты на пару (AppointmentId, тип ленты) и get-or-create по unique-индексам клиента/лида (см. «Идемпотентность и replay»).

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

Контракты — дома producer-а; здесь только читаемые поля и эффект. SlotId, TimeZone, CalendarEventId, CancelledBy, PendingExpiresAt-производные и прочие поля payload консюмер не читает.

КонтрактЧитаемые поляЭффект
appointment-created.mdAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Comment?, ProjectId?, PropertyObjectId?, RescheduledFromAppointmentId?Get-or-create клиента и лида + запись ленты ShowingAppointmentCreated/ConsultationAppointmentCreated (по Kind); заполненный RescheduledFromAppointmentId помечает запись как перенос (продолжение, не новая заявка).
appointment-confirmed.mdAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, ConfirmedAt, AutoConfirmedЗапись ленты AppointmentConfirmed (get-or-create fail-safe — контракт самодостаточен на случай обработки раньше appointment-created).
appointment-cancelled.mdAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Reason, RescheduledToAppointmentId?Запись ленты AppointmentCancelled с причиной (Reason); заполненный RescheduledToAppointmentId — перенос, не разрыв.
appointment-completed.mdAppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Outcome, CompletedAtЗапись ленты AppointmentCompleted (Outcome = Completed) / AppointmentNoShow (Outcome = NoShow).

Маппинг «событие → тип ленты лида»

Дом типов и payload-модели LeadAppointmentPayloadlead.md; маппинг поле-в-поле — из читаемых полей контракта в одноимённые поля payload.

Событие / условиеLeadEventTypeОсобенности
appointment-created, Kind = ShowingShowingAppointmentCreatedComment, ProjectId/PropertyObjectId, RescheduledFromAppointmentId — в payload.
appointment-created, Kind = ConsultationConsultationAppointmentCreatedте же поля.
appointment-created, неизвестный KindConsultationAppointmentCreatedfail-safe (более общий вид встречи): warning + метрика; строка Kind сохраняется в payload как есть.
appointment-confirmedAppointmentConfirmedAutoConfirmed — в payload.
appointment-cancelled (любой Reason)AppointmentCancelledReason — в payload; неизвестный Reason → warning, запись фиксируется с сырой строкой (нейтральное «запись отменена»); RescheduledToAppointmentId — перенос.
appointment-completed, Outcome = CompletedAppointmentCompleted
appointment-completed, Outcome = NoShowAppointmentNoShowсигнал потерянного контакта — повторная связь с клиентом.
appointment-completed, неизвестный OutcomeAppointmentCompletedfail-safe «встреча завершена»: warning + метрика; строка Outcome не парсится в тип.

Алгоритм

  1. Контекст события. Восстановить метаданные (идентификатор сообщения, AppointmentId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключу crm-calendar-appointments:{MessageId}: уже обработано → пропустить как дубль (ack).
  2. Проверка входа. Обязательные поля контракта присутствуют (AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt; для cancelledReason, для completedOutcome). Нарушение → CRM_CALENDAR_SYNC_INVALID_PAYLOAD (non-retriable → DLQ + алерт). Неизвестные значения enum (Kind/Reason/Outcome) — не ошибка: fail-safe по таблице маппинга (get-or-create клиента/лида от enum не зависит).
  3. Get-or-create клиента (для всех четырёх топиков — контракты самодостаточны, обработка любого события раньше appointment-created корректна):
    • display-данные (ФИО, телефон) — строка реплики identity по InvestorUserId; строки нет → CRM_CALENDAR_SYNC_USER_REPLICA_NOT_FOUND (retriable — реплика догоняется pin.identity.users.v1);
    • карточка по (TenantId, InvestorUserId) (unique, INV-CLI-3); нет → по (TenantId, нормализованный телефон реплики) (unique, INV-CLI-1): найдена с InvestorUserId = null → проставить InvestorUserId;
    • карточки нет вовсе → создать: Source = Appointment, ФИО/телефон из реплики, CreatedByUserId = null (системный консюмер).
  4. Get-or-create активного лида пары (ExecutorAgentUserId, PrimaryInvestorUserId, Status = Active) — резолв по событийным AgentUserId/InvestorUserId (unique, INV-CRM-001); нет → создать: Source = Appointment, событийный агент — OriginAgentUserId и начальный ExecutorAgentUserId (Origin = Executor при создании), событийный инвестор — PrimaryInvestorUserId, ExpiresAt = now + 1 месяц, пустые хотелки, TenantId из события. Реактивация Expired-лида не выполняется — при отсутствии активного создаётся новый (история пары остаётся в неактивных лидах).
  5. Запись ленты. Business-guard: в ленте лида уже есть запись с тем же (AppointmentId, тип ленты) → no-op (лог skipped-business-duplicate, ack). Иначе — LeadEventEntity типа по таблице маппинга, Payload = LeadAppointmentPayload (маппинг поле-в-поле; Comment — как есть, класс Internal), SourceMessageId = MessageId, OccurredAt — момент факта источника (ConfirmedAt/CompletedAt из payload; для created/cancelled — время события из метаданных). Лента упорядочена по OccurredAt — поздно доставленное событие встаёт на историческую позицию (INV-LEAD-2 выполняется: лента пишется в активный лид из шага 4). Обновить Lead.LastEventAt, Client.LastActivityAt и Client.ActiveLeadId (INV-CLI-2).
  6. Какие события фиксируются. Если лид на шаге 4 создан — публикуется LeadCreatedEvent → topic pin.crm.lead-created.v1 (Source = Appointment; контракт — lead-created.md; потребители — notifications (уведомление агенту о новом лиде), referral). Переиспользованный лид интеграционных событий не порождает.
  7. Согласованность. Клиент, лид, запись ленты, отметка идемпотентности и исходящее lead-created — в одном согласованном изменении; offset подтверждается только после успешной фиксации. Гонки уникальности (два события одной пары параллельно, гонка с консюмером заявки/реферала за тот же телефон или пару) → конфликт unique-индекса → перечитать и повторить (см. Retry).
  8. Результат. Успех → ack (включая no-op-ветки); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт.

Диаграмма

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

  • На уровне консюмера: ключ crm-calendar-appointments:{MessageId}; unique SourceMessageId записи ленты — повторная доставка не создаёт дублей.
  • На бизнес-уровне: business-guard (AppointmentId, тип ленты) — не более одной записи ленты на факт жизненного цикла записи независимо от числа доставок; get-or-create идемпотентен по unique-индексам (INV-CLI-1, INV-CLI-3, INV-CRM-001) — второго клиента/лида не появляется.
  • Кросс-топик порядок. Четыре топика взаимно не упорядочены; каждый контракт самодостаточен (несёт участников и время) — confirmed/completed/cancelled, обработанные раньше created, выполняют get-or-create по своим данным, а лента дополняется задним числом по OccurredAt без дублей и без подавления (лента — append-only история, статус записи CRM не реплицирует).
  • Replay: да. Полный повторный проход всех четырёх топиков с offset 0 безопасен: unique SourceMessageId + business-guard + get-or-create по unique-индексам сходятся к той же воронке без дублей клиентов, лидов и записей ленты. Runbook — «пересинхронизация ленты лидов по записям календаря» (docs/06-services/crm/runbooks/, будущий путь по конвенции).

Retry и DLQ

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook: восстановить зависимость
Пользователь отсутствует в display-реплике identity (user-upserted отстаёт)CRM_CALENDAR_SYNC_USER_REPLICA_NOT_FOUNDExponential backoff до 24 чПосле исчерпания + алертПроверить лаг консюмера синка identity; replay топика pin.identity.users.v1
Отсутствуют обязательные поля / нераспознаваемый payloadCRM_CALENDAR_SYNC_INVALID_PAYLOADНетДа + алертРазбор контракта с producer-ом calendar
Гонка уникальности (INV-CLI-1 / INV-CLI-3 / INV-CRM-001 — параллельные события пары или консюмер заявки/реферала)3 попытки немедленно (перечитать клиента/лид)После исчерпанияРасследование гонки (ожидаемый исход — переиспользование записи победителя)
Неизвестное значение enum (Kind/Reason/Outcome)Нет (fail-safe по таблице маппинга, warning)НетПроверить additive-расширение контракта calendar
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта producer-а

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

  • Logs (structured): consumed / skipped-duplicate / skipped-business-duplicate / client-created / lead-created / feed-appended / failed — с MessageId, AppointmentId, топиком, LeadId/ClientId. Comment инвестора в логи не пишется (класс Internal, маскирование); ФИО/телефон из реплики — маскирование +7***1234 / И***.
  • Metrics: crm_calendar_appointments_consume_duration_ms, crm_calendar_appointments_feed_events_total{type}, crm_calendar_appointments_clients_created_total, crm_calendar_appointments_leads_created_total, crm_calendar_appointments_skipped_total{reason}, crm_calendar_appointments_user_replica_not_found_total, crm_calendar_appointments_unknown_enum_total{field}, crm_calendar_appointments_dlq_total, crm_calendar_appointments_contract_mismatch_total, crm_calendar_appointments_lag_seconds.
  • Traces: span приёма события → span обработки → spans обращений к хранилищу; корреляция из метаданных.
  • Alerts: лаг консюмера > 5 мин (SLA контракта: лид в CRM < 1 мин после записи — агент реагирует на запись из CRM); DLQ > 0; устойчивый рост user_replica_not_found_total (рассинхрон с identity-синком).
  • Безопасность: system-actor; запись — в tenant из события (TenantId = компания агента, ADR-0052); топики pin.calendar.appointment-* — m2m ACL Kafka; PII в payload нет по дизайну (ФИО/телефон — из собственной реплики identity); Comment — единственное свободнотекстовое поле, обрабатывается как конфиденциальное (без индексации в поиске, маскирование в логах).

Acceptance Criteria

  • AC-1 (новый клиент по записи). Дано: аноним забронировал слот показа (lazy-регистрация создала пользователя, реплика identity наполнена), клиента/лида пары в CRM нет. Когда: appointment-created (Kind = Showing, Comment заполнен). Тогда: созданы карточка клиента (Source = Appointment, ФИО и телефон из реплики) и активный лид (Source = Appointment), в ленте — ShowingAppointmentCreated с Comment и PropertyObjectId, опубликован lead-created, ack.
  • AC-2 (существующая воронка). Дано: у пары уже есть карточка клиента и активный лид. Когда: appointment-created (Kind = Consultation). Тогда: второй клиент/лид не создаются, лента существующего лида пополнена ConsultationAppointmentCreated, lead-created не публикуется.
  • AC-3 (реплика identity отстаёт). Дано: пользователя нет в crm.identity_user_replicas. Когда: любое из четырёх событий. Тогда: CRM_CALENDAR_SYNC_USER_REPLICA_NOT_FOUND, retry с backoff; после прихода user-upserted повтор успешен.
  • AC-4 (жизненный цикл в ленте). Дано: запись подтверждена, затем исход NoShow. Когда: обработаны appointment-confirmed и appointment-completed (Outcome = NoShow). Тогда: в ленте — записи AppointmentConfirmedAutoConfirmed) и AppointmentNoShow, упорядоченные по OccurredAt.
  • AC-5 (отмена по причине). Дано: worker календаря просрочил Pending-запись. Когда: appointment-cancelled (Reason = Expired). Тогда: в ленте — AppointmentCancelled с Reason = Expired («запись просрочена без решения агента»).
  • AC-6 (перенос — не новая заявка). Дано: инвестор перенёс запись. Когда: обработаны appointment-cancelled (RescheduledToAppointmentId заполнен) и appointment-created новой записи (RescheduledFromAppointmentId заполнен) — в любом порядке. Тогда: обе записи ленты помечены как перенос, новый лид не создаётся.
  • AC-7 (out-of-order создания). Дано: appointment-confirmed обработан раньше appointment-created (auto-confirm, отставание партиции). Тогда: get-or-create выполнен по данным confirmed, лента получает AppointmentConfirmed; последующий appointment-created дополняет ленту *AppointmentCreated без дублей клиента/лида.
  • AC-8 (идемпотентность). Дано: уже обработанный MessageId либо повторное событие с тем же (AppointmentId, тип). Когда: повторная доставка / replay. Тогда: дублей клиента, лида и записей ленты нет (skipped-duplicate/skipped-business-duplicate), ack.
  • AC-9 (неизвестный enum). Дано: событие с новым значением Kind/Reason/Outcome. Когда: обработка. Тогда: warning + fail-safe тип ленты по таблице маппинга, сырая строка сохранена в payload; сообщение не в DLQ.
  • AC-10 (несовместимый payload). Дано: сообщение без обязательного поля. Когда: консюмер не смог его обработать. Тогда: CRM_CALENDAR_SYNC_INVALID_PAYLOAD → DLQ + алерт, записи других инвесторов не блокируются.

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

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

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