Сценарий: расчёт цены (с custom handlers)
NOTE
Статус: Target design. Документ описывает целевую доменную модель. Соответствующий код реализован частично (см.
backend/internal/core/) или пока не начат. Правила маркировки — в50-processes/documentation-standard.md.
Триггер
- Customer открывает карточку товара / корзину / смету.
- Estimate orchestrator вызывает
Compute(customer, canonical_id, quantity, context). - Search использует pricing для preview (опционально, кэшируется).
Участники
| BC | Роль |
|---|---|
| Pricing | Owner. Selection observation, визибилити, кастомные обработчики, цепочка правил, breakdown. |
| Offers | Источник OfferObservation через offer_current_observation projection. |
| Credentials | Routing observation: own > group > system. |
| Visibility | Filter observations / transform price. |
| Supplier Network | TrustLevel для tie-break + SupplyChainTrace для breakdown. |
| Customer | SessionContext, pricing_group, contract. |
| Custom Pricing Handler (plugin) | Применяет правила, когда API поставщика их не отдаёт. |
| Ingestion | Async refresh job если все observations stale. |
Sequence diagram
sequenceDiagram autonumber participant E as 🟫 Caller (Estimate / Customer) participant P as Pricing participant CR as Credentials participant OFR as Offers (projection) participant V as Visibility participant CPH as Custom Pricing Handler participant ING as Ingestion E->>P: 🟦 Compute(customer, canonical, quantity, context) P-->>P: 🟧 PricingRequested P->>CR: own credential? group? system? CR-->>P: candidate CredentialContexts P->>OFR: read observations for canonical OFR-->>P: candidate observations P->>V: GetCompiledPolicy(subject, target=observation) V-->>P: filter spec P->>P: filter by Visibility + freshness (TTL price=6h, stock=2h) alt все stale / empty P-->>ING: 🟦 EnqueueEnrichmentJob{priority=high, kind=refresh_observation} alt можно отдать stale P-->>P: 🟧 PricingObservationSelected{stale=true} else пусто P-->>E: 🟧 PricingUnavailable end else есть candidates P->>P: sort own/group/system, trust origin/tier_1/tier_2, price, freshness P-->>P: 🟧 PricingObservationSelected end P->>P: 🟦 LoadRules(scope ∋ context) P->>CPH: есть handlers с trigger_condition=true? alt да P->>CPH: invoke handlers (по priority, sandbox 50ms) CPH-->>P: PricingAdjustment[] P-->>P: 🟧 CustomPricingHandlerInvoked alt handler timeout / fail P-->>P: 🟧 CustomPricingHandlerFailed Note over P: fallback без handler, alert end end P->>P: 🟦 ApplyRulesChain (canonical sequence) loop по правилам P-->>P: 🟧 PriceRuleApplied end P->>V: apply transform (если есть transform-policy) V-->>P: transformed P-->>E: 🟧 PricingResolved{breakdown[], observation_source, currency}
Шаги
- Trigger:
Compute(customer_id, canonical_id, quantity, context). - Собираем кандидатные credentials (через
CredentialRouter): own customer + members той же group + system. - Получаем observations из
offer_current_observationprojection (PG materialized) —max(observed_at)per(offer, credential). - Visibility filter: применяем
CompiledPolicy(subject, target=observation).deny→ выкидываем observation.transform(например,hide_warehouse) → меняем поле и продолжаем.
- Freshness filter: отфильтровываем stale (price TTL=6h, stock TTL=2h).
- Все пусто / stale:
- Async ставим
EnrichmentJob{priority=high, kind=refresh_observation}. - Если есть stale, и политика разрешает — берём с пометкой
fallback_reason=stale. - Если нет ничего —
PricingUnavailable+ клиент видит «нужны учётные данные / нет данных».
- Async ставим
- Сортировка кандидатов:
- Источник: own customer credential > group-shared > system.
- Trust level supply chain:
origin > tier_1 > tier_2 > unverified. - Tie-break: лучшая цена для клиента, далее самое свежее.
PricingObservationSelected— single chosen observation.- Load rules (Price Rules): где
RuleScope∋ context (supplier / canonical / classification_tag / customer_pricing_group / customer);RuleAppliesTomatches. - Check Custom Pricing Handlers:
- Loop через
HandlerRegistrationгдеapplies_to∋ context. - Eval
trigger_condition. Если true — invoke handler в sandbox (timeout 50 мс). - Handler возвращает
PricingAdjustment(список доп. правил). - Merge с loaded rules.
- Fail / timeout →
CustomPricingHandlerFailed, fallback без handler, alert.
- Loop через
- Выбор
PriceKind(base price):BasePriceKindPolicylookup по priority:customer → customer_pricing_group → supplier → default.- Default
netс fallbackgross. - Взять
PriceSet[kind]из observation. Если null — fallback по цепочкеnet → gross → list_tarif → retail_recс пометкойfallback_reason=base_price_kind_fallback. - Если все поля
PriceSetnull →PricingUnavailable. - Breakdown первая строка:
{label: "base", kind: <PriceKind>, amount: <price>}.
- Apply rules chain в канонической последовательности внутри priority уровня:
Каждый шаг →1. base_price (PriceSet[base_price_kind] из observation) 2. fixed (overrides) 3. tiers (по quantity) 4. percentage (наценки/скидки) 5. formula 6. logistics 7. vat (пропускается если base_price_kind = gross)PriceRuleAppliedв breakdown. - Apply Visibility transform (если есть transform-policy на target=price).
- Compose
PricingResult:final_price,currency,base_price_kind.observation_source:(type, credential_label, observed_at, fallback_reason, price_set_snapshot)— snapshot всего PriceSet для объяснимости.breakdown[]:(rule_id, label, amount).
PricingResolvedevent (опционально) для analytics.
Custom Pricing Handler — сценарий «договор 8% от 100 шт, ETM не возвращает»
Контекст: customer «Альфа», supplier ETM, quantity = 150.
flowchart LR A["🟧 PricingObservationSelected<br/>(observation от alpha-etm cred)"] B["🟦 LoadRules"] R1["🟨 PriceRule: ETM B2B base markup +3%"] R2["🟨 PriceRule: VAT 20%"] C["🟪 Handler trigger:<br/>customer=alpha AND supplier=etm AND quantity≥100"] H["⚙️ alpha-etm-volume-discount<br/>returns: -8% percentage rule"] D["🟦 ApplyRulesChain"] E["🟧 PricingResolved<br/>breakdown:<br/>+1000 base<br/>+3% B2B markup<br/>-8% Альфа/ETM volume<br/>+20% VAT"] A --> B --> R1 B --> R2 A --> C -->|true| H R1 --> D R2 --> D H --> D D --> E
Decision points
- Customer credential failed / отсутствует → fallback на system credential, breakdown.observation_source.fallback_reason указывается, UX badge.
- Все observations stale → возможны две стратегии: (a) вернуть stale с пометкой; (b) PricingUnavailable. По умолчанию (a) для UX, (b) только при политике строгости.
- Handler stuck (timeout 50 мс) → fallback без handler; circuit breaker после N подряд →
DisableHandler. - Pricing_mode=on_request → не применяем rules; возвращаем флаг + UX «цена по запросу».
- Conflict в rules priority (overlap) → по
priority, далее поidдля детерминизма; при создании — валидатор предупреждает.
Edge cases
| Случай | Поведение |
|---|---|
| Holding (Альфа-Север + Альфа-Юг в одной group) | Pricing для обоих может использовать observations group, но Price Rules могут различаться (разные регионы) → разная финальная цена. |
| Handler делает network call наружу sandbox | Запрещено по умолчанию. Whitelist через registration metadata; нарушение → reject. |
| Observation от system credential для B2B клиента | Допустимо как fallback; UX badge «приближённая цена». |
| Multiple offers одного canonical (от разных поставщиков) | Pricing per-offer (1 observation = 1 price). Estimate optimizer выбирает лучший. |
Логистика зависит от warehouse, который выбран Visibility transform hide_warehouse | Логистика правила пересчитывает на доступном складе. Если ни одного — delivery_unavailable флаг. |
| Customer изменил pricing_group в момент расчёта | Eventual consistency: используется snapshot pricing_group из SessionContext или per-request fetch (политика). Cache invalidation после event. |
Race: OfferObservationRecorded пришло между select и compute | Используется уже выбранное; следующий запрос увидит новое. |
Инварианты сценария
- Privacy: observation customer’а A не используется для customer’а B (если B не в той же group). Проверка на repository уровне.
- Determinism: при тех же
(observation, pricing_context, rules_snapshot, handlers_snapshot, base_price_kind)— идентичныйPricingResult. - Никогда не блокируемся ожиданием поставщика. Stale + async refresh — допустимо.
- Breakdown сохраняется (для estimate audit и replay).
- Handler invoke isolated (sandbox), no creds access.
- Каноническая последовательность типов трансформации соблюдается.
- Cyclic rules — hard error при сохранении.
- No double-vat: если
base_price_kind=gross, ступень VAT пропускается; попытка применить vat при gross-базе — сохранение правила блокируется. price_set_snapshotвobservation_sourceсохраняется целиком (все 4 поля) даже если в расчёт пошло только одно — для аудита и ad-hoc пересчёта с другимPriceKind.
Метрики и observability
pricing_compute_total{result}—resolved/stale/unavailable.pricing_compute_latency_seconds— p95 (целевая < 100 мс с handler).pricing_observation_source{source}— own/group/system fallbacks.pricing_handler_invocations_total{handler_id, status}.pricing_handler_latency_seconds{handler_id}— alert при p95 > 50 мс.pricing_handler_disabled_total{handler_id, reason}— circuit breaker tripped.pricing_fallback_to_system_total{customer_id, reason}— для dashboard.pricing_cache_hit_rate— observability.
Связанные файлы
- Контексты:
../contexts/pricing.md,../contexts/offers.md,../contexts/credentials.md,../contexts/visibility.md,../contexts/supplier-network.md,../contexts/customer.md. - Сценарии:
credential-onboarding.md,estimate-end-to-end.md,update-and-diff.md. - ADR-0011 (no-proxy), ADR-0014 (graceful degradation).