348 lines
17 KiB
Python
348 lines
17 KiB
Python
"""Extração do PDF de Balancete + DRE. Dois leiautes: Questor (este módulo) e
|
|
Contabit (`parser_contabit.py`) — `extrai_balancete_dre()` detecta qual é
|
|
pela primeira página e despacha; o resto deste docstring trata do Questor.
|
|
|
|
O texto deste relatório é desenhado caractere a caractere (cada letra/número
|
|
tem sua própria posição X, sem depender de kerning do PDF) e o próprio PDF
|
|
inclui, por baixo do texto real, uma grade densa de caracteres de espaço
|
|
cobrindo a largura inteira de cada linha — isso confunde tanto
|
|
`page.extract_words()` quanto `page.extract_text()` do pdfplumber, que
|
|
tratam esses espaços "de fundo" como separadores de palavra reais e acabam
|
|
quebrando números em dígitos isolados. `_reconstroi_linhas()` contorna isso
|
|
ignorando todo caractere de espaço literal do PDF e reconstruindo a linha
|
|
a partir da posição real dos caracteres não-espaço, reinserindo um único
|
|
espaço só quando o vão horizontal entre dois caracteres consecutivos indica
|
|
uma quebra de campo/palavra de verdade (validado contra
|
|
`792 - balancete 072026.pdf`, ver `plano.md`)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
from typing import IO
|
|
|
|
import pdfplumber
|
|
|
|
from . import parser_contabit
|
|
from .conversao import CodigoEmpresaAusenteError, ExtracaoInvalidaError # noqa: F401 — reexportados
|
|
from .conversao import para_data as _para_data
|
|
from .conversao import para_decimal as _para_decimal
|
|
from .conversao import para_percentual as _para_percentual
|
|
from .modelos import (
|
|
CabecalhoExtraido,
|
|
LinhaAnaliseVerticalExtraida,
|
|
LinhaBalanceteExtraida,
|
|
LinhaDreExtraida,
|
|
ResultadoExtracao,
|
|
ValorMensalAnaliseVertical,
|
|
)
|
|
|
|
# Vão horizontal (em pontos) acima do qual dois caracteres consecutivos são
|
|
# tratados como pertencendo a campos/palavras diferentes. Calibrado contra o
|
|
# relatório real: o vão dentro de uma palavra/número é ~0, entre duas
|
|
# palavras da mesma descrição é ~1.7-1.9, e entre campos da tabela (conta →
|
|
# flag S/A → código → descrição, ou entre colunas de valor) é sempre >= 5.
|
|
_GAP_ESPACO = 0.8
|
|
|
|
_TITULO_DRE = "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO"
|
|
_TITULO_ANALISE_VERTICAL = "Demonstração Mensal (Análise Vertical)"
|
|
|
|
|
|
def _normaliza_titulo(texto: str) -> str:
|
|
"""Remove acento antes de comparar título de seção — confirmado contra
|
|
`1751 - Balancete 07.2026.pdf` (cliente diferente do PDF de referência
|
|
original): a fonte embutida nesse relatório específico perde o til de
|
|
"ÇÃO" na extração ("DEMONSTRAÇÃO" vira "DEMONSTRAÇAO", só falta o til do
|
|
à — o resto do caractere sai certo, não é um replacement character nem
|
|
texto corrompido), o suficiente pra `texto.startswith(_TITULO_DRE)` nunca
|
|
bater e a seção DRE nunca ser detectada (`ExtracaoInvalidaError: Nenhuma
|
|
linha de DRE encontrada`). Como o relatório é sempre gerado pelo mesmo
|
|
sistema (Questor) mas cada cliente/instalação pode embutir uma fonte
|
|
ligeiramente diferente, comparar sem acento é mais robusto do que
|
|
calibrar um título fixo por cliente — mesmo espírito de `_RE_PERIODO`
|
|
já aceitar "Per[ií]odo" pelo mesmo tipo de variação."""
|
|
sem_acento = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode("ascii")
|
|
return sem_acento.upper()
|
|
|
|
|
|
_TITULO_DRE_NORM = _normaliza_titulo(_TITULO_DRE)
|
|
_TITULO_ANALISE_VERTICAL_NORM = _normaliza_titulo(_TITULO_ANALISE_VERTICAL)
|
|
|
|
_RE_MONETARIO = re.compile(r"\(?-?[\d.]+,\d{2}\)?")
|
|
_RE_LINHA_BALANCETE = re.compile(r"^(?P<conta>\d+)\s+(?P<tipo>S)?\s*(?P<codigo>[\d.]+)\s+(?P<resto>.+)$")
|
|
_RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
|
|
_RE_PERIODO = re.compile(r"Per[ií]odo:\s*(\d{2}/\d{2}/\d{4})\s*a\s*(\d{2}/\d{2}/\d{4})")
|
|
_RE_CODIGO_NOME_EMPRESA = re.compile(r"^(?P<codigo>\d+)\s+(?P<nome>.+)$")
|
|
|
|
# Uma linha da Análise Vertical repete N pares "Valor Variação" (um por mês
|
|
# mostrado) em vez do valor único da DRE. O valor às vezes traz um espaço
|
|
# residual antes do parêntese de fechamento (ex. "(552.338,51 )") — artefato
|
|
# do mesmo gerador de relatório, `Decimal()` já tolera esse espaço; a
|
|
# variação é sempre um percentual com "%" colado ao número. Calibrado contra
|
|
# `792 - balancete 072026.pdf` (mesmo arquivo de referência da DRE/Balancete).
|
|
_TOKEN_VALOR_ANALISE_VERTICAL = r"\(?-?[\d.]+,\d{2}\s?\)?"
|
|
_TOKEN_PERCENTUAL_ANALISE_VERTICAL = r"\(?-?[\d.]+,\d{2}\s?%\s?\)?"
|
|
_RE_PAR_VALOR_VARIACAO = re.compile(
|
|
rf"({_TOKEN_VALOR_ANALISE_VERTICAL})\s+({_TOKEN_PERCENTUAL_ANALISE_VERTICAL})"
|
|
)
|
|
# Cabeçalho de mês da Análise Vertical, ex. "mai - 2026 jun - 2026 jul - 2026"
|
|
# — capturado uma única vez (primeira página da seção), as páginas seguintes
|
|
# repetem o mesmo cabeçalho a cada quebra de página.
|
|
_RE_MES_ANALISE_VERTICAL = re.compile(r"([A-Za-z]{3})\s*-\s*(\d{4})")
|
|
|
|
# Trecho (não o texto exato) da linha "(+/-) Despesas/Receitas Financeiras",
|
|
# usado só pelo cálculo de EBIT/EBITDA em dashboard_contabil.indicadores —
|
|
# diferente das duas constantes acima, ainda não foi calibrado contra o
|
|
# texto exato de um PDF real, por isso casa por substring (ver
|
|
# `_busca_linha_por_trecho` em indicadores.py) em vez de igualdade exata.
|
|
LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS = "DESPESAS/RECEITAS FINANCEIRAS"
|
|
|
|
|
|
def _reconstroi_linhas(pagina) -> list[dict]:
|
|
caracteres = [c for c in pagina.chars if c["text"] != " "]
|
|
linhas: dict[float, list] = {}
|
|
for c in caracteres:
|
|
chave = round(c["top"], 0)
|
|
linhas.setdefault(chave, []).append(c)
|
|
|
|
resultado = []
|
|
for topo in sorted(linhas.keys()):
|
|
fileira = sorted(linhas[topo], key=lambda c: c["x0"])
|
|
texto = ""
|
|
anterior_x1 = None
|
|
negrito = False
|
|
for c in fileira:
|
|
if anterior_x1 is not None and (c["x0"] - anterior_x1) > _GAP_ESPACO:
|
|
texto += " "
|
|
texto += c["text"]
|
|
anterior_x1 = c["x1"]
|
|
if "bold" in c["fontname"].lower():
|
|
negrito = True
|
|
resultado.append({"top": topo, "x0": fileira[0]["x0"], "texto": texto, "negrito": negrito})
|
|
return resultado
|
|
|
|
|
|
_RE_CARIMBO_DATA_PAGINA = re.compile(r"^\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2}")
|
|
|
|
|
|
def _extrai_cabecalho(primeiras_linhas: list[str]) -> CabecalhoExtraido:
|
|
texto_completo = " ".join(primeiras_linhas)
|
|
|
|
match_cnpj = _RE_CNPJ.search(texto_completo)
|
|
match_periodo = _RE_PERIODO.search(texto_completo)
|
|
if not match_cnpj or not match_periodo:
|
|
raise ExtracaoInvalidaError("Não foi possível localizar CNPJ/período no cabeçalho do PDF.")
|
|
|
|
# As linhas reconstruídas vêm ordenadas por posição vertical (`top`), não
|
|
# por ordem de leitura — nesta página, o carimbo "data/hora Pág:NNNN" (que
|
|
# fica alinhado à direita) cai *entre* as duas linhas do nome da empresa
|
|
# (que pode quebrar em duas linhas quando é longo). Só as linhas antes de
|
|
# "CNPJ:" que não são esse carimbo nem "Período:" compõem o nome.
|
|
linhas_nome = []
|
|
for linha in primeiras_linhas:
|
|
if linha.startswith("CNPJ:"):
|
|
break
|
|
if _RE_CARIMBO_DATA_PAGINA.match(linha) or linha.startswith("Período:"):
|
|
continue
|
|
linhas_nome.append(linha)
|
|
match_nome = _RE_CODIGO_NOME_EMPRESA.match(" ".join(linhas_nome).strip())
|
|
if not match_nome:
|
|
raise ExtracaoInvalidaError("Não foi possível localizar código/nome da empresa no cabeçalho do PDF.")
|
|
|
|
return CabecalhoExtraido(
|
|
codigo_empresa=match_nome.group("codigo"),
|
|
nome_empresa=match_nome.group("nome").strip(),
|
|
cnpj=match_cnpj.group(1),
|
|
periodo_inicio=_para_data(match_periodo.group(1)),
|
|
periodo_fim=_para_data(match_periodo.group(2)),
|
|
)
|
|
|
|
|
|
def extrai_balancete_dre(origem: str | IO[bytes], nome_arquivo: str | None = None) -> ResultadoExtracao:
|
|
"""Lê o PDF combinado de Balancete + DRE e devolve tudo já estruturado.
|
|
Um PDF do Contabit (detectado pela primeira página) vai para
|
|
`parser_contabit.extrai()`, que usa `nome_arquivo` pra obter o código da
|
|
empresa (o PDF não traz); o Questor segue abaixo, lendo o código do
|
|
próprio PDF e ignorando `nome_arquivo`.
|
|
|
|
`origem` aceita tanto um caminho em disco quanto um
|
|
arquivo já aberto em memória (`io.BytesIO`) — a view chama isto direto
|
|
sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos
|
|
daqui), então ainda não há um caminho em disco definitivo nesse momento.
|
|
As páginas finais de "Demonstração Mensal (Análise Vertical)", quando
|
|
presentes, também são extraídas (`linhas_analise_vertical`/
|
|
`meses_analise_vertical`) — é a mesma árvore da DRE, só que com um
|
|
valor+percentual por mês em vez de um valor único; **não** substitui o
|
|
histórico próprio do Portal usado pelas regras de variação mês a mês
|
|
(`regras.py` continua comparando contra apurações anteriores já
|
|
persistidas, não contra esta seção), só alimenta a aba/relatório
|
|
"Análise Vertical" com os mesmos meses que o próprio Questor já calcula."""
|
|
|
|
cabecalho: CabecalhoExtraido | None = None
|
|
contas: list[LinhaBalanceteExtraida] = []
|
|
linhas_dre: list[LinhaDreExtraida] = []
|
|
linhas_analise_vertical: list[LinhaAnaliseVerticalExtraida] = []
|
|
meses_analise_vertical: list[str] | None = None
|
|
|
|
secao = "cabecalho"
|
|
ordem_dre = 0
|
|
ordem_av = 0
|
|
base_x0_dre: float | None = None
|
|
base_x0_av: float | None = None
|
|
# Ver ResultadoExtracao.fonte_pdf_atipica — só vira True se a comparação
|
|
# SEM acento (texto_norm) bateu onde a comparação COM acento (texto) não
|
|
# bateria, ou seja, a normalização foi realmente necessária pra este
|
|
# arquivo, não só uma checagem redundante.
|
|
fonte_pdf_atipica = False
|
|
|
|
with pdfplumber.open(origem) as pdf:
|
|
if not pdf.pages:
|
|
raise ExtracaoInvalidaError("O PDF não tem páginas.")
|
|
if parser_contabit.eh_leiaute_contabit(pdf.pages[0]):
|
|
return parser_contabit.extrai(pdf, nome_arquivo)
|
|
|
|
primeiras_linhas_pagina1 = [
|
|
linha["texto"] for linha in _reconstroi_linhas(pdf.pages[0]) if linha["texto"].strip()
|
|
][:8]
|
|
cabecalho = _extrai_cabecalho(primeiras_linhas_pagina1)
|
|
|
|
for pagina in pdf.pages:
|
|
for linha in _reconstroi_linhas(pagina):
|
|
texto = linha["texto"].strip()
|
|
if not texto:
|
|
continue
|
|
# Comparado sem acento (ver _normaliza_titulo) — a fonte
|
|
# embutida varia entre PDFs de clientes/instalações
|
|
# diferentes do Questor e pode perder um til/acento
|
|
# específico sem corromper o resto do texto.
|
|
texto_norm = _normaliza_titulo(texto)
|
|
|
|
if texto_norm.startswith(_TITULO_ANALISE_VERTICAL_NORM):
|
|
if not texto.startswith(_TITULO_ANALISE_VERTICAL):
|
|
fonte_pdf_atipica = True
|
|
if secao != "analise_vertical":
|
|
secao = "analise_vertical"
|
|
base_x0_av = None
|
|
continue
|
|
# Guarda só contra cabeçalho/balancete pra não disparar de
|
|
# novo quando o título da DRE se repete no topo de cada
|
|
# página já dentro da seção "dre" (comportamento de sempre)
|
|
# ou da Análise Vertical, que também reimprime esse título —
|
|
# sem essa restrição, `secao` voltaria de "analise_vertical"
|
|
# pra "dre" a cada quebra de página da Análise Vertical.
|
|
if secao in ("cabecalho", "balancete") and texto_norm.startswith(_TITULO_DRE_NORM):
|
|
if not texto.startswith(_TITULO_DRE):
|
|
fonte_pdf_atipica = True
|
|
secao = "dre"
|
|
base_x0_dre = None
|
|
continue
|
|
|
|
if secao in ("cabecalho", "balancete"):
|
|
match = _RE_LINHA_BALANCETE.match(texto)
|
|
if not match:
|
|
continue
|
|
secao = "balancete"
|
|
valores = _RE_MONETARIO.findall(match.group("resto"))
|
|
if len(valores) < 4:
|
|
continue
|
|
saldo_anterior, debito, credito, saldo_atual = valores[-4:]
|
|
# Descrição = tudo antes do início do bloco de 4 valores
|
|
# (sempre exatamente 4 colunas por linha de balancete).
|
|
posicao_valores = match.group("resto").find(saldo_anterior)
|
|
if posicao_valores == -1:
|
|
continue
|
|
descricao = match.group("resto")[:posicao_valores].strip()
|
|
contas.append(
|
|
LinhaBalanceteExtraida(
|
|
conta_numero=int(match.group("conta")),
|
|
codigo=match.group("codigo"),
|
|
descricao=descricao,
|
|
tipo=match.group("tipo") or "A",
|
|
saldo_anterior=_para_decimal(saldo_anterior),
|
|
debito=_para_decimal(debito),
|
|
credito=_para_decimal(credito),
|
|
saldo_atual=_para_decimal(saldo_atual),
|
|
)
|
|
)
|
|
elif secao == "dre":
|
|
if texto.startswith("Valores expressos") or texto_norm.startswith(_TITULO_DRE_NORM):
|
|
continue
|
|
valores = _RE_MONETARIO.findall(texto)
|
|
if not valores:
|
|
continue
|
|
valor_texto = valores[-1]
|
|
posicao_valor = texto.rfind(valor_texto)
|
|
descricao = texto[:posicao_valor].strip()
|
|
if not descricao:
|
|
continue
|
|
if base_x0_dre is None:
|
|
base_x0_dre = linha["x0"]
|
|
nivel = max(0, round((linha["x0"] - base_x0_dre) / 7.0))
|
|
ordem_dre += 1
|
|
linhas_dre.append(
|
|
LinhaDreExtraida(
|
|
ordem=ordem_dre,
|
|
descricao=descricao,
|
|
nivel=nivel,
|
|
valor=_para_decimal(valor_texto),
|
|
totalizador=linha["negrito"],
|
|
)
|
|
)
|
|
elif secao == "analise_vertical":
|
|
# O cabeçalho de mês ("mai - 2026 jun - 2026 jul - 2026")
|
|
# só existe uma vez de verdade — repete a cada quebra de
|
|
# página, mas só a primeira ocorrência importa (captura
|
|
# os meses e não é tratada como linha de dado, já que
|
|
# não tem par valor+variação nenhum). Qualquer outra
|
|
# linha sem nenhum par (título repetido, cabeçalho de
|
|
# coluna "Descrição/Valor/Variação") é ignorada do mesmo
|
|
# jeito que a DRE ignora linhas sem valor monetário.
|
|
if meses_analise_vertical is None:
|
|
candidatos_mes = _RE_MES_ANALISE_VERTICAL.findall(texto)
|
|
if candidatos_mes:
|
|
meses_analise_vertical = [
|
|
f"{mes.lower()}/{ano}" for mes, ano in candidatos_mes
|
|
]
|
|
continue
|
|
pares = _RE_PAR_VALOR_VARIACAO.findall(texto)
|
|
if not pares:
|
|
continue
|
|
posicao = texto.find(pares[0][0])
|
|
if posicao == -1:
|
|
continue
|
|
descricao = texto[:posicao].strip()
|
|
if not descricao:
|
|
continue
|
|
if base_x0_av is None:
|
|
base_x0_av = linha["x0"]
|
|
nivel = max(0, round((linha["x0"] - base_x0_av) / 7.0))
|
|
ordem_av += 1
|
|
linhas_analise_vertical.append(
|
|
LinhaAnaliseVerticalExtraida(
|
|
ordem=ordem_av,
|
|
descricao=descricao,
|
|
nivel=nivel,
|
|
totalizador=linha["negrito"],
|
|
valores=[
|
|
ValorMensalAnaliseVertical(
|
|
valor=_para_decimal(valor_texto), percentual=_para_percentual(percentual_texto)
|
|
)
|
|
for valor_texto, percentual_texto in pares
|
|
],
|
|
)
|
|
)
|
|
|
|
if not contas:
|
|
raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.")
|
|
if not linhas_dre:
|
|
raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.")
|
|
|
|
return ResultadoExtracao(
|
|
cabecalho=cabecalho,
|
|
contas=contas,
|
|
linhas_dre=linhas_dre,
|
|
meses_analise_vertical=meses_analise_vertical or [],
|
|
linhas_analise_vertical=linhas_analise_vertical,
|
|
fonte_pdf_atipica=fonte_pdf_atipica,
|
|
)
|