# OpenAPI 3.1 da API pública do Prodio — Entrega 1 (leituras de cadastros e estoque).
#
# Fonte dos nomes: docs/api/contrato-v1.md (rotas, campos, parâmetros, envelope, erros). A tabela de rotas do worker
# (apps/worker/src/api/rotas.ts) e este arquivo são comparados nos dois sentidos pelo apps/worker/src/api/openapi.test.ts:
# rota sem doc ou doc sem rota reprova. As Entregas 2 e 3 acrescentam paths e esquemas aqui, no mesmo padrão
# (operationId = verbo + recurso; `x-prodio-escopo` em toda operação). O site publica este arquivo em prodio.com.br/api.
#
# Exemplos de uma confecção inventada (Confecção Aurora, prefixo AU, SKUs CAM-AZUL-M e MAL-AZ, CNPJ 11222333000181):
# nenhum dado da operação real entra aqui.
openapi: 3.1.0
info:
  title: API do Prodio
  version: 1.0.0
  summary: Leia e grave os dados da sua fábrica a partir do sistema da sua empresa.
  description: |
    A API do Prodio deixa o sistema da sua empresa (ERP, loja, planilha automatizada, BI) ler e gravar os mesmos dados que
    você vê nas telas. O endereço é `https://api.prodio.com.br/v1`, as respostas são JSON UTF-8 em português e o acesso é
    por um **token da empresa**, criado em Configurações › API por um administrador.

    **Versão.** A versão `1.0.0` deste documento acompanha a rota `/v1`. Mudança **aditiva** (campo novo, rota nova,
    valor novo num enum) não muda a versão da API: ignore os campos que não conhece. Quebra de compatibilidade vira `/v2`.

    **Autenticação.** Cabeçalho `Authorization: Bearer prodio_live_…` (token de 55 caracteres). Token na URL é recusado
    com 400 `token_na_url`. Cada rota exige o escopo indicado em `x-prodio-escopo` (`qualquer` = qualquer token);
    `:escrever` inclui `:ler` do mesmo recurso. `custos:ler` libera os campos em reais; sem ele eles vêm `null` e o resto
    da resposta continua igual.

    **Limites.** 120 chamadas por minuto por token e 300 por minuto por empresa, em janela fixa de 1 minuto. Acima disso
    429 `limite_excedido` com `Retry-After` (segundos). Toda resposta autenticada traz `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` e `X-RateLimit-Reset` (instante Unix em segundos). Toda resposta traz `X-Request-Id`: cite-o
    ao pedir suporte.

    **Paginação e leitura incremental.** Toda lista devolve `{ "dados": [ … ], "proximo_cursor": "…" }`. `limite` padrão
    50, máximo 200; ordem estável crescente por `atualizado_em` (ou `criado_em` nos movimentos) com desempate por `id`.
    Repita exatamente o `proximo_cursor` recebido no parâmetro `cursor`; `null` = acabou; cursor adulterado → 400
    `cursor_invalido`. `atualizado_desde` e `desde` são **inclusivos** (a última linha da carga anterior volta: trate-a
    como atualização). Há uma **janela de estabilização de 30 segundos**: a lista só devolve linhas alteradas há mais de
    meio minuto, para uma gravação longa nunca ficar para trás. `GET …/{id}` não tem janela.

    **Datas e instantes.** Datas `AAAA-MM-DD`. A API **devolve** instantes em UTC (`2026-10-03T17:05:00+00:00`). Nos
    filtros você pode **enviar** com qualquer fuso (`-03:00`, `Z`, frações de segundo); sem fuso → 400
    `parametro_invalido`. Na query string, o sinal `+` de um fuso positivo tem de ir codificado como `%2B`
    (`atualizado_desde=2026-10-01T05:30:00%2B02:00`): `+` cru chega como espaço e a API recusa com 400.

    **Erros.** Sempre JSON `{ "erro": { "codigo", "mensagem", "campo"?, "id_requisicao" } }`, em português, com código
    estável em `snake_case` (esquema `Erro`). Além dos documentados em cada operação: 405 `metodo_nao_permitido` (com
    `Allow`), 413 `corpo_grande_demais`, 415 `tipo_nao_suportado`, 409 `conflito`/`chave_idempotencia_reutilizada`,
    422 `validacao`, 500 `erro_interno` e 503 `banco_indisponivel` (repita com espera exponencial).

    **CORS fechado.** A API é servidor-a-servidor: nenhuma resposta traz `Access-Control-*`; não a chame do navegador.
    Guarde o token num cofre de segredos, um token por sistema, e revogue em Configurações › API quando o sistema sair
    do ar. Quem criou o token deixou de ser admin da empresa → 403 `token_sem_dono`.
  contact:
    name: Central de ajuda do Prodio
    url: https://ajuda.prodio.com.br
servers:
  - url: https://api.prodio.com.br
    description: Produção. Os caminhos já levam o `/v1`.
security:
  - token: []
tags:
  - name: Empresa e token
    description: Quem sou eu — a empresa do token e o próprio token. Funciona com qualquer token.
  - name: Locais
    description: Locais de estoque (fábrica, terceiros, depósitos).
  - name: Produtos
    description: Cadastro de produtos e kits, com custo da ficha.
  - name: Insumos
    description: Cadastro de insumos (matéria-prima), com saldo e custo médio.
  - name: Fornecedores
    description: Cadastro de fornecedores. O contato (pessoa) não é exposto.
  - name: Fichas técnicas
    description: Ficha técnica ativa de cada produto.
  - name: Estoque
    description: Saldos por local e o ledger de movimentos.

paths:
  /v1/eu:
    get:
      operationId: obterEu
      summary: Empresa e token da chamada
      description: Use para confirmar que o token está certo e descobrir a empresa, o fuso, a hora de virada do dia de produção e os escopos do token.
      tags: [Empresa e token]
      x-prodio-escopo: qualquer
      security:
        - token: []
      responses:
        '200':
          description: Empresa e token.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Eu' }
              example:
                empresa: { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000001, nome: Confecção Aurora, cnpj: '11222333000181', fuso: America/Sao_Paulo, hora_virada: '06:00', sku_prefixo: AU }
                token:
                  prefixo: prodio_live_Ab12Cd34
                  nome: Sistema de loja
                  escopos: [produtos:ler, estoque:ler]
                  expira_em: '2027-10-03T03:00:00+00:00'
                  limite_por_minuto: 120
                  limite_empresa_por_minuto: 300
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/locais:
    get:
      operationId: listarLocais
      summary: Locais de estoque
      description: Todos os locais da empresa, ordenados por nome, no envelope de lista. Não há paginação aqui — `proximo_cursor` é sempre `null`.
      tags: [Locais]
      x-prodio-escopo: qualquer
      security:
        - token: []
      responses:
        '200':
          description: Locais da empresa.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeLocais' }
              example:
                dados:
                  - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000010, nome: Fábrica, tipo: fabrica, ativo: true }
                  - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000011, nome: Depósito da loja, tipo: deposito, ativo: true }
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/produtos:
    get:
      operationId: listarProdutos
      summary: Listar produtos
      description: Produtos e kits da empresa, em ordem crescente de `atualizado_em` com desempate por `id`. Excluídos só com `incluir_excluidos=true` (vêm com `excluido_em`).
      tags: [Produtos]
      x-prodio-escopo: produtos:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/atualizado_desde'
        - $ref: '#/components/parameters/sku'
        - $ref: '#/components/parameters/status_produto'
        - $ref: '#/components/parameters/familia'
        - $ref: '#/components/parameters/tipo_produto'
        - $ref: '#/components/parameters/incluir_excluidos'
      responses:
        '200':
          description: Página de produtos.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeProdutos' }
              # A âncora (&exProduto) é reaproveitada em components.examples.produto: um exemplo só, nos dois lugares.
              example:
                dados:
                  - &exProduto
                    id: 0a7f1c2e-5d3b-4f8a-9e21-000000000101
                    sku: CAM-AZUL-M
                    nome: Camiseta básica azul M
                    familia: Camisetas
                    atributos: { cor: Azul, tamanho: M }
                    ean: '7891234567890'
                    ncm: '61091000'
                    status: ativo
                    tipo: produto
                    apelidos: [CAMAZM-ML, CAMAZM-SHOPEE]
                    tem_ficha: true
                    peso_kg: 0.18
                    peso_cubado_kg: 0.3
                    largura_cm: 30
                    altura_cm: 2
                    comprimento_cm: 40
                    ipi_pct: 0
                    icms_pct: null
                    importado: false
                    variacao: { grupo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000100, base: false, eixos: [cor, tamanho] }
                    custo_ficha: 12.35
                    custo_ficha_bruto: 13.1
                    custo_manual: null
                    criado_em: '2026-09-21T12:00:00+00:00'
                    atualizado_em: '2026-10-03T14:05:00+00:00'
                    excluido_em: null
                proximo_cursor: eyJ1IjoiMjAyNi0xMC0wM1QxNDowNTowMC4wMDAwMDBaIiwiaSI6IjBhN2YxYzJlLTVkM2ItNGY4YS05ZTIxLTAwMDAwMDAwMDEwMSJ9
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/produtos/{id}:
    get:
      operationId: obterProduto
      summary: Obter um produto
      description: O produto pelo id, inclusive excluído (com `excluido_em`). Id de outra empresa → 404, como id inexistente.
      tags: [Produtos]
      x-prodio-escopo: produtos:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/IdNoCaminho'
      responses:
        '200':
          description: O produto.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Produto' }
              examples:
                produto: { $ref: '#/components/examples/produto' }
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/insumos:
    get:
      operationId: listarInsumos
      summary: Listar insumos
      description: Insumos da empresa, em ordem crescente de `atualizado_em` com desempate por `id`. `saldo` é a soma de todos os locais; por local use `/v1/estoque/saldos`.
      tags: [Insumos]
      x-prodio-escopo: insumos:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/atualizado_desde'
        - $ref: '#/components/parameters/sku'
        - $ref: '#/components/parameters/ativo'
        - $ref: '#/components/parameters/familia'
        - $ref: '#/components/parameters/incluir_excluidos'
      responses:
        '200':
          description: Página de insumos.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeInsumos' }
              example:
                dados:
                  - &exInsumo
                    id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201
                    sku: MAL-AZ
                    nome: Malha fio 30 azul
                    familia: Malhas
                    ativo: true
                    unidade_compra: kg
                    unidade_consumo: m
                    fator_conversao: 3.2
                    ncm: '60062200'
                    minimo: 40
                    lead_time_dias: 7
                    fornecedor_padrao_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000301
                    saldo: 130.5
                    custo_medio: 31.2
                    custo_medio_bruto: 34.8
                    custo_referencia: null
                    criado_em: '2026-09-21T12:00:00+00:00'
                    atualizado_em: '2026-10-03T13:40:12+00:00'
                    excluido_em: null
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/insumos/{id}:
    get:
      operationId: obterInsumo
      summary: Obter um insumo
      description: O insumo pelo id, inclusive excluído (com `excluido_em`). Id de outra empresa → 404.
      tags: [Insumos]
      x-prodio-escopo: insumos:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/IdNoCaminho'
      responses:
        '200':
          description: O insumo.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Insumo' }
              examples:
                insumo: { $ref: '#/components/examples/insumo' }
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/fornecedores:
    get:
      operationId: listarFornecedores
      summary: Listar fornecedores
      description: Fornecedores da empresa, em ordem crescente de `atualizado_em` com desempate por `id`. O `contato` (nome e telefone de pessoa) e o e-mail de XML não são expostos.
      tags: [Fornecedores]
      x-prodio-escopo: fornecedores:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/atualizado_desde'
        - $ref: '#/components/parameters/ativo'
        - $ref: '#/components/parameters/cnpj'
        - $ref: '#/components/parameters/incluir_excluidos'
      responses:
        '200':
          description: Página de fornecedores.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeFornecedores' }
              example:
                dados:
                  - &exFornecedor
                    id: 0a7f1c2e-5d3b-4f8a-9e21-000000000301
                    nome: Malharia Sul
                    cnpj: '11222333000181'
                    regime: simples
                    uf: SC
                    lead_time_dias: 10
                    condicao_pagamento: [30, 60]
                    ativo: true
                    criado_em: '2026-09-21T12:00:00+00:00'
                    atualizado_em: '2026-09-30T09:12:40+00:00'
                    excluido_em: null
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/fornecedores/{id}:
    get:
      operationId: obterFornecedor
      summary: Obter um fornecedor
      description: O fornecedor pelo id, inclusive excluído (com `excluido_em`). Id de outra empresa → 404.
      tags: [Fornecedores]
      x-prodio-escopo: fornecedores:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/IdNoCaminho'
      responses:
        '200':
          description: O fornecedor.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fornecedor' }
              examples:
                fornecedor: { $ref: '#/components/examples/fornecedor' }
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/fichas:
    get:
      operationId: listarFichas
      summary: Listar fichas técnicas ativas
      description: A ficha **ativa** de cada produto, em ordem crescente de `atualizado_em` (o instante em que a versão foi criada) com desempate pelo id da versão. Produto sem ficha ativa não aparece.
      tags: [Fichas técnicas]
      x-prodio-escopo: fichas:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/atualizado_desde'
        - $ref: '#/components/parameters/produto_id'
      responses:
        '200':
          description: Página de fichas.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeFichas' }
              example:
                dados:
                  - &exFicha
                    produto_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000101
                    versao: 3
                    ativa: true
                    atualizado_em: '2026-10-01T16:30:00+00:00'
                    linhas:
                      - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000401, tipo: insumo, insumo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201, consumo: 0.85, unidade: m, perda_pct: 0.03 }
                      - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000402, tipo: produto, componente_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000103, consumo: 1, unidade: un, perda_pct: 0 }
                    custo: 12.3456
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/fichas/{produto_id}:
    get:
      operationId: obterFicha
      summary: Obter a ficha técnica de um produto
      description: A ficha ativa do produto. Produto inexistente, de outra empresa ou sem ficha ativa → 404 `nao_encontrado`.
      tags: [Fichas técnicas]
      x-prodio-escopo: fichas:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/ProdutoIdNoCaminho'
      responses:
        '200':
          description: A ficha ativa.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ficha' }
              examples:
                ficha: { $ref: '#/components/examples/ficha' }
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/estoque/saldos:
    get:
      operationId: listarSaldos
      summary: Listar saldos por local
      description: Uma linha por insumo e local com saldo, em ordem crescente de `atualizado_em` com desempate por insumo e local (o cursor carrega os três).
      tags: [Estoque]
      x-prodio-escopo: estoque:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/atualizado_desde'
        - $ref: '#/components/parameters/insumo_id'
        - $ref: '#/components/parameters/local_id'
      responses:
        '200':
          description: Página de saldos.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeSaldos' }
              example:
                dados:
                  - { insumo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201, local_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000010, saldo: 118.5, unidade: m, custo_medio: 31.2, valor: 3697.2, atualizado_em: '2026-10-03T13:40:12+00:00' }
                  - { insumo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201, local_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000011, saldo: 12, unidade: m, custo_medio: 31.2, valor: 374.4, atualizado_em: '2026-10-03T13:40:12+00:00' }
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

  /v1/estoque/movimentos:
    get:
      operationId: listarMovimentos
      summary: Listar movimentos de estoque
      description: O ledger de movimentos, em ordem crescente de `criado_em` com desempate por `id`; a janela de 30 s vale sobre `criado_em`. `quantidade` é o delta (negativo nas saídas). Quem fez o movimento não é exposto.
      tags: [Estoque]
      x-prodio-escopo: estoque:ler
      security:
        - token: []
      parameters:
        - $ref: '#/components/parameters/limite'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/desde'
        - $ref: '#/components/parameters/insumo_id'
        - $ref: '#/components/parameters/local_id'
        - $ref: '#/components/parameters/tipo_movimento'
      responses:
        '200':
          description: Página de movimentos.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaDeMovimentos' }
              example:
                dados:
                  - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000501, insumo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201, local_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000010, tipo: entrada_nfe, quantidade: 160, unidade: m, custo_unitario: 30.78, referencia: NF 1234, motivo: null, origem: nfe, criado_em: '2026-10-02T18:20:05+00:00' }
                  - { id: 0a7f1c2e-5d3b-4f8a-9e21-000000000502, insumo_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000201, local_id: 0a7f1c2e-5d3b-4f8a-9e21-000000000010, tipo: ajuste, quantidade: -3.5, unidade: m, custo_unitario: 31.2, referencia: null, motivo: Diferença de contagem, origem: api, criado_em: '2026-10-03T13:40:12+00:00' }
                proximo_cursor: null
        '400': { $ref: '#/components/responses/ErroRequisicao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '429': { $ref: '#/components/responses/LimiteExcedido' }

components:
  securitySchemes:
    token:
      type: http
      scheme: bearer
      bearerFormat: prodio_live_… (55 caracteres)
      description: Token da empresa criado em Configurações › API. Só no cabeçalho `Authorization`; nunca na URL.

  headers:
    X-Request-Id:
      description: Id desta requisição (uuid). Vem em toda resposta; cite-o ao pedir suporte.
      schema: { type: string, format: uuid }
    X-RateLimit-Limit:
      description: Limite de chamadas por minuto do token (120).
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Chamadas que ainda cabem nesta janela de 1 minuto.
      schema: { type: integer, minimum: 0 }
    X-RateLimit-Reset:
      description: Instante Unix (segundos) em que a janela reinicia.
      schema: { type: integer }
    Retry-After:
      description: Segundos a esperar antes de repetir a chamada.
      schema: { type: integer, minimum: 1 }
    Idempotency-Replayed:
      description: '`true` quando a resposta é a repetição de uma escrita anterior com a mesma `Idempotency-Key` e o mesmo corpo (nada foi gravado de novo). Ausente nas demais respostas.'
      schema: { type: string, enum: ['true'] }

  parameters:
    limite:
      name: limite
      in: query
      description: Linhas por página. Padrão 50, máximo 200. Fora de 1..200 → 400 `parametro_invalido`.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    cursor:
      name: cursor
      in: query
      description: Página seguinte — repita exatamente o `proximo_cursor` recebido. Opaco (base64url); adulterado ou de outra lista → 400 `cursor_invalido`.
      schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,400}$' }
    atualizado_desde:
      name: atualizado_desde
      in: query
      description: |
        Só linhas com `atualizado_em` **a partir** deste instante, inclusive. ISO 8601 **com fuso** (`2026-10-01T00:00:00-03:00`,
        `2026-10-01T03:00:00Z`, frações de segundo aceitas); sem fuso, data impossível ou texto → 400 `parametro_invalido`.
        Fuso positivo: codifique o `+` como `%2B` na query string (`2026-10-01T05:30:00%2B02:00`), senão ele chega como espaço.
      schema: { type: string, format: date-time }
    desde:
      name: desde
      in: query
      description: |
        Só linhas com `criado_em` **a partir** deste instante, inclusive. ISO 8601 **com fuso** (`-03:00`, `Z`); sem fuso → 400
        `parametro_invalido`. Fuso positivo: codifique o `+` como `%2B` na query string.
      schema: { type: string, format: date-time }
    dia:
      name: dia
      in: query
      description: Dia de produção, `AAAA-MM-DD`. Onde é opcional, o padrão é o dia de produção atual pelo fuso e pela hora de virada da empresa.
      schema: { type: string, format: date }
    incluir_excluidos:
      name: incluir_excluidos
      in: query
      description: '`true` traz também os cadastros excluídos, com `excluido_em` preenchido, para o outro sistema apagar do lado dele. Excluir move o `atualizado_em`, então a exclusão aparece na leitura incremental.'
      schema: { type: boolean, default: false }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Chave sua, única por operação (1 a 100 caracteres `[A-Za-z0-9_.:-]`), guardada por 24 h por token. **Obrigatória** nos
        `POST` que criam (movimento de estoque, ordem de compra); opcional nos lotes, nos `PUT`, no estorno e no cancelamento.
        Mesma chave e mesmo corpo → a mesma resposta com `Idempotency-Replayed: true`; mesma chave com corpo diferente → 409
        `chave_idempotencia_reutilizada`; ausente onde é obrigatória → 400 `parametro_invalido` com `campo: "Idempotency-Key"`.
      schema: { type: string, pattern: '^[A-Za-z0-9_.:-]{1,100}$' }
    IdNoCaminho:
      name: id
      in: path
      required: true
      description: Id do registro (uuid). Id de outra empresa responde 404, igual a id inexistente.
      schema: { type: string, format: uuid }
    ProdutoIdNoCaminho:
      name: produto_id
      in: path
      required: true
      description: Id do produto (uuid).
      schema: { type: string, format: uuid }
    sku:
      name: sku
      in: query
      description: SKU exato (até 200 caracteres).
      schema: { type: string, minLength: 1, maxLength: 200 }
    familia:
      name: familia
      in: query
      description: Família exata, como está no cadastro.
      schema: { type: string, minLength: 1, maxLength: 200 }
    status_produto:
      name: status
      in: query
      description: Situação do produto.
      schema: { type: string, enum: [ativo, inativo] }
    tipo_produto:
      name: tipo
      in: query
      description: Produto simples ou kit.
      schema: { type: string, enum: [produto, kit] }
    ativo:
      name: ativo
      in: query
      description: Só ativos (`true`) ou só inativos (`false`).
      schema: { type: boolean }
    cnpj:
      name: cnpj
      in: query
      description: CNPJ exato. Pontuação é ignorada (`11.222.333/0001-81` acha `11222333000181`); só casa 14 dígitos. Fornecedor pessoa física (CPF, 11 dígitos) nunca é achado por aqui.
      schema: { type: string, minLength: 1, maxLength: 200 }
    produto_id:
      name: produto_id
      in: query
      description: Só a ficha deste produto.
      schema: { type: string, format: uuid }
    insumo_id:
      name: insumo_id
      in: query
      description: Só este insumo.
      schema: { type: string, format: uuid }
    local_id:
      name: local_id
      in: query
      description: Só este local de estoque.
      schema: { type: string, format: uuid }
    tipo_movimento:
      name: tipo
      in: query
      description: Tipo do movimento.
      schema: { type: string, enum: [entrada_nfe, entrada_manual, baixa_producao, ajuste, perda, estorno, saldo_inicial] }

  responses:
    ErroRequisicao:
      description: 'Requisição malformada: `parametro_invalido` (parâmetro desconhecido, repetido, vazio ou fora do formato; `campo` diz qual), `cursor_invalido`, `token_na_url` ou `corpo_invalido` (escritas). Os parâmetros são lidos **depois** de autenticar, então a chamada conta no limite e a resposta traz os `X-RateLimit-*`; só `token_na_url` (recusado antes do token) sai sem eles.'
      x-prodio-status: 400
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erro' }
          example: { erro: { codigo: parametro_invalido, mensagem: 'Parâmetro atualizado_desde inválido: instante ISO 8601 com fuso, como 2026-10-01T00:00:00-03:00.', campo: atualizado_desde, id_requisicao: 8c1e0d2f-7a4b-4c6d-9e8f-000000000999 } }
    NaoAutenticado:
      description: 'Sem token válido: `token_ausente`, `token_invalido` (fora do formato ou desconhecido), `token_revogado` ou `token_vencido`. Token revogado ou vencido é um token que o banco achou: a primeira recusa conta no limite dele e traz os `X-RateLimit-*`; repetir o mesmo token nos 60 s seguintes é recusado pelo cache do servidor, sem contar e **sem** os `X-RateLimit-*`. `token_ausente` e `token_invalido` saem sempre sem eles (nada para contar).'
      x-prodio-status: 401
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erro' }
          example: { erro: { codigo: token_ausente, mensagem: 'Informe o token no cabeçalho Authorization: Bearer.', id_requisicao: 8c1e0d2f-7a4b-4c6d-9e8f-000000000999 } }
    SemPermissao:
      description: 'Token reconhecido, mas sem direito: `escopo_insuficiente` (falta o escopo da rota), `empresa_suspensa`, `token_sem_dono` (quem criou o token deixou de ser admin da empresa) ou `sem_permissao`. A chamada conta no limite.'
      x-prodio-status: 403
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erro' }
          example: { erro: { codigo: escopo_insuficiente, mensagem: O token não tem o escopo necessário para esta rota., id_requisicao: 8c1e0d2f-7a4b-4c6d-9e8f-000000000999 } }
    NaoEncontrado:
      description: '`nao_encontrado`: id inexistente **ou de outra empresa** (a API não revela qual). `rota_inexistente` para caminho que não existe.'
      x-prodio-status: 404
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erro' }
          example: { erro: { codigo: nao_encontrado, mensagem: Não encontrado., id_requisicao: 8c1e0d2f-7a4b-4c6d-9e8f-000000000999 } }
    LimiteExcedido:
      description: '`limite_excedido`: mais de 120 chamadas no minuto com este token ou mais de 300 com a empresa. Espere o `Retry-After` e repita.'
      x-prodio-status: 429
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erro' }
          example: { erro: { codigo: limite_excedido, mensagem: Limite de chamadas por minuto excedido. Espere o Retry-After., id_requisicao: 8c1e0d2f-7a4b-4c6d-9e8f-000000000999 } }

  # Os valores são as âncoras definidas nas listas (&exProduto…): o mesmo exemplo na lista e no recurso único.
  examples:
    produto:
      summary: Camiseta básica azul M
      value: *exProduto
    insumo:
      summary: Malha fio 30 azul
      value: *exInsumo
    fornecedor:
      summary: Malharia Sul
      value: *exFornecedor
    ficha:
      summary: Ficha ativa da camiseta (versão 3)
      value: *exFicha

  schemas:
    Erro:
      type: object
      description: Envelope de todo erro da API. `codigo` é estável; `mensagem` é em português e pode mudar; `campo` aponta o parâmetro, cabeçalho ou chave do corpo recusado.
      required: [erro]
      properties:
        erro:
          type: object
          required: [codigo, mensagem, id_requisicao]
          properties:
            codigo:
              type: string
              enum:
                - corpo_invalido
                - cursor_invalido
                - parametro_invalido
                - token_na_url
                - token_ausente
                - token_invalido
                - token_revogado
                - token_vencido
                - escopo_insuficiente
                - empresa_suspensa
                - token_sem_dono
                - sem_permissao
                - nao_encontrado
                - rota_inexistente
                - metodo_nao_permitido
                - conflito
                - chave_idempotencia_reutilizada
                - corpo_grande_demais
                - tipo_nao_suportado
                - validacao
                - limite_excedido
                - erro_interno
                - banco_indisponivel
            mensagem: { type: string }
            campo: { type: string, description: 'Parâmetro, cabeçalho ou chave do corpo (`itens[2].qtd`) a que o erro se refere.' }
            id_requisicao: { type: string, format: uuid, description: O mesmo valor do cabeçalho `X-Request-Id`. }

    Eu:
      type: object
      description: A empresa do token e o próprio token (`GET /v1/eu`).
      required: [empresa, token]
      properties:
        empresa:
          type: object
          required: [id, nome, cnpj, fuso, hora_virada, sku_prefixo]
          properties:
            id: { type: string, format: uuid }
            nome: { type: string }
            cnpj: { type: string, description: Só dígitos. }
            fuso: { type: string, description: 'Fuso IANA da empresa (`America/Sao_Paulo`). As respostas da API vêm em UTC; este é o fuso para interpretar o dia de produção.' }
            hora_virada: { type: string, description: 'Hora (`HH:MM`) em que vira o dia de produção, no fuso da empresa.' }
            sku_prefixo: { type: string, description: Prefixo do SKU automático e das etiquetas. }
        token:
          type: object
          required: [prefixo, nome, escopos, expira_em, limite_por_minuto, limite_empresa_por_minuto]
          properties:
            prefixo: { type: string, description: 'Os 20 primeiros caracteres do token (`prodio_live_` + 8), como aparecem em Configurações › API.' }
            nome: { type: string }
            escopos: { type: array, items: { type: string } }
            expira_em: { type: [string, 'null'], format: date-time, description: '`null` = sem validade.' }
            limite_por_minuto: { type: integer, description: Chamadas por minuto deste token (120). }
            limite_empresa_por_minuto: { type: integer, description: Chamadas por minuto somando todos os tokens da empresa (300). }

    Local:
      type: object
      required: [id, nome, tipo, ativo]
      properties:
        id: { type: string, format: uuid }
        nome: { type: string }
        tipo: { type: string, enum: [fabrica, terceiro, deposito] }
        ativo: { type: boolean }

    Produto:
      type: object
      description: Produto ou kit. Percentuais como fração (`0.05` = 5 %). Campos em reais exigem `custos:ler`. Toda chave vem sempre (com `null` quando não há valor).
      required:
        - id
        - sku
        - nome
        - familia
        - atributos
        - ean
        - ncm
        - status
        - tipo
        - apelidos
        - tem_ficha
        - peso_kg
        - peso_cubado_kg
        - largura_cm
        - altura_cm
        - comprimento_cm
        - ipi_pct
        - icms_pct
        - importado
        - variacao
        - custo_ficha
        - custo_ficha_bruto
        - custo_manual
        - criado_em
        - atualizado_em
        - excluido_em
      properties:
        id: { type: string, format: uuid }
        sku: { type: string, description: 'Letras, números e hífen, até 40.' }
        nome: { type: string }
        familia: { type: [string, 'null'] }
        atributos: { type: object, additionalProperties: { type: string }, description: 'Atributos livres do cadastro (`cor`, `tamanho`…).' }
        ean: { type: [string, 'null'] }
        ncm: { type: [string, 'null'], description: 8 dígitos. }
        status: { type: string, enum: [ativo, inativo] }
        tipo: { type: string, enum: [produto, kit] }
        apelidos: { type: array, items: { type: string }, description: 'SKUs externos (De-Para dos hubs), em ordem alfabética.' }
        tem_ficha: { type: boolean, description: Existe uma versão ativa da ficha técnica. }
        peso_kg: { type: [number, 'null'] }
        peso_cubado_kg: { type: [number, 'null'] }
        largura_cm: { type: [number, 'null'] }
        altura_cm: { type: [number, 'null'] }
        comprimento_cm: { type: [number, 'null'] }
        ipi_pct: { type: [number, 'null'], description: Fração (`0.05` = 5 %). }
        icms_pct: { type: [number, 'null'], description: Fração (`0.05` = 5 %). }
        importado: { type: boolean }
        variacao:
          type: [object, 'null']
          description: '`null` em produto sem grupo de variação. `base: true` no produto que encabeça o grupo (`grupo_id` igual ao próprio `id`).'
          required: [grupo_id, base, eixos]
          properties:
            grupo_id: { type: string, format: uuid }
            base: { type: boolean }
            eixos: { type: array, items: { type: string } }
        custo_ficha: { type: [number, 'null'], description: 'Custo calculado pela ficha ativa, em reais, arredondado a 2 casas (a mesma conta da tela de fichas). Exige `custos:ler`; `null` também quando não há ficha.' }
        custo_ficha_bruto: { type: [number, 'null'], description: 'Custo da ficha com os impostos de entrada, em reais, 2 casas. Exige `custos:ler`.' }
        custo_manual: { type: [number, 'null'], description: 'Custo informado à mão, em reais. Exige `custos:ler`.' }
        criado_em: { type: string, format: date-time }
        atualizado_em: { type: string, format: date-time, description: Base da leitura incremental. }
        excluido_em: { type: [string, 'null'], format: date-time, description: Preenchido quando o produto foi excluído. }

    Insumo:
      type: object
      description: Insumo (matéria-prima). `saldo` soma todos os locais; por local use `/v1/estoque/saldos`. Toda chave vem sempre.
      required:
        - id
        - sku
        - nome
        - familia
        - ativo
        - unidade_compra
        - unidade_consumo
        - fator_conversao
        - ncm
        - minimo
        - lead_time_dias
        - fornecedor_padrao_id
        - saldo
        - custo_medio
        - custo_medio_bruto
        - custo_referencia
        - criado_em
        - atualizado_em
        - excluido_em
      properties:
        id: { type: string, format: uuid }
        sku: { type: string }
        nome: { type: string }
        familia: { type: [string, 'null'] }
        ativo: { type: boolean }
        unidade_compra: { type: string, description: 'Unidade em que se compra (`kg`, `rolo`…), das Unidades da empresa.' }
        unidade_consumo: { type: string, description: 'Unidade em que a ficha consome (`m`, `un`…).' }
        fator_conversao: { type: number, description: Quantas unidades de consumo há em uma de compra. }
        ncm: { type: [string, 'null'] }
        minimo: { type: [number, 'null'], description: 'Estoque mínimo, em unidade de consumo.' }
        lead_time_dias: { type: [integer, 'null'] }
        fornecedor_padrao_id: { type: [string, 'null'], format: uuid }
        saldo: { type: number, description: 'Soma dos saldos de todos os locais, em unidade de consumo. Sempre visível (não exige custos:ler).' }
        custo_medio: { type: [number, 'null'], description: 'Custo médio ponderado dos saldos, em reais por unidade de consumo. Exige `custos:ler`.' }
        custo_medio_bruto: { type: [number, 'null'], description: 'Custo médio com os impostos de entrada, em reais. Exige `custos:ler`.' }
        custo_referencia: { type: [number, 'null'], description: 'Custo de referência do cadastro (usado quando não há saldo), em reais. Exige `custos:ler`.' }
        criado_em: { type: string, format: date-time }
        atualizado_em: { type: string, format: date-time }
        excluido_em: { type: [string, 'null'], format: date-time }

    Fornecedor:
      type: object
      description: Fornecedor. O `contato` (nome e telefone de pessoa) e o e-mail de XML **não** são expostos. Toda chave vem sempre.
      required: [id, nome, cnpj, regime, uf, lead_time_dias, condicao_pagamento, ativo, criado_em, atualizado_em, excluido_em]
      properties:
        id: { type: string, format: uuid }
        nome: { type: string }
        cnpj: { type: [string, 'null'], description: 'CNPJ só dígitos (14). Fornecedor pessoa física (cadastrado com CPF, 11 dígitos) vem `null`: CPF é dado pessoal e não sai pela API.' }
        regime: { type: [string, 'null'], enum: [simples, normal, null], description: Regime tributário. }
        uf: { type: [string, 'null'], description: UF de origem esperada nas compras. }
        lead_time_dias: { type: [integer, 'null'] }
        condicao_pagamento: { type: [array, 'null'], items: { type: integer }, description: 'Dias das parcelas (`[30, 60]`).' }
        ativo: { type: boolean }
        criado_em: { type: string, format: date-time }
        atualizado_em: { type: string, format: date-time }
        excluido_em: { type: [string, 'null'], format: date-time }

    LinhaDaFicha:
      type: object
      description: 'Uma linha da ficha. `tipo: insumo` traz `insumo_id`; `tipo: produto` (componente de kit ou semiacabado) traz `componente_id`.'
      required: [id, tipo, consumo, unidade, perda_pct]
      properties:
        id: { type: string, format: uuid }
        tipo: { type: string, enum: [insumo, produto] }
        insumo_id: { type: string, format: uuid, description: 'Só quando `tipo` é `insumo`.' }
        componente_id: { type: string, format: uuid, description: 'Id do produto componente; só quando `tipo` é `produto`.' }
        consumo: { type: number, description: 'Quantidade consumida por unidade produzida, na `unidade`.' }
        unidade: { type: string }
        perda_pct: { type: number, description: Perda como fração (`0.03` = 3 %). }

    Ficha:
      type: object
      description: A ficha técnica ativa de um produto. `atualizado_em` é o instante em que esta versão foi criada.
      required: [produto_id, versao, ativa, atualizado_em, linhas, custo]
      properties:
        produto_id: { type: string, format: uuid }
        versao: { type: integer }
        ativa: { type: boolean }
        atualizado_em: { type: string, format: date-time }
        linhas: { type: array, items: { $ref: '#/components/schemas/LinhaDaFicha' } }
        custo: { type: [number, 'null'], description: 'Custo da ficha, em reais (4 casas). Exige `custos:ler`.' }

    Saldo:
      type: object
      description: Saldo de um insumo em um local. `unidade` é a unidade de consumo do insumo.
      required: [insumo_id, local_id, saldo, unidade, custo_medio, valor, atualizado_em]
      properties:
        insumo_id: { type: string, format: uuid }
        local_id: { type: string, format: uuid }
        saldo: { type: number }
        unidade: { type: string }
        custo_medio: { type: [number, 'null'], description: 'Custo médio neste local, em reais por unidade. Exige `custos:ler`.' }
        valor: { type: [number, 'null'], description: '`saldo × custo_medio`, em reais. Exige `custos:ler`.' }
        atualizado_em: { type: string, format: date-time }

    Movimento:
      type: object
      description: Um movimento do ledger de estoque (append-only). `quantidade` é o delta — negativo nas saídas.
      required: [id, insumo_id, local_id, tipo, quantidade, unidade, custo_unitario, referencia, motivo, origem, criado_em]
      properties:
        id: { type: string, format: uuid }
        insumo_id: { type: string, format: uuid }
        local_id: { type: string, format: uuid }
        tipo: { type: string, enum: [entrada_nfe, entrada_manual, baixa_producao, ajuste, perda, estorno, saldo_inicial] }
        quantidade: { type: number, description: Delta na unidade de consumo (negativo nas saídas). }
        unidade: { type: string }
        custo_unitario: { type: [number, 'null'], description: 'Custo unitário do movimento, em reais. Exige `custos:ler`.' }
        referencia: { type: [string, 'null'], description: 'Documento de origem: `NF 1234`, `OC 1042` ou o serial da etiqueta; `null` quando não há.' }
        motivo: { type: [string, 'null'] }
        origem: { type: string, enum: [tela, api, producao, nfe, inventario], description: Por onde o movimento entrou. }
        criado_em: { type: string, format: date-time, description: Base da ordem e do filtro `desde`. }

    PaginaDeLocais:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Local' } }
        proximo_cursor: { type: [string, 'null'], description: Sempre `null` nesta rota. }
    PaginaDeProdutos:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Produto' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
    PaginaDeInsumos:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Insumo' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
    PaginaDeFornecedores:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Fornecedor' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
    PaginaDeFichas:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Ficha' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
    PaginaDeSaldos:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Saldo' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
    PaginaDeMovimentos:
      type: object
      required: [dados, proximo_cursor]
      properties:
        dados: { type: array, items: { $ref: '#/components/schemas/Movimento' } }
        proximo_cursor: { type: [string, 'null'], description: Cursor da página seguinte; `null` = acabou. }
