POST /api/pin-manager/crm/deals/{dealId}/move-step — Ручной перевод по этапу (Менеджер ПИН)
Назначение
Даёт Менеджеру ПИН вручную подтвердить/перевести сделку на этап канбана застройщика, когда штатный
авто-переход недоступен (сбой воркера/консюмера, арбитраж, ручное подтверждение стадии — US-MGR-05).
Перевод — тот же доменный переход MoveToStep (INV-DEAL-5), что и на канбане агента, но со сквозным
(кросс-tenant) platform-scope доступом Менеджера ПИН (US-MGR-01: авто-участник всех сделок). Меняет текущий
этап и, при смене системной группы, синхронизирует статус процесса; каждый перевод фиксируется в таймлайне
двумя строками — StageChanged (сам переход) и ManualNote (пометка о ручном характере, INV-TLE-5).
Терминальные группы Completed/Cancelled через этот метод недостижимы — завершение и отмена выполняются
отдельными lifecycle-операциями. Дом полей, статусы и инварианты — DealEntity.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | pin-manager |
| Feature group | deal |
| Статус | draft |
| Документ | docs/06-services/crm/api/pin-manager/move-deal-step.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — Менеджер ПИН (оператор платформы) |
| Auth policy / Permissions | crm.deals.manage в platform-scope (сквозной доступ ко всем тенантам); гейт области pin-manager |
| Scope (RBAC) | платформа (кросс-tenant): Менеджер ПИН видит и ведёт любую сделку (US-MGR-01) |
| Record-level | не применяется — обход record-level и tenant-фильтра (platform-scope) |
| Tenant isolation | Сделка TenantId = DeveloperCompanyId (владелец доски — застройщик, ADR-0052). Операция сквозная; целевой TenantId берётся из самой сделки, а не из claims актора |
| Audit | update: StepId/StepGroup(/Status), UpdatedByUserId, версия записи; бизнес-история — строки таймлайна StageChanged + ManualNote (append-only, deal-timeline-event.md) |
Permission crm.deals.manage в platform-scope даёт Менеджеру ПИН сквозной кросс-tenant доступ: record-level
и tenant-фильтр не применяются, целевой TenantId определяется из самой сделки, а не из claims актора
(ADR-0052, US-MGR-01). В отличие от агентского аналога
move-deal-step.md (record-level по OriginAgencyCompanyId), здесь
доступна любая сделка любого застройщика.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/pin-manager/crm/deals/{dealId}/move-step |
| Success status | 200 (обновлённая карточка сделки) |
| Idempotency header | не применяется — повтор на тот же этап = no-op (шаг 4); гонка ловится сравнением версии карточки |
| Correlation | сквозной trace через заголовок traceparent |
POST … /move-step(а неPUT … /step, как на канбане агента): это явная ручная команда-действие Менеджера ПИН с пометкой-обоснованием (Note), а не REST-замена ресурса.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty; существующая сделка (шаг 2) | — |
TargetStepId | body | Guid | Да | != Guid.Empty; живой этап TenantId сделки, нетерминальная группа (шаг 2, INV-DEAL-5) | — |
Note | body | string? | Нет | 0..2000 после trim; текст ручной пометки ManualNote (шаг 4) | не PII (свободный текст оператора) |
RowVersion | body | uint? | Нет | наблюдаемая версия карточки для оптимистической блокировки (шаг 5) | — |
ActorUserId | identity claims | Guid | Да | из JWT Менеджера ПИН; фиксируется как автор перехода и строк таймлайна | — |
Модель ответа DealCardModel
Проекция карточки канбана; дом полей — DealEntity. 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 | — | Стоимость объекта недвижимости (snapshot). |
PrimaryInvestorUserId | Guid | Да | DealEntity.PrimaryInvestorUserId | — | Основной инвестор сделки (возможны соинвесторы InvestorSecondary). |
ExecutorAgentUserId | Guid | Да | DealEntity.ExecutorAgentUserId | — | Текущий агент-исполнитель сделки (получатель комиссии). |
ChatStatus | string (enum DealChatStatus) | Да | DealEntity.ChatStatus (проекция) | — | Готовность чата сделки. |
UpdatedAt | DateTimeOffset | Да | DealEntity.UpdatedAt | — | Момент перехода. |
RowVersion | uint | Да | DealEntity.RowVersion | — | Токен для следующей операции. |
Алгоритм
- Контекст и доступ. Операцию выполняет Менеджер ПИН с permission
crm.deals.manageв platform-scope;TenantIdцелевой сделки берётся из неё самой (шаг 3), а не из claims актора. - Проверка входа и предусловий.
- формат:
DealId != Guid.Empty,TargetStepId != Guid.Empty, длинаNote0..2000(если задан) → иначеVALIDATION_FAILED; - существование сделки: сделки с таким
DealIdнет →CRM_DEAL_NOT_FOUND(404); - существование целевого этапа: этап должен быть живым этапом того же застройщика (
step.TenantId == deal.TenantId, не архивный) — иначеCRM_DEAL_STEP_FORBIDDEN(422, INV-DEAL-5); - допустимость целевой группы: группа этапа не может быть терминальной
Completed/Cancelled— завершение (INV-DEAL-7) и отмена (INV-DEAL-8) выполняются отдельными lifecycle-операциями, которые публикуют свои события (deal-completed/deal-cancelled); иначе —CRM_DEAL_STEP_FORBIDDEN(422); - допустимость состояния: сделка не в терминальном статусе (
Status ∉ {Completed, Cancelled}, INV-DEAL-9) — иначеCRM_DEAL_ALREADY_TERMINAL(409).
- формат:
- Что читаем. Сделку по
DealIdсквозным доступом (без tenant/record-level предиката — сквозной доступ Менеджера ПИН, US-MGR-01); нужен еёTenantIdдля проверки этапа. Затем этап-приёмник поTargetStepId. - Бизнес-правила и переходы. Если
TargetStepId == deal.StepId→ no-op: вернуть текущую карточку, мутаций/событий/строк таймлайна нет. Иначе переходMoveToStep(step, actor = PinManager)(INV-DEAL-5):- guard:
step.TenantId == deal.TenantId, этап не архивный (доменный отказ →CRM_DEAL_STEP_FORBIDDEN); денормализацияStepId ← step.Id,StepGroup ← step.Group; - в пределах группы (
step.Group == deal.StepGroup):Statusне меняется; - смена группы:
Statusсинхронизируется по маппингу групп (pipeline-step.md):Application→Application,Booking→Booking,Contract/Payment/Registration→Sale. Группы статусаSaleтребуютContractType/PaymentMethod(INV-DEAL-6) → иначеCRM_DEAL_CONTRACT_TYPE_REQUIRED; - маппинг:
UpdatedByUserId ← ActorUserId,UpdatedAt ← now, версия записи +1. В таймлайн добавляются две строки в той же согласованной записи (INV-TLE-2):StageChanged(TimelineStageChangedPayload { FromStepId, FromStepName, ToStepId, ToStepName, ToStepGroup }) иManualNote(TimelineManualNotePayload { Text }, INV-TLE-5: создаёт только Менеджер ПИН/Служба заботы) —Text = Note?.Trim(), иначе канонический маркер «Этап переведён вручную (Менеджер ПИН)» (US-MGR-05/06);ActorRole = PinManager.
- guard:
- Что меняется. Сделка (этап/группа/статус, автор, версия) и две append-only строки таймлайна
изменяются в одной согласованной записи. Оптимистическая блокировка: если сделку параллельно изменил
другой участник (или переданный
RowVersionустарел) →CRM_DEAL_CONFLICT(409); клиент перечитывает карточку. No-op повтор (шаг 4) ничего не меняет. - Событие. При реальном переходе публикуется интеграционное
DealStageChangedEvent→ topicpin.crm.deal-stage-changed.v1(ContractVersion = 1,AggregateType = "Deal"), payload{ DealId, TenantId, FromStepId, ToStepId, FromStepGroup?, ToStepGroup, FromStatus, ToStatus, ChangedByUserId = ActorUserId, ChangedAt = UpdatedAt }— контракт deal-stage-changed.md. Строки таймлайна собственных Kafka-событий не порождают. No-op событий не публикует. - Результат. Проекция карточки после перехода (
StepId,StepGroup,Status,UpdatedAt, свежийRowVersion) → HTTP200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | DealStageChangedEvent → pin.crm.deal-stage-changed.v1 (всегда, кроме no-op) |
| Внешние вызовы | нет (этап-приёмник читается из локального справочника этапов) |
| Проекции / поиск | поисковая проекция сделки обновляется по StepGroup/StepId; сделка перемещается между колонками канбана |
| Уведомления | опосредованно: notifications — консюмер deal-stage-changed (уведомление участникам); messenger — системная карточка «этап изменён» в чате сделки |
Ошибки и failure modes
Код CRM_DEAL_STEP_FORBIDDEN (422) объединяет два условия: целевой этап чужого TenantId или архивный, и
целевая группа терминальная (Completed/Cancelled) — обе недостижимы через этот метод, только через
отдельные lifecycle-операции (INV-DEAL-5/7/8). Терминальная сделка (Status ∈ {Completed, Cancelled}) не
редактируется независимо от целевого этапа — 409 CRM_DEAL_ALREADY_TERMINAL (INV-DEAL-9). Конкурентное
изменение карточки (устаревший RowVersion) — 409 CRM_DEAL_CONFLICT, ретрай после перечитки карточки.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | DealId/TargetStepId = Guid.Empty или длина Note > 2000 | «Проверьте корректность запроса» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделки с DealId нет | «Сделка не найдена» | no retry |
CRM_DEAL_STEP_FORBIDDEN | 422 | целевой этап чужого TenantId/архивный; целевая группа терминальная Completed/Cancelled (INV-DEAL-5/7/8) | «Недопустимый целевой этап» | 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 | конкурентное изменение сделки (устаревший RowVersion) | «Сделка изменена, обновите данные» | retry (перечитать карточку) |
Предусловия и постусловия
Предусловия (истинны до сценария):
- актор — Менеджер ПИН с permission
crm.deals.manageв platform-scope; кросс-tenant, целевойTenantIdберётся из самой сделки; - сделка нетерминальна (
Status ∉ {Completed, Cancelled}, INV-DEAL-9); - целевой этап — живой этап того же застройщика (
step.TenantId == deal.TenantId, не архивный), нетерминальная группа (неCompleted/Cancelled, INV-DEAL-5).
Постусловия (гарантированы после реального перехода):
StepId/StepGroupобновлены; при смене группыStatusсинхронизирован по маппингу групп (Application/Booking/Sale);UpdatedByUserId/UpdatedAt, версия записи +1;- в ленту добавлены две append-only строки —
StageChangedиManualNote(INV-TLE-5) — в одном согласованном изменении; - опубликовано интеграционное
DealStageChangedEvent(топикpin.crm.deal-stage-changed.v1) — атомарно с мутацией; - No-op (
TargetStepId == deal.StepId): состояние, события и таймлайн не меняются.
Интеграционная последовательность (после коммита)
Синхронно в запросе: проверки (INV-DEAL-5..9) → MoveToStep(actor = PinManager) → сохранение сделки + двух строк таймлайна + фиксация исходящего события → 200 клиенту (карточка с новым StepId/Status/RowVersion; сделка сразу в новой колонке канбана, обе строки — в «Ходе сделки»). Далее — асинхронно, событие доставляется гарантированно (at-least-once), окно eventual-consistency — секунды:
DealStageChangedEvent→pin.crm.deal-stage-changed.v1:messenger— системная карточка «этап изменён» в чате сделки;notifications— уведомление участникам; поисковая проекция сделки обновляется поStepGroup/StepId.
До доставки участники уже видят обновлённую карточку сделки (source of truth — crm), а карточка в
чате/уведомление/поисковый индекс догоняют с задержкой.
Что предоставляет: DealStageChangedEvent { DealId, TenantId, FromStepId, ToStepId, FromStepGroup?, ToStepGroup, FromStatus, ToStatus, ChangedByUserId, ChangedAt }, ContractVersion = 1, AggregateType = "Deal", ключ партиционирования — DealId (порядок переходов в пределах сделки сохраняется); no-op события не публикует. Что ожидает: справочник этапов pipeline_steps содержит целевой этап того же tenant (не архивный) — читается синхронно из своей схемы, внешних вызовов нет. Конкурентность/компенсации: оптимистическая блокировка по RowVersion — при устаревшей версии → CRM_DEAL_CONFLICT, проигравший перечитывает и повторяет; компенсаций/саги нет — переход, две строки таймлайна и событие фиксируются атомарно на одном агрегате.
Acceptance Criteria
- Happy path в пределах группы. Дано: сделка нетерминальна, целевой этап той же группы того же застройщика. Когда:
POST …/move-step {targetStepId}. Тогда:200,StepIdсменился,Statusбез изменений, добавлены строкиStageChanged+ManualNote, опубликованоDealStageChangedEvent. - Смена группы. Дано: переход из группы
BookingвContract, у сделки заданыContractType/PaymentMethod. Когда:POST. Тогда:200,StepGroup = Contract,Status:Booking → Sale. - Смена в
Saleбез данных договора. Дано: переход в группу статусаSale,ContractType/PaymentMethodне заданы. Когда:POST. Тогда:409 CRM_DEAL_CONTRACT_TYPE_REQUIRED(INV-DEAL-6). - No-op. Дано:
TargetStepId == deal.StepId. Когда:POST. Тогда:200c текущей карточкой, без мутаций/события/строк таймлайна. - Терминальная целевая группа. Дано: целевой этап группы
Completed/Cancelled. Когда:POST. Тогда:422 CRM_DEAL_STEP_FORBIDDEN(завершение/отмена — отдельные lifecycle-операции). - Чужой/архивный этап. Дано:
step.TenantId != deal.TenantIdили этап архивный. Когда:POST. Тогда:422 CRM_DEAL_STEP_FORBIDDEN. - Терминальная сделка. Дано:
Status ∈ {Completed, Cancelled}. Когда:POST. Тогда:409 CRM_DEAL_ALREADY_TERMINAL(INV-DEAL-9). - Конкурентное изменение. Дано: сделку параллельно изменили (устаревший
RowVersion). Когда:POST. Тогда:409 CRM_DEAL_CONFLICT, клиент перечитывает карточку. - Кастомная/каноническая пометка. Дано:
Noteзадан. Тогда: строкаManualNoteсодержитNote.Trim(); еслиNoteпуст — канонический маркер «Этап переведён вручную (Менеджер ПИН)». - Невалидный вход. Когда:
DealId/TargetStepId = Guid.EmptyилиNote> 2000. Тогда:400 VALIDATION_FAILED.
Совместимость и наблюдаемость
- Контракт: route +
TargetStepId/Note/RowVersion(body) →DealCardModel. Новые optional-поля карточки — additive; смена маршрута/семантики статуса — breaking (migration + rollout). Новые значенияPipelineStepGroup/DealStatus— additive (см. deal-stage-changed.md). - Logs:
pin-manager deal-move-step requested/moved/noop/conflict(DealId,TenantId,FromStepId→ToStepId,ActorUserId; ФИО/телефон и текстNoteв логи не пишутся); metrics:crm_pin_manager_deal_move_step_total{result}, latency p95; traces — сквозной trace черезtraceparent; dashboardpin-crm.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/pin-manager/crm/deals/{dealId}/override-status — Ручная смена статуса сделки
- Use Cases (3): UC-CRM-003. Движение сделки по канбан-доске, UC-CRM-006. Вмешательство Менеджера ПИН, UC-SUP-004. Ручное вмешательство через permissions
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности (дом полей, INV-DEAL-5..9, переход
MoveToStep): deal.md; этапы и группы: pipeline-step.md; таймлайнStageChanged/ManualNote(INV-TLE-5): deal-timeline-event.md. - Событие: deal-stage-changed.md.
- Аналог на канбане агента (record-level,
PUT …/step): move-deal-step.md. - Карта области
pin-managerконтекста: README.md (раздел «API»).