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

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).

Инварианты

  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.