UC-MSG-002. Авто-провизия чата сделки по событию CRM
Назначение
Системный (событийный) сценарий: при создании сделки crm публикует
pin.crm.deal-created.v1; консюмер messenger
(deal-created) авто-создаёт групповой чат сделки
(chat.md, Kind = Deal), зеркалит начальный состав участников
(participant.md), пишет системную ленту
(chat-event.md) и возвращает готовность событием
pin.messenger.deal-chat-status-changed.v1 (Ready) → проекция
Deal.ChatStatus в crm. Оркестрация чат↔сделка — через события CRM (ADR-0055); messenger — консюмер,
владельцем жизненного цикла сделки не является. При сбое строка провизии остаётся Failed/Provisioning
и добирается фоновым chat-provision-retry.
| Поле | Значение |
|---|
| ID | UC-MSG-002 |
| Контекст | messenger |
| Тип | основной (+ A1 повторная доставка/дубль, A2 сбой провизии → retry, A3 последующая реконсиляция состава, A4 событие сделки раньше провизии) |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | System (Kafka-консюмер провизии Deal-чата / фоновый worker) |
| Permissions / scope | system-scope; доступ к Kafka-топику pin.crm.* (доверенный consumer) |
| Tenant | не применяется — доступ по членству; чат несёт DealTenantId = DeveloperCompanyId только для scope арбитража (ADR-0052) |
| Record-level | не применяется (система); состав чата = активные DealMember (INV-CHAT-6) |
Предусловие
- Топик
pin.crm.deal-created.v1 доступен; событие несёт DealId, Title/объект/ЖК, ProjectId,
DeveloperCompanyId и начальный состав (Members: { UserId, ParticipantRole }).
- Реплики
MessengerUserEntity участников наполняются консюмером
pin.identity.users.v1 (display-имена резолвятся на чтении; провизия от их наличия не зависит).
Триггер
| Поле | Значение |
|---|
| Источник | Kafka event pin.crm.deal-created.v1 |
| Триггерящий элемент | консюмер провизии Deal-чата + reliability-строка messenger.deal_chat_provisions |
Основной Поток
Дом алгоритма/маппинга — consumers/deal-created.md; ниже — поток на уровне
use-case.
- Приём события. Консюмер получает событие
deal-created; бизнес-идемпотентность — по DealId.
- Идемпотентность/дубль. По
DealId найти строку провизии — есть с Status = Ready (A1) → skip
(повторная доставка Kafka не плодит чат, INV-CHAT-2). Иначе создать/взять строку намерения.
- Запись намерения + провизия. Создать Deal-чат (
Chat.ProvisionDeal: Kind = Deal, Active, под
ux_chats_deal_id, INV-CHAT-2); для каждого участника события — добавить участника с маппингом роли
(SellManager→DeveloperManager и т.д., participant.md), идемпотентно (INV-PART-1); поднимается событие
ChatCreated.
- Системная лента. По
ChatCreated пишется ChatEventEntity DealChatProvisioned ({ DealId }) +
ParticipantJoined на каждого участника — в той же транзакции (INV-CE-2); read-state и Seq не
затрагиваются (отдельная лента, INV-CE-3).
- Готовность. Строка провизии →
Status = Ready, ChatId = chat.Id; сформировать интеграционное
DealChatStatusChangedEvent (pin.messenger.deal-chat-status-changed.v1, ContractVersion = 1,
ключ = DealId, { DealId, ChatId, Status = Ready, OccurredAt }, без PII).
- Единая транзакция. Чат + участники + лента + строка провизии + исходящее событие фиксируются в одном
согласованном изменении. Инфраструктурный сбой (A2) → откат; строка остаётся
Failed/Provisioning,
добор — chat-provision-retry.
- Проекция в CRM. Консюмер
crm по deal-chat-status-changed проставляет Deal.ChatStatus = Ready —
UI сделки показывает готовый чат; сам ChatEntity.Status остаётся Active (готовность — проекция crm,
не статус чата).
- Realtime. Realtime-доставка
ChatUpdated в chat:{ChatId} — участники онлайн видят чат сразу;
offline — по последующим message-posted.
Диаграмма
Альтернативные Потоки
A1. Повторная доставка события (дубль)
- Kafka at-least-once → повтор
deal-created. Строка провизии по DealId = Ready или Deal-чат по
DealId уже существует (ux_chats_deal_id) → консюмер skip без мутаций (INV-CHAT-2); событие
готовности эффектно не переиздаётся (idempotent by state).
A2. Сбой провизии → фоновый добор
- Транзакция шага 6 откатилась (PostgreSQL/Kafka недоступны) → строка провизии
Failed (или зависла в
Provisioning). Deal.ChatStatus в crm не переходит в Ready.
- chat-provision-retry (цикл 60 с) выбирает строку
Failed/stuck
Provisioning, идемпотентно повторяет провизию и re-emit Ready; poison после MaxAttempts → Failed
- алерт (runbook). Возврат к готовности без ручного вмешательства.
A3. Последующая реконсиляция состава
- Изменение состава сделки →
pin.crm.deal-members-changed.v1 → консюмер
deal-members-changed делает set-реконсиляцию
Participant (добавить/реактивировать/soft-remove, INV-CHAT-6/INV-PART-1/4) + ParticipantJoined/
Left/RoleChanged в ленту. Провизия чата (этот UC) при этом не повторяется.
A4. Событие сделки пришло раньше провизии чата (out-of-order)
deal-created и deal-members-changed/booking-*/deal-stage-changed идут в разных топиках — общий
ключ DealId даёт порядок только внутри одного топика; межтопикового порядка нет. Возможна ситуация: для
DealId пришло deal-members-changed (или карточка booking), а Deal-чата ещё нет (провизия не
выполнена/отстала).
- Консюмер зависимого события, не найдя чат по
DealId, не создаёт его сам (провизия — только по
deal-created, единая точка), а трактует как «чат ещё не готов»: паркует обработку (redeliver по offset с
backoff) до появления чата; после исчерпания попыток — DLQ + алерт (runbook). Как только провизия по
deal-created завершилась (Ready), припаркованное событие реконсилит состав/карточку идемпотентно.
- Обратный порядок безопасен по построению:
deal-created несёт начальный состав (Members), поэтому
базовый чат провизионируется корректно даже если deal-members-changed задержится; последующая
реконсиляция (A3) лишь доводит состав.
Постусловие
- Существует ровно один
Deal-чат на DealId (ux_chats_deal_id), Active, с зеркалом активных
DealMember (роли Investor/Agent/DeveloperManager/PinManager).
- Лента
chat_events содержит DealChatProvisioned + ParticipantJoined; строка провизии Ready.
- Опубликован
pin.messenger.deal-chat-status-changed.v1 (Ready) → crm.Deal.ChatStatus = Ready.
Возможные Ошибки
| Код | HTTP | Условие | Поведение |
|---|
| — (consumer) | n/a | сбой БД/Kafka в транзакции провизии (A2) | строка Failed/Provisioning; добор фоновым retry, ретрай Kafka-offset |
MSG_DEAL_CHAT_MEMBERS_INVALID | n/a | пустой состав / Deal-чат без активных участников (INV-CHAT-7) | лог + карантин строки, алерт (событие crm некорректно) |
| poison | n/a | Attempts >= MaxAttempts в retry-job | Status = Failed фиксируется, алерт, ручной re-enqueue (runbook) |
Пользовательских HTTP-ошибок нет — сценарий системный (консюмер + фоновый worker).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
ChatCreated | Chat.ProvisionDeal | лента ChatEventEntity DealChatProvisioned; realtime ChatUpdated |
ParticipantAdded | chat.AddParticipant | лента ParticipantJoined; realtime ChatUpdated |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
DealChatStatusChangedEvent | pin.messenger.deal-chat-status-changed.v1 | провизия завершена (Ready) / retry re-emit | crm (проекция Deal.ChatStatus) |
Payload { DealId, ChatId?, Status (Provisioning/Ready/Failed), OccurredAt }, ContractVersion = 1,
ключ = DealId; публикуется в одном согласованном изменении с провизией. Потребляемое —
pin.crm.deal-created.v1 (source — deal-created.md). Дом контракта
готовности — deal-chat-status-changed.md.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Провизия идемпотентна: повтор deal-created не создаёт второй чат (ux_chats_deal_id) | Тест: две доставки → один Deal-чат, Ready не эффектно повторно (A1) |
| AC2 | Состав зеркалит Members события с маппингом ролей; лента фиксирует DealChatProvisioned+ParticipantJoined | Тест консюмера: участники + роли, строки chat_events |
| AC3 | Готовность возвращается событием Ready → crm.Deal.ChatStatus; ChatEntity.Status остаётся Active | Тест: deal-chat-status-changed Ready; чат Active |
| AC4 | Сбой провизии → строка Failed/Provisioning, добор chat-provision-retry, re-emit Ready | Тест A2: инъекция сбоя → retry-job доводит до Ready идемпотентно |
| AC5 | Последующий deal-members-changed реконсилит состав без повторной провизии | Тест A3: add/remove участника → Participant set-реконсиляция, чат не пересоздаётся |
| AC6 | Дано deal-members-changed/booking-* пришло раньше провизии; когда консюмер не находит чат по DealId; тогда он паркует/ретраит (не создаёт чат сам), после Ready — идемпотентно реконсилит (A4) | Тест out-of-order: событие до deal-created → park/retry, затем применяется без дубля |
| AC7 | Дано исчерпаны попытки припаркованного события; когда чат так и не появился; тогда событие уходит в DLQ + алерт, поток не блокируется | Тест: poison зависимого события → DLQ, offset продвинут |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Consumer/events (дом алгоритма): consumers/deal-created.md,
consumers/deal-members-changed.md,
events/deal-chat-status-changed.md; фоновый добор —
background/chat-provision-retry.md.
- Domain: chat.md (INV-CHAT-2/6/7,
Deal-чат, готовность=проекция),
participant.md (маппинг ролей, INV-PART-1/4),
chat-event.md (DealChatProvisioned).
- Источник событий crm: deal-created.md,
deal-members-changed.md, deal.md
(
ParticipantRole).
- ADR: 0055 мини-мессенджер (оркестрация чат↔сделка),
0056 simplicity-first.