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 |
| Consumer | CRM Calendar Appointments Sync |
| Обрабатываемые события | pin.calendar.appointment-created, pin.calendar.appointment-confirmed, pin.calendar.appointment-cancelled, pin.calendar.appointment-completed |
| Topics | pin.calendar.appointment-created.v1, pin.calendar.appointment-confirmed.v1, pin.calendar.appointment-cancelled.v1, pin.calendar.appointment-completed.v1 (key = AppointmentId в каждом — порядок событий одной записи внутри топика; кросс-топик порядок не гарантирован, контракты самодостаточны) |
| ContractVersion | 1 у всех четырёх; при несовпадении — warn-and-proceed (метрика crm_calendar_appointments_contract_mismatch_total) |
| Consumer group | crm-calendar-appointments |
| Producer | calendar (жизненный цикл записи, appointment.md) |
| Эффект (событие → что меняется) | Get-or-create ClientEntity + активный LeadEntity пары «агент ↔ инвестор» + append-запись в ленту лида (LeadEventEntity, типы *Appointment* — lead.md) |
Консюмер ведёт только воронку 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(UserIdPK,Name(FullName),PhoneNumbervarchar(32)E.164,IdentityUserVersionlong— монотонный guard). Настоящий консюмер реплику только читает: из неё берутся ФИО и телефон для get-or-create карточки клиента (в payloadpin.calendar.*PII нет по дизайну). Отсутствие строки реплики на момент обработки — retriable: реплика догоняется событиемpin.identity.users.v1(lazy-регистрация анонима при бронировании слота публикуетuser-upserted, порядок между сервисами не гарантирован).
Контекст события
- Из метаданных события доступны: идентификатор сообщения
MessageId(дедупликация),CreatedAt(время публикации), версия контракта; ключ и агрегат-источник —AppointmentId. - Актор — системный контекст консюмера; запись выполняется в tenant из payload события (
TenantId= компания агента — владельца слота; для лида/клиента этоAgencyCompanyId, ADR-0052 «агентство для лида»). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: на уровне консюмера — ключ
crm-calendar-appointments:{MessageId}(+ uniqueSourceMessageIdзаписи ленты, lead.md); на бизнес-уровне — не более одной записи ленты на пару (AppointmentId, тип ленты) и get-or-create по unique-индексам клиента/лида (см. «Идемпотентность и replay»).
Входные данные / payload событий
Контракты — дома producer-а; здесь только читаемые поля и эффект. SlotId, TimeZone, CalendarEventId,
CancelledBy, PendingExpiresAt-производные и прочие поля payload консюмер не читает.
| Контракт | Читаемые поля | Эффект |
|---|---|---|
| appointment-created.md | AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Comment?, ProjectId?, PropertyObjectId?, RescheduledFromAppointmentId? | Get-or-create клиента и лида + запись ленты ShowingAppointmentCreated/ConsultationAppointmentCreated (по Kind); заполненный RescheduledFromAppointmentId помечает запись как перенос (продолжение, не новая заявка). |
| appointment-confirmed.md | AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, ConfirmedAt, AutoConfirmed | Запись ленты AppointmentConfirmed (get-or-create fail-safe — контракт самодостаточен на случай обработки раньше appointment-created). |
| appointment-cancelled.md | AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Reason, RescheduledToAppointmentId? | Запись ленты AppointmentCancelled с причиной (Reason); заполненный RescheduledToAppointmentId — перенос, не разрыв. |
| appointment-completed.md | AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, Outcome, CompletedAt | Запись ленты AppointmentCompleted (Outcome = Completed) / AppointmentNoShow (Outcome = NoShow). |
Маппинг «событие → тип ленты лида»
Дом типов и payload-модели LeadAppointmentPayload — lead.md; маппинг поле-в-поле —
из читаемых полей контракта в одноимённые поля payload.
| Событие / условие | LeadEventType | Особенности |
|---|---|---|
appointment-created, Kind = Showing | ShowingAppointmentCreated | Comment, ProjectId/PropertyObjectId, RescheduledFromAppointmentId — в payload. |
appointment-created, Kind = Consultation | ConsultationAppointmentCreated | те же поля. |
appointment-created, неизвестный Kind | ConsultationAppointmentCreated | fail-safe (более общий вид встречи): warning + метрика; строка Kind сохраняется в payload как есть. |
appointment-confirmed | AppointmentConfirmed | AutoConfirmed — в payload. |
appointment-cancelled (любой Reason) | AppointmentCancelled | Reason — в payload; неизвестный Reason → warning, запись фиксируется с сырой строкой (нейтральное «запись отменена»); RescheduledToAppointmentId — перенос. |
appointment-completed, Outcome = Completed | AppointmentCompleted | — |
appointment-completed, Outcome = NoShow | AppointmentNoShow | сигнал потерянного контакта — повторная связь с клиентом. |
appointment-completed, неизвестный Outcome | AppointmentCompleted | fail-safe «встреча завершена»: warning + метрика; строка Outcome не парсится в тип. |
Алгоритм
- Контекст события. Восстановить метаданные (идентификатор сообщения,
AppointmentId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключуcrm-calendar-appointments:{MessageId}: уже обработано → пропустить как дубль (ack). - Проверка входа. Обязательные поля контракта присутствуют (
AppointmentId,TenantId,AgentUserId,InvestorUserId,Kind,StartAt; дляcancelled—Reason, дляcompleted—Outcome). Нарушение →CRM_CALENDAR_SYNC_INVALID_PAYLOAD(non-retriable → DLQ + алерт). Неизвестные значения enum (Kind/Reason/Outcome) — не ошибка: fail-safe по таблице маппинга (get-or-create клиента/лида от enum не зависит). - 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(системный консюмер).
- display-данные (ФИО, телефон) — строка реплики identity по
- 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-лида не выполняется — при отсутствии активного создаётся новый (история пары остаётся в неактивных лидах). - Запись ленты. 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). - Какие события фиксируются. Если лид на шаге 4 создан — публикуется
LeadCreatedEvent→ topicpin.crm.lead-created.v1(Source = Appointment; контракт — lead-created.md; потребители —notifications(уведомление агенту о новом лиде),referral). Переиспользованный лид интеграционных событий не порождает. - Согласованность. Клиент, лид, запись ленты, отметка идемпотентности и исходящее
lead-created— в одном согласованном изменении; offset подтверждается только после успешной фиксации. Гонки уникальности (два события одной пары параллельно, гонка с консюмером заявки/реферала за тот же телефон или пару) → конфликт unique-индекса → перечитать и повторить (см. Retry). - Результат. Успех → ack (включая no-op-ветки); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт.
Диаграмма
Идемпотентность и replay
- На уровне консюмера: ключ
crm-calendar-appointments:{MessageId}; uniqueSourceMessageIdзаписи ленты — повторная доставка не создаёт дублей. - На бизнес-уровне: 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
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Пользователь отсутствует в display-реплике identity (user-upserted отстаёт) | CRM_CALENDAR_SYNC_USER_REPLICA_NOT_FOUND | Exponential backoff до 24 ч | После исчерпания + алерт | Проверить лаг консюмера синка identity; replay топика pin.identity.users.v1 |
| Отсутствуют обязательные поля / нераспознаваемый payload | CRM_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 mismatch | — | warn-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). Тогда: в ленте — записиAppointmentConfirmed(сAutoConfirmed) и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 + алерт, записи других инвесторов не блокируются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: LeadEntity
- API (2): POST /api/public/crm/booking-requests — Заявка на бронирование объекта (витрина), POST /api/public/crm/consultation-requests — Заявка на консультацию/презентацию
- scenarios (1): UC-OVR-011. Самозапись инвестора: от слота до исхода встречи и лида в воронке
Связанные документы
- Контракты входных событий: appointment-created.md, appointment-confirmed.md, appointment-cancelled.md, appointment-completed.md; агрегат-источник — appointment.md.
- Целевые сущности: lead.md (типы
*Appointment*,LeadAppointmentPayload,LeadSource.Appointment, INV-CRM-001, INV-LEAD-2), client.md (INV-CLI-1/2/3, дельтаClientSource.Appointment); исходящее событие — lead-created.md. - Display-реплика identity: identity-user-upserted.md (дельта
crm.identity_user_replicas). - No-slot путь воронки: create-consultation-request.md
(мягкий
PreferredAt); самозапись на слот — book-slot.md. - Шаблон consumer: consumer-template.md; README контекста crm; ADR: 0064 calendar-контекст, 0052 tenancy, 0056 simplicity-first.