PUT /api/developer/crm/pipeline-steps/order — Изменить порядок этапов
Менеджер застройщика перетаскивает колонки одной kanban-доски и сохраняет новый порядок пакетно, одним
изменением (настраиваемые этапы застройщика — порядок и цвет). Доска одна на застройщика
(pipeline-step.md); глобальная сортировка колонок — Group.Order, затем
Order, а Order уникален в рамках TenantId (unique ux_pipeline_steps_tenant_order, partial по живым).
Пакетный характер обязателен: при drag-drop меняются Order сразу у нескольких этапов, и промежуточные
состояния временно нарушают уникальность — поэтому индекс deferrable (проверка на COMMIT), а весь reorder
идёт в одном изменении. Дом полей и инвариантов — pipeline-step.md.
Назначение
Присваивает перечисленным этапам застройщика новые значения Order за один атомарный запрос, сохранив
инвариант INV-STEP-2 (уникальность Order в tenant) и защитившись от конкурентного reorder по
RowVersion. Группа (Group) при этом иммутабельна — переносится только позиция колонки. Вызывает менеджер
застройщика; тела ответа нет.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | developer |
| Feature group | pipeline |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/reorder-steps.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId) |
| Auth policy / Permissions | crm.pipeline.manage (создание/переименование/цвет/порядок/архив этапов; Менеджер ПИН — platform-scope) |
| Scope (RBAC) | организация (застройщик): менеджер оперирует этапами своего TenantId |
| Record-level | каждый PipelineStepEntity.TenantId == actor.DeveloperCompanyId (иначе этап не входит в набор) |
| Tenant isolation | TenantId = DeveloperCompanyId в каждом запросе (ADR-0052) |
| Audit | update (UpdatedByUserId = actor у каждого сдвинутого этапа, инкремент Version) |
Reorder доступен менеджеру застройщика только в рамках своего TenantId (или Менеджеру ПИН, platform-scope).
Набор Items обязан покрывать этапы только одного tenant — этап другого застройщика в наборе даёт
CRM_STEP_FORBIDDEN.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/developer/crm/pipeline-steps/order |
| Success status | 200 (порядок сохранён; тело пустое; клиент перечитывает доску GET /api/developer/crm/pipeline-steps) |
| Idempotency header | не применяется — повтор того же набора естественно идемпотентен по конечному состоянию, но защищён RowVersion (см. шаг 5) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Items обязан перечислять все живые этапы доски целиком — частичный reorder запрещён (доска одна на
tenant, Order уникален tenant-wide). Неполный набор → 409 CRM_STEP_SET_INCOMPLETE.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = DeveloperCompanyId | Tenant isolation |
ActorUserId | identity claims | Guid | Да | JWT менеджера; пишется в UpdatedByUserId сдвинутых этапов | — |
Items | body | IReadOnlyList<PipelineStepOrderItem> | Да | не пусто; StepId без дублей; Order без дублей; набор покрывает все живые этапы tenant (шаг 2) | — |
Модель элемента PipelineStepOrderItem
Коллекция Items живёт внутри именованной модели (типизация, без голого массива пар).
| Поле | Тип | Обязательность | Валидация / Ограничения | Описание |
|---|---|---|---|---|
StepId | Guid | Да | != Guid.Empty; существующий живой этап в TenantId (шаг 2) | Идентификатор этапа-колонки. |
Order | int | Да | >= 0; уникален в пределах Items | Новая позиция колонки на доске. |
Модель ответа
Тело ответа отсутствует — успех кодируется HTTP 200 c пустым телом. Обновлённую доску клиент получает
отдельным запросом GET /api/developer/crm/pipeline-steps (дом полей —
pipeline-step.md); отдавать её из reorder намеренно не требуется.
Предусловия и постусловия
Предусловия (должны быть истинны до вызова):
- у застройщика есть доска этапов (SEED-дефолты созданы при онбординге);
Itemsпокрывает все живые этапыTenantId(доска одна,Orderуникален tenant-wide — частичный reorder запрещён);Orderуникален внутриItems,StepIdбез повторов;- актор — менеджер застройщика с
crm.pipeline.manage(или менеджер ПИН, platform-scope).
Постусловия при успехе:
- сдвинутым этапам присвоен новый
Order;Groupкаждого этапа неизменна (INV-STEP-1); итоговое множествоOrderуникально в tenant (INV-STEP-2, проверка на COMMIT); - для реально сдвинутых этапов зафиксирован доменный факт
PipelineStepChanged; кэш доски инвалидирован; - интеграционных Kafka-событий не публиковалось (порядок колонок не пересекает границу контекста); сделки не перемещаются.
Постусловия при отказе: порядок этапов доски неизменен, факты PipelineStepChanged не фиксировались.
Алгоритм
-
Контекст и доступ. Выполняет менеджер застройщика (
User, permissioncrm.pipeline.manage; менеджер ПИН — platform-scope); tenant —TenantId = DeveloperCompanyId. Идемпотентность естественная (reorder переустанавливает конечный порядок); защита от гонок — поRowVersion(шаг 5). -
Проверка входа и предусловий.
Itemsне пусто; каждыйStepId != Guid.EmptyиOrder >= 0; нет повторовStepId; нет повторовOrderвнутриItems(INV-STEP-2 на входе) — иначеVALIDATION_FAILED;- существование: все
StepId— живые этапы своегоTenantId; если какой-то не найден, архивирован или принадлежит другому tenant →CRM_STEP_NOT_FOUND; - полнота набора:
Items.StepIdобязано покрывать все живые этапы tenant (доска одна,Orderуникален tenant-wide); частичный reorder запрещён (иначе уникальность не гарантировать) →CRM_STEP_SET_INCOMPLETE(сверка с загруженным набором, шаг 3); - scope: все этапы принадлежат
TenantId; чужой tenant / отсутствиеcrm.pipeline.manage→CRM_STEP_FORBIDDEN.
-
Что читаем. Все живые этапы доски tenant под блокировкой строк (против параллельного reorder того же tenant, INV-STEP-2). По загруженному набору выполняется сверка полноты (шаг 2): множество id этапов должно совпадать с
Items.StepId.Groupберётся из уже загруженных этапов (иммутабелен, INV-STEP-1); сделки не читаются (порядок колонок их не затрагивает). -
Бизнес-правила и переходы. По каждому этапу присваивается новый
OrderизItems:- guard: этап жив (архивный исключён на шаге 2);
- маппинг:
Order = новый из Items;Groupне меняется (иммутабельно);UpdatedByUserId = ActorUserId,UpdatedAt = now; - если
Orderне изменился — этап пропускается (нет мутации, факт не фиксируется); - для реально сдвинутых — фиксируется доменный факт
PipelineStepChanged.
Итоговое множество
Orderуникально (следствие валидацииItems), что и гарантирует INV-STEP-2 на COMMIT. -
Что меняется. Сдвинутым этапам присваивается новый
Order(пакетно). Промежуточная временная коллизияOrder(например при свопе двух колонок) допустима: uniqueux_pipeline_steps_tenant_orderобъявлен deferrable и проверяется только на COMMIT. Конкурентный доступ — по версии строки каждого этапа: если параллельный reorder закоммитил свои изменения между чтением и записью →CRM_STEP_REORDER_CONFLICT(клиент перечитывает доску и повторяет). -
События. Интеграционных Kafka-событий нет: этапы — внутренний справочник CRM, внешние контексты видят только
StepGroupсделки вpin.crm.deal-stage-changed.v1(deal-stage-changed.md), а он от порядка колонок не зависит. Внутренние доменные фактыPipelineStepChanged(по одному на сдвинутый этап) инвалидируют кэш доски; топикаpin.crm.pipeline-step-*не существует. -
Согласованность. Все сдвиги
Orderфиксируются в одном согласованном изменении (атомарно); deferrableux_pipeline_steps_tenant_orderвалидируется на COMMIT (в этом смысл пакетного reorder). Нарушение уникальности (проскочивший дубль/гонка внеRowVersion) → откат всего изменения →CRM_STEP_ORDER_DUPLICATE(БД-бэкстоп после валидации шага 2). -
Результат. Тела ответа нет → HTTP
200с пустым телом. Клиент перечитывает доскуGET /api/developer/crm/pipeline-steps.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (порядок колонок не пересекает границу контекста; объекты недвижимости/сделки/chessboard не затрагиваются) |
| External calls | none |
| Cache / projections / search | доменные PipelineStepChanged инвалидируют кэш доски застройщика (внутренние подписчики); сделки не перемещаются |
| Notifications | none (настройка доски — без уведомлений участникам) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | Items пусто; дубли StepId/Order; Order < 0; StepId = Guid.Empty | «Проверьте корректность порядка этапов» | no retry |
CRM_STEP_NOT_FOUND | 404 | какой-то StepId не найден/архивирован в TenantId | «Этап воронки не найден» | no retry |
CRM_STEP_FORBIDDEN | 403 | чужой tenant / нет crm.pipeline.manage | «Нет доступа к настройке доски» | no retry |
CRM_STEP_SET_INCOMPLETE | 409 | набор не покрывает все живые этапы tenant (частичный reorder) | «Передайте порядок всех этапов доски» | no retry |
CRM_STEP_ORDER_DUPLICATE | 409 | нарушение unique Order на COMMIT (проскочивший дубль/гонка вне RowVersion) | «Порядок этапов конфликтует, повторите» | retry (перечитка доски) |
CRM_STEP_REORDER_CONFLICT | 409 | версионный конфликт с параллельным reorder (между чтением и сохранением доски) | «Доску изменил другой менеджер, обновите и повторите» | retry (перечитка доски) |
Совместимость и наблюдаемость
- Route/тело стабильны;
Items— обязательный контейнер, добавление optional-полей вPipelineStepOrderItem— additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Enum на входе отсутствует (порядок — числовой). Ответ — пустойUnit; переход к возврату доски был бы additive. - Logs:
pipeline-reorder requested/succeeded/conflict(TenantId, число сдвинутых этапов; PII отсутствует); metrics:crm_pipeline_reorder_total{result},crm_pipeline_reorder_steps(гистограмма размера набора); traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Acceptance criteria
- Given полный набор всех живых этапов доски с уникальными
Order, When менеджер сохраняет порядок, Then200с пустым телом; при последующемGET /api/developer/crm/pipeline-stepsдоска отражает новый порядок. - Given своп двух колонок (временная коллизия
Orderв промежуточном состоянии), When reorder, Then200— уникальность проверяется deferrable-индексом только на COMMIT. - Given у части этапов
Orderне изменился, When reorder, Then такие этапы пропущены (нет мутации, фактPipelineStepChangedне фиксируется), сдвинутые — обновлены. - Given
Itemsне покрывает все живые этапы (частичный reorder), When reorder, Then409 CRM_STEP_SET_INCOMPLETE. - Given дубли
StepId/Order,Order < 0илиStepId = Guid.Empty, When reorder, Then400 VALIDATION_FAILED. - Given какой-то
StepIdне найден/архивирован/чужого tenant, When reorder, Then404 CRM_STEP_NOT_FOUND. - Given параллельный reorder закоммитил свои изменения между чтением и записью, When reorder коммитит,
Then
409 CRM_STEP_REORDER_CONFLICT(перечитать доску и повторить); проскочивший дубль на COMMIT →409 CRM_STEP_ORDER_DUPLICATE. - Given тот же набор отправлен повторно, When reorder, Then результат идемпотентен по конечному
состоянию; без
crm.pipeline.manage—403 CRM_STEP_FORBIDDEN.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-CRM-003. Движение сделки по канбан-доске
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность и инварианты: pipeline-step.md (INV-STEP-1 иммутабельность
Group, INV-STEP-2 уникальностьOrder— deferrable-индекс,RowVersion-гонка, доменноеPipelineStepChanged); сделки на этапах: deal.md. - Внешняя проекция группы этапа: deal-stage-changed.md (единственный
выход
StepGroupза пределы контекста); карта событий — ../../README.md. - ADR: 0052 tenancy, 0056 simplicity-first.