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

UC-CAL-005. Отмена и перенос записи

Назначение

Описывает изменение договорённости о встрече: инвестор отменяет свою запись или переносит её на другой слот (в пределах cutoff-окна слота), агент отменяет запись со своей стороны (cutoff его не ограничивает). Перенос — не правка времени, а атомарная пара «отмена текущей записи + бронирование нового слота» со взаимными ссылками; при отказе любой половины не происходит ничего. Каждая отмена каскадно освобождает слот и отменяет календарное событие агента. Связан с контекстом calendar.

Metadata

ПолеЗначение
IDUC-CAL-005
Контекстcalendar (см. docs/06-services/calendar/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (отмена/перенос своей записи) — основной; Агент (отмена записи на свой слот) — альтернативный поток A3
Permissions / scopeИнвестор — record-level по InvestorUserId, без permission-модели tenant-а; агент — calendar.appointments.decide, scope Organization
TenantИнвестор — вне tenant (ADR-0052); запись живёт в tenant компании агента
Record-levelИнвестор управляет только своими записями; агент — только записями на свои слоты

Предусловие

  • Существует активная запись (Pending или Confirmed) — UC-CAL-003 / UC-CAL-004.
  • Для действий инвестора: cutoff не пройден — now ≤ StartAt − CancellationCutoffMinutes (параметр слота, default 120 минут).
  • Для переноса: на витрине есть другой подходящий свободный слот.

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементЛК инвестора → «Мои записи» → «Отменить» / «Перенести»; ЛК агента → «Записи» → «Отменить»

Основной Поток

  1. Инвестор открывает «Мои записи» (../api/investor/list-my-appointments.md) и карточку записи (../api/investor/get-my-appointment.md): время, вид, агент, статус, до какого момента возможна отмена/перенос (StartAt − CancellationCutoffMinutes).
  2. Инвестор нажимает «Отменить»; клиент вызывает ../api/investor/cancel-my-appointment.md.
  3. Система проверяет: запись принадлежит инвестору (record-level), статус активный (Pending/Confirmed), cutoff не пройден (INV-APT-3, иначе APPOINTMENT_CUTOFF_PASSED, A2).
  4. В одном согласованном изменении: запись → Cancelled (CancelledBy = Investor); слот освобождается (Booked → Available, если now ≤ StartAt − MinLeadTimeMinutes, иначе Booked → Expired); календарное событие агента → Cancelled, его Pending-напоминания → Skipped; публикуется pin.calendar.appointment-cancelled.v1 (Reason = CancelledByInvestor).
  5. Асинхронно: crm пишет в ленту лида «встреча отменена клиентом»; notifications уведомляет агента. Освобождённый слот снова виден на витрине.
  6. Инвестор видит запись в статусе «отменена»; активные лимиты (INV-APT-2) высвобождены — можно записаться заново.

Альтернативные Потоки

A1. Перенос записи инвестором

  1. Инвестор нажимает «Перенести», выбирает новый свободный слот (того же вида встречи) и вызывает ../api/investor/reschedule-my-appointment.md.
  2. Система проверяет guard-ы обеих половин одновременно: cutoff текущей записи (INV-APT-3) и доступность нового слота (статус Available, lead time — INV-SLT-3/5); антиспам-лимиты INV-APT-2 считаются с учётом атомарной отмены старой записи (перенос не увеличивает число активных записей).
  3. В одном согласованном изменении: старая запись → Cancelled (CancelledBy = Investor, RescheduledToAppointmentId = новая) с полным каскадом отмены (шаг 4 основного потока); новый слот Available → Booked; создаётся новая запись (RescheduledFromAppointmentId = старая) со статусом по AutoConfirm нового слота и новое календарное событие; публикуются appointment-cancelled (CancelledByInvestor, RescheduledToAppointmentId) и appointment-created (+ appointment-confirmed при auto-confirm).
  4. Если новый слот занят (гонка) — SLOT_ALREADY_BOOKED, если cutoff старой записи пройден — APPOINTMENT_CUTOFF_PASSED: ничего не меняется, старая запись и её слот не тронуты. Инвестор выбирает другой слот или оставляет запись. Возвращается к шагу 1 или завершается.

A2. Cutoff пройден

  1. До начала встречи меньше CancellationCutoffMinutes → отказ APPOINTMENT_CUTOFF_PASSED (409). Клиент объясняет, что отмена самостоятельно уже невозможна, и предлагает связаться с агентом — агент отменить может (A3). Завершается.

A3. Отмена агентом

  1. Агент отменяет активную запись (../api/agent/cancel-appointment.md) с обязательной причиной (CancelReason, иначе VALIDATION_FAILED); cutoff агента не ограничивает, но отмена возможна только до фиксации исхода (UC-CAL-006).
  2. Каскад тот же, что на шаге 4; CancelledBy = Agent, публикуется appointment-cancelled (Reason = CancelledByAgent); инвестор уведомляется. Завершается.

A4. Запись уже неактивна

  1. Запись terminal (Declined/Cancelled/Expired/Completed/NoShow) — например, worker успел раньше или другая сторона уже отменила → отказ APPOINTMENT_INVALID_STATE (409). Клиент перечитывает запись и показывает актуальный статус.

A5. Конкурентные действия сторон

  1. Отмена инвестора конкурирует с подтверждением/отменой агента и таймаутом worker-а: побеждает первая зафиксированная мутация (оптимистическая блокировка), проигравший получает APPOINTMENT_INVALID_STATE. Каскад на слот и событие выполняется ровно один раз.

Постусловие

  • Отмена: запись Cancelled с заполненными CancelledByCancelReason при отмене агентом); слот освобождён (Available) или истёк (Expired); календарное событие Cancelled, напоминания Skipped.
  • Перенос: старая запись Cancelled и новая активная запись связаны взаимными ссылками RescheduledFrom/To (цепочка переносов восстановима); новый слот Booked, новое событие Planned.
  • По каждому переходу опубликованы соответствующие события pin.calendar.appointment-*; лента лида CRM и уведомления пополнены асинхронно.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
APPOINTMENT_NOT_FOUND404Запись не найдена или принадлежит другому инвестору/агентуОбновить список записей
APPOINTMENT_ACCESS_DENIED403Агент без calendar.appointments.decide (A3)Скрыть действие
VALIDATION_FAILED400Отмена агентом без CancelReason (A3)Запросить причину
APPOINTMENT_CUTOFF_PASSED409Отмена/перенос инвестором позже StartAt − CancellationCutoffMinutes (A2)Предложить связаться с агентом
APPOINTMENT_INVALID_STATE409Запись неактивна; проигранная гонка (A4, A5)Перечитать запись
SLOT_ALREADY_BOOKED409Новый слот переноса занят (A1)Предложить другой слот
CAL_SLOT_NOT_AVAILABLE409Новый слот переноса истёк/заархивирован (A1)Обновить витрину
CAL_SLOT_LEAD_TIME_VIOLATION409До нового слота меньше MinLeadTimeMinutes (A1)Предложить более поздний слот
APPOINTMENT_DUPLICATE409Перенос нарушил бы антиспам-лимиты INV-APT-2 (A1)Показать активные записи

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
AppointmentCancelledEvent.v1pin.calendar.appointment-cancelled.v1Отмена инвестором (шаг 4, CancelledByInvestor; при переносе — с RescheduledToAppointmentId), отмена агентом (A3, CancelledByAgent)crm (лента лида), notifications (AppointmentCancelled)
AppointmentCreatedEvent.v1pin.calendar.appointment-created.v1Новая запись при переносе (A1)crm, notifications
AppointmentConfirmedEvent.v1pin.calendar.appointment-confirmed.v1Auto-confirm новой записи при переносе (A1)crm, notifications
CalendarEventStatusChangedEvent.v1 / CalendarEventCreatedEvent.v1pin.calendar.event-status-changed.v1 / pin.calendar.event-created.v1Каскадная отмена старого события; создание нового при переносеnotifications

Acceptance Criteria

#КритерийПроверка
AC1Отмена инвестором в пределах cutoff атомарно: запись Cancelled (CancelledBy = Investor), слот освобождён, событие Cancelled, напоминания Skipped, опубликовано appointment-cancelled (CancelledByInvestor)интеграционный тест отмены
AC2Отмена за 60 минут до встречи при CancellationCutoffMinutes = 120APPOINTMENT_CUTOFF_PASSED; агент ту же запись отменить может (A2, A3)негативный тест cutoff
AC3Перенос атомарен: старая запись Cancelled с RescheduledToAppointmentId, старый слот освобождён, новая запись с RescheduledFromAppointmentId на Booked-слоте, статус — по AutoConfirm нового слотаинтеграционный тест переноса (A1)
AC4Занятость нового слота при переносе → SLOT_ALREADY_BOOKED, старая запись и слот не изменены (всё-или-ничего)тест отказа половины пары (A1)
AC5Отмена агентом без причины → VALIDATION_FAILED; с причиной — каскад и appointment-cancelled (CancelledByAgent)тест отмены агентом (A3)
AC6Слот, освобождённый позже StartAt − MinLeadTimeMinutes, переходит в Expired, а не Available (повторно не продаётся)тест правила освобождения
AC7Отмена уже терминальной записи → APPOINTMENT_INVALID_STATE; конкурентные отмены обеих сторон дают ровно один каскадтест гонки (A4, A5)
AC8После отмены лимиты INV-APT-2 инвестора высвобождены — новая запись к тому же агенту проходиттест высвобождения лимитов

Наблюдаемость

  • Логи (без PII). appointment-cancelled (appointmentId, slotId, cancelledBy, reason? — длина, не текст), appointment-rescheduled (fromAppointmentId, toAppointmentId, newSlotId).
  • Метрики. calendar_appointments_cancelled_total{cancelled_by}; calendar_appointments_rescheduled_total{result}; calendar_cutoff_rejections_total (спрос на отмену после cutoff — сигнал пересмотреть default).

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

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

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

  • Domain: appointment.md (INV-APT-3/5, атомарная пара переноса, каскад-таблица), availability-slot.md (правило освобождения слота), calendar-event.md, event-reminder.md.
  • API: ../api/investor/list-my-appointments.md, ../api/investor/get-my-appointment.md, ../api/investor/cancel-my-appointment.md, ../api/investor/reschedule-my-appointment.md, ../api/agent/cancel-appointment.md.
  • События: ../events/appointment-cancelled.md, ../events/appointment-created.md, ../events/appointment-confirmed.md.
  • Связанные UC: UC-CAL-003 (бронирование, гонка за слот), UC-CAL-004 (таймаут Pending), UC-CAL-006 (после исхода отмена невозможна).