ADR-0025: Расширения observation — PriceSet, StockForecast, WarehouseKind, rich DeliveryTerm

Status: accepted Date: 2026-04-18 Deciders: команда проекта

Контекст

Исходная модель SupplierOfferObservation спроектирована в предположении:

  • одна цена на observation (Money);
  • остаток — qty на warehouse_ref;
  • сроки — одно число;
  • склад — плоский identity без классификации.

Ревизия API первого поставщика (ETM) показала, что эти предположения слишком узки:

  1. Price: ETM отдаёт price (без НДС), pricewnds (с НДС), price_tarif (прайс производителя), price_retail (розница), плюс price98 в Goods endpoint (рекомендованная цена). Четыре-пять значений одновременно для одного observation.

  2. Stock: InfoForecast[]InfoForecastInc (поступление), InfoForecastInWay (в пути), InfoForecastRequest (заявки). Это критическая информация для EstimateOptimization(mode=min_lead_time) и для Pricing fallback_reason=awaiting_delivery. Сейчас не моделируется.

  3. Delivery: InforDeliveryTimeDeliveryTimeInPres (при наличии), DeliveryLabelAtAbs (при отсутствии), DeliveryProductionTerm (срок изготовления), DeliveryIsNeedSpecification (требует спецификации). Одно число — слишком просто.

  4. Warehouse: StoreType (rc/crs/op) + InfoSuppStores (склад производителя). Пять классов складов с разным lead time / trust / доступностью.

Следующие поставщики, скорее всего, принесут ту же структуру (многоцены — b2b-норма, forecast — частая фишка дистрибьюторов, разделение rc/op — типовое).

Решение

1. PriceSet VO заменяет одиночный Money в observation

PriceSet {
  net:         Money?              // без НДС
  gross:       Money?              // с НДС
  list_tarif:  Money?              // прайс / MSRP производителя
  retail_rec:  Money?              // рекомендованная розничная
  vat_rate:    Percent?            // ставка НДС (если известна)
  currency:    ISO4217             // обязательное для PriceSet; один PriceSet = одна валюта
  on_request:  bool                // все поля null ⇒ on_request = true
  validity_window: DateRange?      // для discount / promo цен (Systeme Electric pattern): valid_from/valid_to
  tags: [string]?                  // free-form теги (например, "promo", "eol_discount")
}

DateRange {
  from: timestamp?
  to:   timestamp?                  // null on either = open-ended
}

Мульти-валютные observation (ADR дополнение 2026-04-18, DKC pattern — RUB + KZT параллельно):

  • Observation хранит prices: map<ISO4217, PriceSet> — словарь валюта → PriceSet.
  • Для поставщиков с одной валютой: prices = {"RUB": <PriceSet>} — тривиально.
  • Для multi-currency (DKC): prices = {"RUB": {...}, "KZT": {...}}.
  • Pricing выбирает PriceSet по PricingContext.requested_currency (default: customer billing currency).
  • Отсутствие валюты в observation при её запросе → PricingUnavailable(reason=currency_unavailable) + async trigger на refetch в нужной валюте, если connector это умеет.

Validity window:

  • Observation с validity_window.from > now()future-scheduled price, не применяется для current pricing, но хранится.
  • Observation с validity_window.to < now() — expired, Pricing игнорирует в selection (трактуется как stale независимо от freshness TTL).
  • Pricing при одинаковом (offer, credential, seller_ref) выбирает observation с активным validity_window, если есть; иначе latest без window.

Invariants:

  • Если хотя бы одно ценовое поле ≠ null, on_request запрещён. Ноль цены и null — разные.
  • validity_window.from < validity_window.to, если оба заданы.
  • currency в PriceSet consistent с net.currency / gross.currency / ... (Money имеет свою валюту).

2. StockForecast VO расширяет observation

StockForecast {
  incoming: [
    { warehouse_ref, eta: date, qty, source: string }
  ]
  in_way: [
    { warehouse_ref, departed_at, eta: date, qty }
  ]
  backorders: [
    { warehouse_ref, requested_qty, state }
  ]
}
  • Observation может содержать stock: StockCurrent + forecast: StockForecast одновременно.
  • Derived events: OfferForecastUpdated публикуется при diff к предыдущему forecast. Не источник истины (как OfferPriceChanged).
  • Search BetterDirection=lead_time и Estimate optimizer используют forecast для расчёта effective_lead_time.

3. DeliveryTerm VO расширен

DeliveryTerm {
  lead_time_when_in_stock_days:  int?   // "при наличии"
  lead_time_when_out_of_stock_days: int? // "при отсутствии на складе" (совокупный)
  production_term_days:          int?   // срок изготовления поставщиком
  requires_specification:        bool   // товар изготавливается под спецификацию
  source_warehouse_ref:          WarehouseRef?
  notes:                         text?  // free-form от поставщика
}

4. WarehouseKind VO enum

WarehouseKind enum:
  regional_center       // rc / РЦ — распределительный центр дистрибьютора
  logistics_center      // crs / ЛЦ — логистический hub
  pickup_office         // op / офис продаж
  manufacturer_warehouse // склад производителя
  third_party           // 3PL warehouse
  transit               // склад-транзит
  • Warehouse entity в Supplier Network получает обязательное поле kind: WarehouseKind.
  • manufacturer_warehouse может существовать без прямой declared связи warehouse_operator — auto-создаётся как observed узел с trust_level = origin, если connector встретил его в payload (InfoSuppStores у ETM).
  • SupplyChainTrace включает warehouse_chain — последовательность kind’ов, которые увидит заказ (например, manufacturer_warehouse → logistics_center → pickup_office).

5. Событие и Kafka-схема

  • Kafka topic offer.observation.v1offer.observation.v2. Новый payload содержит PriceSet, StockForecast, расширенный DeliveryTerm. Producers пишут в оба до миграции consumers (policy ADR-0003).
  • offer.price_changed.v1 → deprecated в пользу offer.price_set_changed.v1 (diff по любому полю PriceSet публикуется в единый топик с маской «что изменилось»).
  • offer.forecast_changed.v1 — новый топик (см. topics.md update).

6. Media rights на observation

Observation не содержит media (media — поле SupplierOffer canvas). Но PricingObservationSelected для UI может подтянуть media через canvas.gdsImages с учётом watermarked флага из CredentialFeatures (см. ADR-0024).

Последствия

Плюсы

  • Pricing получает выбор базовой цены (net vs gross vs retail) без поиска по правилам — сразу из observation.
  • Estimate-оптимизатор min_lead_time становится реально рабочим (есть forecast).
  • Классификация складов даёт понятные predicates в Visibility и правилам Matching tie-break.
  • Ни один поставщик больше не «ломает» observation schema — схема достаточно богата, чтобы вместить.

Минусы

  • Наблюдение становится тяжелее: PriceSet + StockForecast + DeliveryTerm vs прежний плоский observation. Объём в ClickHouse observation_history_long вырастет x2-x3.
  • Миграция consumers offer.observation.v1 → v2 — не мгновенная. Consumers, которые читают сырую цену, правим в координации.
  • WarehouseKind enum расширяемый только через ADR (как TrustLevel — критическая семантика).

Нейтральные последствия

  • PricingContext.base_price_kind — новое обязательное поле с дефолтом net. Старые вызовы работают.
  • SupplyChainTrace.warehouse_chain — новое, у старых trace’ов пустое, computed at recompute.

Рассмотренные альтернативы

A. Держать одну цену, мульти-цены моделировать через правила

Cначала выглядело приемлемо (PriceRule с source=supplier_retail), но Pricing Engine при этом должен был бы знать, какое именно поле observation’а считать базовой ценой. Это inversion: сейчас connector решает, что «цена клиента = gross», а потом правила рисуют наценки. При варианте A connector должен был бы сам выбирать — ломает capabilities matrix (одна credential — один customer’s pricing context).

B. Хранить forecast отдельным потоком observations

Создаёт split-brain: forecast живёт рядом с current, но атомарно отделён. Pricing должен был бы джойнить два append-only потока по (offer, credential, observed_at). Слишком дорого на чтении.

C. StoreType как свободная string

Связывает Tracium с конкретным поставщиком. Следующий поставщик принесёт своё deutero-слово, и Visibility-predicates по warehouse_kind потеряют смысл.

Миграция

Фазы:

  1. Добавить новые VO в доменные модели (без backward break).
  2. Добавить offer.observation.v2 topic, producers пишут в оба.
  3. Consumers (Pricing, search-projection, CH sink) мигрируют на v2.
  4. Через 30 дней dual-write прекращается.

Ссылки