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

Entity / Aggregate: BookingEntity

Дом полей агрегата «Бронирование объекта» контекста crm — модуль бронирования (решение 7 ТЗ: единый flow с двумя точками входа — инвестор с формы «Забронировать» и агент из сделки; US-INV-06/10/21). Заявка на бронирование становится событием ленты лида (lead.md BookingRequested) и этим агрегатом. Все проекции (блок брони в карточке сделки, payload booking-событий) ссылаются сюда и описывают только дельту.

Назначение

BookingEntity — процесс закрепления объекта за сделкой: Резерв (короткая блокировка с таймером) → Бронь (по программе бронирования застройщика booking-program.md, с оплатой для платных программ — US-INV-21) → завершение (переход сделки в продажу) или истечение/отмена. Смена статусов объекта в шахматке (Свободен → Резерв → Бронь → Продан) выполняется консюмером chessboard по интеграционным событиям брони — CRM чужие таблицы не пишет (правила границ, service-boundaries).

Таймеры — параметры программы бронирования (решение 7); истечение обрабатывается фоновым polling-worker-ом (ADR-0056, без TTL-магии), при истечении — сообщение-карточка в чат сделки через событие.

Классификация

ПолеЗначение
Контекстcrm
KindAggregateRoot
Source of truthcrm (процесс брони); статус объекта — chessboard (проекция по событиям)
Таблицаcrm.bookings
Tenant isolationTenantId = DeveloperCompanyId (бронь — процесс на объекте застройщика)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор бронирования.
TenantIdGuidДаindex ix_bookings_tenant_idиз сделкиЗастройщик объекта.
DealIdGuidДаFK fk_bookings_deal_id → crm.deals(id), index; unique partial ux_bookings_active_deal on (DealId) where status in ('Reserved','AwaitingPayment','Booked')appСделка-владелец (deal.md). Одна активная бронь на сделку.
PropertyObjectIdGuidДаindex; unique partial ux_bookings_active_property_object on (PropertyObjectId) where status in ('Reserved','AwaitingPayment','Booked')из сделкиОбъект (INV-BKG-001: один активный процесс брони на объект недвижимости).
ProgramIdGuidДаFK fk_bookings_program_id → crm.booking_programs(id)app (выбор клиента/агента из опций, ТЗ.md:453)Программа бронирования застройщика.
ProgramTermsBookingProgramTerms (owned)Дасм. VO нижеsnapshot из BookingProgramEntity на момент созданияИммутабельный снимок условий (программа могла измениться после). Источник блока «условия бронирования» US-INV-10.
StatusBookingStatusДаenum как text, varchar(32), index ix_bookings_statusapp / workerСм. enum и stateDiagram.
InitiatedByBookingInitiatorДаenum как text, varchar(32)appТочка входа (решение 7): инвестор с витрины или агент из сделки.
RequisiteSetIdGuid?Нетлогическая ссылка на profile (шаблон реквизитов)app (инвестор при брони, ТЗ.md:453)Паспортные данные/реквизиты, выбранные для брони; сам состав данных — контекст profile (docs/06-services/profile/).
ReserveExpiresAtDateTimeOffsetДаindex ix_bookings_reserve_expires_at (worker)app: CreatedAt + ProgramTerms.ReserveDurationMinutesДедлайн резерва (ТЗ.md:469: 1 час по умолчанию).
BookingExpiresAtDateTimeOffset?Нетindex where not null (worker)app: BookedAt + ProgramTerms.BookingDurationДедлайн брони (после подтверждения).
PaymentStatusBookingPaymentStatusДаenum как text, varchar(32), default NotRequiredappОплата брони (US-INV-06/21). Эквайринг отложен (решение 9 ТЗ): подтверждение оплаты — вручную менеджером застройщика по чеку (ТЗ.md:401).
PaymentAmountMoney? (owned)Нет (обязателен при ProgramTerms.IsPaid)контракт VO — deal.md, колонки payment_amount_*snapshot ProgramTerms.PriceСумма платного бронирования.
PaidAtDateTimeOffset?Нетapp (подтверждение менеджером)Дата оплаты (US-INV-06: «Оплачено» + дата).
PaymentConfirmedByUserIdGuid?НетappМенеджер застройщика, подтвердивший оплату.
ContractDocumentIdGuid?НетFK fk_bookings_contract_document_id → crm.deal_documents(id)app (застройщик, ТЗ.md:399)Файл договора бронирования, загруженный застройщиком.
BookedAtDateTimeOffset?НетappМомент подтверждения брони (→ Booked).
CompletedAtDateTimeOffset?НетappБронь конвертирована в продажу (Deal.StartSale).
CancelledAtDateTimeOffset?НетappОтмена.
CancelReasonstring?Нетvarchar(1000); обязателен при Status = Cancelled (check)appПричина отмены.
ExpiredAtDateTimeOffset?НетworkerФактический момент истечения (резерва или брони).
ExtensionsCountintДа>= 0, default 0appСколько раз менеджер продлевал бронь (ТЗ.md:471: «подтвердите/продлите — решает менеджер застройщика»).
CreatedAtDateTimeOffsetДаindex ix_bookings_created_at descappСоздание заявки на бронирование (= старт резерва).
CreatedByUserIdGuid?НетappИнициатор (инвестор/агент/Менеджер ПИН).
UpdatedAtDateTimeOffsetДаappДата последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (административно).
DeletedByUserIdGuid?НетappКто скрыл.

Enum BookingStatus

ЗначениеОписание
ReservedРезерв: объект временно заблокирован (chessboard ставит «Резерв» по событию), идёт отсчёт ReserveExpiresAt; клиент выбирает/заполняет реквизиты, застройщик готовит договор брони.
AwaitingPaymentДоговор брони загружен застройщиком; для платной программы клиент скачивает/подписывает/оплачивает и прикрепляет чек (ТЗ.md:455). Для бесплатной программы статус пропускается.
BookedБронь подтверждена (для платной — после подтверждения оплаты менеджером, ТЗ.md:401); chessboard ставит «Бронь»; отсчёт BookingExpiresAt.
CompletedБронь сыграла: сделка перешла в продажу (Deal.StartSale). Терминальный.
ReserveExpiredРезерв истёк (worker); карточка в чат «время резерва истекло, подтвердите бронь» (ТЗ.md:469). Терминальный (новая попытка — новый агрегат).
BookingExpiredБронь истекла (worker); карточка в чат «подтвердите/продлите бронь» (ТЗ.md:471). Терминальный, либо менеджер успел Extend до истечения.
CancelledОтменена (клиент передумал / смена объекта недвижимости / отказ сделки). Терминальный.

Enum BookingInitiator

ЗначениеОписание
InvestorКнопка «Забронировать» на витрине (UC-3; prototype: форма ФИО+телефон).
AgentАгент из карточки сделки/лида (ТЗ.md:583).
PinManagerМенеджер ПИН вручную (US-MGR).

Enum BookingPaymentStatus

ЗначениеОписание
NotRequiredБесплатная программа.
PendingОжидается оплата/чек («Не оплачено» + кнопка «Оплатить», US-INV-06/21).
PaidОплата подтверждена менеджером застройщика (PaidAt, PaymentConfirmedByUserId).

Value objects

BookingProgramTerms (owned, snapshot программы)

Контракт полей повторяет booking-program.md; префикс колонок program_terms_.

ПолеТипОбязательностьОграниченияКолонка
NamestringДаvarchar(128)program_terms_name
IsPaidboolДаprogram_terms_is_paid
PriceAmountdecimal?Нет (обязателен при IsPaid)numeric(18,2) > 0program_terms_price_amount
PriceCurrencyIdshortДассылка на справочник валют handbook; default — валюта RUB; display-код — из CurrencySnapshot событий / lookup справочника (snapshots.md)program_terms_price_currency_id
ReserveDurationMinutesintДа> 0 (default программы 60)program_terms_reserve_duration_minutes
BookingDurationHoursintДа> 0 (например 4 часа или 240 = 10 дней)program_terms_booking_duration_hours
RefundPolicystring?Нетvarchar(2000)program_terms_refund_policy

Money (owned PaymentAmount) — контракт в deal.md.

Модели JSONB (versioned)

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
DealEntityreference (родитель процесса)DealIdзагрузка при командах (проверка статуса сделки)restrict
BookingProgramEntityreferenceProgramIdзагрузка при создании (snapshot)restrict
DealDocumentEntity (договор брони, чек)referenceContractDocumentId; документы типов BookingContract/SignedBookingContract/BookingPaymentProof c deal_documents.booking_idвыборка по BookingIdrestrict
Объект недвижимости (chessboard)внешняя логическаяPropertyObjectIdстатус меняет консюмер chessboard по событиям

Инварианты и переходы состояний

ИнвариантУсловиеКогда проверяетсяОшибка
INV-BKG-001На PropertyObjectId — не более одного активного процесса брони (Reserved/AwaitingPayment/Booked); на DealId — тоже не более одногопри создании брони (unique partial ux_bookings_active_property_object, ux_bookings_active_deal)CRM_BOOKING_PROPERTY_OBJECT_BUSY / CRM_BOOKING_DEAL_ACTIVE_EXISTS
INV-BKG-2Создание брони — только у сделки Status ∈ {Application, Booking}; сделка переводится в Booking в одном согласованном изменениипри создании брониCRM_DEAL_INVALID_STATE
INV-BKG-3ConfirmBooking (→ Booked): для платной программы — только после PaymentStatus = Paid; для бесплатной — сразу из Reserved до ReserveExpiresAtпри подтверждении брониCRM_BOOKING_PAYMENT_REQUIRED / CRM_BOOKING_RESERVE_EXPIRED
INV-BKG-4ConfirmPayment — только менеджер TenantId, только из AwaitingPayment, при наличии документа-чека (BookingPaymentProof со статусом Uploaded+)при подтверждении оплатыCRM_BOOKING_INVALID_STATE / CRM_BOOKING_PROOF_REQUIRED
INV-BKG-5Истечение — только worker-ом: Reserved/AwaitingPayment при now > ReserveExpiresAtReserveExpired; Booked при now > BookingExpiresAtBookingExpired; идемпотентно (ADR-0056)фоновая задача booking-expiration
INV-BKG-6Extend (продление брони): только Booked, только менеджер TenantId; BookingExpiresAt += ProgramTerms.BookingDurationHours, ExtensionsCount += 1при продлении брониCRM_BOOKING_INVALID_STATE
INV-BKG-7Complete — только из Booked, в одном согласованном изменении с Deal.StartSaleпри старте продажи сделкиCRM_BOOKING_INVALID_STATE
INV-BKG-8Терминальные статусы иммутабельнывсе операции над броньюCRM_BOOKING_ALREADY_TERMINAL

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
BookingCreated / BookingConfirmed / BookingExpired / BookingCancelledдоменноесоответствующие переходывнутренние подписчики (таймлайн сделки)
BookingCreatedEventинтеграционное, topic pin.crm.booking-created.v1, ContractVersion = 1[*] → ReservedBookingId, DealId, TenantId, PropertyObjectId, ReserveExpiresAt, ProgramTerms {Name, IsPaid, PriceAmount?, PriceCurrencyId, PriceCurrency (CurrencySnapshot), ReserveDurationMinutes, BookingDurationHours}, InitiatedBy; консюмеры: chessboard (объект недвижимости → «Резерв» + таймер), catalog, messenger, notifications; контракт — ../events/booking-created.md
BookingPaidEventинтеграционное, topic pin.crm.booking-paid.v1, ContractVersion = 1ConfirmPayment (PaymentStatus → Paid)BookingId, DealId, TenantId, PropertyObjectId, PaymentAmount {Amount, CurrencyId} + Currency (CurrencySnapshot), PaidAt; консюмеры: messenger (карточка «договор оплачен», US-INV-16), notifications, rewards; контракт — ../events/booking-paid.md
BookingConfirmedEventинтеграционное, topic pin.crm.booking-confirmed.v1, ContractVersion = 1→ BookedBookingId, DealId, TenantId, PropertyObjectId, BookingExpiresAt; консюмеры: chessboard (объект недвижимости → «Бронь» + таймер), catalog, notifications
BookingExpiredEventинтеграционное, topic pin.crm.booking-expired.v1, ContractVersion = 1→ ReserveExpired / → BookingExpired (worker)BookingId, DealId, TenantId, PropertyObjectId, Phase (Reserve/Booking), ExpiredAt; консюмеры: chessboard (объект недвижимости → «Свободен»), messenger (карточка «время истекло, подтвердите/продлите», ТЗ.md:469/471), notifications; контракт — ../events/booking-expired.md
BookingCancelledEventинтеграционное, topic pin.crm.booking-cancelled.v1, ContractVersion = 1→ CancelledBookingId, DealId, TenantId, PropertyObjectId, CancelReason; консюмеры: chessboard (объект недвижимости → «Свободен»), notifications

Интеграционные события публикуются атомарно со сменой состояния агрегата.

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ix_bookings_tenant_id; ix_bookings_deal_id; ux_bookings_active_deal, ux_bookings_active_property_object unique partial; ix_bookings_status; partial btree (reserve_expires_at) where status in ('Reserved','AwaitingPayment') и (booking_expires_at) where status = 'Booked' — выборки worker-а; ix_bookings_created_at desc
Партиционирование / retentionnone (ADR-0056)
Concurrencyоптимистическая блокировка: гонка worker ↔ менеджер (ConfirmBooking vs Expire) — проигравший повторяет с перечиткой
Permissions / PIIcrm.bookings.view (участники сделки), crm.bookings.manage (агент/инвестор — создание, US-INV-21 оплата), crm.bookings.decide (менеджер застройщика: договор/оплата/продление). Record-level и tenant-правила — как у сделки (deal.md). RequisiteSetId — только ссылка; PII реквизитов живёт в profile (шифрование там).
Auditотметки создания/изменения/удаления (кто и когда); фазовые метки (BookedAt,PaidAt,ExpiredAt…) — иммутабельные факты; полная история — таймлайн сделки

Межсервисные последовательности и промежуточные состояния

Бронь — источник истины процесса (crm); статусы объекта недвижимости в шахматке — проекция, которую по booking-событиям ведёт консюмер chessboard (CRM чужие таблицы не пишет). Все цепочки — асинхронные, at-least-once; порядок событий одной брони гарантирован ключом партиционирования BookingId.

Старт брони

В одном согласованном изменении фиксируются: запись брони Reserved, перевод сделки в Booking, строка таймлайна BookingRequested, исходящее BookingCreatedEvent. Далее асинхронно: chessboard → объект недвижимости «Резерв» + таймер до ReserveExpiresAt; messenger → карточка брони в чат сделки; notifications → уведомление участникам.

:::info Окно eventual-consistency

Между коммитом в crm и обработкой события консюмером chessboard витрина/шахматка ещё показывают объект недвижимости «Свободен» (обычно < 1 мин). Это не допускает двойного бронирования: единственность держится на источнике (crm, INV-BKG-001, unique partial ux_bookings_active_property_object), а не на проекции шахматки — вторая попытка в этом окне отклоняется сразу с CRM_BOOKING_PROPERTY_OBJECT_BUSY.

Что видит пользователь: в карточке сделки бронь и таймлайн-событие появляются сразу (данные crm); карточка в чате и смена цвета объекта недвижимости в шахматке — с задержкой до обработки события.

:::

Подтверждение/оплата

ConfirmPaymentBookingPaidEvent (→ messenger карточка «договор оплачен», US-INV-16; notifications; rewards); ConfirmBookingBookingConfirmedEvent (→ chessboard объект недвижимости «Бронь» + таймер BookingExpiresAt; catalog; notifications). Таймлайн-строки BookingPaid/BookingConfirmed пишутся в тех же согласованных изменениях, поэтому в карточке сделки они видны раньше, чем downstream-эффекты консюмеров.

Истечение/отмена

BookingExpiredEvent (worker) и BookingCancelledEvent (актор либо каскад отмены сделки) оба приводят объект недвижимости в шахматке к «Свободен». Кросс-топик порядок между ними не гарантирован; консюмер chessboard идемпотентен и fail-safe — повторное освобождение уже свободного объекта недвижимости = no-op.

Завершение

Переход брони → Completed происходит в одном согласованном изменении со Deal.StartSale; отдельного освобождения объекта недвижимости нет — по завершении сделки chessboard ставит «Продан» по DealCompletedEvent (deal.md).

Отказы, идемпотентность и конкуренция

  • Идемпотентность создания. Повтор запроса на бронирование того же объекта недвижимости/сделки в активной фазе не создаёт второй брони: структурная защита — unique partial ux_bookings_active_property_object и ux_bookings_active_deal; исход повтора — CRM_BOOKING_PROPERTY_OBJECT_BUSY либо CRM_BOOKING_DEAL_ACTIVE_EXISTS. Гонка двух одновременных создателей на один объект недвижимости → ровно одна бронь Reserved, второй запрос — busy-ошибка.
  • Гонка worker ↔ менеджер. ConfirmBooking/ConfirmPayment/Extend менеджера и истечение worker-а конкурируют за одну бронь; арбитраж — оптимистическая блокировка по версии. Победитель фиксирует переход, проигравший перечитывает состояние: если истечение выиграло — менеджер видит терминальный ReserveExpired/BookingExpired и получает CRM_BOOKING_RESERVE_EXPIRED/CRM_BOOKING_ALREADY_TERMINAL; если выиграл менеджер — очередной проход worker-а видит уже неподходящий статус и не делает ничего (INV-BKG-5, идемпотентно).
  • Идемпотентность worker-а. Проход booking-expiration над уже терминальной бронью не порождает второго BookingExpiredEvent и не меняет статус (ADR-0056).
  • Частичный отказ консюмеров. Бронь валидна независимо от downstream: если chessboard временно недоступен, объект недвижимости в шахматке отстаёт по цвету, но процесс в crm корректен — событие переотправляется (at-least-once), проекция статуса — идемпотентный upsert. Если messenger недоступен, карточка в чате появляется с задержкой; прогресс всё равно виден в таймлайне сделки (данные crm).
  • Компенсации. Многошаговых распределённых транзакций нет: каждый переход брони атомарен в crm и публикует ровно одно интеграционное событие; занятие/освобождение объекта недвижимости — следствие-эффект консюмера, а не часть транзакции crm (eventual). Откат резерва при отказе от брони — не компенсация, а штатный переход → Cancelled/→ ReserveExpired со своим событием.

Acceptance-критерии

  • Happy (создание). Дано: объект недвижимости свободен, сделка в Application, выбрана живая программа. Когда участник сделки создаёт бронь. Тогда бронь Reserved со snapshot ProgramTerms, сделка → Booking, в таймлайне BookingRequested, опубликован BookingCreatedEvent.
  • Занятый объект недвижимости. Дано: на объекте недвижимости уже есть активная бронь. Когда создаётся вторая. Тогда отказ CRM_BOOKING_PROPERTY_OBJECT_BUSY, второй записи нет.
  • Сделка в недопустимом статусе. Дано: сделка Sale/Completed/Cancelled. Когда создаётся бронь. Тогда отказ CRM_DEAL_INVALID_STATE.
  • Платная программа без оплаты. Дано: бронь платной программы, PaymentStatus ≠ Paid. Когда пытаются подтвердить бронь. Тогда отказ CRM_BOOKING_PAYMENT_REQUIRED.
  • Подтверждение оплаты без чека. Дано: бронь AwaitingPayment без документа BookingPaymentProof. Когда менеджер подтверждает оплату. Тогда отказ CRM_BOOKING_PROOF_REQUIRED.
  • Просроченный резерв. Дано: now > ReserveExpiresAt, worker перевёл бронь в ReserveExpired. Когда менеджер подтверждает бронь. Тогда отказ CRM_BOOKING_RESERVE_EXPIRED/CRM_BOOKING_ALREADY_TERMINAL.
  • Конкурентное создание. Дано: два запроса на бронь одного объекта недвижимости одновременно. Когда оба выполняются. Тогда ровно одна бронь Reserved, второй — busy-ошибка.
  • Идемпотентное истечение. Дано: бронь уже ReserveExpired. Когда worker проходит повторно. Тогда статус и число событий не меняются.
  • Отмена освобождает объект недвижимости. Дано: активная бронь. Когда актор отменяет с причиной. Тогда бронь Cancelled, опубликован BookingCancelledEvent, chessboard возвращает объект недвижимости «Свободен».

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

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

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

  • booking-program.md — источник условий; deal.md — родительский процесс; deal-document.md — договор брони/чек; purchase-program.md — способ оплаты сделки (не путать с оплатой брони).
  • Статусы объекта недвижимости — docs/06-services/chessboard/ (консюмер booking-событий, будущий путь по конвенции).
  • ADR-0056 (polling-worker), решение 7 и 9 ТЗ (две точки входа; эквайринг отложен).