""" 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\d{9})\s+(?P.+)$') _CPF_RE = re.compile(r'(\d{11})') _AFTER_CPF_RE = re.compile( r'^(?P.+?)\s+(?P[TDA])\s+(?P\d{1,3})\s*' r'(?P[A-Za-zÀ-ÿ/()]+(?:\s[A-Za-zÀ-ÿ/()]+)?)?\s+' r'(?P\d{2}/\d{2}/\d{4})\s+' r'(?P.+?)\s+' r'(?P\d{1,3}(?:\.\d{3})*,\d{2}-?)\s*' r'(?P\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