Uma consulta pode executar sem erro e produzir um total incorreto. Em um modelo com exclusão lógica e separação por filial, isso acontece quando ela soma itens desconsiderados pela aplicação ou combina documentos de filiais diferentes.

Até aqui, a série explorou recursos do SQL. Neste episódio, o foco está no contrato dos dados: quais registros participam da consulta e quais campos identificam um relacionamento?

Vamos usar cabeçalhos, itens e um cadastro compartilhado para mostrar como essas decisões mudam o resultado de um relatório.

Este exemplo usa dados sintéticos e estruturas próprias no PostgreSQL. Ele não reproduz o dicionário completo nem as regras de negócio do Protheus.

O laboratório reproduzível do episódio 15 contém os scripts de criação, carga, consultas, exercício e validação automatizada. A versão de referência registra duas execuções completas no PostgreSQL 16.14, com 22 verificações aprovadas em 02/11/2026.

O contrato do exemplo

No artigo sobre PostgreSQL no Protheus , separamos o banco transacional do ERP de uma base própria de integração. Aqui usamos uma base própria apenas para estudar a semântica das consultas.

Adotaremos estas regras:

EstruturaEscopoIdentificação de negócio
CabeçalhosExclusivos por filialfilial + número
ItensExclusivos por filialfilial + número + item
ProdutosCompartilhados entre as duas filiais do exemplofilial de cadastro em branco + código

O campo d_e_l_e_t_ recebe um espaço (' ') para registros ativos e um asterisco ('*') para excluídos logicamente. O marcador não remove fisicamente a linha; a consulta precisa declarar se ela participa do resultado.

O campo r_e_c_n_o_ será apenas um identificador técnico de cada linha. Ele não relaciona cabeçalhos e itens nem informa quando um registro foi alterado.

Também assumimos um único contexto de empresa. Duas filiais com os códigos 01 e 02 são suficientes para o experimento; esses tamanhos e essa organização não são um contrato para ambientes reais.

Preparando a massa sintética

Execute a preparação em um banco de estudos. O schema próprio mantém os objetos do episódio identificados e precisa ainda não existir para executar o bloco abaixo.

CREATE SCHEMA sql_semana_15;

CREATE TABLE sql_semana_15.pedidos (
    r_e_c_n_o_ bigint PRIMARY KEY,
    filial text NOT NULL CHECK (filial IN ('01', '02')),
    numero text NOT NULL,
    d_e_l_e_t_ text NOT NULL CHECK (d_e_l_e_t_ IN (' ', '*')),
    UNIQUE (filial, numero)
);

CREATE TABLE sql_semana_15.itens (
    r_e_c_n_o_ bigint PRIMARY KEY,
    filial text NOT NULL CHECK (filial IN ('01', '02')),
    numero text NOT NULL,
    item text NOT NULL,
    produto text NOT NULL,
    valor numeric(12, 2) NOT NULL,
    d_e_l_e_t_ text NOT NULL CHECK (d_e_l_e_t_ IN (' ', '*')),
    UNIQUE (filial, numero, item)
);

CREATE TABLE sql_semana_15.produtos (
    r_e_c_n_o_ bigint PRIMARY KEY,
    filial text NOT NULL CHECK (filial = '  '),
    codigo text NOT NULL,
    descricao text NOT NULL,
    d_e_l_e_t_ text NOT NULL CHECK (d_e_l_e_t_ IN (' ', '*')),
    UNIQUE (filial, codigo)
);

INSERT INTO sql_semana_15.pedidos VALUES
    (1, '01', '100', ' '),
    (2, '02', '100', ' '),
    (3, '01', '101', '*'),
    (4, '01', '102', ' '),
    (5, '01', '103', ' ');

INSERT INTO sql_semana_15.itens VALUES
    (11, '01', '100', '01', 'P01', 100.00, ' '),
    (12, '01', '100', '02', 'P02',  50.00, '*'),
    (13, '02', '100', '01', 'P01', 900.00, ' '),
    (14, '01', '101', '01', 'P01', 300.00, ' '),
    (15, '01', '103', '01', 'P01',  70.00, '*');

INSERT INTO sql_semana_15.produtos VALUES
    (21, '  ', 'P01', 'Produto A', ' '),
    (22, '  ', 'P02', 'Produto B', ' ');

Usamos text para deixar explícito o conteúdo armazenado. Nessa massa, ' ', '' e NULL são valores distintos. O marcador de ativo contém exatamente um espaço; a filial compartilhada dos produtos contém dois. Não substitua um pelo outro apenas porque ambos parecem vazios na tela.

Essas constraints simplificam o exercício. Elas não reproduzem a composição dos índices físicos do ERP nem o tratamento de chaves de registros excluídos.

Exclusão lógica precisa ser considerada em cada tabela

Vamos listar os itens ativos de pedidos ativos da filial 01:

SELECT p.filial, p.numero, i.item, i.produto, i.valor
FROM sql_semana_15.pedidos AS p
JOIN sql_semana_15.itens AS i
  ON i.filial = p.filial
 AND i.numero = p.numero
 AND i.d_e_l_e_t_ = ' '
WHERE p.filial = '01'
  AND p.d_e_l_e_t_ = ' '
ORDER BY p.numero, i.item;

Resultado esperado:

FilialPedidoItemProdutoValor
0110001P01100.00

O filtro do cabeçalho não filtra automaticamente os itens. Se retirarmos apenas i.d_e_l_e_t_ = ' ', a listagem também trará os itens excluídos dos pedidos 100 e 103. Ao somar os valores por pedido, obteremos 150.00 para o 100 e 70.00 para o 103.

Se mantivermos o filtro dos itens, mas retirarmos o filtro de exclusão do cabeçalho, o pedido 101 aparecerá com 300.00. Seu item está ativo na massa, mas o cabeçalho está excluído.

A pergunta de negócio é sobre pedidos ativos com itens ativos. Cada alias tem uma condição própria para cumprir essa regra.

Filtrar a filial do cabeçalho não completa o join

Esta consulta parece próxima da anterior, mas omite a filial no relacionamento:

-- Exemplo incorreto para o contrato deste modelo.
SELECT p.filial, p.numero, sum(i.valor) AS total
FROM sql_semana_15.pedidos AS p
JOIN sql_semana_15.itens AS i
  ON i.numero = p.numero
 AND i.d_e_l_e_t_ = ' '
WHERE p.filial = '01'
  AND p.d_e_l_e_t_ = ' '
GROUP BY p.filial, p.numero;

O pedido 100 da filial 01 encontra o item de 100.00 da própria filial e o item de 900.00 da filial 02. O total esperado dessa consulta incorreta é 1000.00.

O WHERE restringiu p.filial. Nada nessa condição impediu que i trouxesse linhas de outra filial.

Para esse relacionamento, a chave é (filial, numero). Usar DISTINCT não recupera a associação correta entre cabeçalho e item. A correção é incluir i.filial = p.filial no ON.

Cadastro compartilhado tem outro escopo

Agora queremos buscar a descrição do produto. Como os produtos são compartilhados, sua filial de cadastro está em branco no contrato deste exemplo.

WITH contexto AS (
    SELECT '01'::text AS filial_movimento,
           '  '::text AS filial_produto
)
SELECT
    p.filial,
    p.numero,
    i.item,
    i.produto,
    pr.descricao,
    i.valor
FROM contexto AS c
JOIN sql_semana_15.pedidos AS p
  ON p.filial = c.filial_movimento
 AND p.d_e_l_e_t_ = ' '
JOIN sql_semana_15.itens AS i
  ON i.filial = p.filial
 AND i.numero = p.numero
 AND i.d_e_l_e_t_ = ' '
LEFT JOIN sql_semana_15.produtos AS pr
  ON pr.filial = c.filial_produto
 AND pr.codigo = i.produto
 AND pr.d_e_l_e_t_ = ' '
ORDER BY p.numero, i.item;

A linha esperada é o item 01 do pedido 100, com descrição Produto A e valor 100.00.

Se usarmos pr.filial = p.filial, a descrição ficará NULL: os dois campos representam escopos diferentes. Com INNER JOIN, a linha inteira desapareceria.

O LEFT JOIN foi escolhido para preservar o item quando o cadastro ativo não for encontrado. Nesse caso, a descrição ausente deve ser investigada; ela não comprova sozinha que o produto nunca existiu.

A documentação da TOTVS sobre compartilhamento mostra que a configuração pode envolver empresa, unidade e filial. No ambiente real, o escopo precisa ser resolvido por tabela conforme o dicionário e o contexto da aplicação.

Por isso, filial_produto é um parâmetro explícito do nosso modelo, não uma regra para codificar dois espaços em qualquer consulta. Também não usamos filial IN ('01', ' '): misturar escopos sem um contrato pode encontrar mais de um cadastro para o mesmo código e multiplicar as linhas.

Preservando pedidos sem itens ativos

O relatório agora precisa mostrar todos os pedidos ativos da filial 01, inclusive os que não possuem itens ativos:

SELECT
    p.filial,
    p.numero,
    count(i.r_e_c_n_o_) AS itens_ativos,
    coalesce(sum(i.valor), 0.00::numeric) AS total
FROM sql_semana_15.pedidos AS p
LEFT JOIN sql_semana_15.itens AS i
  ON i.filial = p.filial
 AND i.numero = p.numero
 AND i.d_e_l_e_t_ = ' '
WHERE p.filial = '01'
  AND p.d_e_l_e_t_ = ' '
GROUP BY p.filial, p.numero
ORDER BY p.numero;

Resultado esperado:

FilialPedidoItens ativosTotal
011001100.00
0110200.00
0110300.00

O pedido 102 não tem itens. O 103 tem apenas um item excluído. Para esse relatório, ambos possuem zero itens ativos.

O filtro i.d_e_l_e_t_ = ' ' fica no ON para definir quais itens podem corresponder a cada pedido. Se ele for movido para o WHERE, o pedido 102 será descartado porque a linha sem correspondência terá esse campo como NULL. O 103 também desaparecerá: seu item encontrará correspondência pela chave, mas será rejeitado pelo marcador '*'. O WHERE não cria outra linha para preservar o cabeçalho depois disso. A documentação de joins do PostgreSQL explica essa diferença entre restringir o relacionamento e filtrar o resultado de um outer join.

Usamos count(i.r_e_c_n_o_) porque essa coluna é não nula nas linhas reais dos itens. count(*) contaria também a linha preservada pelo LEFT JOIN, produzindo 1 para pedidos sem correspondência.

O que conferir antes de adaptar a consulta

Uma consulta sobre dados reais exige confirmar:

  • o contexto de empresa e o compartilhamento de cada tabela;
  • a chave completa de cada relacionamento, incluindo loja, série ou outros componentes quando aplicáveis;
  • o conteúdo e o tipo dos campos usados como marcadores;
  • se o relatório deve preservar linhas sem cadastro ou sem movimentos ativos;
  • as contagens e os totais antes e depois dos joins.

Essa massa pequena permite observar erros de resultado. Ela não sustenta conclusões sobre desempenho, índices, autovacuum ou capacidade de uma base real.

O filtro de filial também não substitui controles de acesso. Ele faz parte da semântica deste relatório; autorização continua sendo uma responsabilidade própria do sistema.

Desafio

Adapte a última consulta para mostrar os pedidos ativos das duas filiais, mantendo a separação por (filial, numero).

O resultado esperado inclui o pedido 100 da filial 02, com um item ativo e total de 900.00, além das três linhas da filial 01. O total geral deve ser 1000.00, distribuído entre quatro cabeçalhos ativos.

Depois, retire apenas i.filial = p.filial do ON, mantendo o agrupamento por (p.filial, p.numero). Os dois pedidos 100 passarão a encontrar os mesmos dois itens ativos: cada um terá contagem 2 e total 1000.00. Os pedidos 102 e 103 continuarão zerados. O total geral incorreto será 2000.00.

A quantidade de cabeçalhos continuará sendo quatro. Por isso, conferir apenas o número de linhas do relatório não detecta essa regressão: é preciso comparar também as chaves, as contagens de itens e os valores de cada pedido.

Conclusão

Exclusão lógica define quais linhas participam do relatório. A chave completa define como elas se relacionam. O compartilhamento define em qual escopo buscar cada cadastro.

No exemplo, essas regras evitaram somar itens excluídos, combinar pedidos de filiais diferentes e perder descrições de um cadastro compartilhado. O LEFT JOIN, com os filtros no lugar adequado, também preservou os pedidos sem itens ativos.

Antes de investigar o custo da consulta, precisamos conseguir explicar e conciliar o resultado que ela produz.

Referências