PUT /api/agent/crm/deals/{dealId}/property-object — Сменить объект сделки
Агент из карточки сделки (/crm/deals/[dealId]) заменяет объект недвижимости, когда клиент передумал. Дом полей
сделки — deal.md (метод ChangePropertyObject, INV-DEAL-4).
Назначение
Перепривязать сделку на другой объект недвижимости того же застройщика: обновить объект/проект/здание, перечитать
цену из chessboard, записать в таймлайн событие PropertyObjectChanged. Если сделка в статусе Booking —
активная бронь отменяется той же операцией (старый объект освобождается событием).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent |
| Feature group | deals |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/change-deal-property-object.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Агент; Руководитель агентства — по record-level своего агентства) |
| Auth policy / Permissions | crm.deals.manage |
| Scope (RBAC) | организация (агентство) |
| Record-level | актор — активный участник-исполнитель (Executor) сделки либо руководитель агентства (deal.OriginAgencyCompanyId = агентство актора) |
| Tenant isolation | deal.TenantId = DeveloperCompanyId (ADR-0052); агентский доступ — record-level по OriginAgencyCompanyId + участие исполнителя |
| Audit | update (UpdatedByUserId = actor); бизнес-история — таймлайн PropertyObjectChanged |
Актор обязан быть активным участником-исполнителем (Executor) сделки либо руководителем агентства-
владельца (deal.OriginAgencyCompanyId = агентство актора). Сделки других агентств или без участия
актора-исполнителя недоступны — CRM_DEAL_FORBIDDEN.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/agent/crm/deals/{dealId}/property-object |
| Success status | 200 (обновлённая карточка) |
| Idempotency header | не применяется (естественная идемпотентность: повтор с тем же объектом → CRM_DEAL_PROPERTY_OBJECT_UNCHANGED) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty; существует; не терминальна | — |
PropertyObjectId | body | Guid | Да | != Guid.Empty; != deal.PropertyObjectId; существует в chessboard; DeveloperCompanyId == deal.TenantId; статус Свободен | — |
Reason | body | string? | Нет | ≤ 1000; причина отмены брони при Status = Booking (default «Смена объекта сделки») | free-text: не логировать целиком |
AgencyCompanyId | identity claims | Guid | Да | агентство актора для record-level scope | Tenant/record isolation |
ActorUserId | identity claims | Guid | Да | агент/руководитель | — |
Модель ответа DealCardModel
Проекция обновлённой сделки (дом полей — deal.md; та же модель, что у карточки сделки и создания сделки — ниже только отдаваемая дельта операции).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | DealEntity.Id | — | Идентификатор сделки. |
Title | string | Да | DealEntity.Title | — | Заголовок карточки (ручные правки не перетираются сменой объекта). |
Status | string (enum DealStatus) | Да | DealEntity.Status | — | Application (после отмены брони, если была Booking). |
StepId | Guid | Да | DealEntity.StepId | — | Текущий этап канбана (сменой объекта не меняется). |
PropertyObjectId | Guid | Да | DealEntity.PropertyObjectId | — | Новый объект. |
ProjectId | Guid | Да | DealEntity.ProjectId | — | Проект нового объекта (реплика chessboard). |
BuildingId | Guid? | Нет | DealEntity.BuildingId | — | Здание/литер нового объекта. |
Price | MoneyModel | Да | DealEntity.Price | — | Перечитанная стоимость ({Amount, Currency}). |
BookingId | Guid? | Нет | DealEntity.BookingId | — | null (активная бронь отменена при смене объекта из Booking). |
UpdatedAt | DateTimeOffset | Да | DealEntity.UpdatedAt | — | Момент смены объекта (US-INV-04). |
Списки сделок агента — отдельные query-методы; здесь ответ скалярный (одна карточка).
Сменить объект можно только для сделки в статусе Application или Booking и только на объект того же
застройщика (DeveloperCompanyId == deal.TenantId). Для статуса Sale или объекта другого застройщика —
409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN.
Алгоритм
- Контекст и доступ. Операцию выполняет агент/руководитель агентства (permission
crm.deals.manage) в рамках record-level своего агентства. - Проверка входа и предусловий.
- формат:
DealId/PropertyObjectId!= Guid.Empty,Reason ≤ 1000— иначе ошибка валидации; - сделка существует — иначе
CRM_DEAL_NOT_FOUND; - record-level: сделка того же агентства и актор — активный участник-исполнитель (
Executor) (или руководитель) — иначеCRM_DEAL_FORBIDDEN; - не терминальна:
Status ∉ {Completed, Cancelled}— иначеCRM_DEAL_ALREADY_TERMINAL(INV-DEAL-9); - допустимость состояния:
Status ∈ {Application, Booking}— иначе (Sale)CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN(INV-DEAL-4); - без no-op:
PropertyObjectId != deal.PropertyObjectId— иначеCRM_DEAL_PROPERTY_OBJECT_UNCHANGED; - новый объект есть в
chessboard(иначеCRM_PROPERTY_OBJECT_NOT_FOUND); из ответа —DeveloperCompanyId,ProjectId,BuildingId,Price, статус; - тот же застройщик:
DeveloperCompanyId == deal.TenantId— иначеCRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN(INV-DEAL-4: новый объект того жеTenantId); - доступность: статус объекта
Свободен— иначеCRM_PROPERTY_OBJECT_NOT_AVAILABLE.
- формат:
- Что читаем. Сделку (родитель для мутации); если
Status == Bookingи есть активная бронь — саму бронь (для отмены). Цену нового объекта берём snapshot-ом из ответаchessboard. - Бизнес-правила и переходы (INV-DEAL-4).
- если
Status == Booking: отмена активной брони — переходReserved/AwaitingPayment/Booked→Cancelled(INV-BKG-8), с причинойReason ?? "Смена объекта сделки"; - смена объекта (guard INV-DEAL-4: статус
∈ {Application, Booking}, тот жеTenantId, иначеCRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN): новые объект/проект/здание, перечитанная цена; при отменённой брониBookingId = nullи переходBooking → Application,UpdatedAt = now,UpdatedByUserId = actor;Titleне меняется (ручные правки сохраняются); в таймлайн — записьPropertyObjectChanged.
- если
- Что меняется. Сделка (объект/проект/здание/цена/статус/
BookingId) + (бронь, если отменялась) + строка таймлайна — в одном согласованном изменении. - События.
- смена объекта сама Kafka не публикует — только доменное событие пишет таймлайн
PropertyObjectChanged. Новый объект вchessboardне резервируется (резерв ставится лишь при создании новой брони); - только если отменялась активная бронь:
BookingCancelledEvent→ топикpin.crm.booking-cancelled.v1(ContractVersion = 1,AggregateId = BookingId, key =BookingId), payload{BookingId, DealId, TenantId, PropertyObjectId = старый объект, CancelledByUserId = actor, CancelledAt, CancelReason}— transactional outbox; контракт — booking-cancelled.md. Консюмерchessboardосвобождает старый объект (Резерв/Бронь→Свободен).
- смена объекта сама Kafka не публикует — только доменное событие пишет таймлайн
- Результат. Обновлённая карточка сделки (новый объект, цена, статус,
BookingId = null,UpdatedAt) → HTTP200. Гонка с worker-омbooking-expirationразрешается оптимистически: проигравший перечитывает актуальный статус сделки/брони.
Диаграмма
Если worker booking-expiration истёк бронь параллельно с этой операцией, конфликт разрешается
оптимистически: проигравшая сторона перечитывает актуальный статус сделки/брони, без ошибки 409 по этой
причине.
Предусловия и постусловия
Предусловия. Актор — владелец сделки / руководитель агентства с crm.deals.manage. Сделка не терминальна
(Status ∉ {Completed, Cancelled}) и в допустимом статусе для смены (Status ∈ {Application, Booking},
INV-DEAL-4). Новый объект: существует в chessboard, != текущего, того же застройщика (DeveloperCompanyId == deal.TenantId), статус «Свободен».
Ожидания от chessboard (реплика). Из ответа берутся DeveloperCompanyId, ProjectId, BuildingId?,
Price, статус нового объекта — snapshot на момент операции.
Постусловие (успех). Сделка перепривязана: объект/проект/здание/цена обновлены, UpdatedAt = now,
UpdatedByUserId = actor; Title сохранён (ручные правки не перетираются); в таймлайне — PropertyObjectChanged.
Если сделка была Booking: активная бронь → Cancelled (причина Reason ?? "Смена объекта сделки"), BookingId = null, сделка Booking → Application. Смена объекта сама Kafka не публикует; только при отмене брони
публикуется BookingCancelledEvent (payload со старым объектом, key BookingId) — потребитель chessboard
освобождает старый объект. Новый объект не резервируется (резерв появится лишь при новой брони).
Окно eventual-consistency: старый объект освобождается асинхронно; новый остаётся «Свободен».
Постусловие (отказ). Ни сделка, ни бронь не изменены.
Совместимость и наблюдаемость
- Поля запроса/ответа стабильны; новые optional поля — additive без breaking; удаление/переименование/смена типа
— breaking (migration + rollout). Unknown enum-значения
DealStatusв ответе не возникают (сервер — источник). Контрактbooking-cancelled.v1— по booking-cancelled.md. - Logs:
deal property-object changed(DealId,OldPropertyObjectId,NewPropertyObjectId,TenantId;Reason/PII не пишутся полностью); metricscrm_deal_property_object_changed_total{with_booking_cancel}, latency p95 < 500 мс; traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | BookingCancelledEvent → pin.crm.booking-cancelled.v1 — только если сделка была в Booking (отмена активной брони), outbox; сама смена объекта событий не публикует |
| Внешние вызовы | реплика chessboard: новый объект (DeveloperCompanyId, ProjectId, BuildingId, Price, статус; timeout 2 c) |
| Проекции / поиск | deals.PropertyObjectId/ProjectId/BuildingId/Price обновляются; проекция сделок (DealSearchDocument) не затрагивается (Title/ФИО не меняются); статус старого объекта в chessboard → «Свободен» (консюмер booking-cancelled); новый объект статус не меняется |
| Уведомления | опосредованно: консюмер booking-cancelled в notifications (уведомление участникам об отмене брони) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | формат DealId/PropertyObjectId/Reason | «Проверьте корректность запроса» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка не найдена | «Сделка не найдена» | no retry |
CRM_DEAL_FORBIDDEN | 403 | сделка чужого агента/агентства | «Нет доступа к сделке» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | Status ∈ {Completed, Cancelled} (INV-DEAL-9) | «Сделка завершена или отменена» | no retry |
CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN | 409 | Status = Sale либо новый объект другого застройщика (INV-DEAL-4) | «Смена объекта недоступна» | no retry |
CRM_DEAL_PROPERTY_OBJECT_UNCHANGED | 409 | PropertyObjectId == deal.PropertyObjectId | «Объект не изменился» | no retry |
CRM_PROPERTY_OBJECT_NOT_FOUND | 404 | новый объект не найден в chessboard | «Объект не найден» | no retry |
CRM_PROPERTY_OBJECT_NOT_AVAILABLE | 409 | статус нового объекта не «Свободен» | «Объект недоступен» | no retry |
Acceptance Criteria
- Happy path (Application). Given сделка
Application; When смена на свободный объект того же застройщика; Then200, новый объект/цена, Kafka-событий нет, в таймлайнеPropertyObjectChanged. - Happy path (Booking). Given сделка
Bookingс активной бронью; When смена объекта; Then200, броньCancelled,BookingId = null,Status = Application, опубликованbooking-cancelled(старый объект). - No-op. Given
PropertyObjectId == deal.PropertyObjectId; When смена; Then409 CRM_DEAL_PROPERTY_OBJECT_UNCHANGED. - Другой застройщик (INV-DEAL-4). Given новый объект другого
DeveloperCompanyId; When смена; Then409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN. - Статус
Sale(INV-DEAL-4). Givendeal.Status = Sale; When смена; Then409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN. - Терминальная сделка. Given
Status ∈ {Completed, Cancelled}; When смена; Then409 CRM_DEAL_ALREADY_TERMINAL. - Новый объект занят/не найден. Given статус нового объекта не «Свободен» →
409 CRM_PROPERTY_OBJECT_NOT_AVAILABLE; объект не найден в реплике →404 CRM_PROPERTY_OBJECT_NOT_FOUND. - Гонка с worker. Given worker
booking-expirationистёк бронь параллельно; Then разрешается оптимистически: проигравший перечитывает актуальный статус сделки/брони.
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: deal.md (дом полей, метод
ChangePropertyObject, INV-DEAL-4), booking.md (Booking.Cancel, INV-BKG-8), deal-timeline-event.md (PropertyObjectChanged). - Событие: booking-cancelled.md (
pin.crm.booking-cancelled.v1, освобождение старого объекта). Карта областей — README контекста. - ADR: 0052 tenancy, 0056 simplicity-first.