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

POST /api/agent/crm/bookings/{bookingId}/confirm — Подтвердить бронь (бесплатная программа)

Агентская сторона переводит бронь по бесплатной программе из Резерва в Бронь до истечения резерва (US-INV-06/10). Переход брони Reserved → Booked (INV-BKG-3). Для платной программы этот путь недоступен: подтверждение идёт через ConfirmPayment менеджера застройщика (area developer), где оплата и перевод в Booked выполняются одной операцией. Дом полей брони, статусы и инварианты — в booking.md.

Назначение

Дать агенту закрепить объект за сделкой по бесплатной программе бронирования (booking-program.md): пока идёт отсчёт ReserveExpiresAt, агент подтверждает бронь, и объект переходит из «Резерв» в «Бронь» с новым таймером BookingExpiresAt. Смену статуса объекта в шахматке (Резерв → Бронь) выполняет консюмер chessboard по интеграционному событию — CRM чужие таблицы не пишет (service-boundaries.md).

Инвариант

INV-BKG-3: агентская сторона подтверждает только бесплатную программу (Reserved → Booked). Для платной программы этот метод недоступен и возвращает CRM_BOOKING_PAYMENT_REQUIRED — перевод в Booked там выполняет ConfirmPayment менеджера застройщика (area developer) одной операцией с оплатой.

Metadata

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

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

ПроверкаЗначение
Actor typeUser — ответственный агент, руководитель агентства или Менеджер ПИН
Auth policy / Permissionscrm.bookings.manage (агентская сторона); Менеджер ПИН — platform-scope
Scope (RBAC)организация (агентство): record-level по OriginAgencyCompanyId сделки
Record-levelпо участникам сделки (deal.md): рядовой агент — активный участник (обычно Executor: Deal.ExecutorAgentUserId = actor.UserId); руководитель — любая сделка своего Deal.OriginAgencyCompanyId; Менеджер ПИН — обход record-level
Tenant isolationБронь TenantId = DeveloperCompanyId (процесс на объекте застройщика, ADR-0052). Агентская команда выполняется по явному record-level предикату по сделке, а не по авто-tenant-фильтру
Auditupdate: переход Reserved → Booked, поля BookedAt, BookingExpiresAt, UpdatedByUserId, Version
Доступ

Permission crm.bookings.manage проверяется record-level предикатом по сделке, а не авто-tenant-фильтром: рядовой агент — активный Executor сделки (Deal.ExecutorAgentUserId = actor.UserId), руководитель агентства — любая сделка своего Deal.OriginAgencyCompanyId, Менеджер ПИН — обход record-level (platform-scope). Сама бронь лежит в tenant застройщика (TenantId = DeveloperCompanyId, ADR-0052) — доступ агентской стороны к ней даёт именно предикат по сделке.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/agent/crm/bookings/{bookingId}/confirm
Success status200 (возвращает карточку брони в статусе Booked)
Idempotency headerне применяется — повтор ловится проверкой состояния: вторая confirm уже Booked/терминальной брони → CRM_BOOKING_INVALID_STATE / CRM_BOOKING_ALREADY_TERMINAL
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь
ActorUserIdidentity claimsGuidДаиз JWT
ActorAgencyCompanyIdidentity claimsGuidДаrecord-level предикат (сверяется с Deal.OriginAgencyCompanyId)
IsAgencyHeadidentity claimsboolДафлаг «руководитель агентства» (доступ ко всем агентам своего AgencyCompanyId)
IsPinManageridentity claimsboolДаplatform-scope Менеджера ПИН (обход record-level)

Тело запроса пустое. Версия строки клиентом не передаётся — гонка «worker истекает / агент подтверждает» решается сервером оптимистически.

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

Типизированная проекция брони после перехода (дом полей — booking.md; здесь — только состав карточки). PII не содержит.

ПолеТипОбязательностьИсточникMaskingОписание
BookingIdGuidДаBookingEntity.IdИдентификатор брони.
DealIdGuidДаBookingEntity.DealIdСделка-владелец.
PropertyObjectIdGuidДаBookingEntity.PropertyObjectIdОбъект шахматки.
ProgramIdGuidДаBookingEntity.ProgramIdПрограмма бронирования.
ProgramNamestringДаProgramTerms.NameНазвание программы (snapshot).
IsPaidboolДаProgramTerms.IsPaidПлатность программы (здесь всегда false).
StatusBookingStatusДаBookingEntity.StatusСтатус строкой (Booked после операции).
PaymentStatusBookingPaymentStatusДаBookingEntity.PaymentStatusСтрокой (NotRequired).
ReserveExpiresAtDateTimeOffsetДаBookingEntity.ReserveExpiresAtДедлайн резерва (истёкший на момент Booked).
BookedAtDateTimeOffsetДаBookingEntity.BookedAtМомент подтверждения.
BookingExpiresAtDateTimeOffsetДаBookingEntity.BookingExpiresAtНовый дедлайн фазы «Бронь».

Алгоритм

  1. Контекст и доступ. Операцию выполняет ответственный агент / руководитель агентства / Менеджер ПИН (permission crm.bookings.manage).
  2. Проверка входа и предусловий.
    • формат: BookingId != Guid.Empty — иначе ошибка валидации;
    • бронь существует — иначе CRM_BOOKING_NOT_FOUND (404);
    • допустимость программы: программа бесплатная (ProgramTerms.IsPaid = false); для платной → CRM_BOOKING_PAYMENT_REQUIRED (409) — платная подтверждается ConfirmPayment застройщика (INV-BKG-3, событие оплаты — booking-paid.md);
    • допустимость состояния: Status = Reserved и now <= ReserveExpiresAt (проверяются переходом);
    • право актора на бронь по участникам сделки — иначе CRM_BOOKING_FORBIDDEN.
  3. Что читаем. Бронь (по явному предикату, у актора нет tenant застройщика) и её сделку (для record-level: агент/агентство). Статус сделки не проверяется (сделка уже Booking с создания брони, INV-BKG-2).
  4. Бизнес-правила и переходы. Право актора: Менеджер ПИН, руководитель агентства при совпавшем OriginAgencyCompanyId или ответственный агент (активный Executor сделки); иначе CRM_BOOKING_FORBIDDEN (403). Переход Reserved → Booked (guard Status == Reserved; терминальные статусы иммутабельны, INV-BKG-8 → CRM_BOOKING_ALREADY_TERMINAL; иной активный статус → CRM_BOOKING_INVALID_STATE; для бесплатной программы guard now <= ReserveExpiresAt, иначе CRM_BOOKING_RESERVE_EXPIRED, INV-BKG-3): BookedAt = now, BookingExpiresAt = BookedAt + ProgramTerms.BookingDurationHours, UpdatedByUserId = actor. PaymentStatus остаётся NotRequired. В таймлайн сделки пишется запись подтверждения.
  5. Что меняется. Бронь → Booked с новым таймером — в одном согласованном изменении.
  6. События. Интеграционное BookingConfirmedEvent → топик pin.crm.booking-confirmed.v1 (ContractVersion = 1, AggregateId = BookingId, AggregateType = "Booking"), payload {BookingId, DealId, TenantId, PropertyObjectId, ProgramId, BookingExpiresAt, BookedAt} — transactional outbox; контракт — booking-confirmed.md. Консюмеры: chessboard (объект → «Бронь» + таймер до BookingExpiresAt), catalog, notifications.
  7. Результат. Карточка подтверждённой брони (Status = Booked, BookedAt, BookingExpiresAt) → HTTP 200. Если worker booking-expiration истёк резерв параллельно — конкурентный конфликт; при перечитке бронь уже ReserveExpiredCRM_BOOKING_RESERVE_EXPIRED (409); объект вернулся в «Свободен», агент заводит новую бронь (create-booking-request.md).

Диаграмма

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

Предусловия. Актор — ответственный агент / руководитель агентства / Менеджер ПИН с crm.bookings.manage. Бронь существует, Status = Reserved, now <= ReserveExpiresAt. Программа бесплатная (ProgramTerms.IsPaid = false) — платная подтверждается менеджером застройщика через ConfirmPayment.

Постусловие (успех). Бронь Reserved → Booked: BookedAt = now, BookingExpiresAt = BookedAt + BookingDurationHours, PaymentStatus остаётся NotRequired; в таймлайне сделки — запись подтверждения. Сделка статуса не меняет (уже Booking с создания брони, INV-BKG-2). Опубликован BookingConfirmedEvent (key BookingId) — потребители chessboard (объект «Резерв → Бронь» + таймер до BookingExpiresAt), catalog, notifications. Таймаут-драйвер переключается: worker перестаёт следить за ReserveExpiresAt и начинает — за BookingExpiresAt. Окно eventual-consistency: объект в шахматке переходит в «Бронь» асинхронно.

Постусловие (отказ). Бронь не изменена, событие не опубликовано.

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

ТипДетали
Интеграционные событияBookingConfirmedEventpin.crm.booking-confirmed.v1 (outbox); контракт — booking-confirmed.md
Внешние вызовыnone (статус объекта меняет консюмер chessboard по событию, не синхронно)
Проекции / поискбронь выпадает из выборки worker-а по reserve_expires_at (переход из Reserved), попадает в выборку по booking_expires_at (Status = Booked)
Уведомленияопосредованно: консюмер booking-confirmed в notifications (участникам сделки «бронь подтверждена»)

Ошибки и failure modes

Ошибки

Два отказа не сводятся к простой валидации формата: CRM_BOOKING_PAYMENT_REQUIRED — бронь по платной программе (путь — ConfirmPayment застройщика); CRM_BOOKING_RESERVE_EXPIRED — резерв истёк раньше подтверждения (в т.ч. worker booking-expiration победил в гонке) — объект уже освобождён, нужна новая бронь.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400BookingId = Guid.Empty«Некорректный идентификатор брони»no retry
CRM_BOOKING_NOT_FOUND404брони нет или она вне scope актора«Бронь не найдена»no retry
CRM_BOOKING_FORBIDDEN403актор не участник агентской стороны сделки«Недостаточно прав для подтверждения брони»no retry
CRM_BOOKING_PAYMENT_REQUIRED409платная программа (ProgramTerms.IsPaid) — путь ConfirmPayment застройщика (INV-BKG-3)«Платная бронь подтверждается после оплаты застройщиком»no retry
CRM_BOOKING_RESERVE_EXPIRED409now > ReserveExpiresAt (в т.ч. worker истёк резерв)«Время резерва истекло, оформите бронь заново»no retry
CRM_BOOKING_INVALID_STATE409Status ∉ {Reserved} (уже Booked/AwaitingPayment)«Бронь нельзя подтвердить в текущем статусе»no retry
CRM_BOOKING_ALREADY_TERMINAL409бронь в терминальном статусе (INV-BKG-8)«Бронь завершена»no retry

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

  • Контракт: route + BookingCardModel; новые optional поля карточки — additive, без breaking change; смена маршрута/семантики статуса — breaking (migration + rollout). Enum-поля (Status, PaymentStatus) сериализуются строкой; unknown-значения клиент трактует как «неизвестный статус».
  • Logs: booking-confirm requested/confirmed/reserve-expired/conflict (BookingId, DealId, TenantId, ActorUserId; PII не пишется); metrics: crm_booking_confirm_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path. Given Reserved-бронь бесплатной программы, now <= ReserveExpiresAt; When confirm; Then 200, Booked, BookingExpiresAt проставлен, опубликован booking-confirmed.
  • Платная программа. Given ProgramTerms.IsPaid = true; When confirm; Then 409 CRM_BOOKING_PAYMENT_REQUIRED (путь ConfirmPayment застройщика).
  • Резерв истёк. Given now > ReserveExpiresAt (или worker уже истёк резерв); When confirm; Then 409 CRM_BOOKING_RESERVE_EXPIRED, объект уже освобождён.
  • Повторное подтверждение. Given бронь уже Booked/AwaitingPayment; When confirm; Then 409 CRM_BOOKING_INVALID_STATE.
  • Терминальная бронь. Given Status терминальный (INV-BKG-8); When confirm; Then 409 CRM_BOOKING_ALREADY_TERMINAL.
  • Гонка worker/агент. Given агент подтверждает в момент истечения резерва; Then побеждает один из процессов; проигравший агент получает CRM_BOOKING_RESERVE_EXPIRED.
  • Доступ. Given актор не участник агентской стороны сделки; When confirm; Then 403 CRM_BOOKING_FORBIDDEN.

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

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

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