Контекст: поиск — предложения (proposal pipeline)
NOTE
Статус: Skeleton (P1) реализован в
backend/internal/core/search/proposal/. Резолверы stub’овые. Полная реализация правил — P2+. Stack: fx, HTTP handlerPOST /api/v1/search/proposals.
Назначение
Формирование коммерческих предложений (Proposal[]) per customer: из product_ref + qty → кандидатов → их обогащения остатками/ценами/доставкой → ранжирования. Все шаги разведены по слоям; каждый слой — port. Pipeline полностью read-path (ingestion и normalization не затрагиваются).
Разграничение с контекстом “поиск” (catalog)
Существующий search.md описывает каталожный поиск (BM25, аналоги, фасеты). Выход — SearchResult / SearchCandidatesReturned. Новый proposal pipeline работает поверх уже выбранного product_ref — выход Proposal[] с customer-specific price/stock/delivery.
| Use-case | Endpoint | Event | Topic |
|---|---|---|---|
| Catalog search | POST /v1/search | SearchExecuted | search.events.v1 |
| Proposal pipeline | POST /v1/search/proposals | ProposalPipelineExecuted | search.proposal.events.v1 |
Estimate BC вызывает оба: сначала catalog search (если нет явного product_ref), затем proposal pipeline для выбранного product_ref.
Агрегаты / сущности / value objects
Запрос и контекст:
| Имя | Тип | Назначение |
|---|---|---|
ProposalQuery | VO | Запрос: items[], mode, request_policy, target_currency, region. |
QueryItem | VO | Одна позиция: id, product_ref, qty_requested. |
QueryItemID | VO | Стабильный идентификатор item’а в рамках запроса. Сквозной через все слои pipeline. |
ProductRef | VO | Принимает canonical_uuid или viewable_id (TR-XXXXXXX). |
SearchMode | VO enum | live / reference / sourcing / preview. |
CustomerContext | VO | Immutable снимок из JWT: customer_id, tenant_id, credential_group_id. Второй аргумент каждого port method. |
Промежуточные типы между слоями:
| Имя | Тип | Назначение |
|---|---|---|
SupplierBriefing | VO | Выход Layer 1.1: поставщик + QueryItemID + manufacturer_ref + classification_tags + observed_warehouses. |
CredentialRoutingResult | VO | Выход Layer 1.2: status=selected|skipped, Selection (если selected), Reason. |
CredentialSelection | VO | Выбранный credential: credential_ref, scope=customer|system, resolved_at, selection_reason. |
OfferCandidate | VO | Выход Layer 1.3: QueryItemID, offer_ref, canonical_ref, credential_selection, lifecycle_status, matched_observations[]. |
EnrichedOffer | VO | Выход Layer 2: candidate + stock_outcome + price_outcome (supplier ccy) + delivery_outcome. |
Proposal | Aggregate | Финальный выход Layer 3: proposal_id, query_item_id, supplier_ref, price (в target ccy), stock, delivery, score, adjustments[]. |
ProposalPrice | VO | amount, currency, pricing_mode, supplier_base_amount, supplier_base_currency. |
PricingMode | VO enum | fixed / on_request / unavailable. Non-fixed позиции не суммируются в estimate total. |
Политики:
| Имя | Тип | Назначение |
|---|---|---|
SearchPolicy | VO | Контейнер всех sub-policy: Discovery / Stock / Pricing / Delivery / Currency / Proposal / Trace. |
CustomerFixedPolicy | VO | Политика из БД per customer. |
RequestPolicy | VO | Политика из тела запроса. |
EffectivePolicy | VO | RequestPolicy ∩ CustomerFixedPolicy (только ужесточать). Третий аргумент port methods. |
Трассировка:
| Имя | Тип | Назначение |
|---|---|---|
LayerTrace | VO | Одна запись per layer per item: layer, status, reason, is_aggregate. |
ProposalTrace | VO | Агрегат трассировки всего search: entries[], inline_level. |
TraceStatus | VO enum | ok / skipped / rejected / partial. |
TraceReason | VO enum | no_supplier / no_credential / constraint_mismatch / product_discontinued / visibility_blocked / … |
TraceInlineLevel | VO enum | none / summary / full (определяет что попадает в HTTP response). |
Порты
| Порт | Слой | Кардинальность | Назначение |
|---|---|---|---|
SupplierIndexer | 1.1 | fan-out: 1 item → 0..N SupplierBriefing | Список поставщиков для product_ref |
CredentialRouter | 1.2 | 1:1 по briefing | Выбор credential per briefing (precedence customer → system) |
OfferDiscoverer | 1.3 | fan-out: 1 routing-result → 0..M OfferCandidate | Offers по exact credential_ref |
StockResolver | 2a | 1:1 по candidate | Остатки в supplier BC |
PriceResolver | 2b | 1:1 по candidate | Цены в supplier base currency |
DeliveryResolver | 2c | 1:1 по candidate | Условия доставки |
ExchangeRateResolver | 3a | 1:1 по паре валют | Конверсия в target currency |
Все порты принимают (ctx context.Context, c CustomerContext, pol EffectivePolicy, ...) — сигнатура зафиксирована инвариантом ADR-0035 §13.3.
Слои
Девять стадий в fixed порядке (изменение = ADR + все тесты):
Layer 1 — Discovery
1.1 SupplierIndexer.ListSuppliers — fan-out per QueryItem
1.2 CredentialRouter.Select — 1:1 per briefing, precedence customer → system
1.3 OfferDiscoverer.Discover — fan-out per selected routing-result
Layer 2 — Enrichment (fixed order, 1:1 per candidate)
2a StockResolver.Resolve — остатки (exact credential_ref)
2b PriceResolver.Resolve — цены в supplier base currency
2c DeliveryResolver.Resolve — доставка (missing → status=partial)
Layer 3 — Proposal
3a ExchangeRateResolver — конверсия supplier ccy → target ccy
3b Adjustments — VAT, markup из CustomerFixedPolicy
3c Ranking — P1: stable input order; P5: scoring
Diagram → §3.2 design spec.
Инварианты
21 архитектурный инвариант зафиксирован в ADR-0035 docs/docs/20-architecture/adr/0035-proposal-pipeline-layering.md. Ключевые:
- Единственный оркестратор —
core/search/proposalмонополизирует read-path; leaf BC не знают друг о друге (§13.1). - Fixed layer order — нарушение = отдельный ADR (§13.2).
- CustomerContext immutable — второй аргумент каждого port method; no mutable map (§13.3).
- EffectivePolicy — только ужесточение RequestPolicy ∩ CustomerFixedPolicy (§13.4).
- Currency invariant — pricing работает только в supplier base currency; конверсия — эксклюзивно Layer 3a (§13.5).
- No BC-to-BC direct calls — shared types через
backend/internal/shared/(§13.13). - Runtime HMAC leakage check — обязателен; mismatch = HTTP 500 + slog ERROR + counter (§13.14).
- Credential routing —
CredentialRouter.Selectединственное место precedence; после Discovery все чтения — exactcredential_ref(§13.19). - Visibility deny-only в P1 — transforms (
price_to_range,hide_warehouse) — P5 (§13.21).
События
| Событие | Топик | Partition key | Когда |
|---|---|---|---|
ProposalPipelineExecuted | search.proposal.events.v1 | customer_ref | Каждый proposal search без исключений (§13.12, §13.17) |
Payload события — всегда RAW (без редактирования). Inline trace в HTTP response — редактирован согласно TraceInlineLevel и правилам TraceRedactor (§8.2 spec).
Связи в context map
| BC | Паттерн | Назначение |
|---|---|---|
| Offers | OHS (offers экспортирует порты) | SupplierIndexer (Layer 1.1) + OfferDiscoverer (Layer 1.3) |
| Credentials | OHS (credentials экспортирует порт) | CredentialRouter (Layer 1.2) |
| Stock / Pricing / Delivery | OHS (leaf BC экспортируют порты) | Resolvers Layer 2a-c |
| Exchange | OHS (exchange экспортирует порт) | ExchangeRateResolver (Layer 3a) |
| Estimate | OHS (proposal → Estimate) | Estimate вызывает POST /v1/search/proposals как downstream |
| Search (catalog) | Независимый sub-package | search/candidates и search/proposal — два use-case, разные пакеты, разные топики |
Ссылки
- ADR-0035
docs/docs/20-architecture/adr/0035-proposal-pipeline-layering.md— 21 инвариант - Design spec
docs/superpowers/specs/2026-04-23-search-pipeline-skeleton-design.md - Plan
docs/superpowers/plans/2026-04-23-p1-proposal-pipeline-skeleton.md search.md— каталожный поиск (смежный use-case)credentials.md,offers.md,pricing.md,ingestion.md,estimate.md,visibility.md