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;→ CancelledcCancelReason = 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 |
| Topics | pin.rewards.commission-accrual-created.v1, pin.rewards.commission-accrual-status-changed.v1 |
| Partitions / key | key = AccrualId (AggregateId) — порядок per-accrual |
| ContractVersion | обоих событий = 1; при несовпадении — warn-and-proceed (rewards_contract_mismatch_total) |
| Consumer group | pin-rewards-agent-performance (отдельная от pin-rewards-crm-deals — независимый offset для rebuild) |
| Producer | rewards (собственные события начислений; 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-changed | Money | ± CommissionAccruedTotal (created/cancelled), += CommissionPaidTotal (Paid); CurrencySnapshot события не читается |
DealCompletedAt | created | DateTimeOffset | FirstActivityAt ??=, LastActivityAt = max(...) |
AccrualId (Id) | оба | Guid | Бизнес-ключ факта (идемпотентность вклада) |
FromStatus / ToStatus | status-changed | string (enum CommissionAccrualStatus) | Ветвление: → Paid / → Cancelled |
CancelReason | status-changed | string? (enum CommissionCancelReason) | DealCancelled → зачесть в DealsLostCount и реверс accrued |
Переходы → Confirmed и → MoneyReceived для проекции нейтральны (счётчики не меняют — учитываются на
created/paid/cancelled): гейт получения денег (ADR-0067) — этап расчётов платформы с застройщиком, метрики
результативности агента он не двигает; «получено» отражается статусом начисления в ЛК, а не счётчиком
проекции.
Алгоритм
- Контекст события. Восстановить метаданные события; сверить
ContractVersion— при несовпадении warn-and-proceed. Проверить идемпотентность по идентификатору сообщения — дубль →skipped-duplicate, пропуск (ack). - Проверка входа.
AgentUserId != Guid.Empty;TenantId != Guid.Empty(часть ключа строки);CommissionAmount.Value >= 0; для status-changed — известныйToStatus(unknown enum → warning, skip: неизвестный переход не меняет проекцию). Бизнес-идемпотентность: факт по бизнес-ключу (AccrualId/AccrualId:{ToStatus}) уже применён → skip. Нерелевантные переходы (→ Confirmed,→ MoneyReceived, а также→ CancelledcCancelReason != DealCancelledдля счётчикаDealsLost) — no-op (ноCommissionAccruedTotalреверсится при любой отмене неоплаченного начисления; см. шаг 4). No-op-переход фиксирует факт по бизнес-ключу (AccrualId:{ToStatus}) и подтверждает offset — повтор даёт идемпотентный skip. - Что читаем. Строку проекции по паре
(AgentUserId, TenantId)(без tenant-фильтра —TenantIdздесь разрез строки, не фильтр); её отсутствие: для created — новая строка этого агентства с нулевыми метриками; для status-changed без строки — warningperf-row-missing, применить как upsert от нуля (реплей восстановит). Строки других агентств агента не читаются. - Бизнес-правила и переходы.
- 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 = DealCancelled—DealsWonCount -= 1(не ниже 0),DealsLostCount += 1. Version += 1,UpdatedAt = now. Доменных событий проекция не поднимает.
- created:
- Что меняется. Строка проекции
AgentPerformanceEntityпары(AgentUserId, TenantId)создаётся или обновляется; строки других агентств агента не затрагиваются. Конкурентная доставка — проигравший инстанс перечитывает; бизнес-ключ факта исключает двойной вклад. - Согласованность. Обновление проекции, отметка идемпотентности и отметка факта фиксируются в одном согласованном изменении; offset подтверждается после фиксации.
- Результат. Успех → 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.
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (БД/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пришёл раньше, чем создан ряд. Когда: обработка. Тогда: warningperf-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-ввода нет.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Projection: AgentPerformanceEntity
- API (1): GET /api/v1/my/agent-performance — Моя личная статистика (агент, user-centric)
- Use Cases (2): UC-RWD-006. Агент смотрит личную статистику за всю карьеру (user-centric), UC-RWD-010. Акт поступления средств — гейт получения денег
- События (2): Event Contract: pin.rewards.commission-accrual-created, Event Contract: pin.rewards.commission-accrual-status-changed (CommissionAccrualStatusChangedKafkaEvent)
- Консюмеры (2): Consumer: CRM Fixation Approved, Consumer: Identity Agent Membership Changed
- Саги (1): Saga «Commission Settlement» (финансовый контур вознаграждений)
- index.md (1): Rewards Service
Связанные документы
- Проекция: agent-performance.md (счётчики, «Источники наполнения», rebuild).
- Первоисточники начислений: crm-deal-completed.md (created),
crm-deal-cancelled.md (cancelled), commission-accrual.md
(
MarkPaid, статусы). Контракты событий: ../events/commission-accrual-created.md, ../events/commission-accrual-status-changed.md. - Соседние consumer-ы проекции: crm-fixation-approved.md, agent-membership-changed.md.
- Шаблон: consumer-template.md; ADR-0061 (п.5),
ADR-0070 (п.6 ключ
(AgentUserId, CompanyId)), ADR-0056.