Consumer: CRM Agent Offboarding (CrmAgentOffboardingConsumer)
Назначение
Консюмер CRM Agent Offboarding слушает событие pin.identity.agent-membership-changed со Status = Left
и выполняет каскад отвязки уходящего агента от активных CRM-процессов этого агентства: массово
снимает (soft-remove) его активные участия в лидах и сделках, а для процессов, где он остаётся исполнителем
(Executor) — помечает требование назначить преемника. Событие покрывает и добровольный уход, и
принудительное исключение (различаются только Reason); каскад для обоих одинаков. Цель — чтобы после
выхода агента из агентства он потерял доступ к чужим теперь для него лидам/сделкам и их чатам, а процессы не
остались без исполнителя. Историческая атрибуция (OriginAgencyCompanyId/OriginAgentUserId), снапшоты
сделок/лидов/фиксаций и earned/paid-начисления rewards не трогаются — их каскад отдельный (ADR-0072).
Снятие участий публикуется штатными pin.crm.deal-members-changed.v1 / pin.crm.lead-participants-changed.v1,
по которым messenger снимает доступ к чатам (история команде сохраняется).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| Consumer | CRM Agent Offboarding |
| Обрабатываемое событие | pin.identity.agent-membership-changed (обрабатывается только Status = Left) |
| Topic | pin.identity.agent-membership-changed.v1 (key = AgentUserId — порядок join/leave per-agent; 3 партиции — дом agent-membership-changed.md) |
| ContractVersion | ожидается 1; при несовпадении версии события — warn-and-proceed (метрика crm_agent_offboarding_contract_mismatch_total) |
| Consumer group | crm-agent-offboarding |
| Producer | identity (leave-agency / become-investor — Reason = SelfLeave; terminate-agent-membership — Reason = ExcludedByAgency) |
| Эффект (событие → что меняется) | Soft-remove активных участий агента в лидах/сделках агентства + пометка reassign на процессах, где он Executor; публикация событий состава |
Топик общий с событием вступления агента (Status = Active): его этот консюмер игнорирует (ack,
no-op) — вступление и пересчёт ростера/user-centric метрик агентства обрабатывает отдельный консюмер того
же топика. Здесь обрабатывается только выход (Status = Left). Пересчёт метрик агента и перевод
ЛК-истории в read-only — не ответственность этого консюмера (rewards и метрики — отдельные потребители
того же события).
Контекст события
- Из метаданных события доступны: идентификатор сообщения
MessageId(для дедупликации),CreatedAt, версия контракта; из payload —MembershipId,AgentUserId,CompanyId,Status,Reason,EffectiveAt,Version. - Актор — системный контекст консюмера (не пользовательский); каскад выполняется в platform-scope:
лиды адресуются по
TenantId = CompanyId(агентство), сделки/фиксации — поOriginAgencyCompanyId = CompanyIdчерез границы застройщиков (сделка/фиксация несутTenantId = DeveloperCompanyId), поэтому запись по сделкам/фиксациям — санкционированная кросс-tenant операция, ограниченная составом участников и служебными пометками (ADR-0052). Каждая запись несёт собственныйTenantId. - Корреляция сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: на уровне консюмера — по ключу
crm-agent-offboarding:{MessageId}; на бизнес-уровне — по ключу{MembershipId}:{EffectiveAt}(одно членство может пройти циклLeft → Active → Leftс разнымиEffectiveAt; каждый выход обрабатывается ровно один раз) плюс per-процесс last-write-wins (уже снятое участие повторно не снимается).
Требуемая дельта к домам процессов
Пометка «процесс остался без исполнителя и требует назначения преемника» — durable-сигнал для UI и руководителя. Дом полей — соответствующие агрегаты; этот консюмер требует дельту (поле объявляется в доме, здесь только используется):
DealEntity.ExecutorReassignRequiredAt:DateTimeOffset?(deal.md) — момент, когда offboarding оставил сделку с уволеннымExecutor; сбрасывается вnullпри передаче ведения новому исполнителю.LeadEntity.ExecutorReassignRequiredAt:DateTimeOffset?(lead.md) — то же для лида.FixationRequestEntity.ExecutorReassignRequiredAt:DateTimeOffset?(fixation-request.md) — то же для фиксации в статусеPending.
Входные данные / payload события
Контракт — дом event-contract: pin.identity.agent-membership-changed
(AgentMembershipChangedKafkaEvent). Ниже — только поля, которые consumer реально читает.
| Поле payload | Тип | Обязательность | Как используется |
|---|---|---|---|
Status | string (enum AgentMembershipStatus) | Да | Гейт: обрабатывается только Left; Active → пропуск (ack); unknown → skip + warn (не наш переход) |
AgentUserId | Guid | Да | Уходящий агент — по нему ищутся активные участия/исполнительство |
CompanyId | Guid | Да | Агентство выхода — anchor scope: лиды TenantId = CompanyId, сделки/фиксации OriginAgencyCompanyId = CompanyId |
EffectiveAt | DateTimeOffset? | Да при Left | RemovedAt снимаемых участий и ExecutorReassignRequiredAt пометок; бизнес-ключ идемпотентности |
Reason | string (enum LeaveReason) | Нет | Только для логов/формулировки уведомления (SelfLeave / ExcludedByAgency); каскад одинаков; unknown — мягкая деградация (строка + warning) |
MembershipId | Guid | Да | Бизнес-ключ идемпотентности ({MembershipId}:{EffectiveAt}) |
Version | long | Да | Монотонный guard перехода членства |
Поля JoinedAt, LeftAt, InvitationId consumer не читает (EffectiveAt — авторитетный момент выхода).
Алгоритм
- Контекст события. Восстановить метаданные (
MessageId, время, версия контракта); при несовпадении версии — warn-and-proceed. Проверить идемпотентность по ключуcrm-agent-offboarding:{MessageId}: дубль → пропустить (ack, no-op). - Гейт статуса.
Status = Active→ это вступление, не наш консюмер →skipped-not-left, ставится только отметка идемпотентности, ack. UnknownStatus→ warn,skipped-not-left, ack (наш переход — толькоLeft; членство неизвестного статуса активным не считаем, но и каскад не запускаем). - Проверка входа. При
Left: заданыAgentUserId,CompanyId,EffectiveAt,Version— иначеCRM_OFFBOARDING_INVALID_EVENT(non-retriable → DLQ + алерт).Reasonнеобязателен; неизвестное значение не блокирует (строка + warning). Бизнес-ключ{MembershipId}:{EffectiveAt}уже обработан → пропустить (ack). - Лиды агентства. Найти активные участия агента в лидах агентства:
LeadParticipantEntityсUserId = AgentUserId,RemovedAt is null, лид сTenantId = CompanyId(индекс поUserIdучастника + фильтр tenant лида). По каждому:- роль вспомогательная (
Responsible/CoAgent/Assistant/Lawyer) → soft-remove участия (RemovedAt = EffectiveAt,RemovedByUserId = null— система); пересчитать производные лида (если снятResponsible→ResponsibleAgentUserId = null); опубликоватьlead-participants-changed; - роль
Executor→ участие не снимать (лид нельзя оставить без активногоExecutor,CRM_LEAD_EXECUTOR_REQUIRED): пометитьLeadEntity.ExecutorReassignRequiredAt = EffectiveAt, уведомить руководителя агентства. Guard увольнения на сторонеidentity(ADR-0072 п.3) в норме уже обеспечил передачу ведения до выхода, поэтому такой ветки в штатном сценарии нет — она защитная.InvestorPrimary/InvestorSecondaryуходящего агента не касаются (агент — не инвестор).
- роль вспомогательная (
- Сделки агентства. Найти активные участия агента в сделках агентства:
DealMemberEntityсUserId = AgentUserId,RemovedAt is null, сделка сOriginAgencyCompanyId = CompanyId(индекс поUserIdучастника + фильтр по Origin-агентству сделки). По каждой — та же логика, что для лида: вспомогательная роль → soft-remove +deal-members-changed;Executor→ не снимать (CRM_LAST_EXECUTOR_REQUIRED), пометитьDealEntity.ExecutorReassignRequiredAt = EffectiveAt+ уведомить руководителя. Терминальные сделки (Completed/Cancelled) в набор не попадают (их состав неизменен). - Фиксации агентства. У фиксации нет коллекции участников — сторона агентства выражена скаляром
ExecutorAgentUserId. Найти фиксацииStatus = PendingсOriginAgencyCompanyId = CompanyIdиExecutorAgentUserId = AgentUserId: пометитьFixationRequestEntity.ExecutorReassignRequiredAt = EffectiveAt+ уведомить руководителя (заявку вPendingбез исполнителя нельзя одобрить). Уже решённыеFixedUnique/FixedNonUniqueне трогаются — получатель комиссии зафиксирован при решении (CommissionRecipientUserId), Origin иммутабелен; событий состава фиксация не публикует. - Публикация состава. Для затронутых лидов/сделок публикуются
pin.crm.lead-participants-changed.v1/pin.crm.deal-members-changed.v1с полным активным составом + дельтой (Removed: [AgentUserId]; при снятииResponsible—ChangeType = ResponsibleChanged). Консюмеры:messenger(снять участника из чата, история сохраняется),notifications. Пометки reassign публикацией состава не сопровождаются (состав не менялся) — сигнал руководителю идёт уведомлением. - Согласованность и результат. Снятия участий, пометки reassign, обновление производных, исходящие
события состава и отметка идемпотентности фиксируются в одном согласованном изменении; offset
подтверждается только после успешной фиксации. Пустой набор процессов (агент нигде не участвовал) →
no-match, ставится только отметка идемпотентности, ack. Успех → ack; retriable-сбой → retry по политике; non-retriable → DLQ + алерт.
Диаграмма
Идемпотентность и replay
- На уровне консюмера: ключ
crm-agent-offboarding:{MessageId}— повторная доставка после сбоя не меняет состояние. - На бизнес-уровне: ключ
{MembershipId}:{EffectiveAt}(циклLeft → Active → Leftдаёт разныеEffectiveAt) + per-процесс last-write-wins: уже снятое участие (RemovedAt is not null) повторно не снимается, уже выставленныйExecutorReassignRequiredAtне перезаписывается более поздним значением.Version-guard членства отбрасывает устаревший (out-of-order) переход. - Replay: да. Повторный проход топика безопасен: per-agent порядок (key =
AgentUserId) + идемпотентные снятия/пометки дают сходимость. Runbook — «повторная отвязка уволенного агента» (docs/06-services/crm/runbooks/).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Конкурентная правка процесса/состава (гонка с передачей ведения/ручной правкой) | — | 3 попытки немедленно (перечитать) | После исчерпания | Расследование гонки |
Обязательные поля Left-события отсутствуют (AgentUserId/CompanyId/EffectiveAt) | CRM_OFFBOARDING_INVALID_EVENT | Нет | Да + алерт | Исправление данных в identity → повторное событие |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта producer-а |
Unknown Status / Reason | — | warn-and-proceed (skip / мягкая деградация) | Нет | — |
«Нельзя оставить процесс без исполнителя» (CRM_LEAD_EXECUTOR_REQUIRED / CRM_LAST_EXECUTOR_REQUIRED)
консюмер соблюдает конструктивно: активного Executor он не снимает, а помечает reassign — поэтому этот
код здесь возникнуть не может (защита от осиротевшего процесса).
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-not-left/no-match/participation-removed(count) /reassign-required(count) /failedc полямиMessageId,MembershipId,AgentUserId,CompanyId,EffectiveAt, затронутыхLeadId/DealId/FixationRequestId. - Metrics:
crm_agent_offboarding_consume_duration_ms,crm_agent_offboarding_removed_participations_total,crm_agent_offboarding_reassign_required_total,crm_agent_offboarding_dlq_total,crm_agent_offboarding_contract_mismatch_total,crm_agent_offboarding_lag_seconds(now −envelope.CreatedAt). - Traces: span приёма события → span обработки → spans обращений к хранилищу и публикации событий состава; корреляция из метаданных события.
- Alerts: лаг консюмера > 5 мин; DLQ > 0; всплеск
reassign_required(сигнал, что guard увольнения на сторонеidentityпропускает уходящих исполнителей без передачи ведения). - Dashboard / Runbook: Grafana
pin-crm; runbooksdocs/06-services/crm/runbooks/(будущий путь). - Безопасность: system-actor; platform-scope запись (лиды — по
TenantId = CompanyId; сделки/фиксации — санкционированный кросс-tenant поOriginAgencyCompanyId, ограничен составом участников и служебными пометками, ADR-0052). Событие и записи PII не несут (только id и метки времени). ИммутабельныеOriginAgencyCompanyId/OriginAgentUserId, снапшоты процессов и earned/paid-начисленияrewardsне изменяются.
Acceptance Criteria
- AC-1 (снятие вспомогательного участия). Дано: агент — активный
CoAgentв двух сделках агентства (OriginAgencyCompanyId = CompanyId). Когда:Left(EffectiveAt = T). Тогда: оба участия сняты (RemovedAt = T, система), опубликованыdeal-members-changedс полным составом и дельтойRemoved: [агент], ack;messengerснимает агента из чатов, история сохранена. - AC-2 (снятие
Responsible). Дано: агент — активныйResponsibleв лиде агентства. Когда:Left. Тогда: участие снято,ResponsibleAgentUserId = null,lead-participants-changedcChangeType = ResponsibleChanged, ack. - AC-3 (
Executor— защитная ветка). Дано: guardidentityпропущен, агент остался единственным активнымExecutorв сделке. Когда:Left. Тогда: участие не снято,DealEntity.ExecutorReassignRequiredAt = EffectiveAt, уведомление руководителю,deal-members-changedне публикуется (состав неизменен), метрикаreassign_required, ack. - AC-4 (фиксация Pending). Дано:
Pending-фиксация агентства сExecutorAgentUserId = агент. Когда:Left. Тогда:FixationRequestEntity.ExecutorReassignRequiredAt = EffectiveAt, уведомление руководителю; события состава нет. - AC-5 (решённая фиксация не трогается). Дано:
FixedUnique-фиксация сExecutorAgentUserId = агент. Когда:Left. Тогда: фиксация без изменений (получатель комиссии зафиксирован при решении, Origin иммутабелен). - AC-6 (Origin и rewards не трогаются). Дано: агент был
OriginAgentUserIdсделки. Когда:Left. Тогда:OriginAgencyCompanyId/OriginAgentUserIdи суммы-снапшоты неизменны; earned/paid-начисления не пересчитываются (не в scope этого консюмера). - AC-7 (вступление игнорируется). Дано: событие
Status = Active. Когда: доставка. Тогда:skipped-not-left, состояние CRM неизменно, ack. - AC-8 (нет участий). Дано: агент нигде не активен в процессах агентства. Когда:
Left. Тогда:no-match, только отметка идемпотентности, ack. - AC-9 (дубль / replay). Дано:
{MembershipId}:{EffectiveAt}уже обработан. Когда: повторная доставка. Тогда:skipped-duplicate, участия повторно не снимаются, пометки не перезаписываются, ack. - AC-10 (обе причины — один каскад). Дано: одинаковый состав участий. Когда:
LeftсReason = SelfLeaveи, в другом кейсе,Reason = ExcludedByAgency. Тогда: результат каскада идентичен;Reasonвлияет только на формулировку уведомления. - AC-11 (битое событие). Дано:
LeftбезEffectiveAt. Когда: доставка. Тогда:CRM_OFFBOARDING_INVALID_EVENT(non-retriable) → DLQ + алерт.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (3): UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- События (2): Event Contract: pin.calendar.event-owner-changed (CalendarEventOwnerChangedEvent), Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent)
- Консюмеры (1): Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events)
Связанные документы
- Контракт входного события (дом): identity/events/agent-membership-changed.md
(
Reason/EffectiveAt, enumLeaveReason). - Целевые агрегаты (дома): domain/deal.md, domain/lead.md,
domain/fixation-request.md (
ParticipantRole, единственный активныйExecutor, Origin-атрибуция; дельтаExecutorReassignRequiredAt). - События состава (публикуются каскадом):
pin.crm.deal-members-changed.v1(deal.md),pin.crm.lead-participants-changed.v1(lead.md) →messenger(синк участников чата),notifications. - Шаблон consumer: consumer-template.md.
- README контекста crm; решения —
ADR-0072 (self-leave vs terminate,
offboarding-каскад, guard единственного
Executor, иммутабельность Origin/earned-paid), ADR-0071 (ParticipantRole, видимость по составу), ADR-0052 (tenancy), ADR-0056 (простота).