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

PUT /api/agent/crm/deals/{dealId}/property-object — Сменить объект сделки

Агент из карточки сделки (/crm/deals/[dealId]) заменяет объект недвижимости, когда клиент передумал. Дом полей сделки — deal.md (метод ChangePropertyObject, INV-DEAL-4).

Назначение

Перепривязать сделку на другой объект недвижимости того же застройщика: обновить объект/проект/здание, перечитать цену из chessboard, записать в таймлайн событие PropertyObjectChanged. Если сделка в статусе Booking — активная бронь отменяется той же операцией (старый объект освобождается событием).

Metadata

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

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

ПроверкаЗначение
Actor typeUser (Агент; Руководитель агентства — по record-level своего агентства)
Auth policy / Permissionscrm.deals.manage
Scope (RBAC)организация (агентство)
Record-levelактор — активный участник-исполнитель (Executor) сделки либо руководитель агентства (deal.OriginAgencyCompanyId = агентство актора)
Tenant isolationdeal.TenantId = DeveloperCompanyId (ADR-0052); агентский доступ — record-level по OriginAgencyCompanyId + участие исполнителя
Auditupdate (UpdatedByUserId = actor); бизнес-история — таймлайн PropertyObjectChanged
Доступ

Актор обязан быть активным участником-исполнителем (Executor) сделки либо руководителем агентства- владельца (deal.OriginAgencyCompanyId = агентство актора). Сделки других агентств или без участия актора-исполнителя недоступны — CRM_DEAL_FORBIDDEN.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/agent/crm/deals/{dealId}/property-object
Success status200 (обновлённая карточка)
Idempotency headerне применяется (естественная идемпотентность: повтор с тем же объектом → CRM_DEAL_PROPERTY_OBJECT_UNCHANGED)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DealIdrouteGuidДа!= Guid.Empty; существует; не терминальна
PropertyObjectIdbodyGuidДа!= Guid.Empty; != deal.PropertyObjectId; существует в chessboard; DeveloperCompanyId == deal.TenantId; статус Свободен
Reasonbodystring?Нет≤ 1000; причина отмены брони при Status = Booking (default «Смена объекта сделки»)free-text: не логировать целиком
AgencyCompanyIdidentity claimsGuidДаагентство актора для record-level scopeTenant/record isolation
ActorUserIdidentity claimsGuidДаагент/руководитель

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

Проекция обновлённой сделки (дом полей — deal.md; та же модель, что у карточки сделки и создания сделки — ниже только отдаваемая дельта операции).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealEntity.IdИдентификатор сделки.
TitlestringДаDealEntity.TitleЗаголовок карточки (ручные правки не перетираются сменой объекта).
Statusstring (enum DealStatus)ДаDealEntity.StatusApplication (после отмены брони, если была Booking).
StepIdGuidДаDealEntity.StepIdТекущий этап канбана (сменой объекта не меняется).
PropertyObjectIdGuidДаDealEntity.PropertyObjectIdНовый объект.
ProjectIdGuidДаDealEntity.ProjectIdПроект нового объекта (реплика chessboard).
BuildingIdGuid?НетDealEntity.BuildingIdЗдание/литер нового объекта.
PriceMoneyModelДаDealEntity.PriceПеречитанная стоимость ({Amount, Currency}).
BookingIdGuid?НетDealEntity.BookingIdnull (активная бронь отменена при смене объекта из Booking).
UpdatedAtDateTimeOffsetДаDealEntity.UpdatedAtМомент смены объекта (US-INV-04).

Списки сделок агента — отдельные query-методы; здесь ответ скалярный (одна карточка).

Ограничение смены объекта (INV-DEAL-4)

Сменить объект можно только для сделки в статусе Application или Booking и только на объект того же застройщика (DeveloperCompanyId == deal.TenantId). Для статуса Sale или объекта другого застройщика — 409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN.

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент/руководитель агентства (permission crm.deals.manage) в рамках record-level своего агентства.
  2. Проверка входа и предусловий.
    • формат: 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.
  3. Что читаем. Сделку (родитель для мутации); если Status == Booking и есть активная бронь — саму бронь (для отмены). Цену нового объекта берём snapshot-ом из ответа chessboard.
  4. Бизнес-правила и переходы (INV-DEAL-4).
    • если Status == Booking: отмена активной брони — переход Reserved/AwaitingPayment/BookedCancelled (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.
  5. Что меняется. Сделка (объект/проект/здание/цена/статус/BookingId) + (бронь, если отменялась) + строка таймлайна — в одном согласованном изменении.
  6. События.
    • смена объекта сама 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 освобождает старый объект (Резерв/БроньСвободен).
  7. Результат. Обновлённая карточка сделки (новый объект, цена, статус, BookingId = null, UpdatedAt) → HTTP 200. Гонка с worker-ом booking-expiration разрешается оптимистически: проигравший перечитывает актуальный статус сделки/брони.

Диаграмма

Гонка с 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 не пишутся полностью); metrics crm_deal_property_object_changed_total{with_booking_cancel}, latency p95 < 500 мс; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

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

ТипДетали
Интеграционные событияBookingCancelledEventpin.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_FAILED400формат DealId/PropertyObjectId/Reason«Проверьте корректность запроса»no retry
CRM_DEAL_NOT_FOUND404сделка не найдена«Сделка не найдена»no retry
CRM_DEAL_FORBIDDEN403сделка чужого агента/агентства«Нет доступа к сделке»no retry
CRM_DEAL_ALREADY_TERMINAL409Status ∈ {Completed, Cancelled} (INV-DEAL-9)«Сделка завершена или отменена»no retry
CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN409Status = Sale либо новый объект другого застройщика (INV-DEAL-4)«Смена объекта недоступна»no retry
CRM_DEAL_PROPERTY_OBJECT_UNCHANGED409PropertyObjectId == deal.PropertyObjectId«Объект не изменился»no retry
CRM_PROPERTY_OBJECT_NOT_FOUND404новый объект не найден в chessboard«Объект не найден»no retry
CRM_PROPERTY_OBJECT_NOT_AVAILABLE409статус нового объекта не «Свободен»«Объект недоступен»no retry

Acceptance Criteria

  • Happy path (Application). Given сделка Application; When смена на свободный объект того же застройщика; Then 200, новый объект/цена, Kafka-событий нет, в таймлайне PropertyObjectChanged.
  • Happy path (Booking). Given сделка Booking с активной бронью; When смена объекта; Then 200, бронь Cancelled, BookingId = null, Status = Application, опубликован booking-cancelled (старый объект).
  • No-op. Given PropertyObjectId == deal.PropertyObjectId; When смена; Then 409 CRM_DEAL_PROPERTY_OBJECT_UNCHANGED.
  • Другой застройщик (INV-DEAL-4). Given новый объект другого DeveloperCompanyId; When смена; Then 409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN.
  • Статус Sale (INV-DEAL-4). Given deal.Status = Sale; When смена; Then 409 CRM_DEAL_PROPERTY_OBJECT_CHANGE_FORBIDDEN.
  • Терминальная сделка. Given Status ∈ {Completed, Cancelled}; When смена; Then 409 CRM_DEAL_ALREADY_TERMINAL.
  • Новый объект занят/не найден. Given статус нового объекта не «Свободен» → 409 CRM_PROPERTY_OBJECT_NOT_AVAILABLE; объект не найден в реплике → 404 CRM_PROPERTY_OBJECT_NOT_FOUND.
  • Гонка с worker. Given worker booking-expiration истёк бронь параллельно; Then разрешается оптимистически: проигравший перечитывает актуальный статус сделки/брони.

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

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

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