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

POST /api/v1/companies/{companyId}/agents/{agentUserId}/terminate — Исключение агента из агентства

Руководитель или админ агентства принудительно исключает агента из своей компании (ADR-0072): активное AgentMembershipEntity пары (AgentUserId, CompanyId) переводится Active → Left с LeaveReason = ExcludedByAgency и LeftAt = EffectiveAt. Учётка агента, его UserId, метрики и достижения сохраняются; членства в других агентствах не затрагиваются (ADR-0070). Отличается от добровольного ухода (leave-agency.md) инициатором (руководитель, а не сам агент) и причиной (ExcludedByAgency вместо SelfLeave).

Назначение

Развязка членства «агент ↔ агентство» по инициативе агентства без потери наработок и истории найма. Причина исключения фиксируется в LeaveReason, момент вступления в силу — в EffectiveAt (допускается отложенное увольнение). Перед снятием членства проверяется, что агент не остаётся единственным активным Executor ни на одном активном лиде/сделке/фиксации агентства без назначенного преемника (guard ADR-0071/0072). Owning-контекст — identity.

Metadata

ПолеЗначение
Сервис/контекстidentity
API areacompany
Feature groupagent-membership
Статусdraft
Документdocs/06-services/identity/api/company/terminate-agent-membership.md

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

ПроверкаЗначение
Actor typeUser (руководитель / админ агентства)
Auth policy / Permissionsauthenticated; permission identity.users.manage в scope Organization(companyId)
Scope (RBAC)Organization(companyId) — исключать можно только из своей компании; переводит назначение роли agent целевого агента из Organization в Platform, если у агента не остаётся Active-членств
Record-levelчленство целевого агента (agentUserId, companyId) принадлежит ростеру компании актора
Tenant isolationTenantId = companyId; мутация в scope Organization(companyId)
Auditupdate membership (Left, LeftAt, LeaveReason = ExcludedByAgency, UpdatedByUserId = actor); при обнулении/перевыборе — update user (зеркало CompanyId); revoke/regrant RoleAssignment роли agent
MFA/approvalне требуется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/companies/{companyId}/agents/{agentUserId}/terminate
Success status200
Idempotency headerне требуется (повтор без активного членства по паре — идемпотентный 404)
CorrelationOpenTelemetry trace (traceparent)

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claims (sub)GuidДавалидный JWT401 без токена
CompanyIdroute (companyId)GuidДасуществующая компания-агентство; ActorUserId имеет identity.users.manage в её scope403 при чужой компании / отсутствии права
AgentUserIdroute (agentUserId)GuidДаучётка UserType = Agent; активное членство пары (AgentUserId, CompanyId)
EffectiveAtbodyDateTimeOffset?Нет>= now (нельзя задним числом); при отсутствии = момент операции
Reasonbodystring?Нетсвободный комментарий руководителя (audit), <= 500 симв.; не влияет на LeaveReason
Пустое тело запроса

Тело может быть пустым: без EffectiveAt увольнение вступает в силу немедленно, LeaveReason всегда ExcludedByAgency (не передаётся клиентом).

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

ПолеТипОбязательностьИсточникMaskingОписание
MembershipIdGuidДаAgentMembershipEntity.IdЗакрытое членство.
AgentUserIdGuidДаAgentMembershipEntity.AgentUserIdИсключённый агент.
CompanyIdGuidДаAgentMembershipEntity.CompanyIdАгентство.
Statusstring (enum AgentMembershipStatus)ДаAgentMembershipEntity.StatusLeft.
LeaveReasonstring (enum LeaveReason)ДаAgentMembershipEntity.LeaveReasonExcludedByAgency.
LeftAtDateTimeOffsetДаAgentMembershipEntity.LeftAtМомент, с которого членство недействительно (= EffectiveAt).
EffectiveAtDateTimeOffsetДавход/nowМомент вступления исключения в силу.

Алгоритм

  1. Контекст и доступ. Операцию выполняет руководитель/админ агентства (ActorUserId из claim sub); без токена — 401. Требуется permission identity.users.manage в scope Organization(CompanyId); иначе IDENTITY_FORBIDDEN.
  2. Проверка входа и предусловий. CompanyId — существующая компания типа ∈ {Agency, Broker, Partner} в статусе Active; AgentUserId — учётка UserType = Agent. EffectiveAt (если передан) не в прошлом (иначе IDENTITY_TERMINATION_EFFECTIVE_AT_INVALID).
  3. Что читаем. Активное членство AgentMembership по паре (AgentUserId, CompanyId) — если его нет, IDENTITY_MEMBERSHIP_NOT_ACTIVE (агент уже исключён / не состоит в этом агентстве); активное назначение роли agent целевого агента и его прочие Active-членства (для решения по scope роли и зеркалу).
  4. Guard активных процессов (координация с crm). Перед снятием членства проверяется, что агент не остаётся единственным активным Executor ни на одном активном лиде, сделке или фиксации этого агентства без назначенного преемника (инвариант ADR-0071 «процесс нельзя оставить без Executor»). Проверка — предусловие, выполняемое координацией с crm (запрос перечня активных процессов агентства, где ExecutorAgentUserId = AgentUserId и преемник не назначен). Если такие процессы есть — отказ IDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSES со списком процессов под reassign; руководитель сначала переназначает ExecutorAgentUserId, затем повторяет исключение.
  5. Бизнес-правила и переходы (INV-AM2/AM3/AM6):
    • членство закрывается: Status = Left, LeftAt = EffectiveAt, LeaveReason = ExcludedByAgency (INV-AM3: Left ⇔ LeftAt != null ∧ LeaveReason != null, LeftAt > JoinedAt);
    • если это было выбранное активное агентство (зеркало UserEntity.CompanyId = CompanyId) — зеркало сбрасывается (перевыбор через POST /api/v1/me/active-agency); закрытие невыбранного зеркало не трогает;
    • назначение роли agent перевыпускается Organization → Platform только если у агента не остаётся ни одной Active-записи (INV-R3/AM6); при наличии других активных агентств scope сохраняется;
    • метрики и достижения агента (агрегация по AgentUserId) сохраняются.
  6. Что меняется. Обновляются членство, при необходимости — зеркало учётки и назначение роли agent (старое — в Revoked, новое в scope Platform); история членства не удаляется. Изменения и публикация событий — в одном согласованном изменении.
  7. Результат. TerminateAgentMembershipResult: MembershipId, AgentUserId, CompanyId, Status = Left, LeaveReason = ExcludedByAgency, LeftAt, EffectiveAt.
Историческая привязка не пересчитывается

Историческая привязка сделок/лидов/фиксаций не пересчитывается — снапшоты OriginAgencyCompanyId/ OriginAgentUserId в crm иммутабельны, back-fill запрещён (ADR-0061 п.4, ADR-0072 п.4).

Диаграмма

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

ТипДетали
Интеграционные событияAgentMembershipChangedKafkaEventpin.identity.agent-membership-changed.v1 (Status = Left, Reason = ExcludedByAgency, EffectiveAt, key = AgentUserId); при обнулении зеркала/смене scope — UserUpsertedKafkaEventpin.identity.users.v1, UserAccessChangedKafkaEventpin.identity.access.v1
Внешние вызовыкоординация с crm для guard активных процессов (предусловие, до перехода); каскад offboarding (soft-remove участий, перенос BookingDeadline/встреч, revoke RoleAssignment в rewards) — асинхронно по membership-событию (ADR-0072 п.4)
Проекции / поискростер агентства обновляется в crm по событию; user-centric метрики агента остаются (агрегация по AgentUserId)
Уведомленияагенту («вы исключены из агентства») и руководителю — через notifications по membership-событию

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
IDENTITY_FORBIDDEN403Нет identity.users.manage в scope Organization(CompanyId) / чужая компания«Недостаточно прав»no retry
IDENTITY_MEMBERSHIP_NOT_ACTIVE404Нет активного членства пары (AgentUserId, CompanyId) (уже исключён / не состоит)«Активное членство не найдено»no retry
IDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSES409Агент — единственный активный Executor на активных лидах/сделках/фиксациях без преемника«Сначала переназначьте ответственного на процессах агента»retry после reassign
IDENTITY_TERMINATION_EFFECTIVE_AT_INVALID400EffectiveAt в прошлом«Дата вступления в силу не может быть в прошлом»no retry
IDENTITY_MEMBERSHIP_INVALID_STATE409Нарушение INV-AM3 при закрытии членства«Недопустимое состояние членства»no retry
CONCURRENCY_CONFLICT409Гонка версий (параллельные terminate/leave/accept по паре)«Данные изменены, повторите»retry после перечитки

Acceptance Criteria

  • Given руководитель c identity.users.manage и агент с активным членством в его агентстве, у которого нет активных процессов, где он единственный Executor, When terminate, Then членство Active → Left (LeftAt = EffectiveAt, LeaveReason = ExcludedByAgency); если у агента не осталось Active-членств — роль agent перевыпущена Organization → Platform и зеркало CompanyId сброшено (если это было выбранное агентство); ответ содержит MembershipId/AgentUserId/CompanyId/Status = Left/ LeaveReason = ExcludedByAgency/LeftAt/EffectiveAt; публикуется AgentMembershipChangedKafkaEvent (Left, Reason = ExcludedByAgency, EffectiveAt).
  • Given агент — единственный активный Executor на активной сделке агентства без преемника, When terminate, Then IDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSES (409) со списком процессов под reassign; членство не меняется. После переназначения ExecutorAgentUserId повтор проходит.
  • Given агент состоит в нескольких агентствах, When terminate из одного, Then закрывается только членство этой пары; членства в других агентствах и их scope роли сохраняются; зеркало трогается только если исключаемое агентство было выбранным.
  • Given нет активного членства пары (AgentUserId, CompanyId), When terminate, Then IDENTITY_MEMBERSHIP_NOT_ACTIVE (404) — повтор безопасен (идемпотентный исход).
  • Given актор без identity.users.manage в scope компании (или из чужой компании), When terminate, Then IDENTITY_FORBIDDEN (403).
  • Given EffectiveAt в прошлом, When terminate, Then IDENTITY_TERMINATION_EFFECTIVE_AT_INVALID (400).
  • Given параллельные terminate и self-leave по одной паре, When конфликт версий, Then CONCURRENCY_CONFLICT (409); retry после перечитки.
  • Given исключение выполнено, Then метрики/достижения агента (агрегация по AgentUserId) и историческая привязка сделок/лидов/фиксаций сохранены (снапшоты crm иммутабельны, back-fill запрещён); crm запускает offboarding-каскад (soft-remove участий) по membership-событию асинхронно.

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

  • Новый эндпоинт, additive. Событие pin.identity.agent-membership-changed.v1 несёт Reason/EffectiveAt как additive-поля — ContractVersion не бампится (ADR-0072 п.2).
  • Logs: ActorUserId, CompanyId, AgentUserId, MembershipId, EffectiveAt; metrics: identity_terminate_agent_membership_total{result}; traces: OTel (включая координационный вызов guard).

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

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

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

  • Сущности: agent-membership.md (INV-AM2/AM3/AM6, enum LeaveReason), user.md (зеркало CompanyId), role.md (роль agent Organization ↔ Platform).
  • Добровольный уход (self-leave, тот же переход, LeaveReason = SelfLeave, инициатор — сам агент): leave-agency.md; выбор активного агентства при мульти-членстве: POST /api/v1/me/active-agency.
  • Событие: agent-membership-changed.md.
  • Guard активных процессов и каскад offboarding (reference only): docs/06-services/crm/domain/deal.md, lead.md, fixation-request.md; события состава — deal-members-changed, pin.crm.lead-participants-changed.v1.
  • Решение: ADR-0072 (self-leave vs terminate, LeaveReason, guard Executor, каскад доступа), ADR-0061 (независимый агент, membership, запрет back-fill), ADR-0070 (мульти-агентство, SingleActivePerCompany, активное агентство).