Pular para 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 o cod_filial; município/UF da filial não existem nessas planilhas (as colunas UF/Município referem-se ao destino da venda, que vai para bi_movimento). Por isso o FilialRepository só faz ensureExistsBatch (INSERT … ON CONFLICT DO NOTHING) — garante a FK sem tocar em municipio/uf (cadastro manual via docs/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 (o int[] do executeBatch(), ignorando SUCCESS_NO_INFO). Conflito filtrado pelo WHERE IS DISTINCT FROM retorna 0 e preserva o id UUID + xmin. Mesmo escopo de linhas_enviadas.
  • linhas_removidas: soma dos DELETE seletivos. Separada de linhas_efetivas porque um relatório com 99 linhas idênticas + 1 removida na origem teria efetivas=0 mas 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" de registros_processados − linhas_efetivas. As duas colunas estão em escopos diferentes — registros_processados conta só a entidade principal do arquivo, linhas_efetivas soma todas as tabelas —, então a conta dava negativo (um RELATORIO18 real exibia -98). A V15 acrescenta linhas_enviadas no mesmo escopo de linhas_efetivas e a tela passa a usá-la. Sem backfill: uploads anteriores à V15 ficam com linhas_enviadas NULL 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 FROM garante que só linhas realmente alteradas trocam de xmin — 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 e executeBatch() retorna SUCCESS_NO_INFO (-2), matando a métrica linhas_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.
  • reWriteBatchedInserts ligado por engano → métrica linhas_efetivas zera (SUCCESS_NO_INFO); fixado no env.
  • GRANT faltando ao powersync_role numa bi_* 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.md passou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.