ADR-0025: Расширения observation — PriceSet, StockForecast, WarehouseKind, rich DeliveryTerm
Status: accepted Date: 2026-04-18 Deciders: команда проекта
Контекст
Исходная модель SupplierOfferObservation спроектирована в предположении:
- одна цена на observation (
Money); - остаток —
qtyнаwarehouse_ref; - сроки — одно число;
- склад — плоский identity без классификации.
Ревизия API первого поставщика (ETM) показала, что эти предположения слишком узки:
-
Price: ETM отдаёт
price(без НДС),pricewnds(с НДС),price_tarif(прайс производителя),price_retail(розница), плюсprice98в Goods endpoint (рекомендованная цена). Четыре-пять значений одновременно для одного observation. -
Stock:
InfoForecast[]—InfoForecastInc(поступление),InfoForecastInWay(в пути),InfoForecastRequest(заявки). Это критическая информация дляEstimateOptimization(mode=min_lead_time)и для Pricingfallback_reason=awaiting_delivery. Сейчас не моделируется. -
Delivery:
InforDeliveryTime—DeliveryTimeInPres(при наличии),DeliveryLabelAtAbs(при отсутствии),DeliveryProductionTerm(срок изготовления),DeliveryIsNeedSpecification(требует спецификации). Одно число — слишком просто. -
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 // склад-транзит
Warehouseentity в 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.v1→offer.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 + DeliveryTermvs прежний плоский observation. Объём в ClickHouseobservation_history_longвырастет x2-x3. - Миграция consumers
offer.observation.v1 → v2— не мгновенная. Consumers, которые читают сырую цену, правим в координации. WarehouseKindenum расширяемый только через 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 потеряют смысл.
Миграция
Фазы:
- Добавить новые VO в доменные модели (без backward break).
- Добавить
offer.observation.v2topic, producers пишут в оба. - Consumers (Pricing, search-projection, CH sink) мигрируют на v2.
- Через 30 дней dual-write прекращается.
Ссылки
- ADR-0003 (supplier offer ES scope) — уточнён perimeter observation.
- ADR-0016 (kind dictionaries) —
WarehouseKindдобавляется как расширяемый через ADR enum. - ADR-0024 (supplier connector contract) — partner ADR.
- ADR-0026 (marketplace observations) — дополняет observation ещё одной размерностью
seller_ref. Итоговый UNIQUE key observation:(offer, credential, seller_ref, observed_at). ../../10-business/contexts/offers.md,../../10-business/contexts/pricing.md,../../10-business/contexts/supplier-network.md.../schemas/events/topics.md.