131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
"""
|
|
Amil Odonto - Mensalidade.
|
|
|
|
Formato recebido: PDF "Demonstrativo Analítico de Faturamento - Por
|
|
Contrato / Empresa". Esta operadora normalmente não traz coparticipação,
|
|
só mensalidade — mas a arquitetura já está pronta para o dia em que
|
|
precisarmos ler coparticipação de outra operadora (basta criar uma classe
|
|
irmã, ex: operadoras/amil/odonto_coparticipacao.py).
|
|
|
|
Particularidades deste relatório (descobertas inspecionando o PDF real):
|
|
|
|
1. A coluna "Valor Total" é só um subtotal visual de bloco (titular +
|
|
agregados agrupados na exibição) — NÃO é o valor do indivíduo.
|
|
Ignoramos essa coluna e trabalhamos com a coluna "Valor", linha a linha.
|
|
|
|
2. Um mesmo beneficiário (mesmo N° Beneficiário + CPF) pode aparecer em
|
|
várias linhas seguidas, uma por rubrica (ex: mensalidade normal +
|
|
desconto + cobrança retroativa). Nesses casos, SOMAMOS o valor de
|
|
todas as linhas daquele indivíduo para chegar no valor do mês.
|
|
|
|
3. Valores negativos vêm com um "-" DEPOIS do número (ex: "51,69-"),
|
|
não antes.
|
|
|
|
4. Tp. = 'T' (Titular), 'D' (Dependente direto) ou 'A' (Agregado/avulso
|
|
— pai/mãe, irmãos). No leiaute do sistema, D e A são tratados da
|
|
mesma forma (linha de dependente comum).
|
|
|
|
A extração de texto usa pdfplumber com `layout=True` para preservar o
|
|
alinhamento das colunas (mesmo efeito do antigo `pdftotext -layout`, mas
|
|
sem depender de um binário externo do poppler instalado no servidor).
|
|
"""
|
|
import re
|
|
from typing import List, Tuple
|
|
|
|
import pdfplumber
|
|
|
|
from core.modelos import Individuo, ItemAuditoria, Lancamento
|
|
from operadoras.base import OperadoraParser
|
|
|
|
_ROW_RE = re.compile(r'^\s*(?P<numero>\d{9})\s+(?P<resto>.+)$')
|
|
_CPF_RE = re.compile(r'(\d{11})')
|
|
_AFTER_CPF_RE = re.compile(
|
|
r'^(?P<plano>.+?)\s+(?P<tp>[TDA])\s+(?P<idade>\d{1,3})\s*'
|
|
r'(?P<dependencia>[A-Za-zÀ-ÿ/()]+(?:\s[A-Za-zÀ-ÿ/()]+)?)?\s+'
|
|
r'(?P<data>\d{2}/\d{2}/\d{4})\s+'
|
|
r'(?P<rubrica>.+?)\s+'
|
|
r'(?P<valor>\d{1,3}(?:\.\d{3})*,\d{2}-?)\s*'
|
|
r'(?P<valor_total>\d{1,3}(?:\.\d{3})*,\d{2}-?)?$'
|
|
)
|
|
|
|
|
|
def _valor_para_float(texto: str) -> float:
|
|
"""'51,69' -> 51.69 '51,69-' -> -51.69 '1.234,56' -> 1234.56"""
|
|
negativo = texto.endswith('-')
|
|
texto = texto.rstrip('-').replace('.', '').replace(',', '.')
|
|
valor = float(texto)
|
|
return -valor if negativo else valor
|
|
|
|
|
|
class AmilOdontoMensalidade(OperadoraParser):
|
|
nome_operadora = "AMIL"
|
|
chave_casamento = "cpf" # Amil manda CPF de todo mundo
|
|
|
|
def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]:
|
|
"""
|
|
Extrai o texto preservando o layout de colunas (equivalente ao
|
|
antigo `pdftotext -layout`, mas em Python puro via pdfplumber —
|
|
sem depender de binário externo do poppler no servidor).
|
|
"""
|
|
linhas: List[str] = []
|
|
with pdfplumber.open(caminho_pdf) as pdf:
|
|
for page in pdf.pages:
|
|
texto = page.extract_text(layout=True, x_density=6, y_density=10) or ""
|
|
linhas.extend(texto.splitlines())
|
|
return linhas
|
|
|
|
def _parseia_lancamentos(self, linhas: List[str]) -> List[Lancamento]:
|
|
lancamentos = []
|
|
for linha in linhas:
|
|
m = _ROW_RE.match(linha)
|
|
if not m:
|
|
continue
|
|
resto = m.group('resto')
|
|
cpf_m = _CPF_RE.search(resto)
|
|
if not cpf_m:
|
|
continue # não é uma linha de dado (cabeçalho, totais, etc.)
|
|
cpf = cpf_m.group(1)
|
|
resto_pos_cpf = resto[cpf_m.end():].strip()
|
|
m2 = _AFTER_CPF_RE.match(resto_pos_cpf)
|
|
if not m2:
|
|
continue
|
|
|
|
nome_bruto = resto[:cpf_m.start()].strip()
|
|
# remove "Matr Funcional" (número solto que às vezes aparece
|
|
# entre o nome e o CPF)
|
|
nome = re.sub(r'\s+\d+\s*$', '', nome_bruto).strip()
|
|
|
|
lancamentos.append(Lancamento(
|
|
numero_beneficiario=m.group('numero'),
|
|
nome=nome,
|
|
cpf=cpf,
|
|
tipo=m2.group('tp'),
|
|
rubrica=m2.group('rubrica').strip(),
|
|
valor=_valor_para_float(m2.group('valor')),
|
|
dependencia=m2.group('dependencia'),
|
|
))
|
|
return lancamentos
|
|
|
|
def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
|
|
individuos = {}
|
|
ordem = []
|
|
for lc in lancamentos:
|
|
chave = lc.numero_beneficiario
|
|
if chave not in individuos:
|
|
individuos[chave] = Individuo(
|
|
numero_beneficiario=lc.numero_beneficiario,
|
|
nome=lc.nome,
|
|
cpf=lc.cpf,
|
|
tipo=lc.tipo,
|
|
)
|
|
ordem.append(chave)
|
|
individuos[chave].valor_total += lc.valor
|
|
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
|
|
return [individuos[c] for c in ordem]
|
|
|
|
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
|
|
linhas = self._pdf_para_linhas(caminho_arquivo)
|
|
lancamentos = self._parseia_lancamentos(linhas)
|
|
individuos = self._agrega_por_individuo(lancamentos)
|
|
return individuos, [] # Amil não gera itens de auditoria na extração
|