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

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 areaagent
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/agent/cancel-booking.md

Актор и доступ

ПроверкаЗначение
Actor typeUser — ответственный агент сделки, руководитель агентства или Менеджер ПИН
Auth policy / Permissionscrm.bookings.manage (агентская сторона); Менеджер ПИН — platform-scope
Scope (RBAC)организация (агентство): record-level по OriginAgencyCompanyId сделки-владельца
Record-levelDeal.OriginAgencyCompanyId = actor.CompanyId; рядовой агент дополнительно — активный участник (обычно Executor: Deal.ExecutorAgentUserId = actor.UserId); руководитель агентства — любая сделка своего OriginAgencyCompanyId; Менеджер ПИН — обход record-level
Tenant isolationБронь TenantId = DeveloperCompanyId (владелец объекта — застройщик, ADR-0052). Операция агентской стороны идёт с явным предикатом OriginAgencyCompanyId (по загруженной сделке), а не по авто-tenant-фильтру
Auditupdate: переход → Cancelled, поля CancelReason, CancelledAt, UpdatedByUserId, Version; бизнес-факт — строка таймлайна сделки
Доступ

Бронь чужого агентства не раскрывается: и отсутствие записи, и отсутствие прав на неё возвращают одинаковый 404 CRM_BOOKING_NOT_FOUND. Менеджер ПИН — единственный актор, обходящий record-level по OriginAgencyCompanyId.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/agent/crm/bookings/{bookingId}/cancel
Success status204 (No Content)
Idempotency headerне применяется — повтор ловится проверкой состояния: вторая отмена уже терминальной брони → CRM_BOOKING_ALREADY_TERMINAL
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь сделки своего агентства
CancelReasonbodystringДане пустое после trim, 1..1000free-text: в логах — не выводить целиком
ActorUserIdidentity claimsGuidДаиз JWT
ActorAgencyCompanyIdidentity claimsGuidДаAgencyCompanyId актора — record-level предикат по сделке
IsAgencyHeadidentity claimsboolДафлаг «руководитель агентства» (доступ на всех агентов своего AgencyCompanyId)
IsPinManageridentity claimsboolДаplatform-scope Менеджера ПИН (обход record-level)

Версия строки клиентом не передаётся — гонка с worker-ом истечения решается сервером оптимистически.

Модель ответа

Тела нет: HTTP 204 No Content. Актуальное состояние брони агент перечитывает методом карточки сделки/брони (соседний документ area agent).

Алгоритм

  1. Контекст и доступ. Операцию выполняет ответственный агент / руководитель агентства / Менеджер ПИН (permission crm.bookings.manage).
  2. Проверка входа и предусловий.
    • формат: 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).
  3. Что читаем. Бронь (выборка агентской стороны — по явному предикату OriginAgencyCompanyId, у актора нет tenant застройщика) и её сделку (для record-level и полей строки таймлайна). Объект/клиент не загружаются.
  4. Бизнес-правила и переходы. Record-level: если сделка чужого агентства и actor не Менеджер ПИН → CRM_BOOKING_NOT_FOUND (чужое агентство не раскрывается). Право на отмену: Менеджер ПИН, руководитель агентства или ответственный агент (активный Executor: deal.ExecutorAgentUserId == actor); иначе CRM_BOOKING_FORBIDDEN (403). Затем переход → Cancelled (guard Status ∈ {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). Статус сделки командой не меняется.
  5. Что меняется. Бронь → Cancelled + строка таймлайна — в одном согласованном изменении; строка сделки (Status/BookingId) не трогается.
  6. События. Интеграционное 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 (таймлайн).
  7. Результат. HTTP 204 No Content. Если worker booking-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.

Eventual consistency

Объект освобождается асинхронно — до долёта события витрина ещё показывает «Резерв»/«Бронь»; после освобождения объект можно забронировать заново.

Постусловие (отказ). Бронь не изменена, событие не опубликовано.

Побочные эффекты

ТипДетали
Интеграционные событияBookingCancelledEventpin.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_FAILED400BookingId = Guid.Empty или пустой/слишком длинный CancelReason«Укажите причину отмены брони»no retry
CRM_BOOKING_NOT_FOUND404брони нет или она в сделке чужого агентства (record-level скрывает существование)«Бронирование не найдено»no retry
CRM_BOOKING_FORBIDDEN403актор своего агентства, но не ответственный агент и не руководитель/Менеджер ПИН«Недостаточно прав для отмены брони»no retry
CRM_BOOKING_ALREADY_TERMINAL409Status терминальный (Completed/ReserveExpired/BookingExpired/Cancelled, INV-BKG-8)«Бронь уже завершена и не может быть отменена»no retry
CRM_BOOKING_CONFLICT409гонка: 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; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path. Given активная бронь (Reserved/AwaitingPayment/Booked) и непустая причина; When cancel; Then 204, Cancelled, опубликован booking-cancelled, сделка осталась Booking.
  • Пустая причина. Given CancelReason пустой или > 1000; When cancel; Then 400 VALIDATION_FAILED.
  • Терминальная бронь. Given Status ∈ {Completed, ReserveExpired, BookingExpired, Cancelled} (INV-BKG-8); When cancel; Then 409 CRM_BOOKING_ALREADY_TERMINAL.
  • Гонка с worker. Given worker booking-expiration истёк бронь параллельно; When cancel; Then 409 CRM_BOOKING_CONFLICT (retry); при перечитке бронь терминальна, объект уже освобождён booking-expired.
  • Чужое агентство. Given бронь в сделке чужого агентства; When cancel; Then 404 CRM_BOOKING_NOT_FOUND (существование скрыто).
  • Своё агентство, не ответственный. Given актор не ответственный агент и не руководитель/ПИН; When cancel; Then 403 CRM_BOOKING_FORBIDDEN.

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

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

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