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.v1 → commission-accrual.md).
Классификация
| Поле | Значение |
|---|---|
| Контекст | rewards |
| Kind | AggregateRoot |
| Source of truth | rewards |
| Таблица | rewards.reward_rules |
| Tenant isolation | TenantId = CompanyId застройщика (Type = Developer, ADR-0052) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Уникальный идентификатор правила. |
TenantId | Guid | Да | index ix_reward_rules_tenant_id (btree), логическая ссылка на identity.companies.id (без FK — чужой контекст) | identity (актор) | Компания-застройщик, владелец правила. Все запросы фильтруются по tenant. |
Name | string | Да | varchar(256), min 1 | app | Название правила в админке застройщика (например «Квартиры 1–2 комнаты, 3%»). |
Description | string? | Нет | varchar(2000) | app | Комментарий для менеджеров застройщика. |
Status | RewardRuleStatus | Да | хранится как text, varchar(32), index ix_reward_rules_tenant_status (btree: tenant_id, status) | app | Draft / Active / Archived (см. stateDiagram). Только Active участвуют в подборе. |
CalculationBase | CommissionCalculationBase | Да | хранится как text, varchar(32) | app | База расчёта: BasePrice (базовая цена до ремонта/скидки) или FinalPrice (конечная цена = базовая + ремонт). |
CommissionPercent | decimal | Да | numeric(5,2), check commission_percent > 0 AND commission_percent <= 100 | app | Процент вознаграждения агента от базы расчёта. |
Priority | int | Да | check priority >= 0 AND priority <= 100000; partial-unique ux_reward_rules_tenant_priority_active (tenant_id, priority) where status = 'Active' and deleted_at is null | app | Тай-брейк среди подошедших правил одной специфичности (когда ось каталога не разделяет правила — например два правила уровня Project с разными предикатами типа/комнат/площади): выбирается правило с наибольшим Priority. Специфичность каталога (Project/LayoutGroup/Layout) первична (INV-8). Уникален среди активных правил tenant-а (INV-3). |
EffectiveFrom | DateTimeOffset | Да | — | app | Начало периода действия (ADR-0068). При активации без явного значения = момент активации; у существующих (до ADR-0068) записей = дата заведения. |
EffectiveTo | DateTimeOffset? | Нет | check effective_to is null or effective_to > effective_from | app | Конец периода действия (исключительно). null — бессрочно. Правило вне периода не участвует в подборе (но остаётся Active — период проверяется в предикате выборки). |
Conditions | RewardRuleConditionsDocument (jsonb) | Да (может быть «пустым» — все измерения any) | jsonb, typed, ContractVersion; GIN-индекс не требуется (подбор идёт по выборке всех активных правил tenant-а — их десятки, ADR-0056) | app | Условия-предикаты (см. раздел JSONB). Пустой документ = правило-default, подходит любому объекту недвижимости застройщика. |
CreatedAt | DateTimeOffset | Да | index ix_reward_rules_created_at | app | Момент создания (аудит). |
CreatedByUserId | Guid? | Нет | без FK | app | Менеджер застройщика, создавший правило. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | partial-filter where deleted_at is null на рабочих индексах | app | Soft-delete; заполняется только вместе с Status = Archived. |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Доменная версия — инкремент при каждой мутации, идёт в 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 (наиболее специфично) → LayoutGroupIds → ProjectIds → пусто = ставка на застройщика целиком
(наименее специфично). Предикаты PropertyTypes/RoomsRange/AreaRangeM2 — дополнительные фильтры совпадения
(не влияют на уровень специфичности каталога; при равенстве уровня разрешаются через Priority). Сигнатура
специфичности = набор заполненных измерений — используется для инварианта непересечения периодов (INV-9).
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
PropertyTypes | IReadOnlyList<PropertyType> | Нет (пусто = any) | элементы уникальны, ≤ 3 | v1 |
ProjectIds | IReadOnlyList<Guid> | Нет (пусто = any) | элементы уникальны, ≤ 200; каждый — существующий Project.Id (ЖК) из chessboard того же застройщика (валидация при активации через реплику справочника, см. ../consumers/chessboard-dimensions.md). Наименее специфичное измерение оси каталога — задаёт акцию/ставку на конкретный ЖК | v1 |
LayoutGroupIds | IReadOnlyList<Guid> | Нет (пусто = any) | элементы уникальны, ≤ 200; каждая — существующий LayoutGroup.Id из chessboard того же застройщика. Средняя специфичность оси каталога (группа планировок внутри ЖК) | v1 |
LayoutIds | IReadOnlyList<Guid> | Нет (пусто = any) | элементы уникальны, ≤ 500; каждая — существующий Layout.Id из chessboard того же застройщика (валидация при активации через реплику справочника). Наиболее специфичное измерение оси каталога (индивидуальные условия по планировке) | v1 |
RoomsRange | NullableRange<int> | Нет (пусто = any) | границы Min/Max в [0; 20], Min <= Max (для заданных границ) | v1 |
AreaRangeM2 | NullableRange<decimal> | Нет (пусто = any) | границы Min/Max в (0; 10000] м², scale 2, Min <= Max | v1 |
Правила версии: добавление измерения — 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 сделки).
- Отобрать активные правила застройщика на момент
asOf:TenantId = DeveloperCompanyId,Status = Active,DeletedAt is null,EffectiveFrom <= asOf,(EffectiveTo is null or EffectiveTo > asOf)— правил у застройщика десятки, выборка целиком (ADR-0056). - Отфильтровать выборку по предикатам
Conditions(семантика И / any — выше). Объект недвижимости безRooms(парковка/коммерция) не проходит правило с заданным диапазоном комнат. - Из подошедших выбрать наиболее специфичное на оси каталога правило:
LayoutIds-правило побеждаетLayoutGroupIds-правило, оно —ProjectIds-правило, оно — правило без измерений каталога (ставка на застройщика). Более специфичное правило (в т.ч. промо-акция на ЖК/группу планировок) перекрывает базовую ставку на застройщика на время своего периода. - При равной специфичности каталога (например два
ProjectIds-правила с разными предикатами типа/комнат) — тай-брейк по максимальномуPriority(уникален среди активных, INV-3). Внутри одной сигнатуры специфичности пересечения периодов запрещены (INV-9), поэтому на любойasOfактивна не более чем одна такая запись. - Нет подошедших → правило не найдено (каталог не показывает комиссию; начисление агентской стороны не создаётся — см. commission-accrual.md, шаг «нет правила»).
Пример промо-акции. Базовое правило: без измерений каталога, CommissionPercent = 10, EffectiveTo = null
(бессрочно). На неделю акции заводится второе правило: ProjectIds = [ЖК-X], CommissionPercent = 12,
EffectiveFrom/EffectiveTo на границы недели. Объект ЖК-X в период акции резолвится в 12% (более специфичный
Project + покрывающий период), вне периода и объекты других ЖК — в базовые 10%. По завершении акции запись
архивируется (или истекает по EffectiveTo); базовая ставка ретроактивно не менялась.
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
CompanyEntity (застройщик, identity) | reference (логическая, без FK) | TenantId | restrict (архив компании не трогает правила — исторические начисления ссылаются) |
Project / LayoutGroup / Layout (chessboard) | replica-reference (логическая; валидация при активации по локальной реплике справочника) | Conditions.ProjectIds / Conditions.LayoutGroupIds / Conditions.LayoutIds | — (удалённый в chessboard ЖК/планировка делает условие невыполнимым, правило остаётся) |
CommissionAccrualEntity (начисления) | reference (обратная) | commission_accruals.applied_rule_id → reward_rules.id | restrict — правило с начислениями только архивируется |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-1 PercentRange | 0 < CommissionPercent <= 100 | проверка входа + db check | REWARDS_RULE_PERCENT_OUT_OF_RANGE |
| INV-2 ConsistentRanges | RoomsRange.Min <= RoomsRange.Max, AreaRangeM2.Min <= AreaRangeM2.Max, EffectiveFrom < EffectiveTo (для заданных пар) | проверка входа + db check (период) | REWARDS_RULE_RANGE_INVALID |
| INV-3 UniqueActivePriority | Priority уникален среди Active не удалённых правил tenant-а — детерминированный тай-брейк среди подошедших правил одной специфичности каталога (пересечение предикатов допустимо, конфликт разрешает приоритет) | partial-unique index + проверка перед Activate | REWARDS_RULE_PRIORITY_TAKEN |
| INV-4 ValidReferences | Все ProjectIds/LayoutGroupIds/LayoutIds существуют в реплике справочника chessboard и принадлежат застройщику TenantId | Activate (сверка по реплике справочника) | REWARDS_RULE_LAYOUT_NOT_FOUND |
| INV-5 ImmutableWhenActive | В Active меняется только EffectiveTo (досрочное завершение — только сокращение периода, не раньше текущего момента); остальные поля — только в Draft | guard по Status в операции изменения | REWARDS_RULE_ACTIVE_IMMUTABLE |
| INV-6 ArchiveConsistency | Status = Archived ⇔ DeletedAt != null | операция архивирования устанавливает оба | REWARDS_RULE_INVALID_ARCHIVE_STATE |
| INV-7 TenantOwnership | Актор — менеджер компании TenantId с permission rewards.rules.manage | изоляция по tenant + permission-policy | 403 |
| 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_RANGE | 400 | CommissionPercent вне диапазона (0; 100] (INV-1). |
REWARDS_RULE_RANGE_INVALID | 400 | Некорректный диапазон комнат/площади или EffectiveTo <= EffectiveFrom (INV-2). |
REWARDS_RULE_LAYOUT_NOT_FOUND | 400 | Ссылка ProjectIds/LayoutGroupIds/LayoutIds не найдена в реплике или чужого застройщика (INV-4). |
REWARDS_RULE_PRIORITY_TAKEN | 409 | Priority занят другим активным правилом tenant-а при активации (INV-3). |
REWARDS_REWARD_RULE_OVERLAP | 409 | Пересечение периодов активных правил одной сигнатуры специфичности при активации (INV-9). |
REWARDS_RULE_ACTIVE_IMMUTABLE | 409 | Попытка изменить Active-правило сверх сокращения EffectiveTo (INV-5). |
REWARDS_RULE_INVALID_ARCHIVE_STATE | 409 | Нарушение согласованности Status = Archived ⇔ DeletedAt (INV-6). |
REWARDS_RULE_NOT_FOUND | 404 | Правило не найдено (операции admin-API). |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
RewardRuleCreatedEvent | доменное | Успешный Create | Id, TenantId |
RewardRuleActivatedEvent | доменное | Draft → Active | Id, Priority, EffectiveFrom |
RewardRuleArchivedEvent | доменное | * → Archived | Id, actor |
RewardRuleChangedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.rewards.reward-rule-changed.v1, key = Id) | Activate / Deactivate / Archive / изменение EffectiveTo | Id, 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(topicpin.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 |
| Партиционирование / retention | none — правил на застройщика десятки, всего тысячи; retention бессрочный (soft-delete) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую) на мутациях в админке застройщика + блокировка активных правил той же сигнатуры специфичности при Activate (INV-9, непересечение периодов) |
| Permissions / PII | Управление — rewards.rules.manage (scope = организация-застройщик); чтение своих правил — rewards.rules.view. Комиссию по объекту недвижимости видят агенты/менеджеры (rewards.commission.view-property-object, отдаётся через внутренний API для catalog); инвестору комиссия не отдаётся. PII нет |
| Audit | Version + actor-stamping; история изменений — через события (отдельной audit-сущности нет) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: CatalogPropertyObjectCard, Entity / Aggregate: CommissionAccrualEntity, Entity / Aggregate: PlatformCommissionTermsEntity
- API (11): Catalog Public API — реестр методов и общие правила, GET /api/v1/reward-rules — Список правил вознаграждений застройщика, GET /api/v1/reward-rules/{ruleId} — Карточка правила вознаграждения, POST /api/v1/reward-rules — Создание правила вознаграждения (черновик), POST /api/v1/reward-rules/{ruleId}/activate — Активация правила вознаграждения, POST /api/v1/reward-rules/{ruleId}/archive — Архивирование правила вознаграждения, POST /api/v1/reward-rules/{ruleId}/deactivate — Возврат правила в черновик, POST /api/v1/reward-rules/test-calculation — Тестовый расчёт комиссии по атрибутам объекта недвижимости, POST /internal/v1/commission/calculate-property-object — Расчёт комиссии по объекту недвижимости (internal, s2s для catalog), PUT /api/v1/reward-rules/{ruleId} — Изменение правила вознаграждения, Rewards API — реестр операций и общие контрактные модели
- Use Cases (4): UC-RWD-001. Застройщик настраивает правила вознаграждений агентов, UC-RWD-002. Агент видит комиссию на объекте недвижимости в каталоге, UC-RWD-003. Сделка завершена — начисление комиссии (system), UC-RWD-015. Настройка индивидуальных условий вознаграждения по области и сроку (акции и boost)
- События (3): Event Contract: pin.crm.deal-completed (DealCompletedEvent), Event Contract: pin.rewards.commission-rules (CommissionRulesChangedKafkaEvent), Event Contract: pin.rewards.reward-rule-changed (RewardRuleChangedKafkaEvent)
- Консюмеры (3): Consumer: Chessboard Dimensions Sync (pin-rewards-chessboard-dimensions), Consumer: CRM Deal Completed, Consumer: Rewards Commission Enrichment (pin-catalog-rewards-commission)
- adr (2): ADR-0066 — удержание ПИН и реферальные ставки, ADR-0068 — Scoped и time-bounded конфигурация комиссионных и реферальных ставок
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Rewards Service
Связанные документы
- commission-accrual.md — применение правила при завершении сделки (snapshot правила в начислении).
- agency-commission-policy.md, payout-order.md — дальнейшая судьба комиссии в агентстве.
- ../README.md — границы контекста; consumer справочника планировок — ../consumers/chessboard-dimensions.md.
- События: ../events/reward-rule-changed.md (per-rule дельта);
../events/commission-rules.md — per-developer снапшот
набора правил
pin.rewards.commission-rules.v1(основной канал в каталог, ADR-0068); потребитель — ../../catalog/consumers/rewards-commission.md. - ЖК/планировки:
../../chessboard/domain/project.md,../../chessboard/domain/layout-group.md,../../chessboard/domain/layout.md(пути по конвенции). - ADR-0068 —
единый паттерн scoped + time-bounded, ось
Developer → Project → LayoutGroup, промо как временные записи, снапшот применённой области/периода; эталон периодной резолюции — referral-rate-rule.md. - ADR-0052 (tenancy), ADR-0056 (simplicity-first — подбор правил выборкой в память, без спец-хранилищ).
- Шаблон: entity-template.md; правила: cross-cutting-document-requirements.md.