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 area | agent |
| Feature group | deal |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/move-deal-step.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — ответственный агент, руководитель агентства или Менеджер ПИН |
| Auth policy / Permissions | crm.deals.manage (агентская сторона); Менеджер ПИН — platform-scope |
| Scope (RBAC) | организация (агентство): record-level по OriginAgencyCompanyId + участие исполнителя |
| Record-level | OriginAgencyCompanyId = actor.CompanyId; рядовой агент дополнительно — активный участник-исполнитель (ExecutorAgentUserId = actor.UserId); руководитель агентства — любая сделка своего OriginAgencyCompanyId; Менеджер ПИН — обход record-level |
| Tenant isolation | Сделка TenantId = DeveloperCompanyId (владелец доски — застройщик, ADR-0052). Операция агентской стороны идёт по явному предикату OriginAgencyCompanyId, а не по авто-tenant-фильтру |
| Audit | update: StepId/StepGroup(/Status), UpdatedByUserId, Version; бизнес-история — строка таймлайна StageChanged |
Сделка чужого агентства не раскрывается: и отсутствие записи, и отсутствие прав на неё возвращают одинаковый
404 CRM_DEAL_NOT_FOUND. Рядовой агент дополнительно должен быть активным исполнителем сделки
(ExecutorAgentUserId = actor.UserId); руководитель агентства видит любую сделку своего
OriginAgencyCompanyId; Менеджер ПИН — единственный актор, обходящий record-level.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/agent/crm/deals/{dealId}/step |
| Success status | 200 (обновлённая карточка) |
| Idempotency header | не применяется — повтор на тот же этап = no-op, гонка ловится конкурентной проверкой версии |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty; существующая сделка своего агентства | — |
TargetStepId | body | Guid | Да | != Guid.Empty; живой этап TenantId сделки (INV-DEAL-5) | — |
CancelReason | body | string? | Нет (обязателен при переходе в группу Cancelled) | 1..1000 после trim, если задан | — |
RowVersion | body | uint? | Нет | наблюдаемая версия карточки для оптимистической блокировки drag-n-drop | — |
ActorUserId | identity claims | Guid | Да | из JWT | — |
ActorAgencyCompanyId | identity claims | Guid | Да | агентство актора — record-level предикат по OriginAgencyCompanyId сделки | — |
IsAgencyHead | identity claims | bool | Да | флаг «руководитель агентства» (доступ на все сделки своего OriginAgencyCompanyId) | — |
IsPinManager | identity claims | bool | Да | platform-scope Менеджера ПИН (обход record-level) | — |
Модель ответа DealCardModel
Проекция карточки канбана; дом полей — deal.md, здесь только дельта. EscrowAccountNumber
(Sensitive) и данные фиксации в карту не входят.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
DealId | Guid | Да | DealEntity.Id | — | Идентификатор сделки. |
Title | string | Да | DealEntity.Title | — | Заголовок карточки. |
StepId | Guid | Да | DealEntity.StepId (после перехода) | — | Текущий этап канбана. |
StepGroup | string (enum PipelineStepGroup) | Да | DealEntity.StepGroup | — | Системная группа этапа. |
Status | string (enum DealStatus) | Да | DealEntity.Status | — | Статус процесса после перехода. |
PropertyObjectId | Guid | Да | DealEntity.PropertyObjectId | — | Объект сделки (chessboard). |
ProjectId | Guid | Да | DealEntity.ProjectId | — | Проект объекта. |
Price | Money { Amount: decimal, Currency: string } | Да | DealEntity.Price (owned) | — | Стоимость объекта (snapshot). |
PrimaryInvestorUserId | Guid | Да | DealEntity.PrimaryInvestorUserId | — | Основной инвестор сделки. |
ExecutorAgentUserId | Guid | Да | DealEntity.ExecutorAgentUserId | — | Текущий исполнитель (ответственный агент). |
ChatStatus | string (enum DealChatStatus) | Да | DealEntity.ChatStatus | — | Готовность чата сделки. |
UpdatedAt | DateTimeOffset | Да | DealEntity.UpdatedAt | — | Момент перехода. |
RowVersion | uint | Да | DealEntity.RowVersion | — | Токен для следующего перетаскивания. |
Алгоритм
- Контекст и доступ. Операцию выполняет ответственный агент / руководитель агентства / Менеджер ПИН
(permission
crm.deals.manage). - Проверка входа и предусловий.
- формат:
DealId/TargetStepId!= Guid.Empty, длинаCancelReason1..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).
- формат:
- Что читаем. Сначала сделку (нужен её
TenantIdдля проверки этапа), затем целевой этап-приёмник. - Бизнес-правила и переходы (INV-DEAL-5). Если
TargetStepId == deal.StepId→ no-op: вернуть текущую карточку (идемпотентный повтор), мутаций и событий нет. Иначе переход:- guard: этап того же застройщика и живой (иначе
CRM_DEAL_STEP_FORBIDDEN); денормализацияStepId ← step.Id,StepGroup ← step.Group; - в пределах группы:
Statusне меняется; - смена группы:
Statusсинхронизируется по маппингу групп (pipeline-step.md):Application→Application,Booking→Booking,Contract/Payment/Registration→Sale,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; при переходе вCancelled—CancelledAt = now,CancelReason. В таймлайн — строкаStageChanged(TimelineStageChangedPayload { FromStepId, FromStepName, ToStepId, ToStepName, ToStepGroup }, deal-timeline-event.md, INV-TLE-2).
- guard: этап того же застройщика и живой (иначе
- Что меняется. Сделка (этап/группа/статус) + строка таймлайна — в одном согласованном изменении.
- События.
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 повтор событий не публикует. - Результат. Карточка после перехода (
StepId,StepGroup,Status,UpdatedAt, свежийRowVersion) → HTTP200. Если карточку параллельно перетащил другой участник (или переданный клиентом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). При переходе в группу Cancelled —
CancelledAt = now, сохранён CancelReason, активная бронь отменена (INV-DEAL-8) и дополнительно опубликован
DealCancelledEvent (pin.crm.deal-cancelled.v1).
Агент видит новую колонку сразу в ответе, но карточка чата (messenger), уведомление участникам
(notifications) и — при отмене — освобождение объекта в chessboard появляются с лагом (SLA консюмеров
< 5 мин); порядок переходов per-deal гарантирован ключом DealId.
Постусловие (no-op, TargetStepId == Deal.StepId). Состояние сделки, таймлайн и outbox не изменены;
возвращается текущая карточка.
Постусловие (отказ). Ни мутации сделки, ни строки таймлайна, ни события.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | DealStageChangedEvent → pin.crm.deal-stage-changed.v1 (всегда, кроме no-op); при переходе в Cancelled — дополнительно DealCancelledEvent → pin.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_FAILED | 400 | DealId/TargetStepId = Guid.Empty или длина CancelReason вне 1..1000 | «Проверьте корректность запроса» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделки нет или она чужого агентства (record-level скрывает существование) | «Сделка не найдена» | no retry |
CRM_DEAL_STEP_FORBIDDEN | 422 | целевой этап чужого TenantId/архивный; переход в группу Cancelled без CancelReason; переход в группу Completed через agent-эндпоинт (INV-DEAL-5/7) | «Недопустимый целевой этап» | no retry |
CRM_DEAL_CONTRACT_TYPE_REQUIRED | 409 | переход в группу статуса Sale без ContractType/PaymentMethod (INV-DEAL-6) | «Укажите тип договора и способ оплаты» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | сделка в терминальном Completed/Cancelled (INV-DEAL-9) | «Сделка завершена и не редактируется» | no retry |
CRM_DEAL_CONFLICT | 409 | конкурентная гонка 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,FromStepId→ToStepId,ActorUserId; ФИО/телефон в логи не пишутся); metrics:crm_deal_move_step_total{result}, latency p95; traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path. Given нетерминальная сделка своего агентства и живой целевой этап другой группы; When агент
перетаскивает карточку с актуальным
RowVersion; ThenStepId/StepGroup/Statusобновлены по маппингу, добавлена строка таймлайнаStageChanged, опубликованоDealStageChangedEvent,200с новымRowVersion. - No-op. Given
TargetStepId == Deal.StepId; WhenPUT; Then200с текущей карточкой, без мутаций, строк таймлайна и событий. - Отмена без причины. Given переход в группу
CancelledбезCancelReason; WhenPUT; Then422 CRM_DEAL_STEP_FORBIDDEN, состояние не изменено. - Отмена с причиной. Given переход в группу
Cancelledс непустымCancelReason; WhenPUT; ThenStatus → Cancelled, заданыCancelledAt/CancelReason, активная бронь отменена (INV-DEAL-8), опубликованыDealStageChangedEventиDealCancelledEvent. - Sale без договора. Given переход в группу статуса
SaleбезContractType/PaymentMethod; WhenPUT; Then409 CRM_DEAL_CONTRACT_TYPE_REQUIRED(INV-DEAL-6). - Completed через агента. Given переход в группу
Completedчерез agent-эндпоинт; WhenPUT; Then422 CRM_DEAL_STEP_FORBIDDEN(группа зарезервирована за lifecycle-методом застройщика, INV-DEAL-7). - Терминальная. Given сделка в
Completed/Cancelled; WhenPUT; Then409 CRM_DEAL_ALREADY_TERMINAL(INV-DEAL-9). - Гонка drag-n-drop. Given карточку параллельно переместили или передан устаревший
RowVersion; WhenPUT; Then409 CRM_DEAL_CONFLICT; проигравший перечитывает доску, победитель — первый закоммитивший. - Чужая сделка. Given сделка другого агентства; When
PUT; Then404 CRM_DEAL_NOT_FOUND(существование не раскрывается). - Чужой этап. Given целевой этап чужого
TenantIdили архивный; WhenPUT; Then422 CRM_DEAL_STEP_FORBIDDEN. - Downstream (eventual). Given успешный переход; When проходит ≤ 5 мин; Then в чате появляется системная
карточка «этап изменён» (
messenger) и уведомление участникам (notifications); при отмене объект освобождается вchessboard.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/pin-manager/crm/deals/{dealId}/move-step — Ручной перевод по этапу (Менеджер ПИН)
- Use Cases (1): UC-CRM-003. Движение сделки по канбан-доске
- scenarios (1): UC-OVR-006. Сделка: от брони до регистрации (инвестор-инициатор)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности (дом полей, INV-DEAL-5..9, метод
MoveToStep): deal.md; этапы и группы: pipeline-step.md; таймлайнStageChanged: deal-timeline-event.md. - Событие: deal-stage-changed.md; при отмене — deal-cancelled.md.
- Пример command-эндпоинта area agent: cancel-fixation.md, create-booking-request.md.