Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent)
Версионируемый контракт интеграционного события «Запись не состоится» — отклонена агентом, отменена
инвестором/агентом, просрочена без решения агента или отменена как половина переноса. Consumer-документы
ссылаются сюда, не переописывая payload. Дом полей агрегата —
entity: AppointmentEntity.
Назначение
Фиксирует любой терминальный исход записи до встречи: Declined / Cancelled / Expired. Причина
кодируется enum Reason — консюмерам не нужно различать топики или восстанавливать статус. Публичное:
crm пополняет ленту лида (отклонение/отмена/просрочка — сигнал по работе с клиентом), notifications
уведомляет вторую сторону встречи. Каскадные эффекты (освобождение слота, отмена календарного события) —
внутри calendar (appointment.md, таблица каскадов) и в payload не входят.
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.calendar.appointment-cancelled |
| Topic | pin.calendar.appointment-cancelled.v1 |
| Partitions / key | 3 (ADR-0056) / key = AppointmentId (порядок событий одной записи) |
| ContractVersion | 1 |
| Producer | calendar: отклонение/отмена агентом (../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 | Описание |
|---|---|---|---|---|---|
AppointmentId | Guid | Да | AppointmentEntity.Id | Immutable | Ключ агрегата (= Kafka key). |
TenantId | Guid | Да | AppointmentEntity.TenantId | Immutable | Компания агента (ADR-0052). |
AgentUserId | Guid | Да | AppointmentEntity.AgentUserId | Immutable | Агент/консультант. |
InvestorUserId | Guid | Да | AppointmentEntity.InvestorUserId | Immutable | Инвестор. |
Kind | string (enum EventKind) | Да | AppointmentEntity.Kind | Additive only (новые значения) | Вид встречи: Showing / Consultation. |
StartAt | DateTimeOffset | Да | AppointmentEntity.StartAt | Immutable (снапшот слота) | Начало несостоявшейся встречи, UTC — для текста уведомления. |
TimeZone | string | Да | AppointmentEntity.TimeZone | Additive only | IANA-таймзона слота. |
Reason | string (enum AppointmentCancelReason) | Да | производное от перехода (таблица ниже) | Additive only (новые значения) | Почему запись не состоится — определяет получателя и текст уведомления. |
CancelledBy | string? (enum AppointmentCancelledBy) | Нет (обязателен при финальном статусе Cancelled) | AppointmentEntity.CancelledBy | Additive only (новые значения) | Кто отменил: Investor / Agent / System; null для Declined/Expired (там актор однозначен из Reason). |
RescheduledToAppointmentId | Guid? | Нет | AppointmentEntity.RescheduledToAppointmentId | Immutable | Новая запись атомарной пары переноса — заполнен, если отмена выполнена переносом (INV-APT-5); консюмеры трактуют как перенос, а не разрыв. |
Enum AppointmentCancelReason
Маппинг «переход записи → Reason» (appointment.md, матрица переходов и
таблица каскадов):
| Значение | Финальный статус записи | CancelledBy | Кто / операция |
|---|---|---|---|
DeclinedByAgent | Declined | null | Агент отклонил Pending-запись (decline-appointment, причина обязательна). |
CancelledByInvestor | Cancelled | Investor | Инвестор отменил или перенёс запись в пределах cutoff (INV-APT-3). |
CancelledByAgent | Cancelled | Agent | Агент отменил Pending/Confirmed-запись до фиксации исхода. |
Expired | Expired | null | Worker appointment-timeout: агент не принял решение до PendingExpiresAt. |
CancelledBy = System (административная отмена платформой) зарезервирован enum-ом записи; выделенной
операции в v1 нет — при её появлении добавляется additive-значение Reason без смены контракта.
Когда публикуется
| Условие | Переход состояния | Публикация |
|---|---|---|
Агент отклонил Pending-запись | Pending → Declined (Reason = DeclinedByAgent) | атомарно с переходом и каскадом (слот освобождён, событие Cancelled) |
| Инвестор отменил запись до cutoff | Pending/Confirmed → Cancelled (Reason = CancelledByInvestor) | атомарно с переходом и каскадом |
| Инвестор перенёс запись (первая половина атомарной пары) | Pending/Confirmed → Cancelled (Reason = CancelledByInvestor, RescheduledToAppointmentId заполнен) | атомарно с созданием новой записи (appointment-created в своём топике) |
| Агент отменил запись | Pending/Confirmed → Cancelled (Reason = CancelledByAgent) | атомарно с переходом и каскадом |
Worker: now > PendingExpiresAt | Pending → Expired (Reason = Expired) | атомарно с переходом и каскадом; ровно одно событие на запись (идемпотентность worker-а) |
Потребители (consumers)
| Consumer-контекст | Side effects | Идемпотентность | Задержка (SLA) |
|---|---|---|---|
crm | Запись в ленту лида по Reason: отклонена агентом / отменена инвестором / отменена агентом / просрочена без решения; при заполненном RescheduledToAppointmentId — фиксируется перенос (продолжение следует в appointment-created новой записи). Консюмер — ../../crm/consumers/calendar-appointments.md | MessageId (unique-ключ записи ленты) + business key AppointmentId | < 1 мин |
notifications | NotificationKind = AppointmentCancelled, получатель — вторая сторона: DeclinedByAgent/CancelledByAgent → инвестору; CancelledByInvestor → агенту; Expired → обоим (инвестору — «агент не подтвердил запись», агенту — «запись просрочена»). При заполненном RescheduledToAppointmentId уведомление об отмене подавляется — участников уведомит appointment-created новой записи (перенос ≠ отмена). Консюмер — ../../notifications/consumers/calendar-events.md | MessageId | < 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, RescheduledToAppointmentId | Internal | include (только идентификаторы, время и enum) |
CancelReason (свободный текст записи) | Internal | exclude — в 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, остальные записи обрабатываются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): US-реестр контекста calendar (US-CAL)
- События (6): Event Contract: pin.calendar.appointment-completed (AppointmentCompletedEvent), Event Contract: pin.calendar.appointment-confirmed (AppointmentConfirmedEvent), Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent), Event Contract: pin.calendar.event-rescheduled (CalendarEventRescheduledEvent), Event Contract: pin.calendar.event-status-changed (CalendarEventStatusChangedEvent), Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
- Консюмеры (2): Consumer: Calendar Events → Notifications (notifications-calendar-events), Consumer: CRM Calendar Appointments Sync (crm-calendar-appointments)
- scenarios (1): UC-OVR-011. Самозапись инвестора: от слота до исхода встречи и лида в воронке
- registries (1): Реестр событий PIN
Связанные документы
- Сущность/дом полей: appointment.md (матрица переходов, каскады, INV-APT-3/5/6,
enum
AppointmentCancelledBy); слот: availability-slot.md (правило освобожденияAvailable/Expired); отменяемое событие: calendar-event.md. - Соседние контракты семейства: appointment-created.md,
appointment-confirmed.md, appointment-completed.md;
параллельное событие календаря: event-status-changed.md (для
Origin = Appointmentуведомления строит только этот контракт). - Producer-операции:
../api/agent/decline-appointment.md,../api/agent/cancel-appointment.md,../api/investor/cancel-my-appointment.md,../api/investor/reschedule-my-appointment.md; worker:../background/appointment-timeout.md; consumers:../../crm/consumers/calendar-appointments.md,../../notifications/consumers/calendar-events.md. - ADR: 0064 calendar-контекст, 0052 tenancy, 0056 simplicity-first.