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

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.

Metadata

ПолеЗначение
Сервис/контекстrewards
API areadeveloper-admin
Feature grouprules
Статусdraft
Документdocs/06-services/rewards/api/admin/test-reward-rule-calculation.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (менеджер компании-застройщика)
Auth policy / Permissionsrewards.rules.view
Scope (RBAC)организация: компания Type = Developer
Record-levelне применяется
Tenant isolationПодбор идёт только по правилам TenantId актора
Auditnone (read-only вычисление; POST — из-за объёмного тела запроса)

HTTP-контракт

ПолеЗначение
MethodPOST (без побочных эффектов — semantically safe)
Route/api/v1/reward-rules/test-calculation
Success status200 (в т.ч. при «правило не найдено» — это валидный результат теста)
Idempotency headerне применяется (read)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
PropertyTypebodyPropertyTypeДаenum Apartment/Parking/Commercial
ProjectIdbodyGuidДанепустой Guid; ЖК гипотетического объекта — нужен для правил с предикатом ProjectIds; существование в реплике не требуется (тест гипотетического объекта недвижимости)
LayoutGroupIdbodyGuid?Нетнепустой Guid, если задан
LayoutIdbodyGuid?Нетнепустой Guid, если задан; существование в реплике не требуется (тест гипотетического объекта недвижимости)
Roomsbodyint?Нет0..20
AreaTotalM2bodydecimalДа> 0, <= 10000, scale 2
BasePricebodyMoneyModelДаValue > 0; Currency = RUBсуммы не логируются
FinalPricebodyMoneyModelДаValue >= BasePrice.Value (конечная = базовая + ремонт); Currency == BasePrice.Currency (REWARDS_CURRENCY_MISMATCH)суммы не логируются
AsOfbodyDateTimeOffset?Нетdefault UtcNow; момент, на который проверяется период действия правил

Модель ответа TestRewardRuleCalculationQueryResult

ПолеТипОбязательностьИсточникMaskingОписание
IsRuleFoundboolДаcomputedfalse = ни одно активное правило не подошло (начисление в проде не создалось бы).
AppliedRuleRewardRuleModel?При IsRuleFoundпобедившее правилоПолная проекция победителя (README.md).
MatchedRulesIReadOnlyList<MatchedRuleModel>Да (может быть пустым)все подошедшиеДиагностика конфликтов: кто подошёл и почему проиграл.
CommissionAmountMoneyModel?При IsRuleFoundcomputedСумма комиссии по победившему правилу.
CalculationBaseAmountMoneyModel?При IsRuleFoundcomputedИспользованная база (BasePrice или FinalPrice запроса).

Вложенная модель MatchedRuleModel

ПолеТипОбязательностьОписание
RuleIdGuidДаПодошедшее правило.
NamestringДаНазвание.
PriorityintДаПриоритет (победитель — максимальный).
CommissionPercentdecimalДаПроцент.
CalculationBasestring (enum CommissionCalculationBase)ДаБаза.
IsWinnerboolДаtrue ровно у одного элемента (INV-3 гарантирует единственность максимума).

Предусловия и постусловия

  • Предусловия. Актор — менеджер застройщика с rewards.rules.view; его компания — застройщик. Набор правил tenant-а может быть любым (в т.ч. пустым — валидный кейс IsRuleFound = false); синхронизация реплики планировок не требуется (тест гипотетического объекта, существование LayoutId/ LayoutGroupId не проверяется).
  • Постусловия. Мутаций нет: ни одно правило, начисление или проекция не создаются и не меняются, события не публикуются. Ответ — диагностика подбора (победитель + все подошедшие) на момент AsOf; результат детерминированно совпадает с боевым расчётом (calculate-property-object-commission.md) при тех же входах и состоянии правил (единый RewardRuleResolver).

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер компании-застройщика; scope — организация застройщика (TenantId); требуется право rewards.rules.view. Подбор идёт только по правилам этого tenant-а.
  2. Проверка входа. Enum, диапазоны, FinalPrice.Value >= BasePrice.Value, равенство валют — иначе ValidationError / REWARDS_CURRENCY_MISMATCH. Существование LayoutId/LayoutGroupId не требуется (тест гипотетического объекта).
  3. Выборка правил. Отбираются активные правила застройщика, действующие на момент AsOf (EffectiveFrom/EffectiveTo), в порядке Priority DESC — это шаг 1 подбора правила.
  4. Подбор и расчёт (алгоритм подбора — дом 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 = [].
  5. Результат. TestRewardRuleCalculationQueryResult200 (в том числе при IsRuleFound = false — это валидный результат теста, а не ошибка). Мутаций нет.

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
REWARDS_CURRENCY_MISMATCH400Валюты 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.

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

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

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