Сценарий: расчёт цены (с 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Роль
PricingOwner. Selection observation, визибилити, кастомные обработчики, цепочка правил, breakdown.
OffersИсточник OfferObservation через offer_current_observation projection.
CredentialsRouting observation: own > group > system.
VisibilityFilter observations / transform price.
Supplier NetworkTrustLevel для tie-break + SupplyChainTrace для breakdown.
CustomerSessionContext, pricing_group, contract.
Custom Pricing Handler (plugin)Применяет правила, когда API поставщика их не отдаёт.
IngestionAsync 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}

Шаги

  1. Trigger: Compute(customer_id, canonical_id, quantity, context).
  2. Собираем кандидатные credentials (через CredentialRouter): own customer + members той же group + system.
  3. Получаем observations из offer_current_observation projection (PG materialized) — max(observed_at) per (offer, credential).
  4. Visibility filter: применяем CompiledPolicy(subject, target=observation).
    • deny → выкидываем observation.
    • transform (например, hide_warehouse) → меняем поле и продолжаем.
  5. Freshness filter: отфильтровываем stale (price TTL=6h, stock TTL=2h).
  6. Все пусто / stale:
    • Async ставим EnrichmentJob{priority=high, kind=refresh_observation}.
    • Если есть stale, и политика разрешает — берём с пометкой fallback_reason=stale.
    • Если нет ничего — PricingUnavailable + клиент видит «нужны учётные данные / нет данных».
  7. Сортировка кандидатов:
    • Источник: own customer credential > group-shared > system.
    • Trust level supply chain: origin > tier_1 > tier_2 > unverified.
    • Tie-break: лучшая цена для клиента, далее самое свежее.
  8. PricingObservationSelected — single chosen observation.
  9. Load rules (Price Rules): где RuleScope ∋ context (supplier / canonical / classification_tag / customer_pricing_group / customer); RuleAppliesTo matches.
  10. 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.
  11. Выбор PriceKind (base price):
    • BasePriceKindPolicy lookup по priority: customer → customer_pricing_group → supplier → default.
    • Default net с fallback gross.
    • Взять PriceSet[kind] из observation. Если null — fallback по цепочке net → gross → list_tarif → retail_rec с пометкой fallback_reason=base_price_kind_fallback.
    • Если все поля PriceSet null → PricingUnavailable.
    • Breakdown первая строка: {label: "base", kind: <PriceKind>, amount: <price>}.
  12. 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.
  13. Apply Visibility transform (если есть transform-policy на target=price).
  14. 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).
  15. PricingResolved event (опционально) для 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Используется уже выбранное; следующий запрос увидит новое.

Инварианты сценария

  1. Privacy: observation customer’а A не используется для customer’а B (если B не в той же group). Проверка на repository уровне.
  2. Determinism: при тех же (observation, pricing_context, rules_snapshot, handlers_snapshot, base_price_kind) — идентичный PricingResult.
  3. Никогда не блокируемся ожиданием поставщика. Stale + async refresh — допустимо.
  4. Breakdown сохраняется (для estimate audit и replay).
  5. Handler invoke isolated (sandbox), no creds access.
  6. Каноническая последовательность типов трансформации соблюдается.
  7. Cyclic rules — hard error при сохранении.
  8. No double-vat: если base_price_kind=gross, ступень VAT пропускается; попытка применить vat при gross-базе — сохранение правила блокируется.
  9. 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.

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