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

Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent)

Версионируемый контракт интеграционного события «Запись не состоится» — отклонена агентом, отменена инвестором/агентом, просрочена без решения агента или отменена как половина переноса. Consumer-документы ссылаются сюда, не переописывая payload. Дом полей агрегата — entity: AppointmentEntity.

Назначение

Фиксирует любой терминальный исход записи до встречи: Declined / Cancelled / Expired. Причина кодируется enum Reason — консюмерам не нужно различать топики или восстанавливать статус. Публичное: crm пополняет ленту лида (отклонение/отмена/просрочка — сигнал по работе с клиентом), notifications уведомляет вторую сторону встречи. Каскадные эффекты (освобождение слота, отмена календарного события) — внутри calendar (appointment.md, таблица каскадов) и в payload не входят.

Metadata

ПолеЗначение
Event typepin.calendar.appointment-cancelled
Topicpin.calendar.appointment-cancelled.v1
Partitions / key3 (ADR-0056) / key = AppointmentId (порядок событий одной записи)
ContractVersion1
Producercalendar: отклонение/отмена агентом (../api/agent/decline-appointment.md, ../api/agent/cancel-appointment.md); отмена инвестором (../api/investor/cancel-my-appointment.md) и первая половина переноса (../api/investor/reschedule-my-appointment.md); таймаут Pending — worker ../background/appointment-timeout.md
Статусdraft

Событие публикуется атомарно с терминальным переходом записи и каскадом «слот освобождён/истёк + календарное событие Cancelled»: пока изменение не зафиксировано, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once, transactional outbox), ключ = AppointmentId.

Payload AppointmentCancelledEvent

Дом полей — entity: AppointmentEntity (раздел «Доменные и интеграционные события»); здесь перечислено только то, что реально в событии. Свободнотекстовый CancelReason записи в payload не входит — консюмерам достаточно enum Reason (текст причины остаётся внутри calendar).

ПолеТипОбязательностьИсточник (поле агрегата)CompatibilityОписание
AppointmentIdGuidДаAppointmentEntity.IdImmutableКлюч агрегата (= Kafka key).
TenantIdGuidДаAppointmentEntity.TenantIdImmutableКомпания агента (ADR-0052).
AgentUserIdGuidДаAppointmentEntity.AgentUserIdImmutableАгент/консультант.
InvestorUserIdGuidДаAppointmentEntity.InvestorUserIdImmutableИнвестор.
Kindstring (enum EventKind)ДаAppointmentEntity.KindAdditive only (новые значения)Вид встречи: Showing / Consultation.
StartAtDateTimeOffsetДаAppointmentEntity.StartAtImmutable (снапшот слота)Начало несостоявшейся встречи, UTC — для текста уведомления.
TimeZonestringДаAppointmentEntity.TimeZoneAdditive onlyIANA-таймзона слота.
Reasonstring (enum AppointmentCancelReason)Дапроизводное от перехода (таблица ниже)Additive only (новые значения)Почему запись не состоится — определяет получателя и текст уведомления.
CancelledBystring? (enum AppointmentCancelledBy)Нет (обязателен при финальном статусе Cancelled)AppointmentEntity.CancelledByAdditive only (новые значения)Кто отменил: Investor / Agent / System; null для Declined/Expired (там актор однозначен из Reason).
RescheduledToAppointmentIdGuid?НетAppointmentEntity.RescheduledToAppointmentIdImmutableНовая запись атомарной пары переноса — заполнен, если отмена выполнена переносом (INV-APT-5); консюмеры трактуют как перенос, а не разрыв.

Enum AppointmentCancelReason

Маппинг «переход записи → Reason» (appointment.md, матрица переходов и таблица каскадов):

ЗначениеФинальный статус записиCancelledByКто / операция
DeclinedByAgentDeclinednullАгент отклонил Pending-запись (decline-appointment, причина обязательна).
CancelledByInvestorCancelledInvestorИнвестор отменил или перенёс запись в пределах cutoff (INV-APT-3).
CancelledByAgentCancelledAgentАгент отменил Pending/Confirmed-запись до фиксации исхода.
ExpiredExpirednullWorker appointment-timeout: агент не принял решение до PendingExpiresAt.

CancelledBy = System (административная отмена платформой) зарезервирован enum-ом записи; выделенной операции в v1 нет — при её появлении добавляется additive-значение Reason без смены контракта.

Когда публикуется

УсловиеПереход состоянияПубликация
Агент отклонил Pending-записьPending → Declined (Reason = DeclinedByAgent)атомарно с переходом и каскадом (слот освобождён, событие Cancelled)
Инвестор отменил запись до cutoffPending/Confirmed → Cancelled (Reason = CancelledByInvestor)атомарно с переходом и каскадом
Инвестор перенёс запись (первая половина атомарной пары)Pending/Confirmed → Cancelled (Reason = CancelledByInvestor, RescheduledToAppointmentId заполнен)атомарно с созданием новой записи (appointment-created в своём топике)
Агент отменил записьPending/Confirmed → Cancelled (Reason = CancelledByAgent)атомарно с переходом и каскадом
Worker: now > PendingExpiresAtPending → Expired (Reason = Expired)атомарно с переходом и каскадом; ровно одно событие на запись (идемпотентность worker-а)

Потребители (consumers)

Consumer-контекстSide effectsИдемпотентностьЗадержка (SLA)
crmЗапись в ленту лида по Reason: отклонена агентом / отменена инвестором / отменена агентом / просрочена без решения; при заполненном RescheduledToAppointmentId — фиксируется перенос (продолжение следует в appointment-created новой записи). Консюмер — ../../crm/consumers/calendar-appointments.mdMessageId (unique-ключ записи ленты) + business key AppointmentId< 1 мин
notificationsNotificationKind = AppointmentCancelled, получатель — вторая сторона: DeclinedByAgent/CancelledByAgent → инвестору; CancelledByInvestor → агенту; Expired → обоим (инвестору — «агент не подтвердил запись», агенту — «запись просрочена»). При заполненном RescheduledToAppointmentId уведомление об отмене подавляется — участников уведомит appointment-created новой записи (перенос ≠ отмена). Консюмер — ../../notifications/consumers/calendar-events.mdMessageId< 5 мин

Версионирование и совместимость

  • ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию по конвенции суффиксом .v1.
  • Additive-only для minor: новые поля добавляются как optional с безопасным default — без bump; новые значения Reason/CancelledBy — additive.
  • Breaking (удаление/переименование/смена типа или смысла) — ContractVersion = 2 + новый топик pin.calendar.appointment-cancelled.v2 + dual-publish на период миграции + deadline миграции consumers + sunset .v1.
  • Unknown enum (Reason/CancelledBy/Kind) — fallback: сохранить строку как есть, warning; лента CRM фиксирует нейтральное «запись отменена», уведомление — обеим сторонам с нейтральным текстом (fail safe).

Безопасность

ПолеКласс данныхПравило
AppointmentId, TenantId, AgentUserId, InvestorUserId, Kind, StartAt, TimeZone, Reason, CancelledBy, RescheduledToAppointmentIdInternalinclude (только идентификаторы, время и enum)
CancelReason (свободный текст записи)Internalexclude — в payload не входит по дизайну; текст остаётся внутри calendar
  • PII (ФИО/телефоны) в событии отсутствует по дизайну: display-данные консюмеры берут из своей реплики pin.identity.users.v1.
  • Доступ к топику — m2m ACL Kafka (crm, notifications).

Доставка, retry/DLQ и восстановление

  • Гарантия. At-least-once после атомарной публикации из outbox; ключ = AppointmentId — порядок событий одной записи в топике сохранён. Дубликаты возможны — консюмеры идемпотентны по MessageId.
  • Идемпотентность producer-а. Worker appointment-timeout над уже не-Pending записью — no-op: ровно одно appointment-cancelled (Reason = Expired) на запись.
  • Retry. Транзиентный сбой консюмера → повтор с backoff; партиция не продвигается дальше застрявшего оффсета, порядок по ключу сохраняется.
  • DLQ. Неразбираемое/несовместимое сообщение после конечного числа попыток — в DLQ с алертом; записи других инвесторов не блокируются.
  • Кросс-топик порядок. С appointment-created новой записи переноса межтопиковый порядок не гарантирован: RescheduledToAppointmentId позволяет консюмеру корректно связать пару в любом порядке обработки (лента CRM фиксирует перенос, уведомление об отмене подавлено независимо от порядка). Терминальный исход — финальная картина записи: устаревшие appointment-confirmed, пришедшие после, консюмер игнорирует по бизнес-состоянию (no-op, warning).
  • Промежуточное состояние. Слот освобождается и запись исчезает из активных сразу (данные calendar); лента CRM и уведомления — после обработки событий (обычно < 1–5 мин).

Acceptance-критерии (контракт потребления)

  • Отклонение агентом. Дано: агент отклонил Pending-запись. Когда notifications обрабатывает событие. Тогда инвестору уходит AppointmentCancelled (Reason = DeclinedByAgent); агент не уведомляется.
  • Отмена инвестором. Дано: инвестор отменил Confirmed-запись до cutoff. Когда notifications обрабатывает событие. Тогда агенту уходит уведомление (Reason = CancelledByInvestor, CancelledBy = Investor).
  • Просрочка. Дано: worker перевёл запись в Expired. Когда консюмеры обрабатывают событие. Тогда в ленте лида — «запись просрочена без решения агента», уведомлены обе стороны; повторный проход worker-а второго события не публикует.
  • Перенос без «отмены». Дано: событие с заполненным RescheduledToAppointmentId. Когда notifications обрабатывает. Тогда уведомление об отмене не отправляется; crm фиксирует в ленте перенос.
  • Идемпотентность. Дано: сообщение доставлено дважды. Тогда дублей записей ленты и уведомлений нет.
  • Неизвестный Reason. Дано: сообщение с новым значением Reason. Когда консюмер обрабатывает. Тогда warning + нейтральная реакция (лента «запись отменена», уведомление обеим сторонам); сообщение не в DLQ.
  • Несовместимый payload. Дано: сообщение без обязательного поля. Когда консюмер повторно не смог его обработать. Тогда сообщение уходит в DLQ, остальные записи обрабатываются.

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

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

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