Контекст: предложения поставщиков
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. |
MultiCurrencyPrices | VO | map<ISO4217, PriceSet>. Один ключ валюты → один PriceSet. Для single-currency поставщиков: {"RUB": <PriceSet>}; для multi-currency (DKC RUB+KZT): несколько ключей параллельно. |
SellerRef | VO | Ссылка на Seller entity в Supplier Network. Опциональная: null для не-marketplace supplier’ов (ETM). |
SkuIdentityPack | VO | (supplier_sku, manufacturer_article?, manufacturer_code?, client_sku?) — полный набор кодов товара. Connector обязан заполнять по ADR-0024. |
OfferCharacteristicSnapshot | VO | Срез characteristics_from_supplier — в исходной таксономии поставщика (supplier_char_code). |
Packaging | VO | Упаковка / единица отгрузки. |
MediaAsset | VO | (kind, url_small?, url_large?, mime, watermarked, rights_source_credential_ref?). Kind: image/video/certificate/datasheet. |
PriceSet | VO | (net?, gross?, list_tarif?, retail_rec?, vat_rate?, currency, on_request, validity_window?, tags?). ADR-0025. validity_window: DateRange? — для discount-периодов (Systeme Electric). |
DateRange | VO | (from: timestamp?, to: timestamp?). Open-ended с любой стороны. |
StockCurrent | VO | (warehouses: [WarehouseStock], observed_at). |
WarehouseStock | VO | (warehouse_ref, warehouse_kind, qty, available_at?). |
StockForecast | VO | (incoming[], in_way[], backorders[]) — прогноз поставок. |
DeliveryTerm | VO | (lead_time_when_in_stock_days?, lead_time_when_out_of_stock_days?, production_term_days?, requires_specification, source_warehouse_ref?, notes?). |
PricingMode | VO enum | fixed / on_request (синоним PriceSet.on_request=true). |
MatchConfidence | VO enum | exact / strong / probable / weak / unmatched. |
RawPayloadRef | VO | Указатель в S3. |
ObservationFreshness | VO | TTL: цены 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) | Ingestion | SupplierOffer (create/update) | SupplierOfferSeen или OfferCharacteristicsUpdated |
ЗаписатьНаблюдение (RecordObservation) | Ingestion | SupplierOfferObservation | OfferObservationRecorded |
ОбновитьСтатусЖизненногоЦикла (UpdateOfferLifecycle) | Parser / supplier feed | SupplierOffer | OfferLifecycleStatusChanged |
ПрикрепитьКанонический (AttachCanonical) | Matching | SupplierOffer | MatchDecided (источник — 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).
Инварианты
(supplier_ref, supplier_sku)уникальна.match_confidence = unmatched⇔canonical_product_refis null.SupplierOfferObservationappend-only: existing observation never updated, never deleted (только архивация).- Текущее 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 declaredCapabilities.Marketplace.IsMarketplace=true, observation обязана содержатьseller_ref. Если false —seller_refзапрещён. 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.source_credential_refs[]— append-only, только добавление.- Canvas (характеристики, media, identity_pack, packaging) изменяется через canvas-events; цены/остатки/forecast/срок — никогда не часть canvas, только в
SupplierOfferObservation. supply_chain_trace— value object, пересчитывается отдельно (см. Supplier Network), не часть transactionSupplierOffer.MediaAsset.watermarked = falseдопустим только еслиrights_source_credential_refуказывает на credential с featuremedia_unwatermarked(ADR-0024).StockForecast.in_way[].eta>departed_at.StockForecast.incoming[]всегда относится к конкретномуwarehouse_refиз графа Supplier Network.DeliveryTerm.requires_specification = true⇒production_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.v2—OfferObservationRecordedсPriceSet + StockCurrent + StockForecast + DeliveryTerm(основной поток).v1deprecated, dual-write 30 дней (ADR-0025).offers.derived.v1—OfferPriceSetChanged,OfferStockChanged,OfferForecastUpdated,OfferDeliveryTermChanged(для интеграций; не источник истины). Каждое derived-событие несёт маску{changed_fields: [...]}.
Подписанные интеграционные события
| Источник | Событие | Реакция |
|---|---|---|
| Ingestion | RawPayloadStored | Парсинг → команда ОбработатьСырыеДанные |
| Matching | MatchDecided | Update canonical_product_ref, match_confidence |
| Supplier Network | SupplyChainTraceRecomputed | Update supply_chain_trace snapshot |
Связи в context map
| BC | Паттерн | Назначение |
|---|---|---|
| Ingestion | PL (Ingestion → Offers) | RawPayloadStored — основной вход |
| Matching | ACL (Offers → Matching → Catalog) | Matching — единственный путь связи offer ↔ canonical |
| Pricing | Customer/Supplier (Offers — supplier) | Pricing читает observations через read-model |
| Supplier Network | SK (SupplyChainTrace VO) | Snapshot trace вкладывается в SupplierOffer |
| Catalog | Customer/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,Sellerentity). - ADR-0027 — HTML scraper connector pattern.
- Сценарий:
../scenarios/update-and-diff.md.