POST /api/v1/reward-rules/test-calculation — Тестовый расчёт комиссии по атрибутам объекта недвижимости
Проверка таблицы решений застройщиком «какое правило сработает и какая комиссия получится» по вручную
заданным атрибутам объекта недвижимости и ценам — без каких-либо мутаций. Использует тот же доменный сервис
RewardRuleResolver, что и боевые расчёты (calculate-property-object-commission.md,
consumer pin.crm.deal-completed.v1), поэтому результат теста гарантированно совпадает с продом.
Назначение
Менеджер застройщика вводит параметры гипотетического объекта недвижимости (тип, планировка, комнаты, площадь, цены)
и видит: какие активные правила подошли, какое победило по приоритету, какая сумма комиссии выйдет.
Owning-контекст — rewards, алгоритм подбора — дом
domain/reward-rule.md «Алгоритм подбора правила». Feature group — rules.
| Поле | Значение |
|---|
| Сервис/контекст | rewards |
| API area | developer-admin |
| Feature group | rules |
| Статус | draft |
| Документ | docs/06-services/rewards/api/admin/test-reward-rule-calculation.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (менеджер компании-застройщика) |
| Auth policy / Permissions | rewards.rules.view |
| Scope (RBAC) | организация: компания Type = Developer |
| Record-level | не применяется |
| Tenant isolation | Подбор идёт только по правилам TenantId актора |
| Audit | none (read-only вычисление; POST — из-за объёмного тела запроса) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST (без побочных эффектов — semantically safe) |
| Route | /api/v1/reward-rules/test-calculation |
| Success status | 200 (в т.ч. при «правило не найдено» — это валидный результат теста) |
| Idempotency header | не применяется (read) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
TenantId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
PropertyType | body | PropertyType | Да | enum Apartment/Parking/Commercial | — |
ProjectId | body | Guid | Да | непустой Guid; ЖК гипотетического объекта — нужен для правил с предикатом ProjectIds; существование в реплике не требуется (тест гипотетического объекта недвижимости) | — |
LayoutGroupId | body | Guid? | Нет | непустой Guid, если задан | — |
LayoutId | body | Guid? | Нет | непустой Guid, если задан; существование в реплике не требуется (тест гипотетического объекта недвижимости) | — |
Rooms | body | int? | Нет | 0..20 | — |
AreaTotalM2 | body | decimal | Да | > 0, <= 10000, scale 2 | — |
BasePrice | body | MoneyModel | Да | Value > 0; Currency = RUB | суммы не логируются |
FinalPrice | body | MoneyModel | Да | Value >= BasePrice.Value (конечная = базовая + ремонт); Currency == BasePrice.Currency (REWARDS_CURRENCY_MISMATCH) | суммы не логируются |
AsOf | body | DateTimeOffset? | Нет | default UtcNow; момент, на который проверяется период действия правил | — |
Модель ответа TestRewardRuleCalculationQueryResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
IsRuleFound | bool | Да | computed | — | false = ни одно активное правило не подошло (начисление в проде не создалось бы). |
AppliedRule | RewardRuleModel? | При IsRuleFound | победившее правило | — | Полная проекция победителя (README.md). |
MatchedRules | IReadOnlyList<MatchedRuleModel> | Да (может быть пустым) | все подошедшие | — | Диагностика конфликтов: кто подошёл и почему проиграл. |
CommissionAmount | MoneyModel? | При IsRuleFound | computed | — | Сумма комиссии по победившему правилу. |
CalculationBaseAmount | MoneyModel? | При IsRuleFound | computed | — | Использованная база (BasePrice или FinalPrice запроса). |
Вложенная модель MatchedRuleModel
| Поле | Тип | Обязательность | Описание |
|---|
RuleId | Guid | Да | Подошедшее правило. |
Name | string | Да | Название. |
Priority | int | Да | Приоритет (победитель — максимальный). |
CommissionPercent | decimal | Да | Процент. |
CalculationBase | string (enum CommissionCalculationBase) | Да | База. |
IsWinner | bool | Да | true ровно у одного элемента (INV-3 гарантирует единственность максимума). |
Предусловия и постусловия
- Предусловия. Актор — менеджер застройщика с
rewards.rules.view; его компания — застройщик. Набор
правил tenant-а может быть любым (в т.ч. пустым — валидный кейс IsRuleFound = false); синхронизация
реплики планировок не требуется (тест гипотетического объекта, существование LayoutId/
LayoutGroupId не проверяется).
- Постусловия. Мутаций нет: ни одно правило, начисление или проекция не создаются и не меняются,
события не публикуются. Ответ — диагностика подбора (победитель + все подошедшие) на момент
AsOf;
результат детерминированно совпадает с боевым расчётом
(calculate-property-object-commission.md) при тех
же входах и состоянии правил (единый RewardRuleResolver).
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер компании-застройщика; scope — организация
застройщика (
TenantId); требуется право rewards.rules.view. Подбор идёт только по правилам
этого tenant-а.
- Проверка входа. Enum, диапазоны,
FinalPrice.Value >= BasePrice.Value, равенство валют — иначе
ValidationError / REWARDS_CURRENCY_MISMATCH. Существование LayoutId/LayoutGroupId не
требуется (тест гипотетического объекта).
- Выборка правил. Отбираются активные правила застройщика, действующие на момент
AsOf
(EffectiveFrom/EffectiveTo), в порядке Priority DESC — это шаг 1 подбора правила.
- Подбор и расчёт (алгоритм подбора — дом domain/reward-rule.md):
- для каждого правила проверяются
Conditions (логическое И, «пусто = any»): PropertyTypes
содержит PropertyType; ProjectIds содержит ProjectId; LayoutGroupIds содержит LayoutGroupId
(объект без LayoutGroupId не проходит непустой список); LayoutIds содержит LayoutId
(аналогично); Rooms ∈ RoomsRange (объект без Rooms не проходит правило с заданным диапазоном);
AreaTotalM2 ∈ AreaRangeM2;
- подошедшие собираются в
MatchedRuleModel[]; победитель — наиболее специфичный на оси каталога
(LayoutIds > LayoutGroupIds > ProjectIds > без измерений каталога), при равной специфичности —
с максимальным Priority (единственный внутри специфичности, INV-3), IsWinner = true;
- сумма комиссии по победителю: база =
BasePrice или FinalPrice по CalculationBase,
CommissionAmount = процент от базы (округление banker's, scale 2), валюта базы;
- если ни одно правило не подошло →
IsRuleFound = false, AppliedRule /CommissionAmount /
CalculationBaseAmount = null, MatchedRules = [].
- Результат.
TestRewardRuleCalculationQueryResult → 200 (в том числе при IsRuleFound = false
— это валидный результат теста, а не ошибка). Мутаций нет.
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
REWARDS_CURRENCY_MISMATCH | 400 | Валюты BasePrice/FinalPrice различаются или не RUB | «Валюты цен должны совпадать» | no retry |
Общие коды — README.md. «Правило не найдено» — не ошибка (см. шаг 5 алгоритма).
Acceptance-критерии
- Правило подошло. Дано: активное правило застройщика (в периоде на
AsOf), чьи Conditions
покрывают атрибуты запроса. Когда: POST .../test-calculation. Тогда: 200, IsRuleFound = true,
AppliedRule = победитель, CommissionAmount = процент от базы (CalculationBase, banker's, scale 2,
валюта базы), CalculationBaseAmount = использованная база; в MatchedRules победитель помечен
IsWinner = true.
- Промо перекрывает базу по специфичности. Дано: базовое правило без измерений каталога
(
CommissionPercent = 10, бессрочно) и промо-правило ProjectIds = [ProjectId запроса]
(CommissionPercent = 12, период покрывает AsOf). Когда: POST с ProjectId акции. Тогда:
IsRuleFound = true, победитель — промо-правило (более специфичный Project перекрывает ставку на
застройщика), CommissionAmount = 12% от базы; оба правила присутствуют в MatchedRules.
- Конфликт приоритетов внутри специфичности. Дано: два активных правила одной специфичности каталога
подходят под атрибуты, с разными
Priority. Когда: POST. Тогда: IsRuleFound = true, победитель —
с максимальным Priority (единственный внутри специфичности, INV-3), IsWinner = true ровно у него;
оба правила присутствуют в MatchedRules (диагностика, кто и почему проиграл).
- Правило-default. Дано: активное правило с пустыми
Conditions (все измерения = any). Когда: POST
с любыми атрибутами. Тогда: оно попадает в MatchedRules; при отсутствии более приоритетных подошедших
— становится победителем.
- Объект без комнат под диапазоном комнат. Дано: правило с заданным
RoomsRange, объект — Parking
без Rooms. Когда: POST с Rooms = null. Тогда: правило не подходит (в MatchedRules его нет —
объект без Rooms не проходит правило с заданным диапазоном).
- Ни одно правило не подошло. Дано: ни одно активное правило не покрывает атрибуты (или все правила
Draft/Archived, либо вне периода на AsOf). Когда: POST. Тогда: 200, IsRuleFound = false,
AppliedRule/CommissionAmount/CalculationBaseAmount = null, MatchedRules = [] — валидный результат
теста, не ошибка (в проде начисление не создалось бы).
- Период на момент AsOf. Дано: правило с
EffectiveTo раньше AsOf (или EffectiveFrom позже
AsOf). Когда: POST с этим AsOf. Тогда: правило исключено из выборки (не в MatchedRules).
- Планировка не в реплике. Дано:
LayoutId, отсутствующий в справочнике застройщика. Когда: POST.
Тогда: 200 (существование не требуется — тест гипотетического объекта); правило с этим LayoutId в
LayoutIds подходит, если LayoutId запроса совпал.
- Несовпадение валют. Дано:
BasePrice.Currency != FinalPrice.Currency или валюта не RUB. Когда:
POST. Тогда: 400 REWARDS_CURRENCY_MISMATCH; подбор не выполняется.
- Нет права. Дано: у актора нет
rewards.rules.view. Когда: POST. Тогда: 403 FORBIDDEN.
Совместимость и наблюдаемость
- Вход/выход — additive-only; расчётная логика меняется только вместе с
RewardRuleResolver
(единая точка — гарантия совпадения теста и прода).
- Logs:
TenantId, IsRuleFound, AppliedRule.Id, число MatchedRules (цены и суммы не логируются);
metrics: rewards_rule_test_calculation_total{found}; traces: OTel; dashboard rewards-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы