Apêndice — consultas estruturadas no eproc
O capítulo anterior entregou uma Tool: processos meus com um determinado movimento. Ela responde uma pergunta de cada vez, e responde bem.
Este apêndice é sobre o que vem depois — quando a pergunta deixa de ser "quais processos têm o movimento X" e passa a ser "quantos entraram este ano, quantos foram julgados, quantos estão suspensos e quantos continuam pendentes". É a família de perguntas que produz indicador, e é a que mais dá resposta errada com cara de certa.
A boa notícia: não são dezenas de consultas diferentes. São quatro tijolos, combinados de jeitos diferentes.
Medido: os padrões deste apêndice vêm de um projeto real de cálculo das Metas do CNJ para a 2ª instância, rodando sobre o acervo do eproc de 2º grau ao lado do sistema legado. As armadilhas descritas aqui — o código que não existe, a contagem inflada pelo JOIN, estoque confundido com fluxo — são erros que aconteceram e foram corrigidos, não hipóteses.
Não medido: o seu esquema, de novo. Todo nome de tabela e de coluna abaixo é hipótese a confirmar com o WHERE 1=0 do capítulo do banco. O nome da coluna de situação, o nome da coluna de data, o esquema onde a tabela mora: os três variam.
E o de sempre: senha nenhuma entra em consulta, em código ou em anotação. Ela vive em variável de ambiente, como no capítulo do eproc. Se você encontrar credencial escrita dentro de um script — e vai encontrar —, isso é achado a comunicar, não a copiar.
Os quatro tijolos
Toda consulta de indicador é feita destes quatro, nesta ordem:
| Tijolo | O que responde | |
|---|---|---|
| 1 | Recorte | quais processos sequer entram na conta |
| 2 | Varredura de eventos | o que aconteceu com eles, e quando |
| 3 | Primeiro / último por processo | qual evento vale, quando há vários |
| 4 | O que falta | quem não tem o evento seguinte |
O tijolo 3 é o que separa uma consulta que funciona de uma que quase funciona, e o tijolo 4 é o que quase todo mundo escreve errado. Um de cada vez.
1. Recorte
O recorte é uma CTE, e existe para ser lido em voz alta:
WITH elegiveis AS (
SELECT p.id_processo,
p.numero_processo,
p.id_classe_judicial
FROM processo p
WHERE p.nivel_sigilo = 0
AND p.cod_magistrado = %(magistrado)s
)
Duas coisas moram aqui e em nenhum outro lugar: o sigilo e a identidade. Repetidas em cada consulta, elas viram algo que se esquece uma vez; concentradas numa CTE que toda consulta usa, elas viram algo que só dá para esquecer de propósito.
2. Varredura de eventos
eventos AS (
SELECT e.id_processo,
e.cod_mni_movimento,
e.data_evento
FROM processo_evento e
JOIN elegiveis g ON g.id_processo = e.id_processo
WHERE e.cod_mni_movimento = ANY(%(codigos)s)
AND e.data_evento < %(fim)s
)
= ANY(%(codigos)s) recebe uma lista Python direto, sem montar IN (...) com concatenação de string — que é como se escreve injeção de SQL sem perceber.
Repare no < %(fim)s sem limite inferior. É proposital, e a seção de estoque e fluxo explica por quê.
3. Primeiro ou último por processo
Um processo tem muitos eventos do mesmo tipo. A pergunta quase sempre quer um: a primeira distribuição, o último julgamento, a última suspensão.
No PostgreSQL há duas formas, e a segunda é a que ele faz melhor que os outros bancos:
-- Forma geral (funciona em qualquer banco moderno)
SELECT id_processo, data_evento
FROM (
SELECT id_processo, data_evento,
row_number() OVER (PARTITION BY id_processo
ORDER BY data_evento ASC) AS n
FROM eventos
) t
WHERE n = 1
-- Forma do PostgreSQL, mais curta e mais rápida
SELECT DISTINCT ON (id_processo) id_processo, data_evento
FROM eventos
ORDER BY id_processo, data_evento ASC
Troque ASC por DESC e "primeiro" vira "último". É a mesma consulta.
No DISTINCT ON, o ORDER BY precisa começar pelas mesmas colunas do DISTINCT ON. Se não começar, o PostgreSQL devolve uma linha qualquer do grupo — sem erro, sem aviso. É a assinatura que este curso persegue desde a Unidade 3: resultado plausível.
4. O que falta
"Distribuídos e ainda não julgados" não é uma subtração de contagens. É um anti-join:
SELECT d.id_processo
FROM distribuidos d
WHERE NOT EXISTS (
SELECT 1 FROM julgados j
WHERE j.id_processo = d.id_processo
)
Subtrair count(distribuídos) - count(julgados) dá um número que quase sempre está errado, porque os dois conjuntos não são um subconjunto do outro: há processos julgados este ano que foram distribuídos no ano passado. O anti-join responde a pergunta certa; a subtração responde uma pergunta que ninguém fez.
Estoque e fluxo
Esta distinção vale metade do apêndice.
| Fluxo | Estoque | |
|---|---|---|
| Pergunta | quantos aconteceram no período | quantos estão assim no fim do período |
| Exemplo | processos suspensos durante o ano | processos suspensos em 31/12 |
| Filtro de data | BETWEEN inicio AND fim |
< fim, e depois o último evento |
| Muda se eu rodar amanhã? | não | sim |
Um processo suspenso em março e retomado em agosto entra no fluxo de suspensões do ano e não está no estoque de 31/12. Contar os dois com a mesma consulta é o erro mais caro desta família, porque o número sai coerente, sai estável entre execuções, e sai errado.
A regra prática, em SQL:
-- ESTOQUE: último evento de suspensão até o fim do período,
-- e ele tem que ser um evento que SUSPENDE (não um que retoma).
suspensos_no_fim AS (
SELECT DISTINCT ON (id_processo) id_processo, cod_mni_movimento, data_evento
FROM eventos_suspensao -- suspende E retoma, juntos
WHERE data_evento < %(fim)s
ORDER BY id_processo, data_evento DESC
)
SELECT count(*) FROM suspensos_no_fim
WHERE cod_mni_movimento = ANY(%(codigos_que_suspendem)s)
Repare que a varredura precisa trazer os dois tipos de evento — suspensão e retomada — para descobrir qual foi o último. Uma consulta que filtra só os eventos de suspensão nunca fica sabendo da retomada, e devolve como suspenso quem voltou a andar em agosto.
Numa Tool, isso vira uma chave na resposta. "recorte": "estoque em 2026-12-31" ou "recorte": "fluxo em 2026" custa nada, aparece na conversa e impede o modelo de dizer "suspensos" sem dizer qual dos dois. Sem essa chave, você tem dois números diferentes com o mesmo nome — e nenhuma forma de saber qual está na tela.
O gatilho depende da classe
Aqui a regra de negócio invade a consulta, e não tem como evitar.
Um processo entra numa meta quando fica pendente de julgamento. Mas "ficar pendente" só vale se antes tiver acontecido o evento de entrada certo para aquela classe — e o evento certo é diferente conforme o tipo de processo:
| Grupo de classe | Eventos de entrada válidos |
|---|---|
| Processos de conhecimento | distribuição |
| Procedimentos investigatórios | recebimento de denúncia, início de fase, evolução para ação penal |
Um processo de conhecimento cuja pendência foi precedida de "recebimento de denúncia" não vale: é gatilho da família errada. O contrário também.
Duas consequências para quem escreve a Tool:
A validação é uma comparação de datas, não uma presença. Não basta o processo ter o evento de entrada em algum lugar da vida: o evento tem que ser anterior à primeira pendência. Em SQL, WHERE gatilho.data <= pendencia.primeira_data — e é aí que o tijolo 3 é obrigatório dos dois lados.
A lista de códigos não é constante do código. Ela muda quando o CNJ republica a parametrização, e republica a cada poucos meses. Deixe num arquivo de configuração ao lado, não espalhada em if. O agente não precisa saber que a lista existe; você precisa saber onde ela está no dia em que o número mudar sem ninguém ter mexido em nada.
O código que não existe
A armadilha mais silenciosa deste apêndice, e a mais fácil de reproduzir.
Uma consulta escrita para o sistema legado filtra cinco códigos de entrada. Copiada para o eproc, ela roda sem erro nenhum — e devolve menos processos, porque um daqueles cinco códigos não existe no eproc. O evento correspondente simplesmente não é gerado por esse sistema.
Não há mensagem. Não há linha faltando visível. Há um total menor, que ninguém questiona porque ninguém tinha o total certo para comparar.
A defesa é uma consulta de três linhas, rodada antes de confiar em qualquer filtro:
SELECT cod_mni_movimento, count(*) AS n
FROM processo_evento
WHERE cod_mni_movimento = ANY(%(codigos)s)
GROUP BY cod_mni_movimento
ORDER BY cod_mni_movimento
Se um dos códigos que você passou não aparece na saída, ele não existe nessa base. Ou o sistema não o gera, ou o nome da coluna é outro, ou a sua janela de data cortou tudo. Os três casos exigem uma decisão sua; nenhum dos três aparece se você pular esta consulta.
Da mesma família, e igualmente barata:
SELECT count(*) AS total,
count(*) FILTER (WHERE cod_mni_movimento IS NULL) AS sem_movimento,
count(*) FILTER (WHERE data_evento IS NULL) AS sem_data,
min(data_evento) AS mais_antigo,
max(data_evento) AS mais_recente
FROM processo_evento
NULL numa coluna de filtro é linha que some sem avisar — WHERE coluna = 3 descarta os NULL silenciosamente. E o min/max de data responde de graça a pergunta que evita a maior perda de tempo possível: esta base tem os anos que eu preciso?
Conte antes de filtrar. Uma consulta de diagnóstico custa dois minutos; um indicador errado custa a confiança de quem o leu.
Contagem inflada pelo JOIN
O erro mais comum de todos, e o único deste apêndice que dá para provar em dez segundos.
-- ERRADO: conta EVENTOS, não processos
SELECT count(*)
FROM processo p
JOIN processo_evento e ON e.id_processo = p.id_processo
WHERE e.cod_mni_movimento = %(codigo)s
Um processo com quatro sobrestamentos conta quatro vezes. O número sai maior que a realidade, e sai plausível.
-- CERTO: conta PROCESSOS
SELECT count(DISTINCT p.id_processo)
FROM processo p
JOIN processo_evento e ON e.id_processo = p.id_processo
WHERE e.cod_mni_movimento = %(codigo)s
A prova de que você acertou é rodar as duas e olhar a diferença. Se count(*) e count(DISTINCT ...) derem o mesmo número, ótimo — mas confira por quê, porque muitas vezes significa que a sua janela de data está tão estreita que cada processo só tem um evento dentro dela.
Na Tool, isto é a diferença entre "42 processos sobrestados" e "42 sobrestamentos, em 19 processos". A segunda frase é a verdadeira, e é a que o magistrado precisa ouvir.
Uma Tool que responde indicador
Juntando: recorte com sigilo e identidade, tijolo 3 para pegar o primeiro evento, tijolo 4 para o que falta, teto de linhas, e as chaves que impedem o modelo de inventar o contexto.
TETO_LINHAS = 30
@mcp.tool(structured_output=True)
def pendentes_de_julgamento_2grau(ano: int) -> dict:
"""Processos meus distribuídos no ano e ainda não julgados.
Args:
ano: ano de referência, quatro dígitos. Ex.: 2026.
"""
sql = """
WITH elegiveis AS (
SELECT p.id_processo, p.numero_processo, p.classe
FROM processo p
WHERE p.nivel_sigilo = 0
AND p.cod_magistrado = %(magistrado)s
),
distribuidos AS (
SELECT DISTINCT ON (e.id_processo)
e.id_processo, e.data_evento
FROM processo_evento e
JOIN elegiveis g ON g.id_processo = e.id_processo
WHERE e.cod_mni_movimento = ANY(%(cod_entrada)s)
ORDER BY e.id_processo, e.data_evento ASC
),
julgados AS (
SELECT DISTINCT e.id_processo
FROM processo_evento e
WHERE e.cod_mni_movimento = ANY(%(cod_julgamento)s)
AND e.data_evento < %(fim)s
)
SELECT g.numero_processo, g.classe, d.data_evento
FROM distribuidos d
JOIN elegiveis g ON g.id_processo = d.id_processo
WHERE d.data_evento >= %(inicio)s
AND d.data_evento < %(fim)s
AND NOT EXISTS (SELECT 1 FROM julgados j
WHERE j.id_processo = d.id_processo)
ORDER BY d.data_evento
LIMIT %(teto)s
"""
...
return {
"fonte": "eproc 2º grau",
"recorte": f"distribuídos em {ano}, sem julgamento até 31/12/{ano}",
"quantidade": len(linhas),
"truncado": len(linhas) == TETO_LINHAS,
"processos": [...],
}
Três detalhes que não são estilo:
A janela de julgamento não é a janela de distribuição. julgados olha tudo até o fim do período; distribuidos olha só o ano. Um processo distribuído em fevereiro e julgado em novembro do ano seguinte ainda estava pendente em 31/12 — e essa é a resposta certa. Igualar as duas janelas é o erro que faz o número parecer melhor do que é.
"recorte" está escrito por extenso, com as datas. É a frase que o modelo vai repetir. Se ela estiver certa, ele acerta; se estiver ausente, ele inventa uma.
O LIMIT corta a lista, não a contagem. Se a Tool também precisa dizer quantos são, a contagem tem que vir de um count(DISTINCT ...) sem LIMIT — são duas consultas, e é assim mesmo. Devolver len(linhas) como se fosse o total é o mesmo bug do "truncado" do capítulo anterior, com outra roupa.
Antes de acreditar no número
- A consulta de diagnóstico rodou, e todos os códigos que você filtra aparecem na saída com contagem maior que zero?
- Você sabe dizer, sem pensar, se o seu número é estoque ou fluxo — e a Tool diz isso na resposta?
count(*)ecount(DISTINCT id_processo)foram comparados, e você sabe explicar a diferença entre os dois?- As colunas de sigilo e de identidade estão na CTE de recorte, e não espalhadas por cada consulta?
- Alguma senha, host ou usuário aparece escrito em algum arquivo do projeto? (Se sim, isso sai antes de qualquer outra coisa.)
- Um total conhecido — um que você possa conferir na tela do sistema — bate com o que a consulta devolve?
O item 6 é o único que mede. Todos os outros são higiene; esse é prova. Um único processo conferido à mão, do começo ao fim, vale mais do que seis consultas que rodaram sem erro.
Próximo: SEI — o lado administrativo, onde a chave deixa de ser sua outra vez.