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

PUT /api/agent/crm/deals/{dealId}/step — Переместить сделку по этапу (канбан)

Drag-n-drop карточки сделки на единой канбан-доске /crm: агент перетаскивает карточку в другой настраиваемый этап застройщика. Переход меняет StepId, денормализует StepGroup, при смене группы синхронизирует Status (INV-DEAL-5). Дом полей сделки, статусы и инварианты — в deal.md.

Назначение

Дать агентской стороне двигать свою сделку по воронке застройщика без синхронного обращения в UI застройщика: перетаскивание карточки меняет текущий этап и, при переходе в другую системную группу, статус процесса. Настраиваемые этапы — внутренний справочник CRM (pipeline-step.md); внешним консюмерам нужна лишь системная группа, поэтому она денормализуется на сделку (StepGroup) и несётся в событие. Переход в группу Cancelled требует CancelReason (INV-DEAL-5).

Metadata

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

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

ПроверкаЗначение
Actor typeUser — ответственный агент, руководитель агентства или Менеджер ПИН
Auth policy / Permissionscrm.deals.manage (агентская сторона); Менеджер ПИН — platform-scope
Scope (RBAC)организация (агентство): record-level по OriginAgencyCompanyId + участие исполнителя
Record-levelOriginAgencyCompanyId = actor.CompanyId; рядовой агент дополнительно — активный участник-исполнитель (ExecutorAgentUserId = actor.UserId); руководитель агентства — любая сделка своего OriginAgencyCompanyId; Менеджер ПИН — обход record-level
Tenant isolationСделка TenantId = DeveloperCompanyId (владелец доски — застройщик, ADR-0052). Операция агентской стороны идёт по явному предикату OriginAgencyCompanyId, а не по авто-tenant-фильтру
Auditupdate: StepId/StepGroup(/Status), UpdatedByUserId, Version; бизнес-история — строка таймлайна StageChanged
Доступ

Сделка чужого агентства не раскрывается: и отсутствие записи, и отсутствие прав на неё возвращают одинаковый 404 CRM_DEAL_NOT_FOUND. Рядовой агент дополнительно должен быть активным исполнителем сделки (ExecutorAgentUserId = actor.UserId); руководитель агентства видит любую сделку своего OriginAgencyCompanyId; Менеджер ПИН — единственный актор, обходящий record-level.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/agent/crm/deals/{dealId}/step
Success status200 (обновлённая карточка)
Idempotency headerне применяется — повтор на тот же этап = no-op, гонка ловится конкурентной проверкой версии
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DealIdrouteGuidДа!= Guid.Empty; существующая сделка своего агентства
TargetStepIdbodyGuidДа!= Guid.Empty; живой этап TenantId сделки (INV-DEAL-5)
CancelReasonbodystring?Нет (обязателен при переходе в группу Cancelled)1..1000 после trim, если задан
RowVersionbodyuint?Нетнаблюдаемая версия карточки для оптимистической блокировки drag-n-drop
ActorUserIdidentity claimsGuidДаиз JWT
ActorAgencyCompanyIdidentity claimsGuidДаагентство актора — record-level предикат по OriginAgencyCompanyId сделки
IsAgencyHeadidentity claimsboolДафлаг «руководитель агентства» (доступ на все сделки своего OriginAgencyCompanyId)
IsPinManageridentity claimsboolДаplatform-scope Менеджера ПИН (обход record-level)

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

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

ПолеТипОбязательностьИсточникMaskingОписание
DealIdGuidДаDealEntity.IdИдентификатор сделки.
TitlestringДаDealEntity.TitleЗаголовок карточки.
StepIdGuidДаDealEntity.StepId (после перехода)Текущий этап канбана.
StepGroupstring (enum PipelineStepGroup)ДаDealEntity.StepGroupСистемная группа этапа.
Statusstring (enum DealStatus)ДаDealEntity.StatusСтатус процесса после перехода.
PropertyObjectIdGuidДаDealEntity.PropertyObjectIdОбъект сделки (chessboard).
ProjectIdGuidДаDealEntity.ProjectIdПроект объекта.
PriceMoney { Amount: decimal, Currency: string }ДаDealEntity.Price (owned)Стоимость объекта (snapshot).
PrimaryInvestorUserIdGuidДаDealEntity.PrimaryInvestorUserIdОсновной инвестор сделки.
ExecutorAgentUserIdGuidДаDealEntity.ExecutorAgentUserIdТекущий исполнитель (ответственный агент).
ChatStatusstring (enum DealChatStatus)ДаDealEntity.ChatStatusГотовность чата сделки.
UpdatedAtDateTimeOffsetДаDealEntity.UpdatedAtМомент перехода.
RowVersionuintДаDealEntity.RowVersionТокен для следующего перетаскивания.

Алгоритм

  1. Контекст и доступ. Операцию выполняет ответственный агент / руководитель агентства / Менеджер ПИН (permission crm.deals.manage).
  2. Проверка входа и предусловий.
    • формат: DealId/TargetStepId != Guid.Empty, длина CancelReason 1..1000 (если задан) — иначе ошибка валидации;
    • сделка существует и принадлежит агентству актора (record-level предикат) — иначе CRM_DEAL_NOT_FOUND (404, существование сделок чужого агентства не раскрывается);
    • целевой этап существует, живой и того же застройщика (TenantId сделки) — иначе CRM_DEAL_STEP_FORBIDDEN (INV-DEAL-5; чужие/архивные этапы не раскрываются);
    • допустимость состояния: Status ∉ {Completed, Cancelled} (INV-DEAL-9) — иначе CRM_DEAL_ALREADY_TERMINAL (409).
  3. Что читаем. Сначала сделку (нужен её TenantId для проверки этапа), затем целевой этап-приёмник.
  4. Бизнес-правила и переходы (INV-DEAL-5). Если TargetStepId == deal.StepIdno-op: вернуть текущую карточку (идемпотентный повтор), мутаций и событий нет. Иначе переход:
    • guard: этап того же застройщика и живой (иначе CRM_DEAL_STEP_FORBIDDEN); денормализация StepId ← step.Id, StepGroup ← step.Group;
    • в пределах группы: Status не меняется;
    • смена группы: Status синхронизируется по маппингу групп (pipeline-step.md): Application→Application, Booking→Booking, Contract/Payment/RegistrationSale, Cancelled→Cancelled. Guards: группы статуса Sale требуют ContractType/PaymentMethod (INV-DEAL-6) → иначе CRM_DEAL_CONTRACT_TYPE_REQUIRED; группа Completed зарезервирована за lifecycle-методом застройщика Complete (INV-DEAL-7) → на agent-эндпоинте CRM_DEAL_STEP_FORBIDDEN; группа Cancelled требует непустой CancelReason (INV-DEAL-5) → иначе CRM_DEAL_STEP_FORBIDDEN, и делегирует отмену сделки (INV-DEAL-8: активная бронь отменяется той же операцией);
    • UpdatedByUserId = actor, UpdatedAt = now; при переходе в CancelledCancelledAt = now, CancelReason. В таймлайн — строка StageChanged (TimelineStageChangedPayload { FromStepId, FromStepName, ToStepId, ToStepName, ToStepGroup }, deal-timeline-event.md, INV-TLE-2).
  5. Что меняется. Сделка (этап/группа/статус) + строка таймлайна — в одном согласованном изменении.
  6. События. DealStageChangedEvent → топик pin.crm.deal-stage-changed.v1 (ContractVersion = 1, AggregateId = DealId, AggregateType = "Deal"), payload { DealId, TenantId, FromStepId, ToStepId, FromStepGroup?, ToStepGroup, FromStatus, ToStatus, ChangedByUserId = ActorUserId, ChangedAt = UpdatedAt } — контракт deal-stage-changed.md; transactional outbox. Если переход завершился в группе Cancelled — дополнительно DealCancelledEvent → топик pin.crm.deal-cancelled.v1 (deal-cancelled.md, освобождение объекта консюмером chessboard). No-op повтор событий не публикует.
  7. Результат. Карточка после перехода (StepId, StepGroup, Status, UpdatedAt, свежий RowVersion) → HTTP 200. Если карточку параллельно перетащил другой участник (или переданный клиентом RowVersion устарел) — конкурентный конфликт → CRM_DEAL_CONFLICT (409); клиент перечитывает доску.

Диаграмма

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

Предусловия. Актор — ответственный агент / руководитель агентства / Менеджер ПИН с crm.deals.manage. Сделка существует, принадлежит агентству актора (record-level OriginAgencyCompanyId + участие исполнителя) и нетерминальна (Status ∈ {Application, Booking, Sale}, INV-DEAL-9). Целевой этап существует, живой и относится к тому же застройщику (TenantId сделки, INV-DEAL-5). Клиент располагает актуальным RowVersion, полученным при чтении доски.

Постусловие (успех, не no-op). Deal.StepId = TargetStepId, StepGroup = step.Group; при смене группы Status синхронизирован по маппингу групп; UpdatedByUserId = actor, UpdatedAt = now, RowVersion инкрементирован. В таймлайн добавлена строка StageChanged (append-only, INV-TLE-2). Опубликован DealStageChangedEvent (pin.crm.deal-stage-changed.v1, key DealId). При переходе в группу CancelledCancelledAt = now, сохранён CancelReason, активная бронь отменена (INV-DEAL-8) и дополнительно опубликован DealCancelledEvent (pin.crm.deal-cancelled.v1).

Eventual consistency

Агент видит новую колонку сразу в ответе, но карточка чата (messenger), уведомление участникам (notifications) и — при отмене — освобождение объекта в chessboard появляются с лагом (SLA консюмеров < 5 мин); порядок переходов per-deal гарантирован ключом DealId.

Постусловие (no-op, TargetStepId == Deal.StepId). Состояние сделки, таймлайн и outbox не изменены; возвращается текущая карточка.

Постусловие (отказ). Ни мутации сделки, ни строки таймлайна, ни события.

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

ТипДетали
Интеграционные событияDealStageChangedEventpin.crm.deal-stage-changed.v1 (всегда, кроме no-op); при переходе в Cancelled — дополнительно DealCancelledEventpin.crm.deal-cancelled.v1; всё в outbox
Внешние вызовыnone (этап-приёмник читается из локальной таблицы crm.pipeline_steps)
Проекции / поискпроекция сделки (DealSearchDocument) обновляется по StepGroup/StepId (Title/ФИО не затронуты); сделка перемещается между колонками канбана (индекс (tenant_id, step_id))
Уведомленияопосредованно: консюмер deal-stage-changed в notifications (уведомление участникам); messenger — системная карточка «этап изменён» в чате сделки

Ошибки и failure modes

Ошибки

Код CRM_DEAL_STEP_FORBIDDEN (422) объединяет три разных условия: целевой этап чужого TenantId или архивный, переход в группу Cancelled без CancelReason, и переход в группу Completed через agent-эндпоинт (она зарезервирована за lifecycle-методом застройщика Complete, INV-DEAL-7). Терминальная сделка (Completed/Cancelled) не редактируется независимо от целевого этапа — 409 CRM_DEAL_ALREADY_TERMINAL (INV-DEAL-9). Гонка drag-n-drop (устаревший RowVersion или параллельное перемещение) — 409 CRM_DEAL_CONFLICT, ретрай после перечитки доски.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400DealId/TargetStepId = Guid.Empty или длина CancelReason вне 1..1000«Проверьте корректность запроса»no retry
CRM_DEAL_NOT_FOUND404сделки нет или она чужого агентства (record-level скрывает существование)«Сделка не найдена»no retry
CRM_DEAL_STEP_FORBIDDEN422целевой этап чужого TenantId/архивный; переход в группу Cancelled без CancelReason; переход в группу Completed через agent-эндпоинт (INV-DEAL-5/7)«Недопустимый целевой этап»no retry
CRM_DEAL_CONTRACT_TYPE_REQUIRED409переход в группу статуса Sale без ContractType/PaymentMethod (INV-DEAL-6)«Укажите тип договора и способ оплаты»no retry
CRM_DEAL_ALREADY_TERMINAL409сделка в терминальном Completed/Cancelled (INV-DEAL-9)«Сделка завершена и не редактируется»no retry
CRM_DEAL_CONFLICT409конкурентная гонка drag-n-drop«Доска изменена, обновите данные»retry (перечитать доску)

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

  • Контракт: route + TargetStepId/CancelReason/RowVersion (body) → DealCardModel. Новые optional-поля карточки — additive; смена маршрута/семантики статуса — breaking (migration + rollout). Новые значения PipelineStepGroup/DealStatus — additive (см. deal-stage-changed.md).
  • Logs: deal-move-step requested/moved/noop/conflict (DealId, TenantId, FromStepIdToStepId, ActorUserId; ФИО/телефон в логи не пишутся); metrics: crm_deal_move_step_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path. Given нетерминальная сделка своего агентства и живой целевой этап другой группы; When агент перетаскивает карточку с актуальным RowVersion; Then StepId/StepGroup/Status обновлены по маппингу, добавлена строка таймлайна StageChanged, опубликовано DealStageChangedEvent, 200 с новым RowVersion.
  • No-op. Given TargetStepId == Deal.StepId; When PUT; Then 200 с текущей карточкой, без мутаций, строк таймлайна и событий.
  • Отмена без причины. Given переход в группу Cancelled без CancelReason; When PUT; Then 422 CRM_DEAL_STEP_FORBIDDEN, состояние не изменено.
  • Отмена с причиной. Given переход в группу Cancelled с непустым CancelReason; When PUT; Then Status → Cancelled, заданы CancelledAt/CancelReason, активная бронь отменена (INV-DEAL-8), опубликованы DealStageChangedEvent и DealCancelledEvent.
  • Sale без договора. Given переход в группу статуса Sale без ContractType/PaymentMethod; When PUT; Then 409 CRM_DEAL_CONTRACT_TYPE_REQUIRED (INV-DEAL-6).
  • Completed через агента. Given переход в группу Completed через agent-эндпоинт; When PUT; Then 422 CRM_DEAL_STEP_FORBIDDEN (группа зарезервирована за lifecycle-методом застройщика, INV-DEAL-7).
  • Терминальная. Given сделка в Completed/Cancelled; When PUT; Then 409 CRM_DEAL_ALREADY_TERMINAL (INV-DEAL-9).
  • Гонка drag-n-drop. Given карточку параллельно переместили или передан устаревший RowVersion; When PUT; Then 409 CRM_DEAL_CONFLICT; проигравший перечитывает доску, победитель — первый закоммитивший.
  • Чужая сделка. Given сделка другого агентства; When PUT; Then 404 CRM_DEAL_NOT_FOUND (существование не раскрывается).
  • Чужой этап. Given целевой этап чужого TenantId или архивный; When PUT; Then 422 CRM_DEAL_STEP_FORBIDDEN.
  • Downstream (eventual). Given успешный переход; When проходит ≤ 5 мин; Then в чате появляется системная карточка «этап изменён» (messenger) и уведомление участникам (notifications); при отмене объект освобождается в chessboard.

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

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

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