Table of Contents
BI Comercial — Processador XLS¶
O BI Comercial substitui um processo manual de planilhas da OnPetro por um serviço que recebe os arquivos Excel do comercial de combustíveis — vendas, margens, metas e cadastros de postos, TRRs e clientes —, reconhece sozinho o tipo de cada arquivo, valida, arquiva e mantém um histórico rastreável de cada envio. Reenviar a mesma planilha não duplica nada, e os dados ficam disponíveis para consulta no aplicativo e nas telas web.
Na prática: o que antes dependia de alguém rodar um script à mão — e saber de antemão qual relatório era qual — vira um envio autenticado que se identifica e se processa sozinho, notifica o resultado e guarda a trilha do que entrou. São sete relatórios diferentes ao longo do mês (relatório 18, margem consolidada, margem-dia, meta, meta por vendedor, postos e TRR), e o operador não informa nada além do próprio arquivo. O resultado fica pronto para auditoria e para os relatórios de quem opera o comercial, com os dados sincronizados para os aplicativos do time em campo.
→ Documentação Completa — o sistema inteiro, de cima a baixo.
O que já foi entregue:
- Recepção autenticada de planilhas Excel comerciais com detecção automática de tipo e processamento assíncrono.
- Reprocessar um período sem gerar tráfego nem churn desnecessário para os aplicativos móveis.
- Garantia, no CI, de que o processador Java produz o mesmo banco que o legado Python para cada família de planilha.
- Arquivamento dos arquivos recebidos em storage dedicado, fora do banco de dados.
- Reset seguro e auditável da replicação PowerSync, disparável por API ou por um clique na tela admin.
- Acesso às telas internas restrito a contas @xadm.com.br, sem tocar na auth Bearer da API.
- Configurações compartilhadas entre servidor e aplicativo, ajustáveis sem novo deploy.
- Stack uma major atrás modernizada e contrato de erro da API em RFC 7807, sem mudar comportamento observável.
Atalhos¶
- Etapas do Projeto — o desenho dos subsistemas e fluxos.
- Decisões — o porquê de cada escolha técnica (ADRs).
- Operação — runbooks: deploy, resetar replicação, login Google.
- Dev / API — contrato da API REST e referência técnica.
- Manual — passo a passo para o usuário/suporte.
- Público — manual e referência da API expostos (
docs/public/). - Glossário do projeto — vocabulário do domínio.
Projeto
Documentação Completa — BI Comercial¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
O sistema como ele é hoje, em ordem de raciocínio: o problema, o que entra, como os dados são guardados, como o processamento funciona e, no fim, os contratos que outros sistemas consomem. Quem só integra pode pular direto para o cap. 7. O histórico ("como chegamos aqui") está no apêndice; o passo a passo de cada feature, nas Etapas do Projeto.
1. Contexto e problema¶
A OnPetro extrai do ERP, ao longo do mês, planilhas Excel do comercial de combustíveis: vendas (relatório 18), margens (consolidada e diária), metas (gerais e por vendedor), cadastros de postos, TRRs e clientes, compras de combustível e a cota Petrobrás. São nove tipos de planilha. Antes, transformar esses arquivos em dados consultáveis dependia de alguém rodar um script Python à mão — e ainda saber de antemão qual relatório era qual, para não gravar na tabela errada. Frágil, sem histórico, sem rastreabilidade de "qual arquivo gerou qual dado".
O BI Comercial substitui esse processo por um serviço contínuo (Java/Micronaut):
recebe o .xlsx por uma API autenticada, detecta sozinho o tipo pelo conteúdo,
valida, processa de forma assíncrona e persiste os fatos no PostgreSQL, de onde os
clientes móveis leem (via PowerSync) e a equipe consulta (telas web e API). Cada
envio fica registrado com checksum, tipo detectado, status e métricas — auditável.
Fora do escopo: editar os dados pela web (a origem é sempre a planilha) e informar manualmente o tipo ou o período (ambos vêm do conteúdo). Relatórios e dashboards são de quem consome os dados, não deste serviço; a sincronização em si é do container PowerSync, que lê o slot lógico do Postgres.
2. Dados de entrada¶
A entrada é um .zip com 1..N .xlsx — o zip é o lote —, e cada planilha é
validada por magic bytes ZIP antes de qualquer leitura. Diferente do projeto irmão
Transporte (3 abas de nome fixo), aqui não há layout único: o
ArquivoDetector recebe os bytes + o nome e
decide qual dos 9 tipos é, de forma determinística e ordenada por prioridade (a primeira
condição satisfeita vence; sinais de conteúdo antes das regras por nome — decisão
0010). A leitura é StAX (streaming,
FastExcel) via ExcelSheetReader — arquivos grandes não estouram a memória. O
upload não exige período (vem do conteúdo) nem o tipo (é detectado).
| Ordem | Tipo | Como é reconhecido | → Alimenta |
|---|---|---|---|
| 1 | COMPRAS |
aba chamada Compras |
bi_fornecedor + bi_compra |
| 2 | COTA_PETROBRAS |
aba com nome de ano (^\d{4}$) cujo cabeçalho começa com POLO/PRODUTO/JANEIRO |
bi_cota_petrobras |
| 3 | META_FERNANDO |
aba Meta por vendedor / coluna Tipo de meta |
bi_meta |
| 4 | POSTOS |
coluna Vinculação a Distribuidor |
bi_posto |
| 5 | TRR |
coluna Tipo de Instalação / Qualificação da Empresa |
bi_trr |
| 6 | RELATORIO18 |
nome contém relatorio/relatório/rel 18 |
bi_movimento + dimensões |
| 7 | MARGEM_CONSOLIDADA |
nome contém margem consolidada |
bi_custo_inventario + bi_movimento |
| 8 | MARGEM_DIA |
nome no padrão margem - |
bi_movimento |
| 9 | META |
nome contém meta (fallback) |
bi_meta |
Cada tipo tem um processador dedicado em processamento/planilha/{Tipo}Processor. Nomes
começando com ~$ (temporário do Excel) ou # (em revisão) são rejeitados com
400 antes de entrar no banco — assim como um arquivo cujo tipo não é
reconhecido (400, não persiste). Já um arquivo com tipo detectado mas
período ≤ a data mínima (10/08/2025) é gravado com status IGNORADO (recebido,
não processado; sem 4xx).
Cada coluna reconhecida grava na coluna homônima da tabela de destino; a identidade de cada fato não é a posição na planilha, e sim a chave natural (cap. 3) — é o que permite reprocessar o mesmo período sem duplicar.
3. Modelo de dados¶
O schema abaixo é a fonte da verdade — toda migration que evolui o banco atualiza este modelo no mesmo PR.
O schema vive em PostgreSQL 18+ (a função uuidv7() é nativa a partir dele), versionado
por Flyway (V1..VN, congeladas por checksum — nunca editar uma aplicada, sempre uma
nova). Duas famílias de tabela, por prefixo, que é contrato com o PowerSync:
bi_*— fatos, dimensões e configuração sincronizados para os clientes móveis via PowerSync (exigemGRANT SELECTaopowersync_role, decisão 0013). PKid UUID DEFAULT uuidv7(); a chave natural viraUNIQUE(uq_*).xls_*— controle e auditoria locais ao backend, nunca sincronizados. PKBIGSERIAL(ouTEXTno lote). Não precisam de GRANT.
Regras para tabela nova¶
PK e chave natural. Toda bi_* usa id UUID NOT NULL DEFAULT uuidv7(): o PowerSync
exige PK de coluna única TEXT/UUID, e BIGSERIAL ou PK composta não replicam (decisão
0008). A chave natural (código do ERP, CNPJ, ou
composta como NF+produto+cliente+filial) vira CONSTRAINT uq_<tabela>_<sufixo> UNIQUE (…),
e as FKs apontam para ela. Tabela sem chave natural de linha não faz upsert — é
replace-por-período ou full-replace (bi_compra, bi_cota_petrobras). A única exceção à
PK UUID é bi_configuracao, com id TEXT como chave semântica da KV.
GRANT ao powersync_role. O ALTER DEFAULT PRIVILEGES da V13 já dá SELECT a toda
tabela nova em public, mas toda migration que cria uma bi_* repete o GRANT como cinto,
logo após o CREATE TABLE (no-op se o role não existir ou se o default já cobriu):
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role') THEN
EXECUTE 'GRANT SELECT ON bi_nova_coisa TO powersync_role';
END IF;
END $$;
Sem o GRANT, o PowerSync para com permission denied for table (42501), o cliente trava no
sync e o nuke morre em SNAPSHOT_PRE. A conferência antes de redeployar o PowerSync
(\dp <tabela> mostra powersync_role=r) e o fix manual em produção estão na decisão
0013. O GRANT não basta para o dado chegar ao
cliente: a tabela ainda entra na publication e no sync_rules.yaml do repo do PowerSync
(runbook do reset).
Coluna nova de conteúdo entra no upsert diff-aware — ver Persistência em lote no guia do código.
Tabelas e relacionamentos¶
erDiagram
bi_cliente ||--o{ bi_movimento : "cod_cliente"
bi_vendedor ||--o{ bi_movimento : "cod_vendedor"
bi_filial ||--o{ bi_movimento : "cod_filial"
bi_produto ||--o{ bi_movimento : "cod_produto"
bi_vendedor ||--o{ bi_meta : "cod_vendedor"
bi_fornecedor ||--o{ bi_compra : "cnpj_fornecedor"
xls_conjunto_lote ||--o{ xls_processamento : "conjunto_id (lógico)"
bi_movimento {
uuid id PK
integer nf
text cod_produto FK
text cod_cliente FK
text cod_filial FK
text cod_vendedor FK
text segmento
}
bi_cliente {
uuid id PK
text cod_cliente UK
}
bi_vendedor {
uuid id PK
text cod_vendedor UK
}
bi_filial {
uuid id PK
text cod_filial UK
}
bi_produto {
uuid id PK
text cod_produto UK
text segmento
}
bi_meta {
uuid id PK
text cod_vendedor FK
date data
}
bi_posto {
uuid id PK
text cnpj UK
}
bi_trr {
uuid id PK
text cnpj UK
}
bi_custo_inventario {
uuid id PK
date data UK
}
bi_configuracao {
text id PK
}
bi_fornecedor {
uuid id PK
text cnpj UK
}
bi_compra {
uuid id PK
text cnpj_fornecedor FK
date data_nf
}
bi_cota_petrobras {
uuid id PK
date competencia
text polo
text produto
}
xls_processamento {
bigint id PK
varchar checksum_sha256 UK
varchar conjunto_id
}
xls_conjunto_lote {
varchar conjunto_id PK
}
xls_nuke_replication {
bigint id PK
}
FKs físicas só entre os
bi_*.bi_movimentoreferencia as dimensões (bi_cliente/bi_vendedor/bi_filial/bi_produto) por suasUNIQUEde código natural, ebi_metareferenciabi_vendedor— Postgres aceita FK apontando para colunaUNIQUE. Não há FK ligandoxls_processamentoaos fatos: um upload mescla osbi_*do seu período (UPSERT diff-aware), não os "possui". A ligaçãoxls_processamento.conjunto_id → xls_conjunto_loteé lógica (sem constraint, para ser retrocompatível).bi_posto,bi_trr,bi_custo_inventario,bi_configuracaoebi_cota_petrobrassão independentes; asxls_*são controle/auditoria.
Fluxo de dados¶
flowchart TD
erp[ERP / job OnPetro] -->|.zip multipart| up["POST /api/xls/processar"]
up -->|binário| store[("Garage / arquivo_bytes")]
up -->|registra upload| ctrl[("xls_processamento")]
up --> det[ArquivoDetector]
det -->|1 de 9 tipos| proc["{Tipo}Processor"]
proc --> upsert["BulkUpserter diff-aware + DELETE seletivo"]
proc --> comprasp["ComprasProcessor: upsert fornecedor + DELETE período + INSERT"]
proc --> cotap["CotaPetrobrasProcessor: full-replace (deletarTudo + INSERT)"]
upsert --> mov[("bi_movimento")]
upsert --> dims[("bi_cliente / vendedor / filial / produto")]
upsert --> outras[("bi_meta / bi_posto / bi_trr / bi_custo_inventario")]
comprasp --> compras[("bi_fornecedor / bi_compra")]
cotap --> cota[("bi_cota_petrobras")]
upsert -->|linhas_efetivas / linhas_removidas| ctrl
mov -->|PowerSync| cli[Clientes móveis]
dims -->|PowerSync| cli
outras -->|PowerSync| cli
compras -->|PowerSync| cli
cfg[("bi_configuracao")] -->|PowerSync| cli
Detalhe de cada tabela¶
Dicionário por tabela (coluna, tipo, chave, nulo?, desde, nota). A chave natural, os
índices e os CHECK vêm em bullets abaixo de cada tabela. Desde vazio = coluna
original (V1 para os bi_* de domínio, V2 para xls_processamento).
bi_movimento — fato de vendas (RELATORIO18 / MARGEM_*) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
nf |
integer | NOT NULL | parte da chave natural | ||
data |
date | NOT NULL | data da venda | ||
uf |
text | UF de destino da venda | |||
municipio |
text | município de destino | |||
bandeira |
text | ||||
categoria |
text | ||||
cif |
char(1) | CIF/FOB | |||
quantidade |
decimal(15,3) | litros | |||
preco |
decimal(15,2) | ||||
taxa_adm |
decimal(10,2) | ||||
frete |
decimal(10,2) | ||||
margem |
decimal(10,2) | complementada por MARGEM_* | |||
prazo |
integer | dias | |||
cod_cliente |
text | FK | → bi_cliente · parte da chave natural |
||
cod_vendedor |
text | FK | → bi_vendedor |
||
cod_filial |
text | FK | → bi_filial · parte da chave natural |
||
cod_produto |
text | FK | → bi_produto · parte da chave natural |
||
segmento |
text | V20 | denormalizado de bi_produto.segmento (role-gating PowerSync) |
- Chave natural (UK)
uq_bi_movimento_natural:nf, cod_produto, cod_cliente, cod_filial. É a chave do UPSERT diff-aware. - FKs (recriadas em V7 apontando para as
UNIQUEde código das dimensões):cod_cliente,cod_vendedor,cod_filial,cod_produto. - Índices (V1):
data,cod_filial,cod_vendedor,cod_produto,cod_cliente;segmento(V20 — a sync rule PowerSync filtra por ele).
bi_cliente — dimensão cliente · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cod_cliente |
text | UK | NOT NULL | uq_bi_cliente_cod |
|
cnpj |
text | ||||
razao_social |
text | ||||
municipio |
text | ||||
uf |
text | ||||
classe |
text | classe comercial |
bi_vendedor — dimensão vendedor · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cod_vendedor |
text | UK | NOT NULL | uq_bi_vendedor_cod |
|
nome |
text |
bi_filial — dimensão filial · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cod_filial |
text | UK | NOT NULL | uq_bi_filial_cod |
|
municipio |
text | da filial (cadastro manual, docs/privado/seed-filiais.sql) |
|||
uf |
text | idem |
- Os processadores só garantem o
cod_filial(FilialRepository.ensureExistsBatch,INSERT … ON CONFLICT DO NOTHING, necessário pela FK debi_movimento) sem tocar emmunicipio/uf: os relatórios XLSX só carregam o código da filial. As colunas UF/Município desses relatórios são do destino da venda e vão parabi_movimento.
bi_produto — dimensão produto · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cod_produto |
text | UK | NOT NULL | uq_bi_produto_cod |
|
nome |
text | ||||
segmento |
text | V20 | combustivel/lubrificante/NULL, derivado do nome |
segmento— classificado denomeporSegmento.doNome(dono único da regra) na pipeline, inline e diff-aware (entra noWHERE … IS DISTINCT FROMdos upserts); as linhas anteriores à coluna vieram do backfill da V20. Pré-requisito do role-gating PowerSync: a sync rule cortabi_movimentoporsegmentoe não faz JOIN, por isso o valor é denormalizado na linha. Regra:combustivel←^ONU \d+;lubrificante←MAXON OILouON LUB; senãoNULL(Arla/Ureia, fora do COMERCIAL). Distinto do filtro "só combustível" doComprasProcessor(startsWith("ONU"), domínio compra/fornecedor) — regras separadas de propósito, não unificar. Decisão 0023.
bi_meta — metas por vendedor/período (META / META_FERNANDO) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
data |
date | NOT NULL | parte da chave natural | ||
litros |
decimal(15,3) | ||||
margem |
decimal(10,2) | ||||
cod_vendedor |
text | FK | NOT NULL | → bi_vendedor · parte da chave natural |
- Chave natural (UK)
uq_bi_meta_natural:cod_vendedor, data. - Índices (V1):
data,cod_vendedor.
bi_posto — cadastro de postos (POSTOS) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cnpj |
text | UK | NOT NULL | uq_bi_posto_cnpj |
|
razao_social |
text | ||||
municipio |
text | ||||
uf |
text | ||||
distribuidor |
text | vinculação a distribuidor | |||
data |
date |
bi_trr — cadastro de TRRs (TRR) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
cnpj |
text | UK | NOT NULL | uq_bi_trr_cnpj |
|
razao_social |
text | ||||
municipio |
text | ||||
uf |
text | ||||
data |
date |
bi_custo_inventario — custo/inventário (MARGEM_CONSOLIDADA) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V7 | DEFAULT uuidv7() |
data |
date | UK | NOT NULL | uq_bi_custo_inventario_data |
|
valor |
decimal(15,2) | ||||
taxa_aplicacao |
decimal(15,2) | ||||
total_juros |
decimal(15,6) | ||||
total_venda_simulada |
decimal(15,2) | ||||
total |
decimal(15,2) |
bi_configuracao — KV global sincronizada · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
text | PK | NOT NULL | V12 | a chave É o id (semântica) |
valor |
text | NOT NULL | V12 | ||
tipo |
varchar(16) | NOT NULL | V12 | CHECK (ver abaixo) | |
descricao |
text | V12 | |||
sistema |
boolean | NOT NULL | V12 | default FALSE |
|
atualizado_em |
timestamptz | NOT NULL | V12 | default NOW() |
- CHECK
chk_bi_configuracao_tipo:tipo ∈ {STRING, INT, BOOL, TIMESTAMP, JSON}. - Seed (V12):
ultimo_nuke(TIMESTAMP,sistema=TRUE— sinal de reset, contrato com o cliente Flutter) eslow_query_timeout_ms(INT,10000, editável). idé a própria chave semântica (não surrogate; renomear = delete+insert).sistema=TRUE= só o backend escreve: a UI mostra a linha read-only e o POST responde403. Chave criada pela tela nascesistema=FALSE; chave de sistema só nasce por migration. Consumo e serialização na etapa 07.
bi_fornecedor — dimensão fornecedor de compra (COMPRAS) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V16 | DEFAULT uuidv7() |
cnpj |
text | UK | NOT NULL | V16 | chave natural |
razao_social |
text | V16 | |||
uf |
text | V16 |
- Chave natural
uq_bi_fornecedor_cnpj:cnpjUNIQUE. Upsert diff-aware (ON CONFLICT (cnpj) DO UPDATE … IS DISTINCT FROM). - Populada só com fornecedores de linhas combustível (ONU); fornecedores de aditivo/envelope, filtrados fora, não entram (seriam dimensão órfã).
bi_compra — fato item-de-NF de compra combustível (COMPRAS) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V16 | DEFAULT uuidv7() |
data_nf |
date | V16 | data da NF (define o período) | ||
numero_nota |
text | V16 | parte antes do "/" de Nº Nota |
||
serie |
text | V16 | parte após o "/" (ex. 802962/1 → série 1) |
||
cnpj_fornecedor |
text | FK | V16 | → bi_fornecedor(cnpj) |
|
produto |
text | V16 | descrição ONU normalizada (trim + espaços) | ||
codigo_onu |
text | V16 | ex. 1202 extraído de "ONU 1202, …" |
||
uf |
text | V16 | |||
municipio |
text | V16 | |||
quantidade |
numeric | V16 | cru (sem arredondar) | ||
valor_bruto |
numeric | V16 | cru; custo médio é derivado no cliente |
- Sem chave natural de linha (PK surrogate puro): a candidata
(cnpj, numero_nota, produto)colide em dados reais (NF802962/1,803823/1,806243/1aparecem 2×) e o arquivo não traz discriminador de linha. Decisão 0017. - Idempotência/correção = replace-por-período: numa transação,
DELETE FROM bi_compra WHERE data_nf BETWEEN <min> AND <max>(intervalo do arquivo) + INSERT de todas as linhas. Sem chave natural não há upsert diff-aware; o skip do reenvio idêntico vem do checksum SHA-256 doxls_processamento. Premissa: um arquivo é o conjunto completo do seu período (se um mês vier partido em 2, o 2º apaga o 1º). - Índices:
(produto, data_nf)e(cnpj_fornecedor).
bi_cota_petrobras — cota mensal de volume por polo/produto (COTA_PETROBRAS) · sincronizada¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
uuid | PK | NOT NULL | V19 | DEFAULT uuidv7() |
competencia |
date | NOT NULL | V19 | 1º dia do mês (YYYY-MM-01) |
|
polo |
text | NOT NULL | V19 | polo de distribuição (forward-fill da célula mesclada) | |
produto |
text | NOT NULL | V19 | canônico: Diesel S10 / Diesel S500 / Gasolina / Diesel R5 |
|
volume_m3 |
numeric | NOT NULL | V19 | volume da cota em m³ |
- Sem chave natural de linha (PK surrogate puro): a planilha é a matriz inteira
(uma aba por ano);
(competencia, polo, produto)poderia repetir sob correções. - Idempotência/correção = full-replace:
DELETE FROM bi_cota_petrobras+ INSERT da matriz inteira, numa transação. Guard de vazio: arquivo sem produto canônico com número não apaga (nunca há caso legítimo de esvaziar via upload). Premissa: ≤1 planilha de cota por lote (o DELETE full-table não é seguro sob concorrência). Decisão 0019. - Parsing (
CotaPetrobrasProcessor): itera abas cujo nome casa^\d{4}$(ignoraPlanilha1), mapeia os 12 meses canônicos por header normalizado (descartaJANEIRO.2024/TOTAL PRODUTO/ANO/ruído) e emite linha só quando o produto é canônico (Diesel S10,Diesel S500,GasolinaouDiesel R5) e a célula é número. Produto não-canônico com valor →LOG.warn(sinal de produto novo/renomeado), não silêncio. - Índices:
(competencia)e(polo, produto).
xls_processamento — controle do pipeline · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | BIGSERIAL |
|
recebido_em |
timestamp | NOT NULL | default NOW() |
||
checksum_sha256 |
varchar(64) | UK | NOT NULL | SHA-256 do binário (dedup) | |
nome_arquivo |
varchar(255) | ||||
arquivo_bytes |
bytea | object storage (etapa 04) | |||
tipo_arquivo |
varchar(20) | tipo detectado (ArquivoDetector) |
|||
status |
varchar(20) | NOT NULL | default PENDENTE |
||
periodo_inicial |
date | vem do conteúdo | |||
periodo_final |
date | vem do conteúdo | |||
iniciado_em |
timestamp | ||||
concluido_em |
timestamp | ||||
tempo_ms |
bigint | ||||
erro_mensagem |
text | ||||
registros_processados |
integer | só a entidade principal do arquivo | |||
log_arquivo |
varchar(500) | ||||
conjunto_id |
varchar(64) | V4 | lote (lógico → xls_conjunto_lote) |
||
objeto_bucket |
varchar(63) | V11 | object storage | ||
objeto_chave |
varchar(512) | V11 | object storage | ||
objeto_tamanho_bytes |
bigint | V11 | object storage | ||
linhas_enviadas |
integer | V15 | linhas pedidas, somando TODAS as tabelas tocadas (etapa 02) | ||
linhas_efetivas |
integer | V9 | INSERT/UPDATE que escreveram, mesmo escopo de linhas_enviadas (etapa 02) |
||
linhas_removidas |
integer | V9 | DELETE seletivo (etapa 02) | ||
enviado_por_id |
text | V17 | quem enviou pela página /xls/importar (id do usuário do bi-comercial) |
||
enviado_por_nome |
text | V17 | nome desse usuário | ||
fk_tentativas |
integer | NOT NULL | V18 | default 0; falhas por violação de FK (23503) de um item de lote — retry de FK transiente (cap. 5) |
- UNIQUE
uq_xls_processamento_checksum (checksum_sha256)— dedup de upload. - Índices (V3):
status,recebido_em DESC; parcialconjunto_id(V4). - Object storage (V11):
objeto_*+arquivo_bytes(etapa 04). - Métricas de idempotência (V9 + V15):
linhas_enviadas/linhas_efetivas/linhas_removidas— sem backfill; registros pré-deploy ficam NULL e a view exibe "—" (etapa 02).linhas_enviadaselinhas_efetivasestão no mesmo escopo (todas as tabelas tocadas), e é a diferença entre as duas que dá as linhas inalteradas — as que oWHERE ... IS DISTINCT FROMfiltrou, sem gerar WAL nem checkpoint PowerSync. Não derivar isso deregistros_processados: essa coluna conta só a entidade principal do arquivo (ex.: os movimentos do RELATORIO18), e misturar os escopos produzia "inalteradas" negativa. status ∈ {PENDENTE, PROCESSANDO, SUCESSO, ERRO, IGNORADO}—IGNORADOquando o tipo foi detectado mas o período é ≤ a data mínima (10/08/2025): o processador pula (RELATORIO18/MARGEM_), sem 4xx. Tipo não* reconhecido é400na recepção (não persiste).
xls_conjunto_lote — coordenação de lote · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
conjunto_id |
varchar(64) | PK | NOT NULL | V4 | pk_xls_conjunto_lote |
total |
int | NOT NULL | V4 | nº de arquivos do lote | |
duplicados |
int | NOT NULL | V4 | default 0 |
|
criado_em |
timestamp | NOT NULL | V4 | default NOW() |
|
notificado_em |
timestamp | V4 | quando o resumo foi enviado | ||
nao_processados |
int | NOT NULL | V14 | default 0 — entradas rejeitadas/em andamento |
- Fecha e notifica quando
(duplicados + finalizados + nao_processados) >= total. nao_processados(V14) contempla o lote-por-ZIP: entradas que não geram processamento nem duplicata-sucesso, para o resumo do Telegram sempre sair.
xls_nuke_replication — auditoria do nuke · local¶
| Coluna | Tipo | Chave | Nulo? | Desde | Nota |
|---|---|---|---|---|---|
id |
bigint | PK | NOT NULL | V8 | BIGSERIAL |
iniciado_em |
timestamp | NOT NULL | V8 | default NOW() |
|
concluido_em |
timestamp | V8 | |||
status |
varchar(20) | NOT NULL | V8 | default PENDENTE; CHECK (ver abaixo) |
|
motivo |
text | V8 | |||
iniciado_por |
varchar(100) | V8 | |||
tempo_ms |
bigint | V8 | |||
erro_mensagem |
text | V8 | |||
step_atual |
varchar(30) | V8 | |||
steps_completados |
jsonb | NOT NULL | V8 | default [] |
|
detalhes |
jsonb | V8 | |||
atividade_atual |
text | V10 | escrita ao vivo p/ polling da UI |
- CHECK
chk_nuke_status:status ∈ {PENDENTE, EM_ANDAMENTO, SUCESSO, ERRO}. - Unique parcial
uq_xls_nuke_replication_em_andamentosobre((1)) WHERE status = 'EM_ANDAMENTO': no máximo um nuke em andamento (2º disparo → 409). - Índice
iniciado_em DESC.
4. Mapeamento entrada↔dados¶
O tipo detectado decide qual processador roda e quais tabelas ele toca:
| Tipo de arquivo | → Tabela(s) do banco | Estratégia |
|---|---|---|
RELATORIO18 |
bi_movimento (fato) + bi_filial/bi_cliente/bi_vendedor/bi_produto |
DELETE seletivo do período + UPSERT diff-aware |
MARGEM_CONSOLIDADA |
bi_custo_inventario + complementa bi_movimento |
UPSERT diff-aware (sem DELETE — reupload é no-op) |
MARGEM_DIA |
bi_movimento (variante diária) |
UPSERT diff-aware (sem DELETE) |
META |
bi_meta |
UPSERT + DELETE seletivo (período + vendedor) |
META_FERNANDO |
bi_meta (variante por vendedor) |
UPSERT + DELETE seletivo |
POSTOS |
bi_posto |
UPSERT por CNPJ + DELETE seletivo (catálogo) |
TRR |
bi_trr |
UPSERT por CNPJ + DELETE seletivo (catálogo) |
COMPRAS |
bi_fornecedor + bi_compra |
fornecedor por UPSERT diff-aware (CNPJ); compra por replace-por-período — DELETE por data_nf + INSERT, porque bi_compra não tem chave natural (decisão 0017) |
COTA_PETROBRAS |
bi_cota_petrobras |
full-replace — DELETE da tabela + INSERT da matriz inteira; arquivo sem produto válido não apaga (guard de vazio); no máximo uma cota por lote (decisão 0019) |
Esta tabela é a cobertura de UPSERT diff-aware × DELETE seletivo por tipo: só
MARGEM_CONSOLIDADA e MARGEM_DIA ficam sem limpeza, de propósito.
Transformações na leitura: datas → DATE; valores → DECIMAL. As dimensões
(bi_filial/bi_cliente/…) são garantidas por INSERT … ON CONFLICT DO NOTHING
(ensureExistsBatch) — o RELATORIO18 só assegura que cada código existe (necessário
pela FK), sem tocar em municipio/uf da filial (esses vêm de cadastro manual,
docs/privado/seed-filiais.sql; ver cap. 3). O período vem do conteúdo
e não vira coluna de fato — delimita quais linhas o processamento mescla naquele
upload (cap. 5).
O detalhe célula-a-célula de cada tipo — coluna do Excel → campo, regras de descarte, deduplicação, ordem de upsert FK-safe e validação — é o contrato de parsing (regra de negócio, fonte da verdade junto ao código dos processadores):
As regras abaixo são o contrato de parsing de cada um dos 9 tipos: como o
ArquivoDetector decide o tipo, de qual aba/coluna cada campo sai, quais linhas
são descartadas e em que ordem as tabelas são populadas. Nomes lógicos (MOVIMENTO,
CLIENTE…) correspondem às tabelas físicas bi_movimento, bi_cliente, etc.
(ver modelagem).
1. Detecção de tipo de arquivo¶
Ordem de verificação (a primeira que satisfazer vence):
- Aba
"Meta por vendedor"existe (ou coluna "Tipo de meta") → META_FERNANDO - 1ª linha da aba padrão tem coluna "Vinculação a Distribuidor" (com/sem acento) → POSTOS
- 1ª linha da aba padrão tem coluna "Tipo de Instalação" ou "Qualificação da Empresa" (com/sem acento) → TRR
- Nome (lower-case) contém
relatorio/relatório/rel 18/relatorio18→ RELATORIO18 - Nome (lower-case) contém
margem consolidadaoumargem_consolidada→ MARGEM_CONSOLIDADA - Nome (lower-case) contém
margem -(espaço + hífen) → MARGEM_DIA - Nome (lower-case) contém
meta→ META - Nenhuma condição → desconhecido →
BadRequestException→ HTTP 400 (não persiste)
Arquivos temporários (nome começa com ~$ ou #) → 400 antes de qualquer análise.
Bug do Python corrigido no Java
O Python fazia 'margem consolidada' or 'margem_consolidada' in nome — sempre
True (string não-vazia é truthy). O Java usa a lógica correta:
nome.contains("margem consolidada") || nome.contains("margem_consolidada").
2. Extração da data de referência¶
| Tipo | Origem da data | Exemplo | Resultado |
|---|---|---|---|
| RELATORIO18 | yyyyMMdd no fim do nome |
relatorio18_20240316.xlsx |
2024-03-16 |
| MARGEM_CONSOLIDADA | MM.YYYY no nome |
margem_consolidada_03.2024.xlsx |
2024-03-01 (dia 1) |
| MARGEM_DIA | DD.MM.YYYY no nome |
margem - 15.03.2024.xlsx |
2024-03-15 |
| POSTOS / TRR | — (data de processamento) | qualquer | LocalDate.now().minusDays(1) (ontem) |
| META / META_FERNANDO | coluna DATA no arquivo |
qualquer | do Excel |
3. Aba lida por tipo¶
| Tipo | Aba | Observação |
|---|---|---|
| POSTOS / TRR | padrão (índice 0) | detecção pela coluna de cabeçalho |
| RELATORIO18 | padrão (índice 0) | dados de NF; aba Inventário processada à parte (§6) |
| MARGEM_CONSOLIDADA | DADOS |
obrigatória — não usar índice 0 |
| MARGEM_DIA | DADOS |
obrigatória — não usar índice 0 |
| META | padrão (índice 0) | — |
| META_FERNANDO | Meta por vendedor (ou aba com "Tipo de meta") |
prioridade: aba nomeada |
4. Mapeamento de colunas Excel → banco¶
4.1 POSTOS → bi_posto¶
| Coluna Excel | Campo | Regra |
|---|---|---|
| Razão Social | razao_social |
strip |
| CNPJ | cnpj |
strip; chave natural |
| MUNICÍPIO | municipio |
strip |
| UF | uf |
strip |
| Vinculação a Distribuidor | distribuidor |
strip |
| (injetado) | data |
LocalDate.now().minusDays(1) |
Upsert por cnpj; linha com qualquer campo nulo é removida.
4.2 TRR → bi_trr¶
| Coluna Excel | Campo | Regra |
|---|---|---|
| Razão Social | razao_social |
strip |
| CNPJ | cnpj |
strip; chave natural; dedup keep-last |
| Município | municipio |
strip |
| UF | uf |
strip |
| (injetado) | data |
LocalDate.now().minusDays(1) |
4.3 RELATORIO18 → bi_filial, bi_cliente, bi_vendedor, bi_produto, bi_movimento¶
Coluna Cód duplicada
O cabeçalho tem Cód duas vezes. O pandas renomeava para Cód.1; o POI SAX
não renomeia — mapear por posição: 1ª ocorrência = Cód (cliente),
2ª ocorrência = Códp (produto).
bi_filial (upsert por cod_filial): FILIAL→cod_filial, Município→municipio, UF→uf.
bi_cliente (upsert por cod_cliente): Cód (1ª)→cod_cliente, R.Social→razao_social, CNPJ→cnpj, Município→municipio, UF→uf, Class→classe.
bi_vendedor (upsert por cod_vendedor): Cod.Vendedor→cod_vendedor, Vendedor→nome.
bi_produto (upsert por cod_produto): Cód (2ª = Códp)→cod_produto, Produto→nome.
bi_movimento (upsert por nf, cod_produto, cod_cliente, cod_filial):
| Coluna Excel | Campo | Regra especial |
|---|---|---|
| NF | nf |
numérico; descartar se inválido |
| DATA NF | data |
DATE; descartar linha se nulo |
| FILIAL | cod_filial |
TEXT |
| UF / Município | uf / municipio |
TEXT |
| CIF | cif |
CHAR(1) |
| Qtde. | quantidade |
DECIMAL; descartar se inválido |
| PV | preco |
DECIMAL; descartar se inválido |
| ADM | taxa_adm |
DECIMAL; descartar se inválido |
| FRETE | frete |
DECIMAL; descartar se inválido |
| Margem Total | margem |
DECIMAL |
| PRAZO | prazo |
INTEGER; nulo/vazio → 0 |
| Bandeira / Categoria | bandeira / categoria |
TEXT (nullable) |
| Cód (1ª) | cod_cliente |
FK bi_cliente |
| Cod.Vendedor | cod_vendedor |
FK bi_vendedor |
| Cód (2ª=Códp) | cod_produto |
FK bi_produto |
Validação: descartar linha se DATA NF nula ou se qualquer de NF, Qtde., PV, ADM, FRETE
não for numérico (logar cada descarte: NF, razão social, CNPJ, produto, linha). Ordenar por NF antes do upsert.
4.4 MARGEM_CONSOLIDADA → mesmas 5 tabelas¶
FILIAL aqui é o nome da cidade
A coluna Excel FILIAL contém o nome do município (não o código). O código
está em CÓD FILIAL.
bi_filial: CÓD FILIAL→cod_filial, FILIAL→municipio (nome da cidade), UF→uf.
bi_cliente: Cód→cod_cliente, R.Social→razao_social, CNPJ→cnpj, Município→municipio, UF→uf.
bi_vendedor: RESP→cod_vendedor, VENDEDORES→nome.
bi_produto: Códp→cod_produto, Produto→nome.
bi_movimento (sem prazo/bandeira/categoria): NF→nf, DATA→data, CÓD FILIAL→cod_filial, UF→uf, Município→municipio, CIF→cif, Qtde.→quantidade, PV→preco, ADM→taxa_adm, FRETE→frete, Margem Total→margem, Cód→cod_cliente, RESP→cod_vendedor, Códp→cod_produto.
4.5 MARGEM_DIA → como MARGEM_CONSOLIDADA, exceto¶
FILIAL→cod_filial(aqui a colunaFILIALcontém o código, não a cidade);Município→municipio.bi_movimento.dataé injetado do nome do arquivo (não há colunaDATAno Excel).- Vendedor:
VENDEDORES→nome(igual à consolidada).
4.6 META → bi_meta¶
CODIGO_VENDEDOR→cod_vendedor (strip), DATA→data, LITROS→litros (DECIMAL), MARGEM→margem (DECIMAL).
Upsert por (cod_vendedor, data): DO UPDATE SET litros = EXCLUDED.litros, margem = EXCLUDED.margem.
4.7 META_FERNANDO → bi_meta¶
Vendedor→cod_vendedor (INTEGER cast p/ TEXT), Data→data (parse dd/MM/yyyy), Valor da meta (L)→volume (DECIMAL; descartar se nulo).
Transformações:
- Agrupar por
(cod_vendedor, mês)somandovolume→litros. data= 1º dia do mês (LocalDate.of(ano, mes, 1)).- Arredondar
litrosse próximo de inteiro redondo:ordem = nº de dígitos de floor(valor);tolerancia = 0.1 * 10^(ordem-1); se|valor − round(valor)| < tolerancia→round(valor). Ex.:3.999.999,99 → 4.000.000.
Upsert por (cod_vendedor, data): DO UPDATE SET litros = EXCLUDED.litros (não atualiza margem).
5. Limpeza de bi_movimento antes do insert¶
RELATORIO18 — DELETE por período (adotado no Java)¶
DELETE FROM bi_movimento WHERE data >= :periodo_inicio AND data <= :periodo_fim
periodo_inicio = MIN(DATA NF), periodo_fim = MAX(DATA NF) do arquivo.
Por que por-período e não open-ended
O Python usava DELETE WHERE data >= min_date (aberto para frente) — só correto se
os arquivos chegam em ordem cronológica. Na API REST a ordem de upload é
não-determinística: subir Fev/2025 e depois Jan/2025 (correção) apagaria fevereiro.
O DELETE por período preserva os demais meses.
Data mínima: se periodo_inicio ≤ 10/08/2025 → status IGNORADO (não é ERRO;
log de aviso; nenhuma alteração no banco). A data-limite existe porque registros
anteriores foram migrados/corrigidos à mão — reprocessá-los sobrescreveria a correção.
A mesma guarda de data mínima vale para MARGEM_CONSOLIDADA e MARGEM_DIA.
MARGEM_CONSOLIDADA / MARGEM_DIA — sem DELETE prévio¶
Só upsert. Registros ausentes no novo arquivo permanecem (os arquivos de margem representam a visão completa do período; reupload sem mudança é no-op).
6. Aba Inventário (opcional em RELATORIO18 e MARGEM_CONSOLIDADA) → bi_custo_inventario¶
Se presente: DATA→data, VALOR→valor, TAXA_APLICACAO→taxa_aplicacao,
TOTAL_JUROS→total_juros, TOTAL_VENDA_SIMULADA→total_venda_simulada, TOTAL→total.
Upsert por data. Aba ausente → ignorar silenciosamente (sem erro).
7. Filtro de movimentos (antigo hack do Power BI) — não implementar no Java¶
O script Python 08-retirar-movimento-indesejado.py copiava registros com
cod_filial = '10' e cod_produto IN ('7','8') para uma tabela de excluídos e os
removia de MOVIMENTO. No Java esses registros permanecem em bi_movimento;
toda query de BI deve aplicar WHERE NOT (cod_filial = '10' AND cod_produto IN ('7','8')).
Documentado no Javadoc de Relatorio18Processor.
8. Deduplicação dentro do arquivo¶
| Tipo | Chave de dedup (keep-last) |
|---|---|
| POSTOS / TRR | cnpj |
| RELATORIO18 / MARGEM_CONSOLIDADA / MARGEM_DIA | (nf, cod_produto, cod_cliente, cod_filial) |
| META | sem dedup interna (o upsert resolve) |
| META_FERNANDO | (cod_vendedor, mês) via agrupamento/soma |
9. Ordem de upsert (FK-safe)¶
Para os tipos que populam múltiplas tabelas, a ordem é obrigatória (garante as FKs de bi_movimento):
bi_filial → bi_cliente → bi_vendedor → bi_produto → bi_movimento
- RELATORIO18 / MARGEM_CONSOLIDADA / MARGEM_DIA populam as 5 tabelas.
- META / META_FERNANDO populam só
bi_meta(ocod_vendedorjá deve existir). - POSTOS → só
bi_posto; TRR → sóbi_trr.
10. Validação na recepção (síncrona, antes de enfileirar)¶
- Magic bytes ZIP
0x50 0x4B 0x03 0x04(xlsx) — senão 400. - Nome não começa com
~$nem#— senão 400. - Detectar o tipo (conteúdo/nome) — desconhecido → 400.
- Calcular checksum SHA-256 e conferir deduplicação de upload.
- Persistir bytes + nome + checksum +
tipo_arquivo+ statusPENDENTE. - Enfileirar o processamento assíncrono.
11. Status do processamento¶
| Status | Quando |
|---|---|
PENDENTE |
recebido e validado; aguardando a thread |
PROCESSANDO |
thread iniciou; banco sendo alterado |
SUCESSO |
todos os upserts concluídos sem exceção |
ERRO |
exceção durante o processamento (erro_mensagem preenchido) |
IGNORADO |
tipo detectado mas período ≤ 10/08/2025 (RELATORIO18/MARGEM_); não* é falha |
Recepção: 202 → PENDENTE; 200 → arquivo com checksum já existente em SUCESSO;
400 → arquivo inválido ou tipo desconhecido (não persiste).
5. Fluxos e processamento¶
Arquitetura interna¶
O código é package-by-feature (decisão 0030):
as fatias processamento (o pipeline XLS), processamento.planilha (a leitura das planilhas),
dados (as tabelas bi_*, folha), importacao, powersync, configuracao, diagnostico e
seguranca, sobre a base transversal comum. Dentro de cada fatia o fluxo é
Controller → Service → Repository: o controller só fala com service, nunca com
repositório direto, e a entidade não cruza a borda HTTP (fala DTO/view-model). A
leitura do Excel está em processamento.planilha (StAX/FastExcel + ArquivoDetector + 9 processadores),
que grava em dados sem conhecer o pipeline; a persistência é Micronaut Data JDBC, sem Hibernate (decisão
0007), com a escrita em lote no BulkUpserter. O
que é igual em todo app da casa — motor de auth e tela de login, erro RFC 7807, object storage,
utilitários — vem das libs br.com.xadm. Pacotes, libs e as travas do ArchUnit estão no
guia do código.
Recepção e processamento assíncrono¶
O envio responde 202 Accepted na hora; o trabalho pesado roda num pool
dedicado. O status em xls_processamento é a máquina de estados do upload:
stateDiagram-v2
[*] --> PENDENTE: receber (checksum, grava binário, insere linha)
PENDENTE --> PROCESSANDO: job inicia
PROCESSANDO --> SUCESSO: grava métricas
PROCESSANDO --> ERRO: catch → erro_mensagem
PROCESSANDO --> IGNORADO: período ≤ 10/08/2025
ERRO --> PENDENTE: reprocessar
SUCESSO --> [*]
IGNORADO --> [*]
Reenviar o mesmo arquivo (mesmo checksum) já concluído com sucesso responde
200 sem reprocessar (decisão 0006); já em
fila, 409. Jobs presos por um restart são retomados no boot
(ProcessamentoStartupRecovery). Todo envio é um lote (o .zip recebido): o
ConjuntoLoteService conta os arquivos que chegaram e
dispara uma notificação de resumo no Telegram quando o lote fecha (decisão
0011).
Falha de FK entre arquivos do mesmo lote é transiente. As entradas do zip rodam
concorrentes no executor processamento (até 4 por vez, sem ordem por dependência), então um
arquivo dependente — ex. META, com FK em bi_vendedor — pode rodar antes do arquivo que cria
a dimensão (RELATORIO18), e a FK aborta o batch. Por isso a 1ª violação de FK num item de lote
não alarma: o item fica ERRO com fk_tentativas=1 e só um LOG.warn, sem Telegram. Quando o
lote fecha — todas as entradas terminais, logo o arquivo-master já commitou —, o
ConjuntoLoteService reivindica esses itens um a um, de forma atômica, e os reprocessa uma
vez antes do resumo. Só a 2ª falha (fk_tentativas=2: a dimensão genuinamente não existe) é
erro de verdade, com LOG.error e Telegram. Teste: ProcessamentoRetryFkIntegrationTest.
Persistência idempotente (diff-aware)¶
O núcleo é o UPSERT diff-aware (etapa 02):
por período, INSERT … ON CONFLICT (chave natural) DO UPDATE … WHERE … IS DISTINCT
FROM, via BulkUpserter (PreparedStatement.executeBatch, com as linhas ordenadas pela
chave natural para não deadlockar entre arquivos paralelos — regras no
guia do código), seguido de DELETE
seletivo do que sumiu no período (nos tipos que fazem catálogo/período; cobertura por tipo
no cap. 4). Só linhas
realmente alteradas trocam de xmin — as inalteradas não re-propagam pelo
PowerSync (sem churn de bateria/banda nos celulares). Cada upload grava duas métricas
agregadas em xls_processamento: linhas_efetivas (INSERT/UPDATE que escreveram) e
linhas_removidas (DELETE seletivo). Para a métrica funcionar, o batch não pode
ser reescrito em multi-VALUES: reWriteBatchedInserts=false é obrigatório (decisão
0009) — com a flag ligada,
executeBatch() devolve SUCCESS_NO_INFO e a contagem per-row se perde.
O binário do .xlsx vai para object storage (Garage), não para o banco — ver a
configuração de storage no cap. 6.
6. Conceitos transversais e configuração¶
Idempotência por checksum + chave natural (caps. 3 e 5) é o conceito central:
identidade de upload (checksum_sha256) separada da identidade de fato (chave
natural, UNIQUE). Detecção automática de tipo (cap. 2) tira do cliente a
responsabilidade de acertar o relatório. Sinal de reset (ultimo_nuke em
bi_configuracao) avisa os clientes a reconectar após um reset da replicação
(etapas 05/07,
decisão 0014).
Prefixos de tabela são contrato com o PowerSync: bi_* sincroniza — PK UUID v7
single-column (decisão 0008) e GRANT SELECT
ao powersync_role (decisão 0013); xls_*
é local (BIGSERIAL/TEXT).
Configuração (variáveis do projeto):
| Tema | Variáveis principais |
|---|---|
| Banco | DATASOURCES_DEFAULT_URL, DATASOURCES_DEFAULT_USERNAME, DATASOURCES_DEFAULT_PASSWORD |
| API | BI_COMERCIAL_XLS_API_TOKEN (Bearer do /api/**), BI_COMERCIAL_XLS_IMPORT_TOKEN (segredo da página /xls/importar) |
| Object storage | S3_FILE_STORAGE (PSQL/PSQL_GARAGE/GARAGE), S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET |
| Auth das views | AUTH_FIREBASE_*, AUTH_SESSION_SECRET (login Google @xadm.com.br) |
| Reset / PowerSync | POWERSYNC_MONGODB_URI, POWERSYNC_NUKE_CONFIRMATION, COOLIFY_API_TOKEN |
| Notificação / erros | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, SENTRY_DSN |
O object storage tem 3 modos (PSQL / PSQL_GARAGE default / GARAGE), com
chave content-addressed {aaaa}/{mm}/{checksum}.xlsx e backfill opt-in
(etapa 04, decisão
0005); logs vão para o Bugsink
via Logback (decisão 0004). Defaults,
semântica de cada modo e o fluxo de auth/reset estão nas etapas e no runbook exaustivo
de envs em operacao/deploy. O deploy é procedimento —
fica no runbook, não neste livro.
7. Contratos públicos / API¶
A conclusão de tudo acima: as assinaturas que outros sistemas consomem.
| Método / rota | Auth | Resultado |
|---|---|---|
POST /api/xls/processar |
Bearer (ROLE_API) |
202 lote recebido (status por arquivo) · 400 zip inválido/vazio/sem .xlsx · 401 token ausente ou inválido |
GET /api/comercial/processamentos |
Bearer ou sessão da view | lista paginada (page/size/status/tipo) |
GET /api/comercial/processamentos/{id} |
Bearer ou sessão da view | detalhe (status, linhas_efetivas/linhas_removidas) |
GET …/{id}/logs |
Bearer ou sessão da view | log (text/plain) |
POST /api/comercial/admin/nuke-replication |
Bearer + X-Confirm-Nuke |
202 (reset da replicação) |
GET/POST /xls/importar (alias /compras/importar) |
segredo ?k= + usuário ?u=/?n= |
página aberta de import manual (Compras e Cota Petrobrás), usada pelo bi-comercial |
A porta de ingestão é única e minimalista: o cliente manda um multipart/form-data
com um só campo, arquivo, contendo um .zip com 1..N .xlsx — o zip é o
lote. Não há data/hora (o período vem do conteúdo), não há tipoArquivo (é
detectado) e não há conjunto_id/conjunto_total: tudo é derivado do conteúdo no
servidor. A resposta 202 traz o conjuntoId 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) —, o total de arquivos e a lista itens, com um status por
arquivo (nome, id, httpStatus, tipoArquivo, mensagem). No httpStatus de
cada item: 202 enfileirado, 200 duplicado, 400/409 não processado. O resumo do
lote sai por Telegram quando todas as entradas terminam. Auth em
duas camadas (decisão 0002 +
0003): /api/** por Bearer estático (as
consultas de processamento aceitam também a sessão da view); as telas server-rendered
(/processamentos/**, /admin/**) por login Google @xadm.com.br. A exceção aberta é a
página /xls/importar, protegida por segredo compartilhado
(contrato). Todo erro do /api/** sai como application/problem+json (RFC
7807, decisão 0015). O contrato narrado
(exemplos curl/Python, respostas) está em dev/api-rest; a
referência interativa gerada do código (OpenAPI/Swagger) no /swagger-ui da app.
8. Apêndice — Histórico (changelog)¶
Como o sistema chegou ao estado atual — as etapas entregues e as decisões que as fundamentaram, até a modernização da stack (Java 25 + Micronaut 5, decisão 0016, etapa 08). Cada etapa descreve o seu delta; este livro é o consolidado.
Etapas:
- Recepção autenticada de planilhas Excel comerciais com detecção automática de tipo e processamento assíncrono.
- Reprocessar um período sem gerar tráfego nem churn desnecessário para os aplicativos móveis.
- Garantia, no CI, de que o processador Java produz o mesmo banco que o legado Python para cada família de planilha.
- Arquivamento dos arquivos recebidos em storage dedicado, fora do banco de dados.
- Reset seguro e auditável da replicação PowerSync, disparável por API ou por um clique na tela admin.
- Acesso às telas internas restrito a contas @xadm.com.br, sem tocar na auth Bearer da API.
- Configurações compartilhadas entre servidor e aplicativo, ajustáveis sem novo deploy.
- Stack uma major atrás modernizada e contrato de erro da API em RFC 7807, sem mudar comportamento observável.
Decisões:
- Micronaut em vez de Spring Boot
- Bearer estático para /api/**
- Login Firebase (Google) nas views server-rendered
- Logs Logback + Bugsink (protocolo Sentry)
- Object storage Garage com 3 modos (PSQL / PSQL_GARAGE / GARAGE)
- UNIQUE em checksum_sha256 (idempotência de upload)
- Micronaut Data JDBC sem Hibernate
- UUID v7 como PK nas tabelas bi_* (sync PowerSync)
- reWriteBatchedInserts=false (preservar rowCount de executeBatch)
- Detecção automática do tipo de relatório
- Coordenação de lotes e notificação agregada
- Exceções em pacote top-level
- GRANT SELECT ao powersync_role
- bi_configuracao (KV) e sinal de nuke (ultimo_nuke)
- Erro da API em RFC 7807 (application/problem+json)
- Bump major: Java 25 + Micronaut 5
- bi_compra sem chave natural — surrogate PK + replace-por-período
- Adotar a lib xadm-seguranca (motor de auth) por versão publicada
- Cota Petrobrás — full-replace + allow-list de produto + alias /xls/importar
- Leitura XLSX: Apache POI → FastExcel (viabiliza native-image)
- Config de build native-image (Fase B): perfil GraalVM + reflect-config local
- Views server-render em JTE (gg.jte) no lugar de Thymeleaf — native-safe
- Segmento denormalizado em bi_produto/bi_movimento para role-gating do PowerSync
- 0024 — Adota o smoke de produção pós-deploy — ponteiro do ADR central 0031
- 0025 — Login servida pela lib — ponteiro da decisão central 0032
- 0026 — Adota o CI 100% GitHub Actions e o deploy pelo control-plane — ponteiro dos ADRs centrais 0026 e 0027
- 0027 — Slug de registry onpetro-xls, distinto do app_id — ponteiro do ADR central 0024
- Adoção do native por padrão (ADR central 0033) — alvo único native
- Adoção das libs da casa na versão corrente (ADR central 0034)
- Adota o package-by-feature
Operação
Deploy¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O que é¶
Como o bi-comercial-xls chega a produção. A release cria a tag vX.Y.Z; o
.github/workflows/pipeline.yml testa, builda a imagem native num runner do GitHub e pede o deploy ao
control-plane (central-backend), que dispara o recurso Coolify. O Coolify só puxa a imagem: não builda e
não tem auto-deploy. Depois do deploy, o smoke confere produção.
Push em master sem tag não deploya: roda o gate (se mudou código) e publica as docs dev.
flowchart TB
T["Tag vX.Y.Z<br/>(skill /xadm-release)"] --> G["gate<br/>./gradlew check + guardas"]
T --> RC["release-check<br/>SemVer × CHANGELOG × tag"]
T --> B["build_native<br/>Dockerfile.native no runner"]
B --> I[("fonte.xadm.biz/xadm/onpetro-xls<br/>native-amd64 e SHA-native")]
G --> D["deploy<br/>central-backend: /api/ci/deploy"]
RC --> D
B --> D
D --> C["central-backend<br/>control-plane"]
C --> K["Coolify<br/>recurso excel-native.onpetro"]
K -- "puxa native-amd64" --> I
D --> S["smoke<br/>commit no /health, rotas, GlitchTip"]
Topologia: o Traefik termina o TLS na borda e encaminha HTTP para a porta interna 8080. Postgres e
Garage são recursos compartilhados; o PowerSync (ps.onpetro, com o Mongo dele) é recurso próprio, do
repo onpetro-powersync.
| Item | Valor |
|---|---|
| Recurso Coolify | excel-native.onpetro, Build Pack Docker Image |
| Imagem | fonte.xadm.biz/xadm/onpetro-xls:native-amd64 (tag móvel canônica; nunca latest) |
| Tag imutável por entrega | fonte.xadm.biz/xadm/onpetro-xls:<sha>-native (alvo de reversão) |
| Domínios | https://excel.onpetro.xadm.biz (principal) e https://excel-native.onpetro.xadm.biz |
| Auto-deploy | desligado; quem dispara é o control-plane |
| Recurso jar | excel.onpetro-jar-pull (excel-jar.onpetro.xadm.biz), parado e sem imagem nova (decisão 0028) |
O control-plane casa o recurso pelo slug da imagem (onpetro-xls) e pelo alvo (native), não pelo uuid:
recriar o recurso não exige mudança no repo (decisão 0026).
Quando usar¶
- Subir uma versão nova: o fluxo normal é a skill
/xadm-release. - Provisionar um ambiente novo (banco, recurso Coolify e variáveis).
- Conferir ou ajustar as variáveis de ambiente do recurso.
- Reverter um deploy que o smoke reprovou.
Pré-requisitos¶
| Componente | Mínimo |
|---|---|
| Runtime | binário native (GraalVM CE 25) sobre ubuntu:24.04, porta 8080 |
| PostgreSQL | 18+ (uuidv7() nativo) |
| Garage | instância compartilhada arquivo-api.xadm.biz, bucket onpetro |
| Docker | 24+, só para build local e para a integração dos testes (Testcontainers) |
Secrets do repo no GitHub (lidos pelo pipeline.yml):
| Secret | Uso |
|---|---|
CENTRAL_DEPLOY_TOKEN |
POST /api/ci/deploy e aviso de publish das docs ao central-backend |
FORGEJO_USER, FORGEJO_TOKEN |
push da imagem em fonte.xadm.biz |
DOCS_S3_ENDPOINT, DOCS_S3_ACCESS_KEY, DOCS_S3_SECRET_KEY |
publicação das docs no Garage |
GLITCHTIP_API_TOKEN |
camada 3 do smoke (read-only). Com smoke.glitchtip declarado no docs/app.json, a falta dele reprova o smoke |
SMOKE_TOKEN, SMOKE_M2M_TOKEN |
rotas session (GET /processamentos) e m2m (GET /api/comercial/processamentos) do smoke. SMOKE_TOKEN é o mesmo valor da env do recurso; SMOKE_M2M_TOKEN é o valor do BI_COMERCIAL_XLS_API_TOKEN. Sem eles, as duas rotas viram só asserção negativa |
DEPLOY_SSH_KEY, COOLIFY_TOKEN e COOLIFY_NATIVE_UUID não são lidos por nenhum job. Se ainda existirem no
repo, apague-os e revogue o token no Coolify.
Variáveis de ambiente¶
Cadastradas no recurso Coolify, com senha e token marcados como Secret. Nunca commitar valor de segredo.
Core¶
| Variável | Padrão | Descrição |
|---|---|---|
BI_COMERCIAL_XLS_API_TOKEN |
— | Bearer estático do /api/** (openssl rand -hex 32; mesmo valor no cliente). Vazia, a lib recusa o boot em produção |
DATASOURCES_DEFAULT_URL |
jdbc:postgresql://localhost:5432/db_onpetro?reWriteBatchedInserts=false |
URL JDBC. O host é o nome do serviço Docker do Postgres na rede do Coolify, não localhost |
DATASOURCES_DEFAULT_USERNAME |
user_onpetro |
usuário do banco |
DATASOURCES_DEFAULT_PASSWORD |
(senha de dev) | senha do banco |
PORT |
8080 |
porta HTTP |
APP_BASE_URL |
https://excel.onpetro.xadm.biz |
base dos links nas notificações do Telegram e no linkStatus do reset REST |
BI_COMERCIAL_XLS_IMPORT_TOKEN |
(vazio) | segredo compartilhado da página aberta de import de XLS. Vazio, a página recusa tudo (403) |
CLIENTE |
OnPetro |
nome do cliente nas views e tag cliente nos eventos do GlitchTip |
reWriteBatchedInserts=false é obrigatório na URL JDBC
Com a flag ligada, executeBatch() retorna SUCCESS_NO_INFO e a métrica linhas_efetivas morre
(decisão 0009). Ao sobrescrever
DATASOURCES_DEFAULT_URL, mantenha o parâmetro.
Import manual de XLS. A rota canônica é /xls/importar; a herdada /compras/importar segue viva para o
botão "Importar XLS" do bi-comercial, que abre a página com ?u=<id>&n=<nome>&k=<segredo>. A página não
tem login Google: a barreira é o BI_COMERCIAL_XLS_IMPORT_TOKEN, com o mesmo valor embutido no
bi-comercial. Aceita a Relação de Compras e a Cota Petrobrás (no máximo uma Cota por lote). Contrato em
API REST.
Telegram¶
TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID. Sem qualquer um dos dois, as notificações ficam desligadas, sem
erro. Reupload que não muda nada (linhas_efetivas = 0 e linhas_removidas = 0) não notifica.
Object storage (Garage)¶
| Variável | Padrão | Descrição |
|---|---|---|
S3_FILE_STORAGE |
PSQL_GARAGE |
PSQL | PSQL_GARAGE | GARAGE, sempre em MAIÚSCULAS |
GARAGE_ENDPOINT |
http://localhost:3900 |
em produção, https://arquivo-api.xadm.biz |
GARAGE_REGION |
garage |
bate com o s3_region do garage.toml |
GARAGE_ACCESS_KEY_ID |
— | access key da key onpetro no Garage. Obrigatória em produção |
GARAGE_SECRET_ACCESS_KEY |
— | secret da mesma key. Obrigatória em produção |
GARAGE_BUCKET |
onpetro |
bucket dedicado deste app |
GARAGE_CRIAR_BUCKET |
false |
true cria o bucket no startup (dev e teste); em produção o bucket já é provisionado |
S3_FILE_STORAGE_BACKFILL |
false |
true liga a migração dos arquivo_bytes legados para o Garage no startup |
Credencial do Garage pelos nomes do broker
Os nomes são os que o broker da Central injeta no recurso: GARAGE_ACCESS_KEY_ID e
GARAGE_SECRET_ACCESS_KEY. Sem eles, a lib ainda lê os antigos GARAGE_ACCESS_KEY e
GARAGE_SECRET_KEY e loga WARN pedindo a migração; esse fallback sai numa versão futura da
xadm-comum-storage. Recurso com os nomes antigos: cadastre os novos, confira o app no ar sem o WARN
e só então apague os antigos. Sem nenhum dos dois pares, o cliente S3 não sobe.
PSQL_GARAGE: o upload grava nos dois backends e o processamento lê do Garage. Falha do Garage no upload responde503, sem linha persistida, e o cliente reenvia.GARAGE: o upload grava só no Garage (arquivo_bytesficaNULL); o backfill, se ligado, migra os legados e zera oarquivo_bytes.- Backfill: depois de mudar de modo, ligue
S3_FILE_STORAGE_BACKFILL=truepor um startup. É idempotente e isola falha por linha; sem dado legado, termina sem trabalho. - A coluna
arquivo_bytesnão é dropada: o modoGARAGEsó a zera em runtime, e voltar paraPSQLouPSQL_GARAGEé trocar a variável.
Desenho dos modos: etapa 04 e decisão 0005.
Error tracking (GlitchTip)¶
Os erros de nível ERROR vão ao GlitchTip (bug.xadm.biz, protocolo Sentry), projeto bi-comercial-xls.
O Sentry é inicializado no main, antes do Micronaut, e lê só variáveis de ambiente. Falha de
inicialização é logada e engolida: o app sobe sem error tracking.
| Variável | Padrão | Descrição |
|---|---|---|
SENTRY_DSN |
(vazio: desligado) | DSN do projeto, o mesmo do features.glitchtip.dsn do docs/app.json. É o único nome lido |
SENTRY_ENVIRONMENT |
production |
ambiente reportado nos eventos |
SENTRY_TRACES_SAMPLE_RATE |
0.0 |
amostragem de tracing (0.0 a 1.0); 0.0 envia só erros |
SENTRY_RELEASE |
(vazio) | não setar. Sem ela, o release é o commit da imagem, que é o que o smoke casa |
GLITCHTIP_DSNnão é lido. Presente semSENTRY_DSN, o boot logaWARNe o error tracking fica desligado; a camada do smoke que procura exceção nova passa sem ver nada.SENTRY_RELEASEsetada quebra o smoke: o release deixa de ser o sha da entrega, e o smoke não reconhece exceção nova deste deploy. Recurso que ainda a tenha: apague.- O commit vem da imagem, não do recurso: o pipeline carimba
XADM_COMMIT=<sha>como build-arg, e oDockerfile.nativeo promove aENV. Não cadastreXADM_COMMITnemSOURCE_COMMITno recurso. MICRONAUT_ENVIRONMENTScomdevoutestdesliga o Sentry; em produção, não a defina.
Reset da replicação PowerSync (POWERSYNC_* / COOLIFY_*)¶
O procedimento está no runbook Resetar a replicação; a tabela completa das variáveis mora aqui.
| Variável | Padrão | Descrição |
|---|---|---|
POWERSYNC_MONGODB_URI |
— | connection string do Mongo do PowerSync (o PS_MONGO_URI do container). Vazia, o reset só simula o drop. O nome do database vem do path da URI |
POWERSYNC_MONGODB_DATABASE |
(do path da URI) | override do nome do database Mongo (raro) |
POWERSYNC_NUKE_CONFIRMATION |
— | habilita o endpoint REST (a tela /admin não depende dela). O nome "nuke" é contrato |
POWERSYNC_SLOT |
(auto-descoberta) | vazio, descobre o slot lendo sync_rules com state='ACTIVE' no Mongo. Explícito só para forçar ou em recuperação |
POWERSYNC_PLUGIN |
pgoutput |
plugin de replicação lógica do slot |
POWERSYNC_BOOTSTRAP_WAIT |
60 |
segundos de espera depois do restart, antes da amostra final |
COOLIFY_API_TOKEN |
— | token do Coolify com permissão de escrita/deploy. Setá-lo liga o restart automático do PowerSync; sem ele, o restart é manual |
POWERSYNC_COOLIFY_NAME |
— | nome exato do recurso PowerSync no Coolify (ps.onpetro). NAME ou UUID |
POWERSYNC_COOLIFY_UUID |
— | alternativa ao _NAME. O nome sobrevive à recriação do recurso; o uuid não |
COOLIFY_API_URL |
https://coolify.xadm.biz |
no mesmo daemon Docker do Coolify, use http://coolify:8080 |
POWERSYNC_HEALTH_URL |
— | health check do PowerSync conferido depois do restart |
POWERSYNC_RESTART_TIMEOUT |
120 |
timeout, em segundos, do polling do deployment no Coolify |
COOLIFY_API_URL interna. O caminho externo passa por DNS público, hairpin NAT e o Traefik com o
certificado wildcard, que a truststore pode recusar (PKIX path building failed). A URL interna é HTTP
numa rede privada e dispensa a allowlist de IP da API do Coolify. Com o app em outra máquina, ou fora da
rede coolify, use a URL pública e ponha o IP de saída na allowlist.
Login Google das views (AUTH_*)¶
A tabela completa e o setup do Firebase estão no runbook Login Google das views. Sem as
cinco variáveis obrigatórias, as views ficam públicas em dev e test e fechadas (503) em
produção.
Passos¶
Deploy de versão nova¶
- Rode a skill
/xadm-releaseno repo: bump SemVer, CHANGELOG, tag anotadavX.Y.Ze push. Um trailerDeploy:na tag anotada escolhe os alvos; sem ele, vale obuild.targetsdodocs/app.json(native). - A tag dispara o
pipeline.yml, com estes jobs em paralelo:gate: guardas estáticas e./gradlew check, com a integração (Testcontainers com Postgres, Mongo e Garage);release-check: SemVer × CHANGELOG × tag (valida-release.py);docs: publica a versão das docs;build_native: builda oDockerfile.nativeno runner do GitHub, comXADM_COMMIT=<sha>, e publicafonte.xadm.biz/xadm/onpetro-xls:native-amd64e:<sha>-native.
- O job
deploysó roda comgate,release-checkebuild_nativeverdes. Ele lê ocommitdo/healthno ar (o alvo de reversão) e fazPOST https://central-backend.xadm.biz/api/ci/deploycom{"target":"native","image":"…:native-amd64"}e oCENTRAL_DEPLOY_TOKEN. Ocentral-backendresolve o recurso e chama o deploy do Coolify, que puxa a tag e faz o rolling: o container novo sobe, fica saudável, e o velho drena. A resposta é assíncrona (queued). -
O job
smoke(smoke.py, manifestosmokedodocs/app.json) espera o/healthresponder comcommitigual ao sha da tag, confere as rotas declaradas (hoje/health→200) e procura exceção nova no GlitchTip comfirstReleaseigual a esse sha. Reprovado, o job falha e o relatório diz o alvo de reversão. -
Deploy fora de release:
workflow_dispatchcomnativeetestsligados builda e deploya o commit do branch. Comtestsdesligado, a imagem é buildada e o deploy sai pulado. central-backendfora do ar: oPOSTnão responde e o deploy é à mão, de dentro da casa: Redeploy do recursoexcel-native.onpetrona UI do Coolify.
Provisionar ambiente novo¶
-
Banco, no Postgres compartilhado, como superuser, antes do app. A senha é gerada na hora (
openssl rand -base64 32) e vai só para oDATASOURCES_DEFAULT_PASSWORDdo recurso:CREATE DATABASE db_onpetro; CREATE USER user_onpetro WITH PASSWORD '<SENHA>'; GRANT ALL PRIVILEGES ON DATABASE db_onpetro TO user_onpetro; \c db_onpetro GRANT ALL ON SCHEMA public TO user_onpetro;A replicação para o PowerSync exige o
powersync_rolecomSELECTnasbi_*(decisão 0013); o reset da replicação exigeREPLICATIONna role do app (Resetar a replicação). 2. Garage: bucketonpetroe key com leitura e escrita nele. O broker da Central injetaGARAGE_ACCESS_KEY_IDeGARAGE_SECRET_ACCESS_KEYno recurso (skill/xadm-setup). 3. Recurso Coolify:+ New Resource→ Build Pack Docker Image →fonte.xadm.biz/xadm/onpetro-xls:native-amd64. Auto-deploy desligado. 4. Domínio:https://excel.onpetro.xadm.biz(DNS A no servidor; o certificado é automático). 5. Variáveis: as das tabelas acima, segredos como Secret; asAUTH_*conforme o runbook de login, incluindo o domínio nos Authorized domains do Firebase. 6. Volume em/app/data: guarda a trilha de cada processamento (app.logs.dir=data/execucoes, arquivosprocessamento-<id>.log), exibida na UI, entre redeploys — é dado, não log (Operação no Coolify). Recurso que montava o volume em/app/logsremonta o mesmo volume em/app/data: o conteúdo segue emdata/execucoes/, porque o volume nomeado já continhaexecucoes/. O log do servidor vai para stdout. Volume nomeado herda o donoappda imagem; bind mount não herda, e o dono no host tem de ser1001:1001. 7. Limite e reserva de memória, nunca0: o baseline da casa para server native é limite256Me reserva128M(Coolify, limite de memória); ajuste pelo consumo medido (docker stats). 8. Health check:/healthna porta8080. OHEALTHCHECKe a cadência já vêm na imagem. 9. Primeiro deploy: reconcile nocentral-backend(sob demanda) e depois uma release ou umworkflow_dispatchcomnativeetests.
As migrations Flyway rodam no startup, com o histórico em bi_comercial_flyway_schema_history (separado
de outros projetos Flyway no mesmo banco).
Os dados das planilhas ficam no PostgreSQL e no Garage, sempre atrás de HTTPS. A trilha de processamento em disco não substitui auditoria fiscal: mantenha as políticas de backup do banco e do volume.
Build local da imagem (diagnóstico)¶
docker build -f Dockerfile.native -t onpetro-xls:local-native . # o mesmo build do job build_native
docker build -t onpetro-xls:local . # par JVM, para diagnóstico
O build native leva por volta de 13 minutos e é limitado por RAM. Rodar o app localmente está em Como rodar.
Verificação¶
-
/healthcom o commit da tag e o flavor native:curl -fsS https://excel.onpetro.xadm.biz/health # {"status":"UP","versao":"X.Y.Z","commit":"<sha da tag>","flavor":"native"}versaosozinha não prova a entrega: dois deploys da mesma versão só se distinguem pelocommit. 2. O jobsmokedo run da tag está verde. 3.GET /processamentosabre depois do login Google. 4. Os logs do container no Coolify não têm erro de Flyway ou datasource, nem oWARNda credencial antiga do Garage ou doGLITCHTIP_DSN.
Observabilidade depois do deploy. linhas_efetivas e linhas_removidas do xls_processamento medem o
que chegou ao disco (e ao slot lógico do PowerSync), separado do que o app tentou
(registros_processados):
-- % de churn evitado nos últimos 30 dias, por tipo
SELECT
tipo_arquivo,
COUNT(*) AS uploads,
SUM(registros_processados) AS enviadas_total,
SUM(linhas_efetivas) AS efetivas_total,
SUM(linhas_removidas) AS removidas_total,
ROUND(100.0 * (1.0 - (SUM(linhas_efetivas) + SUM(linhas_removidas))::numeric
/ NULLIF(SUM(registros_processados), 0)), 1) AS churn_evitado_pct
FROM xls_processamento
WHERE concluido_em > NOW() - INTERVAL '30 days'
AND status = 'SUCESSO'
AND linhas_efetivas IS NOT NULL
GROUP BY tipo_arquivo
ORDER BY enviadas_total DESC;
-- os 50 reuploads mais recentes sem nenhum INSERT/UPDATE/DELETE
SELECT id, nome_arquivo, tipo_arquivo, concluido_em
FROM xls_processamento
WHERE linhas_efetivas = 0
AND COALESCE(linhas_removidas, 0) = 0
ORDER BY concluido_em DESC
LIMIT 50;
churn_evitado_pct perto de 100 indica reupload sem mudança real; perto de 0, todo upload muda dado
(esperado quando o ERP corrige NFs).
Reversão¶
Rollback automático pendente no control-plane
O smoke reprova e informa o alvo de reversão (a tag imutável do commit que estava no ar), mas não reverte sozinho: o control-plane ainda só aceita a tag móvel. A reversão é manual.
| Via | Efeito | O que não faz |
|---|---|---|
Recurso no Coolify → imagem fonte.xadm.biz/xadm/onpetro-xls:<sha anterior>-native → Redeploy |
volta o binário anterior; o <sha anterior> é o que o relatório do smoke dá |
não reverte migration. Enquanto o recurso apontar a tag imutável, um deploy do control-plane re-puxa essa tag: volte o recurso para native-amd64 antes da próxima release |
Nova release com a correção (/xadm-release) |
caminho definitivo, pelo fluxo normal | — |
| Stop do recurso no Coolify (via bruta) | tira o app do ar: uploads e views param | não termina o que está em andamento: no próximo startup, o processamento órfão em PROCESSANDO vira ERRO e o PENDENTE é reenfileirado |
| Religar o recurso jar parado | não é via: ele tem imagem velha, e religar a perna JVM exige release com jar no build.targets |
— |
| Rollback de Deployments do Coolify | não é via confiável: o recurso aponta a tag móvel native-amd64, que já é a imagem nova |
— |
As migrations Flyway não revertem. Elas seguem expand/contract: a release N não dropa o que a N-1 usa, e
por isso o binário anterior roda no schema novo. Migration destrutiva (marcada -- destrutivo-ok: no
.sql) quebra essa garantia: nesse caso, corrija para frente.
Resetar a replicação PowerSync¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Operação destrutiva
Zera o estado replicado (slot lógico do Postgres + base do PowerSync no MongoDB) e força todos os clientes mobile a um full re-sync. Leia até o fim antes de executar pela primeira vez.
O que é¶
Endpoint/tela admin que zera o estado replicado e força os clientes a refazer o sync
a partir do estado atual das tabelas bi_*. Usado para colapsar o histórico
acumulado no Mongo (bucket_data/op_log), que cresce sem parar. São 9 steps
(VALIDAR → LOCK → PAUSE → SNAPSHOT_PRE → RESET_SLOT → DROP_MONGO → RESUME →
SAMPLE_POST → FINALIZAR) executados pelo NukeReplicationService; o desenho está na
etapa 05.
Contrato: apesar do título "Resetar a replicação", o endpoint, o header e as envs mantêm o nome "nuke" (
/admin/nuke-replication,X-Confirm-Nuke,POWERSYNC_NUKE_CONFIRMATION) e a chave de sinal permaneceultimo_nuke— são contratos lidos por operadores e pelo cliente Dart; não renomear.
Quando usar¶
- Storage do Mongo passou da faixa de tolerância (limpeza mensal/trimestral).
- Após mudança de schema em
bi_*que invalide os checkpoints. - Diagnóstico de divergência entre PG e Mongo (último recurso).
Quando não usar¶
- Em horário comercial — clientes online sofrem um freeze de alguns segundos; com muitos clientes (50+), o re-bootstrap simultâneo gera carga.
- Sem janela de baixo tráfego confirmada com operação.
- Com restart manual (sem
COOLIFY_API_TOKEN): sem ter parado o PowerSync antes.
Pré-requisitos¶
Variáveis no app (tabela completa em deploy):
POWERSYNC_MONGODB_URI— sem ela, o reset só simula o drop.POWERSYNC_NUKE_CONFIRMATION— habilita o endpoint REST (a tela/adminnão depende dela).- Restart automático (recomendado):
COOLIFY_API_TOKEN+POWERSYNC_COOLIFY_NAME(ps.onpetro) ou_UUID→ a estratégiacoolifyreinicia o recurso PowerSync sozinha ao fim. Sem o token, o restart é manual. Config incompleta degrada para no-op com WARNING (o reset não quebra, mas o PowerSync não reinicia sozinho). - Postgres — aplicar uma vez, como superuser (o step
RESET_SLOTusapg_drop_replication_slot+pg_terminate_backend):Sem isso, o reset falha comALTER ROLE user_onpetro WITH REPLICATION; -- gerenciar slots GRANT pg_signal_backend TO user_onpetro; -- terminar backend de outra sessãopermission denied to use replication slots. Não dar escrita aopowersync_role(ele éSELECT-only, decisão 0013). - PowerSync — o
powersync.yamlprecisa terbi_configuracaono streamrecent_data(priority 0, sem filtro). Após mudar osync_rules, redeploy do recurso PowerSync no Coolify — senão o sinalultimo_nukeé gravado no PG mas nunca chega no cliente. - Tabela
bi_*nova replica em DOIS passos, e o 2º é EXTERNO a este repo. A migração no processador só faz oGRANT SELECTaopowersync_role(ex.V16parabi_fornecedor/bi_compra). Para o dado chegar no cliente é preciso, no repo../powersync/: (1) incluir a tabela napublicationpowersync(se ela não forFOR ALL TABLES) e (2) adicioná-la aosync_rules.yaml+ redeploy do recurso PowerSync. Sem esse passo, a tabela não replica mesmo com o GRANT aplicado — obi_comprafica populado no PG mas o Painel de Compras não vê nada.
Pré-requisitos no Coolify (só para o restart automático): Settings → API
ligado; token de Keys & Tokens com permissão de escrita/deploy; e (se a
COOLIFY_API_URL for a pública) o IP de saída do container na allowlist. Usando a
URL interna http://coolify:8080 (recomendado no mesmo daemon), a allowlist é dispensada.
Checklist antes de chamar:
- [ ] Janela de baixo tráfego confirmada.
- [ ] Só com restart manual: PowerSync parado (
docker stop powersync/kubectl scale deploy/powersync --replicas=0). Comcoolify, pule. - [ ]
POWERSYNC_NUKE_CONFIRMATIONconfigurada (só para o endpoint REST). - [ ] Disparo REST: Bearer em mãos. Disparo UI: acesso à tela
/admin. - [ ] Telegram (canal de ops) sendo monitorado.
Passos¶
Opção A — Tela /admin (recomendado)¶
- Abrir
/admin(login Google), clicar em "Resetar replicação PowerSync". - Preencher Motivo e Operador e digitar
RESETARno modal anti-acidente (a palavra destrava o submit). Confirmar. - A tela redireciona para o progresso (
/admin/nuke-replication/{id}), que recarrega sozinha a cada 3 s — os 9 steps vão sendo marcados. - Ao terminar:
SUCESSO(verde) ouERRO(vermelho, com o step e a mensagem — consultar a matriz de recovery abaixo). O botão fica desabilitado enquanto houver um resetEM_ANDAMENTO(só um por vez).
Com a estratégia coolify ativa, o PowerSync reinicia sozinho ao final.
Opção B — Endpoint REST¶
Sem
COOLIFY_API_TOKEN(restart manual): parar o PowerSync ANTES. Sem isso, o slot fica ativo e oRESET_SLOTfalha (replication slot ... is active) — a falha mais comum. Subir de volta ao fim (step 3). Comcoolify, o PAUSE/RESUME é automático e este passo é dispensável.
BASE="https://excel.onpetro.xadm.biz"
curl -X POST "$BASE/api/comercial/admin/nuke-replication" \
-H "Authorization: Bearer <BI_COMERCIAL_XLS_API_TOKEN>" \
-H "X-Confirm-Nuke: <POWERSYNC_NUKE_CONFIRMATION>" \
-H "X-Operator-Id: $USER" \
-H "Content-Type: application/json" \
-d '{"motivo": "limpeza trimestral 2026Q2"}'
# → 202 { "nukeId": 42, "status": "EM_ANDAMENTO", "linkStatus": ".../nuke-replication/42" }
Polling do status:
curl "$BASE/api/comercial/admin/nuke-replication/42" \
-H "Authorization: Bearer <BI_COMERCIAL_XLS_API_TOKEN>"
Acompanhar status (EM_ANDAMENTO → SUCESSO) e stepsCompletados. Tempo típico:
< 60 s + POWERSYNC_BOOTSTRAP_WAIT.
Subir o PowerSync de volta — só com restart manual:
docker start powersync # ou
kubectl scale deployment/powersync --replicas=1
Verificação¶
- Telegram de ops:
✅ NUKE CONCLUÍDO em N ms. - Smoke em 1 cliente mobile: reabrir o app e confirmar o re-sync (download dos
buckets atuais; o bootstrap escala com o volume de
bi_*). - O sinal
ultimo_nukeembi_configuracaoé atualizado noFINALIZAR— o cliente Dart compara com o valor local, fazdisconnectAndClear()e re-sincroniza do zero (decisão 0014; etapa 07).
Reversão / recovery por step¶
Sem rollback automático. Falha → status ERRO + Telegram ❌ NUKE FALHOU em step X.
A única ação depois da falha é o RESUME quando ela acontece em DROP_MONGO (o slot já é novo).
Resets órfãos em EM_ANDAMENTO (container morreu no meio) são marcados ERRO
automaticamente pelo NukeStartupRecovery no próximo boot ("Interrompido após
reinício da aplicação"; step_atual preservado para debug).
| Step | Estado deixado pelo erro | Recovery |
|---|---|---|
VALIDAR/LOCK |
outro reset em andamento (409 já no POST) | aguardar o concorrente terminar e re-chamar |
PAUSE |
none: no-op. coolify: API não respondeu / UUID não resolveu — nada destrutivo tocado |
sem cleanup; corrigir config/allowlist; re-chamar |
SNAPSHOT_PRE |
PG consultado, Mongo intacto | sem cleanup; re-chamar |
RESET_SLOT |
slot dropado mas não recriado | psql: SELECT * FROM pg_replication_slots WHERE slot_name='powersync'; — se vazio, SELECT pg_create_logical_replication_slot('powersync','pgoutput'); e subir o PowerSync |
DROP_MONGO |
Mongo parcial/intacto (pior caso); o nuke já tentou o RESUME |
mongosh "$URI": use <db>; db.dropDatabase(); reiniciar o PowerSync (bootstrap fresh). O <db> vem do path da POWERSYNC_MONGODB_URI |
RESUME |
destrutivo já feito, PowerSync parado | coolify: reiniciar o recurso no Coolify. none: docker start powersync / kubectl scale --replicas=1 |
SAMPLE_POST/FINALIZAR |
reset de fato OK, só a finalização falhou | SQL de emergência abaixo |
SQL de emergência (finalização falhou — fecha o reset e grava o sinal pros clientes):
-- 1. marca o reset como concluído
UPDATE xls_nuke_replication SET status = 'SUCESSO' WHERE id = N;
-- 2. grava o sinal pros clientes, se o STEP_FINALIZAR não gravou.
-- 'ultimo_nuke' é contrato (lido pelo cliente Dart) — não renomear.
INSERT INTO bi_configuracao (id, valor, tipo, sistema, atualizado_em)
VALUES ('ultimo_nuke', NOW()::TEXT, 'TIMESTAMP', TRUE, NOW())
ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor, atualizado_em = NOW();
Falha mais provável — RESET_SLOT por slot ativo: sintoma
replication slot ... is active; causa = PowerSync não parou antes. Diagnóstico:
SELECT slot_name, active, active_pid FROM pg_replication_slots;. Preferir parar o
PowerSync e re-chamar (a estratégia coolify faz PAUSE/RESUME sozinha). Último
recurso: kill -9 <active_pid>.
Sintomas pós-reset¶
| Sintoma | Causa provável | Ação |
|---|---|---|
| Cliente mobile não re-sincroniza ao reabrir | PowerSync não voltou a rodar | docker ps / kubectl get pods; subir o recurso |
| Bootstrap demorando (> 10 min) | volume grande em bi_* ou conexão fraca |
esperar; checar logs do PowerSync e o storage do Mongo crescendo |
| Storage Mongo voltou a crescer rápido | upsert diff-aware não está filtrando as linhas inalteradas | verificar se xls_processamento.linhas_efetivas está sendo gravado |
| Erros de checkpoint nos clientes | slot novo à frente do esperado | esperar o bootstrap; se persistir, rodar o reset de novo |
Requests prontos (IntelliJ HTTP Client / VS Code REST Client)¶
Cole num arquivo .http local e preencha os segredos com os valores do deploy
(nunca commitar os tokens reais — use variáveis de ambiente/secrets do seu cliente HTTP).
Rode em ordem: POST → GET do status, repetindo o GET até SUCESSO/ERRO.
@base_url = https://excel.onpetro.xadm.biz
@bearer_token = <BI_COMERCIAL_XLS_API_TOKEN>
@nuke_token = <POWERSYNC_NUKE_CONFIRMATION>
@operator_id = seu-usuario
### Dispara o reset — captura o nukeId da resposta
POST {{base_url}}/api/comercial/admin/nuke-replication
Authorization: Bearer {{bearer_token}}
X-Confirm-Nuke: {{nuke_token}}
X-Operator-Id: {{operator_id}}
Content-Type: application/json
{ "motivo": "limpeza trimestral" }
### Polling do status — repetir até status = SUCESSO ou ERRO (< 60s + bootstrap-wait)
GET {{base_url}}/api/comercial/admin/nuke-replication/{{nuke_id}}
Authorization: Bearer {{bearer_token}}
### Override do bootstrap-wait (útil em janela de teste curta)
POST {{base_url}}/api/comercial/admin/nuke-replication?wait_secs=10
Authorization: Bearer {{bearer_token}}
X-Confirm-Nuke: {{nuke_token}}
X-Operator-Id: {{operator_id}}
Content-Type: application/json
{ "motivo": "smoke test" }
### Listagem paginada (mais recentes primeiro; page base 0, size default 20 e máximo 100)
GET {{base_url}}/api/comercial/admin/nuke-replication?page=0&size=20
Authorization: Bearer {{bearer_token}}
A listagem responde o Page do micronaut-data (forma):
execuções em content, cada uma na forma do polling de status; total em totalSize; página e tamanho em
pageable.number/pageable.size.
Smoke checks de segurança (validam o deploy): POST sem Authorization → 401;
sem X-Confirm-Nuke ou com token errado → 403; GET .../nuke-replication/999999999 → 404.
Configurar login Google das views¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
O que é¶
Habilita o login Google (Firebase) nas views server-rendered (/processamentos/**,
/admin/**), restrito a emails @xadm.com.br. O desenho está na
etapa 06 e na decisão
0003; aqui é o procedimento de
configuração.
- Client: Firebase Authentication (Google sign-in) — popup do Google no navegador.
- Servidor: valida o ID token do Firebase (RS256, contra o JWKS público do
Google via
nimbus-jose-jwt) e emite um cookie de sessão próprio (xadm_session, JWT HS256, TTL 8 h). - Gate: a regra de segurança das views exige o cookie em todo path fora da
whitelist (dentro do
micronaut-security, sem filtro paralelo).
O /api/** (Bearer) não é afetado — a regra das views faz whitelist de /api/**
(decisão 0002).
Quando usar¶
- Subir um ambiente de produção (as views precisam das envs
AUTH_*, senão ficam fail-closed em503). - Apontar o app para um novo domínio público (precisa autorizar o domínio no Firebase).
Pré-requisitos no Firebase (infra, fora do código)¶
Reusa o projeto Firebase existente xadm-6ab81 (compartilhado entre apps X-Adm).
Antes do deploy:
- Authorized domains — Firebase Console → Authentication → Settings →
Authorized domains → adicionar o domínio do deploy (ex.:
excel.onpetro.xadm.biz). Sem isso, osignInWithPopupdo Google falha no navegador. - Provider Google — já habilitado no
xadm-6ab81; nada a fazer. AUTH_SESSION_SECRET— segredo HS256 exclusivo deste deploy, ≥32 chars:openssl rand -hex 32. Nunca reusar de outro deploy/serviço.
Não é necessário o
firebase-admin-sdk.json: a validação do ID token usa só o JWKS público do Google.
Passos¶
Configurar as variáveis no Coolify:
| Variável | Obrigatória | Padrão | Descrição |
|---|---|---|---|
AUTH_FIREBASE_PROJECT_ID |
sim | — | xadm-6ab81 |
AUTH_FIREBASE_API_KEY |
sim | — | Web API key (valor público) |
AUTH_FIREBASE_AUTH_DOMAIN |
sim | — | xadm-6ab81.firebaseapp.com |
AUTH_FIREBASE_APP_ID |
sim | — | Web App ID (valor público) |
AUTH_SESSION_SECRET |
sim | — | segredo HS256 ≥32 chars, exclusivo |
AUTH_SESSION_TTL_SECONDS |
não | 28800 (8 h) |
TTL do cookie |
AUTH_COOKIE_SECURE |
não | true |
Secure (true em prod HTTPS) |
AUTH_XADM_EMAIL_DOMAIN |
não | xadm.com.br |
domínio aceito |
AUTH_ISSUER |
não | bi-comercial-xls |
claim iss do cookie de sessão (lib xadm-seguranca, decisão 0018). Não mudar — o valor é validado na verificação; alterar invalida todas as sessões em voo |
Os 4 AUTH_FIREBASE_* são públicos (vão no HTML da página de login, por design do
Firebase Web SDK). AUTH_SESSION_SECRET é o único segredo — nunca logado, nunca
exposto em HTML.
Fluxo de login / logout¶
- Usuário sem sessão abre uma view → o servidor redireciona para
/login?from=<path>. /loginmostra "Entrar com Google"; o clique abre o popup do Google.- O navegador obtém o ID token do Firebase e faz
POST /login/callback. - O servidor valida CSRF + ID token + domínio
@xadm.com.br, emite o cookiexadm_sessione redireciona de volta para ofromoriginal. - A navbar passa a mostrar o email logado + "Sair" (
POST /logout, expira o cookie).
Verificação¶
- Abrir uma view (
/processamentos) → redireciona para/login→ "Entrar com Google" → após login com conta@xadm.com.br, volta para a view com o email na navbar. - Conta fora do domínio → "Acesso restrito a emails @xadm.com.br" (esperado).
Reversão / comportamento sem as envs¶
Não há "desligar" explícito — o comportamento depende das envs e do ambiente:
| Situação | Comportamento |
|---|---|
Envs AUTH_* completas |
filtro ativo (views exigem login) |
Envs ausentes e ambiente dev/test |
bypass (views públicas, WARN no log) |
| Envs ausentes e produção | fail-closed (503 em todas as views) |
Em produção, sempre configure as 5 variáveis obrigatórias.
Sessão e segurança (caveats)¶
- Sessão stateless (cookie
xadm_session, JWT HS256 auto-contido): não há revogação server-side. O logout só expira o cookie no navegador; um token vazado vale até expirar. O TTL de 8 h (AUTH_SESSION_TTL_SECONDS) limita essa janela. - CSRF: os forms POST de view (
/admin/nuke-replication,/processamentos/{id}/excluir,/processamentos/{id}/reprocessar,/processamentos/{id}/reprocessar-lotee/processamentos/reprocessar-erros) carregam_csrfderivado da sessão; ausente/divergente →403. O enforcement só vale com auth ativo — emdev/test(bypass) o CSRF não é exigido. - Qualquer sessão válida reprocessa. Os três
reprocessar*são ações de escrita liberadas para todo email@xadm.com.brlogado — não há papel de operador vs. leitor. A trava é de estado, não de pessoa: oWHERE … AND status = 'ERRO'doresetarParaPendenteSeErrorecusa (409) tudo que não esteja em ERRO, então o estrago possível é refazer um arquivo que já falhou. Mesmo critério do nuke em/admin.
Troubleshooting¶
| Sintoma | Causa / ação |
|---|---|
Todas as views em 503 |
produção sem as 5 envs obrigatórias — configurar |
| Popup do Google falha (domínio) | domínio do deploy não está em Authorized domains |
Login OK mas volta sempre pro /login |
cookie não persistiu — checar AUTH_COOKIE_SECURE vs HTTPS |
Acesso restrito a emails @xadm.com.br |
conta usada não é do domínio — usar conta @xadm.com.br |
| Bean falha no startup (secret curto) | AUTH_SESSION_SECRET < 32 chars — gerar novo com openssl rand -hex 32 |
| Login intermitente com erro do Google | JWKS do Google indisponível → o servidor responde 503 + Retry-After; tentar de novo |
Dev / API
Como rodar localmente¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Build e testes estão em Como buildar e testar; a organização do código, no guia do código.
Pré-requisitos¶
- JDK 25 no
JAVA_HOME(GraalVM 25, para quem vai compilar o native). O Gradle roda na JVM do ambiente e o plugin Micronaut 5 só configura numa JVM 25. A versão é a dodocs/app.json(toolchain.java), a mesma da CI. - Docker rodando: o Postgres de dev sobe em container, e os testes usam Testcontainers.
- Build pelo wrapper (
./gradlew; no Windows,.\gradlew.bat) — nunca Maven.
Subir o app¶
No Windows, o run-dev.bat da raiz faz tudo:
run-dev.bat
Ele escolhe portas livres (Postgres entre 6543 e 6699, HTTP entre 8080 e 8179), sobe só o serviço
postgres do docker-compose.dev.yml, exporta as variáveis de dev e roda gradlew run --continuous.
Na subida, imprime a URL do servidor, o banco e o Bearer de dev.
Fora do Windows, o mesmo à mão:
docker compose -f docker-compose.dev.yml up -d --wait postgres
export MICRONAUT_ENVIRONMENTS=dev
export DATASOURCES_DEFAULT_URL='jdbc:postgresql://localhost:5432/db_onpetro?reWriteBatchedInserts=false'
export DATASOURCES_DEFAULT_USERNAME=user_onpetro
export DATASOURCES_DEFAULT_PASSWORD=dev123
export BI_COMERCIAL_XLS_API_TOKEN=dev-token-apenas-local
export S3_FILE_STORAGE=PSQL # sem Garage local; ver a seção abaixo
./gradlew run
O reWriteBatchedInserts=false fica na URL: com a flag ligada, a métrica linhas_efetivas se perde
(decisão 0009). Sem as variáveis AUTH_*, no
ambiente dev as views ficam abertas; o login Google está no runbook do login.
Sem TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, as notificações ficam desligadas.
| Caminho | O quê |
|---|---|
/health |
liveness, com a versão |
/processamentos |
acompanhamento dos arquivos recebidos |
/swagger-ui |
contrato da API REST |
O envio de planilhas é o POST /api/xls/processar com o Bearer de dev, descrito no
contrato da API.
Object storage local (Garage)¶
O modo padrão do storage é PSQL_GARAGE, que grava o .xlsx também no Garage: sem Garage no ar, o
upload responde 503. Para rodar sem ele, exporte S3_FILE_STORAGE=PSQL (o run-dev.bat não exporta).
Para rodar com ele, suba o serviço garage e faça uma vez o init de cluster, key e bucket pela CLI do
Garage (a imagem não tem shell para um init automático):
docker compose -f docker-compose.dev.yml up -d garage
C="docker compose -f docker-compose.dev.yml exec garage /garage"
$C status # anote o ID do nó
$C layout assign <ID_DO_NO> -z local -c 1G && $C layout apply --version 1
$C key import --yes -n onpetro GK0123456789abcdef01234567 \
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
$C key allow onpetro --create-bucket
$C bucket create onpetro
$C bucket allow onpetro --key onpetro --read --write --owner
A key é a de dev que o run-dev.bat exporta em GARAGE_ACCESS_KEY_ID/GARAGE_SECRET_ACCESS_KEY; fora
dele, exporte as duas antes do ./gradlew run. Os modos estão na
decisão 0005.
Rodar a imagem native¶
docker build -f Dockerfile.native -t onpetro-xls:native-local .
É o mesmo Dockerfile.native que a CI builda e publica. Para o loop rápido só do binário, sem imagem:
./gradlew nativeCompile -PnativeQuick com a GraalVM 25 no JAVA_HOME — o -PnativeQuick troca o
-Os de produção pelo -Ob, que compila mais rápido. O binário sai em
build/native/nativeCompile/application.
Testar uma lib da casa antes do release¶
A versão da xadm-commons que ainda não saiu no registro vem do seu ~/.m2: a lib sobe o número e
publica com publishToMavenLocal, e este app declara o número novo no build.gradle.kts. O bloco de
repositórios já traz o mavenLocal() depois do registro e filtrado para br.com.xadm, então versão
publicada sempre sai do registro e o local só preenche o número que ainda não saiu.
A CI builda num runner sem ~/.m2: fica vermelha até a lib sair no registro, e a /xadm-release recusa
dependência da casa fora dele. Com o registro fora do ar o Gradle não cai no local; use
./gradlew --offline. Regra em Bibliotecas da casa.
Guia do código¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Audiência: dev que abriu o repositório e quer passar os olhos e entender como o
código está organizado, sem ainda mergulhar no Javadoc classe a classe. Para o
porquê das decisões, veja decisoes/;
para rodar, Como rodar; para testar, Como rodar;
para o contrato externo, API REST.
Como o código é organizado¶
O código segue package-by-feature sob br.com.onpetro.bi.comercial (decisão
0030): cada fatia carrega as próprias camadas, e o que é
transversal fica na base comum. As travas do ArchUnit (abaixo) protegem as fronteiras.
| Fatia | Responsabilidade |
|---|---|
processamento |
o pipeline XLS: a API de ingestão (POST /api/xls/processar) e de consulta (/api/comercial/processamentos), as telas /processamentos/**, a recepção do .zip, a fila assíncrona, o lote (ConjuntoLoteService), o storage do binário (ArmazenamentoArquivoService, backfill), as mensagens de Telegram do processamento e o recovery no boot; entidades xls_processamento/xls_conjunto_lote |
processamento.planilha |
a leitura das planilhas: ArquivoDetector + TipoArquivo, os 9 {Tipo}Processor, ExcelSheetReader (FastExcel), ZipXlsExtrator, Segmento |
dados |
as tabelas bi_* de negócio: entidades, repositórios Micronaut Data e a escrita em lote (BulkUpserter, ColunasConteudo, UpsertResultado). É folha |
importacao |
a página aberta de import manual (/xls/importar, alias /compras/importar): ImportacaoXlsController |
powersync |
o reset (nuke) da replicação: NukeReplicationService, a API REST e a tela /admin, Mongo, slot, Coolify, a auditoria xls_nuke_replication, as mensagens de Telegram do nuke e o recovery no boot |
configuracao |
bi_configuracao: leitura com cache (ConfigService) e a tela /admin/configuracoes |
diagnostico |
/test e /test/glitchtip |
seguranca |
a policy de auth deste app: ViewSecurityRule, ViewWhitelist, ViewRejectionHandler, AuthExceptionHandler |
comum |
base transversal, não é feature: comum.exception (StorageIndisponivelException, 503) e comum.notificacao (TelegramService, só o transporte) |
Na raiz ficam Application e BearerTokenEnv (resolve a env do Bearer no main). As dependências entre
fatias têm um sentido só:
%% lint-mermaid: LR-ok
flowchart LR
I[importacao] --> P[processamento]
P --> PL[processamento.planilha]
PL --> D[dados]
P --> D
P --> C[comum]
PS[powersync] --> CF[configuracao]
PS --> C
O que é igual em todo app da casa vem das libs br.com.xadm, não de cópia local:
| Lib | O que entrega aqui |
|---|---|
xadm-seguranca |
o motor de auth das views (sessão, validação do token Firebase, CSRF, settings) e a tela de login (xadm/login) — decisões 0018 e 0025 |
xadm-comum-web |
o erro RFC 7807 (ProblemDetail + processor), /health e /info, o init do Sentry e as exceções HTTP genéricas (BadRequest/Conflict/NotFound/Forbidden) |
xadm-comum-storage |
o seam de object storage (ArquivoXlsStorage + impl S3/Garage), ArmazenamentoModo e a config S3 |
xadm-comum-util |
ChecksumSha256 e DataConverter |
As camadas de uma requisição¶
Dentro de cada fatia o fluxo é Controller → Service → Repository:
- Controller (
@Controller): só HTTP. Em API retorna DTO (record+@Serdeable, sufixo*Request/*Response) → JSON; em view server-rendered alimenta o template JTE com um view-model (*View, ex.:NukeView,ConfiguracaoView). Zero regra de negócio. - Service (
@Singleton): regra de negócio, orquestração,@Transactional, conversão entidade↔DTO. Injeção por construtor. - Repository (Micronaut Data JDBC, sem Hibernate — decisão 0007): só acesso a dado. Sem proxies lazy, sem sessão; SQL previsível.
- A entidade (
@MappedEntity) nunca cruza o controller. A borda HTTP fala DTO (API) ou view-model (view); a entidade fica da camada de service para dentro.
Notificação e recovery seguem o corte por feature: o TelegramService (em comum) só envia texto
pronto, e cada feature monta o seu (ProcessamentoNotificador, NukeNotificador); o boot recupera cada
feature no seu bean (ProcessamentoStartupRecovery, NukeStartupRecovery), depois do backfill do storage.
O pipeline de processamento (processamento.planilha)¶
Diferente do projeto irmão Transporte (que lê 3 abas fixas), o Comercial
detecta automaticamente o tipo do arquivo — o cliente não informa tipoArquivo
(decisão 0010):
ArquivoDetectorclassifica o.xlsxem um dos 9 tipos por ordem de prioridade — sinais de conteúdo (abas e colunas-âncora) primeiro, nome do arquivo depois. Nomes começando com~$ou#(temporários do Excel) são rejeitados. A tabela de ordem e critérios está no livro, cap. 2.- Cada tipo tem seu
{Tipo}Processordedicado, que sabe o layout daquela planilha e mapeia linhas → entidadesbi_*dedados. - A leitura das células usa o
ExcelSheetReader(FastExcel-reader, StAX) compartilhado — streaming, não carrega a planilha inteira em memória. É o que viabiliza o native-image (decisão 0020). - A gravação passa pelos repositórios de
dados, com oBulkUpserterpara os lotes grandes (seção seguinte).
A leitura não conhece o pipeline: quem a chama é o ProcessamentoTransactionalHelper, em
processamento. O upload (POST /api/xls/processar, em processamento/XlsController) é uma porta
única: um campo arquivo com o .zip do ciclo. Ele não exige período (vem do conteúdo) nem
qualquer campo de coordenação — o zip é o lote. ProcessamentoService.receberZip
deriva o conjuntoId ("zip-" + sha256(zip)[0..60], idempotente) e o total pelo número
de entradas .xlsx, e delega cada entrada ao receber(...). A notificação Telegram
agregada sai quando todas as entradas terminam (via ConjuntoLoteService, decisão
0011).
Persistência em lote¶
A escrita dos bi_* é diff-aware (desenho na
etapa 02): INSERT … ON CONFLICT (chave_natural)
DO UPDATE … WHERE … IS DISTINCT FROM …, seguido do DELETE seletivo do que sumiu no período. Só a linha
que mudou troca de xmin, e as inalteradas não re-propagam pelo PowerSync. Regras para quem mexe num
repositório:
BulkUpserterusaPreparedStatement.executeBatch(), nãoCOPY: oCOPYnão suportaON CONFLICT DO UPDATE, e oexecuteBatch()devolve o rowCount por linha que alimenta a métricalinhas_efetivas. Por issoreWriteBatchedInserts=falseé obrigatório na URL JDBC — ligado, o pgjdbc reescreve o lote em multi-VALUES e devolveSUCCESS_NO_INFO(-2) (0009). Chamar fora de transação ativa lançaIllegalStateException.- Ordene
rowspela chave natural antes doexecuteBatch(). O executorprocessamentoroda até 4 arquivos em paralelo e as dimensões se sobrepõem entre arquivos; oON CONFLICT DO UPDATEtira row lock mesmo quando oWHERE IS DISTINCT FROMnão atualiza, e duas transações upsertando as mesmas linhas em ordens diferentes deadlockam (40P01). Cada repositório mantém umORDEM_CHAVE_NATURALe aplicasorted(...)antes de montar os parâmetros. - Coluna de conteúdo nova entra no
ColunasConteudo, a lista canônica que monta oWHERE … IS DISTINCT FROMe a comparação da conformidade Python↔Java. OWHEREcompara só as colunas presentes noSETdaquele upsert; coluna que não vem do arquivo (ex.:datadebi_posto/bi_trr) fica de fora, senão todo reupload vira UPDATE. - Tabela sem chave natural (
bi_compra,bi_cota_petrobras) não faz upsert: é replace-por-período ou full-replace, em transação. A estratégia por tipo está no livro, cap. 4.
Travas de arquitetura (ArchUnit)¶
As regras vêm da fábrica RegrasArquitetura da lib xadm-comum-teste, aplicadas no ArchitectureTest
— rodam no ./gradlew check, sem Docker:
- import não-vazio — a guarda falha alto se o ArchUnit importar zero classe;
- sem ciclos entre as fatias;
comumnão depende de fatia;dadosé folha — não depende de nenhuma fatia;processamento.planilhanão depende do pacoteprocessamento(fila, lote, storage, controllers);- controller não acessa repository — passa sempre pelo service da fatia;
- nada depende de controller;
- SQL cru por conexão JDBC direta só nas classes de escrita em lote da allowlist (as de
dadose opowersync.PostgresReplicationAdmin); o resto do acesso a dado usa Micronaut Data; - a entidade não depende de infra, e a escrita de controller de view declara
@Consumes.
As regras de fronteira citam o nome completo da fatia (br.com.onpetro.bi.comercial.seguranca..): o
padrão curto casaria também o pacote br.com.xadm.comum.seguranca da lib. Rename de pacote leva o literal
junto e prova que a trava ainda morde, quebrando uma regra de propósito — com o literal para trás, a regra
passa verde sobre zero classe. Mexeu na estrutura de pacotes → rode o gate antes de abrir o PR.
Contratos transversais¶
- Auth — duas portas independentes: Bearer estático no
/api/**(0002) e login Google nas views (0003); ver a etapa 06 e o runbook do login. As consultas de processamento (GET /api/comercial/processamentos,/{id}e/{id}/logs) exigem autenticação — Bearer ou a sessão da view. A exceção aberta é o/xls/importar, protegido por segredo compartilhado (API REST). - Erros da API — todo erro do
/api/**sai comoapplication/problem+json(RFC 7807), via o processor e oProblemDetailda libxadm-comum-web(0015). O código lança as exceções HTTP da lib ou acomum.exception.StorageIndisponivelException; não monta corpo de erro à mão. - Storage — o binário
.xlsxvai para o Garage (S3), coordenado peloprocessamento/ArmazenamentoArquivoService, o único ponto que conhece o modo; 3 modos (0005, etapa 04). - Trilha por processamento — o log de cada execução, exibido na tela de detalhe, é dado: mora em
app.logs.dir(data/execucoes, volume/app/dataem produção — deploy).
Views (JTE) e kit visual¶
As telas são templates JTE em src/main/jte/** (decisão
0022), compostas pelo kit/layout.jte. A tela
recebe view-model ou DTO, nunca a entidade.
- Dois CSS, dois donos:
public/css/custom-theme.cssé a identidade da casa (cópia verbatim do kit do central);public/css/app.csssão os componentes deste app (nasce do scaffold e é editado aqui). O Bootstrap (public/css/bootstrap.min.css+public/js/bootstrap.bundle.min.js), o logo e o favicon também vêm do kit — bump é re-derivar pela/xadm-docs, nunca editar à mão. - Estático anônimo se libera no
intercept-url-mapdoapplication.yml: a regra do YAML roda antes daViewSecurityRulee decide sozinha para usuário anônimo. Sem/js/**ali, o bundle responde401na tela de login (0025). OEstaticosDoKitAnonimosTestfaz GET anônimo em cadahref/srcdo layout e do/login. - Write-endpoint de controller de view declara o media type consumido (
consumes = …): o default do Micronaut é JSON, e o POST de um<form>sem a declaração recebe415sem log útil. - Status visual por classe
.badge-{STATUS}(nãoswitchinline no template); paginação com.pagination .pagination-sm; a tela de progresso do nuke tem<noscript>com meta refresh.
Base de teste¶
Os testes espelham as fatias (src/test/java/.../processamento/planilha, .../dados, .../powersync,
…); ficam na raiz os transversais — ArchitectureTest, AbstractRepositoryIntegrationTest,
IntegracaoDocker, TestXlsxFactory, /health, RFC 7807 e serde. O Postgres e o Garage de teste vêm da
xadm-comum-teste; o AbstractRepositoryIntegrationTest limpa as tabelas do projeto entre testes. Camadas
de teste, fixtures golden e as armadilhas de @Replaces e de cobertura estão em
Como rodar.
Divergências deliberadas do bi-transporte¶
O projeto irmão bi-transporte-xls compartilha a stack e o layout package-by-feature. O corte das fatias
difere onde o domínio pede: lá as bi_* moram em processamento e a leitura em
processamento.processing; aqui as bi_* são a fatia folha dados e a leitura é
processamento.planilha. No domínio, diverge onde o negócio pede: aqui o tipo é detectado entre 9
(lá, 3 abas fixas); a entrada é um .zip que é o lote (lá, data/hora no POST); os bi_* usam
UUID v7 porque sincronizam (0008); as métricas de
idempotência são agregadas por upload (etapa 02);
e o upsert usa executeBatch() em vez de COPY + tabela temporária, pelo rowCount por linha.
Referência completa¶
O Javadoc gerado (árvore de classes navegável, interna) fica em
dev/api/ — gerado pelo CI de docs a cada publish. É o inventário completo
das classes; use-o quando precisar do detalhe de uma delas.
Armadilhas¶
Armadilhas de framework e plataforma que o código deste app contorna; o comentário no código aponta para cá. As armadilhas de framework que a casa já registrou estão em https://docs.xadm.biz/engenharia/java-micronaut/ (seção Armadilhas); aqui ficam só as deste app.
| Sintoma | Causa | Cura |
|---|---|---|
/health e /info respondem 400 |
os controllers da xadm-comum-web são incondicionais e colidem com os do micronaut-management (rota dupla) |
endpoints.health/endpoints.info com enabled: false; o /health fica liveness, sem checar o banco |
Restart do PowerSync falha com PKIX path building failed |
a URL externa do Coolify passa por DNS público, hairpin NAT e Traefik com TLS, e o cert wildcard pode não estar na truststore da JVM (há ainda a allowlist de IP da API) | COOLIFY_API_URL=http://coolify:8080, a URL interna da rede Docker (app na network coolify) |
Como buildar e testar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que entra no projeto e precisa rodar, buildar e testar localmente.
Requisitos¶
- Java 25, preferencialmente GraalVM, no
JAVA_HOME(toolchain pinada nobuild.gradle.kts:JavaLanguageVersion.of(25); instale via SDKMAN). - Gradle 9.5.1 — via wrapper
./gradlew(exigido pelo Java 25; o 8.10.x anterior não roda em JDK 25). Nunca Maven. - PostgreSQL 18+ — necessário para a função nativa
uuidv7()(as PKsbi_*são UUID v7). Flyway 12+ para suporte a PG18. - Docker em execução — o banco (Postgres) roda em container no dev local, e os testes de integração (Testcontainers) sobem Postgres/Mongo/Garage reais.
Atenção ao launcher do Gradle: ele roda no JDK do ambiente. O Gradle 9.5.1 exige Java ≤25 e não roda em JDK futuro não suportado — se
JAVA_HOMEapontar para uma JVM que o Gradle não reconhece, o build falha comIllegalArgumentException: <versão>.
Recomendações de ambiente¶
- VS Code para editar (extensões de Java/Gradle ajudam).
- Claude Code — plugin no VS Code ou CLI no terminal; as regras de IA do repo
estão no
CLAUDE.md. - Forgejo configurado no repo (remote + credencial) para issues e PRs.
- SDKMAN para instalar/alternar JDKs (a GraalVM 25 do projeto).
Rodar e buildar¶
./gradlew run # sobe o app local (precisa de um Postgres 18+ alcançável)
./gradlew shadowJar # gera build/libs/app.jar (deploy via Dockerfile no Coolify)
./gradlew check # compila + Checkstyle + testes, integração inclusa (Docker rodando)
Build tool: Gradle (./gradlew) — nunca Maven. Lint estático: Checkstyle
10.20.1 (regras X-Adm comuns; roda no check).
As camadas de teste¶
| Camada | O que cobre | Como roda |
|---|---|---|
| Unitário | lógica pura (detector, parsers, conversores, limites) | ./gradlew test |
| Conformidade golden (Java×Python) | paridade do parse Java com o resultado do fluxo Python legado, por tipo de arquivo | ./gradlew test — compara contra fixtures golden |
| Integração (Testcontainers) | repositórios, BulkUpserter diff-aware, storage Garage, nuke, rotas autenticadas — contra Postgres/Mongo/Garage reais |
./gradlew test (@IntegracaoDocker — Docker rodando) |
| Arquitetura (ArchUnit) | as travas do guia do código, pela fábrica RegrasArquitetura da xadm-comum-teste |
./gradlew test |
./gradlew test # tudo: unitário + conformidade + ArchUnit + integração
./gradlew check # o gate: test + checkstyle + cobertura (o que a CI roda)
A integração (@IntegracaoDocker — Testcontainers + Postgres/Mongo/Garage reais) roda por
default: é o que o gate da CI executa (docker nativo do runner). A anotação e a condição vêm da
xadm-comum-teste (br.com.xadm.comum.teste.IntegracaoDocker). Sem Docker rodando, a
DockerDisponivelCondition pula cada classe com ERROR no log e no relatório do JUnit, que a marca
skipped — o verde dessa rodada não prova a integração.
Integração atrás de flag seria cobertura órfã: não há opt-in.
Infra de teste¶
- Postgres: o singleton da
xadm-comum-teste(PostgresTestResource,postgres:18-alpineem tmpfs), ligado ao contexto peloPostgresTestPropertyProvider. O teste que precisa de estado próprio (replicação lógica, flag do driver) sobe container dedicado. - Garage: o
GarageTestResourceda lib, com o bucketonpetro(garage.test.bucketna tasktest). Teste que não exercita storage fixaapp.storage.modo=PSQL. - Limpeza entre testes: o
AbstractRepositoryIntegrationTestapaga as tabelasbi_*/xls_*que os testes gravam, depois de esperar o processamento assíncrono pendente. Não é aIntegracaoComPostgresda lib porque oTRUNCATEde todas as tabelas apagaria o seed dabi_configuracao. - Travas: a task
testfalha em 15 min, cada teste em 2 min (junit-platform.properties, desligado no debug) e o pool de teste desiste de um banco morto em 5 s (application-test.yml).
Filtro por classe:
./gradlew test --tests "*Relatorio18ProcessorTest*"
@Replaces declarado em classe de teste vale para o source-set INTEIRO
Um fake @Singleton @Replaces(Servico.class) escrito como classe aninhada dentro de um
teste não fica restrito àquele teste: o processador de anotações gera uma bean
definition global, e todo @MicronautTest que injetar Servico recebe o fake.
Regra: todo fake @Replaces fica atrás de um @Requires(property = …) e cada teste
que o quer liga a property no seu getProperties() — ver AdminControllerTest.FAKES.
Como flagrar: cobertura perto de zero numa classe que "tem teste de integração verde" é o sintoma. Confirme sabotando uma asserção (o teste tem de ficar vermelho) ou procurando no log de teste uma linha que o código real emitiria.
Invocação parcial sobrescreve a cobertura
jacocoTestReport depende de test, e o filtro faz parte do input da task: rodar o
relatório depois de um --tests (ou sem Docker) re-executa test com a rodada menor e
sobrescreve o test.exec. Os XMLs antigos em build/test-results/ continuam no disco, o
que dá a falsa impressão de que os testes rodaram. Meça sempre numa invocação só, com Docker:
./gradlew test jacocoTestReport imprimirCobertura
O relatório conta só código autoral: o jacocoTestReport exclui o gerado pelo Micronaut
(*$Introspection*, *$Definition*, *$IntrospectionRef*, *$Intercepted*), o serializer do
serde 3.x (Serde*) e as templates JTE precompiladas.
Fixtures de conformidade¶
Em src/test/resources/fixtures/<tipo>-YYYYMMDD/:
input.xlsx— a planilha do período (derivada de uma planilha real, anonimizada).resultado-python.json— o golden (saída do fluxo Python) a bater.
Uma fixture por família de arquivo; o teste de conformidade roda a saída Java
contra o golden. A receita de captura + o status por família estão no javadoc de
processamento/planilha/ConformidadeTest.
Fixture nova nasce anonimizada
As planilhas de origem são reais (dado de cliente). Antes de commitar, troque
nomes, CNPJs e valores sensíveis por sintéticos em lockstep no input.xlsx
e no resultado-python.json (mesma substituição nos dois, preservando shape
e o resultado esperado — a conformidade segue verde). "Repo privado" não é
anonimização. Manter dado real exige justificativa declarada (REGRA Nº 3),
não silenciosa.
Contrato do arquivo e detecção de tipo¶
O layout das planilhas e a ordem de detecção dos 9 tipos estão em a detecção automática de tipo da etapa 01 e na etapa 01. O contrato externo da API (envio/consulta) está em API REST.
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 |
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).statusetiposão filtros opcionais que combinam (AND);tipoaceita qualquer valor deTipoArquivo(COMPRAS, COTA_PETROBRAS, POSTOS, TRR, RELATORIO18, MARGEM_CONSOLIDADA, MARGEM_DIA, META, META_FERNANDO).pagecomeça em 0;sizetem default 20 e máximo 100 (acima disso vale 100; zero ou negativo cai no default). Ordenação fixa porrecebido_emdecrescente — o parâmetrosorté ignorado. Corpo: oPagedo micronaut-data, sem DTO próprio (forma)GET /api/comercial/processamentos/{id}— detalhe (JSON, inclui as métricaslinhas_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) e Cota Petrobrás
(COTA_PETROBRAS) — outro tipo → 400 — e no máximo uma planilha de cota por envio (o
full-replace assume a matriz inteira; 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).
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 + headerX-Confirm-Nuke(valor dePOWERSYNC_NUKE_CONFIRMATION). Retorna202comnukeId+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), compage/sizenos mesmos limites da listagem de processamentos e ordenação fixa poriniciado_emdecrescente. Corpo: o mesmoPage(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 oidna lista). - Em item
400recorrente, conferir o nome do arquivo (sem~$/#) e se o conteúdo tem uma coluna reconhecível peloArquivoDetector. - Swagger UI interativo:
https://<host>/swagger-ui(botão Authorize para o Bearer).
Público
BI Comercial — documentação pública¶
Esta seção é a parte aberta da documentação do processador de planilhas comerciais da OnPetro: o manual de quem usa o sistema e o contrato da API para quem integra.
O serviço recebe planilhas Excel do setor comercial (relatórios de faturamento, margens, metas, postos e TRR), reconhece automaticamente o tipo de cada arquivo, valida o conteúdo e alimenta os dashboards e os aplicativos de BI. Quem envia não precisa dizer que tipo de planilha está mandando — o sistema descobre sozinho.
Manual do usuário¶
API¶
- Referência da API (OpenAPI) — contrato interativo, gerado do código.
Manual
Como acompanhar os processamentos de planilha¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-29
Use esta tela para ver o que aconteceu com cada planilha Excel enviada ao sistema: qual tipo foi reconhecido, se foi processada com sucesso, quanto tempo levou e quantas linhas entraram nos dashboards.
Antes de começar¶
- Acesse o sistema com seu login
@xadm.com.br. - No topo da tela, clique em Processamentos.
Passo a passo¶
-
A tela mostra a lista dos envios mais recentes, do mais novo para o mais antigo. Cada linha é uma planilha enviada, com o tipo que o sistema reconheceu (por exemplo, POSTOS, TRR, METAS ou um dos relatórios de margem).

-
Leia a coluna Status de cada envio:
- PENDENTE (cinza): o arquivo foi recebido e está na fila, aguardando processamento.
- PROCESSANDO (azul): o sistema está lendo a planilha e gravando os dados neste momento.
- SUCESSO (verde): a planilha foi processada e os dados já estão nos dashboards.
- ERRO (vermelho): algo impediu o processamento — veja Quando der erro abaixo.
-
Para filtrar, use as duas caixas e clique em Filtrar:
- Status — mostra só os envios naquele estado (por exemplo, só os que deram ERRO).
- Tipo de relatório — mostra só os envios de um tipo (por exemplo, só as METAS, ou só o RELATORIO18). A lista de tipos acompanha o que o sistema reconhece.
Os dois filtros se combinam (ex.: só as METAS que deram ERRO). Para voltar a ver tudo, clique em Limpar filtros.
-
Para ver os detalhes de um envio, clique no número dele (coluna ID). Abre a tela com o tipo detectado, o período da planilha, o tempo de processamento, quantas linhas foram efetivadas/removidas e o registro completo da execução.

Quando der erro, faça isso¶
Status ERRO: abra o detalhe (clique no número do envio) e leia o registro de execução no rodapé — ele aponta o que falhou. A partir daí, há dois caminhos, conforme a causa:
- A planilha está certa e a falha foi do sistema (uma indisponibilidade momentânea, um erro de infraestrutura): clique em Reprocessar, no rodapé do detalhe. O sistema lê de novo o mesmo arquivo, que ficou guardado — você não precisa reenviar nada. Se o envio fazia parte de um pacote e vários arquivos falharam juntos, use Reprocessar os N erros deste lote para refazer todos de uma vez. Para varrer tudo, filtre a lista por ERRO e use Reprocessar todos com erro.
- A planilha tem um problema (em geral uma linha com campo obrigatório em branco): reprocessar vai falhar de novo, porque o arquivo é o mesmo. Corrija a planilha e envie de novo; as linhas que já estavam corretas não são duplicadas.
O botão Reprocessar só aparece em envios com ERRO — não é possível refazer um envio que deu certo, nem atropelar um que ainda está na fila. Reprocessar é seguro: nada é duplicado, e as linhas que não mudaram sequer são regravadas.
Status PENDENTE ou PROCESSANDO há muito tempo: o processamento é assíncrono e costuma terminar em segundos. Se um envio ficar preso por muito tempo, atualize a página; persistindo, anote o número do envio e abra um chamado para o suporte.
Não encontro um envio que fiz: confira os filtros de Status e Tipo de relatório — se algum estiver marcado, clique em Limpar filtros para voltar a ver tudo. Use também a paginação no rodapé da lista.
Como enviar planilhas para processamento¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16
As planilhas Excel do comercial chegam ao sistema por uma chamada de API — em
geral disparada por um job agendado, um script ou o próprio ERP. O envio é
sempre um arquivo .zip com as planilhas do ciclo dentro: o .zip é o
lote. Você não escolhe o tipo de cada planilha nem informa o período: o
sistema reconhece automaticamente se é um relatório de faturamento, de margem,
de metas, de postos ou de TRR ao ler o conteúdo de cada arquivo.
O que você precisa¶
- A URL base do serviço (ex.:
https://excel.onpetro.xadm.biz). - O token de acesso (Bearer) combinado com o suporte — exigido apenas no envio.
- Um arquivo
.zipcontendo de 1 a N planilhas.xlsx. Não é preciso informar período, tipo nem identificação de lote: tudo vem do conteúdo.
Enviando o lote¶
O envio é um POST para /api/xls/processar, com o .zip no campo arquivo
(formato multipart/form-data). É um único envio, mesmo quando o lote tem
vários arquivos.
BASE="https://excel.onpetro.xadm.biz"
TOKEN="cole-o-token-combinado-com-o-suporte"
# Monte o .zip com as planilhas do ciclo
zip -j fechamento.zip ./relatorio18_fev2026.xlsx ./postos_fev2026.xlsx
curl -sS -X POST "$BASE/api/xls/processar" \
-H "Authorization: Bearer $TOKEN" \
-F "arquivo=@./fechamento.zip;type=application/zip"
O sistema responde na hora dizendo que recebeu o lote (o processamento
continua em segundo plano) e devolve, por arquivo do .zip, o número do
processamento, o tipo detectado e o que aconteceu com ele:
{
"conjuntoId": "zip-3f5a…",
"total": 2,
"itens": [
{ "nome": "relatorio18_fev2026.xlsx", "id": 41, "httpStatus": 202,
"tipoArquivo": "RELATORIO18", "mensagem": "Processamento agendado" },
{ "nome": "postos_fev2026.xlsx", "id": 42, "httpStatus": 202,
"tipoArquivo": "POSTOS", "mensagem": "Processamento agendado" }
]
}
O httpStatus de cada item diz o que houve com aquele arquivo: 202
enfileirado, 200 duplicado (já processado antes), 400/409 não processado.
Um arquivo com problema não derruba os outros do lote.
Quando o processamento de todos os arquivos termina, sai uma única notificação do lote — não uma por arquivo. Depois, acompanhe o resultado na tela Processamentos — veja Como acompanhar os processamentos.
Quando der erro, faça isso¶
O .zip foi recusado: confira se ele tem pelo menos um .xlsx dentro. Um
zip vazio, corrompido ou só com outros formatos é rejeitado inteiro.
Um arquivo do lote veio com erro na resposta: confira se é um .xlsx de
verdade (não um .xls antigo nem um arquivo temporário do Excel, cujo nome
começa com ~$ ou #) e se ele tem uma das planilhas reconhecidas pelo
sistema. Um arquivo sem nenhuma coluna reconhecível é rejeitado — mas os demais
do lote seguem normalmente.
Um item respondeu "conflito": aquele arquivo já está na fila ou sendo processado. Aguarde terminar e consulte o resultado na tela Processamentos; não é preciso reenviar.
O envio respondeu "indisponível" (temporário): o armazenamento de arquivos
teve uma indisponibilidade passageira. Aguarde alguns instantes e reenvie o
mesmo .zip.
Reenviei um lote que já tinha mandado: sem problema. O sistema reconhece
tanto o .zip idêntico quanto cada planilha repetida e não duplica os dados —
reenviar só reprocessa o que mudou.
Para o contrato técnico completo (todos os campos, códigos de resposta e exemplos em outras linguagens), veja a Referência da API.
Como resetar a replicação pela tela de Admin¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Esta tela dispara o reset da replicação: ela limpa e recria o canal que leva os dados até os aplicativos de BI nos celulares. Use só quando o suporte orientar — depois do reset, todos os celulares fazem uma sincronização completa na próxima vez que abrirem o app.
Operação destrutiva
O reset afeta todos os usuários de celular. Faça em horário de baixo movimento e só com orientação do suporte. Os detalhes técnicos, o que acontece em cada etapa e como recuperar estão no runbook Resetar Powersync.
Quando (e por que) usar¶
- O armazenamento da replicação cresceu demais e precisa ser enxugado (limpeza periódica combinada com o suporte).
- Houve uma mudança de estrutura de dados que exige que os celulares refaçam a carga do zero.
- Diagnóstico de divergência entre o banco e os aplicativos — como último recurso, sempre com o suporte.
Não faça em horário comercial: com muitos celulares conectados, todos reconectam ao mesmo tempo e sentem uma pausa de alguns segundos.
Antes de começar¶
- Acesse o sistema com seu login
@xadm.com.br. - No topo da tela, clique em Admin.
- Combine o horário com o suporte — de preferência fora do horário de pico.
Passo a passo¶
-
No painel de Administração, localize o cartão Resetar replicação PowerSync e clique no botão de mesmo nome.

-
Abre uma janela de confirmação. Preencha:
- Motivo — por que está fazendo o reset (ex.: orientação do suporte).
- Operador — seu e-mail.
- O campo de confirmação — digite exatamente a palavra RESETAR.

-
Confira tudo e clique em confirmar. Acompanhe o andamento na tela de detalhe que abre em seguida — ela recarrega sozinha e mostra cada etapa até concluir, terminando em SUCESSO (verde) ou ERRO (vermelho).
-
Enquanto um reset estiver em andamento, o botão fica desabilitado — só é possível um reset por vez. O histórico de resets aparece no próprio painel de Admin.
Quando der erro, faça isso¶
O botão de confirmar não habilita: confira se você digitou a palavra RESETAR exatamente (maiúsculas, sem espaços) e se preencheu Motivo e Operador.
O reset parou no meio (uma etapa ficou em vermelho): não tente de novo às cegas — chame o suporte com o horário e a etapa que falhou. O passo a passo de recuperação está no runbook Resetar Powersync.
Depois do reset, um celular não atualiza: peça ao usuário para abrir o app conectado à internet e aguardar — a primeira sincronização após o reset é completa e pode demorar mais que o normal.
Referência da API (OpenAPI)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Referência interativa gerada do próprio código (anotações @Operation/@Schema
nos controllers, via micronaut-openapi). É a fonte-máquina do contrato; a
narrativa — audiência, exemplos curl, layout das abas — vive no
Contrato da API REST.
Como este spec chega aqui
O openapi.yaml é gerado pelo CI de docs (./gradlew classes) e costurado no
portal no build da documentação, não no deploy do app. Para pré-visualizar
localmente: ./gradlew classes e copie
build/classes/java/main/META-INF/swagger/*.yml para
docs/public/api/openapi.yaml.
Etapas do Projeto
Etapa 01 — Processador XLS do BI Comercial¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Correção 2026-07-16: a porta de ingestão descrita nesta etapa (
POST /api/xls/processar, um.zippor envio, com o lote identificado no servidor opcionais) foi substituída por uma porta única.zip—POST /api/xls/processar, campoarquivo, sem nenhum outro campo. Ver o estado atual no livro. O restante desta etapa (detecção de tipo, máquina de estados, idempotência) segue válido.Etapa fundacional: define a base (recepção, detecção de tipo, parsing, persistência, status) que as etapas seguintes estendem. Idempotência → etapa 02; conformidade com o legado Python → etapa 03; object storage do binário → etapa 04.
1. Contexto e escopo¶
Substitui um fluxo manual baseado em scripts Python por um serviço Java
contínuo que recebe planilhas Excel comerciais da OnPetro (vendas, margens,
metas, cadastros de postos/TRRs), detecta automaticamente o tipo do arquivo,
valida e persiste em PostgreSQL (bi_* para BI sincronizado, xls_* para
metadados internos), com rastreabilidade por checksum SHA-256 e histórico
acessível por API e web.
Dentro do escopo: recepção do .xlsx por API autenticada (Bearer);
detecção do tipo via ArquivoDetector (9 tipos, incluindo COMPRAS e COTA_PETROBRAS); parsing
StAX por ExcelSheetReader (FastExcel); gravação idempotente pelos 9 processadores; máquina de
estados do processamento; coordenação de lotes (conjunto_id); notificação
Telegram; recuperação no startup; telas de listagem/detalhe.
Fora do escopo: informar o tipo no upload (é detectado do conteúdo — §3.1); edição dos dados pela web (a origem é a planilha); período no POST (vem do conteúdo, diferente do projeto irmão Transporte).
Integrações: PostgreSQL do BI (escrita); PowerSync (leitura pelos clientes móveis — ver etapa 02); Telegram (notificação); Garage (object storage do binário — etapa 04).
2. Contexto do sistema¶
flowchart TB
equipe[Equipe X-Adm]
cliente[Sistema cliente<br/>ERP / job / script Python]
subgraph app[bi-comercial-xls]
direction TB
http[Micronaut<br/>HTTP + Views]
det[ArquivoDetector]
job[Processamento<br/>assíncrono]
end
pg[(PostgreSQL bi_* / xls_*)]
tg[Telegram Bot API]
equipe -->|GET /processamentos| http
cliente -->|POST /api/xls/processar + Bearer| http
http --> det
http -->|agenda| job
job -->|JDBC / Flyway| pg
job -->|notificação| tg
http -->|consultas| pg
3. Arquitetura — blocos e dependências¶
Código package-by-layer (alinhamento cross-project com o bi-transporte;
decisão consciente — ver a decisão 0030). O ArchUnit guarda os invariantes:
sem ciclos entre pacotes top-level, processing não depende de api,
controllers não dependem de repositórios.
| Pacote | Papel |
|---|---|
api |
Controllers HTTP (REST + views JTE), DTOs expostos |
service |
Recepção (ProcessamentoService), processamento assíncrono (ProcessamentoProcessador), coordenação de lotes (ConjuntoLoteService), consultas, nuke, Telegram, recovery |
processing |
Leitura Excel StAX/FastExcel (ExcelSheetReader), detecção (ArquivoDetector + TipoArquivo), 9 processadores especializados, BulkUpsert |
repository |
Acesso JDBC / Micronaut Data (decisão 0007), repositórios por tabela |
domain |
Entidades mapeadas (bi_* PK UUID v7; xls_* PK BIGSERIAL) |
storage |
Object storage (etapa 04) |
security |
Bearer estático + login Firebase (etapa 06) |
exception (top-level) |
Exceções de domínio → HTTP 4xx/5xx (decisão 0012) |
3.1 Detecção automática de tipo¶
O ArquivoDetector (@Singleton) recebe os bytes + o nome do arquivo e devolve
um TipoArquivo — sem o cliente precisar informar nada. A detecção é
determinística e ordenada por prioridade (a primeira condição satisfeita
ganha), decisão 0010:
| Ordem | Tipo | Critério | Grava |
|---|---|---|---|
| 0 | COMPRAS |
aba chamada "Compras" (conteúdo; antes das regras por nome) | bi_fornecedor + bi_compra |
| 1 | META_FERNANDO |
sheet "Meta por vendedor" ou coluna "Tipo de meta" | bi_meta (por vendedor) |
| 2 | POSTOS |
coluna "Vinculação a Distribuidor" | bi_posto (upsert por CNPJ) |
| 3 | TRR |
coluna "Tipo de Instalação" ou "Qualificação da Empresa" | bi_trr (upsert por CNPJ) |
| 4 | RELATORIO18 |
nome contém relatorio/relatório/rel 18 |
bi_movimento + cadastros |
| 5 | MARGEM_CONSOLIDADA |
nome contém margem consolidada |
bi_custo_inventario + bi_movimento |
| 6 | MARGEM_DIA |
nome contém margem - |
bi_movimento (variante diária) |
| 7 | META |
nome contém meta (e nenhuma acima bateu) |
bi_meta (por período + vendedor) |
- Nada bateu (tipo desconhecido) →
BadRequestException→ HTTP 400 na recepção; o arquivo não é persistido (a detecção é síncrona, antes de enfileirar). Não confundir comIGNORADO(§6): esse é um tipo detectado que o processador pula por regra de negócio (período ≤ 10/08/2025). - Nomes iniciando com
~$(temporário do Excel) ou#(revisão interna) →BadRequestException(400), não entram emxls_processamento. - Detalhe e receita para adicionar um tipo novo:
a decisão
0010.
4. Modelo de dados¶
O controle do pipeline é xls_processamento (xls_*, não sincroniza via
PowerSync). Os fatos e dimensões bi_* são o tema da
etapa 02 (é lá que o schema vira contrato
de UPSERT).
erDiagram
xls_processamento ||--o{ bi_movimento : "produz no período"
xls_conjunto_lote ||--o{ xls_processamento : "agrupa lote"
xls_processamento {
bigserial id PK
varchar checksum_sha256 UK "SHA-256; dedup de upload"
varchar tipo_arquivo "detectado pelo ArquivoDetector"
varchar status "PENDENTE|PROCESSANDO|SUCESSO|ERRO|IGNORADO"
date periodo_inicial
date periodo_final
varchar conjunto_id "FK lote (nullable)"
timestamp recebido_em
bigint tempo_ms
integer linhas_efetivas
integer linhas_removidas
}
xls_conjunto_lote {
varchar conjunto_id PK
int total
int duplicados
timestamp notificado_em
}
xls_processamento — dicionário (grupos de colunas):
- Identidade/recepção:
id(BIGSERIAL PK);checksum_sha256(VARCHAR(64), NOT NULL, UNIQUEuq_xls_processamento_checksum— identidade do upload, decisão 0006);nome_arquivo;recebido_em(default NOW());tipo_arquivo(detectado);periodo_inicial/periodo_final(lidos do conteúdo);conjunto_id(nullable). - Status/execução:
status(default'PENDENTE');iniciado_em,concluido_em;tempo_ms;erro_mensagem(TEXT);registros_processados;log_arquivo. - Object storage (etapa 04):
objeto_bucket,objeto_chave,objeto_tamanho_bytes;arquivo_bytes(BYTEA). - Métricas de idempotência (etapa 02):
linhas_efetivas,linhas_removidas(2 colunas agregadas;NULL= upload anterior à spec de métricas).
Herança do histórico: as tabelas nasceram sem prefixo (V1), ganharam prefixo
bi_*/xls_*(V5/V6) e asbi_*migraram para PKid UUID v7(V7). A tabela de controle passou a se chamarxls_processamento; o lote,xls_conjunto_lote.
5. Contratos¶
5.1 API REST¶
| Método/rota | Auth | Resultado |
|---|---|---|
POST /api/xls/processar |
Bearer (ROLE_API) |
202 aceito (com lote e itens) · 400 zip inválido/tipo desconhecido · 401 Bearer inválido · 503 storage |
GET /api/comercial/processamentos |
anônimo | lista paginada (page/size/status) |
GET …/{id} · …/{id}/logs |
anônimo | detalhe (JSON, inclui métricas) · log (text/plain) |
O POST não exige período (vem do conteúdo) e aceita conjunto_id +
conjunto_total opcionais para coordenar lotes (decisão
0011). Contrato completo e
exemplos curl/Python: o contrato da API REST.
5.2 Formato do arquivo (contrato com o ERP)¶
.xlsx validado por magic bytes ZIP. A leitura usa ExcelSheetReader (FastExcel-reader,
StAX streaming) — não carrega a planilha inteira em memória. O mapeamento de colunas
vive em cada {Tipo}Processor e nas fixtures golden
(etapa 03): mudança de layout pelo cliente =
nova fixture + ajuste no parser, no mesmo PR.
6. Fluxos e estados¶
Pipeline assíncrono: o cliente recebe 202 Accepted na hora (com o id e o
tipoArquivo detectado); o processamento pesado roda no executor processamento
(pool fixo, 4 threads).
sequenceDiagram
actor C as Cliente
participant CC as ComercialController
participant PS as ProcessamentoService
participant AD as ArquivoDetector
participant PP as Processador (@Async)
participant PR as {Tipo}Processor
participant TG as TelegramService
C->>CC: POST /processar (multipart + Bearer)
CC->>PS: receber(bytes, conjunto?)
PS->>PS: magic bytes + checksum SHA-256 + dedup
PS->>AD: detectar tipo
PS->>PS: grava binário (Garage/Postgres) + linha PENDENTE
PS-->>CC: 202 + id + tipoArquivo
CC-->>C: 202 Accepted
Note over PP: assíncrono (pool "processamento")
PP->>PR: processa (UPSERT diff-aware + DELETE seletivo, etapa 02)
PP->>PP: SUCESSO + métricas (ou ERRO / IGNORADO)
PP->>TG: notifica (agregada se lote)
Máquina de estados do status:
stateDiagram-v2
[*] --> PENDENTE: receber (insert)
PENDENTE --> PROCESSANDO: job inicia
PROCESSANDO --> SUCESSO: métricas + tempo_ms
PROCESSANDO --> ERRO: catch → erro_mensagem
PROCESSANDO --> IGNORADO: período ≤ 10/08/2025 (data mínima)
ERRO --> PENDENTE: reprocessar (botão na tela)
SUCESSO --> [*]
IGNORADO --> [*]
ERRO → PENDENTE é a única transição disparada por gente, pelos botões
Reprocessar da tela de detalhe (o envio, o lote dele) e da lista filtrada por
ERRO (todos). Reenfileirar é seguro porque o .xlsx continua no storage e o
upsert é diff-aware — o que não seria seguro é reenfileirar algo em andamento,
e é por isso que o guard mora no WHERE … AND status = 'ERRO' do
resetarParaPendenteSeErro, não no th:if do template: o botão escondido é
conforto de UI, a trava é o SQL. Fora de ERRO a rota responde 409. Em dev, o
botão aparece para qualquer status (refazer um SUCESSO durante o
desenvolvimento) e usa o reset sem guard.
Guard de dupla execução: o job só processa PENDENTE. StartupRecoveryService
retoma no boot jobs presos em PENDENTE/PROCESSANDO após restart.
6.1 Coordenação de lotes¶
Quando o cliente envia N arquivos com o mesmo conjunto_id (+ conjunto_total=N),
o ConjuntoLoteService registra em xls_conjunto_lote; ao completar os N
(processados + duplicados), dispara uma notificação Telegram agregada com o
resumo de efetivas/removidas por tipo.
7. Configuração¶
| Env | Property | Default | Controla |
|---|---|---|---|
PORT |
micronaut.server.port |
8080 |
porta HTTP |
| — | micronaut.server.multipart.max-file-size |
50 MB | tamanho máx. do upload |
| — | micronaut.executors.processamento |
4 threads | pool assíncrono |
BI_COMERCIAL_XLS_API_TOKEN |
app.api-token |
(obrigatória) | Bearer do /api/** |
| — | app.recovery.enabled |
true |
recovery no startup |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID |
telegram.* |
vazio → desabilita | notificação |
Datasource (DATASOURCES_DEFAULT_URL/USERNAME/PASSWORD) e envs das demais
etapas: etapa 04 (Garage),
etapa 05 (PowerSync), etapa 06
(auth). Runbook completo: Deploy.
8. Decisões¶
| Nº | Decisão |
|---|---|
| 0001 | Micronaut vs Spring Boot |
| 0007 | Micronaut Data JDBC, sem Hibernate |
| 0010 | Detecção automática do tipo de arquivo |
| 0011 | Coordenação de lotes via conjunto_id |
| 0012 | Exceções top-level → HTTP |
9. Riscos¶
- Planilha fora do layout → parsing tolerante; erro vira
status=ERRO+ log, nunca derruba o serviço. - Tipo detectado errado → linhas na tabela errada; mitigado pela heurística
ordenada +
ArquivoDetectorTest(cobre as prioridades por tipo + negativos). - Divergência parse Java × Python legado → etapa 03 (golden-master + conformidade em produção).
- Vazamento do
BI_COMERCIAL_XLS_API_TOKEN→ secrets no Coolify, HTTPS obrigatório; rotação é troca atômica (sem janela de dois tokens).
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.
Etapa 02 — Idempotência: UPSERT diff-aware¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16
Estende a base da etapa 01: aqui o schema dos fatos e dimensões
bi_*vira contrato de UPSERT (chave natural + métricas). A validação de que Java escreve o mesmo que o Python é a etapa 03.
1. Contexto e escopo¶
Os bi_* (fatos + dimensões) sincronizam para clientes móveis via PowerSync.
Reprocessar um período (reupload do mesmo relatório) não pode gerar churn de
checkpoint: cada linha tocada no Postgres re-propaga para os celulares,
gastando banda e bateria mesmo sem mudança real.
Dentro do escopo: PK UUID v7 + chave natural UNIQUE por tabela; pipeline
diff-aware (BulkUpsert com ON CONFLICT DO UPDATE WHERE IS DISTINCT FROM);
DELETE seletivo por período; métricas agregadas por upload; preservação de id
UUID e xmin nas linhas inalteradas.
Fora do escopo: a conformidade linha-a-linha com o legado Python → etapa 03.
2. Modelo de dados¶
Toda bi_* tem PK id UUID NOT NULL DEFAULT uuidv7() (migration V7, decisão
0008 — PowerSync exige PK single-column
TEXT/UUID) e a chave natural como UNIQUE (código do ERP, CNPJ, ou composta
NF+produto+cliente+filial). As FKs de bi_movimento/bi_meta apontam para as
UNIQUE das dimensões. xmin (system column do Postgres) é o que o PowerSync
observa para propagar mudanças.
erDiagram
bi_cliente ||--o{ bi_movimento : "cod_cliente"
bi_produto ||--o{ bi_movimento : "cod_produto"
bi_filial ||--o{ bi_movimento : "cod_filial"
bi_vendedor ||--o{ bi_movimento : "cod_vendedor"
bi_vendedor ||--o{ bi_meta : "cod_vendedor"
bi_movimento {
uuid id PK "uuidv7()"
integer nf "uq natural"
text cod_produto "uq natural"
text cod_cliente "uq natural"
text cod_filial "uq natural"
decimal margem
}
bi_meta {
uuid id PK "uuidv7()"
text cod_vendedor "uq natural"
date data "uq natural"
decimal litros
}
Chaves naturais (UNIQUE, V7):
| Tabela | Constraint | Colunas |
|---|---|---|
bi_cliente/bi_produto/bi_filial/bi_vendedor |
uq_bi_*_cod |
código do ERP |
bi_posto/bi_trr |
uq_bi_*_cnpj |
cnpj |
bi_movimento |
uq_bi_movimento_natural |
nf, cod_produto, cod_cliente, cod_filial |
bi_meta |
uq_bi_meta_natural |
cod_vendedor, data |
bi_custo_inventario |
uq_bi_custo_inventario_data |
data |
Procedência de
bi_filial: os relatórios XLSX só carregam ocod_filial; município/UF da filial não existem nessas planilhas (as colunas UF/Município referem-se ao destino da venda, que vai parabi_movimento). Por isso oFilialRepositorysó fazensureExistsBatch(INSERT … ON CONFLICT DO NOTHING) — garante a FK sem tocar emmunicipio/uf(cadastro manual viadocs/privado/seed-filiais.sql).
Métricas por upload (3 colunas agregadas em xls_processamento, V9 + V15):
linhas_enviadas(V15): soma das linhas que o app pediu pra persistir, em todas as tabelas tocadas pelo arquivo.linhas_efetivas: soma dos INSERT/UPDATE que de fato escreveram em disco (oint[]doexecuteBatch(), ignorandoSUCCESS_NO_INFO). Conflito filtrado peloWHERE IS DISTINCT FROMretorna 0 e preserva oidUUID +xmin. Mesmo escopo delinhas_enviadas.linhas_removidas: soma dos DELETE seletivos. Separada delinhas_efetivasporque um relatório com 99 linhas idênticas + 1 removida na origem teriaefetivas=0mas 1 DELETE real disparando WAL.
O placar do diff-aware é linhas_enviadas − linhas_efetivas: as linhas que já
estavam idênticas no banco e não geraram WAL nem checkpoint PowerSync. É o número que
justifica todo o desenho desta etapa, e a tela de detalhe o exibe como Inalteradas.
Correção 2026-07-16: até a V15 não havia
linhas_enviadas, e a tela derivava as "Inalteradas" deregistros_processados − linhas_efetivas. As duas colunas estão em escopos diferentes —registros_processadosconta só a entidade principal do arquivo,linhas_efetivassoma todas as tabelas —, então a conta dava negativo (um RELATORIO18 real exibia-98). A V15 acrescentalinhas_enviadasno mesmo escopo delinhas_efetivase a tela passa a usá-la. Sem backfill: uploads anteriores à V15 ficam comlinhas_enviadasNULL e a tela exibe "—" — a informação nunca foi persistida e não dá pra reconstruir.
Granularidade agregada (3 colunas) é divergência legítima do bi-transporte (que usa 8 colunas por-tabela) — ambas válidas.
3. Fluxos principais¶
Pipeline diff-aware na ordem UPSERT → DELETE, por período. O BulkUpsert
usa PreparedStatement.executeBatch() (decisão
0009) — necessário aqui para
preservar o rowCount per-row que alimenta linhas_efetivas.
flowchart TD
A[Lote do upload] --> C["INSERT ... ON CONFLICT (chave natural)<br/>DO UPDATE WHERE ... IS DISTINCT FROM"]
C --> D{Linha mudou?}
D -- sim --> E[UPDATE: novo xmin → PowerSync propaga]
D -- não --> F[no-op: id UUID e xmin preservados]
C --> G["DELETE seletivo: linhas do período<br/>ausentes no upload"]
G --> H[UPSERT primeiro, DELETE depois]
- O
WHERE ... IS DISTINCT FROMgarante que só linhas realmente alteradas trocam dexmin— as inalteradas não re-propagam (zero churn no reupload). - Ordem: UPSERT primeiro (preserva
id), DELETE depois (remove órfãs). - DELETE seletivo:
DELETE … WHERE periodo BETWEEN X AND Y AND NOT EXISTS (…upload…)— só apaga o que sumiu neste período, nunca o período inteiro.
3.1 Cobertura do DELETE seletivo¶
| Processador | UPSERT diff | DELETE seletivo |
|---|---|---|
| RELATORIO18, META, META_FERNANDO, POSTOS, TRR | ✅ | ✅ (por período/catálogo) |
| MARGEM_CONSOLIDADA, MARGEM_DIA | ✅ | ❌ (intencional — reupload só faz no-op) |
reWriteBatchedInserts=falseé obrigatório (env Coolify): com a flag ON, pgjdbc reescreve N inserts em multi-VALUES eexecuteBatch()retornaSUCCESS_NO_INFO(-2), matando a métricalinhas_efetivas.
4. Configuração¶
| Env / Property | Valor | Papel |
|---|---|---|
DATASOURCES_DEFAULT_URL (query) |
reWriteBatchedInserts=false |
preserva rowCount per-row |
| — | PostgreSQL 18+ | função uuidv7() nativa |
A criação de qualquer bi_* nova exige GRANT ao powersync_role (decisão
0013) — sem ele o PowerSync para com
42501.
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0007 | Micronaut Data JDBC, sem Hibernate |
| 0008 | PK UUID v7 nas bi_* |
| 0009 | reWriteBatchedInserts=false |
| 0013 | GRANT ao powersync_role |
6. Riscos¶
- Chave natural mal definida → colisão de linhas legítimas; mitigado pela escolha de chave por domínio (código/CNPJ/composta).
- UPSERT cego (sem
IS DISTINCT FROM) reintroduziria churn → coberto pelos testes dos processadores. reWriteBatchedInsertsligado por engano → métricalinhas_efetivaszera (SUCCESS_NO_INFO); fixado no env.- GRANT faltando ao
powersync_rolenumabi_*nova → replicação para (42501); ver decisão 0013 e as regras para tabela nova no modelo de dados.
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.
Etapa 03 — Conformidade Python↔Java¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14
Rede de segurança da transição: o processador Java (etapa 01) substitui scripts Python que rodaram anos. Esta etapa prova, arquivo a arquivo, que os dois produzem o mesmo banco — pela idempotência da etapa 02, o que importa é o estado final, não a ordem de escrita.
1. Contexto e escopo¶
Durante a coexistência Python/Java, uma regressão silenciosa (arredondamento,
ordem de processamento, edge case de dado real) escreveria dado errado no BI sem
ninguém perceber. A conformidade compara os dois lados pelo golden-master offline
(ConformidadeTest): fixtures versionadas provam a paridade no CI, sem Python vivo.
Dentro do escopo: o formato canônico do snapshot Python; o ConformidadeTest
offline; a política de fixture anonimizada; TestXlsxFactory.
Fora do escopo: conformidade em produção (ver seção 5); auto-correção de divergências; comparação reversa (Java→Python).
2. Componentes e modelo de dados¶
O formato do resultado-python.json é a fonte de verdade do schema do
snapshot: {tipo, nome_arquivo, dados: {…}}, onde dados tem uma lista por
tabela (filial, cliente, vendedor, produto, movimento,
custo_inventario, …) cujas chaves variam por tipo detectado.
flowchart TB
fx[fixtures/<tipo>-YYYYMMDD/<br/>input.xlsx + resultado-python.json]
ct[ConformidadeTest]
fx --> ct
ct -->|processa input.xlsx| javadb[(Postgres de teste)]
ct -->|compara| fx
- Fixtures (
src/test/resources/fixtures/<tipo>-YYYYMMDD/):input.xlsx(planilha real anonimizada) +resultado-python.json(snapshot que o Python produziu). Cada fixture roda contra o Postgres de teste com as tabelas limpas. TestXlsxFactory: monta.xlsxsintéticos nos testes.
3. Fluxo¶
ConformidadeTest carrega cada fixture, processa input.xlsx pelo processador
Java e valida que o conteúdo do banco bate com resultado-python.json. É o gate de
regressão do CI.
O oráculo Python não existe mais: os resultado-python.json versionados continuam
válidos (o teste os lê de disco), mas fixture nova desse tipo não pode mais ser gerada.
Paridade de tipo novo usa a caracterização do reader (ReaderCaracterizacaoTest), que
congela o comportamento vigente como oráculo — ver decisão
0020.
4. Configuração e política de fixtures¶
- O
ConformidadeTesté integração (@IntegracaoDocker) e roda no./gradlew test/check:./gradlew test --tests "*ConformidadeTest*". Pré-requisito: Docker rodando — sem ele a classe é pulada comERRORno log. - A fixture golden-master nasce anonimizada: nomes, CNPJs e valores sensíveis trocados por sintéticos preservando shape e resultado esperado — "repo privado" não é anonimização. Manter dado real exige justificativa declarada, não silenciosa.
- Receita de captura e status por família de fixture: javadoc de
ConformidadeTest.
5. Frente de produção abandonada¶
O desenho original previa uma segunda frente: cada XLSX processado pelos dois lados teria o pedaço de banco que ele contribuiu comparado em produção, por um endpoint que recebia o snapshot do Python. Ela não foi construída — o projeto de Automação que hospedava o Python foi desativado antes, e sem o lado Python não há o que comparar em produção. A etapa fecha com o golden-master offline como entrega.
6. Riscos¶
- Fixture desatualizada após mudança de layout do cliente → nova fixture (ou caracterização do reader) no mesmo PR do ajuste no parser (etapa 01).
- Dado real vazando numa fixture → anonimização obrigatória na captura; auditado.
Correção 2026-09-14: a etapa descrevia como entregue um endpoint de conformidade em produção que nunca foi construído; o texto passou a registrar o abandono dessa frente, e a etapa fecha com o golden-master offline.
Etapa 04 — Object storage do XLS recebido (Garage)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Estende a recepção da etapa 01: onde o binário do
.xlsxé gravado e lido.
1. Contexto e escopo¶
O binário do .xlsx recebido era guardado em BYTEA no Postgres, o que infla o
banco com o volume de uploads. O objetivo é mover o binário para object storage
S3-compatível (Garage) sem perder a rede de segurança durante a transição.
Dentro do escopo: seam de storage; 3 modos de operação; chave content-addressed; backfill no startup; tratamento de falha de I/O no upload.
Fora do escopo: CDN/cache de leitura; versionamento de objeto.
2. Componentes e modelo de dados¶
flowchart TB
rec[receber upload] --> coord[ArmazenamentoArquivoService]
coord -->|seam ArquivoXlsStorage| s3[S3ArquivoXlsStorage<br/>AWS SDK v2]
s3 --> factory[S3ClientFactory]
factory --> garage[(Garage bucket: onpetro)]
coord -.->|modo PSQL / PSQL_GARAGE| bytea[(xls_processamento.arquivo_bytes)]
Único ponto que conhece o modo: service/ArmazenamentoArquivoService. O seam é
storage/ArquivoXlsStorage (impl S3ArquivoXlsStorage, cliente via
S3ClientFactory). As referências do objeto vivem em xls_processamento (V11):
objeto_bucket (VARCHAR(63)), objeto_chave (VARCHAR(512)),
objeto_tamanho_bytes (BIGINT); o binário no Postgres fica em arquivo_bytes
(BYTEA). Chave content-addressed {aaaa}/{mm}/{checksum_sha256}.xlsx,
particionada por periodoInicial quando disponível, senão por
recebidoEm.toLocalDate() (no receber() ainda não há período — só após o parse
async).
3. Fluxos e estados¶
app.storage.modo / env S3_FILE_STORAGE (sempre em MAIÚSCULAS; enum
storage/ArmazenamentoModo):
| Modo | Grava | Lê (processamento) | Backfill no startup |
|---|---|---|---|
PSQL |
só Postgres (arquivo_bytes) |
Postgres | no-op |
PSQL_GARAGE (default) |
Postgres e Garage | Garage | copia legados p/ Garage, mantém arquivo_bytes |
GARAGE |
só Garage | Garage | migra legados e zera arquivo_bytes |
- Falha de I/O no upload →
StorageIndisponivelException→ HTTP 503 (sem linha persistida; o cliente reenvia). - Backfill (
StorageBackfillService, opt-inapp.storage.backfill.enabled=true,@Order(-100)— roda antes doStartupRecoveryService): idempotente, isola falha por linha. - A coluna
arquivo_bytesnão é dropada — o "cleanup" do modoGARAGEé zerar em runtime (reversível: voltar paraPSQL_GARAGEre-popula no backfill). - AWS SDK ≥2.30 liga flexible checksums que o Garage rejeita → o
S3ClientFactoryusaWHEN_REQUIRED(ver etapa 08).
4. Configuração¶
| Env | Property | Default |
|---|---|---|
S3_FILE_STORAGE |
app.storage.modo |
PSQL_GARAGE |
| — | app.storage.backfill.enabled |
false |
GARAGE_ENDPOINT |
garage.endpoint |
http://localhost:3900 |
GARAGE_REGION |
garage.region |
garage |
GARAGE_ACCESS_KEY / GARAGE_SECRET_KEY |
garage.access-key/garage.secret-key |
dev keys |
GARAGE_BUCKET |
garage.bucket |
onpetro |
Bucket de produção: onpetro. Endpoint compartilhado: arquivo-api.xadm.biz
(provisionado no Coolify). Em testes que não exercitam storage, fixar
app.storage.modo=PSQL evita depender do Garage (GarageTestResource sobe um
container dxflrs/garage singleton por JVM quando necessário). Envs completas:
o runbook de deploy (variáveis do Garage).
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0005 | Object storage Garage com 3 modos |
| 0006 | Checksum SHA-256 UNIQUE (identidade do upload) |
6. Riscos¶
- Garage indisponível no modo
GARAGE→ uploads recusados (503) até voltar; o modoPSQL_GARAGEmitiga durante a transição. - Migração irreversível por engano → mitigado:
arquivo_bytesnão é dropada; o modoGARAGEsó zera em runtime. - Checksum flexível do AWS SDK rejeitado pelo Garage →
WHEN_REQUIREDnoS3ClientFactory(etapa 08).
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.
Etapa 05 — Resetar a replicação (nuke)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Esta página é o desenho (porquê e como funciona). O procedimento operacional passo a passo vive no runbook
nuke-replication. O sinal que os clientes leem está na etapa 07.
1. Contexto e escopo¶
Mudanças estruturais nas bi_*, ou o histórico acumulado no Mongo do PowerSync
(bucket_data/op_log) crescendo sem parar, exigem de vez em quando zerar o
estado replicado (slot de replicação no Postgres + base do PowerSync no
MongoDB) e forçar um re-sync limpo nos clientes móveis. É destrutivo e arriscado
à mão — encapsulado num serviço auditável, com confirmação anti-acidente e sinal
para os clientes reconectarem.
Dentro do escopo: o NukeReplicationService (9 steps); os dois gatilhos
(REST + tela /admin); restart opcional do PowerSync via Coolify; audit por step
em xls_nuke_replication.
Fora do escopo: o consumo do sinal pelos clientes (chave ultimo_nuke) →
etapa 07.
2. Componentes e modelo de dados¶
flowchart TD
rest[POST /api/comercial/admin/nuke-replication<br/>Bearer + X-Confirm-Nuke]
tela[Tela /admin<br/>modal RESETAR + login Google]
rest --> svc[NukeReplicationService]
tela --> svc
svc --> pg[(Postgres: RESET_SLOT)]
svc --> mongo[(MongoDB PowerSync: DROP_MONGO)]
svc --> coolify[Coolify API<br/>restart opcional]
svc --> audit[(xls_nuke_replication<br/>audit por step)]
Audit em xls_nuke_replication (xls_*, local, não sincroniza — BIGSERIAL,
V8). Colunas-chave: id (PK), status, iniciado_em/concluido_em,
iniciado_por, motivo, step_atual, steps_completados (JSONB default []),
detalhes (JSONB), erro_mensagem. Garantias de schema:
- CHECK
chk_nuke_status:status ∈ {PENDENTE, EM_ANDAMENTO, SUCESSO, ERRO}. - Índice unique parcial
uq_xls_nuke_replication_em_andamentoWHERE status = 'EM_ANDAMENTO'(sobre a expressão constante(1)): no máximo um nuke em andamento — um 2º disparo recebe409. - O audit nunca grava a URI do Mongo — só o nome do DB (lido do path da URI).
3. Fluxos e estados¶
stateDiagram-v2
[*] --> EM_ANDAMENTO: criar (409 se já houver)
EM_ANDAMENTO --> SUCESSO: FINALIZAR
EM_ANDAMENTO --> ERRO: falha em qualquer step
SUCESSO --> [*]
Os 9 steps idempotentes (executor dedicado, single-thread):
flowchart TB
V[VALIDAR] --> L[LOCK] --> P[PAUSE] --> SP[SNAPSHOT_PRE]
SP --> RS[RESET_SLOT] --> DM[DROP_MONGO] --> R[RESUME]
R --> SA[SAMPLE_POST] --> F[FINALIZAR]
Por que o estado pode ficar parcial (recovery por step): RESET_SLOT roda
antes de DROP_MONGO. O drop do slot roda com autoCommit ligado (o Postgres
proíbe pg_drop_replication_slot em transação), então uma falha no meio deixa
estado parcial — daí o recovery ser passo a passo, não rollback transacional.
No FINALIZAR, grava o sinal ultimo_nuke (etapa 07):
INSERT INTO bi_configuracao (id, valor, tipo, sistema, atualizado_em)
VALUES ('ultimo_nuke', NOW()::TEXT, 'TIMESTAMP', TRUE, NOW())
ON CONFLICT (id) DO UPDATE
SET valor = EXCLUDED.valor, atualizado_em = NOW();
4. Contratos¶
4.1 Gatilhos¶
| Método/rota | Confirmação | Status |
|---|---|---|
POST /api/comercial/admin/nuke-replication |
header X-Confirm-Nuke = token (POWERSYNC_NUKE_CONFIRMATION); body {motivo} |
202 · 403 confirmação errada · 409 já em andamento · 503 desabilitado |
GET …/nuke-replication/{id} |
— | status + steps |
A tela /admin (spec 005, modal anti-acidente exigindo digitar RESETAR +
CSRF, protegida por login Google — etapa 06)
chama o mesmo NukeReplicationService com um clique. A tela não usa o
header de confirmação (o modal é a barreira).
4.2 Estratégia de restart¶
Escolhida pela presença do COOLIFY_API_TOKEN:
| Estratégia | pause/resume |
Quando |
|---|---|---|
none (default) |
no-op — operador para/sobe o PowerSync na mão | ambientes sem Coolify |
coolify |
resume() reinicia o recurso PowerSync via API REST do Coolify e faz polling do deployment |
produção OnPetro |
5. Configuração¶
| Env | Property | Default | Papel |
|---|---|---|---|
POWERSYNC_MONGODB_URI |
powersync.mongodb.uri |
vazio → stub (simula o drop) | conexão Mongo; o DB sai do path da URI |
POWERSYNC_NUKE_CONFIRMATION |
— | vazio → 503 no REST | habilita o endpoint REST |
COOLIFY_API_TOKEN |
— | vazio → restart manual | liga a estratégia coolify |
POWERSYNC_COOLIFY_NAME/_UUID |
— | — | alvo do restart |
POWERSYNC_SLOT |
powersync.slot |
auto-discover (via Mongo) | slot de replicação |
POWERSYNC_BOOTSTRAP_WAIT |
— | 60 |
espera no SAMPLE_POST |
Runbook completo: nuke-replication.
6. Decisões¶
| Nº | Decisão |
|---|---|
| 0014 | Sinal de reset via bi_configuracao |
| 0013 | GRANT ao powersync_role |
| 0003 | Login Firebase nas views (protege a tela) |
7. Riscos¶
- Nuke disparado por engano → modal anti-acidente (
RESETAR) + header de confirmação no REST + token configurável; audit por step permite recovery. - PowerSync não sobe
sync_rulesACTIVE (ex.: GRANT faltando, decisão 0013) → o nuke morre emSNAPSHOT_PRE; conferir privilégios antes. - Nuke em horário comercial → freeze de segundos + re-bootstrap simultâneo dos clientes; usar só em janela de baixo tráfego (ver runbook).
Etapa 06 — Autenticação Google nas views¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
O setup operacional (envs Firebase, cookies) vive no runbook
google-auth. Esta página é o desenho. As telas protegidas incluem o disparo do nuke (etapa 05) e a edição de configurações (etapa 07).
1. Contexto e escopo¶
As telas server-rendered (/processamentos/**, /admin/**) são operadas por
humanos da X-Adm e não podem ficar públicas. Diferente da API (consumida por
máquina, Bearer estático), elas precisam de login humano — resolvido com
Google/Firebase restrito ao domínio @xadm.com.br.
Dentro do escopo: as duas camadas independentes de auth; o gate das views; a sessão; o CSRF; o comportamento sem envs (dev/test vs prod).
Fora do escopo: a auth da API (/api/**, Bearer estático — decisão
0002); não mexer nela ao trabalhar
nas views.
2. Componentes¶
flowchart TB
user[Usuário X-Adm] -->|login Google| fb[Firebase]
fb -->|ID token RS256| login[LoginController]
login -->|cookie xadm_session<br/>JWT HS256| browser[Browser]
browser -->|cookie| fetcher[Autenticador de sessão]
fetcher -->|email @xadm.com.br| regra[Regra de segurança das views]
regra -->|autenticado| views[Views /processamentos, /admin]
regra -.->|sem cookie / inválido| nega[302 /login ou 503]
3. Fluxos principais¶
3.1 Duas camadas independentes¶
As duas camadas rodam dentro do micronaut-security — não há filtro paralelo. A
regra de segurança das views devolve "não é comigo" nos paths whitelisted, e é aí que a
auth da API segue mandando sozinha.
/api/**→ Bearer estático (@Secured("ROLE_API"),micronaut-security)./api/**é whitelisted na regra das views — o login Google não afeta a API.- Views → login Google + cookie de sessão JWT HS256 (
xadm_session, TTL 8h), restrito a@xadm.com.br. O servidor valida o ID token do Firebase contra o JWKS público do Google (vianimbus-jose-jwt) — não precisa dofirebase-admin-sdk.json.
3.2 Login (CSRF double-submit)¶
GET /login→ renderiza o Firebase JS SDK + grava cookie CSRF.POST /login/callback(JSON{credential, from, csrfToken}) → valida CSRF + Firebase ID token + domínio; emitexadm_session. Status:200 {redirect};400csrf/token;401token inválido;403domínio não permitido;503JWKS indisponível.POST /logout→ expiraxadm_session. Sessão é stateless (sem revogação server-side); o TTL de 8h limita a janela de um token vazado.- Os forms POST de view (
/admin/nuke-replication,/processamentos/{id}/excluire/reprocessar) carregam token CSRF (_csrf) derivado da sessão; ausente/ divergente →403(só com auth ativo).
3.3 Sem envs AUTH_* — bypass vs fail-closed¶
"Auth configurado" ≡ os 5 obrigatórios preenchidos (AUTH_FIREBASE_PROJECT_ID,
_API_KEY, _AUTH_DOMAIN, _APP_ID, AUTH_SESSION_SECRET) — o predicado tem uma
fonte única, consumida pela regra de segurança, pelo tratador de recusa e pelo model das
views. Comportamento:
| Situação | Comportamento |
|---|---|
Envs AUTH_* completas |
gate ativo (views exigem login) |
Ausentes e ambiente dev/test |
bypass (views públicas, WARN no log) |
| Ausentes e produção | fail-closed: views respondem 503, nunca abrem por engano |
4. Configuração¶
Envs AUTH_* → AuthSettings: AUTH_FIREBASE_PROJECT_ID (xadm-6ab81),
AUTH_FIREBASE_API_KEY, AUTH_FIREBASE_AUTH_DOMAIN, AUTH_FIREBASE_APP_ID
(os quatro são valores públicos, vão no HTML), AUTH_SESSION_SECRET (único
segredo, ≥32 chars, openssl rand -hex 32), AUTH_SESSION_TTL_SECONDS
(default 28800), AUTH_COOKIE_SECURE (default true), AUTH_XADM_EMAIL_DOMAIN
(default xadm.com.br). Reusa o projeto Firebase compartilhado xadm-6ab81.
Setup detalhado: google-auth.
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0002 | Bearer estático para /api/** |
| 0003 | Login Firebase Google nas views |
6. Riscos¶
- Deploy de produção sem as envs
AUTH_*→ fail-closed (503), não exposição. - Vazamento do
AUTH_SESSION_SECRET→ rotação; secrets no Coolify. - Domínio do deploy fora dos Authorized domains do Firebase → popup do Google falha; adicionar no console antes do deploy.
Etapa 07 — Configuração global sincronizada¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Fornece a tabela KV sincronizada que os clientes e o backend leem em runtime — e o canal do sinal de reset disparado pela etapa 05. A tela de edição é protegida pelo login Google da etapa 06.
1. Contexto e escopo¶
Algumas configurações precisam ser compartilhadas entre o backend e os clientes
móveis em runtime — e o sinal de reset do PowerSync precisa chegar aos celulares.
A solução é uma tabela KV (bi_configuracao) sincronizada via PowerSync,
consumida pelos dois lados.
Dentro do escopo: a tabela KV; o consumo no backend (cache + TTL); as telas
de administração; o sinal de reset (ultimo_nuke); a separação chaves de sistema
vs editáveis.
Fora do escopo: o serviço de reset em si → etapa 05.
2. Modelo de dados¶
bi_configuracao (V12, spec 007) é bi_* (sincroniza via PowerSync, stream
recent_data, priority 0, sem filtro; GRANT ao powersync_role garantido em
V13). A chave é o próprio id (TEXT) — não há surrogate. É a única exceção
à convenção UUID v7 das bi_* (decisão 0008):
PowerSync aceita PK single-column TEXT, e a KV fica mais legível com o id sendo
a própria chave semântica do que com UUID v7 surrogate + UNIQUE(chave).
erDiagram
bi_configuracao {
text id PK "a chave É o id semântico"
text valor "NOT NULL"
varchar tipo "CHECK: STRING|INT|BOOL|TIMESTAMP|JSON"
text descricao
boolean sistema "default FALSE; TRUE = read-only na UI"
timestamptz atualizado_em "default NOW()"
}
Seed (V12, ON CONFLICT (id) DO NOTHING):
id |
tipo |
sistema |
Quem escreve | Quem lê |
|---|---|---|---|---|
ultimo_nuke |
TIMESTAMP |
TRUE |
NukeReplicationService (auto, no FINALIZAR) |
Cliente Flutter |
slow_query_timeout_ms |
INT (10000) |
FALSE |
UI admin | Cliente Flutter |
Convenção de serialização: STRING/JSON cru, INT Integer.toString, BOOL
lowercase, TIMESTAMP ISO 8601. Renomear o id = delete + insert (é a chave
semântica, não surrogate).
3. Fluxos principais¶
- Backend (
service/ConfigService): lê com cache in-memory + TTL 30 s (getString/getInt/getBool/…). Janela máxima de staleness após edição pela UI = 30 s. - Cliente Flutter: lê via PowerSync sync (stream
recent_data). - Telas admin (
/admin/configuracoes, sob login Google — etapa 06): listam, editam e criam chaves. Rowssistema=TRUE(ex.:ultimo_nuke) aparecem read-only; POST manual via curl numa chave de sistema retorna 403. Chave nova pela UI nascesistema=FALSE; chave de sistema só nasce via migration. - Sinal de reset: o nuke (etapa 05) faz UPSERT em
ultimo_nuke = NOW()::TEXTnoFINALIZAR; o cliente Dart compara com o valor guardado emSharedPreferencese, quando muda, persiste o novo valor antes de chamardisconnectAndClear()e reconectar — evita rows zumbi no SQLite local.
4. Decisões¶
| Nº | Decisão |
|---|---|
| 0014 | bi_configuracao KV + sinal de nuke |
| 0008 | PK UUID v7 nas bi_* (KV é a exceção TEXT) |
| 0013 | GRANT ao powersync_role |
5. Riscos¶
- Edição de chave de sistema corromper o sinal → bloqueado na UI (read-only) e no POST (403).
- Staleness de até 30 s no backend após edição → aceitável por design (TTL do cache); documentado.
bi_configuracaosem GRANT aopowersync_role→ não sincroniza (42501); consolidado em V13 (decisão 0013).
Etapa 08 — Modernização: Java 25 + Micronaut 5 e RFC 7807¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11
Delta de modernização sobre a base Java 21 / Micronaut 4.7.6. Nenhum comportamento observável mudou (status HTTP dos contratos 409/404/503 intactos); o único ganho outward-facing é o corpo de erro do
/api/**virarapplication/problem+json.
1. Contexto¶
O projeto estava uma major atrás (Micronaut 4.7.6, Java 21) e o corpo de erro da
API saía no formato Hateoas default ({message, _links}). Esta etapa moderniza a
stack e padroniza o contrato de erro, em passos verificados contra a suíte
completa (incl. @Tag("docker") Testcontainers).
2. Stack: Java 25 + Gradle 9.5.1 + Micronaut 5.0.2¶
- Toolchain JDK 25 (
JavaLanguageVersion.of(25); GraalVM 25 via SDKMAN no WSL). O Gradle 9.5.1 roda em Java 25 — exigência do pluginio.micronaut.application5.0.0.Dockerfilepassa aeclipse-temurin:25;docs/app.jsondeclaratoolchain.java=25. - Micronaut 4.7.6 → 5.0.2. Breaking changes do salto e como foram resolvidos (decisão 0016):
micronaut-jackson-databind(reflexivo, removido do BOM do MN5) →micronaut-serde-jackson(serde build-time, Jackson 3tools.jackson.*). Exigiu@Serdeablenos DTOs serializados e migração deCoolifyClient+ConformidadeTestpara Jackson 3.ViewModelProcessorvirou 2-arg (GlobalViewModel).ConnectionOperationsganhoumanagesConnection.- Testcontainers precisa de versão explícita (o BOM parou de propagar).
- AWS SDK ≥2.30 liga flexible checksums que o Garage rejeita →
WHEN_REQUIREDnoS3ClientFactory(etapa 04).
Nota: o artefato de baseline que a §6 da constituição nomeia é o
docs/app.json(não existe ummatriz-baseline.jsonconcreto).
3. Contrato de erro da API — RFC 7807¶
Todo erro do /api/** passa a sair como application/problem+json, portado
do bi-transporte (decisão 0015):
flowchart LR
erro["Exceção no /api/**"] --> proc["xadm-comum-web<br/>UnifiedErrorResponseProcessor<br/>@Replaces Hateoas default"]
authx["Falha de auth"] --> ah["security/<br/>AuthExceptionHandler"]
proc --> pd["xadm-comum-web<br/>ProblemDetail<br/>@Serdeable"]
ah --> pd
pd --> body["application/problem+json"]
- O processor
@Replaceso Hateoas + oProblemDetail(@Serdeable) vêm da libbr.com.xadm:xadm-comum-web(spec 009 / ADR 0019; antes cópias locais emexception/+dto/);security/AuthExceptionHandleré local e importa oProblemDetailda lib. - Status HTTP preservados: os contratos 409/404/503 do
cliente da API seguem idênticos — só o corpo
mudou de
{message, _links}para problem+json. - Teste de contrato:
api/RfcProblemDetailTest.
Outward-facing: avisar consumidores da API que o corpo de erro mudou de
{message, _links}paraapplication/problem+json(os status não mudaram).
4. Package layout — mantido de propósito¶
api//service//repository/ (package-by-layer, em inglês) não foi migrado
para feature-slices: é alinhamento cross-project deliberado com o
bi-transporte (~42 classes rastreadas para extração para bi-commons; quatro fatias
já saíram — auth (xadm-seguranca, decisão 0018),
infra web (xadm-comum-web), object storage (xadm-comum-storage) e utilitários
(xadm-comum-util)); renomear divergiria os dois sem ganho funcional. O ArchitectureTest (ArchUnit)
já trava o pior (controller não fala com repository direto). Decisão consciente de
manter (REGRA Nº 3 — declarada, não silenciosa).
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0015 | Contrato de erro da API em RFC 7807 |
| 0016 | Bump major Java 25 + Micronaut 5 |
6. Riscos¶
- Mudanças de CI/Docker para Java 25 só se confirmam num run real de CI — validar
no primeiro PR antes do merge em
master. - Consumidores da API parseando o formato Hateoas antigo quebram no problem+json — comunicar a troca de contrato de corpo.
- Jackson 3 (
tools.jackson.*) coexistindo com Jackson 2 no uber-jar → resolvido removendo as classes duplicadas; conferir no shadowJar.
Decisões
0001 — Micronaut em vez de Spring Boot¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-04-01
Contexto¶
O processador de planilhas comerciais roda como serviço containerizado no
Coolify: recebe uploads de .xlsx, detecta o tipo, valida e persiste em
PostgreSQL. O framework de aplicação precisava ser escolhido antes de qualquer
outra decisão de stack.
Decisão¶
Usar Micronaut (à época 4.x, hoje 5.0.2 — ver 0016) em vez de Spring Boot. DI e AOP são resolvidos em build-time (sem reflexão em runtime), o que dá startup rápido e footprint de memória menor — perfil adequado a um serviço que sobe e desce em container.
Consequências¶
- Startup rápido e baixo consumo de RAM no alvo de deploy.
- DI/validação resolvidas em compilação → erros de wiring aparecem no build, não
em runtime; em troca, DTOs serializados exigem
@Serdeable(serde build-time). - Ecossistema alinhado ao irmão
bi-transporte-xls— a base compartilhada (~42 classes candidatas abi-commons) só existe porque os dois apps usam a mesma stack Micronaut.
Alternativas consideradas¶
- Spring Boot: ecossistema mais amplo, mas startup e memória maiores num alvo de container, e a reflexão em runtime não agrega aqui. Descartado.
- Quarkus: perfil parecido com o do Micronaut, mas a casa já padroniza Micronaut nos processadores BI; adotar outro framework divergiria sem ganho. Descartado.
0002 — Bearer estático para /api/**¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16 · Decidido em: 2026-04-01
Correção 2026-07-16: o endpoint de ingestão citado no Contexto mudou de nome e de formato — hoje é
POST /api/xls/processar, que recebe um.zipcom os.xlsxdo lote. É um fato incidental do contexto, não da decisão: o Bearer estático para/api/**segue valendo, e a nova rota está sob a mesma proteção (@Secured("ROLE_API")). Estado atual do contrato no livro.
Contexto¶
O endpoint de ingestão (POST /api/xls/processar) é consumido por um
cliente server-to-server único e confiável — a automação que envia as
planilhas comerciais. Não há usuário final humano nem múltiplos consumidores no
/api/**; a superfície é máquina-para-máquina.
Decisão¶
Proteger /api/** com Bearer token estático via micronaut-security
(@Secured("ROLE_API")). O token é injetado por env e comparado; não há emissão,
rotação automática nem claims. As views server-rendered têm um esquema
próprio e independente (login Google — ver 0003).
Consequências¶
- Integração simples: o cliente manda
Authorization: Bearer <token>e pronto. - Sem infraestrutura de emissão/refresh de JWT para um único consumidor confiável.
- Rotação é operacional (trocar a env e redeployar) — aceitável para o volume de consumidores (um).
- Não mexer neste esquema ao trabalhar em auth das views: são camadas independentes.
Alternativas consideradas¶
- JWT assinado com claims/expiração: dá rotação e escopo por claim, mas é excesso para um único cliente confiável server-to-server. Descartado por over-engineering.
- mTLS: mais forte, porém mais custo operacional de certificados para um ganho marginal neste cenário. Descartado.
0003 — Login Firebase (Google) nas views server-rendered¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-05-21
Contexto¶
As telas server-rendered (/processamentos/**, /admin/**) são operadas por
gente da X-Adm — incluindo o botão de nuke da replicação PowerSync, uma operação
destrutiva. Diferente do /api/** (Bearer, máquina — ver
0002), essas views precisam de identidade humana e
allowlist. Não faz sentido a equipe gerir mais um cadastro de senhas.
Decisão¶
Login Google via Firebase (projeto xadm-6ab81, compartilhado entre os apps
X-Adm), restrito a e-mails @xadm.com.br. Após o login, o app emite um cookie
de sessão xadm_session (JWT HS256, TTL 8h). O gate das views é aplicado no
servidor; os forms POST das views carregam token CSRF (_csrf). O allowlist por
domínio é aplicado no login (spec 006).
Correção 2026-07-16: o texto original dizia que o gate estava num
security/AuthFilter. Isso era como o gate foi implementado na época, não a decisão — e mudou: hoje o gate roda dentro domicronaut-security(autenticador de sessão + regra de segurança), sem filtro paralelo. A decisão (login Google/Firebase restrito ao domínio, com cookie de sessão) segue válida e inalterada; o desenho vivo está na etapa 06.
Consequências¶
- Zero senhas próprias: SSO Google reaproveitado do projeto Firebase da casa.
- Comportamento por ambiente: sem as envs
AUTH_*, emdev/testas views ficam públicas (bypass para desenvolvimento); em produção o acesso é fail-closed (503) — nunca abre por engano. - Allowlist é por domínio (
@xadm.com.br), não enumeração por e-mail; endurecer para lista explícita é ajuste pontual emAuthSettings, não reescrita.
Alternativas consideradas¶
- Basic Auth / usuário-senha local: mais um cadastro para manter e rotacionar. Descartado em favor do SSO Google.
- Bearer estático também nas views: inadequado para acesso humano de browser (sem sessão, sem CSRF, sem identidade). Descartado.
0004 — Logs Logback + Bugsink (protocolo Sentry)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11 · Decidido em: 2026-04-01
Contexto¶
O processamento é assíncrono (o cliente recebe 202 e o trabalho pesado corre em thread do pool). Falhas de parse/persistência acontecem fora do ciclo request/response, então precisam de destinos além do console: um agregador para alertas e correlação entre deploys, e uma trilha por-processamento que o suporte consulta na UI.
Decisão¶
Logback com CONSOLE (stdout) + appender Sentry (8.16.0) que envia os
eventos para o Bugsink (bug.xadm.biz), que fala o protocolo Sentry. O
log operacional do servidor vai para stdout (o Coolify captura) — norma da
casa (engenharia/java-micronaut): servidor não grava log-de-framework em arquivo;
só CLI/worker o faz. A trilha por-processamento (app.logs.dir, default
logs/execucoes/processamento-<id>.log, exibida na UI) é artefato de domínio,
não log de framework, e segue em arquivo. Falhas de processamento ficam com
status=ERRO em xls_processamento e viram evento no Bugsink.
Consequências¶
- Erro assíncrono não some: fica no stdout capturado, na trilha por-processamento,
na tabela (
status=ERRO) e no Bugsink. - Log do servidor centralizado pelo runtime (Coolify), sem arquivo de framework a rotacionar/mapear em volume; um destino a menos para dar errado.
- Correlação cross-deploy e alertas ficam no Bugsink, sem operar um Sentry próprio (Bugsink é self-hosted, compatível com o SDK Sentry).
- Alinhado ao irmão
bi-transporte-xls— mesma stack de erro entre os apps BI.
Alternativas consideradas¶
- Só log em arquivo: sem alerta e sem visão agregada entre instâncias/deploys. Descartado.
- Sentry SaaS: custo e dado saindo da infra da casa; o Bugsink self-hosted cobre o caso pelo mesmo protocolo. Descartado.
0005 — Object storage Garage com 3 modos¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11 · Decidido em: 2026-05-21
Contexto¶
O binário do .xlsx recebido era guardado em BYTEA (arquivo_bytes) no
Postgres, o que infla o banco a cada upload. Quer-se mover para object storage
S3-compatível (Garage) sem perder a rede de segurança durante a transição.
Decisão¶
Um seam storage/ArquivoXlsStorage (impl S3ArquivoXlsStorage, AWS SDK v2;
factory S3ClientFactory; coordenador service/ArmazenamentoArquivoService) com
3 modos (app.storage.modo / env S3_FILE_STORAGE, MAIÚSCULAS):
PSQL— só Postgres (arquivo_bytes).PSQL_GARAGE(default) — grava nos dois, lê do Garage. Rede de segurança.GARAGE— só Garage; oBYTEAficaNULL.
Chave content-addressed: {aaaa}/{mm}/{checksum_sha256}.xlsx. A partição usa
periodoInicial quando já disponível; senão, recebidoEm.toLocalDate() (no
receber() ainda não há período — só após o parse async). O StorageBackfillService
é opt-in (app.storage.backfill.enabled=true), roda no startup com
@Order(-100), é idempotente e isola falhas por linha.
Consequências¶
- Migração reversível: a coluna
arquivo_bytesnão é dropada — o "cleanup" do modoGARAGEé zerar os bytes em runtime, não um DROP. - Falha de I/O no upload →
StorageIndisponivelException→ HTTP 503 (sem linha persistida; o cliente reenvia). - Bucket de produção
onpetro, endpoint compartilhadoarquivo-api.xadm.biz.
Atualização (ADR 0019 — bi-commons): o seam + impl deixaram de ser cópia local.
ArquivoXlsStorage,S3ArquivoXlsStorage,S3ClientFactory,ArmazenamentoModo(+ os records de configS3Config/ArmazenamentoConfig) vêm da libbr.com.xadm:xadm-comum-storage:0.1.0(br.com.xadm.comum.armazenamento); os checksumsWHEN_REQUIREDpara o Garage e os prefixos de configs3.*/app.storage.*são idênticos. Local fica só o coordenadorservice/ArmazenamentoArquivoService(domínio). Utilitários correlatos (ChecksumSha256,DataConverter) idem, viaxadm-comum-util:0.1.0. A decisão dos 3 modos abaixo segue valendo.
Alternativas consideradas¶
- Só Postgres BYTEA: o banco infla com o volume de uploads. Descartado.
- Migrar direto para
GARAGEsem o modo dual: sem rede de segurança se o storage falhar durante a transição. Descartado em favor doPSQL_GARAGEdefault.
0006 — UNIQUE em checksum_sha256¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-04-01
Contexto¶
O mesmo arquivo pode ser reenviado — por retry do cliente, por duplicidade num
lote, ou por duas requisições concorrentes com o mesmo conteúdo. Reprocessar o
mesmo .xlsx é desperdício e, pior, duas threads processando o mesmo arquivo ao
mesmo tempo podem gerar corrida na escrita.
Decisão¶
Identificar cada arquivo pelo SHA-256 do conteúdo binário e impor UNIQUE
em checksum_sha256 na tabela xls_processamento. É a segunda linha de
defesa: a primeira é a checagem lógica antes de enfileirar; o índice único
garante que, mesmo sob corrida, só uma requisição vence e a outra colide no banco.
Consequências¶
- Reenvio do mesmo conteúdo é barato e seguro — a colisão no índice evita processamento duplicado, sem depender só de checagem em código.
- Junto com o upsert diff-aware (ver 0009), um arquivo cujo conteúdo já bate com o banco gera zero linhas efetivas e zero churn no PowerSync.
- A chave content-addressed do storage (ver 0005) reusa o mesmo checksum.
Alternativas consideradas¶
- Só checagem em código (SELECT antes de inserir): sujeito a race entre a checagem e o insert. O índice único fecha essa janela. Descartado como única defesa.
- Dedup por nome de arquivo: nome não identifica conteúdo (dois arquivos diferentes com o mesmo nome, ou o mesmo conteúdo com nomes diferentes). Descartado.
0007 — Micronaut Data JDBC sem Hibernate¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-04-01
Contexto¶
O processador persiste dimensões e fatos (bi_*) e metadados (xls_*) em
PostgreSQL. O pipeline depende de SQL fino: UPSERT diff-aware com
ON CONFLICT DO UPDATE WHERE IS DISTINCT FROM, DELETE seletivo por período e —
crucialmente — executeBatch() retornando rowCount per-row para alimentar a
métrica linhas_efetivas (spec 003). A camada de dados precisava dar esse
controle.
Decisão¶
Usar Micronaut Data JDBC — repositórios sobre entidades @MappedEntity —
sem Hibernate/JPA. O schema é versionado exclusivamente por Flyway
(classpath:db/migration, histórico bi_comercial_flyway_schema_history), nunca
por DDL automático de ORM.
Consequências¶
- Controle explícito do SQL — pré-requisito do
BulkUpsert(PreparedStatement +executeBatch) e das métricas per-row (ver 0009). - Sem cache de 1º nível, lazy-loading ou flush implícito surpreendendo o fluxo assíncrono.
- Migrações revisáveis em PR; sem divergência entre modelo Java e banco.
Alternativas consideradas¶
- Hibernate/JPA: abstrai o SQL demais; o SQL implícito atrapalharia o
controle per-row que a métrica
linhas_efetivasexige. Descartado. - JDBC puro / JdbcTemplate manual: daria o controle, mas os repositórios
@MappedEntityreduzem boilerplate sem perder o SQL cru onde importa. Descartado por reinventar o que o Data JDBC já entrega.
0008 — UUID v7 como PK nas tabelas bi_*¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-04-10
Contexto¶
As tabelas bi_* deste projeto (cliente, filial, vendedor, produto, movimento,
meta, posto, trr, custo_inventario) sincronizam com clientes móveis via
PowerSync. O PowerSync exige PK single-column TEXT/UUID — BIGSERIAL e PKs
compostas não replicam. Ao mesmo tempo, os fatos têm chave natural (NF+produto+
cliente+filial, código do ERP, CNPJ) que precisa continuar valendo para o upsert
idempotente.
Decisão¶
Toda tabela bi_* de domínio usa id UUID NOT NULL DEFAULT uuidv7() PRIMARY
KEY (migration V7). A chave natural vira UNIQUE (uq_<tabela>_<sufixo>);
FKs apontam para ela (Postgres aceita FK referenciando coluna UNIQUE). O upsert
faz ON CONFLICT (chave_natural) DO UPDATE, o que preserva o id (e o xmin)
das linhas inalteradas — estabilidade essencial para o PowerSync não gerar
checkpoints à toa.
Exceção intencional: bi_configuracao usa id TEXT como chave semântica
(KV) — PowerSync aceita TEXT single-column PK, e a KV fica mais legível assim
(ver 0014).
Consequências¶
- Sync PowerSync viável;
idestável cross-sync mesmo em reupload (upsert diff-aware não recria a linha). - Exige PostgreSQL 18+ (função
uuidv7()nativa) — restrição já registrada na stack. - Diverge do irmão
bi-transporte-xls, que usaBIGSERIAL(não sincroniza via PowerSync). Ambas as formas são aceitas pela regra cross-project; a escolha aqui é ditada pelo sync ativo.
Alternativas consideradas¶
BIGSERIAL(como no Transporte): mais simples, mas não replica no PowerSync (PK precisa ser TEXT/UUID single-column). Descartado por bloquear o requisito de sync.gen_random_uuid()(v4): funciona no PowerSync, mas UUID v7 é time-ordered — melhor localidade de índice e ordenação natural por criação. Descartado em favor do v7.
0009 — reWriteBatchedInserts=false¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-05-04
Contexto¶
O pipeline diff-aware (spec 003) mede quantas linhas o Postgres realmente
escreveu por processamento — a métrica linhas_efetivas. O BulkUpsert usa
PreparedStatement.executeBatch() com ON CONFLICT DO UPDATE WHERE IS DISTINCT
FROM, e soma o int[] retornado (ignorando SUCCESS_NO_INFO) para obter esse
número. A diferença enviadas - efetivas é o "churn evitado" — linhas que não
mudaram e portanto não geraram evento de WAL nem checkpoint no PowerSync.
Decisão¶
Manter reWriteBatchedInserts=false na URL do datasource (env Coolify). Com a
flag ligada, o pgjdbc reescreve N inserts individuais num único multi-VALUES e o
executeBatch() passa a devolver SUCCESS_NO_INFO (-2) por posição — o que
zera a granularidade per-row e mata a métrica linhas_efetivas.
Consequências¶
executeBatch()devolve o rowCount por linha →linhas_efetivas/linhas_removidascorretas emxls_processamento(observabilidade do churn).- Abre-se mão da otimização de rewrite em multi-VALUES; o ganho de throughput não compensa perder a métrica que sustenta o diff-aware.
- Divergência técnica legítima vs. o irmão Transporte: lá o bulk é COPY +
TEMP TABLE, então esta flag não existe. Aqui
executeBatch()é necessário justamente para o rowCount per-row — mesma filosofia diff-aware, técnica diferente por motivo real.
Alternativas consideradas¶
reWriteBatchedInserts=true(throughput): perde o per-row count e, com ele, a métrica. Descartado.- COPY + TEMP TABLE (como no Transporte): rápido, mas não devolve rowCount
per-row — inviabiliza
linhas_efetivasno modelo agregado do Onpetro. Descartado aqui.
0010 — Detecção automática do tipo de relatório¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16 · Decidido em: 2026-04-10
Correção 2026-09-08: eram 7 tipos quando esta decisão foi tomada; hoje são 9 — COMPRAS entrou com a decisão 0017 e COTA_PETROBRAS com a 0019. O corpo abaixo fica como registro do que se decidiu em 2026-04-10; a contagem saiu do título justamente por envelhecer. Lista viva: o enum
processing/TipoArquivo.Correção 2026-07-16: a rota citada no Contexto mudou — hoje o upload é
POST /api/xls/processar, um.zipcom os.xlsxdo lote. Fato incidental: a decisão (detectar o tipo em vez de pedir ao cliente) segue valendo e ficou ainda mais central, já que o servidor detecta o tipo de cada entrada do zip. O período continua vindo do conteúdo. Estado atual no livro.
Contexto¶
Ao longo do mês o cliente comercial envia 7 tipos de relatório diferentes:
RELATORIO18, MARGEM_CONSOLIDADA, MARGEM_DIA, META, META_FERNANDO, POSTOS e TRR.
Forçar o cliente a informar o tipo (na URL, num header, num campo) cria uma
classe de bugs evitável — tipo errado significa linhas gravadas na tabela errada.
O upload (POST /api/xls/processar) também não exige período: ele vem
do conteúdo.
Decisão¶
Um processing/ArquivoDetector (@Singleton) recebe os bytes do .xlsx + o
nome e devolve um TipoArquivo (enum), de forma determinística e ordenada por
prioridade (primeira condição satisfeita ganha): META_FERNANDO → POSTOS → TRR
por coluna/sheet do cabeçalho; RELATORIO18 → MARGEM_CONSOLIDADA → MARGEM_DIA →
META por padrão de nome. Cada tipo tem um {Tipo}Processor dedicado em
processing/, todos lendo via ExcelSheetReader (SAX) e persistindo via
BulkUpsert diff-aware.
- Nada bateu (tipo desconhecido) →
BadRequestException→ HTTP 400 na recepção; não entra emxls_processamento. (O statusIGNORADOé outra coisa: tipo detectado, mas período ≤ a data mínima 10/08/2025 — ver etapa 01.) - Nome iniciando com
~$(temporário do Excel) ou#(revisão interna) →BadRequestException(HTTP 400), sem entrar emxls_processamento.
Consequências¶
- UX do cliente: envia qualquer arquivo, o app deduz o tipo — menos erros de integração.
- Estender é ritual conhecido: novo valor no enum + regra no
detectar()(com teste emArquivoDetectorTest) +{Tipo}Processor+ registro noProcessamentoTransactionalHelper. - Diverge do irmão Transporte, que lê 3 abas fixas (10/20/30) — domínios diferentes; lá o formato é estável, aqui são 7 relatórios distintos.
Alternativas consideradas¶
- Parâmetro
tipoArquivona URL/header: joga a responsabilidade no cliente e abre a porta para gravar na tabela errada. Descartado. - 3 abas fixas (modelo Transporte): não modela 7 relatórios com layouts distintos. Descartado por não caber no domínio comercial.
0011 — Coordenação de lotes e notificação agregada¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16 · Decidido em: 2026-04-10
Correção 2026-07-16: o mecanismo decidido aqui continua em uso — o
ConjuntoLoteServiceainda registra o progresso emxls_conjunto_lotee dispara uma única notificação Telegram agregada ao fim do lote. O que mudou é a origem doconjunto_ide do total: eles deixaram de ser campos opcionais declarados pelo cliente no POST e passaram a ser derivados do conteúdo pelo servidor. Hoje a ingestão é uma porta únicaPOST /api/xls/processarque recebe um.zip(o zip é o lote): o id ézip-<sha256 do zip>e o total é o número de entradas.xlsx. Com isso o lote virou a regra, não a exceção — não existe mais o caso "upload avulso sem os campos" descrito nas Consequências. Estado atual no livro.
Contexto¶
O cliente comercial não envia um arquivo por vez: manda um pacote de N planilhas (os vários relatórios do período). Cada upload é independente e assíncrono, então, sem coordenação, a equipe receberia N notificações Telegram soltas — ruído — e não teria um marco claro de "o lote inteiro terminou".
Decisão¶
O POST /api/xls/processar aceita dois campos opcionais: conjunto_id
(identificador do lote) e conjunto_total (quantos arquivos o lote tem). O
service/ConjuntoLoteService registra o progresso na tabela xls_conjunto_lote
e dispara uma notificação Telegram agregada quando o N-ésimo arquivo do
conjunto chega. Sem esses campos, cada arquivo é processado normalmente, de forma
independente.
Consequências¶
- Uma notificação por lote em vez de N — sinal limpo de "terminou" para a equipe.
- Os campos são opcionais: uploads avulsos continuam funcionando sem mudança.
- A tabela
xls_conjunto_loteé metadado do processador (xls_*): não sincroniza via PowerSync; o cliente não a edita. - Recurso exclusivo do Onpetro — o irmão Transporte não recebe pacotes de N arquivos, então não tem coordenação de lote.
Alternativas consideradas¶
- Uma notificação por arquivo: ruído proporcional ao tamanho do lote e sem marco de conclusão. Descartado.
- Agregar por janela de tempo (debounce): frágil — depende de timing de
chegada, não do total real do lote; um arquivo atrasado quebraria a contagem.
O
conjunto_totalexplícito é determinístico. Descartado.
0012 — Exceções em pacote top-level¶
Decisão obsoleta — superada pela decisão 0030
Status: Obsoleto · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-04-01
Contexto¶
As exceções de domínio (BadRequestException, ConflictException,
NotFoundException, StorageIndisponivelException) são lançadas de qualquer
camada: o ArquivoDetector rejeita nome temporário com BadRequestException, o
storage sinaliza I/O com StorageIndisponivelException, os repositórios colidem
com ConflictException. Se elas morassem dentro de um pacote de feature
(processing/, storage/, api/), qualquer camada que as lançasse passaria a
depender daquele pacote — criando ciclos arquiteturais.
Decisão¶
Manter as exceções de domínio num pacote top-level exception/, sem depender
de nenhuma feature. Assim qualquer camada as lança sem introduzir dependência
cruzada. O ArchitectureTest (ArchUnit) valida a ausência de ciclos entre os
pacotes top-level.
Consequências¶
- Cross-cutting sem ciclos:
processing,storage,api,servicelançam as mesmas exceções sem se acoplarem entre si. - As classes eram idênticas às do irmão
bi-transporte-xls— candidatas diretas aobi-commons. - Mapeamento para HTTP fica centralizado (o handler RFC 7807 traduz cada uma — ver 0015).
Atualização (spec 009 / ADR 0019):
BadRequestException,ConflictExceptioneNotFoundExceptionforam extraídas para a libbr.com.xadm:xadm-comum-web(o "bi-commons" desta fatia) — seguem top-level (br.com.xadm.comum.web), lançáveis de qualquer camada, mesma propriedade anti-ciclo. Ficam locais as de domínio:StorageIndisponivelException(503, XLS) eForbiddenException(403,bi_configuracao). A decisão desta ADR (top-level, não por feature) segue valendo para as duas locais.
Alternativas consideradas¶
- Exceções dentro de cada feature: acopla quem lança ao pacote da feature e gera ciclos (barrados pelo ArchUnit). Descartado.
- Só exceções padrão do Java/HTTP: perde a semântica de domínio (409 vs 400 vs 503) e o mapeamento uniforme. Descartado.
Correção 2026-09-14: superada pela 0030 (package-by-feature): a exceção local de storage mora em
comum.exception, a base transversal que não depende de fatia.
0013 — GRANT SELECT ao powersync_role¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-05-26
Contexto¶
O powersync_role (user que o PowerSync usa para replicar) lê as tabelas com
SELECT. Mas tabelas criadas pelo user das migrations (user_onpetro) nascem
sem GRANT para ele — Postgres não propaga GRANT automático entre roles.
Sintoma se esquecer: a replicação para com permission denied for table (42501),
o cliente Flutter trava no sync e o nuke morre em SNAPSHOT_PRE (o PowerSync
não consegue subir um sync_rules ACTIVE no Mongo).
Decisão¶
A migration V13 aplicou ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT
SELECT ON TABLES TO powersync_role rodando como user_onpetro — assim toda
tabela futura em public herda o GRANT. Como cinto de segurança, cada
migration que cria uma tabela bi_* nova adiciona, logo após o CREATE TABLE, um
GRANT SELECT … TO powersync_role explícito guardado por um bloco DO que checa
pg_roles (no-op se o role não existir ou se o default já cobriu).
Consequências¶
- Tabelas
bi_*novas herdam oSELECTsem intervenção manual — mas "confia desconfiando": conferir\dp <tabela>(deve mostrarpowersync_role=r) antes de redeployar o PowerSync com a tabela nova nosync_rules.yaml. - Fix manual em prod se aparecer 42501 mesmo assim:
GRANT SELECT ON <tabela> TO powersync_role+ ajustar a migration retroativa. - Tabelas
xls_*não precisam (não sincronizam), mas ganham o GRANT sem custo — a publicaçãopowersyncno PG filtra o que entra no WAL, não o GRANT.
Alternativas consideradas¶
- GRANT manual a cada tabela: fácil esquecer → 42501 em produção e nuke travado. O default privileges + cinto reduz o risco (o cinto vira a defesa). Parcialmente descartado.
- Rodar migrations como superuser: quebraria o isolamento de privilégios do banco. Descartado.
0014 — bi_configuracao (KV) e sinal de nuke¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-05-26
Contexto¶
O nuke da replicação PowerSync recria o slot do zero; o cliente Flutter precisa saber que aconteceu para descartar seu estado local e ressincronizar. Além disso, há configs globais (ex.: timeout de slow query) que devem chegar ao cliente sem outro canal. Faltava um caminho de config sincronizado backend → cliente.
Decisão¶
Uma tabela KV bi_configuracao (migration V12, spec 007), sincronizada via
PowerSync no stream recent_data (priority 0, sem filtro). PK é id TEXT — a
própria chave semântica (ex.: ultimo_nuke, slow_query_timeout_ms) — exceção
consciente à regra UUID v7 das bi_* (ver 0008):
PowerSync aceita TEXT single-column PK e a KV fica mais legível assim que com um
surrogate UUID + UNIQUE(chave). Backend lê via service/ConfigService (cache
TTL 30s); UI em /admin/configuracoes.
Cada linha tem uma flag sistema: sistema=TRUE (ex.: ultimo_nuke, escrito
pelo NukeReplicationService) só é editável por código backend — UI/HTTP retornam
403. Chaves criadas pela tela nascem sistema=FALSE; só migration marca TRUE.
Serialização por tipo: STRING/JSON cru, INT Integer.toString, BOOL lowercase,
TIMESTAMP ISO 8601.
Consequências¶
- O
ultimo_nuke(TIMESTAMP) replica para o cliente Flutter, que detecta o reset e ressincroniza — fecha o loop do nuke sem canal paralelo. - Configs globais (
slow_query_timeout_ms, INT) chegam ao cliente pelo mesmo mecanismo, editáveis pela UI admin. - A flag
sistemaprotege chaves de máquina de edição acidental pela tela.
Alternativas consideradas¶
- UUID v7 +
UNIQUE(chave)(como as outrasbi_*): consistente com a regra, mas a KV fica menos legível — oidseria surrogate sem uso. Descartado para esta tabela específica. - Canal separado para o sinal de nuke (push/endpoint): mais infra para algo que o PowerSync já entrega replicando uma linha. Descartado.
0015 — Erro da API em RFC 7807¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Contexto¶
O corpo de erro do /api/** saía no shape default do Micronaut (Hateoas,
{message, _links}). O padrão da casa para erro de API é RFC 7807 / 9457
(application/problem+json) — padrão IETF, já adotado no irmão bi-transporte-xls
(decisão viva 0021 daquele projeto). Este app era o outlier; alinhar reduz custom
e faz futuros consumidores herdarem o mesmo formato.
Decisão¶
Erro de API no formato RFC 7807. Um ErrorResponseProcessor que @Replaces o
Hateoas default emite o shape 7807 + Content-Type: application/problem+json, com o
record ProblemDetail (@Serdeable) e security/AuthExceptionHandler para os erros
de autenticação. Todo erro do /api/** sai como problem+json; os status HTTP são
preservados (contratos 409/404/503 intactos). Teste de contrato em api/RfcProblemDetailTest.
Atualização (spec 009 / ADR 0019): o processor e o
ProblemDetailnão são mais cópias locais — vêm da libbr.com.xadm:xadm-comum-web:0.1.0, junto com/health,/info(VersaoInfo) e oSentryInitializer. Comportamento HTTP idêntico; muda só a procedência do código (importbr.com.xadm.comum.web).
Consequências¶
- Contrato de erro padrão, interoperável, alinhado à casa e ao app irmão.
- Outward-facing: o corpo de erro do
/api/**mudou de{message, _links}para problem+json — consumidores que liammessagepassam a lerdetail. Avisar quem integra. - Menos custom no longo prazo; sem dependência nova (reusa o processor que já substituía o Hateoas).
Alternativas consideradas¶
- Manter o default Hateoas (
{message, _links}): divergente do padrão da casa, sem vantagem real. Descartado. - Módulo
micronaut-problem-json(Zalando): dá os tipos/exceções 7807 prontos, mas adiciona dependência + modelo de exceção próprio; para middleware interno, o shape 7807 via o processor existente é mais leve. Descartado por ora.
0016 — Bump major: Java 25 + Micronaut 5¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Contexto¶
O projeto estava em Java 21 / Micronaut 4.7.6 — uma major atrás. O salto para
Micronaut 5 exige Java 25 baseline (o plugin io.micronaut.application 5 exige)
e traz breaking changes que não são óbvios no diff. Registrar aqui evita que o
próximo bump redescubra cada um na marra.
Decisão¶
Subir para Java 25 + Gradle 9.5.1 + Micronaut 5.0.2 (plugin 5.0.0).
docs/app.json declara toolchain.java=25; o Dockerfile usa eclipse-temurin:25.
No WSL o JDK é gerenciado via SDKMAN (GraalVM 25). Efeitos colaterais resolvidos no
mesmo trabalho:
micronaut-jackson-databind(reflexivo, removido do BOM) →micronaut-serde-jackson(serde build-time, Jackson 3tools.jackson.*) — exigiu@Serdeablenos DTOs e migração deCoolifyClient+ConformidadeTestpara Jackson 3.ViewModelProcessorvirou 2-arg (GlobalViewModel).ConnectionOperationsganhoumanagesConnection.- Testcontainers precisa de versão explícita (o BOM parou de propagar).
- AWS SDK ≥2.30 liga flexible checksums (CRC32) que o Garage rejeita →
RequestChecksumCalculation.WHEN_REQUIREDnoS3ClientFactory.
Atenção operacional: o Gradle 9.5.1 exige Java ≤25 e não roda em JDK futuro não
suportado — se JAVA_HOME apontar para JVM que ele não reconhece, o build falha
com IllegalArgumentException: <versão>.
Consequências¶
- Stack atual, CVEs corrigidos, baseline alinhado ao framework e ao irmão
bi-transporte-xls. - Exige PostgreSQL 18+ e Flyway 12+ (já pré-requisitos da stack).
- CI/Docker (
temurin:25) validado no deploy; contrato do export canônico preservado (gate:ConformidadeTest).
Alternativas consideradas¶
- Ficar no último 4.x (Java 21): pega CVEs sem exigir Java 25, mas adia o inevitável e não destrava os primitivos do MN5. Foi o passo intermediário; seguimos para o 5 por ser a base alvo da casa.
0017 — Compras sem chave natural: surrogate PK + replace-por-período¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-29 · Decidido em: 2026-07-29
Contexto¶
O processador de Compras (spec 008,
migração V16) grava o fato bi_compra (item de NF de compra combustível). Todo o
resto do repo usa o padrão diff-aware: INSERT … ON CONFLICT (chave_natural) DO
UPDATE … WHERE IS DISTINCT FROM + DELETE seletivo, que preserva id/xmin em linhas
inalteradas e evita checkpoints PowerSync desnecessários (spec 003).
Esse padrão exige uma chave natural estável. Em bi_compra a candidata óbvia
(cnpj, numero_nota, produto) colide em dados reais: no arquivo real, as NFs
802962/1, 803823/1 e 806243/1 aparecem 2× cada (mesmo fornecedor, mesma nota,
mesmo produto, quantidades/valores diferentes — item de nota fracionado). O arquivo do
ERP não traz discriminador de linha. Sem chave natural única não há ON CONFLICT.
Decisão¶
bi_compra usa PK surrogate uuidv7() e nenhuma UNIQUE de linha. A
idempotência/correção fica no replace-por-período: numa transação,
DELETE FROM bi_compra WHERE data_nf BETWEEN <min> AND <max> (intervalo de Data NF do
arquivo) seguido de INSERT de todas as linhas combustível (CompraRepository). O
upsert de bi_fornecedor (chave natural cnpj limpa, mantém diff-aware) roda antes,
para satisfazer a FK cnpj_fornecedor → bi_fornecedor(cnpj).
O skip do reenvio idêntico continua vindo do checksum SHA-256 do xls_processamento
(mecanismo type-agnostic já existente), então o DELETE+INSERT — que recicla id do
período — só dispara em arquivo corrigido, não no reenvio igual.
Consequências¶
- Diverge conscientemente do diff-aware do resto do repo; é a exceção documentada
(ver a cobertura de DELETE no cap. 4 do livro).
bi_fornecedornão diverge. - Premissa: um arquivo é o conjunto completo do seu período. Se um mês vier partido
em 2 arquivos, o 2º apaga o 1º (sem chave natural não dá
DELETE … AND NOT EXISTSpor linha). A confirmar com quem exporta o ERP; a v1 assume completo. - Correção de período recicla
idUUID das linhas daquele período → um checkpoint PowerSync no período. Aceito: só ocorre em arquivo corrigido (o checksum barra o reenvio idêntico), e não há discriminador de linha para fazer melhor. - Contrato com o cliente Flutter (
bi-comercial, painel 015) e sync rules (../powersync/) já assumem esse schema — colunas alinhadas.
Alternativas consideradas¶
- Chave natural
(cnpj, numero_nota, produto)+ diff-aware: descartada — colide em dados reais (3 casos no arquivo de amostra), quebraria oON CONFLICT. - Adicionar um índice de linha sintético (ex. ordinal na NF) à chave: o arquivo não fornece ordinal estável entre reuploads; um contador de parse não é determinístico entre exportações. Descartado — daria falsa estabilidade.
- Hash da linha inteira como chave: duas linhas idênticas de propósito (item fracionado com mesmos valores) colidiriam de novo; e qualquer mudança de valor criaria linha nova (churn). Descartado.
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.
0018 — Adotar a lib xadm-seguranca (motor de auth) por versão publicada¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-04 · Decidido em: 2026-08-04
Contexto¶
A stack de auth das views (login Firebase → cookie de sessão HS256 → gate micronaut-security
→ CSRF double-submit; spec 006, decisão 0003) nasceu como cópia
byte-a-byte entre onpetro e thoms/vantroba — 13 classes em security/. A casa extraiu o motor
para a lib br.com.xadm:xadm-seguranca (ADR 0019 do repo xadm-commons/raiz): 9 classes no
pacote br.com.xadm.comum.seguranca, publicada em 0.2.0 no Forgejo Packages.
Enquanto o onpetro mantinha as cópias, o validador do IdP Firebase compartilhado (xadm-6ab81)
era 1-de-N cópias — um drift no check de domínio/audience de um app viraria exposição cross-client.
Decisão¶
bi-comercial-xls depende de xadm-seguranca:0.2.0 por versão publicada (não includeBuild /
fonte local), deleta as 9 cópias do motor e mantém local só o que é policy/coisa de app:
- Whitelist de rota fica local (
security/ViewWhitelist) — é policy per-app: o onpetro tem/compras/(import aberto), que os irmãos não têm. O motor traz só o contrato (AuthSupport:AUTH_*,deveRecusarAnonimo,isHttps). issuerconfig per-app (auth.issuer, envAUTH_ISSUER, defaultbi-comercial-xls) — a lib lê deAuthSettings.getIssuer(); antes era hardcoded. Mantido idêntico ao valor anterior → zero invalidação das sessões de 8h em voo.- Handlers acoplados ao
ProblemDetail(AuthExceptionHandler,ViewRejectionHandler) e o view-model (GlobalViewModel) ficam locais — dependem dedto.ProblemDetail(RFC 7807, decisão 0015); re-extrair só após oxadm-comum-web(fase futura). - Testes-espelho do motor deletados (
SessionTokenServiceTest,FirebaseIdTokenValidatorTest, os predicados deAuthSupportTest) — a fonte de verdade deles passou pra lib (testada noxadm-commons); e acessavam membros package-private que não sobrevivem à troca de pacote. Fica o que é do app:ViewWhitelistTest(novo) +ViewSecurityRuleTest+ o e2e de paridadeAuthIntegrationTest.
Consequências¶
security/passou de 13 classes para 5 locais (ViewSecurityRule,ViewWhitelist,ViewRejectionHandler,AuthExceptionHandler,GlobalViewModel); o resto vem da lib.- Validador Firebase agora é fonte única (audience/domínio seguem config per-app) — some o risco de drift cross-client.
- Gate fiel ao artefato: o build compila contra o jar que a produção embarca (ADR 0019).
- Paridade provada por e2e contra o artefato publicado (
./gradlew check -PdockerTestsverde): login RS256 → cookiexadm_sessionHS256iss=bi-comercial-xls→ rota protegida → CSRF (403 em mismatch), sem regressão de status/redirect. - Leitura anônima: a org
xadmno Forgejo é pública →repositories { maven { url = ".../api/packages/xadm/maven" } }sem credencial. Se virar privada, adicionarcredentials(tokenread:package). - Fica pra depois (não é débito): re-extrair os handlers
ProblemDetailquando sair oxadm-comum-web; bump da lib pra1.0.0após validar a API em produção (decisão da casa noxadm-commons).
Alternativas consideradas¶
includeBuild/ fonte local da lib: descartada — o gate compilaria contra fonte, não contra o artefato que a produção embarca; ADR 0019 exige fidelidade ao publicado.- Unificar a whitelist na lib: descartada — whitelist é policy de rota per-app (
/compras/só existe no onpetro); subir pra lib acoplaria os apps sem ganho. - Migrar os testes-espelho (só trocar import): impossível — usam construtor 2-arg /
init()package-private, que não são visíveis de outro pacote; e reimplementariam o que a lib já testa.
0019 — Cota Petrobrás: full-replace, allow-list de produto e alias de endpoint¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-18 · Decidido em: 2026-08-18
Contexto¶
Novo tipo de arquivo (spec 011, migração
V19): a planilha "Cota Petrobrás mês.xlsx" — cota mensal de volume (m³) concedida pela
Petrobras por polo de distribuição e produto. Shape verificado no arquivo real (2022–2026):
uma aba por ano (^\d{4}$) + aba Planilha1 (resumo derivado, ignorar); cabeçalho
POLO | PRODUTO | JANEIRO..DEZEMBRO; célula POLO mesclada (só na 1ª linha do bloco);
coluna de ruído JANEIRO.2024 (bleed do mês seguinte, colide com o Janeiro da aba 2024) e
TOTAL PRODUTO/ANO; linha TOTAL MÊS; célula "_" (sem cota) e vazia (mês futuro); e uma
side-table de anotação solta (na aba 2023: Gas/S10 com número na coluna de mês, produto
em branco). A planilha entra pela porta genérica (POST /api/xls/processar) e pela página manual
do botão "Importar XLS" do bi-comercial.
Decisão¶
-
bi_cota_petrobras: PK surrogateuuidv7(), sem chave natural de linha. A planilha é a matriz inteira;(competencia, polo, produto)poderia repetir sob correções. -
Idempotência/correção = full-replace (não replace-por-período como Compras/0017): numa transação,
deletarTudo()(DELETE FROM bi_cota_petrobras) +inserirBatch()da matriz inteira. Guard de vazio: arquivo detectado como cota mas sem nenhum produto canônico com número não roda o DELETE — nunca há caso legítimo de esvaziar a matriz via upload. -
Guard de emissão = allow-list de produto canônico (
Diesel S10/Diesel S500/Gasolina/Diesel R5, comparado pornorm= NFD + strip acento + lowercase + colapso de espaços; grava a forma canônica). É o único guard — mata de uma vez a linhaTOTAL MÊS(produto vazio), as separadoras e a side-table de anotação. Só os 12 meses canônicos são mapeados por header normalizado;JANEIRO.2024/TOTAL PRODUTO/ANO/ruído são descartados. -
Detecção por conteúdo, prioridade logo após COMPRAS: existe aba
^\d{4}$e cujo cabeçalho começa com POLO + PRODUTO + JANEIRO. Não usa o header da 1ª aba física (éPlanilha1). -
Endpoint manual: alias
/xls/importar(canônico) +/compras/importar(herdado, vivo). A página aberta aceita a allow-list{COMPRAS, COTA_PETROBRAS}e no máximo uma planilha de cota por lote. A rota herdada continua servida porque o botão do bi-comercial já está deployado nela (segredoBI_COMERCIAL_XLS_IMPORT_TOKEN).
Consequências¶
- Diverge do diff-aware e também do replace-por-período (0017): é full-replace, a 2ª exceção documentada na cobertura de DELETE do cap. 4 do livro.
- Premissa: ≤1 planilha de cota por lote. O
DELETE FROMfull-table não é seguro sob concorrência (o executor roda até 4 arquivos em paralelo; oORDEM_INSERCAOanti-deadlock só cobre row-locks deON CONFLICT, não um DELETE de tabela inteira). Mitigado pela guarda ≤1 cota/batch na página de import. UmLOCK TABLEfoi considerado e descartado (over-engineering para um risco operacional ~zero). - Produto novo/renomeado some em silêncio? Não: linha que parecia dado (polo + valor de mês)
com produto não-vazio e não-canônico vira
LOG.warn(sinal pro operador), não descarte mudo. - Correção reprocessa a matriz inteira → um checkpoint PowerSync no reenvio corrigido (o checksum
do
xls_processamentobarra o reenvio idêntico). - Contrato com o cliente Flutter (painel de cotas) e sync rules (
../powersync/) assumem esse schema — passo externo, fora deste repo.
Alternativas consideradas¶
- Replace-por-período (como 0017): descartada para a v1 — a planilha é a matriz completa (todos os anos), full-replace é mais simples. Fica como caminho futuro se a cota passar a ser enviada partida por ano (Open Question OQ2 da spec 011).
- Guard "pular polo que começa com TOTAL" (proposto no prompt): frágil — não pega a
side-table de anotação da aba 2023 (polo
Gas/S10, produto vazio, com número). A allow-list de produto canônico é mais robusta e cobre os três descartes de uma vez. LOCK TABLE bi_cota_petrobras IN EXCLUSIVE MODEno full-replace: descartada — fecharia o buraco de dois lotes simultâneos, mas a probabilidade operacional é ~zero e a premissa ≤1 cota/lote já basta. Simplicidade primeiro.- Renomear a rota
/compras/importar→/xls/importar(hard): descartada — quebraria o botão do bi-comercial já deployado. Alias mantém as duas vivas; o bi-comercial migra depois e aí a herdada some. Rename da classeComprasImportControllerfica deferido (cosmético).
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.Correção 2026-09-14: o rename da classe saiu junto com o package-by-feature (0030):
ComprasImportControllerpassou aimportacao.ImportacaoXlsController.
0020 — Leitura XLSX: Apache POI → FastExcel¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-19 · Decidido em: 2026-08-19
Contexto¶
O alvo é deployar a aplicação como binário GraalVM native-image (RSS ~3× menor, imagem ~9×
menor, build fora do host de produção — spec 012).
O bloqueio era a leitura dos XLSX: ExcelSheetReader (motor SAX sobre XSSFReader/OPCPackage
do Apache POI 5.3.0) + SheetSaxHandler liam via XMLBeans, cujo type-system por reflexão
não compila em native-image (ClassCastException no StylesTable; sem fix no GraalVM CE).
Enquanto POI estivesse alcançável no grafo de runtime, o native não fechava.
O projeto irmão bi-transporte-xls já havia trilhado o caminho (ADR central
0023 — Ler XLSX com FastExcel).
Decisão¶
-
Reader por
org.dhatim:fastexcel-reader:0.20.2(StAX puro, zero XMLBeans).ExcelSheetReaderfoi reescrito preservando as 4 assinaturas públicas (readSheet,readDefaultSheet,readFirstRowOfSheet,listSheetNames) e a semântica de célula byte-a-byte: string →trim(blank→null); boolean →"true"/"false"; erro/vazio → null; numérico com formato de data →ddMMyyyy; numérico comum →getRawValue()(o texto<v>cru — nãoasNumber(), que introduziria.0/notação). A detecção de data POI-free (isDateFormat) reimplementa a lógica doDateUtil.isADateFormat(IDs built-in 14–22/45–47 + custom com tokeny/d/h/s). -
POI removido do projeto inteiro (nem
implementation, nemtestImplementation). As fixtures de teste passaram a ser escritas porXlsxFixtureWriter(FastExcel-writer,org.dhatim:fastexcel). Removido junto o bridgelog4j-to-slf4j, que só existia porque o POI arrastavalog4j-api. -
Paridade garantida por caracterização do reader (
ReaderCaracterizacaoTest). O oráculo é o comportamento POI atual — não o antigo oráculo Python (exportar-json.py, projeto de Automação externo que não existe mais). Para cada um dos 9 tipos, a saídaString[]do reader POI foi congelada como baseline (src/test/resources/caracterizacao/*.txt) e o reader FastExcel deve reproduzi-la idêntico. Um guard falha se algumTipoArquivonão tiver baseline.
Consequências¶
- Native desbloqueado — nenhum XMLBeans no runtime; o rollout native é a Fase B da spec 012.
- Cobertura declarada (REGRA Nº 3): os samples sintéticos são all-string, então a caracterização
prova paridade de indexação de coluna / seleção de aba / trim / vazio→null nos 9 tipos. O ramo
numérico-com-data (
ddMMyyyy) não é alcançável por fixture sintética — o writer não preserva o formato-de-data (getDataFormatString null no roundtrip) — e fica coberto pelos golden-masters reais (RELATORIO18/META/TRR) + o teste unitário direto deisDateFormat. - Golden-masters Java×Python mantidos, não regenerados — o oráculo Python morreu; o
resultado-python.jsoncongelado ainda roda oConformidadeTest, mas fixtures novas desse tipo não podem ser geradas.
Alternativas descartadas¶
- Manter POI só em
testImplementation(permitido pelo guard de CI, que só reprova POI não-test em app native): rejeitada por decisão do usuário — POI zero, alinhado ao piloto. - Escrever fixtures via FastExcel com data numérica estilizada para cobrir o ramo
ddMMyyyy: inviável — o roundtrip writer→reader não preserva a detecção de formato-de-data (achado do piloto).
0021 — Config de build native-image (Fase B)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-08-24
Continua a decisão 0020 (Fase A — POI→FastExcel, o
bloqueio de biblioteca). Espelha a ADR central
0022 — native-image é o alvo de deploy.
É a etapa de build do native, antes do deploy (o deploy native está na 0028).
Contexto¶
Com o POI fora do grafo (0020), o nativeCompile do plugin io.micronaut.application já roda —
faltava só a configuração que a casa fixou: o perfil de build (otimização + GC) e o único hint
de reflexão recorrente da frota (o SentryAppender do logback, instanciado pelo Joran).
Decisão¶
- Perfil GraalVM no
build.gradle.kts. O accessor Kotlin DSLgraalvmNative {}não é gerado (o plugin native-build-tools entra transitivo, não aplicado direto), então configura-se viaconfigure<GraalVMExtension>+import org.graalvm.buildtools.gradle.dsl.GraalVMExtension: -PnativeQuick→-Ob(build ~40–60% mais rápido, binário maior/lento — loop de dev/e2e); sem a flag (prod) →-Os(menor imagem).--gc=serial— menor footprint de RAM; é o default da GraalVM CE, explicitado.-
A CE 25 não tem
--gc=G1, PGO nem--emit build-report(Oracle-GraalVM-only), e-O3em CE equivale a-O2— nada a prometer de-O3. -
reflect-config.jsonlocal emsrc/main/resources/META-INF/native-image/br.com.onpetro/bi-comercial-xls/, registrandoio.sentry.logback.SentryAppenderpor nome-como-string (nãoClass.class— evita o javac carregar a classe do Sentry, que arrastaorg.jetbrains.annotations.Nullableausente do classpath). É o único hint manual recorrente da casa, confirmado byte-idêntico em múltiplos apps native.
Consequências¶
nativeCompileverde é o gate do repo do app. O runtime (boot/Flyway/POST) é o e2e native, em repo separado (0022) — não replicado aqui.- Deploy segue JVM durante a migração (defer explícito, REGRA Nº 3). A topologia da 0022 manda o
Dockerfiledo repo continuar JVM (dockerfile-java, shadowJar); a imagem native é buildada na fábrica de imagens (outro repo). Por issodocs/app.json "native": truee odockerfile-java-nativeficam para a T7, não entram aqui — setarnative:trueagora mentiria o discriminador ("deploya native") que o gate doci.ymllê. - A hint do Sentry é dívida de repo de lib. O ideal é subir pra reachability-metadata da
xadm-comum-web(dona doSentryInitializer) e sumir a cópia local em toda a frota; o jar 0.5.0 ainda não a traz (verificado). Enquanto isso, a cópia local é o caminho.
Correção 2026-09-11: o
reflect-config.jsonlocal do item 2 não existe mais. A hint doSentryAppendervem daxadm-comum-web0.9.1, que embarcaMETA-INF/native-image/br.com.xadm/xadm-comum-web/reflect-config.jsoncom as mesmas três entradas (SentryAppendereSentryOptionscom construtor eallPublicMethods, eLevel.valueOf), e o native-image o descobre no classpath. A dívida de lib das Consequências está fechada.O deploy native deferido para a T7 está em vigor, e o native é o alvo único do
build.targets: decisão 0028.
Alternativas descartadas¶
graalvmNative {}(accessor direto): não compila — o plugin é transitivo, o accessor não é gerado.configure<GraalVMExtension>é o caminho.- Setar
native:true+ Dockerfile native já (T7): premature — abre o gate de deploy native com a fábrica/e2e ainda pendentes (B2/B3/B4). Deferido para T7 (REGRA Nº 3).
Correção 2026-09-14: o andaime
.ia/012saiu do repo depois de destilado; o ponteiro passou para a decisão 0028.
0022 — Views em JTE no lugar de Thymeleaf¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-26 · Decidido em: 2026-08-26
Contexto¶
As telas server-render do bi-comercial (login, processamentos, admin, import de XLS) rodavam em
Thymeleaf (micronaut-views-thymeleaf). Sob GraalVM native-image (decisão
0021) o Thymeleaf/OGNL resolve o modelo por reflexão — cada
tipo alcançado por template exigiria @ReflectiveAccess e a falha aparece só em runtime
(500), tela por tela: whack-a-mole de reflexão. A casa fixou o alvo na ADR central
0025 — Views server-render com JTE.
Decisão¶
Adotar JTE (gg.jte) como motor das views server-render, substituindo o Thymeleaf, seguindo a central 0025. Template compilado, type-check no build, zero reflexão de modelo em runtime.
Consequências¶
- Plugin
gg.jte.gradle3.2.4 casado ao runtimemicronaut-views-jte; templates emsrc/main/jte/**(login.jte,kit/,processamentos/,compras/,admin/),generateJteantes docompileJava.Correção 2026-09-10: o
login.jtelocal foi apagado na adoção da login servida pela lib (decisão 0025) — a lista acima era o estado da migração JTE, não o atual. generate()mode — os.javagerados são compilados junto do app (native-safe; sem.binpor reflexão em runtime).contentType = Html.reflect-config.jsonde reachability-metadata dos templates gerados emsrc/main/resources/META-INF/native-image/jte-views/.- Checkstyle exclui
**/gg/jte/generated/**(código gerado, não humano). - CSRF (
_csrf) nos forms POST viaCsrfTokensda libxadm-seguranca(decisão 0018), agora consumido a partir dos templates JTE.
Fora de escopo: o motivo e as alternativas da migração (vivem na central 0025).
0023 — Segmento denormalizado em bi_produto/bi_movimento (role-gating PowerSync)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-02 · Decidido em: 2026-09-02
Contexto¶
O PowerSync vai passar a filtrar o bucket por role do JWT (central-backend 016): COMERCIAL vê
só combustível, LUBRIFICANTE só lubrificante, ADMIN/DIRETORIA tudo. bi_movimento
mistura os dois segmentos → o corte precisa ser row-level. A data-query do PowerSync não
faz JOIN nem subquery cross-table — a sync rule só enxerga a própria linha. Hoje bi_movimento só
tem cod_produto; o segmento vive no nome do produto (regra que hoje é regex no cliente Flutter,
produto_labels.dart). Migração V20.
Decisão¶
-
Coluna
segmento TEXTdenormalizada embi_produtoebi_movimento(combustivel/lubrificante/NULL). A sync rule lêWHERE segmento = 'combustivel'direto na linha debi_movimento— sem JOIN. -
Regra derivada do nome, dono único
Segmento.doNome(processing/):combustivel←^\s*ONU\s+\d+;lubrificante←MAXON\s+OILou\bON\s*LUB; senãoNULL.combustiveltem precedência. Autoridade = handoffintegracao/powersync(porta doproduto_labels.dart, que vive no cliente Flutter, fora deste repo). -
População inline na pipeline, diff-aware — não
UPDATE bi_movimento … FROM bi_produtopor carga. Os três readers que constroemProduto+Movimentono mesmo loop (Relatorio18Processor,MargemDiaProcessor.MovimentoSheetReaderreusado porMargemConsolidadaProcessor) chamamSegmento.doNome(nome)e setam nos dois;segmentoentra noWHERE IS DISTINCT FROMdos upserts. -
Backfill one-shot no
V20(ALTER ADD COLUMN IF NOT EXISTS+UPDATEdas duas + índiceidx_movimento_segmento). PG usa~*com\y; a paridade com o regex Java (\b) é garantida por teste (SegmentoBackfillParityTest, Postgres real), não por diff contra o Dart. -
NULL(Arla/Ureia) fica fora do COMERCIAL — a sync rule dá ao COMERCIALsegmento='combustivel'; linhaNULLsó chega em ADMIN/DIRETORIA.
Consequências¶
- Diff-aware preservado:
UPDATEcross-table por carga reescreveria a linha mesmo sem mudança (bumpaxmin→ checkpoint PowerSync desnecessário em todo movimento tocado). Inline +WHERE IS DISTINCT FROMnão toca linha inalterada — coerente com a doutrina doBulkUpserter. TestepipelineNasceComSegmentoEReuploadNaoBumpaXminprova (reupload exato →xminintacto). - Sem GRANT novo pro
powersync_role: coluna em tabela existente herda oGRANT SELECTtable-level (V13/default privileges); a regra de GRANT-cinto para tabela nova está no modelo de dados. - Mudança futura do regex não recomputa segmento de produto com
nomeestável (onome IS DISTINCTgate bloqueia) → exigiria nova migration de backfill, não só redeploy. Aceitável (regra de segmento muda raro e deliberado). - Divergência de
nomeentre arquivos (pré-existente donomediff-aware): segmento reflete o último arquivo a escrever onome. Não introduzido aqui; só vira trabalho se aparecer segmento errado em prod (aí, precedência de fonte probi_produto). - Fora deste repo: sync rule (
../powersync/), token com claimrole+ gating (central-backend 016), login do bi-comercial enviandoapp_id. A sync rule só aplica o YAML vivo depois desegmentopopulado em prod.
Alternativas consideradas¶
UPDATE bi_movimento m SET segmento = p.segmento FROM bi_produto pa cada carga (SQL literal do handoff): descartada —UPDATEpuro bumpaxminem todo movimento tocado = churn de checkpoint PowerSync, contra a doutrina diff-aware. Inline no processador é o caminho da casa.- Regex/sets de
cod_produtona SQL da sync rule (sem coluna): impossível — movimento não tem o nome, e lista curada decod_produtonão cobre código novo (conflito Paulínia). Denormalizar é o único corte confiável row-level. - Unificar com o filtro "só combustível" do
ComprasProcessor(startsWith("ONU")): descartada — aquele classifica fornecedor de compra (bi_compra/bi_fornecedor), com propósito e bordas próprios;Segmentoclassifica produto/movimento pro role-gating. Unificar arrastaria COMPRAS pro escopo e exigiria re-testar, sem ganho funcional. Regras separadas de propósito (spec 013, L9).
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.Correção 2026-09-14: o andaime
.ia/013saiu do repo depois de destilado; o durável está nesta decisão e no modelo de dados.
0024 — Adota o smoke de produção pós-deploy¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-09
Contexto¶
Este RD não decide nada novo: o smoke pós-deploy é norma da casa (constituição §9, ADR central 0031, página smoke-producao). O que faltava aqui era o rastro local — o que este app declarou como crítico, e por quê.
O problema alcança este repo em cheio: o POST /api/ci/deploy é assíncrono, então o pipeline
ficava verde antes de o container novo servir. E /health verde é liveness — prova que o
processo subiu e o Flyway passou, não que o app responde o que deveria. A frota já entregou, com
o container healthy, tela nova em 500 e <APP>_API_TOKEN ausente que só apareceu como 401 no
cliente.
Decisão¶
Adota o smoke declarando o manifesto no docs/app.json:
"smoke": {
"bases": ["https://excel.onpetro.xadm.biz"],
"glitchtip": { "org": "x-adm", "project": "bi-comercial-xls" },
"routes": [ { "path": "/health", "class": "public", "status": 200 } ]
}
Três escolhas locais, com o porquê:
basesé o fqdn PRINCIPAL, não os dois próprios. O app está em canário 50/50 (excel-nativedono do principal +excel.onpetro-jar-pull), e o principal é o que o cliente usa — é lá que a entrega tem de estar de pé. Consequência declarada: com o split, uma sonda pode cair no par que ainda não trocou; o retry mitiga, e a alternativa (declarar os dois fqdn próprios) prova cada metade mas deixa de provar o que o cliente vê. Cabe revisitar quando o ratio for medível.- Só
/healthnesta onda. É a camada 2 no seu mínimo honesto. Ampliar a lista exige conferir cada rota contra ointercept-url-map(/swagger/**,/public/**,/css/**,/xls/**são anônimas;/admin/**e/api/comercial/**não) — e a norma é explícita sobre o custo de errar:classque mente arma bomba de efeito retardado, e rota protegida declaradapublicreprova por motivo errado. Cresce app a app, com o manifesto conferido. session/m2mficam para depois. O seamPOST /smoke/sessionexigexadm-seguranca ≥ 0.7.1(aqui é 0.7.2 desde a migração da constituição 1.4.8 — o que falta é o resto) maisSMOKE_TOKENno secret e a propertysmoke.tokenno recurso Coolify. Sem os três, rotasessionvira asserção negativa — não é o que se quer estrear.
Correção (2026-09-15): o manifesto declara uma rota de cada classe de credencial, como a norma de smoke passou a exigir (guarda
Classe servida sem rota no smokedopipeline.yml):GET /api/comercial/processamentos(m2m) eGET /processamentos(session), além do/health. Obasessegue o fqdn principal, agora sem canário (deploy só native). O que liga as duas rotas está em Deploy:SMOKE_TOKENno recurso e nos secrets,SMOKE_M2M_TOKENnos secrets.
Consequências¶
GLITCHTIP_API_TOKEN(read-only) é obrigatório nos secrets do repo no GitHub. Declararsmoke.glitchtipsem o secret REPROVA o smoke — é defeito de configuração, não degradação: sem ele a camada 3 não roda e o job ficaria "verde provando 3 de 4".- Bump
xadm-comum-web0.7.1 → 0.9.0, que é quem emite ocommitno/healthe resolve oreleasedo GlitchTip a partir deXADM_COMMIT. Sem o bump, a camada 1 degrada paraversaoe o rollback fica sem alvo — o job falharia dizendo isso. - O
commitdo/healthpassa a ser o discriminador de entrega:XADM_COMMITentra como build-arg no pipeline e viraARG/ENVtarde no Dockerfile (declarado no topo, invalidaria o cache de toda camada abaixo a cada commit). Um identificador só atravessa pipeline, imagem,/healthe tag imutável. - Reprovar reverte para
<imagem>:<sha anterior>-<target>e confirma a reversão repolando o/health. Isso torna a tag imutável um ativo operacional: podar a tag que está no/healthde um recurso em produção transforma o rollback em "não confirmado" no pior momento. - Este RD é ponteiro: divergência de mérito sobre o mecanismo se resolve no ADR central 0031, não aqui. O que se decide localmente é o manifesto — quais rotas não podem quebrar.
0025 — Login servida pela lib¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-10
Contexto¶
Este RD não decide nada novo: a tela de login servida pela lib é norma da casa (constituição §5, decisão central 0032, constituição 1.4.0). O que faltava aqui era o rastro local — quando e como este app adotou.
A frota tinha cinco login.jte copiados — e o duplicado era o que não deveria: mecanismo de
auth idêntico (mesmo SDK, mesmo popup, mesmo callback com CSRF), moldura divergente. Um bug de auth
custava 5 PRs. A xadm-seguranca passou a servir a view (xadm/login, sob namespace — sem colisão
de FQCN com a view do app) e o app configura marca/tagline/título por property.
Decisão¶
Adota a login da lib com o bump xadm-seguranca 0.6.1 → 0.7.2, no mesmo PR (bumpar é migrar,
constituição §5):
- Apaga
src/main/jte/login.jte— oXadmLoginControllerda 0.7.2 renderizaxadm/logindo próprio JAR (GET /login,POST /login/callbackePOST /logoutinalterados). - Preserva a identidade que a view local tinha, por property (título vazio = derivado do
app-nome, decisão 0032):
xadm:
views:
app-nome: "BI Comercial"
login:
marca: true
tagline: "Um ERP Completo para sua empresa"
rodape: "BI Comercial · Processador XLS"
Três consequências locais, com o porquê:
- A view da lib linka os paths do kit (
/css/bootstrap.min.css,/js/bootstrap.bundle.min.js). Isso forçou o fim da divergência deliberada do vendor local (/public/vendor/bootstrap-5.3.3): manter os dois seria servir dois Bootstrap — o da lib quebraria sem os paths do kit. O app adota os estáticos do kit verbatim (Bootstrap 5.3.8) e olayout.jtevolta aos paths do kit. Sem o/js/**anônimo (intercept-url-map+ViewWhitelist), o bundle responderia 401 justo na tela de login (lição da constituição 1.4.1). BearerTokenGuard(fail-fast, constituição 1.4.7).app.api-tokendeclarado-e-vazio agora recusa o boot em vez de subir healthy respondendo 401 a tudo — a classe de indisponibilidade silenciosa que a camada 2 do smoke existe para pegar do lado de fora.- Seam destravado, não ligado. A 0.7.2 traz o
POST /smoke/session(dormindo por@Requiressemsmoke.token); rotasession/m2mno manifesto continua deferida — faltaSMOKE_TOKENno secret e a property no recurso Coolify (RD 0024).
Correção (2026-09-15): o manifesto declara a rota
session(GET /processamentos) e am2m; o seam liga com oSMOKE_TOKENno recurso (RD 0024).
Consequências¶
- Prova no mesmo passo: o
AuthIntegrationTest(GET/logincom "Entrar com Google" + CSRF double-submit contra a página) agora exercita a view da lib — é o e2e-local pegando o drift de contrato da adoção, como manda a §5. - Estáticos travados por teste (java-micronaut §UI, constituição 1.4.1): o
EstaticosDoKitAnonimosTestextrai oshref/srcdokit/layout.jtee do HTML doGET /logine afirma200sem credencial em cada um, com o contexto real. Controle vermelho feito: sem/js/**nointercept-url-map, reprova. - Este RD é ponteiro: divergência de mérito sobre o mecanismo se resolve na central 0032, não aqui. O que se decide localmente é a identidade (marca/tagline/rodapé) e o quando.
0026 — Adota o CI 100% GitHub Actions e o deploy pelo control-plane¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-31
Contexto¶
Este RD não decide nada novo. O deploy orquestrado pelo central-backend, disparado pelo CI por
API M2M, é norma da casa — ADR central
0026. O CI/CD num pipeline.yml
único no GitHub Actions também — ADR central
0027. Os dois chegaram aqui no mesmo
dia. O que faltava era o rastro local.
O número desta decisão local coincide por acaso com o do ADR central 0026.
Decisão¶
Em 2026-08-31, dois passos:
- Deploy pelo control-plane (commit
81a8763): o CI passou a pedir o deploy ao central-backend, autenticado porCENTRAL_DEPLOY_TOKEN(secret do repo no GitHub), no lugar da ponte SSH. - Pipeline único (commit
234df87, mesmo dia): o.github/workflows/pipeline.ymlfunde gate, docs,build_jar,build_nativee deploy, e o Forgejo deixa de rodar CI deste repo.
O deploy ancora na imagem fonte.xadm.biz/xadm/onpetro-xls (slug de registry, decisão local
0027), com os alvos jar e native do app.json build.targets. O
endereço do control-plane é o domínio estável central-backend.xadm.biz, sem flavor de build
(constituição §9).
Consequências¶
- O pedido de deploy é assíncrono: o central só enfileira. Quem confirma que a entrega está de pé é o smoke de produção (decisão local 0024).
- O recurso Coolify é casado pelo slug da imagem, não por uuid. Por isso um cutover blue-green não deixa o deploy órfão, como deixava o allow-list da ponte SSH.
- O
pipeline.ymlé template rastreado da casa: muda por re-derivação (/xadm-docs), não por edição solta. - Este RD é ponteiro: divergência de mérito sobre o control-plane ou o modelo de CI se resolve nos ADRs centrais 0026 e 0027, não aqui.
0027 — Slug de registry onpetro-xls, distinto do app_id¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-24
Contexto¶
Este RD não decide nada novo. A forma do slug (<cliente_id>-<projeto> para app de cliente), a
igualdade slug == nome da imagem no registry e a permissão de o slug divergir do app_id são norma
da casa — ADR central 0024, que tem
este app na tabela de renames da consolidação da frota native. O que faltava era o rastro local.
Decisão¶
Em 2026-08-24 (commit eddd800, junto do build native no modelo Docker), o slug passou de
bi-comercial-xls a onpetro-xls:
docs/app.jsonslug: onpetro-xls, a imagemfonte.xadm.biz/xadm/onpetro-xls(oIMAGEdopipeline.yml) e a doc emhttps://docs.xadm.biz/aplicacoes/onpetro-xls/(osite_urldomkdocs.yml).- O
app_idseguebi-comercial-xls: é a identidade do app no broker (grants, provisão), e renomeá-lo quebraria os grants. OAUTH_ISSUERda sessão também não mudou, pelo mesmo motivo.
Consequências¶
- Imagem e doc atendem por
onpetro-xls. No central-backend e na Central de Apps, o app segue sendobi-comercial-xls. A divergência é a que a norma prevê, não acidente. - O control-plane casa o recurso Coolify pelo slug da imagem (ADR central 0026, decisão local
0026), não pelo
app_id. Por isso opipeline.ymle o recurso Coolify têm de usaronpetro-xls. - Um rename futuro do slug é cosmético (docs + registry). O
app_idnão se renomeia. - Este RD é ponteiro: divergência de mérito sobre a nomenclatura se resolve no ADR central 0024, não aqui.
0028 — Adota o native por padrão (ADR central 0033)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Decisão local do
bi-comercial-xlsque aponta a decisão CENTRAL 0033 da casa. Os números não se correspondem.
Contexto¶
Server Micronaut da casa é native por padrão, e jar só entra com motivo registrado: um bloqueio de native numa decisão do app, ou host sem a arquitetura do binário (ADR central 0033, que substitui a central 0022 espelhada na local 0021). Este app não tem bloqueio de native aberto: a leitura de XLSX é FastExcel (0020), a config de build está na 0021 e o domínio principal serve o binário native.
Decisão¶
O build.targets do docs/app.json contém só native, e o .github/workflows/pipeline.yml segue a
variante native do template: sem o job build_jar e sem o step de deploy jar. O Dockerfile JVM fica
no repo como par do Dockerfile.native, para build e diagnóstico locais; a guarda de paridade do
pipeline.yml confere os dois.
Consequências¶
- Não há motivo de jar a registrar: nenhum bloqueio de native está aberto, e o e2e native cobre a superfície do binário.
- A saída de emergência é a tag imutável
fonte.xadm.biz/xadm/onpetro-xls:<sha>-native, não um deploy jar. O procedimento está no runbook Deploy. - O recurso jar (
excel-jar.onpetro.xadm.biz) deixa de receber imagem nova; desligá-lo é operação de infraestrutura, fora deste repo. Religar a perna JVM exige release comjarnobuild.targetse o motivo registrado, nunca o recurso parado com a imagem velha. - O deploy segue pelo control-plane da local 0026, só com o
alvo
native; o alvojarque ela cita sai por esta decisão. - Este RD é ponteiro: divergência de mérito sobre native × jar se resolve no ADR central 0033.
Alternativas consideradas¶
- Manter o jar por dispatch manual, como fallback: descartado. A 0033 exige motivo registrado para o jar, e a tag imutável do native já cobre o rollback.
0029 — Adota as libs da casa na versão corrente (ADR central 0034)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-14
Decisão local do
bi-comercial-xlsque aponta a decisão CENTRAL 0034 da casa. Os números não se correspondem.
Contexto¶
O app declara a última versão publicada de cada módulo do xadm-commons que consome. Atrás da corrente
é pendência, e abaixo do piso a /xadm-release recusa a release (ADR central 0034, que substitui a
política de piso da central 0019 citada na local 0018). O
bi-comercial-xls consome cinco módulos: xadm-seguranca, xadm-comum-web, xadm-comum-storage e
xadm-comum-util em produção, e xadm-comum-teste nos testes.
Decisão¶
O app acompanha a corrente dos cinco módulos: xadm-seguranca 0.8.0, xadm-comum-web 0.10.0,
xadm-comum-storage 0.3.0, xadm-comum-util 0.2.0 e, em teste, xadm-comum-teste 0.4.0. O mesmo PR
aplica as seções ### Migração de cada CHANGELOG:
- o DSN do GlitchTip vem só do
SENTRY_DSN; - a credencial do Garage vem dos nomes do broker,
GARAGE_ACCESS_KEY_IDeGARAGE_SECRET_ACCESS_KEY(os antigosGARAGE_ACCESS_KEY/GARAGE_SECRET_KEYainda são lidos, comWARN); - o Postgres de teste passa ao singleton da
xadm-comum-teste, com a baseIntegracaoComPostgres, e as regras de fronteira daRegrasArquiteturasubstituem as cópias locais; - a
@IntegracaoDockere aDockerDisponivelConditionvêm daxadm-comum-teste, e as cópias locais saem; - no modo
PSQLoArquivoXlsStoragesegue injetado direto: axadm-comum-storagesó cobra a credencial do Garage quando algo chama o S3, e o recurso nesse modo dispensa as envs do Garage.
Consequências¶
- Bumpar lib é migrar: a próxima
/xadm-releasesobe o que estiver atrás e aplica as### Migraçãoem ordem, removendo o código local que a lib passou a prover. - O recurso Coolify precisa de
SENTRY_DSNe do parGARAGE_ACCESS_KEY_ID/GARAGE_SECRET_ACCESS_KEYantes do deploy. As envs antigas saem depois que o app estiver no ar com as novas (Deploy). - A
xadm-seguranca0.8.0 tira o login do event loop; aqui nada muda, porque o app já declaramicronaut.server.thread-selection: AUTO. - Este RD é ponteiro: divergência de mérito sobre versão de lib se resolve no ADR central 0034.
Alternativas consideradas¶
- Subir só as versões e deixar as
### Migraçãopara depois, apoiado no dual-read da credencial do Garage: descartado. O dual-read é transição com saída anunciada, e a 0034 faz do bump uma migração no mesmo PR.
0030 — Adota o package-by-feature¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-09-14
Contexto¶
A norma da casa é package-by-feature: controller, service, repository e entidade convivem no pacote da
feature, e o transversal vive numa base comum
(Java/Micronaut, Arquitetura). O
bi-comercial-xls estava organizado por camada — api/, service/, repository/, processing/,
domain/, dto/, security/ e exception/ —, e o processador irmão, bi-transporte-xls, já tinha
migrado (decisão 0022 daquele repo). A migração para a constituição 2.0.0 é o refactor estrutural em que a
troca entra.
Duas classes misturavam features e impediam o corte limpo: o TelegramService montava as mensagens do
processamento e do nuke, e o StartupRecoveryService recuperava os dois no boot. Em qualquer fatia, cada
uma forçaria uma dependência de volta para a outra feature.
Decisão¶
O código é package-by-feature sob br.com.onpetro.bi.comercial, com as camadas (controller, service,
repository, entidade, DTO, view-model) dentro da fatia. Na raiz ficam só Application e BearerTokenEnv.
| Fatia | Papel |
|---|---|
processamento |
o pipeline XLS: API de ingestão e consulta, telas /processamentos/**, recepção do .zip, fila assíncrona, lote, storage do binário e backfill, mensagens de Telegram do processamento e o recovery no boot; entidades xls_processamento e xls_conjunto_lote |
processamento.planilha |
a leitura das planilhas: ArquivoDetector, TipoArquivo, ExcelSheetReader, ZipXlsExtrator, Segmento e os 9 {Tipo}Processor |
dados |
as tabelas bi_* de negócio: entidades, repositórios e a escrita em lote (BulkUpserter, ColunasConteudo, UpsertResultado) |
importacao |
a página aberta de import manual (/xls/importar) — ImportacaoXlsController |
powersync |
o reset (nuke) da replicação: orquestrador, API REST, tela /admin, Mongo, slot, Coolify, auditoria, mensagens de Telegram do nuke e o recovery no boot |
configuracao |
bi_configuracao: leitura com cache e a tela /admin/configuracoes |
diagnostico |
as rotas /test e /test/glitchtip |
seguranca |
a policy de acesso das views: ViewSecurityRule, ViewWhitelist e os handlers de rejeição |
comum |
base transversal, não é feature: comum.exception (StorageIndisponivelException, 503) e comum.notificacao (TelegramService) |
%% lint-mermaid: LR-ok
flowchart LR
I[importacao] --> P[processamento]
P --> PL[processamento.planilha]
PL --> D[dados]
P --> D
P --> C[comum]
PS[powersync] --> CF[configuracao]
PS --> C
dadosé folha: não depende de nenhuma fatia. Quem grava asbi_*depende dela, nunca o contrário.comumnão conhece feature. O Telegram se parte em transporte e formatação: ocomum.notificacao.TelegramServicesó envia texto pronto; oProcessamentoNotificadore oNukeNotificadormontam as mensagens da sua feature. O recovery de boot se parte do mesmo jeito:ProcessamentoStartupRecovery(PROCESSANDOviraERRO,PENDENTEvolta à fila) eNukeStartupRecovery(nuke órfão viraERRO), ambos atrás deapp.recovery.enablede depois do backfill do storage (@Order(-100)).processamento.planilhanão conhece o pipeline: depende dedados, nunca do pacoteprocessamento(fila, lote, storage, controllers).- A exceção local de storage mora em
comum.exception; as exceções HTTP genéricas vêm daxadm-comum-web. A decisão 0012 fica obsoleta. - O
ComprasImportControllerpassa aImportacaoXlsController: o nome antigo era cosmético e o rename estava deferido (0019). - Os testes espelham as fatias; ficam na raiz os transversais (
ArchitectureTest, bases de teste,TestXlsxFactory,/health, RFC 7807, serde).
Travas ArchUnit (ArchitectureTest, no check, sobre a RegrasArquitetura da xadm-comum-teste):
import não-vazio; sem ciclos entre as fatias; comum não depende de fatia; dados é folha;
processamento.planilha não depende do pacote processamento; controller não acessa repository; nada
depende de controller; SQL cru pela conexão só nas classes de bulk da allowlist (as de dados e
powersync.PostgresReplicationAdmin); entidade livre de infra; escrita de controller de view declara
@Consumes. As regras de fronteira citam o nome completo da fatia: o padrão curto (..seguranca..)
casaria também o pacote br.com.xadm.comum.seguranca da lib.
Consequências¶
- O código de uma feature fica junto; o transversal fica explícito em
comum, e o ArchUnit barra a volta de dependência por descuido. - Migração mecânica (
git mv, pacote e imports), sem mudança de contrato: rotas, JSON, OpenAPI, schema e envs seguem iguais. - A guarda foi provada na migração: uma violação temporária por regra de fronteira reprovou cada uma, e o verde voltou ao desfazê-las. Rename futuro de pacote leva o literal da regra junto e repete a prova (norma, Rename de pacote).
- As telas de admin recebem view-model (
NukeView,ConfiguracaoView), nunca a entidade. bi-comercial-xlsebi-transporte-xlsficam no mesmo layout; o corte difere onde o domínio pede — lá asbi_*moram emprocessamento, aqui são a fatiadados.
Alternativas consideradas¶
- Manter package-by-layer como exceção (a versão anterior desta decisão, não publicada): descartada. A exceção não tinha motivo de domínio, e o custo do rename é o mesmo agora ou depois.
bi_*dentro deprocessamento, como nobi-transporte-xls: descartada. Como fatia folha, o modelo de BI se lê sem puxar o pipeline, e a regra "a planilha grava sem conhecer o pipeline" fica verificável.
Glossário do projeto¶
Vocabulário do domínio comercial de combustíveis (OnPetro) que este app usa. Termos da plataforma X-Adm (Forgejo, Coolify, Garage…) não são redefinidos aqui — linkam para o glossário da plataforma.
Tipos de arquivo¶
Cada envio é um .zip com um ou mais .xlsx; o ArquivoDetector reconhece o tipo
de cada planilha pelo conteúdo (abas e cabeçalhos) e pelo nome, sem o operador
informar nada. Ordem de prioridade e critérios em
Detecção do tipo.
- RELATORIO18
- Relatório principal de vendas. Alimenta o fato
bi_movimentoe, de passagem, garante os cadastros relacionados (bi_filial,bi_cliente,bi_vendedor,bi_produto). Faz DELETE seletivo do período antes do UPSERT. Detectado pelo nome do arquivo (relatorio,relatórioourel 18). - MARGEM_CONSOLIDADA
- Relatório de custo/inventário do período. Alimenta
bi_custo_inventarioe complementabi_movimentodo período. Detectado pelo nome (margem consolidada/margem_consolidada). - MARGEM_DIA
- Variante diária da margem. Complementa
bi_movimento. Detectado pelo nome no padrãomargem -(espaço-hífen-espaço, convenção da equipe). - META
- Metas de litros/margem por vendedor e período. Alimenta
bi_meta(upsert por vendedor + data). Detectado pelo nome (meta, quando nenhum tipo mais específico bateu). - META_FERNANDO
- Variante de meta por vendedor, numa aba específica (
Meta por vendedorou colunaTipo de meta). Também alimentabi_meta. Tem a prioridade mais alta na detecção. - POSTOS
- Cadastro de postos revendedores (com vinculação a distribuidor). Alimenta
bi_posto(upsert por CNPJ). Detectado pela colunaVinculação a Distribuidor. - TRR
- Cadastro de Transportadores-Revendedores-Retalhistas. Alimenta
bi_trr(upsert por CNPJ). Detectado pela colunaTipo de InstalaçãoouQualificação da Empresa(a ANP renomeou a coluna ~2026). - COMPRAS
- Relação de compras de combustível (itens de NF). Alimenta
bi_fornecedor(upsert por CNPJ) ebi_compra(sem chave natural: substitui o período do arquivo). Só entram linhas de combustível (produtoONU …). Detectado por uma aba chamadaCompras, antes de qualquer regra por nome. - COTA_PETROBRAS
- Cota mensal de volume (m³) por polo e produto, uma aba por ano. Alimenta
bi_cota_petrobraspor full-replace (a planilha é a matriz inteira). Detectado por uma aba com nome de ano cujo cabeçalho começa comPOLO/PRODUTO/JANEIRO.
Domínio comercial¶
- Movimento
- Fato central de vendas (
bi_movimento): uma linha por NF × produto × cliente × filial. Carrega quantidade, preço, margem, frete, taxa administrativa, prazo, além de UF/município de destino da venda (não da filial). - Filial
- Unidade da OnPetro que originou a venda (
bi_filial, chavecod_filial). Os relatórios XLSX só trazem o código da filial — município/UF da própria filial são cadastrados à parte (docs/privado/seed-filiais.sql); as colunas UF/ Município dos relatórios referem-se ao destino, e vão parabi_movimento. - Vendedor
- Vendedor responsável pela venda e alvo das metas (
bi_vendedor, chavecod_vendedor). Ligado abi_movimentoe abi_meta. - Meta
- Objetivo de litros e margem por vendedor num período (
bi_meta, chave naturalcod_vendedor + data). custo_inventario- Custo de carregar estoque no período (
bi_custo_inventario, chavedata): valor, taxa de aplicação, juros totais, venda simulada e total. Origem: MARGEM_CONSOLIDADA. - Chave natural
- Combinação de colunas que identifica um fato de forma estável, independente da
posição na planilha (ex.:
nf, cod_produto, cod_cliente, cod_filialembi_movimento). ViraUNIQUE(uq_*) e é a chave do UPSERT diff-aware — é o que permite reprocessar o mesmo período sem duplicar. - Período
- Intervalo de datas coberto por um envio. Vem do conteúdo do arquivo (não do upload — ao contrário do projeto irmão Transporte); é o que delimita o DELETE seletivo do diff-aware.
- Conjunto (lote)
- Pacote de N arquivos enviados juntos: é o próprio
.ziprecebido no POST. O cliente não declara nada — o identificador do lote e o total de arquivos são derivados do conteúdo pelo servidor (o id ézip-<sha256 do zip>; o total, o número de entradas.xlsx). Quando todas as entradas terminam, sai uma notificação de resumo no Telegram (ConjuntoLoteService). - Checksum
- SHA-256 do conteúdo binário do
.xlsx; chave de idempotência de upload — evita reprocessar o mesmo arquivo após sucesso. - Diff-aware (UPSERT)
INSERT … ON CONFLICT (chave natural) DO UPDATE … WHERE … IS DISTINCT FROM: só grava a linha que realmente mudou. Linhas inalteradas preservamidUUID exmin— não re-propagam pelo PowerSync. Métricas agregadas por envio:linhas_efetivas(INSERT/UPDATE que escreveram) elinhas_removidas(DELETE seletivo).- Segmento
- Classificação do produto em
combustivel,lubrificanteou vazio (Arla/Ureia), derivada do nome e gravada embi_produtoe, denormalizada, embi_movimento. É o corte que o PowerSync usa para mandar a cada perfil de usuário só o seu segmento.
Infraestrutura específica¶
- PowerSync
- Serviço de sincronização que replica as tabelas
bi_*do PostgreSQL para os clientes móveis (SQLite). Aqui sincronizam todas asbi_*(fatos, dimensões ebi_configuracao); asxls_*são locais por design. Exige PK de coluna única TEXT/UUID — por isso osbi_*usamid UUID DEFAULT uuidv7(). bi_configuracao- Tabela KV global (
id TEXT) sincronizada via PowerSync. Guardaultimo_nuke(sinal de reset lido pelo cliente Flutter) e parâmetros comoslow_query_timeout_ms. Rowssistema=TRUEsão editáveis só pelo backend. - Nuke da replicação
- Operação destrutiva que zera o estado replicado (slot lógico do PostgreSQL +
MongoDB do PowerSync) e força o re-sync dos clientes. No código,
NukeReplicationService; auditada emxls_nuke_replication. Procedimento no runbook de operação.