Entity / Projection: AgentPerformanceEntity
Дом полей user-centric проекции «Личная статистика агента» контекста rewards (ADR-0061 п.5, ADR-0070
п.6). Проекция хранит строку на пару (AgentUserId, CompanyId) — по одной строке результативности на
каждое агентство, где агент вёл сделки (мульти-членство, ADR-0070). Личная сводка агента — агрегат этих
строк сквозь агентства (SUM метрик по AgentUserId): она остаётся user-centric и не зависит от текущего
активного агентства. Это отличается от agency-centric отчёта
crm get-statistics (якорь — один текущий AgencyCompanyId).
Модель ответа get-my-agent-performance.md ссылается сюда и
описывает только дельту (агрегация строк — на стороне чтения).
Назначение
AgentPerformanceEntity — перестраиваемая read-проекция метрик агента в разрезе агентства: сколько
сделок он закрыл, сколько фиксаций одобрено, сколько комиссии начислено и выплачено в каждой компании, плюс
состояние членства этой строки (Active / Left). Ключ — композитный (AgentUserId, CompanyId): у
агента может быть несколько строк одновременно (несколько активных членств, ADR-0070). Пожизненная личная
сводка получается суммированием строк по AgentUserId сквозь все агентства — она не зависит от
текущего активного агентства: при выходе из одного агентства и работе в другом (ADR-0061: «игрок меняет
клан» / ADR-0070: «игрок в нескольких командах») earned-метрики каждой строки сохраняются, потому что
привязаны к неизменной паре агент+компания, а не к «текущему» членству (UC-IDN-010,
UC-RWD-006).
Проекция производна и перестраиваема: не source of truth (первоисточники — начисления
commission-accrual.md в rewards, фиксации в crm, членство в identity), её
можно снести и пересобрать реплеем событий-источников (см. «Источники наполнения»). Исторические снапшоты
сделок/лидов/фиксаций в crm не пересчитываются (ADR-0061 п.4) — проекция читает их последствия
(события), а не переписывает.
Классификация
| Поле | Значение |
|---|---|
| Контекст | rewards |
| Kind | Read-projection (перестраиваемая; не AggregateRoot, доменных инвариантов сделки не несёт) |
| Базовые контракты SDK | композитный ключ (AgentUserId, CompanyId) (Guid+Guid PK), IModifiedEntity, IVersionedEntity, IOptimisticBlockedEntity |
| Source of truth | нет (проекция); первоисточники — rewards (начисления), crm (фиксации), identity (членство) |
| Таблица | rewards.agent_performance |
| Tenant isolation | не тенантится — платформенный scope самого агента: строки агрегируются по AgentUserId сквозь все компании (ADR-0061 п.5, ADR-0070 п.6). CompanyId — атрибут разреза строки (часть PK), а не tenant-фильтр: ITenantedEntity не реализуется, авто-TenantScopeInterceptor к таблице не применяется |
Реализуемые capability-интерфейсы
| Интерфейс | Применяется | Комментарий |
|---|---|---|
Композитный ключ (AgentUserId, CompanyId) | Да | Естественный ключ строки (одна строка на пару агент+агентство). Простой IEntity<Guid> не применяется — ключ составной. |
ITenantedEntity | Нет | Проекция платформенного scope; агрегация — по AgentUserId сквозь компании, CompanyId — разрез строки, не tenant. |
IModifiedEntity | Да | UpdatedAt — момент последнего применённого события. |
IVersionedEntity | Да | long Version — счётчик применённых обновлений строки (аудит проекции). |
IOptimisticBlockedEntity | Да | uint RowVersion = PG xmin — конкурентные апдейты одной строки из разных consumer-ов. |
IAuditableEntity / ISoftDeleteEntity / IEntityWithStatus<> | Нет | Проекция не удаляется (пересобирается); membership-состояние строки — плоское поле, не статус-машина агрегата. |
Поля (rewards.agent_performance)
Строка проекции — метрики агента в одном агентстве (пара (AgentUserId, CompanyId)). Пожизненная личная
сводка — агрегат строк по AgentUserId (на стороне чтения).
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
AgentUserId | Guid | Да | PK pk_agent_performance (часть 1); index ix_agent_performance_agent | все события (Kafka key) | Агент; логическая ссылка на identity.users(id). Ключ агрегации личной сводки. |
CompanyId | Guid | Да | PK pk_agent_performance (часть 2) | commission — TenantId; membership — CompanyId; фиксация — OriginAgencyCompanyId (снапшот) | Агентство строки. Иммутабелен для строки; определяет разрез результативности. Логическая ссылка на identity.companies(id). |
FixationsApprovedCount | int | Да | >= 0, default 0 | pin.crm.fixation-approved.v1 | Одобрено фиксаций в этом агентстве (сквозь застройщиков). |
DealsWonCount | int | Да | >= 0, default 0 | pin.rewards.commission-accrual-created.v1 | Закрытых (завершённых) сделок с начисленной комиссией в этом агентстве. Сделки без правила (rewards_accrual_no_rule_total) сюда не попадают — задокументированный edge, см. «Источники наполнения». |
DealsLostCount | int | Да | >= 0, default 0 | pin.rewards.commission-accrual-status-changed.v1 (→ Cancelled, DealCancelled) | Ранее закрытых сделок этого агентства, отменённых/расторгнутых после завершения. |
CommissionAccruedTotal | Money (owned) | Да | см. VO; >= 0 | accrual-created (+=), accrual-cancelled (-=) | Начислено комиссии в этом агентстве (gross по начислениям агента). |
CommissionPaidTotal | Money (owned) | Да | см. VO; >= 0 | accrual → Paid (+=) | Выплачено в этом агентстве (gross начисления, перешедшего в Paid). Точная net-доля агента по периодам — get-my-commission-summary.md. |
MembershipStatus | AgentMembershipStatus | Да | .HasConversion<string>(), varchar(16), default Active | pin.identity.agent-membership-changed.v1 | Состояние членства агента в этом агентстве: Active (активное членство) / Left (членство прекращено). Только эта строка деактивируется при Left — прочие строки агента не трогаются. |
LeaveReason | LeaveReason? | Нет | .HasConversion<string>(), varchar(24) | membership-событие (Left) | Основание деактивации строки: SelfLeave / ExcludedByAgency (ADR-0072). null при Active. |
LeftAt | DateTimeOffset? | Нет | — | membership-событие (Left) | Момент выхода из этого агентства; null при Active. |
EffectiveAt | DateTimeOffset? | Нет | — | membership-событие (Left) | Момент вступления выхода в силу (отложенное увольнение может отличаться от LeftAt); null при Active. |
LastMembershipVersion | long? | Нет | — | membership-событие | AgentMembershipEntity.Version последнего применённого перехода этой строки — монотонный guard (не откатывать старым событием). |
FirstActivityAt | DateTimeOffset? | Нет | — | первое событие пары | Начало работы агента в этом агентстве (первое одобрение/сделка/вступление). |
LastActivityAt | DateTimeOffset? | Нет | index ix_agent_performance_last_activity desc | последнее событие-метрика пары | Момент последнего значимого события пары (сделка/фиксация/выплата/членство). |
UpdatedAt | DateTimeOffset | Да | — | app | IModifiedEntity — момент последнего апдейта строки. |
Version | long | Да | >= 0, default 0 | app | Счётчик применённых обновлений строки. |
RowVersion | uint | Да | concurrency (PG xmin) | db | IOptimisticBlockedEntity. |
Достижения (
AgentAchievement) — user-scoped, не поле строки. Достижения присуждаются по порогам агрегированных метрик агента (сквозь агентства, напр. «10 сделок» суммарно), поэтому не хранятся на per-company строке, а вычисляются детерминированно на стороне чтения над агрегатом строк (get-my-agent-performance.md). Форма — модельAgentAchievementsDocumentниже.
Enum AgentMembershipStatus (состояние строки)
Зеркалит статус членства из identity agent-membership-changed;
хранится на строке пары (AgentUserId, CompanyId).
| Значение | Описание |
|---|---|
Active | Активное членство агента в этом агентстве (последнее membership-событие пары — Active). |
Left | Членство прекращено (последнее событие пары — Left); LeaveReason/LeftAt/EffectiveAt заполнены. Earned-метрики строки сохраняются. |
Производный статус AgentMembershipDisplayStatus (агрегат, не хранится)
Вычисляется на чтении из набора строк агента (не колонка таблицы):
| Значение | Правило вывода |
|---|---|
InAgency | Есть ≥ 1 строка с MembershipStatus = Active (агент состоит хотя бы в одном агентстве). Список активных агентств = CompanyId таких строк. |
Independent | Нет ни одной строки с MembershipStatus = Active (все членства Left либо строки только метрические). |
Value objects / EF owned types
Money (owned; единый VO контекста rewards)
Контракт — commission-accrual.md (Value : decimal numeric(18,2) >= 0,
CurrencyId : short — ссылка на валюту справочника handbook, дефолт RUB; RUB-only — ADR-0048).
Колонки: commission_accrued_total_value/_currency_id, commission_paid_total_value/_currency_id.
Платформа single-currency RUB (INV-6 commission-accrual.md).
Модель достижений (read-shape, versioned)
AgentAchievementsDocument (ContractVersion = 1)
Список достижений агента user-scoped (ADR-0061 п.5: «AgentAchievement») — не колонка таблицы, а форма, вычисляемая на стороне чтения над агрегатом строк агента. Достижения присуждаются по порогам суммарных (сквозь агентства) метрик (напр. «10 сделок», «первая выплата») — детерминированно при каждом чтении/пересчёте.
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
Items | IReadOnlyList<AgentAchievementItem> | Да | ≤ 100 | v1 |
AgentAchievementItem: Code : AgentAchievementCode (enum-string), AwardedAt : DateTimeOffset,
Threshold : int? (порог, по которому присуждено). AgentAchievementCode — конечное множество
(FirstDealWon / TenDealsWon / FirstCommissionPaid / FirstFixationApproved / …), расширяется additive.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
CommissionAccrualEntity | external (событийный источник, без FK) | (AgentUserId, TenantId) | не загружается; счётчики строки — из событий | — |
Агент identity.users | external reference (без FK) | AgentUserId | display-ФИО резолвится по реплике pin.identity.users.v1 | — |
Агентство identity.companies | external reference (без FK) | CompanyId | не загружается; display-имя агентства — по реплике компаний | — |
AgentMembershipEntity (identity) | external (событийный источник) | (AgentUserId, CompanyId) | не загружается; состояние строки — из membership-события пары | — |
Источники наполнения (перестраиваемость)
Проекция — чистый fold над долговечными Kafka-событиями; строка создаётся lazy при первом событии пары
(Upsert по (AgentUserId, CompanyId)). Ключ разреза (CompanyId) каждый источник берёт из своего
иммутабельного снапшота агентства (см. столбец «Источник» в полях), поэтому события разных агентств одного
агента ложатся в разные строки и не затирают друг друга. Все апдейты идемпотентны
(IdempotencyKeyService по MessageId + бизнес-ключ); инкременты применяются ровно один раз.
| Источник (topic) | Consumer | Ключ строки | Дельта проекции |
|---|---|---|---|
pin.rewards.commission-accrual-created.v1 | agent-performance-projection.md | (AgentUserId, TenantId) | DealsWonCount += 1; CommissionAccruedTotal += CommissionAmount; FirstActivityAt/LastActivityAt. Первоисточник — сделка завершена (crm-deal-completed.md). |
pin.rewards.commission-accrual-status-changed.v1 | agent-performance-projection.md | (AgentUserId, TenantId) | → Paid: CommissionPaidTotal += CommissionAmount. → Cancelled(DealCancelled): DealsWonCount -= 1, CommissionAccruedTotal -= CommissionAmount, DealsLostCount += 1 (первоисточник — crm-deal-cancelled.md). → Confirmed и → MoneyReceived: no-op — гейт получения денег (ADR-0067) метрик проекции не меняет: начисленное учтено при создании, выплаченное учитывается на Paid; событие потребляется идемпотентно без изменения строки (задокументированное поведение, не ошибка обработки). |
pin.crm.fixation-approved.v1 | crm-fixation-approved.md | (AgentUserId, OriginAgencyCompanyId) | FixationsApprovedCount += 1; LastActivityAt. |
pin.identity.agent-membership-changed.v1 | agent-membership-changed.md | (AgentUserId, CompanyId) | MembershipStatus по Status (Active → Active; Left → Left + LeaveReason/LeftAt/EffectiveAt), monotonic-guard по LastMembershipVersion строки. Обновляется только строка этого агентства — прочие членства агента остаются. Метрики не трогает — членство меняет лишь состояние строки, earned-счётчики сохраняются (ADR-0061 п.5, ADR-0070 п.6). |
Счётчик DealsWonCount учитывает только сделки, породившие начисление; сделки без активного правила
застройщика (метрика rewards_accrual_no_rule_total) в проекцию не входят — компенсируются ручным
начислением застройщика (порождает commission-accrual-created). Rebuild: снести строки и реплеить
перечисленные топики с offset 0 через выделенную consumer-group pin-rewards-agent-performance (fold
детерминирован; runbook — ../runbooks/rewards-agent-performance-rebuild.md по конвенции).
Согласованность, порядок применения источников и eventual-consistency
Проекция агрегирует четыре независимых топика в строки по паре (AgentUserId, CompanyId); сквозного
порядка между топиками Kafka не гарантирует. Правила согласованности:
- Разрез по агентству изолирован. Каждый источник кладёт дельту в строку своего
CompanyId(снапшот агентства из события). События разных агентств одного агента идут в разные строки — апсерт одной строки не затирает earned-метрики или membership-состояние других строк агента (ключевое свойство мульти-членства, ADR-0070). - Внутри одного топика порядок строгий (key =
Idначисления для accrual-топиков, key =AgentUserId/FixationRequestIdдля остальных), поэтому переходы одного начисления применяются в причинном порядке:created→Confirmed/Paid/Cancelled. - Между топиками возможно временное расхождение.
commission-accrual-createdиcommission-accrual-status-changedидут в разных топиках: реверс отмены (Cancelled(DealCancelled):DealsWonCount -= 1,CommissionAccruedTotal -= …,DealsLostCount += 1) причинно следует за созданием, но технически может прийти раньше. Дельты применяются идемпотентно по бизнес-ключуId/Id:{ToStatus}; счётчики защищены нижней границей (>= 0, floor 0) — при временном опережении реверса значение не уходит в минус, а сходится к корректному итогу после доставки парного события (оба долговечны, реплей детерминирован). - Членство применяется с monotonic-guard по
LastMembershipVersionстроки — старое membership-событие не откатывает более новое состояние этого агентства; накопленные метрики членство не трогает (ADR-0061 п.5).Left(любойReason) деактивирует только строку своегоCompanyId. - Окно eventual-consistency. После завершения/отмены сделки или выплаты соответствующий счётчик становится актуальным в течение лага consumer-групп (обычно секунды; алерт при > 5 мин). До применения агент видит прежние значения; это ожидаемое поведение read-проекции, а не рассогласование данных.
Инварианты значений (после сходимости):
- INV-AP1 (неотрицательность). В каждой строке
FixationsApprovedCount,DealsWonCount,DealsLostCount,CommissionAccruedTotal.Value,CommissionPaidTotal.Value— все>= 0. - INV-AP2 (выплата ≤ начисления). В каждой строке
CommissionPaidTotal.Value <= CommissionAccruedTotal.Value(выплатить нельзя больше начисленного). - INV-AP3 (единственная строка на пару). Не более одной строки на пару
(AgentUserId, CompanyId)(уникальность = композитный PK); повторные события пары апсертят ту же строку. - INV-AP4 (согласованность деактивации).
MembershipStatus = Left⇒LeftAt != null(иLeaveReason/EffectiveAtзаполнены источником);MembershipStatus = Active⇒LeaveReason = LeftAt = EffectiveAt = null. - INV-AP5 (мульти-членство). У одного
AgentUserIdдопустимо несколько строк сMembershipStatus = Active(по одной на агентство) — единственное активное членство больше не инвариант (INV-AM1 снят, ADR-0070). - INV-AP6 (производный display-статус). Агрегат
AgentMembershipDisplayStatus = InAgency⇔ у агента есть ≥ 1 строкаMembershipStatus = Active; иначеIndependent(вычисляется на чтении, не хранится). - INV-AP7 (earned переживает деактивацию). Перевод строки в
Leftне изменяет еёDealsWonCount/DealsLostCount/FixationsApprovedCount/CommissionAccruedTotal/CommissionPaidTotal.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK pk_agent_performance (agent_user_id, company_id); ix_agent_performance_agent btree (agent_user_id) — агрегат личной сводки по агенту; ix_agent_performance_company_active btree (company_id) where membership_status = 'Active' (ростер активного агентства); ix_agent_performance_last_activity btree desc (лидерборды/сортировка) |
| Партиционирование / retention | none — строк ≈ агенты × агентства (агентов ≤ 20k, ADR-0056; на агента обычно 1–2 агентства); retention бессрочный (проекция) |
| Concurrency | RowVersion (xmin): конкурентные апдейты одной строки из разных consumer-групп; monotonic-guard членства по LastMembershipVersion строки |
| Permissions / PII | Агент — только свои строки (record-level AgentUserId = actor, permission rewards.performance.view-own); чтение агрегирует строки в личную сводку. Менеджер ПИН — платформенный scope (агрегированные метрики без PII). Суммы — Sensitive-класс, в логи не пишутся; InvestorUserId/раскладка по сделкам в проекции отсутствуют (только агрегаты) |
| Audit | Version + UpdatedAt; история — в событиях-источниках (проекция перестраиваема) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: ClientEntity
- API (2): GET /api/agent/crm/statistics — Статистика агентства/агента, GET /api/v1/my/agent-performance — Моя личная статистика (агент, user-centric)
- Use Cases (4): UC-RWD-004. Застройщик подтверждает начисление комиссии, UC-RWD-005. Агентство настраивает распределение и формирует платёжное поручение, UC-RWD-006. Агент смотрит личную статистику за всю карьеру (user-centric), UC-RWD-017. Уволенный агент: судьба заработанных комиссий
- События (2): Event Contract: pin.rewards.commission-accrual-created, Event Contract: pin.rewards.commission-accrual-status-changed (CommissionAccrualStatusChangedKafkaEvent)
- Консюмеры (3): Consumer: Agent Performance Projection, Consumer: CRM Fixation Approved, Consumer: Identity Agent Membership Changed
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Rewards Service
Связанные документы
- API чтения: ../api/agent/get-my-agent-performance.md; реестр моделей — ../api/README.md.
- Consumers-источники: ../consumers/agent-performance-projection.md, ../consumers/crm-fixation-approved.md, ../consumers/agent-membership-changed.md.
- Первоисточники: commission-accrual.md; события
crm(../../crm/events/fixation-approved.md) иidentity(../../identity/events/agent-membership-changed.md). - Agency-centric параллель (в границах текущего агентства): crm
get-statistics.md. - Решение: ADR-0061 (п.4 иммутабельность снапшотов, п.5 user-centric метрики),
ADR-0070 (п.6 ключ
(AgentUserId, CompanyId), мульти-членство), ADR-0072 (LeaveReason/EffectiveAt), ADR-0052 (tenancy), ADR-0056 (simplicity-first). Шаблон: entity-template.md.