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

POST /api/developer/crm/bookings/{bookingId}/extend — Продлить бронь

Менеджер застройщика продлевает активную бронь (Booked), сдвигая дедлайн BookingExpiresAt на ещё одну длительность программы. Закрывает сценарий из карточки чата «время брони истекло — подтвердите или продлите» (решает менеджер застройщика), которую messenger показывает по событию booking-expired, и реализует инвариант INV-BKG-6 (booking.md): продлевать может только менеджер TenantId и только пока бронь ещё Booked (не истекла worker-ом). Дом полей и переходов — агрегат BookingEntity.

Назначение

Продлить срок фазы «Бронь» объекта за сделкой застройщика: BookingExpiresAt += ProgramTerms. BookingDurationHours, ExtensionsCount += 1; статус остаётся Booked.

Инвариант

INV-BKG-6: продлевать бронь может только менеджер застройщика (TenantId = DeveloperCompanyId) и только пока бронь ещё Booked — резерв уже подтверждён, но worker booking-expiration её не истёк. Операция намеренно неидемпотентна: каждый вызов добавляет ProgramTerms.BookingDurationHours к дедлайну и увеличивает ExtensionsCount на 1 (отдельного ключа идемпотентности нет).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/developer/extend-booking.md

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

ПроверкаЗначение
Actor typeUser (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId)
Auth policy / Permissionscrm.bookings.decide (решение застройщика: договор/оплата/продление)
Scope (RBAC)организация (застройщик): менеджер оперирует бронями своего TenantId
Record-levelBookingEntity.TenantId == actor.DeveloperCompanyId (бронь — процесс на объекте застройщика)
Tenant isolationTenantId = DeveloperCompanyId в каждом запросе (ADR-0052)
Auditupdate (UpdatedByUserId = actor, оптимистическая версия); факт — запись таймлайна сделки
Доступ

Permission crm.bookings.decide проверяется record-level предикатом BookingEntity.TenantId == actor.DeveloperCompanyId: продлевать бронь может только менеджер того застройщика, за которым закреплён объект — доступ агентской стороны и других застройщиков к операции закрыт (CRM_BOOKING_FORBIDDEN).

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/bookings/{bookingId}/extend
Success status200 (бронь продлена; тело — обновлённая карточка брони)
Idempotency headerне применяется — продление не идемпотентно (каждый вызов = +BookingDurationHours, ExtensionsCount += 1)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь в TenantId (шаг 2)
ActorUserIdidentity claimsGuidДаJWT менеджера; используется для record-level и PaymentConfirmedByUserId-подобной атрибуции продления в таймлайне

Тело запроса пустое (действие детерминировано: +BookingDurationHours из иммутабельного snapshot ProgramTerms). Новая длительность/причина не параметризуются — правило фиксировано программой (INV-BKG-6).

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

Проекция агрегата — дом полей booking.md; ниже перечислена только дельта, значимая для продления. BookingCardModel — та же карточка брони, что отдают list/get брони застройщика (полный состав — в доме).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаBookingEntity.IdИдентификатор брони.
DealIdGuidДаBookingEntity.DealIdСделка-владелец процесса.
PropertyObjectIdGuidДаBookingEntity.PropertyObjectIdОбъект (статус в шахматке остаётся «Бронь»).
Statusstring (enum BookingStatus)ДаBookingEntity.StatusПосле операции — всегда Booked (self-transition).
BookingExpiresAtDateTimeOffsetДаBookingEntity.BookingExpiresAtНовый дедлайн = prev + ProgramTerms.BookingDurationHours.
ExtensionsCountintДаBookingEntity.ExtensionsCountИнкрементирован на 1.
VersionlongДаBookingEntity.VersionОптимистическая версия после апдейта.

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер застройщика (User) в своём tenant (TenantId = DeveloperCompanyId); требуется permission crm.bookings.decide, иначе CRM_BOOKING_FORBIDDEN. Продление намеренно неидемпотентно — отдельного ключа идемпотентности нет, каждый вызов сдвигает дедлайн.
  2. Проверка входа и предусловий.
    • BookingId != Guid.Empty — иначе VALIDATION_FAILED;
    • бронь существует в tenant застройщика — иначе CRM_BOOKING_NOT_FOUND;
    • допустимость состояния: Status == Booked; любой другой (Reserved/AwaitingPayment/Completed/ ReserveExpired/BookingExpired/Cancelled) → CRM_BOOKING_INVALID_STATE — резерв ещё не подтверждён либо бронь уже терминальна/истекла worker-ом (INV-BKG-6, INV-BKG-8);
    • принадлежность/scope: бронь в TenantId актора (застройщик оперирует своими бронями) — иначе CRM_BOOKING_FORBIDDEN.
  3. Что читаем. Бронь — с защитой от гонки с фоновым worker-ом booking-expiration (INV-BKG-5), который в том же цикле может пытаться перевести Booked → BookingExpired. Длительность продления берётся из иммутабельного snapshot ProgramTerms.BookingDurationHours (программа не перечитывается).
  4. Бизнес-правила и переходы (INV-BKG-6).
    • guard: Status == Booked, иначе CRM_BOOKING_INVALID_STATE;
    • BookingExpiresAt = BookingExpiresAt + ProgramTerms.BookingDurationHours (часы); ExtensionsCount += 1; self-transition Booked → Booked (статус не меняется);
    • фиксируется append-only запись таймлайна сделки BookingExtended (deal-timeline-event.md); фиксируется доменный факт BookingExtended (BookingId, DealId, NewExpiresAt = BookingExpiresAt, ExtendedByUserId = ActorUserId) для внутренних подписчиков.
  5. Что меняется. Бронь получает новый BookingExpiresAt, инкрементированный ExtensionsCount, UpdatedAt/UpdatedByUserId = ActorUserId; в таймлайн сделки добавляется строка BookingExtended. Конкурентный доступ — по оптимистической версии: если worker booking-expiration закоммитил → BookingExpired между чтением и записью, продление проигрывает гонку и возвращается CRM_BOOKING_INVALID_STATE (бронь уже не Booked).
  6. События. Интеграционных Kafka-событий нет: продление не меняет статус объекта недвижимости (объект остаётся «Бронь») и не пересекает границу контекста — топика pin.crm.booking-extended не существует (см. README, карта событий). Доменный факт BookingExtended наполняет таймлайн сделки локально. Фактическое освобождение объекта инициирует только worker событием booking-expired — оно и есть source of truth момента истечения (таймер в проекции chessboard носит справочный характер).
  7. Согласованность. Обновление брони и запись таймлайна фиксируются в одном согласованном изменении (атомарно; интеграционных событий нет).
  8. Результат. Обновлённая карточка BookingCardModel (Status = Booked, новый BookingExpiresAt, ExtensionsCount) → HTTP 200.

Диаграмма

Предусловия и постусловия

Предусловия (должны быть истинны до вызова):

  • сделка застройщика находится в фазе брони; бронь существует в TenantId актора;
  • бронь в статусе Booked (резерв уже подтверждён переходом → Booked, booking-confirmed.md) и ещё не истекла worker-ом booking-expiration;
  • snapshot условий ProgramTerms присутствует на брони (заполнен при создании);
  • актор — менеджер того же застройщика с permission crm.bookings.decide.

Постусловия при успехе:

  • BookingExpiresAt = prev + ProgramTerms.BookingDurationHours, ExtensionsCount += 1, оптимистическая версия инкрементирована; статус остался Booked (self-transition Booked → Booked);
  • в таймлайн сделки добавлена строка BookingExtended; зафиксирован доменный факт BookingExtended (BookingId, DealId, NewExpiresAt, ExtendedByUserId);
  • интеграционных Kafka-событий не публиковалось; статус объекта недвижимости в проекции chessboard остаётся «Бронь» (справочный таймер продлевается только при следующей публикации от worker-а);
  • source of truth момента истечения — по-прежнему worker (событие booking-expired.md), теперь с более поздним дедлайном.

Постусловия при отказе: состояние брони не изменилось (дедлайн, ExtensionsCount, статус — прежние); таймлайн и факты не пополнялись.

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

ТипДетали
Integration eventsnone (продление kafka-событий не публикует; объект недвижимости остаётся «Бронь»)
External callsnone
Cache / projections / searchзапись таймлайна сделки (BookingExtended) → карточка сделки показывает новый дедлайн брони
Notificationsопосредованно: доменное BookingExtended может уведомить участников сделки через внутренних подписчиков; отдельного kafka-канала нет

Ошибки и failure modes

Ошибки

Единственная содержательная (не формат-валидационная) ошибка — CRM_BOOKING_INVALID_STATE: она покрывает и «резерв ещё не подтверждён» (Reserved/AwaitingPayment), и «бронь уже терминальна» (Completed/ReserveExpired/BookingExpired/Cancelled), и проигранную гонку с worker-ом booking-expiration, который успел перевести бронь в BookingExpired между чтением и записью (конкуренция по версии) — во всех случаях бронь нужно пересоздавать заново, а не продлевать.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400BookingId = Guid.Empty«Некорректный идентификатор брони»no retry
CRM_BOOKING_NOT_FOUND404бронь не найдена в TenantId«Бронь не найдена»no retry
CRM_BOOKING_FORBIDDEN403чужой tenant / нет crm.bookings.decide«Нет доступа к брони»no retry
CRM_BOOKING_INVALID_STATE409Status != Booked (резерв не подтверждён, бронь терминальна, либо worker уже истёк её — гонка RowVersion)«Бронь нельзя продлить в текущем статусе»no retry (истёкшую бронь пересоздают заново)

Совместимость и наблюдаемость

  • Route/тело стабильны; добавление optional-полей в BookingCardModel — additive (без breaking); удаление/ переименование/смена типа — breaking (migration + rollout). Enum BookingStatus — строкой; unknown-значение на входе не встречается (поле выходное).
  • Logs: booking-extend requested/succeeded/invalid-state (BookingId, DealId, TenantId, ExtensionsCount; PII не пишется); metrics: crm_booking_extend_total{result}, crm_booking_extensions_count (гистограмма); traces — OTel, correlation через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

Acceptance criteria

  • Given активная бронь в статусе Booked с дедлайном T, When менеджер застройщика вызывает продление, Then BookingExpiresAt = T + BookingDurationHours, ExtensionsCount += 1, статус остался Booked, ответ 200 с обновлённой BookingCardModel.
  • Given бронь Booked, When продление вызвано дважды подряд, Then дедлайн сдвинут на 2 × BookingDurationHours, а ExtensionsCount увеличен на 2 (операция намеренно неидемпотентна — отдельного ключа идемпотентности нет).
  • Given бронь в статусе Reserved/AwaitingPayment (резерв ещё не подтверждён), When продление, Then 409 CRM_BOOKING_INVALID_STATE, состояние не изменилось.
  • Given бронь в терминальном/истёкшем статусе (Completed/ReserveExpired/BookingExpired/Cancelled), When продление, Then 409 CRM_BOOKING_INVALID_STATE.
  • Given worker booking-expiration перевёл бронь Booked → BookingExpired между чтением и записью, When продление пытается закоммититься, Then оно проигрывает гонку по версии и возвращает 409 CRM_BOOKING_INVALID_STATE (бронь уже не Booked).
  • Given бронь принадлежит другому застройщику или актор без crm.bookings.decide, When продление, Then 403 CRM_BOOKING_FORBIDDEN (несуществующая в tenant — 404 CRM_BOOKING_NOT_FOUND).
  • Given пустой BookingId, When продление, Then 400 VALIDATION_FAILED.

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

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

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

  • Сущность и инварианты: booking.md (INV-BKG-6 Extend, INV-BKG-5 истечение worker-ом, INV-BKG-8 терминальность, RowVersion-гонка); таймлайн: deal-timeline-event.md; родительский процесс: deal.md; условия/длительности: booking-program.md.
  • События агрегата: booking-expired.md (источник сценария «подтвердите/ продлите бронь»), booking-confirmed.md (переход → Booked, с которого начинается фаза брони); карта событий — ../../README.md.
  • Статус объекта недвижимости — docs/06-services/chessboard/ (консюмер booking-событий, будущий путь по конвенции).
  • ADR: 0052 tenancy, 0056 simplicity-first.