portal_publico/projects/project/operadoras/amil/odonto_mensalidade.py

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