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

PUT /api/developer/crm/pipeline-steps/{stepId} — Переименовать/перекрасить этап

Менеджер застройщика правит название и цвет настраиваемого этапа своей kanban-доски (застройщик «создаёт, переименовывает, меняет порядок и цвет» этапы). Операция трогает только Name/Color: привязка к системной группе Group иммутабельна (INV-STEP-1, история сделок и статистика считаются по группе), а порядок Order меняет отдельная команда reorder. Дом полей, инвариантов и индексов — pipeline-step.md.

Назначение

Обновить отображаемые атрибуты одного этапа воронки застройщика (Name, Color) с сохранением уникальности имени в рамках tenant (INV-STEP-2) и оптимистической блокировкой по RowVersion (параллельная правка имени/цвета/порядка тем же или другим менеджером).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper
Feature grouppipeline
Статусdraft
Документdocs/06-services/crm/api/developer/update-step.md

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

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

Правит этап менеджер застройщика — владелец доски по tenant (TenantId = DeveloperCompanyId), либо Менеджер ПИН (platform-scope). Этап чужого tenant не раскрывается: обращение к нему даёт 403 CRM_STEP_FORBIDDEN.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/developer/crm/pipeline-steps/{stepId}
Success status200 (этап обновлён; тело — обновлённая проекция этапа)
Idempotency headerне применяется — идемпотентность естественная (повтор с тем же Name/Color/RowVersion даёт тот же результат; устаревший RowVersion409)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
StepIdrouteGuidДа!= Guid.Empty; существующий живой этап в TenantId (шаг 2)
NamebodystringДаtrim, 1..128, не пустое; уникально по lower(Name) среди живых этапов tenant кроме себя (INV-STEP-2)
ColorbodystringДаформат #RRGGBB, regex ^#[0-9A-Fa-f]{6}$ (char(7))
Groupbodystring? (enum PipelineStepGroup)Нетесли задан — обязан совпасть с текущим step.Group; смена запрещена (INV-STEP-1)
RowVersionbodyuintДаоптимистический токен, полученный из list/get; несовпадение → 409
ActorUserIdidentity claimsGuidДаJWT менеджера; пишется в UpdatedByUserId
Важно

Order в теле не принимается — позицию на доске меняет отдельная команда reorder (пакетный сдвиг Order под своим unique-индексом). Group вынесен в тело только ради явного отказа при попытке смены (иначе поле можно опустить).

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

Проекция агрегата — дом полей pipeline-step.md; ниже перечислена только дельта, значимая для правки. Та же модель отдаётся list/get этапов застройщика (полный состав — в доме).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPipelineStepEntity.IdИдентификатор этапа.
Groupstring (enum PipelineStepGroup)ДаPipelineStepEntity.GroupСистемная группа (не изменилась операцией).
NamestringДаPipelineStepEntity.NameНовое название этапа.
ColorstringДаPipelineStepEntity.ColorНовый цвет #RRGGBB.
OrderintДаPipelineStepEntity.OrderПозиция на доске (не изменилась операцией).
IsSeedDefaultboolДаPipelineStepEntity.IsSeedDefaultДефолтный SEED-этап (для UI-подсказок).
VersionlongДаPipelineStepEntity.VersionИнкрементирована на 1.
RowVersionuintДаPipelineStepEntity.RowVersionНовый токен оптимистической блокировки для следующей правки клиента.
UpdatedAtDateTimeOffsetДаPipelineStepEntity.UpdatedAtМомент правки.

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.pipeline.manage; менеджер ПИН — platform-scope); tenant — TenantId = DeveloperCompanyId. Идемпотентность естественная — защита от параллельной правки строится на RowVersion (шаги 2, 5).
  2. Проверка входа и предусловий.
    • StepId != Guid.Empty; Name после trim 1..128 и не пустое; Color соответствует ^#[0-9A-Fa-f]{6}$; RowVersion задан — иначе VALIDATION_FAILED;
    • этап есть среди живых в TenantId (архивный невидим) — иначе CRM_STEP_NOT_FOUND (правка архива запрещена);
    • scope: step.TenantId == TenantId — иначе CRM_STEP_FORBIDDEN (чужая доска);
    • иммутабельность группы (INV-STEP-1): если Group задан и Group != step.GroupCRM_STEP_GROUP_IMMUTABLE (сменить группу этапа нельзя — история/статистика по группе);
    • уникальность имени (INV-STEP-2): живой этап того же tenant с таким Name (ci), кроме себя — если есть → CRM_STEP_NAME_DUPLICATE.
  3. Что читаем. Только сам этап (single-aggregate update). Связанные сделки не читаются (переименование/перекрас не трогают привязку сделок к этапу). Строчная блокировка не берётся — конкурентность точечной правки покрывает оптимистический RowVersion (в отличие от reorder/archive, которым нужна пессимистичная блокировка порядка).
  4. Бизнес-правила и переходы. Правятся только Name/Color; Group не редактируется (INV-STEP-1 соблюдается конструкцией). Маппинг: Name = trim(Name), Color, UpdatedByUserId = ActorUserId, UpdatedAt = now, Version += 1; Order/IsSeedDefault/CreatedBy* не меняются. Статусной машины у этапа нет (живой ↔ архив — отдельной командой). Фиксируется доменный факт PipelineStepChanged.
  5. Что меняется. Обновляются Name/Color этапа. Оптимистическая блокировка по RowVersion: если между чтением клиента и записью этап был изменён (правка/reorder) — конфликт CRM_STEP_CONCURRENCY_CONFLICT; гонку вставки одинакового имени ловит unique-индекс ux_pipeline_steps_tenant_nameCRM_STEP_NAME_DUPLICATE. Сделки на этапе не перечитываются (FK deals.step_id без изменений).
  6. События. Интеграционных Kafka-событий нет: этап — внутренний справочник CRM, внешние контексты видят только StepGroup, а он не изменился (см. deal-stage-changed.md). Внутренний доменный факт PipelineStepChanged инвалидирует кэш доски.
  7. Согласованность. Обновление этапа и audit/Version фиксируются в одном согласованном изменении (атомарно).
  8. Результат. Карточка PipelineStepModel обновлённого этапа (новые Name/Color, инкрементированный Version, свежий RowVersion) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsnone (правка имени/цвета kafka-событий не публикует; StepGroup не изменился)
External callsnone
Cache / projections / searchдоменное PipelineStepChanged инвалидирует кэш kanban-доски застройщика; сделки на этапе не перечитываются (FK deals.step_id без изменений)
Notificationsnone (настроечная операция; уведомления участникам не шлются)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400StepId = Guid.Empty, пустое/длинное Name, Color не #RRGGBB, нет RowVersion«Проверьте корректность полей этапа»no retry
CRM_STEP_NOT_FOUND404этап не найден в TenantId либо архивирован«Этап не найден»no retry
CRM_STEP_FORBIDDEN403чужой tenant / нет crm.pipeline.manage«Нет доступа к этапу»no retry
CRM_STEP_GROUP_IMMUTABLE409тело содержит Group != step.Group (INV-STEP-1)«Группу этапа изменить нельзя»no retry
CRM_STEP_NAME_DUPLICATE409имя занято живым этапом tenant (INV-STEP-2 / unique-индекс)«Этап с таким названием уже есть»no retry (сменить имя)
CRM_STEP_CONCURRENCY_CONFLICT409RowVersion устарел (параллельная правка/reorder)«Этап изменён параллельно, обновите данные»retry (перечитать RowVersion)

Acceptance Criteria

  • Given живой этап в TenantId с актуальным RowVersion, When валидные Name/Color, Then Name/Color обновлены, Group/Order/IsSeedDefault/CreatedBy* неизменны, Version += 1, свежий RowVersion, ответ 200.
  • Given тело содержит Group != step.Group, When update, Then CRM_STEP_GROUP_IMMUTABLE (409) по INV-STEP-1 — группу этапа сменить нельзя (история/статистика считаются по группе).
  • Given имя занято другим живым этапом того же tenant (ci), When update, Then CRM_STEP_NAME_DUPLICATE (409) по INV-STEP-2 (pre-check или ux_pipeline_steps_tenant_name).
  • Given RowVersion устарел (параллельная правка или reorder), When update, Then CRM_STEP_CONCURRENCY_CONFLICT (409); клиент перечитывает и повторяет.
  • Given Color не соответствует ^#[0-9A-Fa-f]{6}$, пустое после trim Name или StepId = Guid.Empty, When update, Then VALIDATION_FAILED (400).
  • Given этап архивирован либо не существует в TenantId, When update, Then CRM_STEP_NOT_FOUND (404); чужая доска → CRM_STEP_FORBIDDEN (403).
  • Given валидная правка Name/Color, When изменения сохранены, Then привязки сделок (FK deals.step_id) не меняются, внешняя StepGroup не меняется, Kafka-события не публикуются, доменный PipelineStepChanged лишь инвалидирует кэш доски.

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

  • Route/тело стабильны; добавление optional-полей в PipelineStepModel — additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Enum PipelineStepGroup — строкой; неизвестное значение в Group на входе → VALIDATION_FAILED (строгая типизация десериализатора).
  • Logs: pipeline-step-update requested/succeeded/conflict (StepId, TenantId, Group; без PII); metrics: crm_pipeline_step_update_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущность, инварианты и индексы: pipeline-step.md (INV-STEP-1 иммутабельность Group, INV-STEP-2 уникальность имени, RowVersion/soft-delete, доменное PipelineStepChanged); использование этапа сделками — deal.md.
  • События: карта — ../../README.md; внешняя видимость группы — deal-stage-changed.md (интеграционных событий этап не публикует).
  • ADR: 0052 tenancy, 0056 simplicity-first.