Контекст: ценообразование
NOTE
Статус P2 (2026-04-25):
- Implemented:
PriceRuleaggregate,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):
CustomPricingHandlerplugin,formula/logistics/vatTransformation kinds, full Visibility filter chain.Правила маркировки — в
50-processes/documentation-standard.md.
Назначение
Proposal pipeline (
search-proposal.md, Layer 2b) использует pricing resolver строго в базовой валюте поставщика.pricing_mode=on_request↔PricingModeOnRequestvalue 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 | Правило преобразования цены. |
RuleScope | VO | На что распространяется (supplier / canonical / classification_tag / customer_pricing_group / customer). |
RuleAppliesTo | VO | Условия (customer_type, quantity_min, region, …). |
Transformation | VO | percentage / tiers / fixed / formula / logistics / vat. |
Priority | VO | Целое число; больше = выше. |
PricingContext | VO | (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 | 🟨 Aggregate | Per-customer / per-pricing-group policy: {preferred_sellers[], blocked_sellers[], reason?}. Применяется в observation tie-break (ADR-0026). |
PriceKind | VO enum | net / gross / list_tarif / retail_rec. Выбор — из PricingContext или BasePriceKindPolicy. |
BasePriceKindPolicy | 🟨 Aggregate | Правило выбора PriceKind по (customer_pricing_group | customer | supplier). Priority-ordered. |
PricingResult | VO | (final_price, currency, base_price_kind, observation_source, breakdown[]). |
PriceBreakdownItem | VO | (rule_id, label, amount). |
ObservationSource | VO | (type, credential_label, observed_at, fallback_reason, price_set_snapshot). price_set_snapshot сохраняет все поля PriceSet, даже если в расчёт пошло только одно — для объяснимости. |
CustomPricingHandler | ⚙️ Plugin | Плагин: применяет правила, когда API поставщика их не отдаёт. |
HandlerRegistration | E | Регистрация плагина (id, applies_to, trigger_condition, implementation, priority). |
HandlerInvocationContext | VO | Что передаётся в plugin: (observation, pricing_context, supplier_meta, base_price_kind). |
PricingAdjustment | VO | Возврат от plugin: [PriceRule] для применения. |
PricingMode | VO enum | fixed / 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 / Customer | — | PricingResolved или PricingUnavailable |
ВыбратьИсточникНаблюдения (SelectObservation) | PricingEngine | — | PricingObservationSelected |
ВызватьОбработчик (InvokeHandler) | PricingEngine | — | CustomPricingHandlerInvoked |
ПрименитьЦепочкуПравил (ApplyRulesChain) | PricingEngine | — | PriceRuleApplied* |
СоздатьПравилоЦены (CreateRule) | Operator | PriceRule | PriceRuleCreated |
ИзменитьПравилоЦены (UpdateRule) | Operator | PriceRule | PriceRuleChanged |
ЗарегистрироватьОбработчик (RegisterHandler) | Composition root | HandlerRegistration | CustomPricingHandlerRegistered |
ОтключитьОбработчик (DisableHandler) | Operator / Circuit | HandlerRegistration | CustomPricingHandlerDisabled |
Политики
| Триггер | Реакция |
|---|---|
OfferObservationRecorded (Offers) | Invalidate pricing cache для затронутых canonical |
SupplierCredentialActivated (Credentials) | Invalidate cache для этого customer’а |
PriceRuleChanged | Invalidate cache по RuleScope |
SellerRatingUpdated / SellerStatusChanged (Supplier Network) | Invalidate cache для offers затронутого seller’а |
PreferredSellerPolicyChanged | Invalidate 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 cross | Invalidate 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 и валидатора.
Инварианты
- Privacy: observation от credential клиента A с scope=customer и customer_ref=A никогда не используется для pricing клиента B (если B не member той же группы). Проверяется на уровне репозитория.
- Detalism: при тех же входах
(observation, pricing_context, rules_snapshot, base_price_kind_snapshot)— результат идентичен. - Каноническая последовательность типов трансформации внутри одного
priority-уровня:1. base_price (PriceSet[base_price_kind] из observation) 2. fixed (overrides) 3. tiers (по quantity) 4. percentage (наценки/скидки) 5. formula (если есть) 6. logistics 7. vat (если применимо) - Цикл правил (formula → formula) — hard error, сохранение запрещено.
priority— обязательное поле без default.pricing_mode = on_request⇒ финал = флагon_request, не число; в estimate такие позиции не суммируются.- Handler-вызов имеет SLA p95 < 50 мс. При нарушении — fallback и alert.
- Никогда не блокируемся ожиданием поставщика. Stale допустим с пометкой.
- Breakdown сохраняется вместе с estimate для аудита и повторного расчёта.
PriceSet[base_price_kind]= null при фиксированной policy ⇒ fallback по цепочкеnet → gross → list_tarif → retail_recс пометкойfallback_reason=base_price_kind_fallback. Если все null —PricingUnavailable.PriceSet.vat_rateне применяется повторно, еслиbase_price_kind = gross(цена уже с НДС). Invariant защищает от double-vat.- Observations с
seller.status ∈ {inactive, banned}никогда не попадают в tie-break. Observations с seller вblocked_sellers(PreferredSellerPolicy) — тоже. Проверка на уровне repository. preferred_sellers[]иblocked_sellers[]не могут пересекаться. Validator блокирует сохранениеPreferredSellerPolicyс overlap.- Currency invariant:
PricingResult.final_price.currency == PricingContext.requested_currency. Если observation не содержит requested_currency —PricingUnavailable(reason=currency_unavailable). Pricing Engine не выполняет auto-conversion между валютами — это отдельный слой (FX-service), интегрируется позже через отдельный ADR, если понадобится. - 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, CustomPricingHandlerDisabled | Plugin lifecycle |
PricingResolved | Опционально (для аналитики; не для real-time) |
Подписанные интеграционные события
| Источник | Событие | Реакция |
|---|---|---|
| Offers | OfferObservationRecorded | Invalidate cache |
| Credentials | SupplierCredentialActivated/Failing/Revoked | Invalidate cache + fallback flags |
| Customer | CustomerPricingGroupChanged | Invalidate cache для customer |
| Visibility | VisibilityPolicyChanged | Invalidate per-subject cache |
| Supplier Network | SupplyChainTraceRecomputed | Invalidate (trust_level mог измениться) |
Связи в context map
| BC | Паттерн | Назначение |
|---|---|---|
| Offers | Customer/Supplier (Offers — upstream) | Pricing — downstream consumer observations |
| Credentials | OHS (Credentials → Pricing) | CredentialContext для observation routing |
| Customer | SK (SessionContext, PricingContext) | — |
| Visibility | SK (применяется внутри pipeline) | — |
| Supplier Network | SK (SupplyChainTrace, TrustLevel) | — |
| Custom Pricing Handler | OHS (Pricing — open host для plugin) | Расширяемая plugin-точка |
| Estimate | PL (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 как явная строка.
Связанные файлы
- Сценарий:
../scenarios/pricing-calculation.md,../scenarios/credential-onboarding.md. offers.md,credentials.md,visibility.md,supplier-network.md,customer.md.- ADR-0014 — graceful degradation.
- ADR-0025 — PriceSet / PriceKind /
BasePriceKindPolicyрождаются здесь. - ADR-0026 —
seller_refкак ось observation,PreferredSellerPolicy,SellerRatingв tie-break. - Архитектура:
../../20-architecture/integration-patterns.md.