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

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
ConsumerCRM Agent Offboarding
Обрабатываемое событиеpin.identity.agent-membership-changed (обрабатывается только Status = Left)
Topicpin.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 groupcrm-agent-offboarding
Produceridentity (leave-agency / become-investorReason = SelfLeave; terminate-agent-membershipReason = ExcludedByAgency)
Эффект (событие → что меняется)Soft-remove активных участий агента в лидах/сделках агентства + пометка reassign на процессах, где он Executor; публикация событий состава
Границы scope

Топик общий с событием вступления агента (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ТипОбязательностьКак используется
Statusstring (enum AgentMembershipStatus)ДаГейт: обрабатывается только Left; Active → пропуск (ack); unknown → skip + warn (не наш переход)
AgentUserIdGuidДаУходящий агент — по нему ищутся активные участия/исполнительство
CompanyIdGuidДаАгентство выхода — anchor scope: лиды TenantId = CompanyId, сделки/фиксации OriginAgencyCompanyId = CompanyId
EffectiveAtDateTimeOffset?Да при LeftRemovedAt снимаемых участий и ExecutorReassignRequiredAt пометок; бизнес-ключ идемпотентности
Reasonstring (enum LeaveReason)НетТолько для логов/формулировки уведомления (SelfLeave / ExcludedByAgency); каскад одинаков; unknown — мягкая деградация (строка + warning)
MembershipIdGuidДаБизнес-ключ идемпотентности ({MembershipId}:{EffectiveAt})
VersionlongДаМонотонный guard перехода членства
Поля вне обработки

Поля JoinedAt, LeftAt, InvitationId consumer не читает (EffectiveAt — авторитетный момент выхода).

Алгоритм

  1. Контекст события. Восстановить метаданные (MessageId, время, версия контракта); при несовпадении версии — warn-and-proceed. Проверить идемпотентность по ключу crm-agent-offboarding:{MessageId}: дубль → пропустить (ack, no-op).
  2. Гейт статуса. Status = Active → это вступление, не наш консюмер → skipped-not-left, ставится только отметка идемпотентности, ack. Unknown Status → warn, skipped-not-left, ack (наш переход — только Left; членство неизвестного статуса активным не считаем, но и каскад не запускаем).
  3. Проверка входа. При Left: заданы AgentUserId, CompanyId, EffectiveAt, Version — иначе CRM_OFFBOARDING_INVALID_EVENT (non-retriable → DLQ + алерт). Reason необязателен; неизвестное значение не блокирует (строка + warning). Бизнес-ключ {MembershipId}:{EffectiveAt} уже обработан → пропустить (ack).
  4. Лиды агентства. Найти активные участия агента в лидах агентства: LeadParticipantEntity с UserId = AgentUserId, RemovedAt is null, лид с TenantId = CompanyId (индекс по UserId участника + фильтр tenant лида). По каждому:
    • роль вспомогательная (Responsible/CoAgent/Assistant/Lawyer) → soft-remove участия (RemovedAt = EffectiveAt, RemovedByUserId = null — система); пересчитать производные лида (если снят ResponsibleResponsibleAgentUserId = null); опубликовать lead-participants-changed;
    • роль Executor → участие не снимать (лид нельзя оставить без активного Executor, CRM_LEAD_EXECUTOR_REQUIRED): пометить LeadEntity.ExecutorReassignRequiredAt = EffectiveAt, уведомить руководителя агентства. Guard увольнения на стороне identity (ADR-0072 п.3) в норме уже обеспечил передачу ведения до выхода, поэтому такой ветки в штатном сценарии нет — она защитная. InvestorPrimary/InvestorSecondary уходящего агента не касаются (агент — не инвестор).
  5. Сделки агентства. Найти активные участия агента в сделках агентства: 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) в набор не попадают (их состав неизменен).
  6. Фиксации агентства. У фиксации нет коллекции участников — сторона агентства выражена скаляром ExecutorAgentUserId. Найти фиксации Status = Pending с OriginAgencyCompanyId = CompanyId и ExecutorAgentUserId = AgentUserId: пометить FixationRequestEntity.ExecutorReassignRequiredAt = EffectiveAt + уведомить руководителя (заявку в Pending без исполнителя нельзя одобрить). Уже решённые FixedUnique/FixedNonUnique не трогаются — получатель комиссии зафиксирован при решении (CommissionRecipientUserId), Origin иммутабелен; событий состава фиксация не публикует.
  7. Публикация состава. Для затронутых лидов/сделок публикуются pin.crm.lead-participants-changed.v1 / pin.crm.deal-members-changed.v1 с полным активным составом + дельтой (Removed: [AgentUserId]; при снятии ResponsibleChangeType = ResponsibleChanged). Консюмеры: messenger (снять участника из чата, история сохраняется), notifications. Пометки reassign публикацией состава не сопровождаются (состав не менялся) — сигнал руководителю идёт уведомлением.
  8. Согласованность и результат. Снятия участий, пометки 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

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook: восстановить зависимость
Конкурентная правка процесса/состава (гонка с передачей ведения/ручной правкой)3 попытки немедленно (перечитать)После исчерпанияРасследование гонки
Обязательные поля Left-события отсутствуют (AgentUserId/CompanyId/EffectiveAt)CRM_OFFBOARDING_INVALID_EVENTНетДа + алертИсправление данных в identity → повторное событие
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта producer-а
Unknown Status / Reasonwarn-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) / failed c полями 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; runbooks docs/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-changed c ChangeType = ResponsibleChanged, ack.
  • AC-3 (Executor — защитная ветка). Дано: guard identity пропущен, агент остался единственным активным 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 + алерт.

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

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

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

  • Контракт входного события (дом): identity/events/agent-membership-changed.md (Reason/EffectiveAt, enum LeaveReason).
  • Целевые агрегаты (дома): 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 (простота).