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

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 teamPIN Platform (CRM squad)
ТипFan-out choreography (crm-produced, per-consumer идемпотентность); без отдельного оркестратора (ADR-0056)
Область действияпереход Deal Sale → Completed, топик pin.crm.deal-completed.v1 и его консюмеры
Saga instance / correlationDealId (= Kafka key, AggregateId)
Assumptions / ограничениязавершение терминально (INV-DEAL-9) — саги-компенсатора отката нет; событие эмитится ровно один раз на сделку; консюмеры идемпотентны по DealId; завершается только сделка в SaleDraft (застройщик не вовлечён), Application, Booking в этой саге не участвуют (fan-out для них не эмитится); целевая нагрузка низкая (ADR-0056)
ЗависимостиPostgreSQL (crm.deals, outbox), Kafka; консюмеры chessboard, investor-owner, rewards, notifications
Тип документаgreenfield — второй saga-документ PIN

Участники

ServiceResponsibilityOwned State
crmОркестратор/producer: Deal.Complete (Sale → Completed) + публикация терминального события в outboxDealEntity.Status (Sale → Completed), CompletedAt, строка таймлайна DealCompleted
chessboardMarkSold объекта недвижимости (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.

FromToTriggerConditionSide Effects
Deal: SaleDeal: CompletedDeal.Complete (менеджер застройщика — complete-deal)Status = Sale, RosreestrRegisteredAt ≠ null (INV-DEAL-7); не терминальна (INV-DEAL-9)deal-completed.v1 → outbox (ровно один раз); таймлайн DealCompleted
chessboard: Bookedchessboard: Soldconsume deal-completedHoldDealId == DealId (INV-CHS-003); first-seen DealIdперевод объекта в «Продан» (SoldAt = CompletedAt, холд снят); property-object-status-changed
investor-owner: [*]investor-owner: Activeconsume deal-completedfirst-seen DealId (INV-6); нет активного дубля по объекту (INV-1)создание записи портфеля InvestorPropertyEntity из сделки; property-added.v1
rewards: [*]rewards: PendingConfirmation (+ Referral: Pending)consume deal-completedfirst-seen DealId (INV-1); CommissionRecipientUserId задан; правило застройщика найдено; реферальная ветка — активная связь инвестора + ставканачисление комиссии CommissionAccrualEntity (3-стороннее распределение) + реферальное начисление ReferralAccrualEntity; события начислений rewards
notifications: [*]notifications: sentconsume deal-completedMessageId не обработануведомления сторонам
Терминальность и единственный вход

Deal: Completed — терминальный (INV-DEAL-9): обратного перехода нет, повторный CompleteCRM_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 топика безопасен).

DirectionContractOwnerIdempotency
Sendpin.crm.deal-completed.v1crmkey DealId; ровно один раз (INV-DEAL-9)
Consumechessboard MarkSold (crm-property-object-status)chessboardMessageId + DealId + hold-guard HoldDealId == DealId (INV-CHS-003)
Consumeinvestor-owner AddPropertyFromDealinvestor-ownerMessageId + DealId (ux_investor_properties_deal_id, INV-6)
Consumerewards — комиссионное + реферальное начисления (crm-deal-completed)rewardsMessageId + DealId (ux_commission_accruals_deal_id INV-1; уникальность рефералки per-DealId INV-RA-1)
Consumenotifications уведомления сторонамnotificationsMessageId + 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-seen DealId делает повтор безопасным).
  • Повторное завершение. Пока пользователь видит 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 StepCompensation (forward-recovery, НЕ откат)OwnerAudit
chessboard MarkSoldинфра-сбой → бесконечный backoff; чужой холд/недопустимый переход → DLQ + алерт; ручной MarkSold/сверка (runbook)chessboardда
investor-owner addинфра → backoff; невалидный payload → DLQ + алерт crm; активный дубль по объекту (INV-1, второй источник — синк Talent) → DLQ + ручная сверка заботой (archive лишней записи) → replayinvestor-ownerда
rewards accrualинфра → backoff; нарушение контракта/валют → DLQ; нет правила / нет агента / нулевая комиссия → skip (не ошибка) — ручное начисление застройщиком (create-manual-accrual); нет активной связи или ставки → skip реферальной ветки (не ошибка)rewardsда
notificationsretry/DLQ; уведомление некритично для целостности денег/портфеляnotificationsда
Отмена сделки (после завершения не применяется)иной путь: deal-cancelled (release объекта) относится к booking-saga / lifecycle сделки, а не к этой саге; Completed → Cancelled запрещён (INV-DEAL-9)crmда

Timeout и Reconciliation

  • Timeout: саговый таймаут отсутствует — событие терминальное, консюмеры eventually consistent (SLA появления: chessboard Sold < 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-seen DealId у 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 path crm → 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» (будущие пути по конвенции).

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

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

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