Saga «Deal Completion» (fan-out завершения сделки)
Назначение
Saga Deal Completion координирует fan-out терминального завершения сделки: crm переводит
DealEntity Sale → Completed (метод Deal.Complete, complete-deal,
INV-DEAL-7/9) и публикует единственное интеграционное событие pin.crm.deal-completed.v1.
Три независимых downstream-контекста реагируют хореографией:
chessboard— переводит объект недвижимостиBooked → Sold(MarkSold);investor-owner— заводит купленный объект в портфель инвестора;rewards— создаёт начисление комиссии с трёхсторонним распределением агент/агентство/ПИН (PendingConfirmation) и реферальное начисление партнёру при активной реферальной связи инвестора;notifications— уведомляет стороны сделки.
Оркестрируется crm; отдельного оркестратора-сервиса нет (ADR-0056). Ключевое свойство — завершение
терминально и НЕ откатывается (INV-DEAL-9): downstream-сбой не компенсирует Completed, а лечится
retry/DLQ на стороне каждого консюмера (idempotent by DealId). Путь отмены сделки (release объекта,
deal-cancelled) принадлежит booking-saga / жизненному циклу сделки, а не этой саге.
Продолжение финансового контура (подтверждение начисления, гейт получения денег, кошельки, выплаты) —
commission-settlement-saga.
Часть сквозной воронки UC-OVR-004 (шаги 8–9) и UC-OVR-006 (шаг 7).
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Platform (CRM squad) |
| Тип | Fan-out choreography (crm-produced, per-consumer идемпотентность); без отдельного оркестратора (ADR-0056) |
| Область действия | переход Deal Sale → Completed, топик pin.crm.deal-completed.v1 и его консюмеры |
| Saga instance / correlation | DealId (= Kafka key, AggregateId) |
| Assumptions / ограничения | завершение терминально (INV-DEAL-9) — саги-компенсатора отката нет; событие эмитится ровно один раз на сделку; консюмеры идемпотентны по DealId; завершается только сделка в Sale — Draft (застройщик не вовлечён), Application, Booking в этой саге не участвуют (fan-out для них не эмитится); целевая нагрузка низкая (ADR-0056) |
| Зависимости | PostgreSQL (crm.deals, outbox), Kafka; консюмеры chessboard, investor-owner, rewards, notifications |
| Тип документа | greenfield — второй saga-документ PIN |
Участники
| Service | Responsibility | Owned State |
|---|---|---|
crm | Оркестратор/producer: Deal.Complete (Sale → Completed) + публикация терминального события в outbox | DealEntity.Status (Sale → Completed), CompletedAt, строка таймлайна DealCompleted |
chessboard | MarkSold объекта недвижимости (Booked → Sold) по событию (консюмер) | PropertyObject.Status = Sold, SoldAt (холд снят) |
investor-owner | Заводит объект в портфель инвестора-покупателя (/profile/my-lots) | InvestorPropertyEntity (Source = PinCrmDeal, Status = Active) |
rewards | Начисление комиссии (3-стороннее распределение агент/агентство/ПИН) по резолву правила застройщика + реферальное начисление при активной связи инвестора | CommissionAccrualEntity (Status = PendingConfirmation), ReferralAccrualEntity (Status = Pending) |
notifications | Уведомления сторонам сделки о завершении | — |
State Machine
Состояние саги = Deal.Status (переход у crm) + независимые под-состояния каждого консюмера. Дом полей и
переходов сделки — deal.md; ниже — саговый срез fan-out.
| From | To | Trigger | Condition | Side Effects |
|---|---|---|---|---|
Deal: Sale | Deal: Completed | Deal.Complete (менеджер застройщика — complete-deal) | Status = Sale, RosreestrRegisteredAt ≠ null (INV-DEAL-7); не терминальна (INV-DEAL-9) | deal-completed.v1 → outbox (ровно один раз); таймлайн DealCompleted |
chessboard: Booked | chessboard: Sold | consume deal-completed | HoldDealId == DealId (INV-CHS-003); first-seen DealId | перевод объекта в «Продан» (SoldAt = CompletedAt, холд снят); property-object-status-changed |
investor-owner: [*] | investor-owner: Active | consume deal-completed | first-seen DealId (INV-6); нет активного дубля по объекту (INV-1) | создание записи портфеля InvestorPropertyEntity из сделки; property-added.v1 |
rewards: [*] | rewards: PendingConfirmation (+ Referral: Pending) | consume deal-completed | first-seen DealId (INV-1); CommissionRecipientUserId задан; правило застройщика найдено; реферальная ветка — активная связь инвестора + ставка | начисление комиссии CommissionAccrualEntity (3-стороннее распределение) + реферальное начисление ReferralAccrualEntity; события начислений rewards |
notifications: [*] | notifications: sent | consume deal-completed | MessageId не обработан | уведомления сторонам |
Deal: Completed — терминальный (INV-DEAL-9): обратного перехода нет, повторный Complete →
CRM_DEAL_ALREADY_TERMINAL (события не даёт). Единственный вход в fan-out — переход из Sale: сделка в
Draft (застройщик не вовлечён), Application или Booking Complete не проходит
(CRM_DEAL_STAGE_ORDER_VIOLATION, INV-DEAL-7), deal-completed.v1 не эмитится, ни один консюмер завершения
не срабатывает.
Downstream-состояния сходятся независимо (eventual).
Commands и Events
Направление — относительно оркестратора crm. Терминальное событие эмитится один раз (key DealId);
каждый консюмер идемпотентен по MessageId + бизнес-ключу DealId (полный replay топика безопасен).
| Direction | Contract | Owner | Idempotency |
|---|---|---|---|
| Send | pin.crm.deal-completed.v1 | crm | key DealId; ровно один раз (INV-DEAL-9) |
| Consume | chessboard MarkSold (crm-property-object-status) | chessboard | MessageId + DealId + hold-guard HoldDealId == DealId (INV-CHS-003) |
| Consume | investor-owner AddPropertyFromDeal | investor-owner | MessageId + DealId (ux_investor_properties_deal_id, INV-6) |
| Consume | rewards — комиссионное + реферальное начисления (crm-deal-completed) | rewards | MessageId + DealId (ux_commission_accruals_deal_id INV-1; уникальность рефералки per-DealId INV-RA-1) |
| Consume | notifications уведомления сторонам | notifications | MessageId + DealId |
Сквозной поток «producer → topic → consumer»: единственная публикация crm, дальше — параллельный fan-out
(без новых узлов сверх участников из таблицы выше):
Transaction Boundaries
Каждый сервис — только своя локальная транзакция; двухфазного коммита нет, согласованность eventual.
crm: мутацияDealEntity(→ Completed,CompletedAt) + строка таймлайнаDealCompleted+ outbox-записьdeal-completed— один коммит; публикация в Kafka — после коммита (at-least-once).chessboard: перевод объекта в «Продан» + отметка идемпотентности + собственный outbox (property-object-status-changed) — атомарно; offset после фиксации.investor-owner: создание записи портфеля (InvestorPropertyEntity) + отметка идемпотентности + outbox (property-added) — атомарно.rewards: создание комиссионного (CommissionAccrualEntity) и реферального (ReferralAccrualEntity) начислений + отметка идемпотентности + outbox событий начислений — одна локальная транзакция; skip-ветки (нет агента / нет правила / нет активной связи или ставки / дубль) фиксируют только отметку идемпотентности (rewards/crm-deal-completed.md).
Промежуточные состояния и видимость (eventual consistency)
Завершение — терминальный факт в crm (source of truth); fan-out сходится независимо, окно наблюдаемо:
Сразу после complete-deal сделка показывает Completed. Downstream-эффекты появляются с задержкой
per-consumer SLA: объект → «Продан» на витрине < 1 мин, запись в портфеле /profile/my-lots < 1 мин,
начисление комиссии в rewards < 5 мин. В окне возможна частичная картина (сделка Completed, но объект ещё
«Бронь» на витрине, портфель/комиссия ещё пусты) — это ожидаемо, не ошибка.
- Обрыв fan-out. Если один консюмер надолго отстал или ушёл в DLQ, остальные эффекты уже применены (отката
нет). Сверка «объект
Проданбез записи в портфеле / без начисления» (guardrail UC-OVR-004) и метрики лага выявляют обрыв; восстановление — retry/replay (first-seenDealIdделает повтор безопасным). - Повторное завершение. Пока пользователь видит
Completed, повторныйcomplete-dealидемпотентно отклоняется (CRM_DEAL_ALREADY_TERMINAL), событие не эмитится — терминальность (INV-DEAL-9) исключает второй fan-out и рассинхрон downstream.
Compensation
Завершение сделки терминально (INV-DEAL-9): Completed не редактируется и не разворачивается
downstream-сбоем. Терминальное событие + идемпотентность по DealId делают путь forward-recovery:
неуспешный консюмер ретраится/уходит в DLQ и переигрывается после ручного разбора; полный replay топика
безопасен (уже применённые сделки пропускаются). Ниже — не откат, а per-consumer обработка отказа.
| Failed Step | Compensation (forward-recovery, НЕ откат) | Owner | Audit |
|---|---|---|---|
chessboard MarkSold | инфра-сбой → бесконечный backoff; чужой холд/недопустимый переход → DLQ + алерт; ручной MarkSold/сверка (runbook) | chessboard | да |
investor-owner add | инфра → backoff; невалидный payload → DLQ + алерт crm; активный дубль по объекту (INV-1, второй источник — синк Talent) → DLQ + ручная сверка заботой (archive лишней записи) → replay | investor-owner | да |
rewards accrual | инфра → backoff; нарушение контракта/валют → DLQ; нет правила / нет агента / нулевая комиссия → skip (не ошибка) — ручное начисление застройщиком (create-manual-accrual); нет активной связи или ставки → skip реферальной ветки (не ошибка) | rewards | да |
notifications | retry/DLQ; уведомление некритично для целостности денег/портфеля | notifications | да |
| Отмена сделки (после завершения не применяется) | иной путь: deal-cancelled (release объекта) относится к booking-saga / lifecycle сделки, а не к этой саге; Completed → Cancelled запрещён (INV-DEAL-9) | crm | да |
Timeout и Reconciliation
- Timeout: саговый таймаут отсутствует — событие терминальное, консюмеры eventually consistent (SLA
появления:
chessboardSold< 1 мин; портфельinvestor-owner< 1 мин; комиссияrewards< 5 мин). - Retry policy: per-consumer exponential backoff для инфраструктурных сбоев (1s→60s с паузой партиции); concurrency — оптимистичная блокировка по версии; контрактные/бизнес-ошибки — non-retriable → DLQ (не крутятся в retry).
- Reconciliation job: сверка «объект
Проданбез записи в портфеле» и «без начисления комиссии» (guardrail UC-OVR-004); first-seenDealIdуinvestor-owner/rewardsделает полный replay топика безопасным для восстановления пропусков. - Manual review: DLQ > 0 у любого консюмера (алерт + runbook
replay DLQ); INV-1 active-duplicate портфеля — арбитраж Службы заботы;rewards_accrual_no_rule_totalрост — ручное начисление застройщиком.
Observability
- Metrics (полнота fan-out):
crm_deals_completed_total{contract_type}(эмиссия) сверяется сinvestor_owner_deal_properties_created_total,rewards_accrual_created_total(+..._no_rule_total,..._skipped_no_agent_total),chessboard_crm_transitions_total{to=Sold}; лаг —investor_owner_deal_lag_seconds,rewards_crm_deals_lag_seconds,chessboard_crm_lag_seconds; DLQ —*_dlq_total; contract mismatch —*_contract_mismatch_total. - Logs/traces: сквозной correlation id пробрасывается от producer-а к каждому консюмеру;
saga id =
DealId; causation id =MessageId; trace pathcrm→ outbox →chessboard/investor-owner/rewards/notifications. - Alerts: объект «Продан» без объекта в портфеле (обрыв fan-out); DLQ > 0 у любого консюмера; лаг > 15 мин
(
investor-owner) / > 5 мин (rewards). - Runbook:
investor-owner«replay DLQ»,rewards-accrual-replay,chessboard«ручной release/mark-sold» (будущие пути по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Саги (2): Saga «Booking» (Резерв → Бронь → передача в продажу), Saga «Commission Settlement» (финансовый контур вознаграждений)
- adr (1): ADR-0067 — гейт получения денег (акт поступления средств)
- scenarios (1): UC-OVR-006. Сделка: от брони до регистрации (инвестор-инициатор)
- index.md (1): CRM Service
Связанные документы
- Агрегат-источник: deal.md (INV-DEAL-7/9,
Deal.Complete,DealStatus); операция: complete-deal.md; событие: deal-completed.md. - Консюмеры fan-out: chessboard/crm-property-object-status.md
(
MarkSold), investor-owner/crm-deal-completed.md (портфель), rewards/crm-deal-completed.md (комиссия). - Путь отмены (иная сага/lifecycle): deal-cancelled.md, booking-saga.md.
- Продолжение финансового контура (подтверждение → гейт денег → кошельки → выплаты): commission-settlement-saga.md.
- Сквозные сценарии: UC-OVR-004, UC-OVR-006.
- ADR: 0056 simplicity-first, 0052 tenancy.