Контекст: предложения поставщиков
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).
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.
Инварианты
(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.