Pular para conteúdo

API REST — contrato de integração

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14

Referência interativa da API (OpenAPI/Swagger) → · API Reference (Javadoc) →

Audiência: quem integra um sistema cliente (ERP, job agendado, script) com o processador. O sistema cliente é qualquer aplicação capaz de mandar HTTP multipart/form-data sobre HTTPS: job do ERP, script Python, serviço .NET/Java ou curl. Há também uma tela humana (/processamentos, com login Google), mas o envio da planilha é só por esta API.

Diferente do projeto irmão Transporte, aqui o período não vai no POST (é detectado do conteúdo) e o tipo do arquivo é detectado automaticamente (o cliente não informa tipoArquivo) — ver detecção do tipo e decisão 0010.

Como enviar as planilhas

POST /api/xls/processar — multipart/form-data, autenticado por Bearer. É uma porta única, de um campo só: arquivo, um .zip contendo de 1 a N .xlsx do ciclo. O zip é o lote. Não há mais nada a informar: o servidor desempacota, detecta o tipo de cada planilha, deduplica por checksum, agrupa o lote e agenda o processamento assíncrono de cada entrada.

O identificador do lote é gerado no servidor como zip-<sha256 do zip>, o que torna o envio idempotente: reenviar o mesmo zip não abre outro lote nem re-notifica (decisão 0011).

BASE="https://excel.onpetro.xadm.biz"
TOKEN="<mesmo valor de BI_COMERCIAL_XLS_API_TOKEN no servidor>"

# Empacote os .xlsx do ciclo e mande o zip — um POST só, um campo só.
zip -j lote.zip ./postos_nov2025.xlsx ./margem_nov2025.xlsx

curl -sS -X POST "$BASE/api/xls/processar" \
  -H "Authorization: Bearer $TOKEN" \
  -F "arquivo=@./lote.zip;type=application/zip"

No Windows (curl.exe, PowerShell/cmd): mesma chamada, trocando a quebra de linha \ por ^ (cmd) ou ` (PowerShell), e usando aspas duplas nos campos -F.

Python (requests):

import requests

BASE = "https://excel.onpetro.xadm.biz"
TOKEN = "seu-token"  # ideal: variável de ambiente

with open("lote.zip", "rb") as f:
    r = requests.post(
        f"{BASE}/api/xls/processar",
        headers={"Authorization": f"Bearer {TOKEN}"},
        files={"arquivo": ("lote.zip", f, "application/zip")},
        timeout=120)
    r.raise_for_status(); print(r.json())
# {"conjuntoId": "zip-3f9a…", "total": 2, "itens": [
#   {"nome": "postos_nov2025.xlsx", "id": 42, "httpStatus": 202,
#    "tipoArquivo": "POSTOS", "mensagem": "Processamento agendado"}, ...]}

Respostas do POST

O POST responde pelo lote, não por arquivo:

HTTP Situação Corpo
202 Lote recebido; processamento agendado por arquivo conjuntoId (zip-<sha256>), total, itens[]
400 Zip inválido, vazio ou sem nenhum .xlsx erro RFC 7807
401 Bearer ausente ou inválido —

O destino de cada planilha vem em itens[], um objeto por entrada do zip (nome, id, httpStatus, tipoArquivo, mensagem). O httpStatus por item:

httpStatus Situação
202 Enfileirado para processamento assíncrono — inclusive o reenvio de um arquivo cujo envio anterior terminou em ERRO ou IGNORADO: o mesmo registro (mesmo id) volta para a fila, no lote novo
200 Duplicado — mesmo checksum já processado com sucesso (idempotência)
400 Não processado — não-.xlsx, nome temporário ~$/#, ou sem coluna reconhecível pelo ArquivoDetector
409 Não processado — mesmo checksum já em fila / processando

Ou seja: um arquivo recusado não derruba o lote — o 202 do envelope convive com itens em 400/409.

Todo erro do /api/** (400/401/403/404/409/503, falhas de validação e rotas inexistentes) sai no formato RFC 7807 (application/problem+json), decisão 0015:

{ "type": "about:blank", "title": "Conflict", "status": 409,
  "detail": "Mesmo checksum já em processamento", "code": "CONFLICT" }

status é o código HTTP, title o resumo, detail a mensagem específica e code (extensão) é o código estável legível por máquina. O 2xx mantém o corpo de negócio (statusProcessamento etc.), nunca o code.

Chaves sempre presentes. Todo campo das respostas JSON de sucesso do /api/** vem no corpo mesmo quando não tem valor — null para escalar ausente (ex.: "id": null num item recusado, "periodoInicial": null antes do processamento), [] para lista vazia (ex.: "content": [] numa página sem resultado). A chave nunca é omitida. Ainda assim, trate chave ausente como vazio no cliente: é a regra da casa para qualquer produtor.

O processamento é assíncrono: para cada item que voltou 202, faça polling em GET …/processamentos/{id} (o id do próprio item) até SUCESSO/ERRO, ou apenas acompanhe pelo Telegram — o resumo do lote é postado quando todas as entradas terminam.

Leitura (GET, autenticada)

As consultas de processamento exigem autenticação: o Bearer (o mesmo do envio) para integração, ou a sessão do login Google das telas. Sem nenhum dos dois → 401.

  • GET /api/comercial/processamentos?page=0&size=20&status=SUCESSO&tipo=META — lista paginada (JSON). status e tipo são filtros opcionais que combinam (AND); tipo aceita qualquer valor de TipoArquivo (COMPRAS, COTA_PETROBRAS, FLUXO_PAGAR_RECEBER, POSTOS, TRR, RELATORIO18, MARGEM_CONSOLIDADA, MARGEM_DIA, META, META_FERNANDO). page começa em 0; size tem default 20 e máximo 100 (acima disso vale 100; zero ou negativo cai no default). Ordenação fixa por recebido_em decrescente — o parâmetro sort é ignorado. Corpo: o Page do micronaut-data, sem DTO próprio (forma)
  • GET /api/comercial/processamentos/{id} — detalhe (JSON, inclui as métricas linhas_efetivas/linhas_removidas)
  • GET /api/comercial/processamentos/{id}/logs — log de execução (text/plain)

Interface humana (HTML, protegida por login Google — ver etapa 06): GET /processamentos, GET /processamentos/{id} e GET /processamentos/{id}/arquivo — o .xlsx original, em qualquer modo de storage (no PSQL lê de arquivo_bytes, senão do Garage).

Corpo das listagens paginadas

As duas listagens do /api (processamentos e histórico do nuke) devolvem o Page do micronaut-data como ele se serializa, sem DTO de paginação próprio:

{
  "content": [ { "id": 4, "nomeArquivo": "a.xlsx", "status": "SUCESSO", "…": "…" } ],
  "pageable": {
    "size": 20,
    "number": 0,
    "sort": { "orderBy": [ { "ignoreCase": false, "direction": "DESC", "property": "recebidoEm",
                             "ascending": false } ] },
    "mode": "OFFSET"
  },
  "totalSize": 1
}
Chave Conteúdo
content itens da página ([] quando vazia)
totalSize total de registros do filtro
pageable.number página devolvida, base 0
pageable.size tamanho aplicado, já com o corte (default 20, máximo 100)
pageable.sort.orderBy a ordenação que valeu — a fixa da rota, não o sort pedido
pageable.mode OFFSET

O total de páginas não vem no corpo: é ceil(totalSize / pageable.size). O Page serializado também não traz totalPages, pageNumber, offset, numberOfElements nem empty.

Import manual de XLS (página aberta) — contrato com o bi-comercial

O botão Importar XLS do bi-comercial abre, em nova aba, a página aberta GET /xls/importar deste app — sem login Google (o usuário já está autenticado no bi-comercial). A rota /compras/importar é alias da mesma página e continua viva, porque é nela que o botão já deployado aponta. A identidade e a autorização viajam na URL:

https://excel.onpetro.xadm.biz/xls/importar?u=<userId>&n=<nome>&k=<segredo>
Param Conteúdo Obrigatório
u id do usuário logado no bi-comercial ✅ (sem → 400)
n nome do usuário (url-encoded) ✅ (sem → 400)
k segredo compartilhado (env BI_COMERCIAL_XLS_IMPORT_TOKEN, mesmo valor embutido no bi-comercial) ✅ (ausente/errado → 403)

A página aceita 1..N .xlsx de Relação de Compras (COMPRAS), Cota Petrobrás (COTA_PETROBRAS) e FluxoPagarReceber (FLUXO_PAGAR_RECEBER) — outro tipo → 400 — com no máximo uma planilha de cota (o full-replace assume a matriz inteira) e no máximo um FluxoPagarReceber por envio (duas → 400). Ela empacota os arquivos no .zip do lote no servidor, reusa o pipeline de POST /api/xls/processar e grava enviado_por_id/enviado_por_nome no xls_processamento (visíveis no detalhe). Sem BI_COMERCIAL_XLS_IMPORT_TOKEN configurado no servidor, a página recusa tudo (403, fail-closed).

FluxoPagarReceber: a página espera o processamento (consulta a cada app.importacao.intervalo-espera, 500 ms, por até app.importacao.espera-maxima, 30 s) e mostra o desfecho real, porque o usuário do cliente não acessa o histórico de processamentos. Compras e Cota só agendam, como antes.

Desfecho O que a página mostra
SUCESSO "Posição dd/mm/aaaa — passou a ser a posição atual" (ou "entrou no histórico (já existe posição mais recente de …)" ou "substituiu a importação anterior desta posição"); "A pagar/A receber: N títulos · R$ X · vencido R$ Y"; avisos de centros de custo e filiais novos e de divergência do total do receber. Vem do xls_processamento.resumo
Duplicado (200) o mesmo resumo, gravado na importação original, com "(importado em dd/mm/aaaa hh:mm)"
IGNORADO "Arquivo recusado — nada foi gravado." + o motivo (erro_mensagem: aba ausente, posição ausente, ou as linhas inválidas)
ERRO "Falha ao processar o arquivo — a equipe foi avisada; tente reenviar mais tarde." (o detalhe vai ao GlitchTip)
estourou a espera "Ainda processando — a posição nova aparece no BI assim que terminar."

Segurança (consciente): endpoint aberto protegido por segredo compartilhado client-grade — não é auth forte. Aceitável para ferramenta interna; o id do usuário é rastro de autoria, não credencial. Não replicar esse padrão em endpoint sensível sem avaliar. As envs estão no runbook de deploy.

Operação admin (Bearer + confirmação)

  • POST /api/comercial/admin/nuke-replication — dispara o reset da replicação PowerSync. Exige Bearer + header X-Confirm-Nuke (valor de POWERSYNC_NUKE_CONFIRMATION). Retorna 202 com nukeId + linkStatus. Operação destrutiva — o procedimento completo (steps, polling, recovery) está no runbook Resetar a replicação.
  • GET /api/comercial/admin/nuke-replication?page=0&size=20 — histórico das execuções (Bearer), com page/size nos mesmos limites da listagem de processamentos e ordenação fixa por iniciado_em decrescente. Corpo: o mesmo Page (forma), cada item na forma do status de uma execução (nukeId, status, stepsCompletados, …).

Limites e boas práticas

  • Respeitar micronaut.server.multipart.max-file-size (50 MB no exemplo do projeto — ajustável). O limite vale para o zip inteiro, não por planilha.
  • HTTPS sempre em produção.
  • Em item 409, aguardar o término do processamento em andamento (ou consultar o id na lista). Arquivo que terminou em ERRO/IGNORADO pode ser reenviado como está: ele é reprocessado (202).
  • Em item 400 recorrente, conferir o nome do arquivo (sem ~$/#) e se o conteúdo tem uma coluna reconhecível pelo ArquivoDetector.
  • Swagger UI interativo: https://<host>/swagger-ui (botão Authorize para o Bearer).