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

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
KindRead-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)ИсточникОписание
AgentUserIdGuidДаPK pk_agent_performance (часть 1); index ix_agent_performance_agentвсе события (Kafka key)Агент; логическая ссылка на identity.users(id). Ключ агрегации личной сводки.
CompanyIdGuidДаPK pk_agent_performance (часть 2)commission — TenantId; membership — CompanyId; фиксация — OriginAgencyCompanyId (снапшот)Агентство строки. Иммутабелен для строки; определяет разрез результативности. Логическая ссылка на identity.companies(id).
FixationsApprovedCountintДа>= 0, default 0pin.crm.fixation-approved.v1Одобрено фиксаций в этом агентстве (сквозь застройщиков).
DealsWonCountintДа>= 0, default 0pin.rewards.commission-accrual-created.v1Закрытых (завершённых) сделок с начисленной комиссией в этом агентстве. Сделки без правила (rewards_accrual_no_rule_total) сюда не попадают — задокументированный edge, см. «Источники наполнения».
DealsLostCountintДа>= 0, default 0pin.rewards.commission-accrual-status-changed.v1 (→ Cancelled, DealCancelled)Ранее закрытых сделок этого агентства, отменённых/расторгнутых после завершения.
CommissionAccruedTotalMoney (owned)Дасм. VO; >= 0accrual-created (+=), accrual-cancelled (-=)Начислено комиссии в этом агентстве (gross по начислениям агента).
CommissionPaidTotalMoney (owned)Дасм. VO; >= 0accrual → Paid (+=)Выплачено в этом агентстве (gross начисления, перешедшего в Paid). Точная net-доля агента по периодам — get-my-commission-summary.md.
MembershipStatusAgentMembershipStatusДа.HasConversion<string>(), varchar(16), default Activepin.identity.agent-membership-changed.v1Состояние членства агента в этом агентстве: Active (активное членство) / Left (членство прекращено). Только эта строка деактивируется при Left — прочие строки агента не трогаются.
LeaveReasonLeaveReason?Нет.HasConversion<string>(), varchar(24)membership-событие (Left)Основание деактивации строки: SelfLeave / ExcludedByAgency (ADR-0072). null при Active.
LeftAtDateTimeOffset?Нетmembership-событие (Left)Момент выхода из этого агентства; null при Active.
EffectiveAtDateTimeOffset?Нетmembership-событие (Left)Момент вступления выхода в силу (отложенное увольнение может отличаться от LeftAt); null при Active.
LastMembershipVersionlong?Нетmembership-событиеAgentMembershipEntity.Version последнего применённого перехода этой строки — монотонный guard (не откатывать старым событием).
FirstActivityAtDateTimeOffset?Нетпервое событие парыНачало работы агента в этом агентстве (первое одобрение/сделка/вступление).
LastActivityAtDateTimeOffset?Нетindex ix_agent_performance_last_activity descпоследнее событие-метрика парыМомент последнего значимого события пары (сделка/фиксация/выплата/членство).
UpdatedAtDateTimeOffsetДаappIModifiedEntity — момент последнего апдейта строки.
VersionlongДа>= 0, default 0appСчётчик применённых обновлений строки.
RowVersionuintДаconcurrency (PG xmin)dbIOptimisticBlockedEntity.

Достижения (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
ContractVersionintДа= 1v1
ItemsIReadOnlyList<AgentAchievementItem>Да≤ 100v1

AgentAchievementItem: Code : AgentAchievementCode (enum-string), AwardedAt : DateTimeOffset, Threshold : int? (порог, по которому присуждено). AgentAchievementCode — конечное множество (FirstDealWon / TenDealsWon / FirstCommissionPaid / FirstFixationApproved / …), расширяется additive.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
CommissionAccrualEntityexternal (событийный источник, без FK)(AgentUserId, TenantId)не загружается; счётчики строки — из событий
Агент identity.usersexternal reference (без FK)AgentUserIddisplay-ФИО резолвится по реплике pin.identity.users.v1
Агентство identity.companiesexternal 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.v1agent-performance-projection.md(AgentUserId, TenantId)DealsWonCount += 1; CommissionAccruedTotal += CommissionAmount; FirstActivityAt/LastActivityAt. Первоисточник — сделка завершена (crm-deal-completed.md).
pin.rewards.commission-accrual-status-changed.v1agent-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.v1crm-fixation-approved.md(AgentUserId, OriginAgencyCompanyId)FixationsApprovedCount += 1; LastActivityAt.
pin.identity.agent-membership-changed.v1agent-membership-changed.md(AgentUserId, CompanyId)MembershipStatus по Status (ActiveActive; LeftLeft + 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 для остальных), поэтому переходы одного начисления применяются в причинном порядке: createdConfirmed/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 = LeftLeftAt != nullLeaveReason/EffectiveAt заполнены источником); MembershipStatus = ActiveLeaveReason = 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 (лидерборды/сортировка)
Партиционирование / retentionnone — строк ≈ агенты × агентства (агентов ≤ 20k, ADR-0056; на агента обычно 1–2 агентства); retention бессрочный (проекция)
ConcurrencyRowVersion (xmin): конкурентные апдейты одной строки из разных consumer-групп; monotonic-guard членства по LastMembershipVersion строки
Permissions / PIIАгент — только свои строки (record-level AgentUserId = actor, permission rewards.performance.view-own); чтение агрегирует строки в личную сводку. Менеджер ПИН — платформенный scope (агрегированные метрики без PII). Суммы — Sensitive-класс, в логи не пишутся; InvestorUserId/раскладка по сделкам в проекции отсутствуют (только агрегаты)
AuditVersion + UpdatedAt; история — в событиях-источниках (проекция перестраиваема)

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

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

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