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

Entity / Aggregate: RewardRuleEntity

Дом полей агрегата «Правило вознаграждения» контекста rewards. Все проекции (модели ответов API, payload событий, расчёт комиссии для catalog и для начислений) ссылаются сюда и описывают только дельту.

Назначение

RewardRuleEntity — настраиваемое застройщиком правило вознаграждения агента: процент комиссии от базовой цены объекта (до ремонта, до скидки) или от конечной цены (базовая + ремонт), с условиями-предикатами по проекту-ЖК (ProjectId), группе планировок (LayoutGroupId), планировке (LayoutId), типу объекта (квартира / парковка / коммерческое помещение), количеству комнат и площади объекта. Правило действует в границах периода EffectiveFrom/EffectiveTo, что позволяет вводить временные промо-ставки без правки постоянных записей (типовой кейс: застройщик поднимает ставку 10% → 12% на неделю акции — заводится вторая, более специфичная запись с ограниченным периодом).

Набор активных правил застройщика образует таблицу решений: для конкретного объекта недвижимости выбирается одно правило по правилу «наиболее специфичное активное на момент asOf» (специфичность по оси каталога Project → LayoutGroup → Layout — более глубокое измерение побеждает; Priority — детерминированный тай-брейк среди правил одной специфичности — см. раздел «Инварианты» и «Алгоритм подбора»). Выбранное правило определяет комиссию, которую агент увидит в каталоге (catalog → внутренний API rewards) и которая будет начислена при завершении сделки (consumer pin.crm.deal-completed.v1commission-accrual.md).

Классификация

ПолеЗначение
Контекстrewards
KindAggregateRoot
Source of truthrewards
Таблицаrewards.reward_rules
Tenant isolationTenantId = CompanyId застройщика (Type = Developer, ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappУникальный идентификатор правила.
TenantIdGuidДаindex ix_reward_rules_tenant_id (btree), логическая ссылка на identity.companies.id (без FK — чужой контекст)identity (актор)Компания-застройщик, владелец правила. Все запросы фильтруются по tenant.
NamestringДаvarchar(256), min 1appНазвание правила в админке застройщика (например «Квартиры 1–2 комнаты, 3%»).
Descriptionstring?Нетvarchar(2000)appКомментарий для менеджеров застройщика.
StatusRewardRuleStatusДахранится как text, varchar(32), index ix_reward_rules_tenant_status (btree: tenant_id, status)appDraft / Active / Archived (см. stateDiagram). Только Active участвуют в подборе.
CalculationBaseCommissionCalculationBaseДахранится как text, varchar(32)appБаза расчёта: BasePrice (базовая цена до ремонта/скидки) или FinalPrice (конечная цена = базовая + ремонт).
CommissionPercentdecimalДаnumeric(5,2), check commission_percent > 0 AND commission_percent <= 100appПроцент вознаграждения агента от базы расчёта.
PriorityintДаcheck priority >= 0 AND priority <= 100000; partial-unique ux_reward_rules_tenant_priority_active (tenant_id, priority) where status = 'Active' and deleted_at is nullappТай-брейк среди подошедших правил одной специфичности (когда ось каталога не разделяет правила — например два правила уровня Project с разными предикатами типа/комнат/площади): выбирается правило с наибольшим Priority. Специфичность каталога (Project/LayoutGroup/Layout) первична (INV-8). Уникален среди активных правил tenant-а (INV-3).
EffectiveFromDateTimeOffsetДаappНачало периода действия (ADR-0068). При активации без явного значения = момент активации; у существующих (до ADR-0068) записей = дата заведения.
EffectiveToDateTimeOffset?Нетcheck effective_to is null or effective_to > effective_fromappКонец периода действия (исключительно). null — бессрочно. Правило вне периода не участвует в подборе (но остаётся Active — период проверяется в предикате выборки).
ConditionsRewardRuleConditionsDocument (jsonb)Да (может быть «пустым» — все измерения any)jsonb, typed, ContractVersion; GIN-индекс не требуется (подбор идёт по выборке всех активных правил tenant-а — их десятки, ADR-0056)appУсловия-предикаты (см. раздел JSONB). Пустой документ = правило-default, подходит любому объекту недвижимости застройщика.
CreatedAtDateTimeOffsetДаindex ix_reward_rules_created_atappМомент создания (аудит).
CreatedByUserIdGuid?Нетбез FKappМенеджер застройщика, создавший правило.
UpdatedAtDateTimeOffsetДаappМомент изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?Нетpartial-filter where deleted_at is null на рабочих индексахappSoft-delete; заполняется только вместе с Status = Archived.
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appДоменная версия — инкремент при каждой мутации, идёт в payload событий.

Enum RewardRuleStatus (хранится text, хранится как text)

ЗначениеОписание
DraftЧерновик: редактируется, в подборе не участвует.
ActiveДействующее правило: участвует в подборе комиссии для каталога и начислений (с учётом периода EffectiveFrom/EffectiveTo). Редактирование полей запрещено (INV-5), кроме EffectiveTo.
ArchivedАрхив (soft-delete): выведено из подбора навсегда; исторические ссылки из начислений живы.

Enum CommissionCalculationBase

ЗначениеОписание
BasePriceПроцент считается от базовой цены объекта — до ремонта и до скидки.
FinalPriceПроцент считается от конечной цены объекта — базовая цена + ремонт.

Enum PropertyType (условие по типу объекта; значения — из chessboard)

ЗначениеОписание
ApartmentКвартира / апартамент.
ParkingПарковочное место.
CommercialКоммерческое помещение.

Unknown-значение типа объекта в событии/запросе (новый тип в chessboard) — правило с непустым PropertyTypes считается не подошедшим (fail-closed для условия), правило-default (PropertyTypes пуст) — подходит.

Value objects

Отдельных value objects нет — денежных полей нет (правило хранит только процент), условия — типизированный jsonb (ниже).

Модели JSONB (versioned)

RewardRuleConditionsDocument (jsonb, колонка conditions, ContractVersion = 1)

Семантика: измерение со значением null/пустой список = «любое значение» (условие не проверяется); непустое — значение объекта недвижимости обязано попасть в список/диапазон. Между измерениями — логическое И.

Специфичность правила выводится из самого глубокого заданного измерения оси каталога: LayoutIds (наиболее специфично) → LayoutGroupIdsProjectIds → пусто = ставка на застройщика целиком (наименее специфично). Предикаты PropertyTypes/RoomsRange/AreaRangeM2 — дополнительные фильтры совпадения (не влияют на уровень специфичности каталога; при равенстве уровня разрешаются через Priority). Сигнатура специфичности = набор заполненных измерений — используется для инварианта непересечения периодов (INV-9).

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
PropertyTypesIReadOnlyList<PropertyType>Нет (пусто = any)элементы уникальны, ≤ 3v1
ProjectIdsIReadOnlyList<Guid>Нет (пусто = any)элементы уникальны, ≤ 200; каждый — существующий Project.Id (ЖК) из chessboard того же застройщика (валидация при активации через реплику справочника, см. ../consumers/chessboard-dimensions.md). Наименее специфичное измерение оси каталога — задаёт акцию/ставку на конкретный ЖКv1
LayoutGroupIdsIReadOnlyList<Guid>Нет (пусто = any)элементы уникальны, ≤ 200; каждая — существующий LayoutGroup.Id из chessboard того же застройщика. Средняя специфичность оси каталога (группа планировок внутри ЖК)v1
LayoutIdsIReadOnlyList<Guid>Нет (пусто = any)элементы уникальны, ≤ 500; каждая — существующий Layout.Id из chessboard того же застройщика (валидация при активации через реплику справочника). Наиболее специфичное измерение оси каталога (индивидуальные условия по планировке)v1
RoomsRangeNullableRange<int>Нет (пусто = any)границы Min/Max в [0; 20], Min <= Max (для заданных границ)v1
AreaRangeM2NullableRange<decimal>Нет (пусто = any)границы Min/Max в (0; 10000] м², scale 2, Min <= Maxv1

Правила версии: добавление измерения — additive (той же версией, null = any у старых записей); изменение семантики измерения — ContractVersion + 1 + migration-reader. Секреты/PII в jsonb запрещены.

Алгоритм подбора правила для объекта недвижимости

Используется внутренним API для catalog («сколько заработает агент на объекте недвижимости») и consumer-ом завершения сделки (commission-accrual.md). Вход: DeveloperCompanyId, атрибуты объекта недвижимости (ProjectId, LayoutGroupId?, LayoutId?, PropertyType, Rooms?, AreaTotalM2), момент asOf (для начисления asOf = CompletedAt сделки).

  1. Отобрать активные правила застройщика на момент asOf: TenantId = DeveloperCompanyId, Status = Active, DeletedAt is null, EffectiveFrom <= asOf, (EffectiveTo is null or EffectiveTo > asOf) — правил у застройщика десятки, выборка целиком (ADR-0056).
  2. Отфильтровать выборку по предикатам Conditions (семантика И / any — выше). Объект недвижимости без Rooms (парковка/коммерция) не проходит правило с заданным диапазоном комнат.
  3. Из подошедших выбрать наиболее специфичное на оси каталога правило: LayoutIds-правило побеждает LayoutGroupIds-правило, оно — ProjectIds-правило, оно — правило без измерений каталога (ставка на застройщика). Более специфичное правило (в т.ч. промо-акция на ЖК/группу планировок) перекрывает базовую ставку на застройщика на время своего периода.
  4. При равной специфичности каталога (например два ProjectIds-правила с разными предикатами типа/комнат) — тай-брейк по максимальному Priority (уникален среди активных, INV-3). Внутри одной сигнатуры специфичности пересечения периодов запрещены (INV-9), поэтому на любой asOf активна не более чем одна такая запись.
  5. Нет подошедших → правило не найдено (каталог не показывает комиссию; начисление агентской стороны не создаётся — см. commission-accrual.md, шаг «нет правила»).

Пример промо-акции. Базовое правило: без измерений каталога, CommissionPercent = 10, EffectiveTo = null (бессрочно). На неделю акции заводится второе правило: ProjectIds = [ЖК-X], CommissionPercent = 12, EffectiveFrom/EffectiveTo на границы недели. Объект ЖК-X в период акции резолвится в 12% (более специфичный Project + покрывающий период), вне периода и объекты других ЖК — в базовые 10%. По завершении акции запись архивируется (или истекает по EffectiveTo); базовая ставка ретроактивно не менялась.

Связи

СвязьТипКлючDelete behavior
CompanyEntity (застройщик, identity)reference (логическая, без FK)TenantIdrestrict (архив компании не трогает правила — исторические начисления ссылаются)
Project / LayoutGroup / Layout (chessboard)replica-reference (логическая; валидация при активации по локальной реплике справочника)Conditions.ProjectIds / Conditions.LayoutGroupIds / Conditions.LayoutIds— (удалённый в chessboard ЖК/планировка делает условие невыполнимым, правило остаётся)
CommissionAccrualEntity (начисления)reference (обратная)commission_accruals.applied_rule_id → reward_rules.idrestrict — правило с начислениями только архивируется

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 PercentRange0 < CommissionPercent <= 100проверка входа + db checkREWARDS_RULE_PERCENT_OUT_OF_RANGE
INV-2 ConsistentRangesRoomsRange.Min <= RoomsRange.Max, AreaRangeM2.Min <= AreaRangeM2.Max, EffectiveFrom < EffectiveTo (для заданных пар)проверка входа + db check (период)REWARDS_RULE_RANGE_INVALID
INV-3 UniqueActivePriorityPriority уникален среди Active не удалённых правил tenant-а — детерминированный тай-брейк среди подошедших правил одной специфичности каталога (пересечение предикатов допустимо, конфликт разрешает приоритет)partial-unique index + проверка перед ActivateREWARDS_RULE_PRIORITY_TAKEN
INV-4 ValidReferencesВсе ProjectIds/LayoutGroupIds/LayoutIds существуют в реплике справочника chessboard и принадлежат застройщику TenantIdActivate (сверка по реплике справочника)REWARDS_RULE_LAYOUT_NOT_FOUND
INV-5 ImmutableWhenActiveВ Active меняется только EffectiveTo (досрочное завершение — только сокращение периода, не раньше текущего момента); остальные поля — только в Draftguard по Status в операции измененияREWARDS_RULE_ACTIVE_IMMUTABLE
INV-6 ArchiveConsistencyStatus = ArchivedDeletedAt != nullоперация архивирования устанавливает обаREWARDS_RULE_INVALID_ARCHIVE_STATE
INV-7 TenantOwnershipАктор — менеджер компании TenantId с permission rewards.rules.manageизоляция по tenant + permission-policy403
INV-8 SpecificityResolutionПервичный дискриминатор подбора — специфичность оси каталога (Layout > LayoutGroup > Project > застройщик); более специфичное правило перекрывает менее специфичное на время своего периода; Priority — тай-брейк только внутри одной специфичностиалгоритм подбора (ResolveRule)— (детерминизм, не бизнес-ошибка)
INV-9 NoOverlapWithinSpecificityПериоды [EffectiveFrom, EffectiveTo|∞) активных правил с одинаковой сигнатурой специфичности (тот же набор заполненных измерений Conditions с теми же значениями) не пересекаются — на любой asOf активна не более одной такой записи (детерминизм промо-акций)операция Activate, app-level: активные правила той же сигнатуры читаются под блокировкой, пересечение периодов отклоняетсяREWARDS_REWARD_RULE_OVERLAP

Коды ошибок

КодHTTPУсловие
REWARDS_RULE_PERCENT_OUT_OF_RANGE400CommissionPercent вне диапазона (0; 100] (INV-1).
REWARDS_RULE_RANGE_INVALID400Некорректный диапазон комнат/площади или EffectiveTo <= EffectiveFrom (INV-2).
REWARDS_RULE_LAYOUT_NOT_FOUND400Ссылка ProjectIds/LayoutGroupIds/LayoutIds не найдена в реплике или чужого застройщика (INV-4).
REWARDS_RULE_PRIORITY_TAKEN409Priority занят другим активным правилом tenant-а при активации (INV-3).
REWARDS_REWARD_RULE_OVERLAP409Пересечение периодов активных правил одной сигнатуры специфичности при активации (INV-9).
REWARDS_RULE_ACTIVE_IMMUTABLE409Попытка изменить Active-правило сверх сокращения EffectiveTo (INV-5).
REWARDS_RULE_INVALID_ARCHIVE_STATE409Нарушение согласованности Status = ArchivedDeletedAt (INV-6).
REWARDS_RULE_NOT_FOUND404Правило не найдено (операции admin-API).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
RewardRuleCreatedEventдоменноеУспешный CreateId, TenantId
RewardRuleActivatedEventдоменноеDraft → ActiveId, Priority, EffectiveFrom
RewardRuleArchivedEventдоменное* → ArchivedId, actor
RewardRuleChangedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.reward-rule-changed.v1, key = Id)Activate / Deactivate / Archive / изменение EffectiveToId, TenantId (застройщик), Status, CalculationBase, CommissionPercent, Priority, EffectiveFrom, EffectiveTo, Conditions (тот же typed-документ), Version. Потребитель: catalog (инвалидация/обновление денормализованной комиссии в витрине — ../../catalog/consumers/rewards-commission.md)
CommissionRulesChangedKafkaEventинтеграционное (ContractVersion = 1, topic pin.rewards.commission-rules.v1, key = DeveloperCompanyId)Любое изменение набора активных правил застройщика (Activate / Deactivate / Archive / смена EffectiveTo)Per-developer снапшот набора активных правил: DeveloperCompanyId, Rules[] (по каждому активному правилу: Id, CalculationBase, CommissionPercent, Priority, EffectiveFrom, EffectiveTo, Conditions), RulesetVersion (монотонная версия набора для guard-порядка у потребителя). Потребитель: catalog — атомарная замена денормализованного набора правил застройщика и пересчёт комиссий (ADR-0068). Контракт-файл: ../events/commission-rules.md

Интеграционные события публикуются атомарно со сменой состояния агрегата. CommissionRulesChangedKafkaEvent — основной канал распространения в catalog по ADR-0068 (снапшот всего набора устраняет необходимость батч-опроса на стороне каталога); RewardRuleChangedKafkaEvent остаётся per-rule дельтой того же изменения.

Конкурентность, реплики и распространение изменений

  • Гонка за приоритет. Две параллельные активации на один свободный Priority разрешаются partial-unique индексом ux_reward_rules_tenant_priority_active: проходит одна, проигравшая получает REWARDS_RULE_PRIORITY_TAKEN (INV-3) и должна выбрать другой приоритет. Тай-брейк внутри одной специфичности остаётся детерминированным.
  • Гонка за период (промо). Две параллельные активации правил одной сигнатуры специфичности с пересекающимися периодами разрешаются проверкой INV-9 под блокировкой ключа сигнатуры: проходит одна, проигравшая получает REWARDS_REWARD_RULE_OVERLAP. Это гарантирует, что на любой asOf активна не более одной записи данной специфичности → детерминизм ResolveRule при промо-акциях.
  • Устаревшая реплика справочника. Валидация ProjectIds/LayoutGroupIds/LayoutIds при Activate (INV-4) идёт по локальной реплике справочника (../consumers/chessboard-dimensions.md). Если реплика отстала, активация корректного правила может временно отклоняться REWARDS_RULE_LAYOUT_NOT_FOUND — восстановление: повторить после догона синка. ЖК/планировка, удалённые в chessboard уже после активации, делают условие невыполнимым (правило не подойдёт объекту), но само правило остаётся Active — переактивация не требуется.
  • Распространение в каталог (async). Активация/деактивация/архивирование/смена EffectiveTo публикуют per-developer снапшот CommissionRulesChangedKafkaEvent (topic pin.rewards.commission-rules.v1, key = DeveloperCompanyId) и per-rule дельту RewardRuleChangedKafkaEvent; catalog атомарно заменяет денормализованный набор правил застройщика и пересчитывает комиссию по своему consumer-у. Окно eventual-consistency — секунды: агент краткий промежуток может видеть в каталоге прежнюю комиссию (UC-RWD-002, поток инвалидации).
  • Неретроактивность. Изменение или архивирование правила не пересчитывает уже созданные начисления — в каждом начислении хранится AppliedRuleSnapshot на момент завершения сделки (asOf = CompletedAt): Id, применённая специфичность (набор заполненных измерений) и период (EffectiveFrom/EffectiveTo) — начисление объяснимо постфактум («12% применены, т.к. сделка попала в акцию ЖК-X, период …») (commission-accrual.md, решение №4; ADR-0068 п.4). Правило меняет только будущие расчёты (каталог и новые начисления).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK pk_reward_rules (id); ix_reward_rules_tenant_id btree; ix_reward_rules_tenant_status btree (tenant_id, status) where deleted_at is null — основная выборка резолвера; ux_reward_rules_tenant_priority_active unique btree (tenant_id, priority) where status = 'Active' and deleted_at is null; ix_reward_rules_created_at btree
Партиционирование / retentionnone — правил на застройщика десятки, всего тысячи; retention бессрочный (soft-delete)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую) на мутациях в админке застройщика + блокировка активных правил той же сигнатуры специфичности при Activate (INV-9, непересечение периодов)
Permissions / PIIУправление — rewards.rules.manage (scope = организация-застройщик); чтение своих правил — rewards.rules.view. Комиссию по объекту недвижимости видят агенты/менеджеры (rewards.commission.view-property-object, отдаётся через внутренний API для catalog); инвестору комиссия не отдаётся. PII нет
AuditVersion + actor-stamping; история изменений — через события (отдельной audit-сущности нет)

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

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

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