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,ReasonSellerBlockedclassified 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.
Миграция
- Добавить
Sellerentity +SellerRatingVO в модель. - Расширить
offer.observation.v2payload полемseller_ref?. Существующие producers (ETM) пишутnull. - Consumers (Pricing, search-projection, ClickHouse sink) обновляют schema, обрабатывают NULL как non-marketplace.
UNIQUE NULLS NOT DISTINCTдля PG 15+ или синтетическийseller_ref='none'для старых инсталляций.- Через 30 дней — non-nullable в application-level (не в БД), дефолт
'none'для consumer’ов.
Ссылки
- ADR-0013 (suppliers as graph) — parent для Seller.
- ADR-0016 (extensible kind dictionaries) — добавляем
listed_onrelationship 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 задачи. Реализовано:
Selleraggregate в supplier-network BC + PG persistence (migration 0027) + outbox topicsupplier.sellers.v1(Registered/RatingUpdated/StatusChanged).SupplierOfferObservation.SellerRefтипизирован как*supdom.SellerRef(UUID NULL FK, migration 0028); UNIQUE constraint расширен с NULLS NOT DISTINCT.VisibilityRule(P4a) расширен четвёртым predicate axisseller_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команда + событие.PreferredSellerPolicyaggregate в Pricing BC (P5).offer_sellers_viewmaterialized 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_warehousenode. - Auto-detect inactive sellers.