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

PUT /api/investor/crm/bookings/{bookingId}/requisites — Выбрать реквизиты для брони

Инвестор привязывает к активной брони набор реквизитов/паспортных данных из своего profile (кейс «бронь на мужа, покупка на жену» — набор выбирается из связанных лиц профиля). Операция проставляет Booking.RequisiteSetIdлогическую ссылку на profile; сам состав персональных данных живёт и шифруется в контексте profile (CRM хранит только идентификатор набора, README «Не делает»). Это шаг после старта резерва (create-booking.md, где RequisiteSetId обычно ещё пуст) и до подготовки договора брони застройщиком. Дом полей брони — booking.md.

Назначение

Принять от инвестора {BookingId (route), RequisiteSetId (body)}, проверить, что бронь активна (Reserved/AwaitingPayment) и принадлежит актору, что набор реквизитов существует в profile и доступен инвестору, и записать Booking.RequisiteSetId. Владелец — контекст crm, feature group booking, сценарий US-INV-06/10/21. Kafka-событий операция не публикует.

Идемпотентность

PUT естественно идемпотентен: повторный вызов с тем же RequisiteSetId — no-op (карточка брони возвращается без записи); повтор с другим доступным RequisiteSetId переустанавливает ссылку. Отдельный ключ идемпотентности не требуется.

Metadata

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

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

Доступ
ПроверкаЗначение
Actor typeUser (Инвестор)
Auth policy / Permissionscrm.bookings.manage (инвесторская сторона брони — US-INV-21)
Scope (RBAC)не применяется: инвестор — вне developer/agency-tenant (кросс-tenant участник, ADR-0052)
Record-leveldeal.PrimaryInvestorUserId = actor.UserId (бронь принадлежит сделке инвестора); форсится сервером
Tenant isolationбронь TenantId = DeveloperCompanyId (объект застройщика); инвестор кросс-tenant → изоляция record-level по сделке (scope-bypass developer-tenant с явным предикатом, аналогично confirm-booking.md)
Auditupdate (RequisiteSetId, UpdatedByUserId = actor.UserId); RequisiteSetId — только ссылка, PII реквизитов (в profile) в логи не пишется

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/investor/crm/bookings/{bookingId}/requisites
Success status200 (реквизиты выбраны/переустановлены)
Idempotency headerне применяется: PUT естественно идемпотентен — повтор с тем же RequisiteSetId даёт тот же результат
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь Status ∈ {Reserved, AwaitingPayment}, принадлежит сделке актора
RequisiteSetIdbodyGuidДа!= Guid.Empty; существующий набор реквизитов в profile, доступный актору-инвесторуссылка на profile; состав данных наружу не передаётся
ActorUserIdidentity claimsGuidДаJWT инвестора; record-level = deal.PrimaryInvestorUserId (форсится сервером)scope isolation

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

Та же карточка брони, что отдают операции создания/подтверждения; полная таблица полей — дом create-booking.md (проекция дельты booking.md). Ниже — только значимые для этой операции поля; остальные возвращаются без изменений.

ПолеТипОбязательностьИсточникMaskingОписание
BookingIdGuidДаBookingEntity.IdИдентификатор брони.
DealIdGuidДаBookingEntity.DealIdСделка-владелец.
Statusstring (enum BookingStatus)ДаStatusБез изменений: Reserved или AwaitingPayment.
RequisiteSetIdGuid?НетRequisiteSetIdВыбранный набор реквизитов (profile) после операции.
ReserveExpiresAtDateTimeOffsetДаReserveExpiresAtДедлайн резерва (таймер, без изменений).
ProgramTermsBookingProgramTerms (owned VO — booking.md)ДаProgramTermsСнимок условий программы.
PaymentStatusstring (enum BookingPaymentStatus)ДаPaymentStatusБез изменений.

Состав персональных данных набора (ФИО/паспорт/собственник) в модель не входит — только ссылка (profile — единственный дом реквизитов).

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (permission crm.bookings.manage); ActorUserId — из токена (из тела не принимается); BookingId из route, RequisiteSetId из body. PUT естественно идемпотентен.
  2. Проверка входа и предусловий.
    • формат: BookingId != Guid.Empty, RequisiteSetId != Guid.Empty — иначе VALIDATION_FAILED;
    • существование брони: если брони нет → CRM_BOOKING_NOT_FOUND;
    • принадлежность/record-level: deal.PrimaryInvestorUserId == ActorUserId — иначе CRM_BOOKING_FORBIDDEN (чужая бронь недостижима, независимо от tenant);
    • допустимость состояния: booking.Status ∈ {Reserved, AwaitingPayment} — иначе CRM_BOOKING_INVALID_STATE (после Booked/терминальных набор зафиксирован: договор уже готовится/подписан);
    • существование набора реквизитов: запрос в profile «набор реквизитов по id для владельца» (RequisiteSetId, доступность актору-инвестору, включая связанных лиц — супруг/со-собственник) → если набора нет или нет доступа → CRM_REQUISITE_SET_NOT_FOUND; если profile недоступен → PROFILE_UNAVAILABLE.
  3. Что читаем. Бронь и её сделку (record-level по deal.PrimaryInvestorUserId) — уже получены на шаге 2. Состав данных набора из profile в CRM не грузится (только идентификатор-ссылка).
  4. Бизнес-правила и переходы. Смены статуса нет (бронь остаётся Reserved/AwaitingPayment). Проставить RequisiteSetId = command.RequisiteSetId, UpdatedByUserId = ActorUserId, UpdatedAt = now. Идемпотентность: если у брони уже стоит тот же RequisiteSetId — no-op, вернуть текущую карточку без записи. Интеграционных событий метод не поднимает. Маппинг ответа поле-в-поле — BookingCardModel (RequisiteSetId = выбранный).
  5. Что меняется. В одном согласованном изменении обновляется одиночное поле-ссылка RequisiteSetId на брони; outbox не пишется. Гонка «инвестор выбирает реквизиты ↔ worker истекает резерв» разрешается оптимистической блокировкой версии строки: проигравший перечитывает и получает CRM_BOOKING_INVALID_STATE, если бронь уже перешла в ReserveExpired.
  6. Результат. BookingCardModel (из обновлённой брони, RequisiteSetId = выбранный) → HTTP 200. Состав реквизитов в модель и логи не попадает (только id-ссылка).

Диаграмма

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

ТипДетали
Integration eventsnone (Kafka не публикуется; RequisiteSetId не входит в booking-payload'ы)
External callsprofile «набор реквизитов по id» (проверка существования/доступа; timeout 3 c, retry 1)
Cache / projections / searchnone (у брони нет FTS; денормализаций не меняется)
Notificationsnone (для потребителей событие не рождается)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400BookingId/RequisiteSetId == Guid.Empty«Проверьте корректность запроса»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
CRM_BOOKING_NOT_FOUND404бронь не найдена«Бронирование не найдено»no retry
CRM_BOOKING_FORBIDDEN403deal.PrimaryInvestorUserId != actor.UserId (чужая бронь)«Нет доступа к бронированию»no retry
CRM_BOOKING_INVALID_STATE409Status ∉ {Reserved, AwaitingPayment} (уже Booked/терминальная/истекла)«Реквизиты недоступны в текущем статусе брони»no retry (перечитать состояние)
CRM_REQUISITE_SET_NOT_FOUND404набор не найден в profile или недоступен актору«Набор реквизитов недоступен»no retry
PROFILE_UNAVAILABLE503profile недоступен«Сервис реквизитов временно недоступен»retry

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

Предусловия (истинны до сценария):

  • инвестор — владелец сделки брони (deal.PrimaryInvestorUserId = actor) с permission crm.bookings.manage;
  • бронь активна (Status ∈ {Reserved, AwaitingPayment}) — после Booked/терминальных набор зафиксирован (договор готовится/подписан);
  • набор реквизитов существует в контексте profile и доступен актору (включая связанных лиц — супруг/со-собственник); CRM хранит только идентификатор-ссылку.

Постусловия (гарантированы после успеха):

  • Booking.RequisiteSetId = выбранный набор (логическая ссылка на profile), UpdatedByUserId/UpdatedAt обновлены; статус брони не меняется;
  • состав персональных данных в CRM не сохраняется; Kafka-события не публикуются; RequisiteSetId не входит в booking-payload'ы.

Кросс-сервисные ожидания. Синхронно ожидает от profile ответ «набор реквизитов по id существует и доступен владельцу-инвестору» (таймаут 3 c, 1 retry): отрицательный ответ → CRM_REQUISITE_SET_NOT_FOUND, недоступность сервиса → PROFILE_UNAVAILABLE (503, retry). Внешних событий операция не публикует.

Acceptance Criteria

  • Happy path. Дано: активная бронь Reserved, набор реквизитов существует и доступен инвестору. Когда: PUT …/requisites с RequisiteSetId. Тогда: 200, Booking.RequisiteSetId = выбранный, статус брони без изменений, Kafka-событий нет.
  • Идемпотентный повтор. Дано: у брони уже стоит тот же RequisiteSetId. Когда: повтор PUT с тем же id. Тогда: 200 (no-op) без записи.
  • Переустановка набора. Дано: бронь AwaitingPayment с ранее выбранным набором. Когда: PUT с другим доступным RequisiteSetId. Тогда: 200, ссылка заменена.
  • Недопустимый статус. Дано: бронь Booked/ReserveExpired/терминальная. Когда: PUT. Тогда: 409 CRM_BOOKING_INVALID_STATE.
  • Чужая бронь. Дано: deal.PrimaryInvestorUserId != actor. Когда: PUT. Тогда: 403 CRM_BOOKING_FORBIDDEN.
  • Набор недоступен. Дано: profile вернул «нет набора / нет доступа». Когда: PUT. Тогда: 404 CRM_REQUISITE_SET_NOT_FOUND.
  • profile недоступен. Когда: сервис реквизитов не отвечает после retry. Тогда: 503 PROFILE_UNAVAILABLE (retry).
  • Гонка с истечением резерва. Дано: worker истёк резерв параллельно (бронь → ReserveExpired). Когда: PUT проигрывает оптимистическую блокировку. Тогда: проигравший перечитывает и получает 409 CRM_BOOKING_INVALID_STATE.
  • Пустой id. Когда: BookingId/RequisiteSetId == Guid.Empty. Тогда: 400 VALIDATION_FAILED.

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

  • Route и тело стабильны; добавление optional-полей в BookingCardModel — additive, без breaking change; удаление/переименование/смена типа поля или значения enum (BookingStatus/BookingPaymentStatus) — breaking (migration + rollout). Неизвестные enum наружу не отдаются.
  • Logs: booking-requisites-selected (BookingId, DealId, RequisiteSetId, ActorUserId; состав реквизитов/ФИО не пишутся); metrics: crm_booking_requisites_selected_total{result}, latency p95 (SLO команд < 500 мс); traces — OTel, correlation через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущность (дом полей, RequisiteSetId, enum-ы, INV-BKG-*): booking.md; родитель процесса и PrimaryInvestorUserId: deal.md.
  • Дом BookingCardModel и старт резерва: create-booking.md; подтверждение: confirm-booking.md.
  • Контексты: реквизиты/персональные данные — profile (docs/06-services/profile/, будущий путь по конвенции); карта области — README.
  • События брони (для контраста — RequisiteSetId в payload не входит): booking-created.md, booking-confirmed.md, booking-paid.md.
  • ADR: 0052 tenancy.