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 area | developer |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/extend-booking.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId) |
| Auth policy / Permissions | crm.bookings.decide (решение застройщика: договор/оплата/продление) |
| Scope (RBAC) | организация (застройщик): менеджер оперирует бронями своего TenantId |
| Record-level | BookingEntity.TenantId == actor.DeveloperCompanyId (бронь — процесс на объекте застройщика) |
| Tenant isolation | TenantId = DeveloperCompanyId в каждом запросе (ADR-0052) |
| Audit | update (UpdatedByUserId = actor, оптимистическая версия); факт — запись таймлайна сделки |
Permission crm.bookings.decide проверяется record-level предикатом BookingEntity.TenantId == actor.DeveloperCompanyId: продлевать бронь может только менеджер того застройщика, за которым закреплён
объект — доступ агентской стороны и других застройщиков к операции закрыт (CRM_BOOKING_FORBIDDEN).
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/developer/crm/bookings/{bookingId}/extend |
| Success status | 200 (бронь продлена; тело — обновлённая карточка брони) |
| Idempotency header | не применяется — продление не идемпотентно (каждый вызов = +BookingDurationHours, ExtensionsCount += 1) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = DeveloperCompanyId | Tenant isolation |
BookingId | route | Guid | Да | != Guid.Empty; существующая бронь в TenantId (шаг 2) | — |
ActorUserId | identity claims | Guid | Да | JWT менеджера; используется для record-level и PaymentConfirmedByUserId-подобной атрибуции продления в таймлайне | — |
Тело запроса пустое (действие детерминировано: +BookingDurationHours из иммутабельного snapshot
ProgramTerms). Новая длительность/причина не параметризуются — правило фиксировано программой (INV-BKG-6).
Модель ответа BookingCardModel
Проекция агрегата — дом полей booking.md; ниже перечислена только дельта,
значимая для продления. BookingCardModel — та же карточка брони, что отдают list/get брони застройщика
(полный состав — в доме).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | BookingEntity.Id | — | Идентификатор брони. |
DealId | Guid | Да | BookingEntity.DealId | — | Сделка-владелец процесса. |
PropertyObjectId | Guid | Да | BookingEntity.PropertyObjectId | — | Объект (статус в шахматке остаётся «Бронь»). |
Status | string (enum BookingStatus) | Да | BookingEntity.Status | — | После операции — всегда Booked (self-transition). |
BookingExpiresAt | DateTimeOffset | Да | BookingEntity.BookingExpiresAt | — | Новый дедлайн = prev + ProgramTerms.BookingDurationHours. |
ExtensionsCount | int | Да | BookingEntity.ExtensionsCount | — | Инкрементирован на 1. |
Version | long | Да | BookingEntity.Version | — | Оптимистическая версия после апдейта. |
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер застройщика (
User) в своём tenant (TenantId = DeveloperCompanyId); требуется permissioncrm.bookings.decide, иначеCRM_BOOKING_FORBIDDEN. Продление намеренно неидемпотентно — отдельного ключа идемпотентности нет, каждый вызов сдвигает дедлайн. - Проверка входа и предусловий.
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.
- Что читаем. Бронь — с защитой от гонки с фоновым worker-ом
booking-expiration(INV-BKG-5), который в том же цикле может пытаться перевестиBooked → BookingExpired. Длительность продления берётся из иммутабельного snapshotProgramTerms.BookingDurationHours(программа не перечитывается). - Бизнес-правила и переходы (INV-BKG-6).
- guard:
Status == Booked, иначеCRM_BOOKING_INVALID_STATE; BookingExpiresAt = BookingExpiresAt + ProgramTerms.BookingDurationHours(часы);ExtensionsCount += 1; self-transitionBooked → Booked(статус не меняется);- фиксируется append-only запись таймлайна сделки
BookingExtended(deal-timeline-event.md); фиксируется доменный фактBookingExtended(BookingId,DealId,NewExpiresAt = BookingExpiresAt,ExtendedByUserId = ActorUserId) для внутренних подписчиков.
- guard:
- Что меняется. Бронь получает новый
BookingExpiresAt, инкрементированныйExtensionsCount,UpdatedAt/UpdatedByUserId = ActorUserId; в таймлайн сделки добавляется строкаBookingExtended. Конкурентный доступ — по оптимистической версии: если workerbooking-expirationзакоммитил→ BookingExpiredмежду чтением и записью, продление проигрывает гонку и возвращаетсяCRM_BOOKING_INVALID_STATE(бронь уже неBooked). - События. Интеграционных Kafka-событий нет: продление не меняет статус объекта недвижимости (объект
остаётся «Бронь») и не пересекает границу контекста — топика
pin.crm.booking-extendedне существует (см. README, карта событий). Доменный фактBookingExtendedнаполняет таймлайн сделки локально. Фактическое освобождение объекта инициирует только worker событием booking-expired — оно и есть source of truth момента истечения (таймер в проекцииchessboardносит справочный характер). - Согласованность. Обновление брони и запись таймлайна фиксируются в одном согласованном изменении (атомарно; интеграционных событий нет).
- Результат. Обновлённая карточка
BookingCardModel(Status = Booked, новыйBookingExpiresAt,ExtensionsCount) → HTTP200.
Диаграмма
Предусловия и постусловия
Предусловия (должны быть истинны до вызова):
- сделка застройщика находится в фазе брони; бронь существует в
TenantIdактора; - бронь в статусе
Booked(резерв уже подтверждён переходом→ Booked, booking-confirmed.md) и ещё не истекла worker-омbooking-expiration; - snapshot условий
ProgramTermsприсутствует на брони (заполнен при создании); - актор — менеджер того же застройщика с permission
crm.bookings.decide.
Постусловия при успехе:
BookingExpiresAt = prev + ProgramTerms.BookingDurationHours,ExtensionsCount += 1, оптимистическая версия инкрементирована; статус осталсяBooked(self-transitionBooked → Booked);- в таймлайн сделки добавлена строка
BookingExtended; зафиксирован доменный фактBookingExtended(BookingId,DealId,NewExpiresAt,ExtendedByUserId); - интеграционных Kafka-событий не публиковалось; статус объекта недвижимости в проекции
chessboardостаётся «Бронь» (справочный таймер продлевается только при следующей публикации от worker-а); - source of truth момента истечения — по-прежнему worker (событие booking-expired.md), теперь с более поздним дедлайном.
Постусловия при отказе: состояние брони не изменилось (дедлайн, ExtensionsCount, статус — прежние);
таймлайн и факты не пополнялись.
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (продление kafka-событий не публикует; объект недвижимости остаётся «Бронь») |
| External calls | none |
| 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_FAILED | 400 | BookingId = Guid.Empty | «Некорректный идентификатор брони» | no retry |
CRM_BOOKING_NOT_FOUND | 404 | бронь не найдена в TenantId | «Бронь не найдена» | no retry |
CRM_BOOKING_FORBIDDEN | 403 | чужой tenant / нет crm.bookings.decide | «Нет доступа к брони» | no retry |
CRM_BOOKING_INVALID_STATE | 409 | Status != Booked (резерв не подтверждён, бронь терминальна, либо worker уже истёк её — гонка RowVersion) | «Бронь нельзя продлить в текущем статусе» | no retry (истёкшую бронь пересоздают заново) |
Совместимость и наблюдаемость
- Route/тело стабильны; добавление optional-полей в
BookingCardModel— additive (без breaking); удаление/ переименование/смена типа — breaking (migration + rollout). EnumBookingStatus— строкой; 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; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Acceptance criteria
- Given активная бронь в статусе
Bookedс дедлайномT, When менеджер застройщика вызывает продление, ThenBookingExpiresAt = T + BookingDurationHours,ExtensionsCount += 1, статус осталсяBooked, ответ200с обновлённойBookingCardModel. - Given бронь
Booked, When продление вызвано дважды подряд, Then дедлайн сдвинут на2 × BookingDurationHours, аExtensionsCountувеличен на 2 (операция намеренно неидемпотентна — отдельного ключа идемпотентности нет). - Given бронь в статусе
Reserved/AwaitingPayment(резерв ещё не подтверждён), When продление, Then409 CRM_BOOKING_INVALID_STATE, состояние не изменилось. - Given бронь в терминальном/истёкшем статусе (
Completed/ReserveExpired/BookingExpired/Cancelled), When продление, Then409 CRM_BOOKING_INVALID_STATE. - Given worker
booking-expirationперевёл броньBooked → BookingExpiredмежду чтением и записью, When продление пытается закоммититься, Then оно проигрывает гонку по версии и возвращает409 CRM_BOOKING_INVALID_STATE(бронь уже неBooked). - Given бронь принадлежит другому застройщику или актор без
crm.bookings.decide, When продление, Then403 CRM_BOOKING_FORBIDDEN(несуществующая в tenant —404 CRM_BOOKING_NOT_FOUND). - Given пустой
BookingId, When продление, Then400 VALIDATION_FAILED.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-BKG-003. Истечение резерва/брони и продление
- Саги (1): Saga «Booking» (Резерв → Бронь → передача в продажу)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность и инварианты: 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.