# Tracium Public API — target contract (contract-first, OpenAPI 3.1).
#
# Этот файл описывает целевое состояние клиентского API после реализации.
# Он не отражает то, что уже работает в `backend/` — это спецификация, по
# которой будут писаться хэндлеры.
#
# Источник истины: этот YAML. После первой генерации кода из контракта
# (см. ADR-0023) допустимая direction правки — всё ещё этот файл; код
# регенерируется. Любые расхождения в CI считаются ошибкой.
#
# Auth: public `/v1/*` использует API tokens (`Authorization: Bearer trk_*`),
# не пользовательский JWT. Nginx валидирует opaque token через
# `auth_request /_internal/auth/api-key/verify`, кэширует результат и
# прокидывает в api-server только trusted X-Tr-* headers. JWT-cookie flow
# остаётся для cabinet/admin UI. Решение: ADR-0051.
#
# Path convention: servers below already include `/v1`, so path keys in this
# file MUST omit `/v1`. Example: path key `/products` documents external
# URL `https://api.tracium.ru/v1/products`. Do not add `/v1/products` or
# `/v1/whoami` keys here.

openapi: 3.1.0
info:
  title: Tracium Public API
  version: 1.0.0-target
  summary: Внешний API Tracium для B2B/B2C клиентов
  description: |
    Tracium Public API даёт внешнему клиенту единый вход в каталог товаров,
    цены, наличие у поставщиков, расчёт смет и сопутствующую тарификацию.
    Все ответы предварительно отфильтрованы под клиента (см. **Visibility**
    ниже) — клиент не видит ни endpoint'ы, ни поля, скрытые политикой.

    ## Сценарии работы

    Здесь — карта «у тебя есть X → позови Y». Каждый случай раскрыт в
    cookbook'е (`30-services/public-api/cookbook.md`).

    | Что у тебя на руках | Какой endpoint | Что получишь |
    |---|---|---|
    | Supplier SKU / внешний артикул поставщика | `POST /products/resolve` | Canonical product refs для следующего шага сметы |
    | Список Tracium product refs (`TR-XXXXXXX` / canonical UUID) и количеств | `POST /search/proposals` | Ранжированные предложения по каждой позиции: цена, наличие, доставка, поставщик, обоснование |
    | Нужно пролистать доступные карточки каталога | `GET /products` | Страница canonical-карточек с текущим read-side срезом цены, остатков и поставщиков |
    | Знаешь canonical_id или viewable_id (`TR-XXXXXXX`) | `GET /products/{id_or_viewable}` | Карточка товара с описанием и характеристиками |
    | Нужны все поставщики данного товара | `GET /products/{id_or_viewable}` | В live API смотри `supplier_offers[]`; отдельный `/offers` surface пока target |
    | Нужны детали одного оффера | `GET /offers/{id}` | Target: карточка конкретного оффера |
    | Нужна история цен/остатков для аудита | `GET /offers/{id}/observations` | Target: append-only лента наблюдений за период |
    | Хочу пересчитать цену по правилам клиента | `POST /pricing` | Target: финальная цена по одному или batch-набору позиций |
    | Нужно подобрать аналоги по неточным характеристикам | `POST /products/analog-search` | Target: кандидаты-аналоги с confidence и объяснением |

    Главное правило: для **сметы** сначала приводим пользовательские входные
    строки к canonical product refs через `POST /products/resolve`, затем
    отправляем refs + количества в `POST /search/proposals`. Proposal endpoint
    оркестрирует Discovery → Enrichment → Ranking и возвращает коммерческое
    предложение. Endpoint'ы `/products`, `/offers`, `/pricing` — это
    низкоуровневые «чтение каталога / расчёт».

    Для прикладного сценария сметчика есть один шаг выше:
    `POST /estimates/proposals`. Он принимает нормализованные строки сметы,
    сам выполняет deterministic resolve, затем proposal pipeline. Если клиент
    явно включает `strategy.resolve_mode: llm_assisted`, unresolved строки с
    найденными кандидатами дополнительно проверяются через LLM gateway
    (CLIProxy, OpenAI-compatible `/v1/chat/completions`). LLM не может
    изобретать новый товар: он выбирает только один из уже найденных
    candidates, поэтому режим остаётся контролируемым и обратимым.

    В live-версии `POST /products/resolve` умеет точный mapping
    `supplier + supplier_sku`. Вход по характеристикам уже заложен в контракт,
    но пока возвращает `unsupported_query`; fuzzy analog matching вынесен в
    отдельный target endpoint `POST /products/analog-search`.

    ## Аутентификация (ADR-0051)

    Внешний клиент авторизуется opaque API token'ом
    (`Authorization: Bearer trk_*`). Токен валидируется на nginx через
    `auth_request`, в backend приходят только trusted `X-Tr-*` headers.
    JWT-cookie flow зарезервирован за cabinet/admin UI и не является
    дефолтным механизмом публичного API.

    ## Три протокола (ADR-0015)

    REST (этот файл), gRPC (`grpc/public_api.proto`) и WebSocket
    (`asyncapi/public-ws.yaml`) описывают одно и то же подмножество
    application use case'ов. Контракт — этот OpenAPI; gRPC и WS
    рассматриваются как альтернативные транспорты.

    ## Семантика свежести (ADR-0014, no-proxy)

    Все GET-endpoint'ы читают кэш и всегда отвечают быстро — даже когда
    данные устарели или частично отсутствуют. Если данные устарели,
    ответ содержит `freshness.status=stale` и опционально
    `freshness.fallback_reason`, а система автоматически ставит
    EnrichmentJob. Клиент может вручную дёрнуть `POST /enrichment-jobs`
    и подписаться на прогресс через WS-канал.

    ## Visibility (ADR-0012)

    Все ответы уже отфильтрованы data-visibility policy с учётом Subject
    клиента (customer / group / pricing_group / role). Endpoint'ы и поля,
    скрытые policy, не видны клиенту никогда.

    ## Идентификаторы

    UUIDv7, если не указано иное. Для canonical product дополнительно
    есть человекочитаемый `viewable_id` формата `TR-XXXXXXX`. Любой
    endpoint вида `/products/{id_or_viewable}` принимает оба формата.

    **Тарифы и лимиты (plan + packages):** каждый клиент привязан к
    тарифу (`/plans`) и опционально докупает prepaid-пакеты
    (`/me/packages`). Каждый response несёт заголовки использования:

    - `X-RateLimit-Limit-<window>` / `X-RateLimit-Remaining-<window>` /
      `X-RateLimit-Reset-<window>` для окон `minute`, `hour`, `day`, `week`.
    - `X-Quota-Requests-Limit` / `X-Quota-Requests-Remaining` /
      `X-Quota-Requests-Reset` для billing-периода (обычно месяц).
    - `X-Quota-Data-Bytes-*` — то же для объёма выдаваемых данных.
    - `X-Usage-Packages-Requests-Remaining` — остаток в prepaid-пакетах.
    - `X-Plan-Code` — код активного плана (`free`, `starter`, `pro`, ...).
    - `X-Request-Weight` — сколько «единиц» списано с квоты за запрос
      (тяжёлые endpoints вроде `/estimates/{id}/optimize` стоят >1).

    **Превышение:** при исчерпании короткого окна возвращается `429` c
    `code=rate_limited` и `Retry-After`. При исчерпании billing-квоты —
    `429` c `code=quota_exhausted` (reset на следующем billing-периоде или
    после покупки пакета). При запросе endpoint'а, недоступного в плане, —
    `402` c `code=plan_required`.

    **Организации (B2B):** клиент может быть либо standalone (B2C,
    personal plan), либо входить в организацию (legal_entity, shared
    plan). У организации — свой тариф уровня `scope=organization` с
    пулом лимитов на всех участников. Admin/owner организации выдаёт
    каждому участнику персональный cap (`personal_limits`) — это верхняя
    граница для одного участника внутри организационного пула. Если
    клиент входит сразу в несколько организаций, он выбирает активный
    контекст заголовком `X-Organization-Id` при запросе.

    Эффективный лимит запроса = `min(org_pool_remaining,
    member_cap_remaining, personal_plan_remaining)` — срабатывает тот,
    что исчерпался первым.

    Дополнительные заголовки в org-режиме:

    - `X-Organization-Id` — активный org context (echo).
    - `X-Quota-Org-Requests-{Limit,Remaining,Reset}` — пул организации.
    - `X-Member-Cap-Requests-{Limit,Remaining,Reset}` — личный cap в
      рамках org pool.

    **Биллинг:** у организации один consolidated invoice, перекрывающий
    всех членов. Personal плана B2C и org-контекст — взаимоисключающие
    (для любого запроса действует ровно один счётчик подписки).

    См.: `30-services/public-api/README.md`, ADR-0014, ADR-0015, ADR-0018,
    ADR-0051.
  contact:
    name: Tracium API Team
    url: https://docs.tracium.ru
servers:
  - url: https://api.tracium.ru/v1
    description: Production
  - url: https://api.staging.tracium.ru/v1
    description: Staging
  - url: https://api.tracium.dev/v1
    description: Локальная разработка (через nginx-local)
  - url: http://localhost:8080/v1
    description: api-server напрямую, в обход nginx

security:
  - apiKeyBearer: []

tags:
  - name: me
    description: Профиль текущего клиента, юридические реквизиты, адреса доставки
  - name: catalog
    description: Canonical-товары, identity-профили, производители
  - name: search
    description: Параметрический и фасетный поиск + сборка сметы (proposal pipeline)
  - name: offers
    description: Офферы поставщиков и история наблюдений (цена / наличие)
  - name: pricing
    description: Точечный расчёт цен с учётом контекста клиента и visibility
  - name: estimates
    description: Сохранённые сметы (upload / optimize / finalize / export)
  - name: credentials
    description: Учётные данные клиента у поставщиков
  - name: enrichment
    description: Асинхронные задачи актуализации данных
  - name: plans
    description: Каталог тарифов и prepaid пакетов запросов
  - name: subscription
    description: Текущая подписка клиента (смена тарифа, отмена, продление)
  - name: usage
    description: Потребление API и лимиты по окнам (минута/час/день/неделя/месяц)
  - name: packages
    description: Prepaid пакеты запросов (docked поверх плана)
  - name: billing
    description: Счета, платежи, payment methods
  - name: organizations
    description: Организация (B2B контейнер) — профиль, участники, лимиты, пригласительные
  - name: organization-billing
    description: Подписка организации, пулы лимитов, консолидированный биллинг, пакеты

paths:
  # ---------------------------------------------------------------------------
  # Profile
  # ---------------------------------------------------------------------------
  /me:
    get:
      tags: [me]
      operationId: getMe
      summary: Профиль текущего клиента
      responses:
        '200': { $ref: '#/components/responses/MeOk' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    patch:
      tags: [me]
      operationId: updateMe
      summary: Обновить изменяемые поля профиля
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MePatch' }
      responses:
        '200': { $ref: '#/components/responses/MeOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/legal-info:
    get:
      tags: [me]
      operationId: getLegalInfo
      summary: Legal info (только B2B)
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LegalInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    put:
      tags: [me]
      operationId: updateLegalInfo
      summary: Обновить legal info (запускает async ИНН-верификацию)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LegalInfoUpdate' }
      responses:
        '202':
          description: принято; верификация поставлена в очередь
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LegalInfo' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /me/delivery-addresses:
    get:
      tags: [me]
      operationId: listDeliveryAddresses
      summary: Список адресов доставки клиента
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/DeliveryAddress' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [me]
      operationId: createDeliveryAddress
      summary: Добавить адрес доставки
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DeliveryAddressCreate' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeliveryAddress' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /me/delivery-addresses/{address_id}:
    delete:
      tags: [me]
      operationId: deleteDeliveryAddress
      summary: Удалить адрес доставки
      parameters:
        - { name: address_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  /me/data-deletion-requests:
    post:
      tags: [me]
      operationId: requestDataDeletion
      summary: Начать 152-ФЗ grace period (30 дней, B2C)
      responses:
        '202':
          description: запланировано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataDeletionRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # Catalog + Search
  # ---------------------------------------------------------------------------
  /products:
    get:
      tags: [catalog, search]
      operationId: listProducts
      summary: Листинг товаров каталога с текущим read-side срезом
      security:
        - apiKeyBearer: []
      description: |
        Листинг canonical-карточек, доступных текущему клиенту. Возвращает
        описание товара и текущий read-side срез по поставщикам: primary
        supplier snapshot, цены, остатки, доставку и список supplier offers.

        **Когда использовать.** Нужна страница каталога или карточки для UI,
        без запуска proposal pipeline. На текущей реализации поддерживаются
        только `page` и `page_size`; текстовый и фасетный поиск не заявлены
        в публичном контракте, пока их нет в handler/service.

        **Когда НЕ использовать.** Если артикулы уже на руках и нужны цены,
        наличие или подбор поставщика — идти сразу в `POST /search/proposals`,
        а не получать карточки `/products` и потом итерировать по ним.

        Требуемый scope: `products:read`.
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: page_size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        '200':
          description: Страница canonical-товаров с текущим read-side срезом
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductListResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /products/resolve:
    post:
      tags: [catalog, search]
      operationId: resolveProducts
      summary: Сопоставить пользовательские строки с canonical-товарами
      security:
        - apiKeyBearer: []
      description: |
        Batch-resolver входных строк клиента в canonical product refs.

        **Live сейчас.** Точный mapping `supplier + supplier_sku` →
        canonical product. Это первый шаг для сметы, когда у клиента на руках
        артикулы поставщиков, а `POST /search/proposals` требует
        `TR-XXXXXXX` или canonical UUID.

        **Заложено в контракт на будущее.** Те же строки могут содержать
        `manufacturer`, `mpn`, `query` и `characteristics[]`. Пока такой
        selector принимается как валидный shape, но outcome будет
        `unsupported_query` с reason `characteristic_resolver_not_live`.
        Следующая итерация сможет подбирать подходящие товары по параметрам
        без изменения endpoint'а.

        **Не использовать для fuzzy-аналогов.** Нечёткий подбор аналогов по
        близким характеристикам вынесен в target endpoint
        `POST /products/analog-search`, потому что там другие критерии
        ранжирования, confidence и explain.

        Требуемый scope: `products:read`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductResolveRequest' }
      responses:
        '200':
          description: Результаты сопоставления по каждой входной строке
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductResolveResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Ошибка формы запроса
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationErrors' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /products/{id_or_viewable}:
    get:
      tags: [catalog]
      operationId: getProduct
      summary: Карточка canonical-товара
      security:
        - apiKeyBearer: []
      description: |
        Возвращает карточку товара: название, идентификаторы, описание,
        характеристики и текущий read-side срез по поставщикам — primary
        supplier snapshot, цены, остатки, доставку и supplier offers.

        **Когда использовать.** Клиент знает идентификатор товара (UUID или
        viewable_id формата `TR-XXXXXXX`) и хочет получить карточку для
        просмотра или уточнения состава supplier offers.

        **Когда НЕ использовать.** Если нужна ранжированная смета с выбором
        лучшего поставщика под количество, валюту, availability и delivery —
        `POST /search/proposals`. Если нужна только история наблюдений —
        `/offers/{id}/observations`.

        Требуемый scope: `products:read`.
      parameters:
        - name: id_or_viewable
          in: path
          required: true
          schema: { type: string }
          description: UUID canonical product или viewable_id (`TR-XXXXXXX`).
      responses:
        '200':
          description: Карточка товара
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products/{id_or_viewable}/offers:
    get:
      tags: [offers]
      operationId: getProductOffers
      summary: Офферы поставщиков для canonical-товара
      description: |
        Возвращает все офферы поставщиков, привязанные к данному canonical
        product, с актуальной справкой по цене и наличию (срез последних
        наблюдений).

        **Когда использовать.** Клиент уже знает товар и хочет посмотреть,
        у каких поставщиков он есть в каталоге, без расчёта итоговой цены и
        без ранжирования.

        **Когда НЕ использовать.** Если нужна ранжированная смета (лучшая
        цена / минимальный срок поставки), пересчёт по правилам клиента
        (НДС, наценка, скидка) или live-обращение к поставщику —
        используй `POST /search/proposals`.

        `only_available=true` отфильтрует офферы с нулевым стоком.
      parameters:
        - name: id_or_viewable
          in: path
          required: true
          schema: { type: string }
        - { name: only_available, in: query, schema: { type: boolean, default: false } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: Список офферов поставщиков
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Offer' }
                  next_cursor: { type: [string, 'null'] }
                  freshness: { $ref: '#/components/schemas/Freshness' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products/{id_or_viewable}/analogs:
    get:
      tags: [catalog, search]
      operationId: getProductAnalogs
      summary: Аналоги canonical-товара
      description: |
        Возвращает товары, которые matching pipeline посчитал аналогами
        исходного. Параметр `min_confidence` отсекает кандидатов ниже
        указанного уровня уверенности.

        **Когда использовать.** Клиент хочет получить альтернативу к
        конкретному товару (другой производитель, замена ушедшей с рынка
        позиции).
      parameters:
        - name: id_or_viewable
          in: path
          required: true
          schema: { type: string }
        - { name: min_confidence, in: query, schema: { $ref: '#/components/schemas/MatchConfidence' } }
        - { $ref: '#/components/parameters/Limit' }
      responses:
        '200':
          description: Список аналогов в формате результата поиска товаров
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSearchResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products/analog-search:
    post:
      tags: [catalog, search]
      operationId: searchProductAnalogs
      summary: "Target: подобрать аналоги по неточному совпадению характеристик"
      description: |
        Target endpoint для сложной итерации analog matching. В отличие от
        `POST /products/resolve`, здесь клиент намеренно ищет не тот же самый
        товар, а подходящие замены: по близким характеристикам, допустимым
        отклонениям, производителю, availability и бизнес-ограничениям.

        В live coverage handler пока не зарегистрирован.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductAnalogSearchRequest' }
      responses:
        '200':
          description: "Target: список аналогов с confidence и объяснением"
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSearchResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Ошибка формы запроса
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationErrors' }

  # ---------------------------------------------------------------------------
  # Офферы и наблюдения
  # ---------------------------------------------------------------------------
  /offers/{id}:
    get:
      tags: [offers]
      operationId: getOffer
      summary: Карточка одного оффера поставщика
      description: |
        Возвращает один оффер по его UUID: данные поставщика, последний
        снимок цены и наличия, связь с canonical product.

        **Когда использовать.** У клиента на руках конкретный `offer_id`
        (например, из ответа `POST /search/proposals` или
        `GET /products/{id_or_viewable}/offers`) и нужны его актуальные детали.

        **Когда НЕ использовать.** Если требуется срез по всем офферам
        товара — `/products/{id_or_viewable}/offers`. Если нужна история значений —
        `/offers/{id}/observations`.
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Карточка оффера
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Offer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /offers/{id}/observations:
    get:
      tags: [offers]
      operationId: getOfferObservations
      summary: История наблюдений по офферу (цена и наличие за период)
      description: |
        Append-only лента observation'ов конкретного оффера: каждое значение
        цены или количества на складе, зафиксированное в момент опроса
        поставщика. Поддерживает фильтр по периоду и виду наблюдения
        (`price`, `stock`, `all`).

        **Когда использовать.** Аудит изменений цены или остатков, построение
        графиков, выявление трендов.

        **Когда НЕ использовать.** Для текущего значения цены или наличия
        достаточно `/offers/{id}` (карточка) или `POST /pricing`/
        `POST /search/proposals` (если нужна цена с учётом правил клиента).
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: kind, in: query, schema: { type: string, enum: [price, stock, all], default: all } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: Лента наблюдений с пагинацией
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/OfferObservation' }
                  next_cursor: { type: [string, 'null'] }
                  freshness: { $ref: '#/components/schemas/Freshness' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Pricing
  # ---------------------------------------------------------------------------
  /pricing:
    post:
      tags: [pricing]
      operationId: calculatePrice
      summary: Расчёт финальной цены по правилам клиента
      description: |
        Применяет к выбранному предложению полную цепочку pricing-правил:
        конвертация валюты по курсу, НДС, наценки и скидки, custom-handler'ы.
        В запросе — либо конкретный `offer_id`, либо `canonical_id` (в
        последнем случае выбор observation для расчёта делает pricing engine
        с учётом visibility и preferred sellers). Один запрос принимает
        одну позицию или batch через `items[]`.

        **Когда использовать.** Точечный пересчёт цены вне сценария сметы
        (отдельный калькулятор, проверка стоимости конкретного оффера для
        клиента, экспорт прайс-листа).

        **Когда НЕ использовать.** Для сметы из нескольких артикулов
        правильный путь — `POST /search/proposals`: он сам подбирает офферы
        и применяет те же pricing-правила, плюс возвращает наличие, доставку
        и ранжирование.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PricingRequest' }
      responses:
        '200':
          description: Рассчитанная цена для каждой позиции
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PricingResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Proposal pipeline (§11) — главный endpoint для работы со сметой
  # ---------------------------------------------------------------------------
  /search/proposals:
    post:
      tags: [search]
      operationId: createProposal
      summary: Подобрать предложения поставщиков по списку артикулов (смета)
      description: |
        Главный сценарный endpoint. Принимает multi-item смету (массив
        `items[{product_ref, qty_requested}]`) и синхронно гоняет полный
        pipeline Discovery → Enrichment → Ranking. Возвращает ранжированные
        предложения по каждой позиции вместе с ценой, наличием, доставкой,
        обоснованием расчёта и опциональным trace.

        **Когда использовать.** Это «соберись смета» в одном вызове: у
        клиента есть список Tracium product refs (`TR-XXXXXXX` или canonical
        UUID) и количеств, он хочет понять «у кого, по какой цене, сколько в
        наличии, в какой срок и почему именно этот вариант лучший».
        Для первого рабочего сценария по накопленной базе используй
        `mode=reference`: endpoint читает cached-наблюдения и не требует
        customer supplier credentials. `mode=live` предназначен для live-
        запросов к поставщикам и требует заведённых учётных данных customer'а.

        Если на руках только supplier SKU / внешний артикул поставщика, его
        сначала нужно сопоставить с canonical product: текущий live endpoint
        не принимает supplier SKU как `product_ref`.

        **Когда НЕ использовать.** Если сметы как таковой нет — нужно
        пролистать каталог (`/products`), просто посмотреть карточку
        (`/products/{id_or_viewable}`) или историю наблюдений
        (`/offers/{id}/observations`).

        ## Как читать ответ

        1. `items_outcome[]` — короткий статус по каждой позиции из
           `items[]` входа. Если `status != ok`, предложений по этой
           позиции в ответе нет.
        2. `proposals_by_item[query_item_id]` — массив 0-based индексов в
           `proposals[]` для удачных позиций. Это связка «строка сметы →
           её предложения».
        3. `proposals[i]` — само предложение: `price` (с разбивкой
           `adjustments[]`), `stock`, `delivery`, `supplier_ref`,
           `seller_ref`, `lifecycle`, `score`, `position` в ранжировании,
           `source_mode` (`cached_actual` / `cached_estimate` / `live`).
        4. `trace` — пошаговая разбивка решений pipeline'а; включается
           через `policy.trace.inline_level` (`summary` / `raw`).

        ## Аутентификация

        Phase 1 — dual-auth: API-token Bearer (scope `search:read`) ИЛИ
        cabinet JWT-cookie (sentinel-bypass на nginx, D6). nginx dispatcher
        разводит запрос по нужному auth-flow в зависимости от заголовка
        Authorization. Внешние клиенты используют только Bearer.
      security:
        - apiKeyBearer: []
        - {}  # cabinet JWT-cookie flow (sentinel-bypass через nginx, D6)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProposalRequest'
      responses:
        '200':
          description: Pipeline отработал; ответ содержит ранжированные предложения
          headers:
            X-Search-Id:
              description: UUID конкретного запуска поиска (для корреляции с trace и метриками)
              schema: { type: string, format: uuid }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Ошибки валидации входного тела
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrors'
        '500':
          description: Внутренняя ошибка pipeline'а
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ---------------------------------------------------------------------------
  # Estimates
  # ---------------------------------------------------------------------------
  /estimates/proposals:
    post:
      tags: [estimates]
      operationId: createEstimateProposal
      summary: Решить смету: распознать позиции и собрать предложения
      description: |
        Прикладной endpoint для сценария сметчика. Клиент передаёт уже
        нормализованный JSON, а не XLS/XLSX: строки сметы, количества,
        подсказки по бренду, внешние артикулы и характеристики. Сервер
        сначала сопоставляет строки с canonical products (`products.resolve`),
        затем для найденных товаров запускает proposal pipeline и возвращает
        по каждой строке выбранный товар, поставщика, цену, наличие, доставку
        и причину, если строка требует ручной проверки.

        Входные характеристики должны быть нормализованы до системы СИ:
        числовое значение отдельно, единица в `unit` латиницей (`m`, `A`,
        `V`, `W` и т.п.). Например, `1250 А` нужно передавать как
        `{ "value": 1250, "unit": "A" }`, а `80 мм` — как
        `{ "value": 0.08, "unit": "m" }`.

        Endpoint не занимается парсингом Excel. XLS/CSV/ERP-экспорт
        предварительно приводится клиентом или импортным адаптером к
        `EstimateProposalRequest`.
      security:
        - apiKeyBearer: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EstimateProposalRequest'
      responses:
        '200':
          description: Смета обработана; часть строк может требовать ручной проверки
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EstimateProposalResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Ошибки валидации входного тела
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrors'
        '500':
          description: Внутренняя ошибка resolver/pipeline
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /estimates:
    get:
      tags: [estimates]
      operationId: listEstimates
      summary: Список смет клиента
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/EstimateStatus' } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/EstimateSummary' }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [estimates]
      operationId: createEstimate
      summary: Создать смету (upload или inline lines)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EstimateRequest' }
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: "XLSX/CSV файл сметы"
                source_format: { $ref: '#/components/schemas/EstimateSourceFormat' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Estimate' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /estimates/{id}:
    get:
      tags: [estimates]
      operationId: getEstimate
      summary: Состояние сметы (последняя версия)
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Estimate' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /estimates/{id}/versions:
    get:
      tags: [estimates]
      operationId: listEstimateVersions
      summary: История immutable-версий сметы
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EstimateVersion' }
        '404': { $ref: '#/components/responses/NotFound' }

  /estimates/{id}/lines/{line_id}/candidate:
    put:
      tags: [estimates]
      operationId: selectEstimateCandidate
      summary: Выбрать candidate для строки сметы
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - { name: line_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [canonical_product_id]
              properties:
                canonical_product_id: { type: string, format: uuid }
                preferred_offer_id: { type: [string, 'null'], format: uuid }
                comment: { type: string }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EstimateLine' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /estimates/{id}/optimize:
    post:
      tags: [estimates]
      operationId: optimizeEstimate
      summary: Запустить оптимизацию (ILP / greedy) — создаёт новую версию
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EstimateOptimizeRequest' }
      responses:
        '202':
          description: optimization scheduled; новая версия появится через WS
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Estimate' }
        '404': { $ref: '#/components/responses/NotFound' }

  /estimates/{id}/finalize:
    post:
      tags: [estimates]
      operationId: finalizeEstimate
      summary: Зафиксировать смету (immutable snapshot)
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: финализировано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Estimate' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /estimates/{id}/result:
    get:
      tags: [estimates]
      operationId: getEstimateResult
      summary: Подробный результат (breakdown по строкам + alternatives)
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EstimateResult' }
        '404': { $ref: '#/components/responses/NotFound' }

  /estimates/{id}/export:
    post:
      tags: [estimates]
      operationId: exportEstimate
      summary: Экспорт сметы в XLSX / PDF
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              properties:
                format: { type: string, enum: [xlsx, pdf] }
                locale: { type: string, default: ru-RU }
                include_alternatives: { type: boolean, default: false }
      responses:
        '200':
          description: экспортированный файл
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }
            application/pdf:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Credentials
  # ---------------------------------------------------------------------------
  /credentials:
    get:
      tags: [credentials]
      operationId: listCredentials
      summary: Список credentials клиента
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CredentialListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [credentials]
      operationId: createCredential
      summary: Создать customer-scoped supplier credential
      description: |
        `scope` и `owner_ref` не принимаются от клиента: backend берёт owner из
        JWT subject, который уже проверен shared/auth через nginx auth_request.
        `secret_value` шифруется и никогда не возвращается в responses.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCredentialRequest' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CredentialSummary' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }

  /credentials/{id}:
    get:
      tags: [credentials]
      operationId: getCredential
      summary: Credential клиента (без секрета)
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CredentialSummary' }
        '404': { $ref: '#/components/responses/NotFound' }

  /credentials/{id}/secret:
    patch:
      tags: [credentials]
      operationId: rotateCredentialSecret
      summary: Заменить секрет credential клиента
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RotateCredentialSecretRequest' }
      responses:
        '204': { description: secret rotated }
        '404': { $ref: '#/components/responses/NotFound' }

  /credentials/{id}/status:
    patch:
      tags: [credentials]
      operationId: transitionCredentialStatus
      summary: Изменить статус credential клиента
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CredentialStatusTransitionRequest' }
      responses:
        '200':
          description: статус изменён
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CredentialSummary' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # Enrichment jobs
  # ---------------------------------------------------------------------------
  /enrichment-jobs:
    post:
      tags: [enrichment]
      operationId: requestEnrichment
      summary: Запросить актуализацию (discovery / refresh observation / и т.д.)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnrichmentJobRequest' }
      responses:
        '202':
          description: принято
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnrichmentJob' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /enrichment-jobs/{id}:
    get:
      tags: [enrichment]
      operationId: getEnrichmentJob
      summary: Статус async job
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnrichmentJob' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Auxiliary reference data
  # ---------------------------------------------------------------------------
  /manufacturers:
    get:
      tags: [catalog]
      operationId: listManufacturers
      summary: Справочник производителей (фильтруется visibility policy)
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ManufacturerSummary' }
                  next_cursor: { type: [string, 'null'] }

  /identity-profiles:
    get:
      tags: [catalog]
      operationId: listIdentityProfiles
      summary: Identity profiles (category-agnostic, ADR-0007)
      parameters:
        - { name: kind, in: query, schema: { type: string } }
        - { $ref: '#/components/parameters/Limit' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/IdentityProfileSummary' }

  /identity-profiles/{id}:
    get:
      tags: [catalog]
      operationId: getIdentityProfile
      summary: Identity profile по id
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IdentityProfileSummary' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Plans catalog
  # ---------------------------------------------------------------------------
  /plans:
    get:
      tags: [plans]
      operationId: listPlans
      summary: Каталог публичных тарифов
      security: []
      description: |
        Возвращает все visible-тарифы. Эндпоинт не требует авторизации —
        может использоваться на маркетинговых страницах.
      parameters:
        - { name: include_hidden, in: query, schema: { type: boolean, default: false } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Plan' }

  /plans/{code}:
    get:
      tags: [plans]
      operationId: getPlan
      summary: Детали тарифа
      security: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string, example: pro } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Plan' }
        '404': { $ref: '#/components/responses/NotFound' }

  /plans/packages:
    get:
      tags: [plans, packages]
      operationId: listRequestPackages
      summary: Доступные prepaid пакеты запросов
      security: []
      parameters:
        - { name: for_plan, in: query, schema: { type: string }, description: "Фильтр совместимости с планом" }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RequestPackage' }

  # ---------------------------------------------------------------------------
  # Subscription (текущая)
  # ---------------------------------------------------------------------------
  /me/subscription:
    get:
      tags: [subscription]
      operationId: getMySubscription
      summary: Текущая подписка и её лимиты
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [subscription]
      operationId: changeMySubscription
      summary: Сменить тариф (upgrade / downgrade)
      description: |
        Immediate upgrade: смена плана и prorated billing немедленно.
        Downgrade / cancel scheduled: срабатывает на следующий billing cycle
        (см. `proration_policy` плана).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubscriptionChangeRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '409': { $ref: '#/components/responses/Conflict' }

  /me/subscription/cancel:
    post:
      tags: [subscription]
      operationId: cancelMySubscription
      summary: Запланировать отмену подписки (в конце текущего периода)
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
                immediate: { type: boolean, default: false }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Usage
  # ---------------------------------------------------------------------------
  /me/usage:
    get:
      tags: [usage]
      operationId: getMyUsageSummary
      summary: Агрегированная сводка использования по всем окнам
      parameters:
        - { name: window, in: query, schema: { type: string, enum: [minute, hour, day, week, month, billing_period, all], default: all } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/usage/events:
    get:
      tags: [usage]
      operationId: listMyUsageEvents
      summary: Детальный лог запросов (для самодиагностики и аудита)
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: endpoint, in: query, schema: { type: string } }
        - { name: status_bucket, in: query, schema: { type: string, enum: ["2xx", "4xx", "5xx", "429"] } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/UsageEvent' }
                  next_cursor: { type: [string, 'null'] }

  # ---------------------------------------------------------------------------
  # Packages (prepaid)
  # ---------------------------------------------------------------------------
  /me/packages:
    get:
      tags: [packages]
      operationId: listMyPackages
      summary: Купленные пакеты запросов (активные + история)
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [active, expired, exhausted, refunded, pending_payment] } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PurchasedPackage' }
    post:
      tags: [packages, billing]
      operationId: purchaseRequestPackage
      summary: Купить пакет запросов (prepaid)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PackagePurchaseRequest' }
      responses:
        '201':
          description: покупка инициирована
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PackagePurchaseResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402': { $ref: '#/components/responses/PaymentRequired' }

  # ---------------------------------------------------------------------------
  # Billing (invoices + payment methods)
  # ---------------------------------------------------------------------------
  /me/invoices:
    get:
      tags: [billing]
      operationId: listMyInvoices
      summary: Счета клиента
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/InvoiceStatus' } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  next_cursor: { type: [string, 'null'] }

  /me/invoices/{id}:
    get:
      tags: [billing]
      operationId: getMyInvoice
      summary: Счёт с line items
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404': { $ref: '#/components/responses/NotFound' }

  /me/payment-methods:
    get:
      tags: [billing]
      operationId: listPaymentMethods
      summary: Payment methods клиента
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PaymentMethod' }
    post:
      tags: [billing]
      operationId: addPaymentMethod
      summary: Добавить payment method (возвращает redirect на acquirer)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentMethodAddRequest' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentMethodAddResult' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /me/payment-methods/{id}:
    delete:
      tags: [billing]
      operationId: removePaymentMethod
      summary: Удалить payment method
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '204': { description: removed }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # Organizations (B2B container)
  # ---------------------------------------------------------------------------
  /organizations:
    get:
      tags: [organizations]
      operationId: listMyOrganizations
      summary: Организации, в которых состоит текущий клиент
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OrganizationSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [organizations]
      operationId: createOrganization
      summary: Создать организацию (делает клиента owner)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrganizationCreate' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /organizations/{org_id}:
    get:
      tags: [organizations]
      operationId: getOrganization
      summary: Профиль организации
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [organizations]
      operationId: updateOrganization
      summary: Обновить профиль организации (owner/admin only)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrganizationPatch' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /organizations/{org_id}/members:
    get:
      tags: [organizations]
      operationId: listOrganizationMembers
      summary: Участники организации
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: status, in: query, schema: { type: string, enum: [active, invited, suspended, left] } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/OrganizationMember' }
                  next_cursor: { type: [string, 'null'] }
        '403': { $ref: '#/components/responses/Forbidden' }

  /organizations/{org_id}/members/{user_id}:
    get:
      tags: [organizations]
      operationId: getOrganizationMember
      summary: Один участник (роль + personal cap + usage)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationMember' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [organizations]
      operationId: updateOrganizationMember
      summary: Сменить роль / статус участника (owner/admin only)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrganizationMemberPatch' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationMember' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [organizations]
      operationId: removeOrganizationMember
      summary: Удалить участника (оставляет его usage history)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: removed }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /organizations/{org_id}/members/{user_id}/limits:
    put:
      tags: [organizations]
      operationId: setOrganizationMemberLimits
      summary: Задать личный cap участника внутри org pool
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MemberLimitsCap' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationMember' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /organizations/{org_id}/invitations:
    get:
      tags: [organizations]
      operationId: listOrganizationInvitations
      summary: Открытые приглашения
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OrganizationInvitation' }
    post:
      tags: [organizations]
      operationId: inviteOrganizationMember
      summary: Пригласить нового участника (по email или phone)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrganizationInvitationCreate' }
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationInvitation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '409': { $ref: '#/components/responses/Conflict' }

  /organizations/{org_id}/invitations/{invitation_id}:
    delete:
      tags: [organizations]
      operationId: revokeOrganizationInvitation
      summary: Отозвать приглашение
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: invitation_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: revoked }
        '404': { $ref: '#/components/responses/NotFound' }

  /invitations/{code}/accept:
    post:
      tags: [organizations]
      operationId: acceptOrganizationInvitation
      summary: Принять приглашение (по коду из письма / ссылки)
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationMembership' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # Organization subscription / usage / billing
  # ---------------------------------------------------------------------------
  /organizations/{org_id}/subscription:
    get:
      tags: [organization-billing]
      operationId: getOrganizationSubscription
      summary: Подписка организации (scope=organization)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationSubscription' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [organization-billing]
      operationId: changeOrganizationSubscription
      summary: Сменить тариф организации (scope=organization only)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrganizationSubscriptionChangeRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402': { $ref: '#/components/responses/PaymentRequired' }

  /organizations/{org_id}/subscription/cancel:
    post:
      tags: [organization-billing]
      operationId: cancelOrganizationSubscription
      summary: Отмена подписки организации
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
                immediate: { type: boolean, default: false }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationSubscription' }

  /organizations/{org_id}/subscription/seats:
    post:
      tags: [organization-billing]
      operationId: adjustOrganizationSeats
      summary: Изменить число seats (add/remove, prorated billing)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SeatsAdjustmentRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationSubscription' }
        '402': { $ref: '#/components/responses/PaymentRequired' }

  /organizations/{org_id}/usage:
    get:
      tags: [organization-billing]
      operationId: getOrganizationUsage
      summary: Usage организации (pool + breakdown по участникам)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: window, in: query, schema: { type: string, enum: [minute, hour, day, week, month, billing_period, all], default: billing_period } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrganizationUsageSummary' }

  /organizations/{org_id}/usage/events:
    get:
      tags: [organization-billing]
      operationId: listOrganizationUsageEvents
      summary: Сырой лог запросов всех участников
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: user_id, in: query, schema: { type: string, format: uuid } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: status_bucket, in: query, schema: { type: string, enum: ["2xx", "4xx", "5xx", "429"] } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/UsageEvent' }
                  next_cursor: { type: [string, 'null'] }

  /organizations/{org_id}/invoices:
    get:
      tags: [organization-billing]
      operationId: listOrganizationInvoices
      summary: Консолидированные invoices организации
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/InvoiceStatus' } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  next_cursor: { type: [string, 'null'] }

  /organizations/{org_id}/packages:
    get:
      tags: [organization-billing]
      operationId: listOrganizationPackages
      summary: Prepaid-пакеты организации (пул, разделяемый между участниками)
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PurchasedPackage' }
    post:
      tags: [organization-billing]
      operationId: purchaseOrganizationPackage
      summary: Купить пакет на организацию
      parameters:
        - { $ref: '#/components/parameters/OrganizationIdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PackagePurchaseRequest' }
      responses:
        '201':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PackagePurchaseResult' }
        '402': { $ref: '#/components/responses/PaymentRequired' }

  /whoami:
    get:
      tags: [auth]
      operationId: whoami
      summary: Sanity check для customer'а — проверить что Bearer-токен работает
      description: |
        Public surface. Требует `Authorization: Bearer trk_<env>_<base62>`.
        Внешний URL: `/v1/whoami` (servers include `/v1`). nginx валидирует
        token через `/_internal/auth/api-key/verify` и передаёт trusted
        X-Tr-* headers в api-server; handler не делает DB lookup.
      security:
        - apiKeyBearer: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [customer_id, env, key_name]
                properties:
                  customer_id: { type: string, format: uuid }
                  env: { type: string, enum: [production, test] }
                  key_name: { type: string }
        '401':
          description: Unauthorized — токен невалидный, истёк, отозван либо клиент в архиве
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string, enum: [unauthorized] }

components:
  securitySchemes:
    apiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: trk_<env>_<base62>
      description: |
        API-ключ Tracium. Получается через cabinet UI (POST /api/cabinet/keys).
        Формат: trk_live_<22ch base62> (production) или trk_test_<22ch base62>.
        Валидируется через nginx auth_request, см. ADR-0051.

  parameters:
    UuidPath:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    OrganizationIdPath:
      name: org_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
    Cursor:
      name: cursor
      in: query
      description: "opaque cursor из предыдущего ответа (`next_cursor`)"
      schema: { type: string }
    XOrganizationIdHeader:
      name: X-Organization-Id
      in: header
      required: false
      description: |
        Выбор активной организации, если клиент состоит в нескольких.
        Без заголовка используется primary_organization_id клиента
        (если он membre хотя бы одной org), иначе — personal scope.
      schema: { type: string, format: uuid }

  responses:
    MeOk:
      description: ok
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Me' }
    BadRequest:
      description: некорректный запрос
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Unauthorized:
      description: нет или невалидный JWT
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Forbidden:
      description: visibility policy скрыла ресурс
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    NotFound:
      description: ресурс не найден
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Conflict:
      description: конфликт состояния
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    RateLimited:
      description: |
        Лимит превышен. Коды в теле:

        - `rate_limited` — сработал лимит короткого окна (минута / час /
          день / неделя). Retry-After = секунды до reset окна.
        - `quota_exhausted` — исчерпана квота billing-периода (personal).
        - `org_pool_exhausted` — исчерпан пул организации; разблокируется
          после покупки org-пакета или reset billing-периода.
        - `member_cap_exceeded` — участник упёрся в свой personal_cap
          внутри org pool; разблокирует owner/admin организации.
        - `concurrent_limit` — превышено число одновременных запросов.
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit-Minute: { $ref: '#/components/headers/XRateLimitLimitMinute' }
        X-RateLimit-Remaining-Minute: { $ref: '#/components/headers/XRateLimitRemainingMinute' }
        X-RateLimit-Reset-Minute: { $ref: '#/components/headers/XRateLimitResetMinute' }
        X-Quota-Requests-Limit: { $ref: '#/components/headers/XQuotaRequestsLimit' }
        X-Quota-Requests-Remaining: { $ref: '#/components/headers/XQuotaRequestsRemaining' }
        X-Quota-Requests-Reset: { $ref: '#/components/headers/XQuotaRequestsReset' }
        X-Organization-Id: { $ref: '#/components/headers/XOrganizationId' }
        X-Quota-Org-Requests-Limit: { $ref: '#/components/headers/XQuotaOrgRequestsLimit' }
        X-Quota-Org-Requests-Remaining: { $ref: '#/components/headers/XQuotaOrgRequestsRemaining' }
        X-Quota-Org-Requests-Reset: { $ref: '#/components/headers/XQuotaOrgRequestsReset' }
        X-Member-Cap-Requests-Limit: { $ref: '#/components/headers/XMemberCapRequestsLimit' }
        X-Member-Cap-Requests-Remaining: { $ref: '#/components/headers/XMemberCapRequestsRemaining' }
        X-Member-Cap-Requests-Reset: { $ref: '#/components/headers/XMemberCapRequestsReset' }
      content: { application/json: { schema: { $ref: '#/components/schemas/RateLimitError' } } }
    PaymentRequired:
      description: |
        Требуется оплата. Коды:

        - `plan_required` — endpoint недоступен в текущем плане.
        - `plan_upgrade_required` — нужна смена тарифа.
        - `invoice_unpaid` — есть непогашенный счёт, доступ заблокирован.
      content: { application/json: { schema: { $ref: '#/components/schemas/PaymentRequiredError' } } }
    InternalError:
      description: внутренняя ошибка
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  headers:
    XRateLimitLimitMinute:
      schema: { type: integer }
      description: "Лимит запросов в минуту для текущего плана"
    XRateLimitRemainingMinute:
      schema: { type: integer }
      description: "Оставшийся счётчик запросов в текущем 60-сек окне"
    XRateLimitResetMinute:
      schema: { type: integer, format: int64 }
      description: "Unix timestamp reset текущей минуты"
    XRateLimitLimitHour:
      schema: { type: integer }
    XRateLimitRemainingHour:
      schema: { type: integer }
    XRateLimitResetHour:
      schema: { type: integer, format: int64 }
    XRateLimitLimitDay:
      schema: { type: integer }
    XRateLimitRemainingDay:
      schema: { type: integer }
    XRateLimitResetDay:
      schema: { type: integer, format: int64 }
    XRateLimitLimitWeek:
      schema: { type: integer }
    XRateLimitRemainingWeek:
      schema: { type: integer }
    XRateLimitResetWeek:
      schema: { type: integer, format: int64 }
    XQuotaRequestsLimit:
      schema: { type: integer }
      description: "Лимит запросов в billing-периоде (обычно календарный месяц)"
    XQuotaRequestsRemaining:
      schema: { type: integer }
    XQuotaRequestsReset:
      schema: { type: integer, format: int64 }
      description: "Unix timestamp reset billing-периода"
    XQuotaDataBytesLimit:
      schema: { type: integer, format: int64 }
    XQuotaDataBytesRemaining:
      schema: { type: integer, format: int64 }
    XUsagePackagesRequestsRemaining:
      schema: { type: integer }
      description: "Сумма остатков prepaid-пакетов"
    XPlanCode:
      schema: { type: string }
      description: "Код активного плана (`free`, `starter`, `pro`, ...)"
    XRequestWeight:
      schema: { type: integer }
      description: "Сколько единиц квоты стоил запрос"
    XOrganizationId:
      schema: { type: string, format: uuid }
      description: "Активный organization context (echo `X-Organization-Id` или primary)"
    XQuotaOrgRequestsLimit:
      schema: { type: integer }
    XQuotaOrgRequestsRemaining:
      schema: { type: integer }
    XQuotaOrgRequestsReset:
      schema: { type: integer, format: int64 }
    XQuotaOrgDataBytesLimit:
      schema: { type: integer, format: int64 }
    XQuotaOrgDataBytesRemaining:
      schema: { type: integer, format: int64 }
    XMemberCapRequestsLimit:
      schema: { type: integer }
      description: "Личный cap участника внутри org pool"
    XMemberCapRequestsRemaining:
      schema: { type: integer }
    XMemberCapRequestsReset:
      schema: { type: integer, format: int64 }

  schemas:
    # ----- Common -----------------------------------------------------------
    Error:
      type: object
      required: [code, title, message, category, retryable, request_id]
      properties:
        code: { type: string, description: "машинный код (`domain.reason`)" }
        title: { type: string, description: "короткий безопасный заголовок для UI" }
        message: { type: string }
        category:
          type: string
          enum: [validation, auth, forbidden, not_found, conflict, rate_limit, dependency, internal]
        retryable: { type: boolean }
        details: { type: object, additionalProperties: true }
        request_id: { type: string }
        trace_id: { type: string }
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/Error' }

    Freshness:
      type: object
      description: "ADR-0014 freshness metadata — есть в любом list/entity ответе"
      required: [status]
      properties:
        status: { type: string, enum: [fresh, stale, degraded, incomplete, empty] }
        stale_fields:
          type: array
          items: { type: string, example: prices }
        observed_at_max: { type: [string, 'null'], format: date-time }
        refresh_eta: { type: [string, 'null'], format: date-time }
        fallback_reason:
          type: [string, 'null']
          enum:
            - customer_credential_failing
            - customer_credential_expired
            - customer_credential_revoked
            - no_customer_credential
            - system_credential_used
            - async_refresh_enqueued
            - stale_with_async_refresh

    Money:
      type: object
      description: |
        Денежная сумма (ADR-0025): целочисленное `value` с `precision`
        десятичных и валютой ISO 4217. Пример: 1234.56 RUB ⇒ `value=123456, precision=2, currency=RUB`.
      required: [value, precision, currency]
      properties:
        value: { type: integer, format: int64, description: "Масштабированное целое; реальная сумма = value / 10^precision." }
        precision: { type: integer, minimum: 0, maximum: 9, description: "Число десятичных знаков." }
        currency: { type: string, description: "ISO 4217", example: RUB }

    # ----- Customer / me ----------------------------------------------------
    Me:
      type: object
      required: [id, customer_type, pricing_group, features]
      properties:
        id: { type: string, format: uuid }
        customer_type: { type: string, enum: [individual, legal_entity] }
        display_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        pricing_group:
          type: string
          description: "Открытый справочник — базовые: b2c_retail, b2b_standard, b2b_contract_*, partner_*"
        features:
          type: array
          items: { type: string }
          description: "Открытый список customer features (`allow_estimate_approval`, ...)"
        legal_info_status: { type: string, enum: [none, pending, verified, rejected] }
        data_deletion_scheduled_at: { type: [string, 'null'], format: date-time }
        organization_memberships:
          type: array
          items: { $ref: '#/components/schemas/OrganizationMembership' }
        primary_organization_id:
          type: [string, 'null']
          format: uuid
          description: "Используется по умолчанию, если `X-Organization-Id` не задан"
        active_subscription_scope:
          type: string
          enum: [personal, organization]
          description: "Откуда текущему запросу берутся лимиты"

    MePatch:
      type: object
      properties:
        display_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }

    LegalInfo:
      type: object
      properties:
        inn: { type: string }
        kpp: { type: string }
        company_name: { type: string }
        registration_country: { type: string, description: "ISO 3166-1 alpha-2" }
        verified_at: { type: [string, 'null'], format: date-time }
        status: { type: string, enum: [pending, verified, rejected, edge_case] }
        rejection_reason: { type: [string, 'null'] }

    LegalInfoUpdate:
      type: object
      required: [inn, company_name, registration_country]
      properties:
        inn: { type: string }
        kpp: { type: string }
        company_name: { type: string }
        registration_country: { type: string }

    DeliveryAddress:
      type: object
      required: [id, address_line]
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        country: { type: string }
        region: { type: string }
        city: { type: string }
        postal_code: { type: string }
        address_line: { type: string }
        coordinates:
          type: [object, 'null']
          properties:
            lat: { type: number }
            lon: { type: number }
        is_default: { type: boolean }

    DeliveryAddressCreate:
      type: object
      required: [address_line, city, country]
      properties:
        label: { type: string }
        country: { type: string }
        region: { type: string }
        city: { type: string }
        postal_code: { type: string }
        address_line: { type: string }
        coordinates:
          type: object
          properties:
            lat: { type: number }
            lon: { type: number }
        is_default: { type: boolean, default: false }

    DataDeletionRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        requested_at: { type: string, format: date-time }
        physical_removal_at: { type: string, format: date-time, description: "Дата окончательного удаления (+30 дней)" }
        status: { type: string, enum: [pending, cancelled, completed] }

    # ----- Catalog ---------------------------------------------------------
    LifecycleStatus:
      type: string
      enum: [new, active, deprecated, discontinued]

    MatchConfidence:
      type: string
      enum: [exact, strong, probable, weak, unmatched]

    ExternalIdentity:
      type: object
      properties:
        standard_ref:
          type: string
          description: "federated taxonomy: etim_v6, etim_v7, gs1_gtin, mpn, okpd2, eclass, unspsc (ADR-0028, расширяемо)"
        value: { type: string }

    MediaAsset:
      type: object
      properties:
        kind: { type: string, enum: [image, video, certificate, datasheet] }
        url: { type: string, format: uri }
        mime_type: { type: string }
        watermarked: { type: boolean }

    ManufacturerRef:
      type: object
      description: |
        Вложенная ссылка на производителя в карточке товара (§5.5).
        `slug` опционален — может отсутствовать пока Iter-3 LLM его не
        сгенерирует; `alias_slugs` всегда присутствует (пустой массив
        по умолчанию).
      required: [id, name, alias_slugs]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string, description: "Канонический slug (kebab-case, alnum, ≤80)." }
        alias_slugs:
          type: array
          description: "Исторические slugs — для 301-редиректов после LLM-переписывания."
          items: { type: string }
          default: []
        name: { type: string }

    CategoryRef:
      type: object
      description: |
        Вложенная ссылка на категорию в карточке товара (§5.5). Включает
        plain slug/alias_slugs плюс разложенный путь `path_slugs` /
        `path_names`, чтобы фронт мог рендерить хлебные крошки без
        отдельного запроса к дереву категорий.
      required: [id, slug, name, alias_slugs, path_slugs, path_names]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        alias_slugs:
          type: array
          items: { type: string }
          default: []
        name: { type: string }
        path_slugs:
          type: array
          description: "Slugs от корня к листу; последний элемент = slug самой категории."
          items: { type: string }
        path_names:
          type: array
          description: "Отображаемые имена от корня к листу; параллельный path_slugs."
          items: { type: string }

    ManufacturerSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        aliases: { type: array, items: { type: string } }
        country: { type: string }

    IdentityProfileSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { type: string }
        name: { type: string }
        required_characteristics: { type: array, items: { type: string } }

    ProductListResponse:
      type: object
      description: |
        Страница read-side карточек `/products`. Пагинация по `page` + `page_size`;
        наличие следующей страницы — `has_more`. Без `total` для постоянного времени
        отклика на любом размере каталога.
      required: [items, page, has_more]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Product' }
        page: { type: integer, minimum: 1 }
        has_more:
          type: boolean
          description: True если за этой страницей есть ещё хотя бы одна.

    Product:
      type: object
      description: |
        Read-side карточка canonical product. `viewable_id` — TR-XXXXXXX
        Crockford base32, стабильный внешний ID. Коммерческие поля (`supplier`,
        `sku`, `prices`, `stock`, `delivery`) относятся к primary supplier
        snapshot, выбранному read-side сервисом. Все доступные supplier offers
        для этой canonical-карточки идут в `supplier_offers`.
      required: [id, name, lifecycle_status]
      properties:
        id: { type: string, format: uuid }
        viewable_id:
          type: [string, 'null']
          pattern: '^TR-[0-9A-HJKMNP-TV-Z]{7}$'
          description: "Crockford base32 внешний ID (ADR-0032)."
        supplier:
          type: string
          description: Primary supplier для текущего read-side snapshot.
        sku:
          type: string
          description: Артикул primary supplier.
        identity_signature: { type: string, description: "hash: manufacturer + mpn + profile + critical attrs" }
        manufacturer: { $ref: '#/components/schemas/ManufacturerRef' }
        category:
          anyOf:
            - { $ref: '#/components/schemas/CategoryRef' }
            - { type: 'null' }
          description: "Вложенная ссылка на категорию; nullable пока товар не привязан к дереву."
        mpn: { type: string, description: "Manufacturer Part Number" }
        identity_profile_id: { type: [string, 'null'], format: uuid }
        name: { type: string }
        description: { type: string }
        characteristics:
          type: array
          items: { $ref: '#/components/schemas/ProductCharacteristic' }
        images:
          type: array
          description: "Плоский массив URL изображений, отсортирован по релевантности (главное первое)."
          items: { type: string, format: uri }
        media:
          type: array
          items: { $ref: '#/components/schemas/MediaAsset' }
        external_identities:
          type: array
          items: { $ref: '#/components/schemas/ExternalIdentity' }
        prices:
          type: object
          description: "Набор цен по валюте (ключ — ISO 4217). Пусто/отсутствует = цена не опубликована."
          additionalProperties: { $ref: '#/components/schemas/PriceSet' }
        stock:
          $ref: '#/components/schemas/Stock'
        delivery:
          $ref: '#/components/schemas/ProductDelivery'
        supplier_offers:
          type: array
          description: Все supplier offers, привязанные к canonical product, с последним read-side snapshot.
          items: { $ref: '#/components/schemas/ProductSupplierOffer' }
        lifecycle_status: { $ref: '#/components/schemas/LifecycleStatus' }
        replaced_by: { type: [string, 'null'], format: uuid }
        canonical_last_seen_at: { type: [string, 'null'], format: date-time }
        freshness: { $ref: '#/components/schemas/Freshness' }

    ProductCharacteristic:
      type: object
      properties:
        supplier_code: { type: string }
        display_name: { type: string }
        raw_value:
          oneOf:
            - { type: string }
            - { type: number }
            - { type: boolean }
            - { type: object, additionalProperties: true }

    ProductDelivery:
      type: object
      properties:
        lead_time_in_stock_days: { type: [integer, 'null'] }
        lead_time_out_of_stock_days: { type: [integer, 'null'] }
        production_term_days: { type: [integer, 'null'] }
        requires_specification: { type: boolean, default: false }
        notes: { type: string }

    ProductSupplierOffer:
      type: object
      properties:
        supplier: { type: string }
        sku: { type: string }
        mpn: { type: string }
        name: { type: string }
        description: { type: string }
        images:
          type: array
          items: { type: string, format: uri }
        prices:
          type: object
          additionalProperties: { $ref: '#/components/schemas/PriceSet' }
        stock:
          $ref: '#/components/schemas/Stock'
        delivery:
          $ref: '#/components/schemas/ProductDelivery'
        freshness:
          $ref: '#/components/schemas/Freshness'

    Stock:
      type: object
      description: |
        Остатки товара в разрезе складов. `by_warehouse` — плоский
        список наблюдений по складам, где `qty_rc` / `qty_crs` /
        `qty_op` — ADR-0021 корзины (Ready-Count / Coming-Reserve-Stock
        / Off-Party).
      properties:
        by_warehouse:
          type: array
          items:
            type: object
            required: [warehouse_ref]
            properties:
              warehouse_ref:
                type: object
                required: [kind, code]
                properties:
                  kind: { type: string, enum: [own_warehouse, third_party_logistics, transit, external_marketplace] }
                  code: { type: string }
                  name: { type: string }
              qty_rc: { type: [number, 'null'], description: "Готово к отгрузке (Ready-Count)." }
              qty_crs: { type: [number, 'null'], description: "Зарезервировано под приход (Coming-Reserve-Stock)." }
              qty_op: { type: [number, 'null'], description: "Под заказ / Off-Party." }
              observed_at: { type: string, format: date-time }

    ProductSearchHit:
      type: object
      properties:
        product: { $ref: '#/components/schemas/Product' }
        score: { type: number, format: float }
        match_confidence: { $ref: '#/components/schemas/MatchConfidence' }
        explanation:
          type: object
          description: "Почему этот товар попал в выдачу (для прозрачности)"
          additionalProperties: true

    ProductSearchResponse:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProductSearchHit' }
        next_cursor: { type: [string, 'null'] }
        facets:
          type: array
          items: { $ref: '#/components/schemas/SearchFacet' }
        freshness: { $ref: '#/components/schemas/Freshness' }

    ProductResolveRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/ProductResolveItem' }

    ProductResolveItem:
      type: object
      description: |
        Входная строка resolver'а. Live selector — `supplier + supplier_sku`.
        Поля `manufacturer`, `mpn`, `query`, `characteristics[]` заранее
        включены в контракт для параметрического resolver'а; текущий handler
        возвращает по ним `unsupported_query`.
      properties:
        id:
          type: string
          description: Клиентский идентификатор строки сметы. Если не передан, сервер использует `item-{index}`.
        supplier:
          type: string
          description: Код поставщика в Tracium, например `etm`, `iek`, `russvet`.
        supplier_sku:
          type: string
          description: Артикул поставщика. Вместе с `supplier` образует live exact selector.
        manufacturer:
          type: string
          description: Target selector для будущего поиска по производителю.
        mpn:
          type: string
          description: Target selector для будущего поиска по MPN / manufacturer article.
        query:
          type: string
          description: Target selector для будущего текстового resolver'а.
        characteristics:
          type: array
          description: Target selector для будущего параметрического resolver'а.
          items: { $ref: '#/components/schemas/ProductResolveCharacteristic' }
        qty_requested:
          type: integer
          minimum: 0
          description: Количество из строки сметы. Resolver не рассчитывает цену, но возвращает поле обратно для удобной сборки следующего запроса.

    ProductResolveCharacteristic:
      type: object
      properties:
        code: { type: string, description: "Канонический или клиентский код характеристики." }
        name: { type: string, description: "Человекочитаемое имя характеристики, если code неизвестен." }
        value:
          oneOf:
            - { type: string }
            - { type: number }
            - { type: boolean }
        unit: { type: string, description: "Единица измерения, если применимо." }

    ProductResolveResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ProductResolveOutcome' }

    ProductResolveOutcome:
      type: object
      required: [status, candidates]
      properties:
        id: { type: string }
        status:
          type: string
          enum: [resolved, not_found, ambiguous, unsupported_query]
          description: |
            `resolved` — выбран один canonical product; `not_found` — exact
            selector не нашёл оффер; `ambiguous` — найдено несколько
            кандидатов; `unsupported_query` — shape валиден, но этот selector
            ещё не live.
        reason:
          type: string
          description: Машиночитаемая причина, например `offer_not_found` или `characteristic_resolver_not_live`.
        canonical_ref: { type: string, format: uuid, description: "Заполнен при status=resolved." }
        viewable_id:
          type: [string, 'null']
          pattern: '^TR-[0-9A-HJKMNP-TV-Z]{7}$'
        offer_ref: { type: string, format: uuid }
        supplier: { type: string }
        supplier_sku: { type: string }
        qty_requested: { type: integer, minimum: 0 }
        candidates:
          type: array
          items: { $ref: '#/components/schemas/ProductResolveCandidate' }

    ProductResolveCandidate:
      type: object
      required: [canonical_ref, match_type, confidence, rank]
      properties:
        canonical_ref: { type: string, format: uuid }
        viewable_id:
          type: [string, 'null']
          pattern: '^TR-[0-9A-HJKMNP-TV-Z]{7}$'
        offer_ref: { type: string, format: uuid }
        supplier: { type: string }
        supplier_sku: { type: string }
        match_type:
          type: string
          enum: [exact_supplier_sku, exact_mpn, text_query, characteristic_match]
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        rank:
          type: integer
          minimum: 0

    ProductAnalogSearchRequest:
      type: object
      description: Target request for fuzzy analog matching; handler is not live yet.
      properties:
        source_product_ref:
          type: string
          description: Optional known product ref to find replacements for.
        desired_characteristics:
          type: array
          items: { $ref: '#/components/schemas/ProductResolveCharacteristic' }
        constraints:
          type: object
          additionalProperties: true
        limit:
          type: integer
          minimum: 1
          maximum: 100
          default: 20

    SearchFacet:
      type: object
      properties:
        field: { type: string }
        buckets:
          type: array
          items:
            type: object
            properties:
              value: { type: string }
              count: { type: integer }

    # ----- Offers / observations -------------------------------------------
    SupplierRef:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        role: { type: string, enum: [vendor, marketplace, manufacturer_direct, distributor] }
        trust_level: { type: string, enum: [origin, tier_1, tier_2, unverified] }

    SupplyChainTrace:
      type: object
      properties:
        depth: { type: integer, minimum: 0 }
        path:
          type: array
          items:
            type: object
            properties:
              supplier: { $ref: '#/components/schemas/SupplierRef' }
              role: { type: string, enum: [origin, reseller, warehouse_operator, marketplace_seller] }
              warehouse_ref: { type: [string, 'null'] }

    PriceSet:
      type: object
      description: |
        Набор цен по одной валюте (ADR-0025, §5.5). Все суммы —
        Money{value,precision,currency}. `on_request=true` означает,
        что цена доступна только по запросу (manufacturer не публикует).
        `validity_window` — опциональное окно действия цены; null = без
        срока.
      properties:
        net:        { anyOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }] }
        gross:      { anyOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }] }
        list_tarif: { anyOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }], description: "Прайс-лист (каталожная цена)." }
        retail_rec: { anyOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }], description: "Рекомендованная розничная цена." }
        vat_rate:   { type: [number, 'null'], format: float }
        on_request: { type: boolean, default: false }
        validity_window:
          oneOf:
            - type: 'null'
            - type: object
              required: [from]
              properties:
                from: { type: string, format: date-time }
                to:   { type: [string, 'null'], format: date-time }

    StockLine:
      type: object
      properties:
        warehouse_ref: { type: string }
        kind: { type: string, enum: [own_warehouse, third_party_logistics, transit, external_marketplace] }
        quantity: { type: number }
        available_at: { type: [string, 'null'], format: date-time }

    StockForecastLine:
      type: object
      properties:
        kind: { type: string, enum: [incoming, in_way, backorder] }
        quantity: { type: number }
        eta: { type: [string, 'null'], format: date-time }

    DeliveryTerm:
      type: object
      properties:
        lead_time_days: { type: [integer, 'null'] }
        production_term_days: { type: [integer, 'null'] }
        requires_specification: { type: boolean, default: false }
        source_warehouse_ref: { type: [string, 'null'] }

    Offer:
      type: object
      required: [id, supplier, supplier_sku, match_confidence]
      properties:
        id: { type: string, format: uuid }
        supplier: { $ref: '#/components/schemas/SupplierRef' }
        supplier_sku: { type: string }
        manufacturer_article: { type: [string, 'null'] }
        manufacturer_code: { type: [string, 'null'] }
        client_sku: { type: [string, 'null'] }
        canonical_product_id: { type: [string, 'null'], format: uuid }
        match_confidence: { $ref: '#/components/schemas/MatchConfidence' }
        characteristics_from_supplier:
          type: object
          additionalProperties: true
        media:
          type: array
          items: { $ref: '#/components/schemas/MediaAsset' }
        supply_chain_trace: { $ref: '#/components/schemas/SupplyChainTrace' }
        last_observation: { $ref: '#/components/schemas/OfferObservationSnapshot' }
        freshness: { $ref: '#/components/schemas/Freshness' }

    OfferObservationSnapshot:
      type: object
      properties:
        observed_at: { type: string, format: date-time }
        prices:
          type: object
          description: "Карта `ISO-4217 → PriceSet`."
          additionalProperties: { $ref: '#/components/schemas/PriceSet' }
        stock_current:
          type: array
          items: { $ref: '#/components/schemas/StockLine' }
        stock_forecast:
          type: array
          items: { $ref: '#/components/schemas/StockForecastLine' }
        delivery_term: { $ref: '#/components/schemas/DeliveryTerm' }

    OfferObservation:
      allOf:
        - $ref: '#/components/schemas/OfferObservationSnapshot'
        - type: object
          properties:
            id: { type: string, format: uuid }
            credential_label: { type: [string, 'null'] }
            seller_ref: { type: [string, 'null'] }

    # ----- Pricing ---------------------------------------------------------
    PricingRequest:
      type: object
      description: "Либо items[] (batch), либо все поля на верхнем уровне (single)."
      properties:
        offer_id: { type: [string, 'null'], format: uuid }
        canonical_id: { type: [string, 'null'], format: uuid }
        quantity: { type: integer, minimum: 1 }
        requested_currency: { type: string, default: RUB }
        base_price_kind: { $ref: '#/components/schemas/PriceKind' }
        delivery_zone: { type: string }
        delivery_address_id: { type: [string, 'null'], format: uuid }
        date: { type: [string, 'null'], format: date-time }
        preferred_sellers:
          type: array
          items: { type: string }
        blocked_sellers:
          type: array
          items: { type: string }
        items:
          type: array
          items: { $ref: '#/components/schemas/PricingRequestItem' }

    PricingRequestItem:
      type: object
      required: [quantity]
      properties:
        offer_id: { type: [string, 'null'], format: uuid }
        canonical_id: { type: [string, 'null'], format: uuid }
        quantity: { type: integer, minimum: 1 }

    PriceKind:
      type: string
      enum: [net, gross, list_tarif, retail_rec]

    ObservationSource:
      type: object
      properties:
        type: { type: string, enum: [customer_credential, group_shared, system_credential] }
        credential_label: { type: [string, 'null'] }
        observed_at: { type: string, format: date-time }
        fallback_reason:
          type: [string, 'null']
          enum:
            - customer_credential_failing
            - customer_credential_expired
            - customer_credential_revoked
            - no_customer_credential
            - system_credential_used

    PricingBreakdownLine:
      type: object
      properties:
        rule_id: { type: [string, 'null'] }
        kind: { type: string, enum: [base, percentage, tiers, fixed, formula, logistics, vat, custom_handler] }
        label: { type: string }
        amount: { type: number }

    PricingResultItem:
      type: object
      properties:
        offer_id: { type: [string, 'null'], format: uuid }
        canonical_id: { type: [string, 'null'], format: uuid }
        quantity: { type: integer }
        final_price: { type: number }
        base_price_kind: { $ref: '#/components/schemas/PriceKind' }
        currency: { type: string }
        pricing_mode: { type: string, enum: [fixed, on_request, unknown] }
        breakdown:
          type: array
          items: { $ref: '#/components/schemas/PricingBreakdownLine' }
        observation_source: { $ref: '#/components/schemas/ObservationSource' }
        freshness: { $ref: '#/components/schemas/Freshness' }

    PricingResponse:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PricingResultItem' }
        totals:
          type: object
          properties:
            final_price: { type: number }
            currency: { type: string }
            pricing_mode: { type: string, enum: [fixed, mixed, on_request_only] }

    # ----- Estimates -------------------------------------------------------
    EstimateStatus:
      type: string
      enum: [parsing, ready, optimizing, optimized, finalized, approved, stale, error]

    EstimateSourceFormat:
      type: string
      enum: [xlsx, csv, text, json]

    EstimateRequest:
      type: object
      properties:
        source_format: { $ref: '#/components/schemas/EstimateSourceFormat' }
        lines:
          type: array
          items: { $ref: '#/components/schemas/EstimateRawLine' }
        optimization: { $ref: '#/components/schemas/EstimateOptimizeRequest' }

    EstimateRawLine:
      type: object
      properties:
        raw_text: { type: string }
        parsed_mpn: { type: string }
        parsed_description: { type: string }
        quantity: { type: number }
        unit: { type: string }
        expected_price: { type: [number, 'null'] }
        notes: { type: string }

    EstimateOptimizeRequest:
      type: object
      properties:
        mode: { type: string, enum: [min_price, min_suppliers, min_lead_time, single_manufacturer], default: min_price }
        constraints:
          type: object
          additionalProperties: true

    EstimateSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        version: { type: integer }
        status: { $ref: '#/components/schemas/EstimateStatus' }
        total_price: { type: number }
        currency: { type: string }
        lines_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Estimate:
      allOf:
        - $ref: '#/components/schemas/EstimateSummary'
        - type: object
          properties:
            lines:
              type: array
              items: { $ref: '#/components/schemas/EstimateLine' }
            optimization_mode: { type: [string, 'null'] }
            freshness: { $ref: '#/components/schemas/Freshness' }

    EstimateVersion:
      type: object
      properties:
        version: { type: integer }
        created_at: { type: string, format: date-time }
        status: { $ref: '#/components/schemas/EstimateStatus' }
        reason: { type: string, description: "что привело к новой версии" }

    EstimateCandidate:
      type: object
      properties:
        canonical_product: { $ref: '#/components/schemas/Product' }
        match_confidence: { $ref: '#/components/schemas/MatchConfidence' }
        pricing_preview: { $ref: '#/components/schemas/PricingResultItem' }
        reason: { type: string }

    EstimateLine:
      type: object
      properties:
        id: { type: string, format: uuid }
        raw: { $ref: '#/components/schemas/EstimateRawLine' }
        selected_candidate: { $ref: '#/components/schemas/EstimateCandidate' }
        alternatives:
          type: array
          items: { $ref: '#/components/schemas/EstimateCandidate' }
        pricing: { $ref: '#/components/schemas/PricingResultItem' }
        explanation:
          type: object
          additionalProperties: true
        status: { type: string, enum: [parsed, candidates_ready, selected, priced, error] }
        error: { type: [string, 'null'] }

    EstimateResult:
      type: object
      properties:
        estimate_id: { type: string, format: uuid }
        version: { type: integer }
        total_price: { type: number }
        currency: { type: string }
        lines:
          type: array
          items: { $ref: '#/components/schemas/EstimateLine' }
        excluded_lines:
          type: array
          description: "Строки с pricing_mode=on_request, не попавшие в total"
          items: { $ref: '#/components/schemas/EstimateLine' }
        optimization_mode: { type: [string, 'null'] }
        freshness: { $ref: '#/components/schemas/Freshness' }

    # ----- Credentials -----------------------------------------------------
    CredentialStatus:
      type: string
      enum: [active, inactive, revoked]

    CredentialUsage:
      type: string
      enum: [customer_proposal, catalog_sync, price_refresh, stock_refresh, diagnostic_probe, order_placement]

    CredentialRateLimit:
      type: object
      properties:
        requests_per_minute: { type: integer, minimum: 0 }
        requests_per_day: { type: integer, minimum: 0 }
        burst_allowance: { type: integer, minimum: 0 }

    CreateCredentialRequest:
      type: object
      required: [supplier_ref, status, allowed_usages, secret_value, reason]
      properties:
        supplier_ref: { type: string }
        status: { $ref: '#/components/schemas/CredentialStatus' }
        allowed_usages:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/CredentialUsage' }
        valid_from: { type: [string, 'null'], format: date-time }
        valid_to: { type: [string, 'null'], format: date-time }
        rate_limit: { $ref: '#/components/schemas/CredentialRateLimit' }
        secret_value:
          type: string
          writeOnly: true
        reason: { type: string, minLength: 1 }

    CredentialSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        scope:
          type: string
          enum: [customer]
        owner_ref: { type: string, format: uuid }
        supplier_ref: { type: string }
        status: { $ref: '#/components/schemas/CredentialStatus' }
        allowed_usages:
          type: array
          items: { $ref: '#/components/schemas/CredentialUsage' }
        valid_from: { type: [string, 'null'], format: date-time }
        valid_to: { type: [string, 'null'], format: date-time }
        rate_limit: { $ref: '#/components/schemas/CredentialRateLimit' }
        version: { type: integer, format: int64 }

    CredentialListEnvelope:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CredentialSummary' }

    RotateCredentialSecretRequest:
      type: object
      required: [secret_value, reason]
      properties:
        secret_value:
          type: string
          writeOnly: true
        reason: { type: string, minLength: 1 }

    CredentialStatusTransitionRequest:
      type: object
      required: [status, reason]
      properties:
        status: { $ref: '#/components/schemas/CredentialStatus' }
        reason: { type: string, minLength: 1 }

    # ----- Enrichment ------------------------------------------------------
    EnrichmentJobRequest:
      type: object
      required: [type, target]
      properties:
        type:
          type: string
          enum:
            - discovery
            - refresh_observation
            - refresh_characteristics
            - rebuild_supply_chain
            - revalidate_credential
        target:
          type: object
          description: "Зависит от type: {offer_id}, {canonical_id}, {credential_id}, ..."
          additionalProperties: true
        priority: { type: string, enum: [high, normal, low], default: normal }

    EnrichmentJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        status: { type: string, enum: [queued, started, completed, failed, cancelled] }
        progress: { type: number, format: float }
        result:
          type: [object, 'null']
          additionalProperties: true
        error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }

    # ----- Plans / Subscription / Usage / Packages / Billing ---------------
    BillingPeriodKind:
      type: string
      enum: [monthly, yearly, custom]

    RateLimitWindow:
      type: string
      enum: [minute, hour, day, week, month, billing_period]

    PlanLimits:
      type: object
      description: "Снимок лимитов; 0 = unlimited, null = не применяется"
      properties:
        requests_per_minute: { type: [integer, 'null'] }
        requests_per_hour: { type: [integer, 'null'] }
        requests_per_day: { type: [integer, 'null'] }
        requests_per_week: { type: [integer, 'null'] }
        requests_per_billing_period: { type: [integer, 'null'] }
        data_bytes_per_billing_period: { type: [integer, 'null'], format: int64 }
        burst_capacity: { type: [integer, 'null'] }
        concurrent_requests: { type: [integer, 'null'] }
        websocket_connections: { type: [integer, 'null'] }
        per_endpoint_overrides:
          type: object
          additionalProperties:
            type: object
            properties:
              requests_per_minute: { type: [integer, 'null'] }
              requests_per_hour: { type: [integer, 'null'] }
              request_weight: { type: integer, default: 1 }

    PlanFeature:
      type: object
      properties:
        code:
          type: string
          description: "напр. `websocket`, `estimate_optimize`, `admin_view`, `priority_support`"
        label: { type: string }
        included: { type: boolean }

    OveragePolicy:
      type: object
      description: "Что происходит при исчерпании квоты"
      properties:
        mode:
          type: string
          enum: [block, prepaid_packages, postpaid_meter, downgrade_service]
        per_extra_request_price:
          oneOf:
            - { $ref: '#/components/schemas/Money' }
            - { type: 'null' }

    Plan:
      type: object
      required: [id, code, name, limits, scope]
      properties:
        id: { type: string, format: uuid }
        code: { type: string, example: pro }
        scope:
          type: string
          enum: [personal, organization]
          description: "personal — индивидуальная подписка (B2C или отдельный B2B); organization — shared pool на всех участников"
        name: { type: string }
        description: { type: string }
        visible: { type: boolean }
        tier_rank: { type: integer, description: "порядок отображения в сетке (free=0, enterprise=100)" }
        billing_period: { $ref: '#/components/schemas/BillingPeriodKind' }
        price: { $ref: '#/components/schemas/Money' }
        trial_days: { type: [integer, 'null'] }
        limits: { $ref: '#/components/schemas/PlanLimits' }
        features:
          type: array
          items: { $ref: '#/components/schemas/PlanFeature' }
        overage_policy: { $ref: '#/components/schemas/OveragePolicy' }
        eligible_for:
          type: array
          items: { type: string, enum: [individual, legal_entity] }
        tags:
          type: array
          items: { type: string }
        custom_contract_required: { type: boolean, default: false }
        seats:
          type: [object, 'null']
          description: "Только для scope=organization"
          properties:
            included: { type: integer, description: "Включено в базовую цену" }
            minimum: { type: integer }
            maximum: { type: [integer, 'null'] }
            extra_seat_price: { $ref: '#/components/schemas/Money' }
        default_member_cap:
          type: [object, 'null']
          description: "Дефолтный cap для новых участников (только scope=organization)"
          properties:
            limits: { $ref: '#/components/schemas/PlanLimits' }

    SubscriptionStatus:
      type: string
      enum: [trialing, active, past_due, paused, cancel_scheduled, cancelled, expired]

    Subscription:
      type: object
      properties:
        id: { type: string, format: uuid }
        scope:
          type: string
          enum: [personal, organization]
          description: "personal = B2C standalone; organization = view на подписку организации (лимиты из pool)"
        organization_id:
          type: [string, 'null']
          format: uuid
          description: "Заполнено, если scope=organization"
        plan_code: { type: string }
        plan_name: { type: string }
        status: { $ref: '#/components/schemas/SubscriptionStatus' }
        current_period_start: { type: string, format: date-time }
        current_period_end: { type: string, format: date-time }
        renews_at: { type: [string, 'null'], format: date-time }
        cancellation_scheduled_at: { type: [string, 'null'], format: date-time }
        auto_renew: { type: boolean }
        trial_ends_at: { type: [string, 'null'], format: date-time }
        limits: { $ref: '#/components/schemas/PlanLimits' }
        member_cap:
          type: [object, 'null']
          description: "При scope=organization — личный cap участника внутри org pool"
          properties:
            limits: { $ref: '#/components/schemas/PlanLimits' }
            set_by_member_user_id: { type: string, format: uuid }
        overage_policy: { $ref: '#/components/schemas/OveragePolicy' }
        packages_requests_remaining: { type: integer }
        pending_plan_change:
          type: [object, 'null']
          properties:
            plan_code: { type: string }
            effective_at: { type: string, format: date-time }

    SubscriptionChangeRequest:
      type: object
      required: [plan_code]
      properties:
        plan_code: { type: string }
        apply_at:
          type: string
          enum: [immediate, next_period]
          default: immediate
        payment_method_id: { type: [string, 'null'], format: uuid }
        promo_code: { type: [string, 'null'] }

    UsageWindow:
      type: object
      required: [window, requests_used]
      properties:
        window: { $ref: '#/components/schemas/RateLimitWindow' }
        window_start: { type: string, format: date-time }
        window_end: { type: string, format: date-time }
        requests_used: { type: integer }
        requests_limit: { type: [integer, 'null'] }
        requests_remaining: { type: [integer, 'null'] }
        data_bytes_used: { type: [integer, 'null'], format: int64 }
        data_bytes_limit: { type: [integer, 'null'], format: int64 }
        data_bytes_remaining: { type: [integer, 'null'], format: int64 }
        next_reset_at: { type: [string, 'null'], format: date-time }

    UsageSummary:
      type: object
      properties:
        plan_code: { type: string }
        subscription_status: { $ref: '#/components/schemas/SubscriptionStatus' }
        billing_period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
        windows:
          type: array
          items: { $ref: '#/components/schemas/UsageWindow' }
        packages_requests_remaining: { type: integer }
        top_endpoints:
          type: array
          description: "Top-N endpoints по трафику за текущий billing-период"
          items:
            type: object
            properties:
              endpoint: { type: string }
              requests: { type: integer }
              data_bytes: { type: integer, format: int64 }
        last_429_at: { type: [string, 'null'], format: date-time }

    UsageEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        request_id: { type: string }
        at: { type: string, format: date-time }
        method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] }
        endpoint: { type: string, description: "Шаблон пути, напр. `/v1/products/{id}`" }
        status_code: { type: integer }
        bytes_in: { type: integer, format: int64 }
        bytes_out: { type: integer, format: int64 }
        request_weight: { type: integer }
        plan_code: { type: string }
        quota_window_hit: { type: [string, 'null'], description: "Окно, которое сработало на 429 (`minute` / `hour` / ...)" }
        source:
          type: string
          enum: [plan_quota, package, free_tier]
          description: "Из какого пула списано"

    RequestPackage:
      type: object
      description: "Prepaid пакет запросов, который можно докупить поверх плана"
      properties:
        id: { type: string, format: uuid }
        code: { type: string, example: pack_100k }
        name: { type: string }
        description: { type: string }
        requests_quantity: { type: integer }
        price: { $ref: '#/components/schemas/Money' }
        validity_days: { type: integer, description: "Срок жизни пакета с момента покупки" }
        stackable: { type: boolean, default: true }
        eligible_plans:
          type: array
          items: { type: string }

    PackagePurchaseRequest:
      type: object
      required: [package_code]
      properties:
        package_code: { type: string }
        quantity: { type: integer, default: 1, minimum: 1 }
        payment_method_id: { type: [string, 'null'], format: uuid }
        return_url: { type: [string, 'null'], format: uri }

    PackagePurchaseResult:
      type: object
      properties:
        purchased_package_id: { type: string, format: uuid }
        invoice_id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending_payment, completed, failed]
        checkout_url: { type: [string, 'null'], format: uri }

    PurchasedPackage:
      type: object
      properties:
        id: { type: string, format: uuid }
        package_code: { type: string }
        requests_total: { type: integer }
        requests_remaining: { type: integer }
        purchased_at: { type: string, format: date-time }
        expires_at: { type: [string, 'null'], format: date-time }
        status:
          type: string
          enum: [pending_payment, active, expired, exhausted, refunded]

    InvoiceStatus:
      type: string
      enum: [draft, open, paid, void, uncollectible]

    InvoiceItem:
      type: object
      properties:
        description: { type: string }
        quantity: { type: number }
        unit_price: { $ref: '#/components/schemas/Money' }
        total: { $ref: '#/components/schemas/Money' }
        related_object:
          type: [object, 'null']
          description: "напр. {kind: subscription|package, ref: uuid}"
          additionalProperties: true

    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: string }
        status: { $ref: '#/components/schemas/InvoiceStatus' }
        issued_at: { type: string, format: date-time }
        due_at: { type: [string, 'null'], format: date-time }
        paid_at: { type: [string, 'null'], format: date-time }
        currency: { type: string }
        items:
          type: array
          items: { $ref: '#/components/schemas/InvoiceItem' }
        subtotal: { $ref: '#/components/schemas/Money' }
        tax: { $ref: '#/components/schemas/Money' }
        total: { $ref: '#/components/schemas/Money' }
        payment_url: { type: [string, 'null'], format: uri }
        pdf_url: { type: [string, 'null'], format: uri }

    PaymentMethod:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [card, bank_transfer, sbp, invoice] }
        display_name: { type: string }
        details:
          type: object
          description: "Безопасный snapshot (last4, exp_month, exp_year для card)"
          additionalProperties: true
        is_default: { type: boolean }
        added_at: { type: string, format: date-time }

    PaymentMethodAddRequest:
      type: object
      required: [kind]
      properties:
        kind: { type: string, enum: [card, bank_transfer, sbp, invoice] }
        return_url: { type: string, format: uri }

    PaymentMethodAddResult:
      type: object
      properties:
        payment_method_id: { type: string, format: uuid }
        redirect_url: { type: [string, 'null'], format: uri, description: "3DS / tokenization redirect" }
        status: { type: string, enum: [pending, active, failed] }

    # ----- Error subtypes --------------------------------------------------
    RateLimitError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            window: { $ref: '#/components/schemas/RateLimitWindow' }
            scope:
              type: string
              enum: [personal_rate, personal_quota, org_pool, member_cap, concurrency]
              description: "Какой счётчик исчерпан"
            organization_id: { type: [string, 'null'], format: uuid }
            limit: { type: integer }
            retry_after_seconds: { type: integer }
            reset_at: { type: string, format: date-time }
            packages_purchase_url:
              type: [string, 'null']
              format: uri
              description: "Ссылка на покупку пакета (personal или org), только при *_exhausted"
            contact_admin:
              type: boolean
              description: "true при member_cap_exceeded — нужно обратиться к org owner/admin"
      description: |
        code:
          * `rate_limited` — сработал короткий лимит (minute/hour/day/week).
          * `quota_exhausted` — исчерпана billing-квота personal-плана.
          * `org_pool_exhausted` — исчерпан пул организации.
          * `member_cap_exceeded` — личный cap участника внутри org pool.
          * `concurrent_limit` — превышено число одновременных запросов.

    # ----- Organizations ---------------------------------------------------
    OrganizationRole:
      type: string
      enum: [owner, admin, member, viewer]

    OrganizationMemberStatus:
      type: string
      enum: [active, invited, suspended, left]

    OrganizationSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        my_role: { $ref: '#/components/schemas/OrganizationRole' }
        member_status: { $ref: '#/components/schemas/OrganizationMemberStatus' }
        plan_code: { type: [string, 'null'] }
        is_primary: { type: boolean }

    Organization:
      allOf:
        - $ref: '#/components/schemas/OrganizationSummary'
        - type: object
          properties:
            legal_info: { $ref: '#/components/schemas/LegalInfo' }
            billing_email: { type: [string, 'null'], format: email }
            default_payment_method_id: { type: [string, 'null'], format: uuid }
            members_count: { type: integer }
            seats_used: { type: integer }
            seats_limit: { type: [integer, 'null'] }
            created_at: { type: string, format: date-time }

    OrganizationCreate:
      type: object
      required: [name, legal_info]
      properties:
        name: { type: string }
        legal_info: { $ref: '#/components/schemas/LegalInfoUpdate' }
        billing_email: { type: string, format: email }
        initial_plan_code: { type: [string, 'null'] }

    OrganizationPatch:
      type: object
      properties:
        name: { type: string }
        billing_email: { type: string, format: email }
        default_payment_method_id: { type: string, format: uuid }

    MemberLimitsCap:
      type: object
      description: |
        Личный cap участника внутри org pool. null в любом поле = нет cap,
        действует org pool. 0 = запрет запросов этого класса.
      properties:
        limits: { $ref: '#/components/schemas/PlanLimits' }
        allowed_endpoints:
          type: [array, 'null']
          items: { type: string }
          description: "whitelist endpoint patterns; null = все endpoint'ы плана"
        blocked_endpoints:
          type: array
          items: { type: string }

    OrganizationMember:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        display_name: { type: string }
        email: { type: string, format: email }
        role: { $ref: '#/components/schemas/OrganizationRole' }
        status: { $ref: '#/components/schemas/OrganizationMemberStatus' }
        joined_at: { type: [string, 'null'], format: date-time }
        invited_at: { type: [string, 'null'], format: date-time }
        personal_cap: { $ref: '#/components/schemas/MemberLimitsCap' }
        usage_current_period:
          type: object
          properties:
            requests: { type: integer }
            data_bytes: { type: integer, format: int64 }

    OrganizationMemberPatch:
      type: object
      properties:
        role: { $ref: '#/components/schemas/OrganizationRole' }
        status: { $ref: '#/components/schemas/OrganizationMemberStatus' }

    OrganizationMembership:
      type: object
      description: "Как клиент входит в конкретную организацию"
      properties:
        organization_id: { type: string, format: uuid }
        organization_name: { type: string }
        role: { $ref: '#/components/schemas/OrganizationRole' }
        status: { $ref: '#/components/schemas/OrganizationMemberStatus' }
        is_primary: { type: boolean }
        personal_cap: { $ref: '#/components/schemas/MemberLimitsCap' }
        joined_at: { type: [string, 'null'], format: date-time }

    OrganizationInvitation:
      type: object
      properties:
        id: { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        role: { $ref: '#/components/schemas/OrganizationRole' }
        invited_by_user_id: { type: string, format: uuid }
        personal_cap: { $ref: '#/components/schemas/MemberLimitsCap' }
        status: { type: string, enum: [pending, accepted, revoked, expired] }
        expires_at: { type: string, format: date-time }
        accept_url: { type: [string, 'null'], format: uri }

    OrganizationInvitationCreate:
      type: object
      required: [role]
      properties:
        email: { type: string, format: email }
        phone: { type: string }
        role: { $ref: '#/components/schemas/OrganizationRole' }
        personal_cap: { $ref: '#/components/schemas/MemberLimitsCap' }
        note: { type: string }

    OrganizationSubscription:
      allOf:
        - $ref: '#/components/schemas/Subscription'
        - type: object
          properties:
            seats_included: { type: integer }
            seats_used: { type: integer }
            seats_limit: { type: [integer, 'null'] }
            extra_seat_price: { $ref: '#/components/schemas/Money' }
            default_member_cap: { $ref: '#/components/schemas/MemberLimitsCap' }
            pool_usage_current_period:
              type: object
              properties:
                requests_used: { type: integer }
                requests_limit: { type: [integer, 'null'] }
                requests_remaining: { type: [integer, 'null'] }
                data_bytes_used: { type: integer, format: int64 }
                data_bytes_limit: { type: [integer, 'null'], format: int64 }
                data_bytes_remaining: { type: [integer, 'null'], format: int64 }

    OrganizationSubscriptionChangeRequest:
      type: object
      required: [plan_code]
      properties:
        plan_code: { type: string, description: "Должен быть scope=organization" }
        apply_at: { type: string, enum: [immediate, next_period], default: immediate }
        seats: { type: [integer, 'null'] }
        payment_method_id: { type: [string, 'null'], format: uuid }
        promo_code: { type: [string, 'null'] }

    SeatsAdjustmentRequest:
      type: object
      required: [target_seats]
      properties:
        target_seats: { type: integer, minimum: 1 }
        reason: { type: string }

    OrganizationUsageMemberBreakdown:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        display_name: { type: string }
        requests: { type: integer }
        data_bytes: { type: integer, format: int64 }
        cap: { $ref: '#/components/schemas/MemberLimitsCap' }
        cap_hit_count: { type: integer }

    OrganizationUsageSummary:
      type: object
      properties:
        organization_id: { type: string, format: uuid }
        plan_code: { type: string }
        billing_period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
        pool:
          type: array
          items: { $ref: '#/components/schemas/UsageWindow' }
        seats_used: { type: integer }
        seats_limit: { type: [integer, 'null'] }
        members:
          type: array
          items: { $ref: '#/components/schemas/OrganizationUsageMemberBreakdown' }
        packages_requests_remaining: { type: integer }
        pool_hit_count: { type: integer, description: "Сколько раз сработал org_pool_exhausted" }

    PaymentRequiredError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            required_plan: { type: [string, 'null'] }
            current_plan: { type: [string, 'null'] }
            required_plan_scope: { type: [string, 'null'], enum: [personal, organization, null] }
            organization_id: { type: [string, 'null'], format: uuid }
            unpaid_invoice_id: { type: [string, 'null'], format: uuid }
            seats_used: { type: [integer, 'null'] }
            seats_limit: { type: [integer, 'null'] }
            checkout_url: { type: [string, 'null'], format: uri }
      description: |
        code:
          * `plan_required` — endpoint доступен только с определённого плана.
          * `plan_upgrade_required` — превышена граница фичи плана.
          * `org_plan_required` — endpoint требует scope=organization, клиент
            в personal scope.
          * `seat_limit_exceeded` — попытка пригласить участника сверх
            included_seats без апгрейда / seats adjustment.
          * `invoice_unpaid` — неоплаченный счёт блокирует доступ.

    # -------------------------------------------------------------------------
    # Proposal Pipeline schemas (§11)
    # -------------------------------------------------------------------------

    EstimateProposalRequest:
      type: object
      required: [schema_version, lines]
      description: |
        API-first формат сметы. Это не формат Excel: любой табличный или ERP
        источник предварительно преобразуется в этот JSON. Характеристики
        передаются уже в нормализованном виде, с единицами СИ.
      properties:
        schema_version:
          type: string
          enum: [estimate-request.v1]
        request_id:
          type: string
          description: Идентификатор сметы на стороне клиента; возвращается как estimate_id.
        mode:
          $ref: '#/components/schemas/SearchMode'
          default: reference
        target_currency:
          type: string
          pattern: '^[A-Z]{3}$'
          default: RUB
        strategy:
          type: object
          description: |
            Задел для прикладного ранжирования: cheapest, fastest_delivery,
            same_manufacturer, balanced. В первой версии мапится на policy
            proposal pipeline без отдельной долговременной сущности.

            Дополнительный resolver mode включается явно:
            `{"resolve_mode":"llm_assisted"}`. В этом режиме сервер после
            обычного deterministic resolve отправляет строки со статусом
            `needs_review` и непустым `resolve_candidates[]` в LLM gateway
            CLIProxy. Модель может выбрать только candidate.rank из входного
            списка; при confidence ниже порога строка остаётся `needs_review`.
          additionalProperties: true
        lines:
          type: array
          minItems: 1
          maxItems: 250
          items:
            $ref: '#/components/schemas/EstimateProposalLine'

    EstimateProposalLine:
      type: object
      required: [qty_requested]
      properties:
        id:
          type: string
          description: Стабильный ID строки сметы; если не задан, сервер присвоит line-N.
        position:
          type: string
          description: Номер позиции в исходной смете.
        group:
          type: string
          description: Раздел/щит/узел сметы.
        name:
          type: string
          description: Нормализованное название позиции, если клиент уже отделил его от raw_text.
        raw_text:
          type: string
          description: Исходная строка позиции; используется как fallback для text resolver.
        qty_requested:
          oneOf:
            - { type: string }
            - { type: number }
          description: |
            Количество из сметы. Текущий proposal pipeline принимает целые
            количества, поэтому estimate endpoint явно возвращает `proposal_qty`
            — округление вверх до целого для текущего pipeline.
        uom:
          type: string
          description: Единица строки сметы (`шт`, `м`, `комплект`). Это не характеристика и не обязана быть СИ.
        brand:
          type: string
          description: Подсказка производителя/бренда для resolver.
        identifiers:
          $ref: '#/components/schemas/EstimateLineIdentifiers'
        characteristics:
          type: array
          description: |
            Характеристики позиции. Единицы в `unit` должны быть СИ и уже
            приведены к canonical масштабу. Не передавать `мм`, `кВ`, `А`
            кириллицей: нужно `m`, `V`, `A` с пересчитанным числом.
          items:
            $ref: '#/components/schemas/ProductResolveCharacteristic'

    EstimateLineIdentifiers:
      type: object
      properties:
        supplier: { type: string, description: "Код поставщика, если строка уже содержит supplier SKU." }
        supplier_sku: { type: string, description: "Артикул поставщика для exact resolver." }
        mpn: { type: string, description: "Manufacturer part number / внешний артикул производителя." }
        manufacturer_article: { type: string, description: "Артикул производителя, если отделён от MPN." }
        barcode: { type: string, description: "GTIN/EAN/штрихкод; первая версия использует как сильный selector." }

    EstimateProposalResponse:
      type: object
      required: [status, summary, lines]
      properties:
        estimate_id: { type: string }
        search_id:
          type: string
          format: uuid
          description: UUID запуска proposal pipeline, если были resolved строки.
        status:
          type: string
          enum: [completed, completed_with_gaps]
        summary:
          $ref: '#/components/schemas/EstimateProposalSummary'
        lines:
          type: array
          items:
            $ref: '#/components/schemas/EstimateProposalLineResult'

    EstimateProposalSummary:
      type: object
      properties:
        lines_total: { type: integer }
        resolved: { type: integer }
        with_proposals: { type: integer }
        needs_review: { type: integer }

    EstimateProposalLineResult:
      type: object
      properties:
        id: { type: string }
        position: { type: string }
        group: { type: string }
        raw_text: { type: string }
        qty_requested: { type: string }
        proposal_qty:
          type: integer
          format: int64
          description: Количество, переданное во внутренний proposal pipeline.
        uom: { type: string }
        status:
          type: string
          enum: [resolved, needs_review]
        reason:
          type: string
          description: Машиночитаемая причина ручной проверки.
        selected:
          $ref: '#/components/schemas/EstimateSelectedProduct'
        proposal:
          $ref: '#/components/schemas/Proposal'
        alternatives:
          type: array
          items:
            $ref: '#/components/schemas/Proposal'
        resolve_candidates:
          type: array
          items:
            $ref: '#/components/schemas/ProductResolveCandidate'

    EstimateSelectedProduct:
      type: object
      properties:
        product_ref: { type: string, description: "viewable_id или canonical UUID для следующего API шага." }
        canonical_ref: { type: string, format: uuid }
        offer_ref: { type: string, format: uuid }
        supplier: { type: string }
        supplier_sku: { type: string }
        confidence: { type: number, format: float }
        match_type:
          type: string
          enum: [exact_supplier_sku, exact_mpn, text_query, characteristic_match, llm_assisted]

    ProposalRequest:
      type: object
      required: [mode, target_currency, items]
      description: |
        Тело запроса на сборку сметы. `mode` задаёт пресет политики
        pipeline'а (см. SearchMode), `target_currency` — валюту, в которой
        вернуть цены, `items[]` — собственно строки сметы (от 1 до 50).
        `policy` точечно переопределяет отдельные поля пресета.
      properties:
        mode:
          $ref: '#/components/schemas/SearchMode'
        target_currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: Код валюты ISO-4217, верхним регистром.
          example: RUB
        items:
          type: array
          minItems: 1
          maxItems: 50
          items:
            $ref: '#/components/schemas/QueryItem'
        policy:
          $ref: '#/components/schemas/PolicyOverrides'
        region:
          type: object
          description: Регион клиента (используется для расчёта НДС, доставки, доступности оффера).
          properties:
            code: { type: string, example: RU }

    SearchMode:
      type: string
      enum: [live, reference, sourcing, preview]
      description: |
        Пресет политики pipeline'а, разворачивается в базовый RequestPolicy.

        * `live` — реальные данные о цене и наличии, строгая свежесть.
        * `reference` — допускает discontinued, stale, on-request офферы.
        * `sourcing` — как `reference`, но шире: явно допускает
          discontinued.
        * `preview` — ориентировочная цена, отсутствие наличия не блокирует.

    QueryItem:
      type: object
      required: [product_ref, qty_requested]
      description: Одна строка сметы.
      properties:
        id:
          type: string
          description: |
            Стабильный идентификатор позиции на стороне клиента; пробрасывается
            в ответ как `query_item_id`. Если не задан, присваивается
            автоматически в виде `item-N` (0-based индекс). Не может
            начинаться с `__` (зарезервированный префикс).
          example: sku-main
        product_ref:
          type: string
          description: Идентификатор товара — viewable_id (`TR-XXXXXXX`) или canonical UUID. Supplier SKU здесь не принимается.
          example: TR-ABC1234
        qty_requested:
          type: integer
          format: int64
          minimum: 1
          description: Запрошенное количество единиц.

    PolicyOverrides:
      type: object
      description: |
        Точечные переопределения политики поверх пресета `mode`. Любое поле
        опционально; то, что не задано, наследуется из пресета.
      properties:
        pricing:
          type: object
          properties:
            allow_stale_last_known:
              type: boolean
              default: false
              description: Допускать использование последней известной цены, если свежей нет.
        refresh_mode:
          type: string
          enum: [cached_only, live]
          description: |
            Режим обращения к данным.

            * `cached_only` — pipeline читает только кэш, никаких live-вызовов
              к поставщикам (значение по умолчанию для всех пресетов, кроме
              `mode=live`).
            * `live` — для каждого кандидата pipeline пытается сделать
              live-вызов через customer credential. При неудаче (нет
              credential, исчерпан бюджет, ошибка коннектора) автоматически
              откатывается на cached с явной trace-меткой, видимой клиенту.
        trace:
          type: object
          properties:
            inline_level:
              $ref: '#/components/schemas/TraceInlineLevel'

    TraceInlineLevel:
      type: string
      enum: [off, summary, raw]
      description: |
        Управляет объёмом trace, который pipeline вернёт встроенным в ответ.

        * `off` — trace не включается.
        * `summary` — редактированный trace (только safe-причины, без
          секретов поставщиков; правила в §8.2).
        * `raw` — полный trace; требует admin scope.

    ProposalResponse:
      type: object
      required: [search_id, items_echo, proposals, proposals_by_item, items_outcome]
      description: |
        Ответ proposal pipeline'а. Читается так: `items_outcome[]` показывает,
        по каким строкам сметы предложения собрались, а по каким нет;
        `proposals_by_item` — карта `query_item_id → массив 0-based индексов в
        proposals[]`; `proposals[i]` — само предложение (цена, наличие,
        доставка, обоснование); `trace` опциональный, контролируется
        `policy.trace.inline_level` запроса.
      properties:
        search_id:
          type: string
          format: uuid
          description: UUID конкретного запуска поиска. Дублируется в заголовке X-Search-Id и в trace для корреляции.
        items_echo:
          type: array
          description: Эхо-список строк сметы для облегчения сопоставления запроса и ответа.
          items:
            $ref: '#/components/schemas/ItemEcho'
        proposals:
          type: array
          description: Плоский список предложений по всем строкам сметы. Связь со строкой — через `query_item_id` или индексы в `proposals_by_item`.
          items:
            $ref: '#/components/schemas/Proposal'
        proposals_by_item:
          $ref: '#/components/schemas/ProposalsByItem'
        items_outcome:
          type: array
          description: По одной записи на каждую строку сметы. `status=ok` — предложения собрались; `status=no_results` — pipeline не нашёл подходящих офферов.
          items:
            $ref: '#/components/schemas/ItemsOutcome'
        trace:
          $ref: '#/components/schemas/Trace'

    ItemEcho:
      type: object
      description: Эхо одной строки сметы, как её увидел pipeline после нормализации.
      properties:
        query_item_id: { type: string }
        product_ref: { type: string }
        qty_requested: { type: integer, format: int64 }

    Proposal:
      type: object
      description: |
        Одно предложение по конкретной строке сметы. Содержит ссылки на
        задействованные сущности (canonical product, оффер, поставщик,
        продавец), финальную цену с разбивкой adjustments, наличие, доставку
        и метаданные ранжирования.
      properties:
        candidate_id:
          type: string
          description: Детерминированный идентификатор кандидата (offer × credential × seller × строка сметы).
        query_item_id: { type: string, description: "Идентификатор строки сметы из запроса (см. QueryItem.id)." }
        offer_ref: { type: string, description: "UUID оффера поставщика." }
        product_ref: { type: string, description: "Эхо `product_ref` из запроса." }
        canonical_ref: { type: string, format: uuid, description: "UUID canonical product, к которому привязан оффер." }
        supplier_ref: { type: string, description: "Идентификатор поставщика." }
        seller_ref: { type: string, description: "Идентификатор продавца на marketplace; отсутствует для не-marketplace поставщиков." }
        lifecycle:
          $ref: '#/components/schemas/LifecycleFlag'
        price:
          $ref: '#/components/schemas/ProposalPrice'
        exchange_rate:
          $ref: '#/components/schemas/ExchangeRate'
        stock:
          $ref: '#/components/schemas/StockSummary'
        delivery:
          $ref: '#/components/schemas/DeliverySummary'
        adjustments:
          type: array
          description: Упорядоченная разбивка трансформаций цены на слое 3b (НДС, наценка, скидка, custom-handler).
          items:
            type: object
            required: [kind, before_amount, after_amount]
            properties:
              kind:
                type: string
                enum: [vat, markup, discount, custom_handler]
                description: Тип трансформации.
              before_amount:
                type: integer
                format: int64
                description: Сумма (в minor units) на входе в шаг.
              after_amount:
                type: integer
                format: int64
                description: Сумма (в minor units) на выходе из шага.
              detail:
                type: object
                additionalProperties: true
                description: Параметры конкретного шага (например, ставка для НДС, rule_id для наценки/скидки).
        score:
          type: number
          format: double
          description: Релевантность предложения для ранжирования. На текущей фазе (P5a) заполняется заглушкой 1.0; реальный сигнал появится в P6/P7.
        position: { type: integer, description: "Позиция в ранжировании по этой строке сметы (0 — лучшее)." }
        source_mode:
          type: string
          enum: [cached_actual, cached_estimate, live]
          description: |
            Источник данных предложения.

            * `cached_actual` — точное последнее наблюдение из БД.
            * `cached_estimate` — производная статистика; в этом режиме у
              цены заполнено `price.amount_range`.
            * `live` — данные получены прямым вызовом коннектора через
              customer credential.

    ProposalPrice:
      type: object
      required: [mode]
      description: Финальная цена предложения с разбивкой по слоям расчёта.
      properties:
        mode:
          $ref: '#/components/schemas/PricingMode'
        amount:
          type: object
          description: Денежная сумма во внутреннем представлении money.Amount.
          properties:
            value: { type: string, description: "Целое число minor units, упакованное в строку (для безопасной передачи через JSON)." }
            currency: { type: string, pattern: '^[A-Z]{3}$', description: "Код валюты ISO-4217." }
            precision: { type: integer, minimum: 0, maximum: 18, description: "Количество знаков после запятой (для рублей и копеек — 2)." }
        base_amount:
          type: integer
          format: int64
          nullable: true
          description: Сумма (в minor units) на входе в слой 3b после конвертации валюты на 3a. `null`, если adjustments не применялись.
        precision:
          type: integer
          nullable: true
          description: Precision у `base_amount` (обычно 2). `null`, если adjustments не применялись.
        note: { type: string, description: "Свободный комментарий pricing engine'а (например, причина `on_request`)." }
        amount_range:
          type: object
          nullable: true
          required: [min, max, currency, precision]
          description: |
            Диапазон цен в target currency для случая `source_mode=cached_estimate`.
            Adjustments (наценка, скидка, НДС) применяются к обеим границам
            (Live-D §4). Диапазон схлопывается в `null`, если применилось
            немонотонное правило (на сегодня все правила Tracium монотонны).
            Всегда `null` для `cached_actual` или `live`.
          properties:
            min:
              type: integer
              format: int64
              description: Нижняя граница диапазона (minor units).
            max:
              type: integer
              format: int64
              description: Верхняя граница диапазона (minor units).
            currency:
              type: string
              minLength: 3
              maxLength: 3
              description: Код валюты ISO-4217.
            precision:
              type: integer
              description: Precision у границ (для рублей — 2).

    PricingMode:
      type: string
      enum: [fixed, ballpark, stale_last_known, on_request, unavailable]
      description: |
        Режим, в котором рассчитана цена.

        * `fixed` — точная цена.
        * `ballpark` — ориентировочная.
        * `stale_last_known` — последняя известная (свежей нет).
        * `on_request` — цена по запросу у поставщика.
        * `unavailable` — цена не определена.

    LifecycleFlag:
      type: string
      enum: [active, discontinued, archived, draft]
      description: Состояние товара/оффера в жизненном цикле.

    ExchangeRate:
      type: object
      description: Курс конвертации валюты, применённый к цене (если источник и target_currency различаются).
      properties:
        from: { type: string, description: "Исходная валюта (ISO-4217)." }
        to: { type: string, description: "Целевая валюта (ISO-4217)." }
        multiplier: { type: number, format: double, description: "Коэффициент пересчёта from→to." }
        source: { type: string, enum: [supplier, cbr, custom_feed, identity], description: "Источник курса: поставщик, ЦБ РФ, кастомный фид, либо identity (1:1, без конвертации)." }
        at: { type: string, format: date-time, description: "Момент времени, на который зафиксирован курс." }

    StockSummary:
      type: object
      description: Сводка по наличию для предложения.
      properties:
        available_qty: { type: integer, format: int64, description: "Доступное количество единиц." }
        status: { type: string, enum: [ok, partial, rejected], description: "Сводный статус: полное покрытие qty_requested, частичное либо отказ." }
        source_mode:
          type: string
          enum: [cached_actual, live]
          description: |
            Источник данных наличия. `cached_actual` — из базы наблюдений;
            `live` — прямой вызов коннектора через customer credential. Поле
            может отсутствовать (omitempty) у записей, созданных до Live-C.
        live_attempt_reason:
          type: string
          description: |
            Причина исхода tier-0 live-попытки при `RefreshMode=live`. Одно из:
            `live_used`, `live_unavailable_no_credential`,
            `live_budget_exhausted`, `live_connector_failed`,
            `live_axis_unsupported`, `live_customer_scope_mismatch`,
            `live_dedup_hit`. Пустая строка, если live-попытка не делалась
            (`RefreshMode=cached_only`).

    DeliverySummary:
      type: object
      description: Сводка по доставке для предложения.
      properties:
        lead_time_min_hours: { type: integer, format: int64, description: "Минимальный срок поставки в часах." }
        lead_time_max_hours: { type: integer, format: int64, description: "Максимальный срок поставки в часах." }
        carrier: { type: string, description: "Перевозчик / способ доставки (если указан поставщиком)." }
        status: { type: string, enum: [ok, partial, rejected], description: "Сводный статус доступности доставки." }
        source_mode:
          type: string
          enum: [cached_actual, live]
          description: |
            Источник данных доставки. `cached_actual` — из базы наблюдений;
            `live` — прямой вызов коннектора через customer credential. Поле
            может отсутствовать у записей, созданных до Live-C.
        live_attempt_reason:
          type: string
          description: |
            Причина исхода tier-0 live-попытки при `RefreshMode=live` (значения
            те же, что у `StockSummary.live_attempt_reason`). Пустая строка,
            если live-попытка не делалась.

    Score:
      type: object
      description: Числовой скор предложения для ранжирования.
      properties:
        value: { type: number, format: double }

    ProposalsByItem:
      type: object
      description: Карта `query_item_id → массив 0-based индексов в `proposals[]``. Связывает строки сметы с собранными по ним предложениями.
      additionalProperties:
        type: array
        items: { type: integer }

    ItemsOutcome:
      type: object
      description: Сводный исход pipeline по одной строке сметы.
      properties:
        query_item_id: { type: string }
        proposal_count: { type: integer, description: "Сколько предложений собралось." }
        status: { type: string, enum: [ok, no_results], description: "`ok` — есть предложения; `no_results` — pipeline не нашёл подходящих." }

    Trace:
      type: object
      description: Полный или редактированный trace pipeline'а; включается через `policy.trace.inline_level`.
      properties:
        search_id: { type: string, format: uuid }
        entries:
          type: array
          items:
            $ref: '#/components/schemas/TraceEntry'

    TraceEntry:
      type: object
      properties:
        layer: { type: string }
        is_aggregate: { type: boolean }
        query_item_id: { type: string }
        status:
          $ref: '#/components/schemas/TraceStatus'
        reason:
          $ref: '#/components/schemas/TraceReason'
        at: { type: string, format: date-time }

    TraceStatus:
      type: string
      enum: [ok, partial, rejected, policy_downgrade]

    TraceReason:
      type: string
      description: Закрытый enum; расширения вводятся только через ADR.
      enum:
        - product_discontinued
        - visibility_blocked
        - visibility_transform_applied
        - no_supplier_for_item
        - supplier_blocklist_by_policy
        - seller_blocklist
        - no_credential
        - constraint_mismatch
        - multi_customer_credentials_detected
        - stock_insufficient
        - stock_stale
        - price_missing
        - price_on_request
        - price_stale_last_known
        - delivery_unknown
        - exchange_rate_missing
        - policy_downgrade_attempted
        - filtered

    ValidationErrors:
      type: object
      required: [errors]
      properties:
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ValidationError'

    ValidationError:
      type: object
      required: [code]
      properties:
        code:
          type: string
          enum:
            - invalid_mode
            - invalid_target_currency
            - items_out_of_bounds
            - invalid_product_ref
            - invalid_qty
            - duplicate_item_id
            - reserved_id_prefix
            - ambiguous_duplicate_request
            - invalid_trace_inline_level
            - body_read_error
            - body_parse_error
        field: { type: string }
        index: { type: integer, description: 0-based item index; -1 when not item-scoped }
