ADR-0026: Marketplace observations + seller axis

Status: accepted, implemented (P4b 2026-04-26 / P5a 2026-04-27 / P5b 2026-04-27) Date: 2026-04-18 Deciders: команда проекта

Контекст

Анализ второго поставщика (smart-shop.pro) показал, что на одну карточку товара может приходиться несколько конкурирующих предложений от разных юрлиц-продавцов, агрегированных маркетплейсом. Пример: CHINT NXM-125S/3Р 100A 25кА у Smart-shop.pro отдаётся одновременно от:

  • Smart-shop.pro (собственный склад, 12 шт, завтра);
  • Smart-shop.pro (второй пул, 2 349 шт, 4 дня);
  • ООО «Электро-Профи» (рейтинг 4, срок 5-6 дней);
  • ООО «ЭЛЕКТРОЗИП» (рейтинг 4, срок 5-6 дней).

В текущей модели SupplierOfferObservation уникален по (offer, credential, observed_at). Один fetch у агрегатора → несколько observations, у которых (offer, credential) одинаковые. Invariant ломается; ключевая размерность «кто именно предложил цену» потерялась.

Отдельно: нужно ранжировать seller’ов по rating / trust, чтобы Pricing tie-break давал детерминированный результат.

Решение

1. Seller entity в Supplier Network

Новый entity Sellerдочерний узел агрегатора. Живёт только в рамках одного Supplier{kind=aggregator|marketplace}.

Seller
├── id
├── supplier_ref         — parent (aggregator)
├── external_seller_id   — id продавца как его видит агрегатор
├── display_name
├── legal_name?          — если агрегатор отдаёт юрлицо
├── rating: SellerRating?
├── status: active | inactive | banned
└── metadata             — свободное JSONB от connector'а

Отношение seller → supplier_ref(aggregator) — обязательное направленное ребро типа listed_on в графе Supplier Network. listed_on добавляется в стартовый набор supplier_relationship_kind (ADR-0016).

Seller vs Supplier: Seller — lightweight entity внутри Supplier Network, а не полноправный Supplier aggregate. Это нужно, чтобы:

  • не плодить aggregate’ы для каждого seller’а, которых тысячи;
  • не требовать у seller’а credential / session / контракт;
  • сохранить возможность upgrade: если seller оказался «настоящим» дистрибьютором с API — оператор может promote’ить его в Supplier (команда PromoteSellerToSupplier переносит history + замыкает listed_on).

2. SellerRating VO

SellerRating {
  score: float (0..5)
  sample_size: int?
  observed_at: timestamp
  source: marketplace | external_review | moderator
}

Обновляется при каждом scrape/fetch наряду с ценой. Хранится как snapshot в Seller + history — в ClickHouse для аналитики.

3. seller_ref axis на observation

SupplierOfferObservation получает опциональное поле seller_ref:

SupplierOfferObservation {
  offer_ref
  credential_ref
  seller_ref?              // null для не-marketplace offers (ETM)
  prices: PriceSet
  stock_current: StockCurrent
  stock_forecast?: StockForecast
  delivery: DeliveryTerm
  observed_at
  raw_payload_ref
  supply_chain_trace       // snapshot
}

UNIQUE (offer_id, credential_id, seller_ref, observed_at)

Отличие от предыдущей версии:

  • Для non-marketplace supplier’ов (ETM): seller_ref = null, ключ вырождается в (offer, credential, observed_at) — поведение прежнее.
  • Для marketplace: seller_ref заполнен, (offer, credential, seller_ref, observed_at) — один fetch к агрегатору генерирует N observations (по числу активных seller’ов).

observed_at — время fetch’а агрегатора, а не seller’а. Все N observations от одного fetch имеют одинаковый observed_at.

4. Supply chain trace для marketplace observations

SupplyChainTrace.warehouse_chain для marketplace observation включает:

manufacturer_warehouse?  → seller_warehouse? → aggregator_pickup?

seller входит в trace как узел с trust_level=unverified по умолчанию (upgrade через moderation / partner verification). Aggregator остаётся основным узлом с его собственным trust_level.

5. Pricing tie-breaker

Обновлённая последовательность selection observation в Pricing:

1. Кандидаты: own customer credential + group-shared + system.
2. Filter: свежесть, Visibility.
3. Sort:
   a. источник: own > group > system;
   b. trust_level supply chain: origin > tier_1 > tier_2 > unverified;
   c. preferred_seller_policy (если задана для customer/pricing_group);
   d. SellerRating.score (выше = лучше);
   e. лучшая цена по выбранному PriceKind;
   f. самое свежее observed_at.

PreferredSellerPolicy — новый optional aggregate в Pricing BC: (customer \| customer_pricing_group) → preferred_sellers[] + blocked_sellers[].

P5b implementation note (2026-04-27): ADR-0026 §5 описывает Pricing.PriceResolver internal observation selection (Layer 2b — выбор одной observation из множества для одного offer). Layer 3c proposal ordering в search/proposal использует price-primary порядок: bucket → effective price → preferred → rating → observed_at → CandidateID. Justification: customer UX expectation — sorted by price, при равенстве — preferred выигрывает. P5a (2026-04-27) зафиксировал этот pattern с user approval; P5b добавил шаг preferred между price и rating.

Шаги a (credential scope precedence) и b (trust_level supply chain) §5 ordering остаются в scope Pricing internal selection, но trust_level не реализован — supply chain node trust mechanism deferred в Phase 7.

P5b реализовал: filter blocked_sellers (pre-ranking в ProposalRunner.Run, ReasonSellerBlocked classified as leak) и шаг c preferred_seller_policy (comparator step 3 в RankProposals).

6. События

Новые события в Supplier Network (топик supplier.sellers.v1):

  • SellerRegistered — впервые встретился при scrape агрегатора.
  • SellerRatingUpdated — при изменении score / sample_size.
  • SellerStatusChanged — active ↔ inactive ↔ banned.
  • SellerPromotedToSupplier — upgrade до полноценного Supplier.

В топике offer.observation.v2 payload расширяется опциональным seller_ref.

В Offers BC добавляются derived events:

  • OfferSellerAdded / OfferSellerRemoved — для отслеживания, какие seller’ы активны на offer.

7. Capabilities расширение

Capabilities.Marketplace (добавлен к ADR-0024):

Marketplace {
  IsMarketplace          bool   // один offer → N sellers
  SellerExports          bool   // отдаёт ли список seller'ов с rating
  CrossSellerInventory   bool   // агрегирует stock по seller'ам или отдаёт отдельно
}

Для non-marketplace connector’а (ETM): IsMarketplace=false. Core обходит seller_axis.

8. Read-model

Новая projection offer_sellers_view (PG materialized):

(offer_id, seller_ref) → {
  latest_observation_id,
  latest_observed_at,
  current_best_price_net,
  current_stock_sum,
  seller_rating,
  freshness_status
}

Используется admin UI + Pricing pre-select.

Последствия

Плюсы

  • Marketplace-поставщики встраиваются без разрушения single-tenant модели ETM.
  • Seller остаётся lightweight: тысячи seller’ов не становятся тысячами aggregate’ов.
  • Tie-break детерминирован: rating + preferred_seller_policy дают предсказуемую цену.
  • Seller history (rating trends, availability) — аналитика для команды caatalog ops.
  • Upgrade path: лучшие seller’ы могут стать настоящими поставщиками.

Минусы

  • seller_ref опциональный — нужно быть аккуратным в SQL: NULL != NULL в UNIQUE constraint. Решение: синтетическое seller_ref = 'none' literal для non-marketplace, либо PostgreSQL: UNIQUE NULLS NOT DISTINCT (PG 15+).
  • N observations per fetch увеличивает объём offer.observation.v2 и размер ClickHouse history в ~3-10 раз для marketplace.
  • PricingContext растёт: preferred_seller_policy, blocked_sellers. Cache invalidation усложняется.

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

  • Для ETM и подобных поведение не меняется (seller_ref=null / 'none').
  • Seller.external_seller_id — не fingerprint. Смена id агрегатора ломает continuity; если это реально происходит (ребрендинг ООО) — детектируется через legal_name-heuristics + moderation review.

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

A. Виртуальные child-credentials per seller

Каждый seller → синтетическая credential под scope=system, фингерпринт детерминированный. Плюс: нулевые изменения observation schema. Минус: раздувает credential space (1 агрегатор = тысячи credentials), ломает CredentialRouter/CredentialGroup семантику, делает пожар в credential_decrypt_audit.

B. Отдельный BC «Marketplace»

Избыточно. 90% операций совпадает с обычным supplier. Вынос в отдельный BC дублирует Offers / Observations и провоцирует синхронизацию.

C. seller как поле DeliveryTerm

Недостаточно. Нужна separate dimension для tie-break (rating), для policies (preferred/blocked), для istory. Упаковывать в DeliveryTerm — будет boundary violation.

Миграция

  1. Добавить Seller entity + SellerRating VO в модель.
  2. Расширить offer.observation.v2 payload полем seller_ref?. Существующие producers (ETM) пишут null.
  3. Consumers (Pricing, search-projection, ClickHouse sink) обновляют schema, обрабатывают NULL как non-marketplace.
  4. UNIQUE NULLS NOT DISTINCT для PG 15+ или синтетический seller_ref='none' для старых инсталляций.
  5. Через 30 дней — non-nullable в application-level (не в БД), дефолт 'none' для consumer’ов.

Ссылки

  • ADR-0013 (suppliers as graph) — parent для Seller.
  • ADR-0016 (extensible kind dictionaries) — добавляем listed_on relationship kind.
  • ADR-0024 (supplier connector contract) — Marketplace capabilities расширение.
  • ADR-0025 (price & stock observation extensions) — partner: здесь расширяем observation ещё одной размерностью.
  • ADR-0027 (HTML scraper connector pattern) — частый источник marketplace-данных.
  • ../../10-business/contexts/offers.md, ../../10-business/contexts/supplier-network.md, ../../10-business/contexts/pricing.md.

Implementation notes (P4b)

P4b закрывает ADR’a в части filter-only задачи. Реализовано:

  • Seller aggregate в supplier-network BC + PG persistence (migration 0027) + outbox topic supplier.sellers.v1 (Registered/RatingUpdated/StatusChanged).
  • SupplierOfferObservation.SellerRef типизирован как *supdom.SellerRef (UUID NULL FK, migration 0028); UNIQUE constraint расширен с NULLS NOT DISTINCT.
  • VisibilityRule (P4a) расширен четвёртым predicate axis seller_refs[] (migration 0029); pipeline получил Layer 1.3.5 ObservationVisibilityFilter.
  • Capabilities.Marketplace extended (SellerExports, CrossSellerInventory).
  • Admin HTTP read-only + moderator status PATCH.
  • DiscoveryPolicy.SellerBlocklist removed (superseded).

Deferred (subsequent cycles)

  • PromoteSellerToSupplier команда + событие.
  • PreferredSellerPolicy aggregate в Pricing BC (P5).
  • offer_sellers_view materialized projection.
  • ClickHouse rating history sink.
  • Smart-shop.pro marketplace connector (Phase 1-d ingestion).
  • Seller-rating tie-break в Pricing engine (P5).
  • Trust-tier supply chain extension через seller_warehouse node.
  • Auto-detect inactive sellers.