Consumer: Deal Stage Card (MessengerDealStageChangedConsumer)
Назначение
Consumer слушает событие pin.crm.deal-stage-changed.v1 и рисует в чат сделки информационную карточку
«этап изменён» (StageChanged, Category = Info) при каждом переходе From → To, а для отдельных групп
этапа — целевые/специальные карточки (например SentToRosreestr для группы Registration). На
терминальном ToStatus ∈ {Completed, Cancelled} дополнительно закрывает чат (Status → Closed,
INV-CHAT-8) — новые пользовательские сообщения запрещены, системные карточки допустимы. Порядок переходов
per-deal гарантирован ключом DealId.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | messenger |
| Consumer | MessengerDealStageChangedConsumer |
| Обрабатываемое событие | DealStageChangedEvent |
| Topic | pin.crm.deal-stage-changed.v1 (key = DealId, 3 партиции) |
| ContractVersion | ожидается 1; при несовпадении версии — warn-and-proceed (messenger_deal_stage_contract_mismatch_total) |
| Consumer group | messenger-deal-stage-changed |
| Producer | crm: переход этапа сделки / старт продажи / завершение / отмена / авто-переход брони |
| Эффект (событие → что меняется) | В ленту чата добавляются карточки этапа; на терминале сделки чат закрывается |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта,
идентификатор и тип агрегата-источника (
DealId/Deal). - Актор — системный; чат не tenant-фильтруется, доступ по членству (ADR-0052).
- Корреляция сквозного трейса восстанавливается из метаданных события.
- Ключ бизнес-идемпотентности:
messenger-deal-stage:{MessageId}; на уровне данных — уникальность карточки по (ChatId,CardType,SourceEventId = MessageId) (INV-MSG-8); закрытие чата идемпотентно (повторное закрытие ужеClosed— no-op).
Входные данные / payload события
Контракт — дом deal-stage-changed.md. Ниже — читаемые поля.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
DealId | Guid | Да | Поиск чата сделки по DealId; CardStageChangedPayload.DealId |
ToStepGroup | string (PipelineStepGroup) | Да | Ветвление спец-карточек (Registration → SentToRosreestr) |
FromStatus | string (DealStatus) | Да | CardStageChangedPayload.FromStatus |
ToStatus | string (DealStatus) | Да | CardStageChangedPayload.ToStatus; терминал {Completed, Cancelled} → закрытие чата |
ChangedAt | DateTimeOffset | Да | момент отправки карточки / ChatEvent.OccurredAt |
ToStepName карточки = null: настраиваемое имя этапа — внутренний справочник CRM, в событие не входит
(несётся только группа). FromStepId/ToStepId/FromStepGroup/ChangedByUserId consumer не читает
(на карточку не влияют). Неизвестный ToStatus/ToStepGroup — нейтральная формулировка + warning (fallback).
Алгоритм
- Контекст события. Восстановить метаданные события; сверить версию контракта с
1(при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность поmessenger-deal-stage:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack). - Проверка входа.
DealIdзадан;ToStatus/ToStepGroupнепустые. Структурно невалидный payload →MSG_STAGE_PAYLOAD_INVALID(DLQ + алерт). - Что читаем. Найти чат сделки по
DealId. Если чата ещё нет — он не провизионирован (событие пришло раньше deal-created): отказMSG_CHAT_NOT_PROVISIONEDс ретраем (backoff, догонит поDealId). - Бизнес-правила и маппинг. Определить набор карточек:
- всегда —
StageChanged(Category = Info,ActionStatus = None,Payload = CardStageChangedPayload { DealId, FromStatus, ToStatus, ToStepName = null }); - если
ToStepGroup == Registration— дополнительноSentToRosreestr(Category = Info,Payload = CardDealRefPayload { DealId }); - если
ToStatus ∈ {Completed, Cancelled}— пометить чат к закрытию (reason = ToStatus, INV-CHAT-8). Каждой карточкеSourceEventId = MessageId, текст — поCardType/ToStatus. Видимость — все участники (INV-MSG-5).
- всегда —
- Что меняется. В ленту чата добавляется каждая карточка (
Seqприсваивается монотонно, денормализуется last-message,IsSystem = true, INV-MSG-4). При терминале чат переводится вClosed. - Что фиксируется. По закрытию в системную ленту материализуется факт
ChatClosed(chat-event.md); участникам готовится realtime-доставкаMessageReceived/ChatUpdated(persist-then-push). Интеграционных Kafka-событий нет: offline-push шлётnotificationsнапрямую изdeal-stage-changed(без двойного пуша). - Согласованность. Карточки, смена статуса чата, факт ленты и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после фиксации.
- Результат. Успех → ack (SLA < 5 мин).
MSG_CHAT_NOT_PROVISIONED/инфраструктурный сбой → ретрай (backoff); структурная ошибка → DLQ + алерт.
Диаграмма
Предусловия и постусловия
До обработки (истинно на входе):
- Событие
deal-stage-changedопубликованоcrmпри переходе этапа сделкиFrom → To(или терминалеCompleted/Cancelled);messengerпереход не переисчисляет, а отражает. - Deal-чат по
DealId, как правило, уже провизионирован (deal-created); если ещё нет — допустимая гонка, разрешается ретраем (шаг 3). - Порядок переходов per-deal сохранён ключом партиции
DealId— карточки лягут в ленту в порядке этапов.
После успешной обработки (гарантируется):
- В ленте чата ровно одна карточка
StageChangedдля данногоMessageId; приToStepGroup = Registration— дополнительно ровно однаSentToRosreestr; повтор доставки новых карточек не создаёт (INV-MSG-8). last-messageчата денормализован на последнюю добавленную карточку;Seqмонотонно возрос.- При
ToStatus ∈ {Completed, Cancelled}чат в статусеClosed(INV-CHAT-8); повторное закрытие — no-op; фактChatClosedматериализован в системную ленту (chat-event.md). - Онлайн-участникам ушла realtime-доставка
MessageReceived/ChatUpdated; оффлайн — при подключении (persist-then-push). - Наружу не ушло ни одного Kafka-события; offset подтверждён только после фиксации.
Кросс-сервисные контракты
Ожидается от crm (вход pin.crm.deal-stage-changed.v1): at-least-once доставка; ключ партиционирования
DealId (порядок этапов per-deal); обязательные поля DealId, FromStatus, ToStatus, ToStepGroup,
ChangedAt, MessageId (дедуп), ContractVersion. Гарантия продьюсера: DealId указывает на реальную
сделку; ToStatus/ToStepGroup — enum-значения (неизвестные допустимы — нейтральный текст, не отказ);
настраиваемое имя этапа (ToStepName) в событие не входит.
Ожидается от deal-created: чат по DealId провизионирован (eventual; окно — обычно
секунды, при лаге до нескольких минут; пока нет — MSG_CHAT_NOT_PROVISIONED + backoff-ретрай).
Предоставляет наружу: ничего — исходящих Kafka-событий нет. Offline-push шлёт notifications напрямую
из deal-stage-changed; освобождение объекта и прочие эффекты терминала слушают события crm напрямую, не
транзитом через messenger (нет двойного пуша). Единственный эффект вовне контекста — realtime-доставка
карточек участникам внутри messenger.
Особый случай — событие на уже Closed-чат (поздний/повторный терминал, поздний рядовой переход):
системные карточки на Closed-чат допустимы (INV-CHAT-8) — карточка постится, закрытие — no-op.
Критерии приёмки (Given/When/Then)
- Happy (промежуточный этап): Given провизионированный Deal-чат и
deal-stage-changedс не терминальнымToStatus; When consumer обрабатывает событие; Then в ленте карточкаStageChanged(Info,ActionStatus = None) с текстом перехода, участникамMessageReceived, чат остаётсяActive, Kafka молчит. - Регистрация: Given
ToStepGroup = Registration; When обработка; Then помимоStageChangedдобавляетсяSentToRosreestr; обе карточки де-дуплятся поSourceEventId. - Терминал: Given
ToStatus ∈ {Completed, Cancelled}; When обработка; Then карточкаStageChangedдобавлена, чат переведён вClosed, фактChatClosedв системной ленте, участникамChatUpdated. - Дубль: Given уже обработанный
MessageId; When повторная доставка; Then новых карточек нет (skipped-duplicate), закрытие no-op, ack. - Чат не готов: Given чат по
DealIdне провизионирован; When обработка; ThenMSG_CHAT_NOT_PROVISIONED, ретрай с backoff, после лимита — DLQ; карточки не постятся. - Событие на
Closed-чат: Given чат ужеClosed; When приходит следующий stage-эвент; Then системная карточка добавляется (INV-CHAT-8), закрытие no-op. - Неизвестный статус/группа: Given
ToStatus/ToStepGroupвне enum; When обработка; Then нейтральная карточкаStageChanged+ warning, спец-карточка не добавляется, не отказ. - Битый payload: Given структурно невалидное событие; When обработка; Then
MSG_STAGE_PAYLOAD_INVALID, DLQ + алерт, без ретрая. - Иная версия контракта: Given
ContractVersion ≠ 1; When обработка; Then warn-and-proceed (метрика mismatch), обработка продолжается.
Идемпотентность и replay
- По сообщению: ключ
messenger-deal-stage:{MessageId}. - По данным: уникальность (
ChatId,CardType,SourceEventId) де-дуплит каждую карточку (INV-MSG-8); закрытие чата идемпотентно (повтор наClosed— no-op). - Replay: да. Повторный re-read безопасен: карточки не дублируются, закрытие no-op. Порядок переходов
per-deal гарантирован ключом
DealId.
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Чат ещё не провизионирован | MSG_CHAT_NOT_PROVISIONED | Да, backoff (догоняет deal-created) | После лимита | Проверить лаг messenger-deal-created |
| Инфраструктурный (PostgreSQL/Kafka) | — | Бесконечный backoff 1s→60s | Нет | Runbook: восстановить зависимость |
| Структурная ошибка payload | MSG_STAGE_PAYLOAD_INVALID | Нет | Да + алерт | Проверить продьюсер crm |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/card-posted(CardType) /chat-closed/retry-not-provisioned/failedcMessageId,DealId,ChatId,ToStatus. PII нет. - Metrics:
messenger_deal_stage_consume_duration_ms,messenger_stage_cards_posted_total{card_type},messenger_deal_chats_closed_total,messenger_deal_stage_dlq_total,messenger_deal_stage_lag_seconds. - Traces: приём события → обработка → обращения к хранилищу; корреляция из метаданных события.
- Alerts: лаг > 5 мин; всплеск
MSG_CHAT_NOT_PROVISIONED(рассинхрон с deal-created); DLQ > 0. - Безопасность: системный актор, доступ по членству; событие — только id/enum/timestamp (PII нет).
Обратные ссылки
Автоссылки: где используется этот документ.
- События (2): События сервиса messenger: реестр топиков, Event Contract: pin.crm.deal-stage-changed (DealStageChangedEvent)
- Консюмеры (2): Consumer: Booking Expired Card (MessengerBookingExpiredConsumer), Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer)
Связанные документы
- Целевые агрегаты: chat.md (INV-CHAT-8
Closed), message.md (EventCardType.StageChanged/SentToRosreestr,CardStageChangedPayload/CardDealRefPayload), chat-event.md (ChatClosed). - Входное событие (дом): deal-stage-changed.md.
- Соседние консюмеры: deal-created.md, deal-members-changed.md.
- README; ADR-0055, 0056.