# Tracium Admin API — AS-SHIPPED contract (OpenAPI 3.1, strictly linted).
#
# Источник истины о реально работающих admin endpoints. CI job
# `backend-openapilint` строго сверяет этот файл с `mux.HandleFunc`
# регистрациями в core HTTP handlers и platform job-history registrar —
# любой дрейф (лишняя запись в спеке или в коде) роняет пайплайн.
#
# Полный целевой контракт (customers, organizations, identity-profiles,
# enrichment, moderation, plans, suppliers, offers, credentials,
# relationships, connectors, ...) живёт рядом в
# `admin-api-target.openapi.yaml` и линтером не проверяется.
#
# Когда BC переходит из roadmap в код:
#   1. Реализовать handler под `/api/v1/admin/<bc>/...`.
#   2. Скопировать соответствующие операции из target в этот файл,
#      приведя method+path к тому, что возвращает `mux.HandleFunc`.
#   3. Убрать запись из target (чтобы не плодить дубль).
#   4. Прогнать `make backend-openapilint` локально до exit 0.

openapi: 3.1.0
info:
  title: Tracium Admin API (as-shipped)
  version: 0.6.0
  summary: Админский API Tracium — реально реализованные endpoints
  description: |
    Основная авторизация — Bearer JWT (shared/auth). Platform job-history
    side channel временно использует Bearer `ADMIN_API_KEY` и явно помечен
    security scheme `adminApiKeyBearer`.

    Все canonical маршруты живут под `/api/v1/admin/...`; базовый путь
    серверов уже включает `/v1/admin`, а lint normalizer дополнительно
    срезает BC-сегмент (`catalog`, `ingestion`, ...), поэтому в этом файле
    фигурирует только хвост (`/units`, `/canonical-products/...`, `/jobs/...`).
  contact:
    name: Tracium API Team
servers:
  - url: https://admin.tracium.ru/v1/admin
    description: Production
  - url: https://admin.staging.tracium.ru/v1/admin
    description: Staging
  - url: https://api.tracium.dev/v1/admin
    description: Local dev

security:
  - bearerAuth: []

tags:
  - name: catalog-units
    description: Справочник единиц измерения
  - name: catalog-manufacturers
    description: Справочник производителей (+ алиасы)
  - name: catalog-characteristics
    description: Справочник характеристик
  - name: catalog-canonical-products
    description: Канонические товары (CRUD, характеристики, статус)
  - name: catalog-categories
    description: Категории товаров (read-only list)
  - name: offers
    description: SupplierOffer-проекция (read-only в iter-1)
  - name: discovery-visibility
    description: Правила видимости поставщиков (Visibility Policies)
  - name: supplier-network-sellers
    description: Sellers (marketplace агрегаторы — P4b)
  - name: credentials
    description: Admin управление учётными данными поставщиков (P4c-2)
  - name: customer-api-keys
    description: Admin управление API ключами клиентов (D14 — создаются через тот же CreateService)
  - name: ingestion-jobs
    description: "Live observability supplier ingestion jobs: состояние, события, trace, ошибки и slow stages"
  - name: quality
    description: Метрики качества данных каталога (нормализация, сбор, категоризация, per-supplier breakdown)

paths:
  # ---------------------------------------------------------------------------
  # catalog/units
  # ---------------------------------------------------------------------------
  /units:
    post:
      tags: [catalog-units]
      operationId: createUnit
      summary: Создать unit
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateUnitRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Unit' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [catalog-units]
      operationId: listUnits
      summary: Список units (с поиском и пагинацией)
      parameters:
        - { $ref: '#/components/parameters/Search' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UnitList' }

  /units/{id}:
    get:
      tags: [catalog-units]
      operationId: getUnit
      summary: Получить unit по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Unit' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-units]
      operationId: deleteUnit
      summary: Удалить unit
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # catalog/manufacturers
  # ---------------------------------------------------------------------------
  /manufacturers:
    post:
      tags: [catalog-manufacturers]
      operationId: createManufacturer
      summary: Создать производителя
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateManufacturerRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Manufacturer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [catalog-manufacturers]
      operationId: listManufacturers
      summary: Список производителей
      parameters:
        - { $ref: '#/components/parameters/Search' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManufacturerList' }

  /manufacturers/{id}:
    get:
      tags: [catalog-manufacturers]
      operationId: getManufacturer
      summary: Получить производителя по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Manufacturer' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [catalog-manufacturers]
      operationId: updateManufacturer
      summary: Частичное обновление производителя
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateManufacturerRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Manufacturer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-manufacturers]
      operationId: deleteManufacturer
      summary: Удалить производителя
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  /manufacturers/{id}/aliases:
    post:
      tags: [catalog-manufacturers]
      operationId: addManufacturerAlias
      summary: Добавить алиас производителю
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AliasRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Manufacturer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-manufacturers]
      operationId: removeManufacturerAlias
      summary: Удалить алиас производителя
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AliasRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Manufacturer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # catalog/characteristics
  # ---------------------------------------------------------------------------
  /characteristics:
    post:
      tags: [catalog-characteristics]
      operationId: createCharacteristic
      summary: Создать характеристику
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCharacteristicRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Characteristic' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [catalog-characteristics]
      operationId: listCharacteristics
      summary: Список характеристик
      parameters:
        - { $ref: '#/components/parameters/Search' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CharacteristicList' }

  /characteristics/{id}:
    get:
      tags: [catalog-characteristics]
      operationId: getCharacteristic
      summary: Получить характеристику по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Characteristic' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [catalog-characteristics]
      operationId: patchCharacteristicModerationFields
      summary: Обновить moderation/policy поля характеристики
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-characteristics]
      operationId: deleteCharacteristic
      summary: Удалить характеристику
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # catalog/canonical-products
  # ---------------------------------------------------------------------------
  /canonical-products:
    post:
      tags: [catalog-canonical-products]
      operationId: createCanonicalProduct
      summary: Создать canonical product
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCanonicalProductRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalProducts
      summary: Список canonical products
      description: |
        По умолчанию возвращает только рабочие карточки и скрывает
        `status=replaced`, потому что такие строки являются alias/redirect на
        survivor canonical product. Чтобы посмотреть замененные alias-строки
        намеренно, передайте явный фильтр `status=replaced`.
      parameters:
        - { $ref: '#/components/parameters/Search' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
        - name: manufacturer_id
          in: query
          description: Фильтр по производителю (repeatable). Между значениями OR.
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - name: status
          in: query
          description: |
            Фильтр по lifecycle-статусу (repeatable). Между значениями OR.
            Без этого параметра `replaced` исключается из списка.
          schema:
            type: array
            items: { $ref: '#/components/schemas/CanonicalProductStatus' }
          style: form
          explode: true
        - name: characteristic
          in: query
          description: |
            Фильтр по характеристике (repeatable). Формат каждого значения:
            `{id}:{op}[:{value}]` согласно spec §4.1.
            Примеры: `abc123:eq:Red`, `abc123:exists`, `abc123:range:10:100`.
          schema:
            type: array
            items:
              type: string
              pattern: '^[^:]+:.+$'
          style: form
          explode: true
        - name: supplier_sku
          in: query
          description: Поиск по supplier SKU (префикс; точное совпадение при trailing `=`).
          schema: { type: string }
        - name: has_offers_from
          in: query
          description: Фильтр по наличию офферов от поставщиков (repeatable, OR).
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - name: sort
          in: query
          description: Порядок сортировки.
          schema:
            type: string
            enum: [name, created_at, '-name', '-created_at']
        - name: updated_since
          in: query
          description: Только продукты, обновлённые не ранее указанного момента (RFC3339).
          schema: { type: string, format: date-time }
        - name: category_id
          in: query
          description: |
            Фильтр по категории (repeatable, OR). UUID категории из таблицы categories,
            производится по MODE category_id из supplier_offers.
          schema:
            type: array
            items: { type: string, format: uuid }
          style: form
          explode: true
        - name: offers_min
          in: query
          description: Минимальное число офферов (coalesce 0 когда нет supplier_offers).
          schema: { type: integer, minimum: 0 }
        - name: gost
          in: query
          description: |
            Фильтр по номеру ГОСТ. Принимается, но не применяется в SQL — колонка
            gost отсутствует в canonical_products (TODO Phase 1B+).
          schema: { type: string }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProductList' }

  /canonical-products/search:
    post:
      tags: [catalog-canonical-products]
      operationId: searchCanonicalProducts
      summary: Search canonical products with boolean tree filter
      x-runtime-path: /api/v1/admin/catalog/canonical-products/search
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CanonicalSearchRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProductList' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /facets:
    get:
      tags: [catalog-canonical-products]
      operationId: getCatalogFacets
      summary: Faceted counts for catalog list filters
      x-runtime-path: /api/v1/admin/catalog/facets
      parameters:
        - name: scope
          in: query
          required: true
          schema:
            type: string
            enum: [canonical, offers]
        - name: limit_per_field
          in: query
          schema:
            type: integer
            default: 10
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogFacets' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /canonical-products/{id}:
    get:
      tags: [catalog-canonical-products]
      operationId: getCanonicalProduct
      summary: Получить canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-canonical-products]
      operationId: deleteCanonicalProduct
      summary: Удалить canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/name:
    patch:
      tags: [catalog-canonical-products]
      operationId: renameCanonicalProduct
      summary: Переименовать canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RenameRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/characteristics:
    post:
      tags: [catalog-canonical-products]
      operationId: assignCanonicalCharacteristic
      summary: Привязать характеристику к canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignCharacteristicRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/characteristics/{characteristic_id}:
    delete:
      tags: [catalog-canonical-products]
      operationId: unassignCanonicalCharacteristic
      summary: Отвязать характеристику от canonical product
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: characteristic_id
          in: path
          required: true
          description: ID характеристики, которую отвязываем
          schema: { type: string }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/status:
    post:
      tags: [catalog-canonical-products]
      operationId: transitionCanonicalProductStatus
      summary: Сменить lifecycle-статус canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StatusChangeRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalProduct' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/assignments:
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalProductAssignments
      summary: Список normalized assignments canonical product
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/assignments/{cid}:
    put:
      tags: [catalog-canonical-products]
      operationId: putCanonicalProductAssignment
      summary: Ручной override normalized assignment
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: cid
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value, decided_by]
              properties:
                value: {}
                decided_by: { type: string }
      responses:
        '200': { description: updated }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-canonical-products]
      operationId: deleteCanonicalProductAssignment
      summary: Удалить ручной/auto assignment; worker пересчитает при следующем тике
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: cid
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: deleted }
        '400': { $ref: '#/components/responses/BadRequest' }

  /canonical-products/{id}/assignments/{cid}/values:
    post:
      tags: [catalog-canonical-products]
      operationId: addCanonicalProductAssignmentUnionValue
      summary: Добавить значение в union assignment
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: cid
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '201': { description: created }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }

  /canonical-products/{id}/assignments/{cid}/values/{key}:
    delete:
      tags: [catalog-canonical-products]
      operationId: deleteCanonicalProductAssignmentUnionValue
      summary: Удалить значение из union assignment
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: cid
          in: path
          required: true
          schema: { type: string }
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: deleted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/match-log:
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalProductMatchLog
      summary: Лог матчинговых решений по canonical product
      description: |
        Возвращает все строки `match_decisions`, привязанные к данному canonical product
        через `canonical_product_ref`. Включает supplier, supplier_sku, tier,
        match_confidence, status и LLM-поля.
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/MatchLogEntry'
        '404': { $ref: '#/components/responses/NotFound' }

  /canonical-products/{id}/similar:
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalProductSimilar
      summary: Похожие canonical products (по категории и первому слову имени)
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: limit
          in: query
          schema: { type: integer, default: 10, maximum: 100 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/SimilarItem'
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------------------
  # offers (read-only in iter-1)
  # ---------------------------------------------------------------------------
  /supplier-offers:
    get:
      tags: [offers]
      operationId: listSupplierOffers
      summary: List supplier offers
      description: |
        Read-only view over `supplier_offers` joined with the latest
        observation. iter-1 surface; mutation endpoints land in Phase 2.
      parameters:
        - name: supplier
          in: query
          description: Фильтр по slug поставщика (repeatable)
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - name: manufacturer_id
          in: query
          schema: { type: string, format: uuid }
        - name: manufacturer_slug
          in: query
          schema: { type: string }
        - name: has_stock
          in: query
          schema: { type: boolean }
        - name: on_request
          in: query
          schema: { type: boolean }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: credential_scope
          in: query
          schema: { type: string, enum: [system, customer] }
        - name: customer_ref
          in: query
          schema: { type: string, format: uuid }
        - name: credential_ref
          in: query
          schema: { type: string, format: uuid }
        - name: search
          in: query
          description: Подстрочный поиск по имени / SKU / артикулу.
          schema: { type: string }
        - name: canonical_id
          in: query
          description: Фильтр по linked canonical product id.
          schema: { type: string }
        - name: match_tier
          in: query
          description: Фильтр по tier матчинга (repeatable, OR).
          schema:
            type: array
            items:
              type: string
              enum: [exact_mpn, fingerprint, llm, none]
          style: form
          explode: true
        - name: match_confidence
          in: query
          description: Фильтр по уровню уверенности матчинга (repeatable, OR).
          schema:
            type: array
            items:
              type: string
              enum: [exact, strong, probable, weak, unmatched]
          style: form
          explode: true
        - name: price_min
          in: query
          description: Минимальная цена (включительно).
          schema: { type: number }
        - name: price_max
          in: query
          description: Максимальная цена (включительно).
          schema: { type: number }
        - name: stock_min
          in: query
          description: Минимальный остаток на складе (включительно).
          schema: { type: integer }
        - name: stock_max
          in: query
          description: Максимальный остаток на складе (включительно).
          schema: { type: integer }
        - name: freshness_max_hours
          in: query
          description: Максимальный возраст наблюдения в часах.
          schema: { type: integer, minimum: 1 }
        - name: characteristic
          in: query
          description: |
            Фильтр по характеристике (repeatable). Формат: `{id}:{op}[:{value}]`
            согласно spec §4.1.
          schema:
            type: array
            items:
              type: string
              pattern: '^[^:]+:.+$'
          style: form
          explode: true
        - name: sort
          in: query
          description: Порядок сортировки.
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: page_size
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SupplierOffersEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
      security:
        - bearerAuth: ['admin:read']

  # ---------------------------------------------------------------------------
  # canonical assignments / disputes / moderation
  # ---------------------------------------------------------------------------
  /disputes:
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalAssignmentDisputes
      summary: Список disputes по normalized canonical assignments
      parameters:
        - name: state
          in: query
          schema: { type: string }
        - name: canonical_id
          in: query
          schema: { type: string, format: uuid }
        - name: characteristic_id
          in: query
          schema: { type: string, format: uuid }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }

  /disputes/stats:
    get:
      tags: [catalog-canonical-products]
      operationId: getCanonicalAssignmentDisputeStats
      summary: Агрегированные счетчики disputes
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /disputes/{id}:
    get:
      tags: [catalog-canonical-products]
      operationId: getCanonicalAssignmentDispute
      summary: Получить dispute по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [catalog-canonical-products]
      operationId: supersedeCanonicalAssignmentDispute
      summary: Пометить dispute superseded
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: superseded }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /disputes/{id}/reset-llm-attempts:
    post:
      tags: [catalog-canonical-products]
      operationId: resetCanonicalAssignmentDisputeLLMAttempts
      summary: Сбросить LLM attempts и вернуть dispute в pending
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '204': { description: reset }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /disputes/{id}/force-resolve:
    post:
      tags: [catalog-canonical-products]
      operationId: forceResolveCanonicalAssignmentDispute
      summary: Принудительно записать assignment и закрыть dispute
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: {}
                notes: { type: string }
      responses:
        '200':
          description: resolved
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /dispute-moderation:
    get:
      tags: [catalog-canonical-products]
      operationId: listCanonicalAssignmentModeration
      summary: Очередь human moderation по canonical assignment disputes
      parameters:
        - name: state
          in: query
          schema: { type: string }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }

  /dispute-moderation/{id}/decide:
    post:
      tags: [catalog-canonical-products]
      operationId: decideCanonicalAssignmentModeration
      summary: Принять решение по moderation case
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [approve_llm, override, reject, escalate] }
                value: {}
                notes: { type: string }
      responses:
        '200':
          description: decided
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # catalog/categories — read-only list for admin UI popovers (Phase 1B)
  # ---------------------------------------------------------------------------
  /categories:
    get:
      tags: [catalog-categories]
      operationId: listCategories
      summary: Список категорий (для CategoryPopover)
      parameters:
        - name: search
          in: query
          description: Фильтр по имени или slug (case-insensitive, in-process).
          schema: { type: string }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required: [id, name, slug]
                      properties:
                        id:   { type: string, format: uuid }
                        name: { type: string }
                        slug: { type: string }

  # ---------------------------------------------------------------------------
  # matching moderation
  # ---------------------------------------------------------------------------
  /moderation:
    get:
      tags: [catalog-canonical-products]
      operationId: listMatchingModeration
      summary: Очередь модерации product matching
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /moderation/{id}/decide:
    post:
      tags: [catalog-canonical-products]
      operationId: decideMatchingModeration
      summary: Approve/reject product matching moderation item
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision, reviewer_ref]
              properties:
                decision: { type: string, enum: [approved, rejected] }
                reviewer_ref: { type: string }
                note: { type: string }
      responses:
        '204': { description: decided }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # discovery/visibility — Visibility Policies CRUD
  # Paths are normalised: prefix /api/v1/admin/ + BC segment "discovery" stripped.
  # ---------------------------------------------------------------------------
  /visibility-rules:
    get:
      tags: [discovery-visibility]
      operationId: listVisibilityRules
      summary: Список правил видимости
      parameters:
        - name: scope_kind
          in: query
          schema: { type: string, enum: [customer, customer_pricing_group] }
        - name: scope_ref
          in: query
          schema: { type: string, format: uuid }
        - name: decision
          in: query
          schema: { type: string, enum: [deny, allow] }
        - name: status
          in: query
          schema: { type: string, enum: [active, inactive] }
        - name: at_time
          in: query
          description: Фильтр по активности на момент времени (RFC3339)
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListVisibilityRulesResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []
    post:
      tags: [discovery-visibility]
      operationId: createVisibilityRule
      summary: Создать правило видимости
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateVisibilityRuleRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VisibilityRule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []

  /visibility-rules/{id}:
    get:
      tags: [discovery-visibility]
      operationId: getVisibilityRule
      summary: Получить правило видимости по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VisibilityRule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []
    patch:
      tags: [discovery-visibility]
      operationId: updateVisibilityRule
      summary: Частичное обновление правила видимости
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateVisibilityRuleRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VisibilityRule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []

  /visibility-rules/{id}/activate:
    post:
      tags: [discovery-visibility]
      operationId: activateVisibilityRule
      summary: Активировать правило видимости
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: version
          in: query
          required: true
          description: Ожидаемая версия для optimistic locking
          schema: { type: integer, format: int64 }
      responses:
        '204': { description: activated }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []

  /visibility-rules/{id}/deactivate:
    post:
      tags: [discovery-visibility]
      operationId: deactivateVisibilityRule
      summary: Деактивировать правило видимости
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
        - name: version
          in: query
          required: true
          description: Ожидаемая версия для optimistic locking
          schema: { type: integer, format: int64 }
      responses:
        '204': { description: deactivated }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '500': { $ref: '#/components/responses/InternalError' }
      security:
        - bearerAuth: []

  # ---------------------------------------------------------------------------
  # supplier-network/sellers (P4b) — paths normalised: prefix /api/v1/admin/
  # + BC segment "supplier-network" stripped.
  # ---------------------------------------------------------------------------
  /sellers:
    get:
      tags: [supplier-network-sellers]
      operationId: listSellers
      summary: List sellers (admin)
      parameters:
        - { in: query, name: supplier_ref, required: true, schema: { type: string, format: uuid } }
        - { in: query, name: status, schema: { type: string, enum: [active, inactive, banned] } }
        - { in: query, name: page_size, schema: { type: integer, minimum: 1, maximum: 200 } }
        - { in: query, name: page_token, schema: { type: string } }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SellerListResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /sellers/{id}:
    get:
      tags: [supplier-network-sellers]
      operationId: getSeller
      summary: Get seller (admin)
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SellerDTO' }
        '404': { $ref: '#/components/responses/NotFound' }

  /sellers/{id}/status:
    patch:
      tags: [supplier-network-sellers]
      operationId: patchSellerStatus
      summary: Change seller status (moderator)
      parameters:
        - { $ref: '#/components/parameters/IdPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SellerStatusPatchRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SellerDTO' }
        '404': { $ref: '#/components/responses/NotFound' }
        '412': { description: version conflict }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  # ---------------------------------------------------------------------------
  # credentials (P4c-2) — paths normalised: prefix /api/v1/admin/ + BC segment
  # "credentials" stripped → / /{id} /{id}/secret /{id}/status /{id}/reset-health
  # ---------------------------------------------------------------------------
  /:
    get:
      tags: [credentials]
      operationId: listCredentials
      summary: Список учётных данных (с фильтрами и пагинацией)
      parameters:
        - { in: query, name: supplier_ref, schema: { type: string } }
        - { in: query, name: scope, schema: { type: string, enum: [system, customer] } }
        - { in: query, name: status, schema: { type: string, enum: [active, inactive, revoked] } }
        - { in: query, name: owner_ref, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CredentialList' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [credentials]
      operationId: createCredential
      summary: Создать учётные данные
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCredentialRequest' }
      responses:
        '201':
          description: created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Credential' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }

  /{id}:
    get:
      tags: [credentials]
      operationId: getCredential
      summary: Получить учётные данные по id
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Credential' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [credentials]
      operationId: updateCredentialMetadata
      summary: Обновить метаданные (оптимистичная блокировка)
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateCredentialMetadataRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Credential' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
    delete:
      tags: [credentials]
      operationId: deleteCredential
      summary: Удалить учётные данные (только revoked)
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DeleteCredentialRequest' }
      responses:
        '204': { description: deleted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /{id}/secret:
    patch:
      tags: [credentials]
      operationId: rotateCredentialSecret
      summary: Ротация секрета учётных данных
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RotateSecretRequest' }
      responses:
        '204': { description: rotated }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /{id}/status:
    patch:
      tags: [credentials]
      operationId: transitionCredentialStatus
      summary: Переход статуса учётных данных
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StatusTransitionRequest' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Credential' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /{id}/reset-health:
    post:
      tags: [credentials]
      operationId: resetCredentialHealth
      summary: Сброс счётчиков health и rate-budget
      parameters: [{ $ref: '#/components/parameters/IdPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResetHealthRequest' }
      responses:
        '204': { description: reset }
        '400': { $ref: '#/components/responses/BadRequest' }

  # ---------------------------------------------------------------------------
  # cabinet/apikeys (admin flow, D14) — paths normalised: prefix /api/v1/admin/
  # + BC segment "customers" stripped → /{customer_id}/api-keys
  # ---------------------------------------------------------------------------
  /{customer_id}/api-keys:
    get:
      tags: [customer-api-keys]
      operationId: adminListCustomerAPIKeys
      summary: Список API ключей клиента (admin view)
      parameters:
        - name: customer_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Offset' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminAPIKeyList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [customer-api-keys]
      operationId: adminCreateCustomerAPIKey
      summary: Создать API ключ от имени клиента (D14 — через тот же CreateService)
      parameters:
        - name: customer_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminCreateAPIKeyRequest' }
      responses:
        '201':
          description: created — secret доступен только в этом ответе
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminCreateAPIKeyResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ---------------------------------------------------------------------------
  # platform/job-history — paths normalised: prefix /api/v1/admin/
  # + BC segment "ingestion" stripped → /jobs /jobs/{job_id}/events /trace
  # Runtime canonical routes:
  #   GET /api/v1/admin/ingestion/jobs
  #   GET /api/v1/admin/ingestion/jobs/{job_id}/events
  #   GET /api/v1/admin/ingestion/jobs/{job_id}/trace
  #   GET /api/v1/admin/ingestion/job-controls
  #   PUT /api/v1/admin/ingestion/job-controls/{scope}/{target}
  #   POST /api/v1/admin/ingestion/job-controls:stop-all
  #   POST /api/v1/admin/ingestion/job-controls:start-all
  # ---------------------------------------------------------------------------
  /jobs:
    get:
      tags: [ingestion-jobs]
      operationId: listIngestionJobs
      summary: Список ingestion jobs с текущим состоянием
      description: |
        Возвращает последние supplier-sync / worker jobs из persisted
        job-history (`ingestion_job_runs`). Это основной endpoint для
        админ-панели статуса: по нему видно supplier, phase, counters,
        duration, stale flag и последнюю активность.

        Не возвращает supplier credentials. Если `ADMIN_API_KEY` не задан
        или job history storage выключен, endpoint отвечает 503.
      x-runtime-path: /api/v1/admin/ingestion/jobs
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      parameters:
        - name: supplier
          in: query
          description: Фильтр по коду поставщика (`etm`, `iek`, `systeme`, `russvet`, ...)
          schema: { type: string }
        - name: status
          in: query
          description: Фильтр по статусу job run
          schema:
            type: string
            examples: [running, ok, partial, partial_error, error, rate_limited, lease_error]
        - name: limit
          in: query
          description: Максимум записей; default 100, hard cap 500
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestionJobListResponse' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  /jobs/{job_id}/events:
    get:
      tags: [ingestion-jobs]
      operationId: listIngestionJobEvents
      summary: Хронология событий ingestion job
      description: |
        Возвращает raw event timeline из `ingestion_job_events` для одной job:
        phase/event/type/state/outcome/level/message/duration/payload. Нужен
        для drill-down в админке и диагностики provider HTTP, parser,
        DB-write, charnorm, matcher/LLM и canonical assignment фаз.
      x-runtime-path: /api/v1/admin/ingestion/jobs/{job_id}/events
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      parameters:
        - { $ref: '#/components/parameters/IngestionJobIdPath' }
        - name: limit
          in: query
          description: Максимум событий; default 200, hard cap 1000
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestionJobEventListResponse' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '404':
          description: Job id или subresource не найден
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  /jobs/{job_id}/trace:
    get:
      tags: [ingestion-jobs]
      operationId: getIngestionJobTrace
      summary: Агрегированный trace ingestion job
      description: |
        Возвращает diagnostic snapshot по одной job: запись job run,
        агрегаты по фазам, HTTP status/latency summary, самые долгие events,
        группировку ошибок и progress timeline. Это endpoint для ответа
        "где именно тормозит или ломается контур поставщика".
      x-runtime-path: /api/v1/admin/ingestion/jobs/{job_id}/trace
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      parameters:
        - { $ref: '#/components/parameters/IngestionJobIdPath' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestionJobTrace' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '404':
          description: Job id не найден
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  /canonical-assignment/queue:
    get:
      tags: [ingestion-jobs]
      operationId: getCanonicalAssignmentQueue
      summary: Live snapshot очереди canonical assignment resolver
      description: |
        Возвращает read-only snapshot `assignment_dispute_queue`: сколько
        disputes ожидают LLM resolver, сколько сейчас claimed в обработке,
        сколько ушло в manual review, причины постановки в очередь и связь
        с текущими `canonical_assignment` / `canonical_assignment_resolver`
        jobs. Endpoint не возвращает credentials, raw provider payloads или
        LLM prompts.
      x-runtime-path: /api/v1/admin/ingestion/canonical-assignment/queue
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CanonicalAssignmentQueueResponse' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  /job-controls:
    get:
      tags: [ingestion-jobs]
      operationId: listIngestionJobControls
      summary: Runtime controls for ingestion job launches
      description: |
        Returns persisted operator controls and the effective launch policy for
        each configured ingestion job. `enabled=false` blocks only new launches;
        already running jobs are allowed to finish naturally.
      x-runtime-path: /api/v1/admin/ingestion/job-controls
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestionJobControlsResponse' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  /job-controls/{scope}/{target}:
    put:
      tags: [ingestion-jobs]
      operationId: upsertIngestionJobControl
      summary: Update launch controls globally, for a stage, or for a job
      description: |
        Creates or updates one control row. Stage controls use pipeline stage ids
        like `charnorm`, `matcher`, or `canonical`; job controls use worker job
        keys like `supplier-sync:etm`.
      x-runtime-path: /api/v1/admin/ingestion/job-controls/{scope}/{target}
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      parameters:
        - name: scope
          in: path
          required: true
          schema: { $ref: '#/components/schemas/IngestionJobControlScope' }
        - name: target
          in: path
          required: true
          description: "'*' for global, stage id for stage controls, or job key for job controls"
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IngestionJobControlPatch' }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/IngestionJobControl' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  '/job-controls:stop-all':
    post:
      tags: [ingestion-jobs]
      operationId: stopAllIngestionJobs
      summary: Stop all new ingestion job launches
      description: Sets the global `enabled=false` control. Running jobs are not cancelled.
      x-runtime-path: /api/v1/admin/ingestion/job-controls:stop-all
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/IngestionJobControl' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  '/job-controls:start-all':
    post:
      tags: [ingestion-jobs]
      operationId: startAllIngestionJobs
      summary: Allow ingestion job launches again
      description: Sets the global `enabled=true` control and keeps stage/job overrides intact.
      x-runtime-path: /api/v1/admin/ingestion/job-controls:start-all
      security:
        - bearerAuth: []
        - adminApiKeyBearer: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/IngestionJobControl' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }
        '503': { $ref: '#/components/responses/PlainServiceUnavailable' }

  # ---------------------------------------------------------------------------
  # quality
  # BC segment "quality" stripped → /overview
  #   GET /api/v1/admin/quality/overview
  # ---------------------------------------------------------------------------
  /overview:
    get:
      tags: [quality]
      operationId: getQualityOverview
      summary: Агрегированные метрики качества данных
      description: |
        Возвращает снимок качества каталога по 4 секциям:
        canonical_normalization, data_collection, categorization, per_supplier.
        Результат кешируется in-process 60 с. При первом вызове запрос может
        занять до 30 с на больших объёмах данных.
      x-runtime-path: /api/v1/admin/quality/overview
      security:
        - bearerAuth: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QualityOverview' }
        '401': { $ref: '#/components/responses/PlainUnauthorized' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    adminApiKeyBearer:
      type: http
      scheme: bearer
      description: |
        Static admin bearer token from `ADMIN_API_KEY`. Used only by the
        platform job-history side channel until it is migrated to shared auth.

  parameters:
    IdPath:
      name: id
      in: path
      required: true
      description: Идентификатор ресурса
      schema: { type: string }
    Search:
      name: search
      in: query
      description: Подстрочный поиск по имени/коду
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Размер страницы (server default если опущено)
      schema: { type: integer, minimum: 1, maximum: 500 }
    Offset:
      name: offset
      in: query
      description: Смещение для offset-пагинации
      schema: { type: integer, minimum: 0 }
    IngestionJobIdPath:
      name: job_id
      in: path
      required: true
      description: Stable job id, например `supplier-sync:etm:<uuid>`
      schema: { type: string }

  responses:
    BadRequest:
      description: Невалидный запрос
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Ресурс не найден
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Конфликт (дубликат или нарушение инварианта)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Требуется аутентификация
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Недостаточно прав (role scope)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: Нарушение доменного инварианта
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Внутренняя ошибка сервера
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PlainUnauthorized:
      description: Неверный или отсутствующий admin bearer token
      content:
        text/plain:
          schema: { type: string }
    PlainServiceUnavailable:
      description: Job history отключён или `ADMIN_API_KEY` не настроен
      content:
        text/plain:
          schema: { type: string }

  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string, description: machine-readable error code }
        message: { type: string }
        details:
          type: object
          additionalProperties: true

    IngestionJobListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/IngestionJob' }

    IngestionJob:
      type: object
      required: [id, job_id, status, started_at, updated_at, last_event_age_ms, stale]
      properties:
        id:
          type: string
          description: Internal DB UUID of the persisted job run
        job_id:
          type: string
          description: Stable observability job id, e.g. `supplier-sync:etm:<uuid>`
        trace_id:
          type: string
          description: Correlation trace id when emitted by the runner
        operation:
          type: string
          description: High-level operation, e.g. supplier-sync, charnorm, matcher
        supplier:
          type: string
          description: Supplier code when the job belongs to a supplier contour
        status:
          type: string
          description: Current/final job status
          examples: [running, ok, partial, partial_error, error, rate_limited, lease_error]
        current_phase:
          type: string
          description: Current or last known phase (`prices`, `stocks`, `characteristics`, `charnorm`, ...)
        item_count:
          type: [integer, 'null']
          format: int64
          description: Total planned items when known
        ok_count:
          type: [integer, 'null']
          format: int64
          description: Successfully processed items
        partial_count:
          type: [integer, 'null']
          format: int64
          description: Items processed with partial provider/parser/data issues
        errored_count:
          type: [integer, 'null']
          format: int64
          description: Failed items
        duration_ms:
          type: [integer, 'null']
          format: int64
          description: Latest known or final job duration
        error_message:
          type: string
          description: Last persisted error summary; empty string when absent
        started_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        completed_at:
          type: [string, 'null']
          format: date-time
        last_event_age_ms:
          type: integer
          format: int64
          description: Milliseconds since the job run row was last updated
        stale:
          type: boolean
          description: true when job is still running but has not updated for more than 15 minutes

    IngestionJobEventListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/IngestionJobEvent' }

    IngestionJobEvent:
      type: object
      required: [id, job_id, level, created_at]
      properties:
        id: { type: integer, format: int64 }
        job_id: { type: string }
        trace_id: { type: string }
        supplier: { type: string }
        phase:
          type: string
          description: Pipeline phase that emitted the event
        event:
          type: string
          description: Structured event name, e.g. `ingestion.http.request.end`
        event_type:
          type: string
          description: progress, summary, warning, retry, error, etc.
        state:
          type: string
          description: Optional state marker emitted by the producer
        outcome:
          type: string
          description: success, partial, failure, timeout, rate_limited, etc.
        level:
          type: string
          examples: [INFO, WARN, ERROR]
        message:
          type: string
          description: Human-readable diagnostic message
        duration_ms:
          type: [integer, 'null']
          format: int64
          description: Event duration when measured
        payload:
          type: object
          additionalProperties: true
          description: Structured diagnostic metadata; supplier secrets must never be written here
        created_at: { type: string, format: date-time }

    IngestionJobControlsResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/IngestionJobControlsSnapshot' }

    IngestionJobControlsSnapshot:
      type: object
      required: [controls, effective]
      properties:
        controls:
          type: array
          items: { $ref: '#/components/schemas/IngestionJobControl' }
        effective:
          type: array
          items: { $ref: '#/components/schemas/IngestionJobControlEffective' }

    IngestionJobControlScope:
      type: string
      enum: [global, stage, job]

    IngestionJobControl:
      type: object
      required: [scope, target, enabled, reason, updated_by, updated_at]
      properties:
        scope: { $ref: '#/components/schemas/IngestionJobControlScope' }
        target:
          type: string
          description: "'*' for global, stage id for stage controls, or job key for job controls"
        enabled:
          type: boolean
          description: When false, workers skip new launches for the effective scope
        interval_seconds:
          type: [integer, 'null']
          minimum: 1
          description: Optional minimum delay between launches
        max_parallel:
          type: [integer, 'null']
          minimum: 1
          description: Optional maximum number of simultaneously running matching jobs
        reason: { type: string }
        updated_by: { type: string }
        updated_at: { type: string, format: date-time }

    IngestionJobControlEffective:
      type: object
      required: [scope, target, job_key, stage_ids, enabled, reason]
      properties:
        scope: { $ref: '#/components/schemas/IngestionJobControlScope' }
        target: { type: string }
        job_key:
          type: string
          description: Worker job key, e.g. `supplier-sync:etm`, `charnorm`, `matcher`
        stage_ids:
          type: array
          items: { type: string }
        enabled: { type: boolean }
        interval_seconds:
          type: [integer, 'null']
          minimum: 1
        max_parallel:
          type: [integer, 'null']
          minimum: 1
        reason: { type: string }

    IngestionJobControlPatch:
      type: object
      required: [enabled]
      properties:
        enabled: { type: boolean }
        interval_seconds:
          type: [integer, 'null']
          minimum: 1
        max_parallel:
          type: [integer, 'null']
          minimum: 1
        reason: { type: string }

    IngestionJobTrace:
      type: object
      required: [job, phase_summary, http_summary, slow_events, errors_summary, progress]
      properties:
        job: { $ref: '#/components/schemas/IngestionJob' }
        phase_summary:
          type: array
          items: { $ref: '#/components/schemas/IngestionPhaseSummary' }
        http_summary:
          type: array
          items: { $ref: '#/components/schemas/IngestionHTTPSummary' }
        slow_events:
          type: array
          items: { $ref: '#/components/schemas/IngestionSlowEvent' }
        errors_summary:
          type: array
          items: { $ref: '#/components/schemas/IngestionErrorSummary' }
        progress:
          type: array
          items: { $ref: '#/components/schemas/IngestionProgressEvent' }

    CanonicalAssignmentQueueResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/CanonicalAssignmentQueueSnapshot' }

    CanonicalAssignmentQueueSnapshot:
      type: object
      required: [queue, reasons, attempts, settings, jobs, updated_at]
      properties:
        queue: { $ref: '#/components/schemas/CanonicalAssignmentQueueTotals' }
        reasons:
          type: array
          items: { $ref: '#/components/schemas/CanonicalAssignmentQueueReason' }
        attempts:
          type: array
          items: { $ref: '#/components/schemas/CanonicalAssignmentQueueAttempt' }
        settings: { $ref: '#/components/schemas/CanonicalAssignmentQueueSettings' }
        jobs: { $ref: '#/components/schemas/CanonicalAssignmentQueueJobs' }
        updated_at: { type: string, format: date-time }

    CanonicalAssignmentQueueTotals:
      type: object
      required: [pending, in_progress, manual_review, resolved_recent, superseded_recent, estimated_batches_remaining]
      properties:
        pending: { type: integer, format: int64 }
        in_progress: { type: integer, format: int64 }
        manual_review: { type: integer, format: int64 }
        resolved_recent: { type: integer, format: int64 }
        superseded_recent: { type: integer, format: int64 }
        oldest_pending_age_ms: { type: [integer, 'null'], format: int64 }
        oldest_in_progress_age_ms: { type: [integer, 'null'], format: int64 }
        estimated_batches_remaining: { type: integer, format: int64 }

    CanonicalAssignmentQueueReason:
      type: object
      required: [strategy_kind, pending, in_progress, manual_review]
      properties:
        strategy_kind:
          type: string
          enum: [trust_weighted_tie, mode_tie]
        pending: { type: integer, format: int64 }
        in_progress: { type: integer, format: int64 }
        manual_review: { type: integer, format: int64 }

    CanonicalAssignmentQueueAttempt:
      type: object
      required: [llm_attempts, pending, in_progress]
      properties:
        llm_attempts: { type: integer }
        pending: { type: integer, format: int64 }
        in_progress: { type: integer, format: int64 }

    CanonicalAssignmentQueueSettings:
      type: object
      required: [batch_size, max_llm_attempts]
      properties:
        batch_size: { type: integer }
        max_llm_attempts: { type: integer }

    CanonicalAssignmentQueueJobs:
      type: object
      required: [assignment_running, resolver_running, latest_assignment_job_id, latest_resolver_job_id]
      properties:
        assignment_running: { type: integer, format: int64 }
        resolver_running: { type: integer, format: int64 }
        latest_assignment_job_id: { type: string }
        latest_resolver_job_id: { type: string }

    IngestionPhaseSummary:
      type: object
      required: [phase, event, state, outcome, events]
      properties:
        phase: { type: string }
        event: { type: string }
        state: { type: string }
        outcome: { type: string }
        events: { type: integer, format: int64 }
        min_ms: { type: [integer, 'null'], format: int64 }
        avg_ms: { type: [number, 'null'], format: double }
        max_ms: { type: [integer, 'null'], format: int64 }
        sum_ms: { type: [integer, 'null'], format: int64 }

    IngestionHTTPSummary:
      type: object
      required: [phase, status, outcome, requests]
      properties:
        phase: { type: string }
        status:
          type: string
          description: Provider or internal HTTP status as emitted in event payload
        outcome: { type: string }
        requests: { type: integer, format: int64 }
        sum_ms: { type: [integer, 'null'], format: int64 }
        avg_ms: { type: [number, 'null'], format: double }
        p50_ms: { type: [number, 'null'], format: double }
        p95_ms: { type: [number, 'null'], format: double }
        max_ms: { type: [integer, 'null'], format: int64 }

    IngestionSlowEvent:
      type: object
      required: [phase, event, path, status, outcome, created_at]
      properties:
        phase: { type: string }
        event: { type: string }
        path:
          type: string
          description: Provider/API path when available
        status: { type: string }
        outcome: { type: string }
        duration_ms: { type: [integer, 'null'], format: int64 }
        created_at: { type: string, format: date-time }

    IngestionErrorSummary:
      type: object
      required: [level, phase, event, outcome, status, error_or_message, events]
      properties:
        level: { type: string }
        phase: { type: string }
        event: { type: string }
        outcome: { type: string }
        status: { type: string }
        error_or_message:
          type: string
          description: Error text truncated by backend to 240 characters
        events: { type: integer, format: int64 }

    IngestionProgressEvent:
      type: object
      required: [created_at, phase, event, payload]
      properties:
        created_at: { type: string, format: date-time }
        phase: { type: string }
        event: { type: string }
        payload:
          type: object
          additionalProperties: true
          description: Progress payload with correlation and credential reference fields stripped

    Unit:
      type: object
      required: [id, code, name, created_at]
      properties:
        id: { type: string }
        code:
          type: string
          description: Короткий код (например "pcs", "kg")
        name: { type: string }
        created_at: { type: string, format: date-time }

    CreateUnitRequest:
      type: object
      required: [code, name]
      properties:
        code: { type: string }
        name: { type: string }

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

    Manufacturer:
      type: object
      required: [id, name, aliases, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        aliases:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateManufacturerRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }
        aliases:
          type: array
          items: { type: string }

    UpdateManufacturerRequest:
      type: object
      description: Частичное обновление — все поля опциональны
      properties:
        name: { type: string }

    AliasRequest:
      type: object
      required: [alias]
      properties:
        alias: { type: string }

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

    Characteristic:
      type: object
      required: [id, code, name, data_type, created_at]
      properties:
        id: { type: string }
        code: { type: string }
        name: { type: string }
        data_type:
          type: string
          description: Формат значения (string / number / boolean / enum и т.д.)
        unit_id:
          type: [string, 'null']
          description: Привязка к unit (опционально)
        created_at: { type: string, format: date-time }

    CreateCharacteristicRequest:
      type: object
      required: [code, name, data_type]
      properties:
        code: { type: string }
        name: { type: string }
        data_type: { type: string }
        unit_id:
          type: [string, 'null']

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

    CanonicalProductStatus:
      type: string
      description: Lifecycle-статус canonical product (значения валидируются доменом)
      enum: [draft, active, deprecated, replaced]

    CharacteristicAssignment:
      type: object
      required: [characteristic_id, value]
      properties:
        characteristic_id: { type: string }
        characteristic_name:
          type: string
          description: Человекочитаемое имя характеристики (JOIN из characteristics.name)
        unit_symbol:
          type: string
          nullable: true
          description: Код единицы измерения из таблицы units (null если не применимо)
        value:
          description: Значение характеристики (тип зависит от data_type характеристики)

    MatchLogEntry:
      type: object
      required: [offer_id, supplier, supplier_sku, match_confidence, tier, status, decided_at, updated_at, evidence_hash]
      properties:
        offer_id: { type: string, format: uuid }
        supplier: { type: string }
        supplier_sku: { type: string }
        match_confidence:
          type: string
          enum: [exact, strong, probable, weak, unmatched]
        tier:
          type: string
          enum: [exact_mpn, fingerprint, llm, none]
        status:
          type: string
          enum: [active, conflict]
        decided_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        evidence_hash: { type: string }
        llm_rationale:
          type: string
          nullable: true
        llm_model:
          type: string
          nullable: true

    SimilarItem:
      type: object
      required: [id, name, viewable_id, manufacturer_id, offers_count]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        viewable_id: { type: string }
        manufacturer_id: { type: string, format: uuid }
        offers_count: { type: integer }

    CanonicalProduct:
      type: object
      required: [id, name, manufacturer_id, status, classification_tags, assignments, created_at, updated_at]
      properties:
        id: { type: string }
        name: { type: string }
        manufacturer_id: { type: string }
        status: { $ref: '#/components/schemas/CanonicalProductStatus' }
        viewable_id:
          type: string
          description: Человекочитаемый идентификатор TR-XXXXXXX.
        survivor_id:
          type: string
          nullable: true
          description: UUID canonical product, на который заменена эта строка при status=replaced.
        survivor_viewable_id:
          type: string
          description: Viewable ID survivor-товара для прямой ссылки в карточке replaced.
        classification_tags:
          type: array
          items:
            type: string
          description: |
            Controlled classification tags applied to this canonical product.
            Populated by the normalization pipeline (AI classifier — deferred);
            production rows currently always carry an empty array. See spec
            P4c-1 (2026-04-26) and ADR-0028 §9. Read-only from the admin API
            perspective; not accepted on POST/PUT bodies.
        assignments:
          type: array
          items: { $ref: '#/components/schemas/CharacteristicAssignment' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateCanonicalProductRequest:
      type: object
      required: [name, manufacturer_id]
      properties:
        name: { type: string }
        manufacturer_id: { type: string }

    RenameRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }

    AssignCharacteristicRequest:
      type: object
      required: [characteristic_id, value]
      properties:
        characteristic_id: { type: string }
        value:
          description: Значение (string / number / boolean / ...)

    StatusChangeRequest:
      type: object
      required: [status]
      properties:
        status: { $ref: '#/components/schemas/CanonicalProductStatus' }

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

    # -------------------------------------------------------------------------
    # offers (iter-1 read-only)
    # -------------------------------------------------------------------------
    SupplierOffersEnvelope:
      type: object
      required: [items, page, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SupplierOffer' }
        page: { type: integer }
        total: { type: integer }

    SupplierOffer:
      type: object
      properties:
        id: { type: string, format: uuid }
        viewable_id: { type: [string, 'null'], description: "Resolved from canonical in Phase 2 — iter-1 leaves empty" }
        supplier: { type: string }
        sku: { type: string }
        identity_pack:
          type: object
          properties:
            supplier_sku: { type: string }
            manufacturer_article: { type: string }
            manufacturer_code: { type: string }
        name: { type: string }
        manufacturer:
          type: [object, 'null']
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        categories:
          type: array
          items: { type: string }
        characteristics:
          type: array
          items: { type: string }
          description: "iter-1: пустой массив; Phase 2 наполняется после normalization+matching"
        raw_attributes:
          type: object
          additionalProperties: true
        latest_observation:
          anyOf:
            - { $ref: '#/components/schemas/ObservationRef' }
            - { type: 'null' }
        canonical_id: { type: string, format: uuid }
        first_seen_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }

    ObservationRef:
      type: object
      properties:
        credential_ref: { type: string }
        credential_scope: { type: string, enum: [system, customer] }
        customer_ref: { type: [string, 'null'] }
        prices:
          type: object
          description: "map<ISO4217, PriceSet>"
          additionalProperties: { $ref: '#/components/schemas/PriceSet' }
        stock_current:
          type: object
          description: "map<warehouse_code, StockCurrent>"
          additionalProperties: true
        stock_forecast:
          type: object
          properties:
            incoming: { type: array, items: { type: object, additionalProperties: true } }
            in_way: { type: array, items: { type: object, additionalProperties: true } }
            backorders: { type: array, items: { type: object, additionalProperties: true } }
        delivery_term:
          type: object
          additionalProperties: true
        source_outcome: { type: string, enum: [ok, partial, not_found] }
        observed_at: { type: string, format: date-time }

    PriceSet:
      type: object
      description: |
        ADR-0025 price-set shape: net / gross / list_tarif / retail_rec
        в заданной валюте. `on_request=true` означает "цена по запросу" —
        числовые слоты при этом могут отсутствовать.
      properties:
        net: { type: [string, 'null'], description: "Decimal string" }
        gross: { type: [string, 'null'] }
        list_tarif: { type: [string, 'null'] }
        retail_rec: { type: [string, 'null'] }
        vat_rate: { type: [number, 'null'] }
        on_request: { type: boolean, default: false }

    # -------------------------------------------------------------------------
    # discovery/visibility — Visibility Policies
    # -------------------------------------------------------------------------
    ScopeRef:
      type: object
      required: [kind, ref]
      properties:
        kind:
          type: string
          enum: [customer, customer_pricing_group]
        ref:
          type: string
          format: uuid

    Predicates:
      type: object
      description: |
        Фильтр-критерии правила. AND между осями, OR внутри оси.
        Хотя бы одна ось должна быть непустой.
      properties:
        supplier_refs:
          type: array
          items: { type: string }
        seller_refs:
          type: array
          items: { type: string, format: uuid }
          default: []
        manufacturer_refs:
          type: array
          items: { type: string, format: uuid }
        classification_tags:
          type: array
          items: { type: string }

    VisibilityRule:
      type: object
      required: [id, scope, decision, predicates, effective_from, status, version, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        scope: { $ref: '#/components/schemas/ScopeRef' }
        decision: { type: string, enum: [deny, allow] }
        predicates: { $ref: '#/components/schemas/Predicates' }
        effective_from: { type: string, format: date-time }
        effective_until:
          type: [string, 'null']
          format: date-time
        status: { type: string, enum: [active, inactive] }
        description: { type: string, maxLength: 500 }
        version: { type: integer, format: int64 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateVisibilityRuleRequest:
      type: object
      required: [scope, decision, predicates, effective_from]
      properties:
        scope: { $ref: '#/components/schemas/ScopeRef' }
        decision: { type: string, enum: [deny, allow] }
        predicates: { $ref: '#/components/schemas/Predicates' }
        effective_from: { type: string, format: date-time }
        effective_until:
          type: [string, 'null']
          format: date-time
        description: { type: string, maxLength: 500 }

    UpdateVisibilityRuleRequest:
      type: object
      required: [version]
      description: |
        Частичное обновление. Поля, не переданные в теле, остаются без изменений.
        `effective_until: null` явно сбрасывает срок действия (открытый период).
      properties:
        version: { type: integer, format: int64 }
        decision: { type: string, enum: [deny, allow] }
        predicates: { $ref: '#/components/schemas/Predicates' }
        effective_until:
          type: [string, 'null']
          format: date-time
        description: { type: string, maxLength: 500 }

    ListVisibilityRulesResponse:
      type: object
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/VisibilityRule' }
        total: { type: integer }

    # -------------------------------------------------------------------------
    # supplier-network/sellers (P4b)
    # -------------------------------------------------------------------------
    SellerRatingDTO:
      type: object
      required: [score, observed_at, source]
      properties:
        score: { type: number, format: float, minimum: 0, maximum: 5 }
        sample_size: { type: integer, nullable: true, minimum: 0 }
        observed_at: { type: string, format: date-time }
        source: { type: string, enum: [marketplace, external_review, moderator] }

    SellerDTO:
      type: object
      required: [id, supplier_ref, external_id, display_name, status, created_at, updated_at, version]
      properties:
        id: { type: string, format: uuid }
        supplier_ref: { type: string, format: uuid }
        external_id: { type: string }
        display_name: { type: string }
        legal_name: { type: string }
        rating: { $ref: '#/components/schemas/SellerRatingDTO', nullable: true }
        status: { type: string, enum: [active, inactive, banned] }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        version: { type: integer, format: int64 }

    SellerListResponse:
      type: object
      required: [items, next_page_token]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SellerDTO' }
        next_page_token: { type: string }

    SellerStatusPatchRequest:
      type: object
      required: [status, reason, expected_version]
      properties:
        status: { type: string, enum: [active, inactive, banned] }
        reason: { type: string, minLength: 1, maxLength: 500 }
        expected_version: { type: integer, format: int64 }

    # -------------------------------------------------------------------------
    # credentials (P4c-2)
    # -------------------------------------------------------------------------
    RateLimitDTO:
      type: object
      required: [requests_per_minute, requests_per_day, burst_allowance]
      properties:
        requests_per_minute: { type: integer, minimum: 0 }
        requests_per_day: { type: integer, minimum: 0 }
        burst_allowance: { type: integer, minimum: 0 }

    Credential:
      type: object
      description: |
        Read-only projection of a supplier credential. The secret_value field
        is intentionally absent — it is never returned in HTTP responses.
      required: [id, scope, supplier_ref, status, allowed_usages, rate_limit, version]
      properties:
        id: { type: string, format: uuid }
        scope: { type: string, enum: [system, customer] }
        owner_ref: { type: string, format: uuid, nullable: true }
        supplier_ref: { type: string }
        status: { type: string, enum: [active, inactive, revoked] }
        allowed_usages:
          type: array
          items:
            type: string
            enum: [customer_proposal, catalog_sync, price_refresh, stock_refresh, diagnostic_probe, order_placement]
        valid_from: { type: string, format: date-time, nullable: true }
        valid_to: { type: string, format: date-time, nullable: true }
        rate_limit: { $ref: '#/components/schemas/RateLimitDTO' }
        version: { type: integer, format: int64 }

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

    CreateCredentialRequest:
      type: object
      required: [scope, supplier_ref, status, allowed_usages, secret_value, reason]
      properties:
        scope: { type: string, enum: [system, customer] }
        owner_ref: { type: string, format: uuid, nullable: true }
        supplier_ref: { type: string }
        status: { type: string, enum: [active, inactive] }
        allowed_usages:
          type: array
          items:
            type: string
            enum: [customer_proposal, catalog_sync, price_refresh, stock_refresh, diagnostic_probe, order_placement]
        valid_from: { type: string, format: date-time, nullable: true }
        valid_to: { type: string, format: date-time, nullable: true }
        rate_limit: { $ref: '#/components/schemas/RateLimitDTO' }
        secret_value: { type: string, minLength: 1 }
        reason: { type: string, minLength: 1 }

    UpdateCredentialMetadataRequest:
      type: object
      required: [status, allowed_usages, rate_limit, version, reason]
      properties:
        status: { type: string, enum: [active, inactive, revoked] }
        allowed_usages:
          type: array
          items:
            type: string
            enum: [customer_proposal, catalog_sync, price_refresh, stock_refresh, diagnostic_probe, order_placement]
        valid_from: { type: string, format: date-time, nullable: true }
        valid_to: { type: string, format: date-time, nullable: true }
        rate_limit: { $ref: '#/components/schemas/RateLimitDTO' }
        version: { type: integer, format: int64 }
        reason: { type: string, minLength: 1 }

    RotateSecretRequest:
      type: object
      required: [secret_value, reason]
      properties:
        secret_value: { type: string, minLength: 1 }
        reason: { type: string, minLength: 1 }

    StatusTransitionRequest:
      type: object
      required: [status, reason]
      properties:
        status: { type: string, enum: [active, inactive, revoked] }
        reason: { type: string, minLength: 1 }

    ResetHealthRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, minLength: 1 }

    DeleteCredentialRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, minLength: 1 }

    AdminAPIKey:
      type: object
      required: [id, customer_id, name, env, role, scopes, status, created_at, created_by_admin]
      properties:
        id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        name: { type: string }
        env: { type: string, enum: [production, test] }
        role: { type: string, enum: [viewer, editor] }
        scopes:
          type: array
          items: { type: string }
        status: { type: string, enum: [active, revoked] }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        created_by_admin: { type: boolean, description: "Ключ создан администратором от имени клиента" }

    AdminAPIKeyList:
      type: object
      required: [items, total]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AdminAPIKey' }
        total: { type: integer }

    AdminCreateAPIKeyRequest:
      type: object
      required: [name, env, role]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        env: { type: string, enum: [production, test] }
        role: { type: string, enum: [viewer, editor] }
        scopes:
          type: array
          items: { type: string }
        expires_at: { type: string, format: date-time, nullable: true }

    AdminCreateAPIKeyResponse:
      type: object
      required: [key, secret]
      properties:
        key: { $ref: '#/components/schemas/AdminAPIKey' }
        secret: { type: string, description: "Открытый секрет — доступен только в этом ответе (reveal-once)" }

    # -------------------------------------------------------------------------
    # catalog search / facets (Phase 1A)
    # -------------------------------------------------------------------------
    CanonicalSearchRequest:
      type: object
      required: [filter]
      properties:
        filter:
          $ref: '#/components/schemas/FilterNode'
        sort:
          type: string
          description: "name | created_at | assignments_count, prefix `-` for desc"
        limit:
          type: integer
        offset:
          type: integer

    FilterNode:
      type: object
      required: [op]
      properties:
        op:
          type: string
          description: "and | or (composite) OR eq | in | range | exists | missing (leaf)"
        args:
          type: array
          items: { $ref: '#/components/schemas/FilterNode' }
          description: "Children for composite (and/or) nodes"
        field:
          type: string
          enum: [status, manufacturer_id, characteristic, supplier_sku, has_offers_from]
          description: "Leaf field name"
        id:
          type: string
          description: "Characteristic ID for characteristic leaves (UUID)"
        value:
          type: string
        values:
          type: array
          items: { type: string }
        min:
          type: string
        max:
          type: string

    CatalogFacets:
      type: object
      properties:
        scope: { type: string, enum: [canonical, offers] }
        manufacturers:
          type: array
          items: { $ref: '#/components/schemas/FacetCount' }
        statuses:
          type: array
          items: { $ref: '#/components/schemas/FacetCount' }
        suppliers_top:
          type: array
          items: { $ref: '#/components/schemas/FacetCount' }
        characteristics_top:
          type: array
          items: { $ref: '#/components/schemas/CharacteristicFacets' }

    FacetCount:
      type: object
      properties:
        value: { type: string }
        label: { type: string }
        count: { type: integer, format: int64 }

    CharacteristicFacets:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        values_top:
          type: array
          items: { $ref: '#/components/schemas/FacetCount' }

    # -------------------------------------------------------------------------
    # quality overview (Phase 7)
    # -------------------------------------------------------------------------
    QualityOverview:
      type: object
      required: [canonical_normalization, data_collection, categorization, per_supplier, generated_at]
      properties:
        canonical_normalization:
          $ref: '#/components/schemas/CanonicalNormalizationStats'
        data_collection:
          $ref: '#/components/schemas/DataCollectionStats'
        categorization:
          $ref: '#/components/schemas/CategorizationStats'
        per_supplier:
          type: array
          items: { $ref: '#/components/schemas/SupplierQualityStats' }
        generated_at:
          type: string
          format: date-time

    CanonicalNormalizationStats:
      type: object
      required: [total, no_offers, no_assignments, low_confidence_assignments, duplicate_names]
      properties:
        total:                    { type: integer, format: int64 }
        no_offers:                { type: integer, format: int64 }
        no_assignments:           { type: integer, format: int64 }
        low_confidence_assignments: { type: integer, format: int64 }
        duplicate_names:          { type: integer, format: int64 }

    DataCollectionStats:
      type: object
      required: [offers_total, offers_no_observations, offers_stale_7d, offers_no_price, offers_no_stock]
      properties:
        offers_total:           { type: integer, format: int64 }
        offers_no_observations: { type: integer, format: int64 }
        offers_stale_7d:        { type: integer, format: int64 }
        offers_no_price:        { type: integer, format: int64 }
        offers_no_stock:        { type: integer, format: int64 }

    CategorizationStats:
      type: object
      required: [canonical_no_category, offers_no_category]
      properties:
        canonical_no_category: { type: integer, format: int64 }
        offers_no_category:    { type: integer, format: int64 }

    SupplierQualityStats:
      type: object
      required: [supplier, offers_count, with_prices_pct, with_stocks_pct, stale_pct]
      properties:
        supplier:        { type: string }
        offers_count:    { type: integer, format: int64 }
        with_prices_pct: { type: number, format: double }
        with_stocks_pct: { type: number, format: double }
        stale_pct:       { type: number, format: double }
        last_sync:       { type: string, format: date-time }
