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