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 area | investor |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/select-booking-requisites.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор) |
| Auth policy / Permissions | crm.bookings.manage (инвесторская сторона брони — US-INV-21) |
| Scope (RBAC) | не применяется: инвестор — вне developer/agency-tenant (кросс-tenant участник, ADR-0052) |
| Record-level | deal.PrimaryInvestorUserId = actor.UserId (бронь принадлежит сделке инвестора); форсится сервером |
| Tenant isolation | бронь TenantId = DeveloperCompanyId (объект застройщика); инвестор кросс-tenant → изоляция record-level по сделке (scope-bypass developer-tenant с явным предикатом, аналогично confirm-booking.md) |
| Audit | update (RequisiteSetId, UpdatedByUserId = actor.UserId); RequisiteSetId — только ссылка, PII реквизитов (в profile) в логи не пишется |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/investor/crm/bookings/{bookingId}/requisites |
| Success status | 200 (реквизиты выбраны/переустановлены) |
| Idempotency header | не применяется: PUT естественно идемпотентен — повтор с тем же RequisiteSetId даёт тот же результат |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
BookingId | route | Guid | Да | != Guid.Empty; существующая бронь Status ∈ {Reserved, AwaitingPayment}, принадлежит сделке актора | — |
RequisiteSetId | body | Guid | Да | != Guid.Empty; существующий набор реквизитов в profile, доступный актору-инвестору | ссылка на profile; состав данных наружу не передаётся |
ActorUserId | identity claims | Guid | Да | JWT инвестора; record-level = deal.PrimaryInvestorUserId (форсится сервером) | scope isolation |
Модель ответа BookingCardModel
Та же карточка брони, что отдают операции создания/подтверждения; полная таблица полей — дом create-booking.md (проекция дельты booking.md). Ниже — только значимые для этой операции поля; остальные возвращаются без изменений.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
BookingId | Guid | Да | BookingEntity.Id | — | Идентификатор брони. |
DealId | Guid | Да | BookingEntity.DealId | — | Сделка-владелец. |
Status | string (enum BookingStatus) | Да | Status | — | Без изменений: Reserved или AwaitingPayment. |
RequisiteSetId | Guid? | Нет | RequisiteSetId | — | Выбранный набор реквизитов (profile) после операции. |
ReserveExpiresAt | DateTimeOffset | Да | ReserveExpiresAt | — | Дедлайн резерва (таймер, без изменений). |
ProgramTerms | BookingProgramTerms (owned VO — booking.md) | Да | ProgramTerms | — | Снимок условий программы. |
PaymentStatus | string (enum BookingPaymentStatus) | Да | PaymentStatus | — | Без изменений. |
Состав персональных данных набора (ФИО/паспорт/собственник) в модель не входит — только ссылка
(profile — единственный дом реквизитов).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (permission
crm.bookings.manage);ActorUserId— из токена (из тела не принимается);BookingIdиз route,RequisiteSetIdиз body.PUTестественно идемпотентен. - Проверка входа и предусловий.
- формат:
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.
- формат:
- Что читаем. Бронь и её сделку (record-level по
deal.PrimaryInvestorUserId) — уже получены на шаге 2. Состав данных набора изprofileв CRM не грузится (только идентификатор-ссылка). - Бизнес-правила и переходы. Смены статуса нет (бронь остаётся
Reserved/AwaitingPayment). ПроставитьRequisiteSetId = command.RequisiteSetId,UpdatedByUserId = ActorUserId,UpdatedAt = now. Идемпотентность: если у брони уже стоит тот жеRequisiteSetId— no-op, вернуть текущую карточку без записи. Интеграционных событий метод не поднимает. Маппинг ответа поле-в-поле —BookingCardModel(RequisiteSetId= выбранный). - Что меняется. В одном согласованном изменении обновляется одиночное поле-ссылка
RequisiteSetIdна брони; outbox не пишется. Гонка «инвестор выбирает реквизиты ↔ worker истекает резерв» разрешается оптимистической блокировкой версии строки: проигравший перечитывает и получаетCRM_BOOKING_INVALID_STATE, если бронь уже перешла вReserveExpired. - Результат.
BookingCardModel(из обновлённой брони,RequisiteSetId= выбранный) → HTTP200. Состав реквизитов в модель и логи не попадает (только id-ссылка).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (Kafka не публикуется; RequisiteSetId не входит в booking-payload'ы) |
| External calls | profile «набор реквизитов по id» (проверка существования/доступа; timeout 3 c, retry 1) |
| Cache / projections / search | none (у брони нет FTS; денормализаций не меняется) |
| Notifications | none (для потребителей событие не рождается) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | BookingId/RequisiteSetId == Guid.Empty | «Проверьте корректность запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
CRM_BOOKING_NOT_FOUND | 404 | бронь не найдена | «Бронирование не найдено» | no retry |
CRM_BOOKING_FORBIDDEN | 403 | deal.PrimaryInvestorUserId != actor.UserId (чужая бронь) | «Нет доступа к бронированию» | no retry |
CRM_BOOKING_INVALID_STATE | 409 | Status ∉ {Reserved, AwaitingPayment} (уже Booked/терминальная/истекла) | «Реквизиты недоступны в текущем статусе брони» | no retry (перечитать состояние) |
CRM_REQUISITE_SET_NOT_FOUND | 404 | набор не найден в profile или недоступен актору | «Набор реквизитов недоступен» | no retry |
PROFILE_UNAVAILABLE | 503 | profile недоступен | «Сервис реквизитов временно недоступен» | retry |
Предусловия и постусловия
Предусловия (истинны до сценария):
- инвестор — владелец сделки брони (
deal.PrimaryInvestorUserId = actor) с permissioncrm.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; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность (дом полей,
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.