openapi: 3.0.3
# ─────────────────────────────────────────────────────────────────────────────
# eTRN T1-API — единый продуктовый REST API платформы (система-система).
#
# Назначение: ТМС/ERP клиента подаёт электронные транспортные накладные (ЭТрН)
# и следит за их жизненным циклом, НЕ зная ничего про операторов ЭДО. Один
# контракт для всех операторов (Контур, Астрал, СБИС, СберКорус): маршрутизацию,
# агентские/dedicated-учётки и роуминг платформа решает сама.
#
# Статус: ядро v1 + вебхуки + песочница РЕАЛИЗОВАНЫ и живут на проде
# (api.etrn.app/v1, 16.08.2026). Пометки x-maturity:
#   core    — ядро эпика (~2 недели), ложится на существующий агрегатор;
#   planned — следующая итерация (v1.1), в контракте для полноты картины.
#
# Design-doc: docs/t1-api-design.md (в этом же репозитории).
# ─────────────────────────────────────────────────────────────────────────────
info:
  title: eTRN T1-API
  version: 1.0-draft
  description: |
    Программная подача ЭТрН (титул Т1 грузоотправителя), отслеживание статуса
    перевозки, получение документов цепочки (Т1–Т4) и вебхуки о событиях.

    **Принципы**
    - Операторонезависимость по умолчанию: клиенту не нужно разбираться в
      операторах ЭДО — если `operator` при подаче не передан, его выбирает
      платформа (правило настраивается поклиентно в админке платформы).
      Захотел управлять сам — передаёт код из GET /operators; поле `operator`
      в ответах — справочная прозрачность.
    - Тенант = организация (ИНН). API-ключ принадлежит организации, все данные
      скоупятся ею.
    - Идемпотентность записи: POST /waybills с `Idempotency-Key` безопасно
      повторять.
    - Песочница — часть ядра: `etrn_test_`-ключи работают против управляемых
      моков операторов, `POST /waybills/{id}/simulate` прогоняет цепочку до
      `delivered` — интеграция пишется и проверяется, не потратив ни одного
      боевого документа.
    - Версия — сегмент базового пути (`servers`): все ручки живут под
      `https://api.etrn.app/v1/…`, поэтому в самих путях префикса нет —
      версия задаётся в одном месте, а не повторяется в каждой ручке. Внутри
      v1 эволюция только аддитивная (поля добавляются, существующие не
      меняются и не исчезают); ломающее изменение = новый базовый путь `/v2`,
      v1 работает до объявленного заката.
  contact:
    name: eTRN Platform
    url: https://etrn.app
servers:
  - url: https://api.etrn.app/v1
security:
  - apiKey: []

tags:
  - name: waybills
    description: Накладные (перевозки) и их документы
  - name: webhooks
    description: Подписки на события
  - name: organization
    description: Организация, лимиты, операторы

paths:
  /waybills:
    post:
      x-maturity: core
      tags: [waybills]
      operationId: createWaybill
      summary: Подать ЭТрН (титул Т1 грузоотправителя)
      description: |
        Ядро v1: клиент строит титул Т1 по формату ФНС (5.01) и подписывает
        своей КЭП сам — передаёт готовую пару XML+подпись (`signed_xml`).
        Платформа маршрутизирует накладную оператору ЭДО.

        v1.1 (planned): структурированный JSON (`draft`) — титул строит
        платформа, подпись двухфазно (см. /waybills/{id}/prepared-title).

        Повтор запроса с тем же `Idempotency-Key` возвращает исходный результат
        (200 вместо 201), дублей не создаёт.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWaybillRequest'
      responses:
        '201':
          description: |
            Накладная принята. Обычный случай — подача прошла синхронно и
            status уже `pending_receipt`; если оператор временно перегружен,
            вернётся `submitting` — платформа досылает сама с ретраями,
            итог придёт событием `waybill.status_changed`
            (`pending_receipt` или `failed` с причиной в `failure`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Waybill'
        '200':
          description: Повтор по Idempotency-Key — исходный результат
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Waybill'
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        default: { $ref: '#/components/responses/Problem' }
    get:
      x-maturity: core
      tags: [waybills]
      operationId: listWaybills
      summary: Список накладных организации
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/WaybillStatus' }
        - name: number
          in: query
          description: Точный номер ТрН (НомерТрН)
          schema: { type: string }
        - name: created_from
          in: query
          schema: { type: string, format: date-time }
        - name: created_to
          in: query
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: Курсор из `next_cursor` предыдущей страницы
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Страница списка, новые сверху
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Waybill' }
                  next_cursor:
                    type: string
                    description: Пусто — страниц больше нет
        default: { $ref: '#/components/responses/Problem' }

  /waybills/validate:
    post:
      x-maturity: core
      tags: [waybills]
      operationId: validateWaybill
      summary: Проверить титул без подачи
      description: |
        Тот же конвейер проверок, что у `POST /waybills` — схема ФНС,
        криптопроверка подписи (для signed_xml), полнота полей, достижимость
        сторон — но без подачи оператору, без списания подписи и без создания
        объекта. Назначение: отладка титулогенератора клиента и прогон в его
        CI на каждом релизе. Идемпотентна по определению, ключ не требуется.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWaybillRequest'
      responses:
        '200':
          description: Итог проверки (валидно или список проблем — это не ошибка запроса)
          content:
            application/json:
              schema:
                type: object
                required: [valid]
                properties:
                  valid: { type: boolean }
                  problems:
                    type: array
                    description: Пусто при valid=true
                    items:
                      type: object
                      required: [field, message]
                      properties:
                        field: { type: string, example: draft.cargo.gross_mass_kg }
                        message: { type: string }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}:
    get:
      x-maturity: core
      tags: [waybills]
      operationId: getWaybill
      summary: Карточка накладной с цепочкой документов
      parameters:
        - $ref: '#/components/parameters/WaybillId'
      responses:
        '200':
          description: Накладная
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WaybillDetails'
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}/documents/{documentId}/content:
    get:
      x-maturity: core
      tags: [waybills]
      operationId: downloadWaybillDocument
      summary: Скачать документ цепочки (XML титула или отсоединённую подпись)
      parameters:
        - $ref: '#/components/parameters/WaybillId'
        - name: documentId
          in: path
          required: true
          schema: { type: string }
        - name: part
          in: query
          required: true
          schema: { type: string, enum: [xml, sig] }
      responses:
        '200':
          description: Файл как отдал оператор (XML — windows-1251 по ФНС)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}/qr:
    get:
      x-maturity: core
      tags: [waybills]
      operationId: getWaybillQr
      summary: QR подтверждения перевозки для проверки на дороге
      description: |
        PNG с QR-кодом, по которому перевозка сверяется при дорожной
        проверке (инспектор смотрит по ГИС ЭПД). ТМС вкладывает его в
        маршрутный лист или отдаёт водителю; в мобильном приложении eTRN
        этот же QR уже показывается на карточке рейса.
      parameters:
        - $ref: '#/components/parameters/WaybillId'
      responses:
        '200':
          description: PNG
          content:
            image/png:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}/simulate:
    post:
      x-maturity: core
      tags: [waybills]
      operationId: simulateWaybill
      summary: "[Только тест-контур] Прогнать цепочку вперёд"
      description: |
        Работает ТОЛЬКО с ключами `etrn_test_` (песочница на управляемых
        моках операторов): продвигает накладную по жизненному циклу без
        реального водителя и оператора — за минуты проверяется полный цикл,
        включая вебхуки `status_changed`/`document_added` и появление
        подписанных Т2/Т4 в цепочке. В боевом контуре ручки нет (404).
      parameters:
        - $ref: '#/components/parameters/WaybillId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [advance_to]
              properties:
                advance_to:
                  $ref: '#/components/schemas/WaybillStatus'
      responses:
        '200':
          description: Новое состояние накладной
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Waybill' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Переход назад по циклу невозможен
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}/prepared-title:
    get:
      x-maturity: planned
      tags: [waybills]
      operationId: getPreparedTitle
      summary: "[v1.1] XML титула, подготовленный платформой, на подпись"
      description: |
        Вторая фаза подачи из `draft`: платформа построила титул (включая
        СвДовер/МЧД, если переданы) — клиент забирает XML, подписывает своей
        КЭП и возвращает подпись в POST /waybills/{id}/signature. Титул
        неизменен между фазами: подпись кладётся на байты как есть.
      parameters:
        - $ref: '#/components/parameters/WaybillId'
      responses:
        '200':
          description: XML титула (windows-1251)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Накладная не в статусе ожидания подписи
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        default: { $ref: '#/components/responses/Problem' }

  /waybills/{waybillId}/signature:
    post:
      x-maturity: planned
      tags: [waybills]
      operationId: submitTitleSignature
      summary: "[v1.1] Подпись подготовленного титула"
      parameters:
        - $ref: '#/components/parameters/WaybillId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature]
              properties:
                signature:
                  type: string
                  format: byte
                  description: Отсоединённая КЭП (CMS/PKCS7, base64)
      responses:
        '200':
          description: Подпись принята, накладная подаётся оператору
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Waybill' }
        '409':
          description: Титул не ожидает подписи
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        default: { $ref: '#/components/responses/Problem' }

  /webhooks:
    post:
      x-maturity: core
      tags: [webhooks]
      operationId: createWebhook
      summary: Подписаться на события
      description: |
        События доставляются POST'ом на `url` с подписью
        `X-Etrn-Signature: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + body))>`.
        `secret` — ключ этой подписи: генерирует платформа, возвращается
        ТОЛЬКО в ответе на создание (утерян — пересоздайте подписку).
        Гарантия — at-least-once: обработчик обязан быть идемпотентным по
        `event.id`. Ответ не-2xx → повторы с экспоненциальной паузой до 24 ч.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Только https
                events:
                  type: array
                  description: |
                    Какие события слать. Поле необязательное: пусто или
                    отсутствует — подписка на ВСЕ типы (так и сохранится, в
                    ответе список развёрнут явно). Требовать его нет смысла:
                    типовая интеграция хочет всё.
                  items: { $ref: '#/components/schemas/EventType' }
      responses:
        '201':
          description: Подписка создана; secret показывается ОДИН раз
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookWithSecret' }
        default: { $ref: '#/components/responses/Problem' }
      callbacks:
        eventDelivery:
          '{$request.body#/url}':
            post:
              summary: Доставка события платформой на url подписки
              requestBody:
                required: true
                content:
                  application/json:
                    schema: { $ref: '#/components/schemas/Event' }
              responses:
                '200':
                  description: Любой 2xx = доставлено; иначе ретраи до 24 ч
    get:
      x-maturity: core
      tags: [webhooks]
      operationId: listWebhooks
      summary: Список подписок (без секретов)
      responses:
        '200':
          description: Подписки организации
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
        default: { $ref: '#/components/responses/Problem' }

  /webhooks/{webhookId}:
    delete:
      x-maturity: core
      tags: [webhooks]
      operationId: deleteWebhook
      summary: Удалить подписку
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Удалена }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Problem' }

  /events:
    get:
      x-maturity: planned
      tags: [webhooks]
      operationId: listEvents
      summary: "[v1.1] Лента событий организации (альтернатива вебхукам)"
      description: |
        Те же события, что уходят в вебхуки, — курсорной лентой (хранение
        30 дней). Интеграция может вообще не поднимать публичный endpoint:
        поллит ленту с сохранённого курсора и не теряет события даже после
        собственного простоя.
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/EventType' }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
      responses:
        '200':
          description: Страница событий в порядке возникновения (старые сверху)
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Event' }
                  next_cursor:
                    type: string
                    description: Пусто — новее событий пока нет
        default: { $ref: '#/components/responses/Problem' }

  /organization:
    get:
      x-maturity: core
      tags: [organization]
      operationId: getOrganization
      summary: Организация ключа — тариф и остаток лимита
      responses:
        '200':
          description: Организация
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        default: { $ref: '#/components/responses/Problem' }

  /operators:
    get:
      x-maturity: core
      tags: [organization]
      operationId: listOperators
      summary: Справочник операторов ЭДО и их доступность
      description: |
        Коды для необязательного поля `operator` при подаче накладной + их
        текущая доступность (в ТМС можно показать «отправка временно
        недоступна» и не предлагать выключенного оператора). Если `operator`
        при подаче не передан, выбор остаётся за платформой.
      responses:
        '200':
          description: Операторы
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required: [code, name, enabled]
                      properties:
                        code: { type: string, example: kontur }
                        name: { type: string, example: Контур.Диадок }
                        enabled: { type: boolean }
                        disabled_reason:
                          type: string
                          description: Показывается пользователям при enabled=false
        default: { $ref: '#/components/responses/Problem' }

  /counterparties/{inn}:
    get:
      x-maturity: core
      tags: [organization]
      operationId: checkCounterparty
      summary: Достижимость контрагента до подачи
      description: |
        Можно ли доставить документы организации с этим ИНН — ДО создания
        рейса, а не отказом после подачи: подключена ли она к ЭДО, у каких
        операторов, достижима ли напрямую или роумингом. ТМС зовёт это на
        этапе планирования рейса и сразу видит проблемных контрагентов.
        Под капотом — та же детекция присутствия, которой платформа
        маршрутизирует накладные.
      parameters:
        - name: inn
          in: path
          required: true
          schema: { type: string, example: '7713488175' }
      responses:
        '200':
          description: Итог проверки
          content:
            application/json:
              schema:
                type: object
                required: [inn, reachable]
                properties:
                  inn: { type: string }
                  reachable:
                    type: boolean
                    description: Документы доставимы (напрямую или роумингом)
                  name:
                    type: string
                    description: Название организации, если нашлась
                  operators:
                    type: array
                    description: У каких операторов найдены ящики контрагента
                    items:
                      type: object
                      required: [code]
                      properties:
                        code: { type: string, example: kontur }
                        roaming:
                          type: boolean
                          description: Достижим через роуминг (не наш прямой оператор)
        default: { $ref: '#/components/responses/Problem' }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        API-ключ организации: `Authorization: Bearer etrn_live_<48 hex>`.
        Выдаётся в веб-кабинете — раздел «Настройки → API» (доступен только
        владельцу/админу организации) — и в админке платформы; значение
        показывается один раз, платформа хранит только хэш. Скоупы ключа:
        `waybills:read`, `waybills:write`, `webhooks:manage`.

  parameters:
    WaybillId:
      name: waybillId
      in: path
      required: true
      description: UUID накладной в eTRN (`Waybill.id`)
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Уникальный ключ запроса (например UUID). Повтор с тем же ключом в
        течение 24 ч возвращает исходный результат и не создаёт дубль.
      schema: { type: string, maxLength: 128 }

  responses:
    Problem:
      description: Ошибка (RFC 9457 Problem Details)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Не найдено в скоупе организации ключа
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PaymentRequired:
      description: Лимит тарифа исчерпан — оплата в кабинете
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnprocessableEntity:
      description: Титул/подпись не проходят проверку (детали в `errors`)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Problem:
      type: object
      description: |
        RFC 9457 Problem Details (действующая редакция, заменившая RFC 7807)
        + request_id для обращения в поддержку. HTTP-статус в теле
        не дублируется (единственный источник — статус самого ответа);
        машинная классификация ошибки — по стабильному URI в `type`.
      required: [title]
      properties:
        type: { type: string, format: uri, example: 'https://api.etrn.app/errors/tariff-limit' }
        title: { type: string, example: Лимит подписей тарифа исчерпан }
        detail: { type: string }
        request_id: { type: string }
        errors:
          type: array
          description: Пополевые ошибки для 422
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string, example: signed_xml.signature }
              message: { type: string }

    WaybillStatus:
      type: string
      description: |
        Жизненный цикл перевозки (агрегат по цепочке титулов):
        - `submitting` — платформа подаёт титул оператору. При синхронной
          подаче не виден (ответ сразу `pending_receipt`); появляется, когда
          подача асинхронна — оператор перегружен и платформа досылает сама
          (клиенту не нужны свои ретраи) или идёт draft-цепочка v1.1;
        - `awaiting_signature` — [v1.1] титул построен и ждёт подписи:
          человека в приложении eTRN (`signing: app`) или возврата подписи
          клиентом (двухфазный draft);
        - `failed` — подать не удалось окончательно (ретраи исчерпаны или
          оператор отклонил документ). Терминальный; причина — в
          `Waybill.failure`, списанная подпись возвращается. Титул юридически
          неизменяем, поэтому путь дальше — исправить и подать новую
          накладную;
        - `pending_receipt` — ждёт приёмки груза водителем (Т2);
        - `in_transit` — груз в пути;
        - `pending_delivery` — ждёт закрытия выгрузки (Т4 перевозчика /
          Т3 грузополучателя);
        - `delivered` — доставлено, цепочка завершена;
        - `cancelled` — аннулирована.
      enum: [submitting, awaiting_signature, failed, pending_receipt, in_transit, pending_delivery, delivered, cancelled]

    EventType:
      type: string
      enum:
        - waybill.created
        - waybill.status_changed
        - waybill.document_added
      description: |
        - `waybill.created` — накладная принята платформой;
        - `waybill.status_changed` — сменился WaybillStatus;
        - `waybill.document_added` — в цепочке новый документ (напр. подписанный Т2/Т4).

    PartyRole:
      type: string
      enum: [shipper, carrier, consignee]
      description: shipper — грузоотправитель, carrier — перевозчик, consignee — грузополучатель

    Party:
      type: object
      description: Участник перевозки (для маршрутизации у оператора)
      required: [role, inn]
      properties:
        role: { $ref: '#/components/schemas/PartyRole' }
        inn: { type: string, example: '1650152978' }
        kpp: { type: string, example: '165001001' }
        operator_subscriber_id:
          type: string
          description: Ид абонента у оператора, если клиент его знает (необязательно)

    SignedXml:
      type: object
      description: Готовый титул Т1 (формат ФНС 5.01) с отсоединённой КЭП
      required: [xml, signature]
      properties:
        xml:
          type: string
          format: byte
          description: XML титула, base64 (кодировка файла — windows-1251)
        signature:
          type: string
          format: byte
          description: Отсоединённая КЭП (CMS/PKCS7), base64

    WaybillDraft:
      type: object
      x-maturity: planned
      description: |
        [v1.1] Структурированная накладная — титул строит платформа.
        Состав полей соответствует титулу ФНС (5.01); обязательный минимум
        уточняется на реализации v1.1.
      properties:
        number: { type: string, description: НомерТрН }
        date: { type: string, format: date, description: ДатаТрН }
        order_number: { type: string, description: 'НомЗак (пусто — «Без номера»)' }
        shipper: { $ref: '#/components/schemas/DraftParty' }
        consignee: { $ref: '#/components/schemas/DraftParty' }
        carrier: { $ref: '#/components/schemas/DraftParty' }
        cargo:
          type: object
          properties:
            name: { type: string, description: НаимГруз }
            gross_mass_kg: { type: number }
            places: { type: integer }
        driver:
          type: object
          properties:
            last_name: { type: string }
            first_name: { type: string }
            patronymic: { type: string }
            phone: { type: string }
            license_series: { type: string }
            license_number: { type: string }
        vehicle:
          type: object
          properties:
            plate: { type: string, description: РегНомер }
            brand: { type: string }
            capacity_kg: { type: integer }
        loading:
          type: object
          properties:
            address: { $ref: '#/components/schemas/Address' }
            time: { type: string, format: date-time }
        unloading:
          type: object
          properties:
            address: { $ref: '#/components/schemas/Address' }
        signatory:
          type: object
          description: Подписант грузоотправителя; МЧД — реквизитами, попадёт в СвДовер титула
          properties:
            last_name: { type: string }
            first_name: { type: string }
            patronymic: { type: string }
            position: { type: string }
            poa:
              type: object
              description: Реквизиты МЧД (если подписант действует по доверенности)
              properties:
                guid: { type: string, format: uuid }
                principal_inn: { type: string }

    DraftParty:
      type: object
      x-maturity: planned
      properties:
        name: { type: string, description: НаимОрг }
        inn: { type: string }
        kpp: { type: string }
        phone: { type: string }
        address: { $ref: '#/components/schemas/Address' }

    Address:
      type: object
      description: Адрес в терминах АдрРФ
      properties:
        index: { type: string }
        region_code: { type: string, description: 'КодРегион, напр. «16»' }
        city: { type: string }
        street: { type: string }
        house: { type: string }

    CreateWaybillRequest:
      type: object
      required: [parties]
      description: |
        Ровно один источник содержимого: `signed_xml` (ядро v1) ИЛИ `draft`
        (v1.1) — взаимоисключение выражено `oneOf` ниже, оба сразу или ни
        одного валидатор отклоняет. `parties` обязателен всегда — по нему
        платформа маршрутизирует накладную (перевозчик и грузополучатель
        должны быть достижимы напрямую или роумингом).
      oneOf:
        - required: [signed_xml]
          not: { required: [draft] }
        - required: [draft]
          not: { required: [signed_xml] }
      properties:
        signed_xml: { $ref: '#/components/schemas/SignedXml' }
        draft: { $ref: '#/components/schemas/WaybillDraft' }
        parties:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Party' }
        operator:
          type: string
          example: kontur
          description: |
            Код оператора ЭДО (справочник — GET /operators), если клиент хочет
            выбрать его сам. Не передан — оператора выбирает платформа по
            правилу организации (настраивается поклиентно в админке платформы).
            Неизвестный или выключенный код — 422.
        signing:
          type: string
          enum: [external, app]
          default: external
          description: |
            [app — v1.1, только вместе с draft] Кто подписывает титул:
            `external` — клиент сам (готовая пара в signed_xml либо
            draft + POST /signature); `app` — накладную подписывает
            уполномоченный человек в приложении eTRN (пуш → подпись КЭП с
            токена или из облака). ТМС не нужны криптосредства на сервере.
            Подписант должен быть пользователем eTRN с действующей КЭП
            (и МЧД, если он не руководитель организации).
        external_ref:
          type: string
          maxLength: 128
          description: Ссылка клиента (номер рейса в ТМС) — вернётся в ответах и вебхуках

    Waybill:
      type: object
      required: [id, status, created_at]
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/WaybillStatus' }
        failure:
          type: object
          description: |
            Заполнено только при status=failed — почему подача не удалась.
            `code` — машинный классификатор (`operator_rejected`,
            `submission_retries_exhausted`), `message` — человеку (включая
            текст отказа оператора, если он был).
          required: [code, message]
          properties:
            code: { type: string, example: operator_rejected }
            message: { type: string, example: 'Оператор отклонил титул: не найден абонент грузополучателя' }
        number: { type: string, description: НомерТрН из титула }
        date:
          type: string
          format: date
          description: |
            ДатаТрН, ISO 8601 (YYYY-MM-DD). Внутрититульный формат
            «дд.мм.гггг» наружу не протекает — конвертируем при чтении.
        external_ref: { type: string }
        operator:
          type: string
          description: Справочно — через какого оператора идёт документооборот
        operator_waybill_id:
          type: string
          description: Ид перевозки у оператора (для сверки на их стороне)
        shipper_name: { type: string }
        carrier_name: { type: string }
        consignee_name: { type: string }
        loading_address: { type: string }
        unloading_address: { type: string }
        cargo_description: { type: string }
        driver_name: { type: string }
        vehicle_plate: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WaybillDetails:
      allOf:
        - $ref: '#/components/schemas/Waybill'
        - type: object
          properties:
            documents:
              type: array
              description: Цепочка документов (Т1–Т4 и прочее), в порядке появления
              items: { $ref: '#/components/schemas/WaybillDocument' }

    WaybillDocument:
      type: object
      required: [id, kind, title]
      properties:
        id: { type: string, description: Ид для скачивания content }
        kind:
          type: string
          enum: [T1, T2, T3, T4, T5, T6, T7, T8, T9, '']
          description: |
            Номер титула ЭТрН (полная карта — §6.1 дизайн-доки):
            - `T1` — титул грузоотправителя (основные сведения о перевозке);
            - `T2` — титул перевозчика: приёмка груза;
            - `T3` — титул грузополучателя: приём груза;
            - `T4` — титул перевозчика: выдача груза;
            - `T5` — подтверждение стоимости перевозки (грузоотправитель);
            - `T6` — изменение стоимости перевозки (перевозчик);
            - `T7` — переадресация (перевозчик);
            - `T8` — замена водителя или транспорта (перевозчик);
            - `T9` — указание о переадресации (грузоотправитель/грузополучатель);
            - `""` — прочий документ цепочки (служебные квитанции оператора).
            Чтение и событие `waybill.document_added` работают для всех
            видов сразу: интеграция никогда не встречает «неизвестный
            документ». Подача через API: T1 — v1; T5–T9 — v1.2; T2/T4
            подписывает водитель в приложении, T3 — сторона грузополучателя.
        title: { type: string, example: Титул грузоотправителя }
        file_name: { type: string, description: 'ФНС-имя файла, если известно' }
        date:
          type: string
          format: date-time
          description: Когда документ появился в цепочке (ISO 8601)
        has_signature:
          type: boolean
          description: Доступна ли отсоединённая ЭП (part=sig)
        gis_epd:
          type: object
          x-maturity: planned
          description: |
            [v1.1] Квитанция ГИС ЭПД по этому титулу — юридическая значимость
            наступает именно там, поэтому ТМС важно видеть не «мы отправили»,
            а «государство приняло». Охват зависит от того, как оператор
            отдаёт квитанции (матрица уточняется на реализации).
          required: [status]
          properties:
            status:
              type: string
              enum: [pending, accepted, rejected]
            updated_at: { type: string, format: date-time }
            message:
              type: string
              description: Текст отказа ГИС ЭПД при rejected

    Webhook:
      type: object
      description: Подписка, как её видно всегда (список) — секрета здесь нет
      required: [id, url, events, status, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/EventType' }
        status:
          type: string
          enum: [active, broken]
          description: |
            `broken` — платформа сутки не могла доставить события и перестала
            пытаться. Без этого поля мёртвая подписка выглядит живой:
            события просто перестают идти, и причину со стороны не увидеть.
            Чинится пересозданием подписки (адрес приёмника уже работает).
        created_at: { type: string, format: date-time }

    WebhookWithSecret:
      description: Ответ на создание подписки — единственное место с секретом
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                Ключ HMAC-SHA256 для проверки `X-Etrn-Signature`. Генерирует
                платформа (клиентские значения не принимаем — гарантируем
                энтропию); возвращается один раз здесь и больше нигде: список
                подписок его не отдаёт. Утерян — удалите подписку и создайте
                новую, это же и ротация секрета.

    Event:
      type: object
      description: Тело POST-доставки вебхука
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: Ключ идемпотентности обработчика (at-least-once!)
        type: { $ref: '#/components/schemas/EventType' }
        created_at: { type: string, format: date-time }
        data:
          type: object
          required: [waybill]
          properties:
            waybill: { $ref: '#/components/schemas/Waybill' }
            document:
              $ref: '#/components/schemas/WaybillDocument'
            previous_status:
              $ref: '#/components/schemas/WaybillStatus'

    Organization:
      type: object
      required: [inn, name]
      properties:
        inn: { type: string }
        kpp: { type: string }
        name: { type: string }
        tariff:
          type: object
          description: Активный тариф и остаток (ключ списания — организация+документ+титул)
          properties:
            name: { type: string, example: Флот }
            period_end: { type: string, format: date }
            documents_limit: { type: integer }
            documents_used: { type: integer }
