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

PUT /api/agent/crm/{deals|leads}/{id}/responsible — Назначить/снять ответственного (куратора)

Руководитель/администратор агентства-владельца (или текущий Executor) назначает или снимает ответственного (Responsible) процесса CRM — лида или сделки. Ответственный — опциональный куратор ведения (руководитель группы, наставник), может отличаться от исполнителя и не является получателем комиссии. Операция едина для лида (lead.md, INV-LEAD-7) и сделки (deal.md, INV-DEAL-3): меняется только носитель роли Responsible, исполнитель и Origin-атрибуция не затрагиваются.

Назначение

Дать агентству контроль над сложными процессами: закрепить за лидом/сделкой куратора, который видит процесс и подсвечивается в чате, но не получает комиссию (её получает Executor). В отличие от передачи ведения (reassign-executor.md) назначение/снятие ответственного не влияет на исполнителя и получателя вознаграждения — это чисто организационная роль. Роль опциональна: активный Responsible — не более одного; снятие возвращает процесс к состоянию «без куратора».

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent
Feature groupdeals / leads (кураторство)
Статусdraft
Документdocs/06-services/crm/api/agent/reassign-responsible.md

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

ПроверкаЗначение
Actor typeUser — руководитель/администратор агентства-владельца или текущий активный Executor процесса
Auth policy / Permissionsсделка — crm.deals.manage; лид — crm.leads.manage
Scope (RBAC)организация (агентство-владелец): scope-иерархия ADR-0025
Record-levelдоступ по членству (ADR-0071): руководитель/админ агентства-владельца (сделка — агентство активного Executor = OriginAgencyCompanyId в v1; лид — TenantId = AgencyCompanyId) или текущий Executor
Tenant isolationлид — TenantId = AgencyCompanyId; сделка — TenantId = DeveloperCompanyId (ADR-0052), агентский доступ — по предикату агентства-владельца + членству
Auditupdate (UpdatedByUserId = actor, Version++); сделка — строки таймлайна MemberAdded/MemberRemoved (роль Responsible, deal-timeline-event.md)
Доступ

Выполнить может руководитель/администратор агентства-владельца процесса (сделка — агентство активного Executor = OriginAgencyCompanyId в v1; лид — TenantId = AgencyCompanyId) либо сам текущий Executor — иначе CRM_DEAL_FORBIDDEN / CRM_LEAD_FORBIDDEN.

HTTP-контракт

ПолеЗначение
MethodPUT
Routeсделка — /api/agent/crm/deals/{dealId}/responsible; лид — /api/agent/crm/leads/{leadId}/responsible
Success status200 (обновлённая карточка с составом)
Idempotency headerне применяется — повтор с тем же ResponsibleUserId (или повторное снятие) = no-op (200); гонка ловится проверкой версии
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DealId / LeadIdrouteGuidДа!= Guid.Empty; существующий процесс агентства-владельца
ResponsibleUserIdbodyGuid?Нетnullснять ответственного; иначе != Guid.Empty, активный агент (AgentMembership.Status = Active) агентства-владельца
Reasonbodystring?Нет≤ 1000 после trim, если заданfree-text: в логи не писать целиком
RowVersionbodyuint?Нетнаблюдаемая версия карточки для оптимистической блокировки
ActorUserIdidentity claimsGuidДаиз JWT
ActorAgencyCompanyIdidentity claimsGuidДаагентство актора — предикат агентства-владельца процессаTenant/record isolation
IsAgencyHeadidentity claimsboolДаруководитель/админ агентства-владельца

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

Проекция процесса после назначения/снятия куратора (дом полей — deal.md / lead.md; здесь только дельта). Та же модель, что у reassign-executor.md.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealEntity.Id / LeadEntity.IdИдентификатор процесса.
Kindstring (Deal / Lead)ДароутТип процесса.
ExecutorAgentUserIdGuidДаExecutorAgentUserIdТекущий исполнитель (этой операцией не меняется).
ResponsibleAgentUserIdGuid?НетResponsibleAgentUserId (после операции)Новый ответственный/куратор; null — снят.
ActiveMembersList<{ UserId: Guid, Role: string (enum ParticipantRole) }>Даактивные DealMemberEntity / LeadParticipantEntity (RemovedAt is null)Полный активный состав (не более одного Responsible).
UpdatedAtDateTimeOffsetДаUpdatedAtМомент операции.
RowVersionuintДаVersionТокен для следующей операции.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/админ агентства-владельца (permission crm.deals.manage / crm.leads.manage) либо текущий Executor процесса.
  2. Проверка входа и предусловий.
    • формат: DealId/LeadId != Guid.Empty; при заданном ResponsibleUserId!= Guid.Empty; Reason ≤ 1000 — иначе ошибка валидации;
    • процесс существует и принадлежит агентству-владельцу актора — иначе CRM_DEAL_NOT_FOUND / CRM_LEAD_NOT_FOUND (404);
    • право актора: IsAgencyHead по агентству-владельцу или ActorUserId == ExecutorAgentUserId — иначе CRM_DEAL_FORBIDDEN / CRM_LEAD_FORBIDDEN (403);
    • допустимость состояния: сделка не терминальна (Status ∉ {Completed, Cancelled}, INV-DEAL-9) — иначе CRM_DEAL_ALREADY_TERMINAL (409); лид активен (Status = Active) — иначе CRM_LEAD_NOT_ACTIVE (409);
    • назначение (ResponsibleUserId != null): кандидат — активный агент агентства-владельца (AgentMembership.Status = Active, agent-membership.md) — иначе CRM_PARTICIPANT_NOT_IN_AGENCY (422);
    • без no-op: назначение того же ResponsibleUserId при уже совпадающем активном Responsible, либо снятие при отсутствующем Responsible — no-op (возврат текущей карточки, мутаций и событий нет).
  3. Что читаем. Процесс (родитель мутации) и его активный состав; при назначении — членство кандидата по реплике/справке identity agent-membership.
  4. Бизнес-правила и переходы (INV-DEAL-3 / INV-LEAD-7; Responsible опционален, ≤ 1 активный). Атомарно:
    • назначение: прежний активный Responsible (если был) снимается (RemovedAt = now) либо, если это тот же UserId в другой роли — роль меняется; кандидат становится активным Responsible (если уже участник иной нефинансовой роли — роль меняется на Responsible; иначе добавляется новым участником ролью Responsible). Responsible может совпадать или не совпадать с Executor — ограничения «≠ Executor» нет;
    • снятие (ResponsibleUserId = null): активный Responsible снимается из состава (RemovedAt = now); если тот же пользователь остаётся исполнителем (Executor) — снимается только роль куратора, участие исполнителя сохраняется;
    • Executor / PrimaryInvestorUserId не трогаются; денормализация ResponsibleAgentUserId = ResponsibleUserId (или null); UpdatedByUserId = actor, UpdatedAt = now, Version++;
    • Origin-поля иммутабельны (INV-DEAL-10 / INV-LEAD-6) — не пересчитываются.
  5. Что меняется. Состав участников (назначение/снятие Responsible), денормализованный ResponsibleAgentUserId, для сделки — строка таймлайна MemberAdded/MemberRemoved (роль Responsible) — в одном согласованном изменении. Комиссия и получатель вознаграждения не затрагиваются (роль нефинансовая).
  6. Результат. Карточка процесса с новым ResponsibleAgentUserId (или null), полным ActiveMembers, UpdatedAt, свежим RowVersion → HTTP 200. Параллельная правка состава разрешается оптимистической блокировкой (Version): проигравший получает CRM_DEAL_CONFLICT / CRM_LEAD_CONFLICT и перечитывает карточку.

Диаграмма

Предусловия и постусловия

Предусловия. Актор — руководитель/админ агентства-владельца или текущий Executor с соответствующим permission. Процесс нетерминален (сделка Status ∉ {Completed, Cancelled}; лид Status = Active). При назначении ResponsibleUserId — активный агент агентства-владельца.

Постусловие (успех, назначение). Активный Responsible процесса — ResponsibleUserId (ровно один); прежний куратор (если был) снят; денормализованный ResponsibleAgentUserId обновлён; Executor не изменён; Version++. Опубликовано DealMembersChangedEvent / LeadParticipantsChangedEvent с полным активным составом и дельтой ChangeType = ResponsibleChanged.

Постусловие (успех, снятие). ResponsibleAgentUserId = null, активный Responsible снят из состава; Executor и его инвариант единственности не нарушены; опубликовано событие с ChangeType = ResponsibleChanged (дельта Removed).

Постусловие (no-op). Назначение уже действующего куратора / снятие при отсутствующем — состав, денормализация и outbox не изменены; возвращается текущая карточка.

Постусловие (отказ). Ни состав, ни денормализация, ни события.

Окно eventual-consistency. Актор видит куратора сразу; синхронизация участников чата (messenger) и уведомления (notifications) приходят с лагом (SLA консюмеров < 1 мин); порядок изменений per-process гарантирован ключом DealId/LeadId.

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

ТипДетали
Интеграционные событиясделка — DealMembersChangedEventpin.crm.deal-members-changed.v1 (ChangeType = ResponsibleChanged); лид — LeadParticipantsChangedEventpin.crm.lead-participants-changed.v1 (ChangeType = ResponsibleChanged); outbox, key = DealId/LeadId
Внешние вызовыпри назначении — проверка активного членства кандидата в агентстве-владельце (реплика/справка identity agent-membership, timeout 2 c)
Проекции / поискденормализованный ResponsibleAgentUserId обновляется (индекс where not null); проекция поиска сделок не затрагивается (роль куратора в поиск не входит)
Уведомленияопосредованно: консюмеры deal-members-changed/lead-participants-changedmessenger реконсилирует участников чата, notifications уведомляет назначенного/снятого куратора

Ошибки и failure modes

Ошибки

Главные гейты: процесс должен быть нетерминален (сделка Status ∉ {Completed, Cancelled} — иначе CRM_DEAL_ALREADY_TERMINAL; лид Status = Active — иначе CRM_LEAD_NOT_ACTIVE), а назначаемый кандидат — активный агент агентства-владельца (иначе CRM_PARTICIPANT_NOT_IN_AGENCY).

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат DealId/LeadId/ResponsibleUserId/Reason«Проверьте корректность запроса»no retry
CRM_DEAL_NOT_FOUND404сделки нет или она чужого агентства«Сделка не найдена»no retry
CRM_LEAD_NOT_FOUND404лида нет или он чужого агентства«Лид не найден»no retry
CRM_DEAL_FORBIDDEN403актор — не руководитель/админ агентства-владельца и не текущий Executor сделки«Нет доступа к назначению куратора»no retry
CRM_LEAD_FORBIDDEN403то же для лида«Нет доступа к назначению куратора»no retry
CRM_PARTICIPANT_NOT_IN_AGENCY422назначаемый ResponsibleUserId — не активный агент агентства-владельца«Кандидат не состоит в агентстве»no retry
CRM_DEAL_ALREADY_TERMINAL409сделка Completed/Cancelled (INV-DEAL-9)«Сделка завершена и не редактируется»no retry
CRM_LEAD_NOT_ACTIVE409лид Expired/Archived«Лид неактивен»no retry
CRM_DEAL_CONFLICT / CRM_LEAD_CONFLICT409конкурентная правка состава/устаревший RowVersion«Данные изменены, обновите карточку»retry (перечитать)
No-op — не ошибка

Повторное назначение уже действующего Responsible или повторное снятие при отсутствующем кураторе — 200 с текущей карточкой, без мутаций и событий (проверяется на шаге 2 алгоритма, до бизнес-правил).

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

  • Контракт: route + ResponsibleUserId/Reason/RowVersion (body) → ProcessMembershipModel. Новые optional-поля — additive; смена семантики роли — breaking. Новые значения ParticipantRole — additive (см. deal-members-changed.md).
  • Logs (без PII): responsible reassigned (DealId/LeadId, FromResponsibleUserId → ToResponsibleUserId|null, ActorUserId; Reason/ФИО не пишутся); metrics crm_responsible_reassigned_total{kind, action}, latency p95 < 500 мс; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path (назначение). Given нетерминальная сделка с Executor A и агент C — активный агент агентства-владельца; When руководитель назначает C ответственным; Then активный Responsible — C (ResponsibleAgentUserId = C), Executor = A не изменён, опубликован DealMembersChangedEvent (ResponsibleChanged), 200.
  • Responsible ≠ Executor и Responsible = Executor. Given назначают Responsible, отличного от Executor — разрешено; When назначают тем же пользователем, что и Executor — тоже разрешено (ограничения «≠ Executor» нет); Then в обоих случаях 200.
  • Снятие. Given у процесса есть активный Responsible; When ResponsibleUserId = null; Then Responsible снят (ResponsibleAgentUserId = null), Executor не затронут, опубликовано событие ResponsibleChanged (дельта Removed).
  • Кандидат не из агентства. Given ResponsibleUserId — агент другого агентства (или без активного членства); When назначение; Then 422 CRM_PARTICIPANT_NOT_IN_AGENCY, состав не изменён.
  • No-op. Given назначают уже действующего куратора или снимают при отсутствующем; When PUT; Then 200 с текущей карточкой, без мутаций и событий.
  • Терминальная сделка / неактивный лид. Given сделка Completed/Cancelled409 CRM_DEAL_ALREADY_TERMINAL; лид Expired/Archived409 CRM_LEAD_NOT_ACTIVE.
  • Чужой актор. Given актор — рядовой со-агент; When операция; Then 403 CRM_DEAL_FORBIDDEN / CRM_LEAD_FORBIDDEN.
  • Гонка. Given параллельная правка состава / устаревший RowVersion; When операция; Then 409 CONFLICT; проигравший перечитывает карточку.
  • Downstream (eventual). Given успешное назначение/снятие; When проходит ≤ 1 мин; Then messenger реконсилирует участников чата, notifications уведомляет куратора.

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

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

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