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

UC-BKG-003. Истечение резерва/брони и продление

Назначение

Фоновый worker автоматически переводит просроченные резервы/брони в терминальный статус по истечении таймера — основной поток; альтернатива — менеджер застройщика продлевает активную бронь до истечения, сдвигая дедлайн. Worker публикует BookingExpiredEvent (объект недвижимости → «Свободен», карточка в чат сделки «подтвердите/продлите»). Дом полей и переходов — booking.md (INV-BKG-5/6/8); алгоритм worker-а — background/booking-expiration.md; продление — endpoint developer/extend-booking.md.

Metadata

ПолеЗначение
IDUC-BKG-003
Контекстcrm (см. docs/06-services/crm/)
Типосновной (истечение) + альтернативный (продление)
Статусdraft

Актор

РольPermissions / scopeTenantRecord-level
System (worker booking-expiration)system-actor (AccessKind.System, cross-tenant, ADR-0052 п.3)не применяется (scan всех застройщиков)не применяется
Менеджер застройщика (продление, A1)crm.bookings.decide (договор/оплата/продление)TenantId = DeveloperCompanyIdBookingEntity.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; ниже — бизнес-суть.

  1. Триггер и контекст. Фоновый worker с периодом 1 мин формирует задачу истечения (System-actor, cross-tenant; момент проверки AsOf — текущее UTC-время).
  2. Выборка кандидатов. Две фазы по partial-индексам, сортировка по дедлайну по возрастанию:
    • фаза Reserve: Status ∈ {Reserved, AwaitingPayment} && ReserveExpiresAt < AsOf (ix_bookings_reserve_expires_at);
    • фаза Booking: Status == Booked && BookingExpiresAt < AsOf (ix_bookings_booking_expires_at). Строки под in-flight командой менеджера или другого инстанса пропускаются без ожидания (залочены).
  3. Переход (INV-BKG-5). Для каждой брони: Reserved/AwaitingPayment → ReserveExpired (Phase = Reserve) либо Booked → BookingExpired (Phase = Booking); маппинг Status ← целевой терминальный, ExpiredAt ← AsOf. Идемпотентно: терминальные статусы в выборку не попадают (INV-BKG-8) → повтор цикла безопасен.
  4. Таймлайн. Событие BookingExpired → подписчик пишет DealTimelineEventEntity: тип BookingReserveExpired (фаза Reserve) либо BookingExpired (фаза Booking), payload TimelineBookingRefPayload { BookingId } (append-only факт, дом deal-timeline-event.md).
  5. Outbox. BookingExpiredEvent (ContractVersion = 1, топик pin.crm.booking-expired.v1, AggregateId = BookingId, AggregateType = "Booking"; payload BookingId/TenantId/DealId/PropertyObjectId/Phase/ExpiredAt, PII не несёт) — в outbox; контракт — booking-expired.md.
  6. Согласованность. Переходы батча + записи таймлайна + outbox фиксируются атомарно (граница — per batch); блокировки строк держатся до коммита и освобождаются им. Частичный отказ: poison-строка (неожиданный статус вопреки предикату выборки) исключается из батча до коммита — здоровые переходы батча всё равно коммитятся, а проблемная строка переносится на следующий цикл (ERROR-лог с BookingId); при систематике — алерт/runbook. Так один сбойный агрегат не блокирует истечение остальных.
  7. Результат. Метрики цикла { ReserveExpiredCount, BookingExpiredCount, SkippedLockedCount, DurationMs } — логи/метрики, не HTTP. После коммита событие публикуется.
  8. Downstream. Консюмеры pin.crm.booking-expired.v1: chessboard (объект недвижимости PropertyObjectId → «Свободен»), messenger (карточка в чат сделки по Phase — «время резерва истекло, подтвердите бронь» / «бронь истекла — подтвердите/продлите»), notifications (уведомление участникам сделки).

Диаграмма

Альтернативные Потоки

A1. Менеджер продлевает бронь до истечения (INV-BKG-6, extend-booking)

  1. Триггер — POST /api/developer/crm/bookings/{bookingId}/extend; actor — Менеджер застройщика (crm.bookings.decide). Дом endpoint-а — developer/extend-booking.md.
  2. Бронь загружается со строчной блокировкой (защита от гонки с worker-ом); guard INV-BKG-6: только Status == Booked, иначе CRM_BOOKING_INVALID_STATE.
  3. Продление: BookingExpiresAt += BookingDurationHours, ExtensionsCount += 1, self-transition Booked → Booked; длительность — из иммутабельного snapshot ProgramTerms (программа не перечитывается).
  4. Таймлайн DealTimelineEventEntity(BookingExtended) (TimelineBookingExtendedPayload { BookingId, NewBookingExpiresAt }); событие BookingExtended. Kafka-события нет (объект недвижимости остаётся «Бронь», границу контекста продление не пересекает).
  5. Бронь обновляется. Бронь не истекает: в текущем цикле worker её пропускает (залочена), а с новым дедлайном она вне выборки. Завершается вне основного потока.

A2. Гонка worker ↔ менеджер (Extend/ConfirmBooking)

Ровно один исход
  1. Строка под командой менеджера залочена → worker пропускает её в этом цикле.
  2. Если менеджер закоммитил между чтением и записью worker-а — конфликт оптимистичной блокировки; проигравший ретраится с перечиткой. Продлённая/подтверждённая бронь уже вне выборки worker-а → истечения не происходит. Разрешается ровно один исход.

A3. Новая попытка брони после истечения

Новый агрегат, а не реанимация старого
  1. Терминальный статус (ReserveExpired/BookingExpired) иммутабелен (INV-BKG-8) — старый агрегат не реанимируется.
  2. Уникальные индексы активной брони 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_STATE409Продление вне Booked (резерв не подтверждён, бронь терминальна, либо worker уже истёк её — гонка)«Бронь нельзя продлить»; истёкшую пересоздают заново (A3)
CRM_BOOKING_NOT_FOUND404Бронь не найдена в TenantId (продление)«Бронь не найдена»
CRM_BOOKING_FORBIDDEN403Чужой tenant / нет crm.bookings.decide«Нет доступа к брони»
poison-строка (worker)— (internal)Доменный guard при неожиданном статусе (не должно случаться при корректном предикате)Исключается из батча, ERROR-лог с BookingId, ретрай следующим циклом; при систематике — алерт/runbook

Доменные События

СобытиеКогда поднимаетсяSubscribers
BookingExpiredworker-переход → ReserveExpired/→ BookingExpired (INV-BKG-5)подписчик таймлайна (BookingReserveExpired/BookingExpired) → сборка BookingExpiredEvent
BookingExtendedПродление (A1, INV-BKG-6)подписчик таймлайна (BookingExtended); уведомление участников сделки

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
BookingExpiredEventpin.crm.booking-expired.v1worker-переход (Phase = Reserve|Booking), в тот же коммит через outboxchessboard (объект недвижимости → «Свободен»), messenger (карточка «подтвердите/продлите»), notifications
Продление не публикует Kafka-событие

Продление (A1) Kafka-событий не публикует: статус объекта недвижимости не меняется (остаётся «Бронь»), топика pin.crm.booking-extended не существует.

Контракт/payload и правила идемпотентности консюмеров — booking-expired.md (PII осознанно исключён).

Acceptance Criteria

#КритерийПроверка
AC1Резерв истёк: Reserved/AwaitingPayment при now > ReserveExpiresAtReserveExpired, ExpiredAt = now, событие Phase = Reserve, таймлайн BookingReserveExpiredТест worker-а: одна бронь фазы Reserve → проверить статус, ExpiredAt, outbox, строку таймлайна
AC2Бронь истекла: Booked при now > BookingExpiresAtBookingExpired, событие 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); старый терминальный неизмененТест: создать бронь на тот же объект недвижимости после истечения
AC8booking-expired несёт только BookingId/TenantId/DealId/PropertyObjectId/Phase/ExpiredAt — без PIIКонтракт-тест payload события

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

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

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