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 area | company |
| Feature group | agent-membership |
| Статус | draft |
| Документ | docs/06-services/identity/api/company/terminate-agent-membership.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (руководитель / админ агентства) |
| Auth policy / Permissions | authenticated; permission identity.users.manage в scope Organization(companyId) |
| Scope (RBAC) | Organization(companyId) — исключать можно только из своей компании; переводит назначение роли agent целевого агента из Organization в Platform, если у агента не остаётся Active-членств |
| Record-level | членство целевого агента (agentUserId, companyId) принадлежит ростеру компании актора |
| Tenant isolation | TenantId = companyId; мутация в scope Organization(companyId) |
| Audit | update membership (Left, LeftAt, LeaveReason = ExcludedByAgency, UpdatedByUserId = actor); при обнулении/перевыборе — update user (зеркало CompanyId); revoke/regrant RoleAssignment роли agent |
| MFA/approval | не требуется |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/companies/{companyId}/agents/{agentUserId}/terminate |
| Success status | 200 |
| Idempotency header | не требуется (повтор без активного членства по паре — идемпотентный 404) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
CompanyId | route (companyId) | Guid | Да | существующая компания-агентство; ActorUserId имеет identity.users.manage в её scope | 403 при чужой компании / отсутствии права |
AgentUserId | route (agentUserId) | Guid | Да | учётка UserType = Agent; активное членство пары (AgentUserId, CompanyId) | — |
EffectiveAt | body | DateTimeOffset? | Нет | >= now (нельзя задним числом); при отсутствии = момент операции | — |
Reason | body | string? | Нет | свободный комментарий руководителя (audit), <= 500 симв.; не влияет на LeaveReason | — |
Тело может быть пустым: без EffectiveAt увольнение вступает в силу немедленно, LeaveReason всегда
ExcludedByAgency (не передаётся клиентом).
Модель ответа TerminateAgentMembershipResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
MembershipId | Guid | Да | AgentMembershipEntity.Id | — | Закрытое членство. |
AgentUserId | Guid | Да | AgentMembershipEntity.AgentUserId | — | Исключённый агент. |
CompanyId | Guid | Да | AgentMembershipEntity.CompanyId | — | Агентство. |
Status | string (enum AgentMembershipStatus) | Да | AgentMembershipEntity.Status | — | Left. |
LeaveReason | string (enum LeaveReason) | Да | AgentMembershipEntity.LeaveReason | — | ExcludedByAgency. |
LeftAt | DateTimeOffset | Да | AgentMembershipEntity.LeftAt | — | Момент, с которого членство недействительно (= EffectiveAt). |
EffectiveAt | DateTimeOffset | Да | вход/now | — | Момент вступления исключения в силу. |
Алгоритм
- Контекст и доступ. Операцию выполняет руководитель/админ агентства (
ActorUserIdиз claimsub); без токена —401. Требуется permissionidentity.users.manageв scopeOrganization(CompanyId); иначеIDENTITY_FORBIDDEN. - Проверка входа и предусловий.
CompanyId— существующая компания типа∈ {Agency, Broker, Partner}в статусеActive;AgentUserId— учёткаUserType = Agent.EffectiveAt(если передан) не в прошлом (иначеIDENTITY_TERMINATION_EFFECTIVE_AT_INVALID). - Что читаем. Активное членство
AgentMembershipпо паре(AgentUserId, CompanyId)— если его нет,IDENTITY_MEMBERSHIP_NOT_ACTIVE(агент уже исключён / не состоит в этом агентстве); активное назначение ролиagentцелевого агента и его прочиеActive-членства (для решения по scope роли и зеркалу). - Guard активных процессов (координация с
crm). Перед снятием членства проверяется, что агент не остаётся единственным активнымExecutorни на одном активном лиде, сделке или фиксации этого агентства без назначенного преемника (инвариант ADR-0071 «процесс нельзя оставить безExecutor»). Проверка — предусловие, выполняемое координацией сcrm(запрос перечня активных процессов агентства, гдеExecutorAgentUserId = AgentUserIdи преемник не назначен). Если такие процессы есть — отказIDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSESсо списком процессов под reassign; руководитель сначала переназначаетExecutorAgentUserId, затем повторяет исключение. - Бизнес-правила и переходы (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) сохраняются.
- членство закрывается:
- Что меняется. Обновляются членство, при необходимости — зеркало учётки и назначение роли
agent(старое — вRevoked, новое в scopePlatform); история членства не удаляется. Изменения и публикация событий — в одном согласованном изменении. - Результат.
TerminateAgentMembershipResult:MembershipId,AgentUserId,CompanyId,Status = Left,LeaveReason = ExcludedByAgency,LeftAt,EffectiveAt.
Историческая привязка сделок/лидов/фиксаций не пересчитывается — снапшоты OriginAgencyCompanyId/
OriginAgentUserId в crm иммутабельны, back-fill запрещён (ADR-0061 п.4, ADR-0072 п.4).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | AgentMembershipChangedKafkaEvent → pin.identity.agent-membership-changed.v1 (Status = Left, Reason = ExcludedByAgency, EffectiveAt, key = AgentUserId); при обнулении зеркала/смене scope — UserUpsertedKafkaEvent → pin.identity.users.v1, UserAccessChangedKafkaEvent → pin.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_FORBIDDEN | 403 | Нет identity.users.manage в scope Organization(CompanyId) / чужая компания | «Недостаточно прав» | no retry |
IDENTITY_MEMBERSHIP_NOT_ACTIVE | 404 | Нет активного членства пары (AgentUserId, CompanyId) (уже исключён / не состоит) | «Активное членство не найдено» | no retry |
IDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSES | 409 | Агент — единственный активный Executor на активных лидах/сделках/фиксациях без преемника | «Сначала переназначьте ответственного на процессах агента» | retry после reassign |
IDENTITY_TERMINATION_EFFECTIVE_AT_INVALID | 400 | EffectiveAt в прошлом | «Дата вступления в силу не может быть в прошлом» | no retry |
IDENTITY_MEMBERSHIP_INVALID_STATE | 409 | Нарушение INV-AM3 при закрытии членства | «Недопустимое состояние членства» | no retry |
CONCURRENCY_CONFLICT | 409 | Гонка версий (параллельные 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, ThenIDENTITY_AGENT_HAS_ACTIVE_EXECUTOR_PROCESSES(409) со списком процессов под reassign; членство не меняется. После переназначенияExecutorAgentUserIdповтор проходит. - Given агент состоит в нескольких агентствах, When terminate из одного, Then закрывается только членство этой пары; членства в других агентствах и их scope роли сохраняются; зеркало трогается только если исключаемое агентство было выбранным.
- Given нет активного членства пары
(AgentUserId, CompanyId), When terminate, ThenIDENTITY_MEMBERSHIP_NOT_ACTIVE(404) — повтор безопасен (идемпотентный исход). - Given актор без
identity.users.manageв scope компании (или из чужой компании), When terminate, ThenIDENTITY_FORBIDDEN(403). - Given
EffectiveAtв прошлом, When terminate, ThenIDENTITY_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).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (3): UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, UC-RWD-017. Уволенный агент: судьба заработанных комиссий, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: agent-membership.md (INV-AM2/AM3/AM6, enum
LeaveReason), user.md (зеркалоCompanyId), role.md (рольagentOrganization ↔ 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, guardExecutor, каскад доступа), ADR-0061 (независимый агент, membership, запрет back-fill), ADR-0070 (мульти-агентство,SingleActivePerCompany, активное агентство).