UC-BKG-003. Истечение резерва/брони и продление
Назначение
Фоновый worker автоматически переводит просроченные резервы/брони в терминальный статус по истечении
таймера — основной поток; альтернатива — менеджер застройщика продлевает активную бронь до истечения,
сдвигая дедлайн. Worker публикует BookingExpiredEvent (объект недвижимости
→ «Свободен», карточка в чат сделки «подтвердите/продлите»). Дом полей и переходов —
booking.md (INV-BKG-5/6/8); алгоритм worker-а —
background/booking-expiration.md; продление — endpoint
developer/extend-booking.md.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-BKG-003 |
| Контекст | crm (см. docs/06-services/crm/) |
| Тип | основной (истечение) + альтернативный (продление) |
| Статус | draft |
Актор
| Роль | Permissions / scope | Tenant | Record-level |
|---|---|---|---|
System (worker booking-expiration) | system-actor (AccessKind.System, cross-tenant, ADR-0052 п.3) | не применяется (scan всех застройщиков) | не применяется |
| Менеджер застройщика (продление, A1) | crm.bookings.decide (договор/оплата/продление) | TenantId = DeveloperCompanyId | BookingEntity.TenantId == actor.DeveloperCompanyId |
Предусловие
- Существует бронь
BookingEntityв активном статусе:Reserved/AwaitingPayment(истекаетReserveExpiresAt) либоBooked(истекаетBookingExpiresAt). - Таймеры выставлены при создании/подтверждении:
ReserveExpiresAt = CreatedAt + ProgramTerms.ReserveDurationMinutes;BookingExpiresAt = BookedAt + ProgramTerms.BookingDurationHours(snapshot условий программы). - Для продления (A1): бронь ещё
Booked(не истекла worker-ом), актор — менеджер того жеTenantId.
Триггер
| Поле | Значение |
|---|---|
| Источник | Фоновый worker (период 1 мин) — основной поток; вызов API — альтернатива A1 |
| Триггерящий элемент | worker booking-expiration (System); POST /api/developer/crm/bookings/{bookingId}/extend (менеджер) |
Основной Поток
Основная нить — истечение worker-ом. Полный алгоритм — background/booking-expiration.md; ниже — бизнес-суть.
- Триггер и контекст. Фоновый worker с периодом 1 мин формирует задачу истечения (System-actor,
cross-tenant; момент проверки
AsOf— текущее UTC-время). - Выборка кандидатов. Две фазы по partial-индексам, сортировка по дедлайну по возрастанию:
- фаза
Reserve:Status ∈ {Reserved, AwaitingPayment} && ReserveExpiresAt < AsOf(ix_bookings_reserve_expires_at); - фаза
Booking:Status == Booked && BookingExpiresAt < AsOf(ix_bookings_booking_expires_at). Строки под in-flight командой менеджера или другого инстанса пропускаются без ожидания (залочены).
- фаза
- Переход (INV-BKG-5). Для каждой брони:
Reserved/AwaitingPayment → ReserveExpired(Phase = Reserve) либоBooked → BookingExpired(Phase = Booking); маппингStatus ← целевой терминальный,ExpiredAt ← AsOf. Идемпотентно: терминальные статусы в выборку не попадают (INV-BKG-8) → повтор цикла безопасен. - Таймлайн. Событие
BookingExpired→ подписчик пишетDealTimelineEventEntity: типBookingReserveExpired(фаза Reserve) либоBookingExpired(фаза Booking), payloadTimelineBookingRefPayload { BookingId }(append-only факт, дом deal-timeline-event.md). - Outbox.
BookingExpiredEvent(ContractVersion = 1, топикpin.crm.booking-expired.v1,AggregateId = BookingId,AggregateType = "Booking"; payloadBookingId/TenantId/DealId/PropertyObjectId/Phase/ExpiredAt, PII не несёт) — в outbox; контракт — booking-expired.md. - Согласованность. Переходы батча + записи таймлайна + outbox фиксируются атомарно (граница — per
batch); блокировки строк держатся до коммита и освобождаются им. Частичный отказ: poison-строка (неожиданный
статус вопреки предикату выборки) исключается из батча до коммита — здоровые переходы батча всё равно
коммитятся, а проблемная строка переносится на следующий цикл (ERROR-лог с
BookingId); при систематике — алерт/runbook. Так один сбойный агрегат не блокирует истечение остальных. - Результат. Метрики цикла
{ ReserveExpiredCount, BookingExpiredCount, SkippedLockedCount, DurationMs }— логи/метрики, не HTTP. После коммита событие публикуется. - Downstream. Консюмеры
pin.crm.booking-expired.v1:chessboard(объект недвижимостиPropertyObjectId → «Свободен»),messenger(карточка в чат сделки поPhase— «время резерва истекло, подтвердите бронь» / «бронь истекла — подтвердите/продлите»),notifications(уведомление участникам сделки).
Диаграмма
Альтернативные Потоки
A1. Менеджер продлевает бронь до истечения (INV-BKG-6, extend-booking)
- Триггер —
POST /api/developer/crm/bookings/{bookingId}/extend; actor — Менеджер застройщика (crm.bookings.decide). Дом endpoint-а — developer/extend-booking.md. - Бронь загружается со строчной блокировкой (защита от гонки с worker-ом); guard INV-BKG-6: только
Status == Booked, иначеCRM_BOOKING_INVALID_STATE. - Продление:
BookingExpiresAt += BookingDurationHours,ExtensionsCount += 1, self-transitionBooked → Booked; длительность — из иммутабельного snapshotProgramTerms(программа не перечитывается). - Таймлайн
DealTimelineEventEntity(BookingExtended)(TimelineBookingExtendedPayload { BookingId, NewBookingExpiresAt }); событиеBookingExtended. Kafka-события нет (объект недвижимости остаётся «Бронь», границу контекста продление не пересекает). - Бронь обновляется. Бронь не истекает: в текущем цикле worker её пропускает (залочена), а с новым дедлайном она вне выборки. Завершается вне основного потока.
A2. Гонка worker ↔ менеджер (Extend/ConfirmBooking)
- Строка под командой менеджера залочена → worker пропускает её в этом цикле.
- Если менеджер закоммитил между чтением и записью worker-а — конфликт оптимистичной блокировки; проигравший ретраится с перечиткой. Продлённая/подтверждённая бронь уже вне выборки worker-а → истечения не происходит. Разрешается ровно один исход.
A3. Новая попытка брони после истечения
- Терминальный статус (
ReserveExpired/BookingExpired) иммутабелен (INV-BKG-8) — старый агрегат не реанимируется. - Уникальные индексы активной брони
ux_bookings_active_property_object/ux_bookings_active_dealбольше не держат объект недвижимости/сделку → создаётся новый агрегатBookingEntity(новыйBookingId) через agent/create-booking.md / agent/confirm-booking.md — отдельный процесс с собственными таймерами.
Постусловие
- Истечение (основной):
Status = ReserveExpired|BookingExpired,ExpiredAtпроставлен; запись таймлайнаBookingReserveExpired|BookingExpired; опубликованpin.crm.booking-expired.v1(Phase); объект недвижимостиPropertyObjectId → «Свободен»(консюмерchessboard); карточка «подтвердите/продлите» в чате сделки (messenger). - Продление (A1):
Status = Booked,BookingExpiresAtсдвинут наBookingDurationHours,ExtensionsCount += 1; запись таймлайнаBookingExtended; Kafka-события нет; объект недвижимости остаётся «Бронь». - Терминальные статусы иммутабельны; повторная бронь — отдельный агрегат брони.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
CRM_BOOKING_INVALID_STATE | 409 | Продление вне Booked (резерв не подтверждён, бронь терминальна, либо worker уже истёк её — гонка) | «Бронь нельзя продлить»; истёкшую пересоздают заново (A3) |
CRM_BOOKING_NOT_FOUND | 404 | Бронь не найдена в TenantId (продление) | «Бронь не найдена» |
CRM_BOOKING_FORBIDDEN | 403 | Чужой tenant / нет crm.bookings.decide | «Нет доступа к брони» |
| poison-строка (worker) | — (internal) | Доменный guard при неожиданном статусе (не должно случаться при корректном предикате) | Исключается из батча, ERROR-лог с BookingId, ретрай следующим циклом; при систематике — алерт/runbook |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
BookingExpired | worker-переход → ReserveExpired/→ BookingExpired (INV-BKG-5) | подписчик таймлайна (BookingReserveExpired/BookingExpired) → сборка BookingExpiredEvent |
BookingExtended | Продление (A1, INV-BKG-6) | подписчик таймлайна (BookingExtended); уведомление участников сделки |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
BookingExpiredEvent | pin.crm.booking-expired.v1 | worker-переход (Phase = Reserve|Booking), в тот же коммит через outbox | chessboard (объект недвижимости → «Свободен»), messenger (карточка «подтвердите/продлите»), notifications |
Продление (A1) Kafka-событий не публикует: статус объекта недвижимости не меняется (остаётся «Бронь»),
топика pin.crm.booking-extended не существует.
Контракт/payload и правила идемпотентности консюмеров — booking-expired.md (PII осознанно исключён).
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Резерв истёк: Reserved/AwaitingPayment при now > ReserveExpiresAt → ReserveExpired, ExpiredAt = now, событие Phase = Reserve, таймлайн BookingReserveExpired | Тест worker-а: одна бронь фазы Reserve → проверить статус, ExpiredAt, outbox, строку таймлайна |
| AC2 | Бронь истекла: Booked при now > BookingExpiresAt → BookingExpired, событие Phase = Booking, таймлайн BookingExpired, объект недвижимости → «Свободен» | Тест worker-а + консюмер chessboard |
| AC3 | Идемпотентность: повторный цикл на уже истёкшей брони — no-op (терминальный статус вне выборки INV-BKG-8) | Два цикла подряд: одна мутация, одно событие |
| AC4 | Продление до истечения (Extend из Booked): BookingExpiresAt += BookingDurationHours, ExtensionsCount += 1, worker продлённую не истекает | Тест extend-booking + последующий цикл worker-а |
| AC5 | Гонка worker ↔ менеджер: ровно один исход (пропуск залоченной / конфликт оптимистичной блокировки), без двойного события | Конкурентный тест Extend/Expire |
| AC6 | Продление вне Booked (резерв/терминал) → CRM_BOOKING_INVALID_STATE (409) | Тест extend-booking для Reserved/BookingExpired |
| AC7 | Новая попытка после истечения — новый агрегат (новый BookingId); старый терминальный неизменен | Тест: создать бронь на тот же объект недвижимости после истечения |
| AC8 | booking-expired несёт только BookingId/TenantId/DealId/PropertyObjectId/Phase/ExpiredAt — без PII | Контракт-тест payload события |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-BKG-002. Бронирование Резерв → Бронь, UC-BKG-004. Оплата платного бронирования (чек + подтверждение)
- scenarios (2): UC-OVR-003. Бронирование объекта у агента (+3a), UC-OVR-006. Сделка: от брони до регистрации (инвестор-инициатор)
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Domain: booking.md (INV-BKG-5 истечение, INV-BKG-6
Extend, INV-BKG-8 терминальность, stateDiagram), deal-timeline-event.md (типыBookingReserveExpired/BookingExpired/BookingExtended), deal.md. - Background job (дом алгоритма worker-а): booking-expiration.md.
- API (продление / повторная бронь): developer/extend-booking.md, agent/create-booking.md, agent/confirm-booking.md.
- Kafka events: booking-expired.md; соседние —
booking-confirmed.md (переход
→ Booked, старт фазы брони), booking-cancelled.md. - US-реестр / реестр UC: user-stories-deal.md; README контекста.
- ADR: 0056 simplicity-first (polling без Redis, идемпотентность), 0052 tenancy (System cross-tenant scope).