Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent)
Версионируемый контракт интеграционного события «участники сделки изменены». Consumer-документы
(messenger, notifications) ссылаются сюда, не переописывая payload. Фиксирует любое изменение активного состава
участников сделки (DealMemberEntity): добавление участника, удаление участия, смену роли, передачу ведения
(смену Executor) и переназначение куратора (смену Responsible). Дом полей агрегата —
entity: DealEntity.
Назначение
Событие даёт messenger актуальный состав участников чата сделки без синхронного запроса в crm.
Чат сделки создаётся по deal-created; при последующих изменениях участников
(операции POST /deals/{id}/members, DELETE /deals/{id}/members/{memberId},
PUT /deals/{id}/members/{memberId}/role — README, инвариант INV-DEAL-3) messenger синхронизирует
участников комнаты. Полный активный состав (ActiveMembers) позволяет применять идемпотентную
set-реконсиляцию (добавить недостающих, удалить лишних), а дельта (Members c ChangeType) — точечно
обновлять роли и слать уведомления.
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.crm.deal-members-changed |
| Topic | pin.crm.deal-members-changed.v1 |
| Partitions / key | 3 (crm — низкообъёмный, 1–10k активных сделок, ADR-0056) / key = AggregateId = DealId (per-deal порядок изменений на одной партиции) |
| ContractVersion | 1 |
| Producer | crm: добавление/удаление участника, смена роли участника сделки (deal.md) |
| Статус | draft |
Событие публикуется атомарно с мутацией агрегата: пока изменение состава не зафиксировано, событие не
отправляется; после фиксации — доставляется гарантированно (at-least-once), ключ = DealId.
Payload DealMembersChangedEvent
Дом полей — entity: DealEntity / DealMemberEntity (раздел «Доменные и
интеграционные события»); здесь перечислено только то, что реально в событии. Событие несёт и полный
активный состав (для идемпотентного синка), и дельту изменения (для точечных действий и уведомлений).
| Поле | Тип | Обязательность | Источник (поле агрегата) | Compatibility | Описание |
|---|---|---|---|---|---|
DealId | Guid | Да | DealEntity.Id | Immutable | Ключ агрегата (= Kafka key). Чат-комната сделки в messenger. |
TenantId | Guid | Да | DealEntity.TenantId | Immutable | DeveloperCompanyId застройщика (ADR-0052). |
Members | List<DealMemberChange> | Да (≥ 1) | дельта операции | Additive only | Что изменилось в этом событии (модель ниже). |
ActiveMembers | List<DealMemberSnapshot> | Да | активные DealMemberEntity (RemovedAt is null) | Additive only | Полный активный состав после изменения — для set-реконсиляции чата. |
ChangedByUserId | Guid? | Нет | DealMemberEntity.AddedByUserId / RemovedByUserId (актор операции) | Additive only | Кто изменил состав; null — система (авто-состав при создании / backfill PinManager). |
ChangedAt | DateTimeOffset | Да | момент мутации (AddedAt/RemovedAt/апдейт роли) | Additive only | Время изменения состава. |
Version | long | Да | DealEntity.Version | Immutable semantics | Доменная версия сделки; монотонный guard порядка у потребителя. |
Вложенная модель DealMemberChange
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
UserId | Guid | Да | логическая ссылка на identity.users(id) | Пользователь-участник (DealMemberEntity.UserId). |
Role | string (enum ParticipantRole) | Да | значения — deal.md | Роль после изменения; для Removed — роль на момент удаления. |
ChangeType | string (enum DealMemberChangeType) | Да | Added / Removed / RoleChanged / ExecutorChanged / ResponsibleChanged | Характер изменения участия. |
Вложенная модель DealMemberSnapshot
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
UserId | Guid | Да | ссылка на identity.users(id) | Активный участник (RemovedAt is null). |
Role | string (enum ParticipantRole) | Да | значения — deal.md | Текущая роль участника. |
Enum DealMemberChangeType
Enum контракта события; передаётся строкой.
| Значение | Условие | Источник в агрегате |
|---|---|---|
Added | Добавлен новый участник | добавление участника (AddedAt проставлен) |
Removed | Участие снято | снятие участия (RemovedAt проставлен) |
RoleChanged | Сменилась роль существующего участника | смена роли участника |
ExecutorChanged | Сменился текущий исполнитель (передача ведения) | атомарная передача роли Executor новому участнику |
ResponsibleChanged | Сменился/назначен ответственный (куратор ведения) | назначение/смена участника с ролью Responsible |
ExecutorChanged и ResponsibleChanged описывают смену носителя соответствующей роли (передача ведения /
переназначение куратора) — в отличие от RoleChanged, который меняет роль у того же участника. Дельта такой
операции может нести две записи Members (снятие роли у прежнего носителя + назначение новому); полный
актуальный состав всегда в ActiveMembers.
Role — enum ParticipantRole (Executor / Responsible / CoAgent / Assistant / Lawyer /
InvestorPrimary / InvestorSecondary / SellManager / PinManager); дом значений — deal.md.
Ровно один активный Executor; ровно один активный InvestorPrimary; InvestorSecondary — сколько угодно;
Responsible опционален; не более одного PinManager; уникальность UserId среди активных; процесс нельзя
оставить без активного Executor. Подробности — deal.md (INV-DEAL-3).
Событие не содержит ФИО/телефонов: messenger резолвит отображаемые имена из собственной реплики
pin.identity.users.v1 по UserId.
Когда публикуется
| Условие | Переход состояния | Публикация |
|---|---|---|
| Добавлен новый активный участник (INV-DEAL-3) | без смены DealStatus; Version++ | атомарно со сменой состояния агрегата |
Снято участие (RemovedAt проставлен) | без смены DealStatus; Version++ | та же |
| Смена роли участника | без смены DealStatus; Version++ | та же |
Передача ведения — новый Executor (ExecutorChanged) | без смены DealStatus; Version++ | атомарно (снятие + назначение в одной операции; процесс не остаётся без Executor) |
Назначение/смена куратора — Responsible (ResponsibleChanged) | без смены DealStatus; Version++ | та же |
Изменение состава не меняет DealStatus и не дублирует deal-created (начальный
состав при создании сделки уходит в payload DealCreatedEvent, отдельного deal-members-changed на
создание не публикуется). Одно изменение состава → одно событие с дельтой в Members и полным
ActiveMembers.
Потребители (consumers)
| Consumer-контекст | Side effects | Идемпотентность | Задержка (SLA) |
|---|---|---|---|
messenger | Синхронизация участников чата сделки: реконсиляция комнаты по ActiveMembers (пригласить недостающих, убрать отсутствующих), обновление ролей по Members; системное сообщение о смене состава | MessageId + монотонный Version-guard на DealId | < 1 мин |
notifications | Уведомление добавленным (Added — «вас добавили в сделку») и удалённым (Removed — «вас удалили из сделки») участникам; уведомление о передаче ведения (ExecutorChanged) и смене куратора (ResponsibleChanged) полному активному составу (ActiveMembers) | MessageId (дедуп) | < 1 мин |
Порядок событий одной сделки гарантирован ключом DealId (одна партиция). Потребитель держит
Version-guard: событие с Version не больше уже применённого — пропускается (устаревшая доставка).
ActiveMembers делает обработку самодостаточной — при пропуске отдельной дельты состав всё равно сходится.
Доставка, порядок и обработка сбоев
- Гарантия публикации. Событие уходит только после фиксации изменения состава (
Version++); при откате не публикуется. Доставка — at-least-once, ключDealId. - Порядок и version-guard. События одной сделки упорядочены ключом
DealId(одна партиция). Потребитель держит монотонныйVersion-guard: событие сVersionне больше уже применённого — пропускается (устаревшая доставка). При гонке двух изменений состава побеждает большееVersion; более раннее, доставленное позже, отбрасывается guard-ом. - Самодостаточность (set-реконсиляция). Даже при пропуске отдельной дельты
ActiveMembersдаёт полный актуальный состав —messengerсходится к нему реконсиляцией (пригласить недостающих, убрать лишних). - Идемпотентность потребителя. Дедуп по
MessageId+Version-guard; повторная доставка не меняет уже сошедшийся состав. - Retry. Транзиентный сбой потребителя → повтор обработки с backoff; событие доступно до успешной обработки.
- DLQ. Неустранимый сбой после исчерпания попыток → dead-letter c алертом; проигрывание безопасно
(реконсиляция по
ActiveMembersидемпотентна).
Версионирование и совместимость
ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию по конвенции суффиксом.v1.- Additive-only для minor: новые optional-поля с безопасным default — без bump.
- Breaking (удаление/переименование/смена типа или смысла) — новый
ContractVersion = 2+ новый топикpin.crm.deal-members-changed.v2+ dual-publish на период миграции + deadline миграции consumers + sunset.v1.
Unknown enum (Role / ChangeType) — fallback: сохранить строку как есть, warning; неизвестный ChangeType
трактовать как no-op для дельты и опираться на ActiveMembers (состав всё равно синхронен).
Безопасность
| Поле | Класс данных | Правило |
|---|---|---|
DealId, TenantId, Version | Internal | include |
Members[].UserId, ActiveMembers[].UserId, ChangedByUserId | Internal (идентификатор, не PII) | include |
Members[].Role, Members[].ChangeType, ActiveMembers[].Role | Internal | include |
ChangedAt | Internal | include |
Событие не переносит raw PII: ФИО и телефоны участников в payload отсутствуют — только UserId.
Отображаемые имена/контакты messenger берёт из своей реплики pin.identity.users.v1; при логировании
producer/consumer телефоны/ФИО не фигурируют (маскирование +7***1234 — правило
user-upserted, здесь неприменимо, т.к. таких полей нет).
- Доступ к топику ограничен m2m ACL Kafka (producer
crm, consumersmessenger,notifications). - Событие НЕ содержит secrets, tokens, credentials, эскроу/платёжных реквизитов, документов, вложений.
Наблюдаемость
- Логи (без PII): факт публикации
deal-members-changedсDealId/Version,ChangeTypeиRoleдельты (UserId— идентификатор, не PII); ФИО/телефоны участников в логах не фигурируют. - Метрики: число изменений состава по топику; сквозная задержка синхронизации чата у
messenger(< 1 мин); число срабатыванийVersion-guard (индикатор переупорядочения/гонок); глубина DLQ (порог алерта > 0). - Доступ: топик — m2m ACL Kafka (producer
crm, consumersmessenger,notifications); PII в событие не входит.
Acceptance Criteria
- Given сделка с активным составом, When добавлен новый участник, Then
Version++и ровно одноdeal-members-changedпубликуется с дельтой (ChangeType = Added) вMembersи полнымActiveMembers. - Given снятие участия / смена роли, When изменение фиксируется, Then событие с
ChangeType = Removed/RoleChanged;DealStatusне меняется. - Given передача ведения, When роль
Executorатомарно переходит новому участнику, Then событие сChangeType = ExecutorChanged; вActiveMembersровно один активныйExecutor(процесс не остаётся без исполнителя). - Given назначение/смена куратора, When меняется носитель роли
Responsible, Then событие сChangeType = ResponsibleChanged;Responsibleопционален, инвариант единственногоExecutorне нарушен. - Given событие с
Versionне больше уже применённого, Whenmessengerобрабатывает, Then оно пропускается (устаревшая доставка). - Given пропущена отдельная дельта, When приходит следующее событие, Then состав чата всё равно
сходится к
ActiveMembers(set-реконсиляция). - Given создание сделки, When формируется начальный состав, Then
deal-members-changedне публикуется (начальный состав уходит в deal-created). - Given неизвестный
ChangeType, Whenmessengerобрабатывает, Then дельта трактуется как no-op, а состав берётся изActiveMembers(fail-safe) + warning. - Given breaking-изменение payload, When новый контракт, Then
pin.crm.deal-members-changed.v2+ dual-publish + sunset.v1.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: ChatEventEntity, Entity / Aggregate: NotificationEntity, Entity: ParticipantEntity (owned-child ChatEntity)
- API (7): DELETE /api/agent/crm/deals/{dealId}/members/{memberId} — Удалить участника сделки, POST /api/agent/crm/deals — Создать сделку из лида по объекту, POST /api/agent/crm/deals/{dealId}/engage-developer — Вовлечь застройщика (Draft → Application), POST /api/agent/crm/deals/{dealId}/members — Добавить участника сделки, PUT /api/agent/crm/{deals|leads}/{id}/executor — Передать ведение (сменить исполнителя), PUT /api/agent/crm/{deals|leads}/{id}/responsible — Назначить/снять ответственного (куратора), PUT /api/agent/crm/deals/{dealId}/members/{memberId}/role — Сменить роль участника сделки
- Use Cases (13): UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, UC-CRM-002. Агент создаёт сделку из лида (выбор объекта, гейт вовлечения застройщика), UC-CRM-006. Вмешательство Менеджера ПИН, UC-CRM-007. Добавление участника в сделку, UC-CRM-008. Снятие участника со сделки, UC-CRM-009. Передача ведения сделки (смена исполнителя), UC-CRM-010. Назначение/снятие ответственного (куратора) процесса — ответственный ≠ исполнитель, UC-CRM-013. Увольнение агента: каскад отвязки от процессов CRM, UC-CRM-015. Явное назначение исполнителя для разрешения спора о комиссии, UC-CRM-017. Flow B: агент предлагает объект → Draft-сделка → подтверждение инвестором → вовлечение застройщика, UC-MSG-002. Авто-провизия чата сделки по событию CRM, UC-MSG-008. Добавленный участник появляется в чате сделки/лида (+1)
- События (4): Event Contract: pin.calendar.event-owner-changed (CalendarEventOwnerChangedEvent), Event Contract: pin.crm.deal-created (DealCreatedEvent), Event Contract: pin.crm.deal-stage-changed (DealStageChangedEvent), Event Contract: pin.crm.lead-participants-changed (LeadParticipantsChangedEvent)
- Консюмеры (4): Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events), Consumer: CRM Agent Offboarding (CrmAgentOffboardingConsumer), Consumer: CRM Events → Notifications (notifications-crm-events), Consumer: Deal Chat Members Sync (MessengerDealMembersChangedConsumer)
- adr (1): ADR-0071: Владение процессами CRM компанией и единая модель участников
- index.md (1): Calendar Service
- registries (1): Реестр событий PIN
Связанные документы
- Сущность (дом полей
DealEntity/DealMemberEntity+ParticipantRole): deal.md. - Соседние события сделки: deal-created.md, deal-stage-changed.md, deal-completed.md, deal-cancelled.md.
- Проекция готовности чата: consumers/deal-chat-status.md; контекст чата —
../../messenger/, ADR-0055. - Реплика имён участников у
messenger: user-upserted.md. - Адресация уведомлений по составу участников: notifications/consumers/crm-events.md.