POST /api/v1/agent/calendar/appointments/{appointmentId}/cancel — Отмена записи агентом
Назначение
Агент отменяет запись инвестора с обязательной причиной — как ожидающую (Pending), так и уже
подтверждённую (Confirmed): встреча срывается по стороне агента (болезнь, форс-мажор, объект снят с
показа). В отличие от инвестора, агент не связан cutoff-ом (INV-APT-3 ограничивает только инвестора) и
может отменить вплоть до фиксации исхода. Каскадом слот освобождается (если ещё пригоден для повторного
бронирования), календарное событие отменяется, инвестор получает уведомление с причиной.
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | agent |
| Feature group | appointments |
| Статус | draft |
| Документ | docs/06-services/calendar/api/agent/cancel-appointment.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (агент агентства / консультант ПИН — сторона записи) |
| Auth policy / Permissions | calendar.appointments.decide |
| Scope (RBAC) | организация — компания актора (агентство / ПИН) |
| Record-level | только собственные записи (AgentUserId = actor); чужая — APPOINTMENT_NOT_FOUND (fail-closed) |
| Tenant isolation | TenantId из токена (ADR-0052) |
| Audit | update: CancelReason, CancelledBy = Agent, UpdatedByUserId = actor; история — событие appointment-cancelled |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agent/calendar/appointments/{appointmentId}/cancel |
| Success status | 200 (обновлённая запись) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level |
TenantId | токен (tenant_id) | Guid | Да | Required | Tenant isolation |
AppointmentId | route | Guid | Да | Required; валидный Guid | — |
Reason | body | string | Да | 1..512 символов после trim; отсутствие/пустая строка → VALIDATION_FAILED | может содержать ПДн — в логи не пишется |
Модель ответа
Обновлённая запись — AgentAppointmentModel (дом — list-appointments.md):
Status = Cancelled, CancelReason = Reason, CancelledBy = Agent, инкрементирован Version.
Алгоритм
- Контекст и доступ. Актор с permission
calendar.appointments.decide (иначе
APPOINTMENT_ACCESS_DENIED); запись ищется среди записей актора в его tenant — не найдена/чужая →
APPOINTMENT_NOT_FOUND.
- Проверка входа.
Reason непустой, ≤ 512 символов — иначе VALIDATION_FAILED (причина обязательна
при Cancelled, INV-APT-6).
- Проверка состояния. Допустимые исходные статусы —
Pending и Confirmed (матрица переходов —
appointment.md); guard Confirmed → Cancelled — до фиксации исхода.
Терминальная запись → APPOINTMENT_INVALID_STATE. Cutoff инвестора не проверяется — агент им не связан
(INV-APT-3); после наступления StartAt состоявшуюся/несостоявшуюся встречу корректнее закрывать
исходом (complete-appointment.md), но отмена остаётся допустимой (встреча
сорвана до начала).
- Переход и каскады. В одном согласованном изменении (INV-APT-6):
- запись
Pending/Confirmed → Cancelled, CancelReason = Reason, CancelledBy = Agent;
- слот:
Booked → Available, если now ≤ StartAt − MinLeadTimeMinutes (возвращается на витрину),
иначе Booked → Expired (правило освобождения — appointment.md);
- календарное событие (
Origin = Appointment): → Cancelled с CancelReason (INV-CAL-3), его
Pending-напоминания → Skipped (event-reminder.md);
- публикуется
appointment-cancelled (Reason = CancelledByAgent).
- Конкуренция. Гонка «cancel агента ↔ отмена/перенос инвестором ↔ таймаут worker-а» — арбитраж
версией записи; проигравший получает
APPOINTMENT_INVALID_STATE и перечитывает состояние.
- Результат.
200 + обновлённая AgentAppointmentModel.
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | pin.calendar.appointment-cancelled.v1 (Reason = CancelledByAgent), key = AppointmentId (../../events/appointment-cancelled.md); атомарно с переходом |
| Проекции downstream | crm — запись в ленту лида (../../../crm/consumers/calendar-appointments.md) |
| Уведомления | notifications уведомляет инвестора об отмене с причиной (AppointmentCancelled, ../../../notifications/consumers/calendar-events.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Reason отсутствует, пустой или длиннее 512 | «Укажите причину отмены» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
APPOINTMENT_ACCESS_DENIED | 403 | нет permission calendar.appointments.decide | «Недостаточно прав» | no retry |
APPOINTMENT_NOT_FOUND | 404 | запись не существует или принадлежит другому агенту/tenant | «Запись не найдена» | no retry |
APPOINTMENT_INVALID_STATE | 409 | запись терминальна (Declined/Cancelled/Expired/Completed/NoShow), либо проигрыш конкурентной гонки | «Запись уже в другом состоянии» | no retry (перечитать состояние) |
Постусловия
После 200: запись Cancelled (терминальна) с CancelReason и CancelledBy = Agent; слот Available
(снова на витрине) либо Expired; календарное событие Cancelled, напоминания Skipped; опубликовано
ровно одно appointment-cancelled (Reason = CancelledByAgent). Лента лида CRM и уведомление инвестору
догоняют асинхронно.
Acceptance Criteria
- AC-1 (подтверждённая, заранее). Given
Confirmed-запись, до StartAt больше MinLeadTimeMinutes.
When агент отменяет с причиной. Then 200: запись Cancelled (CancelledBy = Agent), слот Available,
событие Cancelled, напоминания Skipped; опубликовано
appointment-cancelled (Reason = CancelledByAgent); инвестор уведомлён.
- AC-2 (в последний момент). Given
Confirmed-запись, до StartAt 10 минут
(CancellationCutoffMinutes = 120 — инвестор отменить уже не может). When агент отменяет. Then 200:
cutoff агента не ограничивает; слот Expired (lead time прошёл).
- AC-3 (ожидающая). Given
Pending-запись. When агент отменяет с причиной. Then 200: запись
Cancelled, слот освобождён по правилу lead time.
- AC-4 (без причины). Given
Confirmed-запись. When cancel с пустым Reason. Then
400 VALIDATION_FAILED.
- AC-5 (после исхода). Given запись
Completed. When cancel. Then 409 APPOINTMENT_INVALID_STATE.
- AC-6 (гонка с инвестором). Given одновременные cancel агента и
cancel-my-appointment инвестора.
Then побеждает ровно один; CancelledBy и Reason события отражают победителя; событий
appointment-cancelled — ровно одно.
Совместимость и наблюдаемость
- Контракт стабилен; структурированный код причины — additive optional поле.
- Logs:
appointment cancelled by agent (TenantId, AgentUserId, AppointmentId, судьба слота; без
Reason); metrics: calendar_appointment_decisions_total{decision=cancelled_by_agent},
calendar_slots_released_total{outcome}; traces — сквозной traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы