POST /api/agent/crm/bookings/{bookingId}/cancel — Отменить бронь
Агентская сторона отменяет активный процесс брони до его истечения: клиент передумал или меняется объект.
Переход брони из активного статуса (Reserved / AwaitingPayment / Booked) в терминальный Cancelled
(INV-BKG-8: терминальные статусы иммутабельны). Дом полей брони, статусы и инварианты — в
booking.md.
Назначение
Дать агенту (от имени передумавшего клиента) снять бронь и освободить объект недвижимости, не дожидаясь
истечения таймера. Отмена возможна только из активного статуса и только актором агентской стороны (ответственный
агент, руководитель его агентства или Менеджер ПИН); инвесторская отмена — симметричный endpoint area
investor, отмена менеджером застройщика — area developer. После отмены объект можно забронировать заново.
Сама сделка не переводится этой командой: она остаётся в статусе Booking; возврат в Application, отказ
сделки или новая бронь — отдельное решение менеджера (deal.md, переход
Booking → Application).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/cancel-booking.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — ответственный агент сделки, руководитель агентства или Менеджер ПИН |
| Auth policy / Permissions | crm.bookings.manage (агентская сторона); Менеджер ПИН — platform-scope |
| Scope (RBAC) | организация (агентство): record-level по OriginAgencyCompanyId сделки-владельца |
| Record-level | Deal.OriginAgencyCompanyId = actor.CompanyId; рядовой агент дополнительно — активный участник (обычно Executor: Deal.ExecutorAgentUserId = actor.UserId); руководитель агентства — любая сделка своего OriginAgencyCompanyId; Менеджер ПИН — обход record-level |
| Tenant isolation | Бронь TenantId = DeveloperCompanyId (владелец объекта — застройщик, ADR-0052). Операция агентской стороны идёт с явным предикатом OriginAgencyCompanyId (по загруженной сделке), а не по авто-tenant-фильтру |
| Audit | update: переход → Cancelled, поля CancelReason, CancelledAt, UpdatedByUserId, Version; бизнес-факт — строка таймлайна сделки |
Бронь чужого агентства не раскрывается: и отсутствие записи, и отсутствие прав на неё возвращают одинаковый
404 CRM_BOOKING_NOT_FOUND. Менеджер ПИН — единственный актор, обходящий record-level по OriginAgencyCompanyId.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/agent/crm/bookings/{bookingId}/cancel |
| Success status | 204 (No Content) |
| Idempotency header | не применяется — повтор ловится проверкой состояния: вторая отмена уже терминальной брони → CRM_BOOKING_ALREADY_TERMINAL |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
BookingId | route | Guid | Да | != Guid.Empty; существующая бронь сделки своего агентства | — |
CancelReason | body | string | Да | не пустое после trim, 1..1000 | free-text: в логах — не выводить целиком |
ActorUserId | identity claims | Guid | Да | из JWT | — |
ActorAgencyCompanyId | identity claims | Guid | Да | AgencyCompanyId актора — record-level предикат по сделке | — |
IsAgencyHead | identity claims | bool | Да | флаг «руководитель агентства» (доступ на всех агентов своего AgencyCompanyId) | — |
IsPinManager | identity claims | bool | Да | platform-scope Менеджера ПИН (обход record-level) | — |
Версия строки клиентом не передаётся — гонка с worker-ом истечения решается сервером оптимистически.
Модель ответа
Тела нет: HTTP 204 No Content. Актуальное состояние брони агент перечитывает методом карточки сделки/брони
(соседний документ area agent).
Алгоритм
- Контекст и доступ. Операцию выполняет ответственный агент / руководитель агентства / Менеджер ПИН
(permission
crm.bookings.manage). - Проверка входа и предусловий.
- формат:
BookingId != Guid.Empty;CancelReasonне пустой и ≤ 1000 — иначе ошибка валидации; - бронь существует — иначе
CRM_BOOKING_NOT_FOUND(404); - допустимость состояния:
Status ∈ {Reserved, AwaitingPayment, Booked}; терминальный статус →CRM_BOOKING_ALREADY_TERMINAL(409, INV-BKG-8); - право актора — по загруженной сделке (
CRM_BOOKING_FORBIDDEN/ скрытие под404).
- формат:
- Что читаем. Бронь (выборка агентской стороны — по явному предикату
OriginAgencyCompanyId, у актора нет tenant застройщика) и её сделку (для record-level и полей строки таймлайна). Объект/клиент не загружаются. - Бизнес-правила и переходы. Record-level: если сделка чужого агентства и actor не Менеджер ПИН →
CRM_BOOKING_NOT_FOUND(чужое агентство не раскрывается). Право на отмену: Менеджер ПИН, руководитель агентства или ответственный агент (активныйExecutor:deal.ExecutorAgentUserId == actor); иначеCRM_BOOKING_FORBIDDEN(403). Затем переход→ Cancelled(guardStatus ∈ {Reserved, AwaitingPayment, Booked}, иначе доменный отказ →CRM_BOOKING_ALREADY_TERMINAL, INV-BKG-8):CancelReason(trim),CancelledAt = now,UpdatedByUserId = actor. Оплата/документы/ReserveExpiresAtне меняются (иммутабельные факты фаз). В таймлайн сделки материализуется строкаBooking*(Status = Done, INV-TLE-2, дом deal-timeline-event.md). Статус сделки командой не меняется. - Что меняется. Бронь →
Cancelled+ строка таймлайна — в одном согласованном изменении; строка сделки (Status/BookingId) не трогается. - События. Интеграционное
BookingCancelledEvent→ топикpin.crm.booking-cancelled.v1(ContractVersion = 1,AggregateId = BookingId,AggregateType = "Booking", key =BookingId), payload{BookingId, DealId, TenantId, PropertyObjectId, CancelledByUserId = ActorUserId, CancelledAt, CancelReason}— transactional outbox. Контракт и консюмеры — booking-cancelled.md (chessboardосвобождает объектРезерв/Бронь → Свободен;notificationsуведомляет участников). ДоменноеBookingCancelled— только in-process (таймлайн). - Результат. HTTP
204 No Content. Если workerbooking-expirationистёк бронь параллельно (переход вReserveExpired/BookingExpired) — конкурентный конфликт →CRM_BOOKING_CONFLICT(409); агент перечитывает бронь (уже терминальна, объект освобождён событиемbooking-expired).
Диаграмма
Предусловия и постусловия
Предусловия. Актор — ответственный агент / руководитель агентства / Менеджер ПИН с crm.bookings.manage.
Бронь существует в сделке своего агентства, Status ∈ {Reserved, AwaitingPayment, Booked} (активна).
Постусловие (успех). Бронь → Cancelled: CancelReason (trim), CancelledAt = now, UpdatedByUserId = actor; иммутабельные факты фаз (оплата/документы/ReserveExpiresAt) сохранены; в таймлайне сделки —
материализована строка таймлайна Booking* (Status = Done, INV-TLE-2). Сделка не переводится (остаётся Booking);
BookingId/статус сделки не трогаются. Опубликован BookingCancelledEvent (key BookingId) — потребители
chessboard (объект «Резерв»/«Бронь» → «Свободен»), notifications.
Объект освобождается асинхронно — до долёта события витрина ещё показывает «Резерв»/«Бронь»; после освобождения объект можно забронировать заново.
Постусловие (отказ). Бронь не изменена, событие не опубликовано.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | BookingCancelledEvent → pin.crm.booking-cancelled.v1 (key BookingId), outbox — booking-cancelled.md |
| Внешние вызовы | none (смену статуса объекта выполняет консюмер chessboard по событию, не синхронно) |
| Проекции / поиск | бронь выходит из активных выборок (Status = Cancelled) → объект можно забронировать заново; проекция сделки (DealSearchDocument) не меняется |
| Уведомления | опосредованно: консюмер booking-cancelled в notifications (уведомление участникам сделки) |
Ошибки и failure modes
Терминальный статус брони (Completed/ReserveExpired/BookingExpired/Cancelled, INV-BKG-8) необратим —
повторная отмена всегда 409 CRM_BOOKING_ALREADY_TERMINAL. Гонка с воркером booking-expiration даёт
409 CRM_BOOKING_CONFLICT (retry после перечитки состояния).
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | BookingId = Guid.Empty или пустой/слишком длинный CancelReason | «Укажите причину отмены брони» | no retry |
CRM_BOOKING_NOT_FOUND | 404 | брони нет или она в сделке чужого агентства (record-level скрывает существование) | «Бронирование не найдено» | no retry |
CRM_BOOKING_FORBIDDEN | 403 | актор своего агентства, но не ответственный агент и не руководитель/Менеджер ПИН | «Недостаточно прав для отмены брони» | no retry |
CRM_BOOKING_ALREADY_TERMINAL | 409 | Status терминальный (Completed/ReserveExpired/BookingExpired/Cancelled, INV-BKG-8) | «Бронь уже завершена и не может быть отменена» | no retry |
CRM_BOOKING_CONFLICT | 409 | гонка: worker истёк бронь параллельно | «Бронь изменена, обновите данные» | retry (перечитать состояние) |
Совместимость и наблюдаемость
- Контракт минимальный (route +
CancelReason→ пустой ответ); breaking — только смена маршрута/семантики статуса. Новые причины отказа — additive-кодыCRM_*без breaking change; событие версионируется по booking-cancelled.md (.v1→.v2при breaking). - Logs:
booking-cancel requested/cancelled/conflict(BookingId,DealId,OriginAgencyCompanyId,ActorUserId;CancelReason— не выводить целиком); metrics:crm_booking_cancel_total{result}, latency p95; traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path. Given активная бронь (
Reserved/AwaitingPayment/Booked) и непустая причина; Whencancel; Then204,Cancelled, опубликованbooking-cancelled, сделка осталасьBooking. - Пустая причина. Given
CancelReasonпустой или > 1000; Whencancel; Then400 VALIDATION_FAILED. - Терминальная бронь. Given
Status ∈ {Completed, ReserveExpired, BookingExpired, Cancelled}(INV-BKG-8); Whencancel; Then409 CRM_BOOKING_ALREADY_TERMINAL. - Гонка с worker. Given worker
booking-expirationистёк бронь параллельно; Whencancel; Then409 CRM_BOOKING_CONFLICT(retry); при перечитке бронь терминальна, объект уже освобождёнbooking-expired. - Чужое агентство. Given бронь в сделке чужого агентства; When
cancel; Then404 CRM_BOOKING_NOT_FOUND(существование скрыто). - Своё агентство, не ответственный. Given актор не ответственный агент и не руководитель/ПИН; When
cancel; Then403 CRM_BOOKING_FORBIDDEN.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): DELETE /api/investor/crm/leads/{leadId}/booking-intents/{intentId} — Инвестор снимает объект-намерение
- Use Cases (1): UC-BKG-002. Бронирование Резерв → Бронь
- Саги (1): Saga «Booking» (Резерв → Бронь → передача в продажу)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности (дом полей, статусы, INV-BKG-8, метод
Cancel): booking.md; сделка-владелец и переходBooking → Applicationменеджером: deal.md; таймлайн: deal-timeline-event.md. - Событие: booking-cancelled.md; каскад при отказе сделки — deal-cancelled.md.
- Соседний flow воронки агента: create-booking-request.md; пример agent-cancel (без kafka-события): cancel-fixation.md.
- ADR: 0052 tenancy (двухсторонние сущности,
record-level); границы контекстов (объект меняет
chessboard): service-boundaries.