Pular para conteúdo

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 (exigem GRANT SELECT ao powersync_role, decisão 0013). PK id UUID DEFAULT uuidv7(); a chave natural vira UNIQUE (uq_*).
  • xls_* — controle e auditoria locais ao backend, nunca sincronizados. PK BIGSERIAL (ou TEXT no 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_movimento referencia as dimensões (bi_cliente/bi_vendedor/bi_filial/bi_produto) por suas UNIQUE de código natural, e bi_meta referencia bi_vendedor — Postgres aceita FK apontando para coluna UNIQUE. Não há FK ligando xls_processamento aos fatos: um upload mescla os bi_* do seu período (UPSERT diff-aware), não os "possui". A ligação xls_processamento.conjunto_id → xls_conjunto_lote é lógica (sem constraint, para ser retrocompatível). bi_posto, bi_trr, bi_custo_inventario, bi_configuracao e bi_cota_petrobras são independentes; as xls_* 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 UNIQUE de 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 de bi_movimento) sem tocar em municipio/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 para bi_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 de nome por Segmento.doNome (dono único da regra) na pipeline, inline e diff-aware (entra no WHERE … IS DISTINCT FROM dos upserts); as linhas anteriores à coluna vieram do backfill da V20. Pré-requisito do role-gating PowerSync: a sync rule corta bi_movimento por segmento e não faz JOIN, por isso o valor é denormalizado na linha. Regra: combustivel ← ^ONU \d+; lubrificante ← MAXON OIL ou ON LUB; senão NULL (Arla/Ureia, fora do COMERCIAL). Distinto do filtro "só combustível" do ComprasProcessor (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) e slow_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 responde 403. Chave criada pela tela nasce sistema=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: cnpj UNIQUE. 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 (NF 802962/1, 803823/1, 806243/1 aparecem 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 do xls_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}$ (ignora Planilha1), mapeia os 12 meses canônicos por header normalizado (descarta JANEIRO.2024/TOTAL PRODUTO/ANO/ruído) e emite linha só quando o produto é canônico (Diesel S10, Diesel S500, Gasolina ou Diesel 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; parcial conjunto_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_enviadas e linhas_efetivas estão no mesmo escopo (todas as tabelas tocadas), e é a diferença entre as duas que dá as linhas inalteradas — as que o WHERE ... IS DISTINCT FROM filtrou, sem gerar WAL nem checkpoint PowerSync. Não derivar isso de registros_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} — IGNORADO quando 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 é 400 na 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_andamento sobre ((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):

  1. Aba "Meta por vendedor" existe (ou coluna "Tipo de meta") → META_FERNANDO
  2. 1ª linha da aba padrão tem coluna "Vinculação a Distribuidor" (com/sem acento) → POSTOS
  3. 1ª linha da aba padrão tem coluna "Tipo de Instalação" ou "Qualificação da Empresa" (com/sem acento) → TRR
  4. Nome (lower-case) contém relatorio / relatório / rel 18 / relatorio18 → RELATORIO18
  5. Nome (lower-case) contém margem consolidada ou margem_consolidada → MARGEM_CONSOLIDADA
  6. Nome (lower-case) contém margem - (espaço + hífen) → MARGEM_DIA
  7. Nome (lower-case) contém meta → META
  8. 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 coluna FILIAL contém o código, não a cidade); Município→municipio.
  • bi_movimento.data é injetado do nome do arquivo (não há coluna DATA no 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:

  1. Agrupar por (cod_vendedor, mês) somando volume → litros.
  2. data = 1º dia do mês (LocalDate.of(ano, mes, 1)).
  3. Arredondar litros se 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 (o cod_vendedor já deve existir).
  • POSTOS → só bi_posto; TRR → só bi_trr.

10. Validação na recepção (síncrona, antes de enfileirar)

  1. Magic bytes ZIP 0x50 0x4B 0x03 0x04 (xlsx) — senão 400.
  2. Nome não começa com ~$ nem # — senão 400.
  3. Detectar o tipo (conteúdo/nome) — desconhecido → 400.
  4. Calcular checksum SHA-256 e conferir deduplicação de upload.
  5. Persistir bytes + nome + checksum + tipo_arquivo + status PENDENTE.
  6. 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: