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

Consumer: Agent Performance Projection

Обновляет два родственных топика одной проекции (как chessboard-dimensions.md) — проекция agent-performance.md.

Назначение

Consumer наполняет денежные и сделочные счётчики user-centric проекции AgentPerformanceEntity, потребляя собственные commission-события rewards (ADR-0061 п.5: «личная статистика агента — user-centric, дом — rewards»):

  • pin.rewards.commission-accrual-created.v1 → сделка закрыта с комиссией: DealsWonCount += 1, CommissionAccruedTotal += CommissionAmount;
  • pin.rewards.commission-accrual-status-changed.v1→ Paid: CommissionPaidTotal += CommissionAmount; → Cancelled c CancelReason = DealCancelled: DealsWonCount -= 1, CommissionAccruedTotal -= CommissionAmount, DealsLostCount += 1.

Первоисточники этих событий — начисления, создаваемые/изменяемые существующими потребителями rewards: завершение сделки (crm-deal-completed.md), отмена (crm-deal-cancelled.md) и выплата (mark-paid поручения, commission-accrual.md). Проекция потребляет долговечные Kafka-события (не in-proc), поэтому её можно пересобрать реплеем этих топиков — не трогая первичные начисления. Апсерт — по паре (AgentUserId, TenantId) (ADR-0070 п.6): TenantId события — иммутабельный снапшот агентства-получателя комиссии, поэтому начисления агента в разных агентствах ложатся в разные строки результативности и не затирают друг друга. Пожизненная личная сводка агента — агрегат этих строк по AgentUserId сквозь агентства (на стороне чтения); earned-метрики каждой строки сохраняются при выходе из агентства и работе в другом.

Идемпотентность — по идентификатору сообщения + бизнес-ключ факта (AccrualId для created; AccrualId:ToStatus для status-changed): каждый вклад в счётчик применяется ровно один раз.

Owning-сервис и триггер

ПолеЗначение
Сервис/контекстrewards (фоновый обработчик)
Обрабатываемые событияpin.rewards.commission-accrual-created, pin.rewards.commission-accrual-status-changed
Topicspin.rewards.commission-accrual-created.v1, pin.rewards.commission-accrual-status-changed.v1
Partitions / keykey = AccrualId (AggregateId) — порядок per-accrual
ContractVersionобоих событий = 1; при несовпадении — warn-and-proceed (rewards_contract_mismatch_total)
Consumer grouppin-rewards-agent-performance (отдельная от pin-rewards-crm-deals — независимый offset для rebuild)
Producerrewards (собственные события начислений; self-consume для read-проекции)
Эффект (событие → что меняется)денежные и сделочные счётчики строки (AgentUserId, TenantId) проекции AgentPerformanceEntity

Контекст события

  • Из метаданных события доступны: идентификатор сообщения, время события, Version, агрегат-источник (AggregateId = AccrualId, AggregateType = "CommissionAccrual").
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Обработка — под системным актором вне tenant-контекста (platform-scope, ADR-0061 п.5).
  • Ключи идемпотентности сообщений — rewards:perf-accrual-created:{MessageId} / rewards:perf-accrual-status:{MessageId}; бизнес-ключи факта — AccrualId (created), AccrualId:{ToStatus} (status-changed) — защита от двойного применения вклада.

Входные данные / payload события

Контракты — ../events/commission-accrual-created.md, ../events/commission-accrual-status-changed.md; дом полей — commission-accrual.md.

Поле payloadСобытиеТипКак используется (маппинг)
AgentUserIdобаGuidЧасть ключа строки (Upsert по паре (AgentUserId, TenantId))
TenantIdобаGuidЧасть ключа строки — агентство-получатель комиссии (иммутабельный снапшот); определяет строку результативности
CommissionAmount (Value+CurrencyId)created / status-changedMoney± CommissionAccruedTotal (created/cancelled), += CommissionPaidTotal (Paid); CurrencySnapshot события не читается
DealCompletedAtcreatedDateTimeOffsetFirstActivityAt ??=, LastActivityAt = max(...)
AccrualId (Id)обаGuidБизнес-ключ факта (идемпотентность вклада)
FromStatus / ToStatusstatus-changedstring (enum CommissionAccrualStatus)Ветвление: → Paid / → Cancelled
CancelReasonstatus-changedstring? (enum CommissionCancelReason)DealCancelled → зачесть в DealsLostCount и реверс accrued

Переходы → Confirmed и → MoneyReceived для проекции нейтральны (счётчики не меняют — учитываются на created/paid/cancelled): гейт получения денег (ADR-0067) — этап расчётов платформы с застройщиком, метрики результативности агента он не двигает; «получено» отражается статусом начисления в ЛК, а не счётчиком проекции.

Алгоритм

  1. Контекст события. Восстановить метаданные события; сверить ContractVersion — при несовпадении warn-and-proceed. Проверить идемпотентность по идентификатору сообщения — дубль → skipped-duplicate, пропуск (ack).
  2. Проверка входа. AgentUserId != Guid.Empty; TenantId != Guid.Empty (часть ключа строки); CommissionAmount.Value >= 0; для status-changed — известный ToStatus (unknown enum → warning, skip: неизвестный переход не меняет проекцию). Бизнес-идемпотентность: факт по бизнес-ключу (AccrualId / AccrualId:{ToStatus}) уже применён → skip. Нерелевантные переходы (→ Confirmed, → MoneyReceived, а также → Cancelled c CancelReason != DealCancelled для счётчика DealsLost) — no-op (но CommissionAccruedTotal реверсится при любой отмене неоплаченного начисления; см. шаг 4). No-op-переход фиксирует факт по бизнес-ключу (AccrualId:{ToStatus}) и подтверждает offset — повтор даёт идемпотентный skip.
  3. Что читаем. Строку проекции по паре (AgentUserId, TenantId) (без tenant-фильтра — TenantId здесь разрез строки, не фильтр); её отсутствие: для created — новая строка этого агентства с нулевыми метриками; для status-changed без строки — warning perf-row-missing, применить как upsert от нуля (реплей восстановит). Строки других агентств агента не читаются.
  4. Бизнес-правила и переходы.
    • created: DealsWonCount += 1; CommissionAccruedTotal += CommissionAmount (guard единой валюты — INV-6 commission-accrual.md); FirstActivityAt ??= DealCompletedAt; LastActivityAt = max(...).
    • status-changed → Paid: CommissionPaidTotal += CommissionAmount.
    • status-changed → Cancelled: CommissionAccruedTotal -= CommissionAmount (не ниже 0); если CancelReason = DealCancelledDealsWonCount -= 1 (не ниже 0), DealsLostCount += 1.
    • Version += 1, UpdatedAt = now. Доменных событий проекция не поднимает.
  5. Что меняется. Строка проекции AgentPerformanceEntity пары (AgentUserId, TenantId) создаётся или обновляется; строки других агентств агента не затрагиваются. Конкурентная доставка — проигравший инстанс перечитывает; бизнес-ключ факта исключает двойной вклад.
  6. Согласованность. Обновление проекции, отметка идемпотентности и отметка факта фиксируются в одном согласованном изменении; offset подтверждается после фиксации.
  7. Результат. Успех → ack; REWARDS_ACCRUAL_EVENT_INVALID → DLQ + алерт; инфраструктурные сбои → retry. Исходящих событий терминальная read-проекция не порождает.

Диаграмма

Идемпотентность и replay

  • Consumer-level: отметка по идентификатору сообщения.
  • Business-level: факт вклада по AccrualId (created) и AccrualId:{ToStatus} (status-changed) применяется ровно один раз — счётчики не дрейфуют при повторной доставке.
  • Replay: да — ключевое свойство проекции. Полный rebuild: обнулить rewards.agent_performance, сбросить offset группы pin-rewards-agent-performance (+ группы fixation/membership) в 0 и переиграть; fold детерминирован. Runbook — ../runbooks/rewards-agent-performance-rebuild.md (по конвенции).

Retry и DLQ

DLQ-топик группы: pin.rewards.agent-performance-dlq.v1.

СбойКодRetryDLQРучное действие
Инфраструктурный (БД/Kafka недоступны)— (exception)Exponential backoff 1s→60sНетRunbook: восстановить зависимость
Нарушение контракта (AgentUserId/TenantId пуст, отрицательная сумма)REWARDS_ACCRUAL_EVENT_INVALIDНетДа + алертРазбор данных начисления; replay из DLQ после фикса
Unknown ToStatus / нерелевантный переход— (skip)НетWarning; проекция не меняется
Дубль/применённый факт— (skip)НетНе требуется

Acceptance-критерии

  • created → Won/Accrued. Дано: commission-accrual-created для агента с TenantId = A. Когда: обработка. Тогда: в строке (AgentUserId, A)DealsWonCount += 1, CommissionAccruedTotal += CommissionAmount, FirstActivityAt ??= DealCompletedAt, LastActivityAt = max(...); факт AccrualId отмечен; ack.
  • status-changed → Paid. Дано: переход начисления → Paid. Когда: обработка. Тогда: CommissionPaidTotal += CommissionAmount; факт AccrualId:Paid отмечен.
  • status-changed → Cancelled (DealCancelled). Дано: отмена с CancelReason = DealCancelled. Когда: обработка. Тогда: CommissionAccruedTotal -= CommissionAmount (не ниже 0), DealsWonCount -= 1 (не ниже 0), DealsLostCount += 1.
  • Нейтральный переход. Дано: → Confirmed или → MoneyReceived (регистрация акта поступления средств, ADR-0067). Когда: обработка. Тогда: счётчики не меняются (no-op), факт AccrualId:{ToStatus} отмечен, ack.
  • Дубль факта. Дано: факт по бизнес-ключу (AccrualId / AccrualId:{ToStatus}) уже применён. Когда: повтор/replay. Тогда: skip, счётчики не дрейфуют.
  • Проекции нет. Дано: status-changed пришёл раньше, чем создан ряд. Когда: обработка. Тогда: warning perf-row-missing, применяется upsert от нуля (replay восстановит корректно).
  • Нарушение контракта. Дано: пустой AgentUserId или отрицательная сумма. Когда: обработка. Тогда: REWARDS_ACCRUAL_EVENT_INVALID → DLQ + алерт.
  • Per-agency разрез (мульти-агентство). Дано: серия начислений агента с TenantId = A и TenantId = B. Когда: обработка. Тогда: метрики ложатся в две строки (AgentUserId, A) и (AgentUserId, B) раздельно; апсерт одной не затирает другую; личная сводка = сумма строк по AgentUserId.
  • Earned переживает выход из агентства. Дано: у агента строка (AgentUserId, A) с earned-метриками, затем membership-Left по A (другой consumer). Когда: чтение. Тогда: строка A деактивирована (MembershipStatus = Left), но её DealsWonCount/CommissionAccruedTotal/… сохранены; агрегат сводки не уменьшается.
  • Replay (rebuild). Дано: обнуление rewards.agent_performance и сброс offset группы в 0. Когда: переигрывание. Тогда: fold детерминирован, строки и счётчики восстанавливаются один-в-один.

Наблюдаемость и безопасность

  • Logs: consumed / skipped-duplicate / perf-applied / perf-row-missing / dead-lettered с MessageId, AccrualId, AgentUserId, TenantId, ToStatus. Суммы не логируются (Sensitive-класс, README «Безопасность»); InvestorUserId в проекцию/логи не пишется.
  • Metrics: rewards_perf_consume_duration_ms, rewards_perf_applied_total{kind}, rewards_perf_row_missing_total, rewards_agent_performance_lag_seconds, rewards_agent_performance_dlq_total.
  • Traces: span приёма события → span обработки → span обращения к хранилищу; корреляция из метаданных события.
  • Alerts: lag consumer-group > 5 мин; DLQ > 0; рост rewards_perf_row_missing_total.
  • Безопасность: system-actor, platform-scope (проекция вне tenant); self-consume собственных доверенных топиков — untrusted-ввода нет.

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

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

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