Контекст: предложения поставщиков

NOTE

Статус: Target design. Документ описывает целевую доменную модель. Соответствующий код реализован частично (см. backend/internal/core/) или пока не начат. Правила маркировки — в 50-processes/documentation-standard.md.

Назначение

Для proposal pipeline (search-proposal.md) offers BC экспортирует SupplierIndexer (Layer 1.1) + OfferDiscoverer (Layer 1.3). Оба фильтруют по точному credential_ref из CredentialRoutingResult без precedence-fallback (инвариант ADR-0035 §13.19).

Хранит товарные канвы поставщиков (SupplierOffer) и наблюдения цены/остатков/условий (SupplierOfferObservation, append-only). Этот BC — «грязный» край системы: данные могут быть противоречивыми, неполными, требовать матчинга. Catalog защищён от него через ACL Matching.

Главный смысл

SupplierOffer — это «как поставщик описывает товар», а не «что это за товар». Финальный смысл присваивает Matching, прикрепляя canonical_product_ref.

Цены / остатки не хранятся в SupplierOffer — только в SupplierOfferObservation, привязанной к credential. Это ключевая инверсия: одна канва — много наблюдений.

Агрегаты / сущности / value objects

ИмяТипНазначение
SupplierOffer🟨 AggregateТоварная канва. Корень: (supplier_ref, supplier_sku) — уникален. Содержит identity_pack: SkuIdentityPack, media: [MediaAsset].
SupplierOfferObservation🟨 Aggregate (append-only)Наблюдение по (offer, credential, seller_ref?). Содержит prices: MultiCurrencyPrices (map<ISO4217, PriceSet>), stock_current: StockCurrent, stock_forecast: StockForecast?, delivery: DeliveryTerm, seller_ref? (для marketplace). Никогда не изменяется. ADR-0025 (extended), ADR-0026.
MultiCurrencyPricesVOmap<ISO4217, PriceSet>. Один ключ валюты → один PriceSet. Для single-currency поставщиков: {"RUB": <PriceSet>}; для multi-currency (DKC RUB+KZT): несколько ключей параллельно.
SellerRefVOСсылка на Seller entity в Supplier Network. Опциональная: null для не-marketplace supplier’ов (ETM).
SkuIdentityPackVO(supplier_sku, manufacturer_article?, manufacturer_code?, client_sku?) — полный набор кодов товара. Connector обязан заполнять по ADR-0024.
OfferCharacteristicSnapshotVOСрез characteristics_from_supplier — в исходной таксономии поставщика (supplier_char_code).
PackagingVOУпаковка / единица отгрузки.
MediaAssetVO(kind, url_small?, url_large?, mime, watermarked, rights_source_credential_ref?). Kind: image/video/certificate/datasheet.
PriceSetVO(net?, gross?, list_tarif?, retail_rec?, vat_rate?, currency, on_request, validity_window?, tags?). ADR-0025. validity_window: DateRange? — для discount-периодов (Systeme Electric).
DateRangeVO(from: timestamp?, to: timestamp?). Open-ended с любой стороны.
StockCurrentVO(warehouses: [WarehouseStock], observed_at).
WarehouseStockVO(warehouse_ref, warehouse_kind, qty, available_at?).
StockForecastVO(incoming[], in_way[], backorders[]) — прогноз поставок.
DeliveryTermVO(lead_time_when_in_stock_days?, lead_time_when_out_of_stock_days?, production_term_days?, requires_specification, source_warehouse_ref?, notes?).
PricingModeVO enumfixed / on_request (синоним PriceSet.on_request=true).
MatchConfidenceVO enumexact / strong / probable / weak / unmatched.
RawPayloadRefVOУказатель в S3.
ObservationFreshnessVOTTL: цены 6ч, остатки 2ч, forecast 12ч (default, см. Pricing).

Доменные события

СобытиеПричина
ТоварнаяКанваОбнаружена (SupplierOfferSeen)Первое появление (supplier_ref, supplier_sku)
ТоварнаяКанваОбновлена (OfferCharacteristicsUpdated)Изменение characteristics / packaging в товарной канве
МедиаОбновлены (OfferMediaUpdated)Изменения media[] / certificates[] (images, videos, datasheets)
СтатусЖизненногоЦиклаОбновлён (OfferLifecycleStatusChanged)active ↔ discontinued ↔ …
ИсточникУчтён (OfferSourceCredentialAdded)Новая credential увидела offer (source_credential_refs[]) + её client_sku (если был)
НаблюдениеЗаписано (OfferObservationRecorded)append-only — основной поток (v2: несёт PriceSet + StockCurrent + StockForecast + DeliveryTerm + seller_ref?)
ПродавецДобавлен (OfferSellerAdded)На marketplace-оффере впервые увиден новый seller
ПродавецУдалён (OfferSellerRemoved)Seller пропал из payload за N последних fetch’ей подряд
ИзменениеЦеныОбнаружено (OfferPriceSetChanged)Derived: diff по любому полю PriceSet vs предыдущее observation. Маска {net?, gross?, list_tarif?, retail_rec?} указывает, что именно изменилось.
ИзменениеОстатковОбнаружено (OfferStockChanged)Derived: diff по StockCurrent
ИзменениеПрогнозаОбнаружено (OfferForecastUpdated)Derived: diff по StockForecast (новое поступление / сдвиг eta / pop backorder)
УсловияДоставкиОбновлены (OfferDeliveryTermChanged)Derived: diff по DeliveryTerm
ОфферПомеченOnRequest (OfferMarkedOnRequest)PriceSet.on_request=true

OfferPriceSetChanged / OfferStockChanged / OfferForecastUpdated / OfferDeliveryTermChanged — derived, не источник истины. Источник — OfferObservationRecorded (см. ADR-0003).

Команды

КомандаАкторЦелевой агрегатРезультат
ОбработатьСырыеДанные (IngestRawPayload)IngestionSupplierOffer (create/update)SupplierOfferSeen или OfferCharacteristicsUpdated
ЗаписатьНаблюдение (RecordObservation)IngestionSupplierOfferObservationOfferObservationRecorded
ОбновитьСтатусЖизненногоЦикла (UpdateOfferLifecycle)Parser / supplier feedSupplierOfferOfferLifecycleStatusChanged
ПрикрепитьКанонический (AttachCanonical)MatchingSupplierOfferMatchDecided (источник — Matching, эффект на Offer)

Политики

ТриггерРеакция
RawPayloadStored (Ingestion)→ команда ОбработатьСырыеДанные
OfferObservationRecorded + diff к предыдущему→ derived events OfferPriceChanged/OfferStockChanged
OfferCharacteristicsUpdated→ команда RematchRequested (Matching)
SupplierOfferSeen→ команда MatchAttemptRequested (Matching)
OfferLifecycleStatusChanged=discontinued + был последний active для canonical→ команда ChangeLifecycleStatus(deprecated) (Catalog)

Read-модели

  • 🟩 offer_current_observation (PG materialized) — max(observed_at) per (offer_id, credential_id, seller_ref). Используется Pricing.
  • 🟩 offer_sellers_view (PG materialized) — (offer_id, seller_ref) → {latest_observation_id, best_price_net, stock_sum, seller_rating, freshness_status}. Для admin UI и Pricing pre-select.
  • 🟩 offer_index (Elasticsearch) — для админского поиска по supplier_sku / characteristics.
  • 🟩 observation_history_long (ClickHouse, append-only) — для аналитики цен / остатков. Partition по (supplier_id, month).

Admin UI display semantics

Admin UI treats observation time as source data, not as a verdict. Offer rows show price and stock update age separately (цена … · остаток …), so the operator decides whether the data is fresh enough for the current task. Availability status stays in the stock column: current quantity, нет остатка, под заказ, остаток не вернулся, остаток не отдаёт, or the supplier’s own availability text. Price deviation is displayed next to the price value because it describes that price, not the supplier row as a whole.

Инварианты

  1. (supplier_ref, supplier_sku) уникальна.
  2. match_confidence = unmatchedcanonical_product_ref is null.
  3. SupplierOfferObservation append-only: existing observation never updated, never deleted (только архивация).
  4. Текущее observation = max(observed_at) per (offer, credential, seller_ref). Для не-marketplace supplier’ов seller_ref = sentinel 'none' (или NULL с UNIQUE NULLS NOT DISTINCT в PG 15+). 4a. Marketplace invariant: если connector declared Capabilities.Marketplace.IsMarketplace=true, observation обязана содержать seller_ref. Если false — seller_ref запрещён.
  5. PriceSet.on_request = trueвсе ценовые поля (net / gross / list_tarif / retail_rec) = null. Ноль ≠ null; 0 означает «бесплатно», null означает «не известно». 5a. MultiCurrencyPrices не может быть пустым (хотя бы одна валюта обязательна). Каждый PriceSet в map’е обязан иметь currency, консистентный с ключом map’а. 5b. PriceSet.validity_window.from < validity_window.to, если оба заданы. Expired observations (to < now()) остаются в истории, но игнорируются Pricing.
  6. source_credential_refs[] — append-only, только добавление.
  7. Canvas (характеристики, media, identity_pack, packaging) изменяется через canvas-events; цены/остатки/forecast/срок — никогда не часть canvas, только в SupplierOfferObservation.
  8. supply_chain_trace — value object, пересчитывается отдельно (см. Supplier Network), не часть transaction SupplierOffer.
  9. MediaAsset.watermarked = false допустим только если rights_source_credential_ref указывает на credential с feature media_unwatermarked (ADR-0024).
  10. StockForecast.in_way[].eta > departed_at. StockForecast.incoming[] всегда относится к конкретному warehouse_ref из графа Supplier Network.
  11. DeliveryTerm.requires_specification = trueproduction_term_days обязателен.

Интеграционные события (публикуем)

Топик: offers.events.v1. Partition key: supplier_offer_id (для канвы) или (supplier_offer_id, credential_id) (для observations).

Подтопики:

  • offers.canvas.v1 — события канвы (SupplierOfferSeen, OfferCharacteristicsUpdated, OfferMediaUpdated, OfferLifecycleStatusChanged, OfferSourceCredentialAdded).
  • offer.observation.v2OfferObservationRecorded с PriceSet + StockCurrent + StockForecast + DeliveryTerm (основной поток). v1 deprecated, dual-write 30 дней (ADR-0025).
  • offers.derived.v1OfferPriceSetChanged, OfferStockChanged, OfferForecastUpdated, OfferDeliveryTermChanged (для интеграций; не источник истины). Каждое derived-событие несёт маску {changed_fields: [...]}.

Подписанные интеграционные события

ИсточникСобытиеРеакция
IngestionRawPayloadStoredПарсинг → команда ОбработатьСырыеДанные
MatchingMatchDecidedUpdate canonical_product_ref, match_confidence
Supplier NetworkSupplyChainTraceRecomputedUpdate supply_chain_trace snapshot

Связи в context map

BCПаттернНазначение
IngestionPL (Ingestion → Offers)RawPayloadStored — основной вход
MatchingACL (Offers → Matching → Catalog)Matching — единственный путь связи offer ↔ canonical
PricingCustomer/Supplier (Offers — supplier)Pricing читает observations через read-model
Supplier NetworkSK (SupplyChainTrace VO)Snapshot trace вкладывается в SupplierOffer
CatalogCustomer/Supplier (Catalog — supplier)Catalog диктует canonical_id; Offers ссылается

Мини event storming

flowchart LR
    subgraph ING["Ingestion"]
        I1["🟧 RawPayloadStored"]
    end
    subgraph OFF["Offers"]
        CMD1["🟦 IngestRawPayload"]
        SO["🟨 SupplierOffer"]
        E1["🟧 SupplierOfferSeen<br/>(или OfferCharacteristicsUpdated)"]
        CMD2["🟦 RecordObservation"]
        SOB["🟨 SupplierOfferObservation<br/>(append-only)"]
        E2["🟧 OfferObservationRecorded"]
        D1["🟧 OfferPriceChanged<br/>(derived)"]
        D2["🟧 OfferStockChanged<br/>(derived)"]
        RM["🟩 offer_current_observation"]
    end
    subgraph M["Matching"]
        SUB["🟪 Subscribe SupplierOfferSeen"]
    end
    subgraph P["Pricing"]
        SUBP["🟪 Read offer_current_observation"]
    end

    I1 --> CMD1 --> SO --> E1
    I1 --> CMD2 --> SOB --> E2
    E2 --> D1
    E2 --> D2
    E2 --> RM
    E1 -.PL.-> SUB
    RM -.read.-> SUBP

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

  • ingestion.md, matching.md, pricing.md, supplier-network.md.
  • Архитектура: ../../20-architecture/event-sourcing.md (ES для канвы; observation append-only).
  • ADR-0003 — Supplier Offer ES scope.
  • ADR-0017 — event-driven supply chain recalc.
  • ADR-0024 — supplier connector contract (identity pack, media rights, error taxonomy, TransportKind, Marketplace capabilities).
  • ADR-0025 — price & stock observation extensions (PriceSet, StockForecast, DeliveryTerm, WarehouseKind).
  • ADR-0026 — marketplace observations + seller axis (seller_ref на observation, Seller entity).
  • ADR-0027 — HTML scraper connector pattern.
  • Сценарий: ../scenarios/update-and-diff.md.