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

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 areadeveloper
Feature grouppipeline
Статусdraft
Документdocs/06-services/crm/api/developer/reorder-steps.md

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

ПроверкаЗначение
Actor typeUser (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId)
Auth policy / Permissionscrm.pipeline.manage (создание/переименование/цвет/порядок/архив этапов; Менеджер ПИН — platform-scope)
Scope (RBAC)организация (застройщик): менеджер оперирует этапами своего TenantId
Record-levelкаждый PipelineStepEntity.TenantId == actor.DeveloperCompanyId (иначе этап не входит в набор)
Tenant isolationTenantId = DeveloperCompanyId в каждом запросе (ADR-0052)
Auditupdate (UpdatedByUserId = actor у каждого сдвинутого этапа, инкремент Version)
Доступ

Reorder доступен менеджеру застройщика только в рамках своего TenantId (или Менеджеру ПИН, platform-scope). Набор Items обязан покрывать этапы только одного tenant — этап другого застройщика в наборе даёт CRM_STEP_FORBIDDEN.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/developer/crm/pipeline-steps/order
Success status200 (порядок сохранён; тело пустое; клиент перечитывает доску GET /api/developer/crm/pipeline-steps)
Idempotency headerне применяется — повтор того же набора естественно идемпотентен по конечному состоянию, но защищён RowVersion (см. шаг 5)
Correlationсквозной trace через заголовок traceparent

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

Важно

Items обязан перечислять все живые этапы доски целиком — частичный reorder запрещён (доска одна на tenant, Order уникален tenant-wide). Неполный набор → 409 CRM_STEP_SET_INCOMPLETE.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
ActorUserIdidentity claimsGuidДаJWT менеджера; пишется в UpdatedByUserId сдвинутых этапов
ItemsbodyIReadOnlyList<PipelineStepOrderItem>Дане пусто; StepId без дублей; Order без дублей; набор покрывает все живые этапы tenant (шаг 2)

Модель элемента PipelineStepOrderItem

Коллекция Items живёт внутри именованной модели (типизация, без голого массива пар).

ПолеТипОбязательностьВалидация / ОграниченияОписание
StepIdGuidДа!= Guid.Empty; существующий живой этап в TenantId (шаг 2)Идентификатор этапа-колонки.
OrderintДа>= 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 не фиксировались.

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.pipeline.manage; менеджер ПИН — platform-scope); tenant — TenantId = DeveloperCompanyId. Идемпотентность естественная (reorder переустанавливает конечный порядок); защита от гонок — по RowVersion (шаг 5).

  2. Проверка входа и предусловий.

    • 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.manageCRM_STEP_FORBIDDEN.
  3. Что читаем. Все живые этапы доски tenant под блокировкой строк (против параллельного reorder того же tenant, INV-STEP-2). По загруженному набору выполняется сверка полноты (шаг 2): множество id этапов должно совпадать с Items.StepId. Group берётся из уже загруженных этапов (иммутабелен, INV-STEP-1); сделки не читаются (порядок колонок их не затрагивает).

  4. Бизнес-правила и переходы. По каждому этапу присваивается новый Order из Items:

    • guard: этап жив (архивный исключён на шаге 2);
    • маппинг: Order = новый из Items; Group не меняется (иммутабельно); UpdatedByUserId = ActorUserId, UpdatedAt = now;
    • если Order не изменился — этап пропускается (нет мутации, факт не фиксируется);
    • для реально сдвинутых — фиксируется доменный факт PipelineStepChanged.

    Итоговое множество Order уникально (следствие валидации Items), что и гарантирует INV-STEP-2 на COMMIT.

  5. Что меняется. Сдвинутым этапам присваивается новый Order (пакетно). Промежуточная временная коллизия Order (например при свопе двух колонок) допустима: unique ux_pipeline_steps_tenant_order объявлен deferrable и проверяется только на COMMIT. Конкурентный доступ — по версии строки каждого этапа: если параллельный reorder закоммитил свои изменения между чтением и записью → CRM_STEP_REORDER_CONFLICT (клиент перечитывает доску и повторяет).

  6. События. Интеграционных Kafka-событий нет: этапы — внутренний справочник CRM, внешние контексты видят только StepGroup сделки в pin.crm.deal-stage-changed.v1 (deal-stage-changed.md), а он от порядка колонок не зависит. Внутренние доменные факты PipelineStepChanged (по одному на сдвинутый этап) инвалидируют кэш доски; топика pin.crm.pipeline-step-* не существует.

  7. Согласованность. Все сдвиги Order фиксируются в одном согласованном изменении (атомарно); deferrable ux_pipeline_steps_tenant_order валидируется на COMMIT (в этом смысл пакетного reorder). Нарушение уникальности (проскочивший дубль/гонка вне RowVersion) → откат всего изменения → CRM_STEP_ORDER_DUPLICATE (БД-бэкстоп после валидации шага 2).

  8. Результат. Тела ответа нет → HTTP 200 с пустым телом. Клиент перечитывает доску GET /api/developer/crm/pipeline-steps.

Диаграмма

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

ТипДетали
Integration eventsnone (порядок колонок не пересекает границу контекста; объекты недвижимости/сделки/chessboard не затрагиваются)
External callsnone
Cache / projections / searchдоменные PipelineStepChanged инвалидируют кэш доски застройщика (внутренние подписчики); сделки не перемещаются
Notificationsnone (настройка доски — без уведомлений участникам)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Items пусто; дубли StepId/Order; Order < 0; StepId = Guid.Empty«Проверьте корректность порядка этапов»no retry
CRM_STEP_NOT_FOUND404какой-то StepId не найден/архивирован в TenantId«Этап воронки не найден»no retry
CRM_STEP_FORBIDDEN403чужой tenant / нет crm.pipeline.manage«Нет доступа к настройке доски»no retry
CRM_STEP_SET_INCOMPLETE409набор не покрывает все живые этапы tenant (частичный reorder)«Передайте порядок всех этапов доски»no retry
CRM_STEP_ORDER_DUPLICATE409нарушение unique Order на COMMIT (проскочивший дубль/гонка вне RowVersion)«Порядок этапов конфликтует, повторите»retry (перечитка доски)
CRM_STEP_REORDER_CONFLICT409версионный конфликт с параллельным 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; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

Acceptance criteria

  • Given полный набор всех живых этапов доски с уникальными Order, When менеджер сохраняет порядок, Then 200 с пустым телом; при последующем GET /api/developer/crm/pipeline-steps доска отражает новый порядок.
  • Given своп двух колонок (временная коллизия Order в промежуточном состоянии), When reorder, Then 200 — уникальность проверяется deferrable-индексом только на COMMIT.
  • Given у части этапов Order не изменился, When reorder, Then такие этапы пропущены (нет мутации, факт PipelineStepChanged не фиксируется), сдвинутые — обновлены.
  • Given Items не покрывает все живые этапы (частичный reorder), When reorder, Then 409 CRM_STEP_SET_INCOMPLETE.
  • Given дубли StepId/Order, Order < 0 или StepId = Guid.Empty, When reorder, Then 400 VALIDATION_FAILED.
  • Given какой-то StepId не найден/архивирован/чужого tenant, When reorder, Then 404 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.manage403 CRM_STEP_FORBIDDEN.

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

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

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

  • Сущность и инварианты: 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.