Контекст: ценообразование

NOTE

Статус P2 (2026-04-25):

  • Implemented: PriceRule aggregate, Transformation (percentage/fixed/tiers), RuleScope (supplier/customer/group/tag), AppliesTo, Priority, ValidityWindow, OverlapValidator, snapshot+polling reload, admin CRUD HTTP, preview, drafts review, auto-derivation pipeline (B1 baseline + D1/D2/D3 detectors + CF3 confidence + SI2 scope inference). См. backend/internal/core/pricing/, ADR-0036.
  • Target design (P2b): PreferredSellerPolicy, BasePriceKindPolicy, regional rules с per-warehouse scope.
  • Target design (P3): CustomPricingHandler plugin, formula/logistics/vat Transformation kinds, full Visibility filter chain.

Правила маркировки — в 50-processes/documentation-standard.md.

Назначение

Proposal pipeline (search-proposal.md, Layer 2b) использует pricing resolver строго в базовой валюте поставщика. pricing_mode=on_requestPricingModeOnRequest value object. Rule aggregates — P2+.

Превращает наблюдение SupplierOfferObservation в финальную цену для конкретного контекста (customer, quantity, region, delivery_point, contract, date). Применяет цепочку PriceRule, кастомные обработчики (plugin), фильтры Visibility — и отдаёт финальный PricingResult с прозрачным breakdown.

Главный смысл

Цена — не число, а функция f(observation, pricing_context). Детерминированная, кэшируемая, объяснимая. Обязательный invariant: никогда не блокироваться ожиданием поставщика — graceful degrade на stale observation, async refresh.

Агрегаты / сущности / value objects

ИмяТипНазначение
PriceRule🟨 AggregateПравило преобразования цены.
RuleScopeVOНа что распространяется (supplier / canonical / classification_tag / customer_pricing_group / customer).
RuleAppliesToVOУсловия (customer_type, quantity_min, region, …).
TransformationVOpercentage / tiers / fixed / formula / logistics / vat.
PriorityVOЦелое число; больше = выше.
PricingContextVO(customer, quantity, region, delivery_point, contract, date, base_price_kind: PriceKind, requested_currency: ISO4217, preferred_sellers?, blocked_sellers?). base_price_kind — какое поле PriceSet брать за базу. requested_currency — в какой валюте клиент хочет результат (default: customer billing currency, fallback RUB).
PreferredSellerPolicy🟨 AggregatePer-customer / per-pricing-group policy: {preferred_sellers[], blocked_sellers[], reason?}. Применяется в observation tie-break (ADR-0026).
PriceKindVO enumnet / gross / list_tarif / retail_rec. Выбор — из PricingContext или BasePriceKindPolicy.
BasePriceKindPolicy🟨 AggregateПравило выбора PriceKind по (customer_pricing_group | customer | supplier). Priority-ordered.
PricingResultVO(final_price, currency, base_price_kind, observation_source, breakdown[]).
PriceBreakdownItemVO(rule_id, label, amount).
ObservationSourceVO(type, credential_label, observed_at, fallback_reason, price_set_snapshot). price_set_snapshot сохраняет все поля PriceSet, даже если в расчёт пошло только одно — для объяснимости.
CustomPricingHandler⚙️ PluginПлагин: применяет правила, когда API поставщика их не отдаёт.
HandlerRegistrationEРегистрация плагина (id, applies_to, trigger_condition, implementation, priority).
HandlerInvocationContextVOЧто передаётся в plugin: (observation, pricing_context, supplier_meta, base_price_kind).
PricingAdjustmentVOВозврат от plugin: [PriceRule] для применения.
PricingModeVO enumfixed / on_request (наследовано от PriceSet.on_request).

Кастомный обработчик ценообразования: назначение и контракт

Зачем

Многие поставщики возвращают только базовую цену — без скидок за объём, без специальных условий клиента, без региональных надбавок. Часть этих правил — известны нам / клиенту вне канала поставщика (контракт офлайн, договорённость на словах, политика клиента «всегда +5% к сметам с пометкой X»).

CustomPricingHandler — точка расширения, в которую такие правила инжектируются как код / DSL, а не как PriceRule в общей таблице. Это даёт:

  • Сложную логику (зависимости от внешних данных, calls в собственный сервис клиента).
  • Per-customer plugin без замусоривания общего справочника правил.
  • Динамическое поведение (handler может обращаться к live-источникам).

Контракт плагина

HandlerRegistration
├── id
├── label
├── applies_to              — (customer_id | customer_pricing_group | supplier_id | scope)
├── trigger_condition       — DSL-предикат: когда вызывать
│                              (e.g. "supplier=X AND observation.has_volume_discount=false")
├── implementation_ref      — ссылка на handler в plugin registry
├── priority                — относительно других handlers
├── valid_from, valid_to
├── enabled
└── metadata

Handler-функция (semantically):

PricingAdjustment handler.apply(HandlerInvocationContext ctx)

Как встроено в пайплайн

flowchart TD
    A["🟦 PricingRequested<br/>(customer, canonical)"] --> B["🟦 SelectObservation"]
    B --> C["🟧 PricingObservationSelected"]
    C --> D["🟦 ApplyVisibilityFilter"]
    D --> E["🟦 LoadRules<br/>(scope ∋ context)"]
    E --> F{"🟪 Есть handlers с trigger=true?"}
    F -->|да| G["🟦 InvokeHandlers (по priority)"]
    G --> H["🟧 CustomPricingHandlerInvoked"]
    H --> I["🟦 MergeAdjustments"]
    F -->|нет| I
    I --> J["🟦 ApplyRulesChain<br/>(canonical sequence)"]
    J --> K["🟧 PriceRuleApplied (each)"]
    K --> L["🟧 PricingResolved<br/>(breakdown + observation_source)"]

Изоляция и безопасность

  • Handler выполняется в изолированном sandbox (timeout 50 мс, no network unless explicitly whitelisted).
  • Handler не имеет прямого доступа к credentials или сырым данным других клиентов.
  • Любой выход handler сверх SLA → fallback на «без handler» + alert.
  • Все вызовы handler логируются в pricing_handler_invocations (CH) с input hash, output hash, latency.

Доменные события

СобытиеПричина
РасчётЦеныЗапрошен (PricingRequested)Customer / Estimate / Search
ИсточникНаблюденияВыбран (PricingObservationSelected)Алгоритм selection отработал
ПолитикаВидимостиПрименена (VisibilityFilterApplied)Visibility BC обработал кандидаты
КастомныйОбработчикВызван (CustomPricingHandlerInvoked)trigger_condition сработал
КастомныйОбработчикПровалил (CustomPricingHandlerFailed)Timeout / exception → fallback
ПравилоЦеныПрименено (PriceRuleApplied)Один шаг chain
ЦенаРассчитана (PricingResolved)Финальный результат с breakdown
ЦенаОтсутствует (PricingUnavailable)Все observations stale + no fallback → ставится job
ПравилоЦеныСоздано (PriceRuleCreated)Operator
ПравилоЦеныИзменено (PriceRuleChanged)Operator
КонфликтПравилОбнаружен (PriceRuleConflictDetected)Validator при сохранении
КастомныйОбработчикЗарегистрирован (CustomPricingHandlerRegistered)Composition root
КастомныйОбработчикОтключён (CustomPricingHandlerDisabled)Operator / circuit breaker

Команды

КомандаАкторЦелевой агрегатРезультат
РассчитатьЦену (Compute)Estimate / Search / CustomerPricingResolved или PricingUnavailable
ВыбратьИсточникНаблюдения (SelectObservation)PricingEnginePricingObservationSelected
ВызватьОбработчик (InvokeHandler)PricingEngineCustomPricingHandlerInvoked
ПрименитьЦепочкуПравил (ApplyRulesChain)PricingEnginePriceRuleApplied*
СоздатьПравилоЦены (CreateRule)OperatorPriceRulePriceRuleCreated
ИзменитьПравилоЦены (UpdateRule)OperatorPriceRulePriceRuleChanged
ЗарегистрироватьОбработчик (RegisterHandler)Composition rootHandlerRegistrationCustomPricingHandlerRegistered
ОтключитьОбработчик (DisableHandler)Operator / CircuitHandlerRegistrationCustomPricingHandlerDisabled

Политики

ТриггерРеакция
OfferObservationRecorded (Offers)Invalidate pricing cache для затронутых canonical
SupplierCredentialActivated (Credentials)Invalidate cache для этого customer’а
PriceRuleChangedInvalidate cache по RuleScope
SellerRatingUpdated / SellerStatusChanged (Supplier Network)Invalidate cache для offers затронутого seller’а
PreferredSellerPolicyChangedInvalidate per-customer / per-pricing-group cache
PricingRequested + все observations stale→ Async EnqueueEnrichmentJob{kind=refresh_observation, priority=high}; вернуть stale с пометкой
CustomPricingHandlerFailed подряд > N разDisableHandler (circuit breaker) + alert
PriceRuleCreated + overlap detected→ publish PriceRuleConflictDetected → AI-агент priority_overlap_review в Moderation BC
ProposedAction(SuggestPriority) от Moderation→ admin UI hint при сохранении (operator подтверждает один клик) или escalate если confidence низкая
VisibilityPolicyChanged (Visibility) + scope crossInvalidate per-subject cache

Read-модели

  • 🟩 pricing_cache (Redis) — (canonical_id, customer_id, quantity_bucket) → PricingResult. TTL по волатильности.
  • 🟩 pricing_breakdown_log (CH) — full audit для каждого расчёта.
  • 🟩 pricing_handler_invocations (CH) — вызовы plugin: input hash, output hash, latency, success.
  • 🟩 price_rule_overlap_matrix (PG) — для админ UI и валидатора.

Инварианты

  1. Privacy: observation от credential клиента A с scope=customer и customer_ref=A никогда не используется для pricing клиента B (если B не member той же группы). Проверяется на уровне репозитория.
  2. Detalism: при тех же входах (observation, pricing_context, rules_snapshot, base_price_kind_snapshot) — результат идентичен.
  3. Каноническая последовательность типов трансформации внутри одного priority-уровня:
    1. base_price (PriceSet[base_price_kind] из observation)
    2. fixed (overrides)
    3. tiers (по quantity)
    4. percentage (наценки/скидки)
    5. formula (если есть)
    6. logistics
    7. vat (если применимо)
    
  4. Цикл правил (formula → formula) — hard error, сохранение запрещено.
  5. priority — обязательное поле без default.
  6. pricing_mode = on_request ⇒ финал = флаг on_request, не число; в estimate такие позиции не суммируются.
  7. Handler-вызов имеет SLA p95 < 50 мс. При нарушении — fallback и alert.
  8. Никогда не блокируемся ожиданием поставщика. Stale допустим с пометкой.
  9. Breakdown сохраняется вместе с estimate для аудита и повторного расчёта.
  10. PriceSet[base_price_kind] = null при фиксированной policy ⇒ fallback по цепочке net → gross → list_tarif → retail_rec с пометкой fallback_reason=base_price_kind_fallback. Если все null — PricingUnavailable.
  11. PriceSet.vat_rate не применяется повторно, если base_price_kind = gross (цена уже с НДС). Invariant защищает от double-vat.
  12. Observations с seller.status ∈ {inactive, banned} никогда не попадают в tie-break. Observations с seller в blocked_sellers (PreferredSellerPolicy) — тоже. Проверка на уровне repository.
  13. preferred_sellers[] и blocked_sellers[] не могут пересекаться. Validator блокирует сохранение PreferredSellerPolicy с overlap.
  14. Currency invariant: PricingResult.final_price.currency == PricingContext.requested_currency. Если observation не содержит requested_currency — PricingUnavailable(reason=currency_unavailable). Pricing Engine не выполняет auto-conversion между валютами — это отдельный слой (FX-service), интегрируется позже через отдельный ADR, если понадобится.
  15. Promo priority invariant: если для одного (offer, credential, seller_ref) есть observation с активным validity_window (promo) и без window (regular) — выбирается observation с window. Overlap двух promo windows запрещён policy-level (validator).

Алгоритм выбора observation

См. ../scenarios/pricing-calculation.md — сквозной разбор. Краткое:

1. Кандидаты: own customer credential + group-shared + system.
2. Filter:
   a. свежесть (TTL price=6h, stock=2h, forecast=12h);
   b. currency availability — observation.prices[requested_currency] должен существовать
      (иначе PricingUnavailable{reason=currency_unavailable} + async refresh);
   c. validity_window: if PriceSet.validity_window задан — now() должен лежать в [from, to];
      expired (to < now()) observations отбрасываются; future (from > now()) игнорируются;
   d. Visibility;
   e. seller status != inactive|banned;
   f. seller NOT IN blocked_sellers (PreferredSellerPolicy).
3. Sort (marketplace-aware, ADR-0026):
   a. источник credential: own > group > system;
   b. trust_level supply chain: origin > tier_1 > tier_2 > unverified;
   c. preferred_sellers first (если задано для customer / pricing_group);
   d. SellerRating.score (выше = лучше; null trait как 0);
   e. reliability: regular > degraded (ADR-0027 scraper observations помечены degraded);
   f. validity_window specificity: observation с активным window (promo) приоритетнее
      observation без window (regular price) — promo должна применяться;
   g. лучшая цена по выбранному PriceKind в requested_currency;
   h. самое свежее observed_at;
   i. lexicographic (observation_id) — детерминированный tie-break.
4. Если пусто — `PricingUnavailable` + async refresh job.

Алгоритм выбора PriceKind (base)

1. BasePriceKindPolicy применяется в порядке priority:
     customer >= customer_pricing_group >= supplier >= default
2. Default: net с fallback gross.
3. Если PriceSet[policy.kind] = null — fallback по цепочке (см. invariant 10).
4. Выбор фиксируется в PricingResult.base_price_kind и в breakdown первой строкой.

Причина: разные типы сделок требуют разной базы. B2B-клиент хочет net (потом свой VAT). B2C видит gross. При экспорте расчёта в коммерческое предложение клиент-оператор может переключить на list_tarif для показа скидки от MSRP.

Интеграционные события (публикуем)

Топик: pricing.events.v1. Partition key: (customer_id, canonical_id).

ИмяКогда
PriceRuleCreated, PriceRuleChanged, PriceRuleDeprecatedИзменения справочника
CustomPricingHandlerRegistered, CustomPricingHandlerDisabledPlugin lifecycle
PricingResolvedОпционально (для аналитики; не для real-time)

Подписанные интеграционные события

ИсточникСобытиеРеакция
OffersOfferObservationRecordedInvalidate cache
CredentialsSupplierCredentialActivated/Failing/RevokedInvalidate cache + fallback flags
CustomerCustomerPricingGroupChangedInvalidate cache для customer
VisibilityVisibilityPolicyChangedInvalidate per-subject cache
Supplier NetworkSupplyChainTraceRecomputedInvalidate (trust_level mог измениться)

Связи в context map

BCПаттернНазначение
OffersCustomer/Supplier (Offers — upstream)Pricing — downstream consumer observations
CredentialsOHS (Credentials → Pricing)CredentialContext для observation routing
CustomerSK (SessionContext, PricingContext)
VisibilitySK (применяется внутри pipeline)
Supplier NetworkSK (SupplyChainTrace, TrustLevel)
Custom Pricing HandlerOHS (Pricing — open host для plugin)Расширяемая plugin-точка
EstimatePL (Pricing → Estimate)PricingResolved потребляется orchestrator’ом

Мини event storming

flowchart LR
    subgraph EST["Estimate / Search / Customer"]
        REQ["🟦 PricingRequested"]
    end
    subgraph PR["Pricing"]
        SEL["🟦 SelectObservation"]
        ES1["🟧 PricingObservationSelected"]
        VIS["🟦 ApplyVisibilityFilter"]
        LOAD["🟦 LoadRules"]
        DEC{"🟪 Есть handlers?"}
        H["🟦 InvokeHandlers"]
        EH["🟧 CustomPricingHandlerInvoked"]
        APPLY["🟦 ApplyRulesChain<br/>(canonical sequence)"]
        EAP["🟧 PriceRuleApplied"]
        RES["🟧 PricingResolved"]
        FAIL["🟧 PricingUnavailable"]
    end
    subgraph CRED["Credentials"]
        CC["🟦 CredentialContext"]
    end
    subgraph OFF["Offers"]
        RM["🟩 offer_current_observation"]
    end
    subgraph V["Visibility"]
        VP["🟩 CompiledPolicy"]
    end
    subgraph CPH["Custom Pricing Handlers (plugin)"]
        PLG["⚙️ Handler"]
    end

    REQ --> SEL
    CC -.OHS.-> SEL
    RM -.read.-> SEL
    SEL --> ES1 --> VIS
    VP -.read.-> VIS
    VIS --> LOAD --> DEC
    DEC -->|да| H --> EH --> APPLY
    DEC -->|нет| APPLY
    PLG -.OHS.-> H
    APPLY --> EAP --> RES
    SEL -. all stale .-> FAIL

Пример кастомного обработчика ценообразования

Сценарий: клиент «Альфа» имеет договор с поставщиком ETM на скидку 8% от объёма ≥ 100 шт за позицию. Скидка не возвращается ETM API (договорная, офлайн).

HandlerRegistration{
  id: "alpha-etm-volume-discount",
  applies_to: { customer_id: "alpha", supplier_id: "etm" },
  trigger_condition: "quantity >= 100",
  implementation_ref: "alpha:volume-discount-v1",
  priority: 80,
  valid_from: 2026-01-01,
}

PricingAdjustment от handler:

[
  {
    "rule_id": "alpha-etm-volume-discount-applied",
    "transformation": { "kind": "percentage", "value": -8 },
    "label": "Договорная скидка Альфа/ETM от 100 шт"
  }
]

Эта скидка вставляется в общую цепочку перед vat, отображается в breakdown как явная строка.

Связанные файлы