portal_publico/projects/project/operadoras/unimed/saude.py

169 lines
6.9 KiB
Python

"""
Unimed (do Estado do Paraná) - Saúde. Mensalidade + Coparticipação.
Diferente da Amil, esta operadora manda os dois tipos de lançamento no
MESMO arquivo — cada linha tem uma coluna "Tipo" dizendo se é
"Mensalidade" ou "Serviços" (= coparticipação). Por isso este parser
devolve indivíduos já separados por tipo, e o main.py gera os dois
arquivos de saída numa única execução.
Formato de entrada: o cliente normalmente manda PDF, mas até hoje só
validamos com o CSV de exemplo (mesmas colunas). A extração de PDF
(`_extrai_pdf`) fica como TODO explícito — não dá pra confiar em um
layout de colunas que a gente nunca viu; precisa de um PDF real da
Unimed pra validar o parsing, do mesmo jeito que fizemos com a Amil.
Particularidades identificadas no CSV de exemplo:
1. NÃO HÁ CPF NESTE ARQUIVO. O casamento com a planilha padrão do
sistema é feito por NOME (chave_casamento = "nome"), usando o
"Id. Titular" do próprio arquivo para saber quem é titular de quem
(evita ambiguidade quando o mesmo nome de dependente aparece em mais
de uma família na planilha do sistema).
2. "Tipo Benef." = '1' é Titular; QUALQUER outro valor (normalmente
'2') é tratado como Dependente — conforme especificado.
3. "Tipo" pode ser "Mensalidade" ou "Serviços" (com espaços sobrando,
ex: "Serviços "). Qualquer outro valor é reportado em auditoria e
NÃO é lançado em nenhum dos dois arquivos de saída, pois não sabemos
classificá-lo.
4. Uma mesma pessoa pode ter várias linhas de "Serviços" (uma por
atendimento/exame) no mês — todas são somadas para chegar no total
de coparticipação do mês daquela pessoa.
"""
import csv
import unicodedata
from typing import Dict, List, Tuple
from core.modelos import Individuo, ItemAuditoria, Lancamento
from operadoras.base import OperadoraParser
def _normaliza_tipo_despesa(tipo_bruto: str) -> str:
"""'Mensalidade' -> 'mensalidade' 'Serviços ' -> 'coparticipacao' outro -> ''"""
t = unicodedata.normalize("NFKD", tipo_bruto.strip())
t = "".join(c for c in t if not unicodedata.combining(c)).lower()
if t == "mensalidade":
return "mensalidade"
if t == "servicos":
return "coparticipacao"
return "" # desconhecido -> tratado como inválido pelo chamador
def _valor_para_float(texto: str) -> float:
"""'257,09' -> 257.09 '1.234,56' -> 1234.56"""
texto = texto.strip().replace(".", "").replace(",", ".")
return float(texto) if texto else 0.0
class UnimedSaude(OperadoraParser):
nome_operadora = "UNIMED"
chave_casamento = "nome" # sem CPF no arquivo da operadora
# ------------------------------------------------------------------
# Extração
# ------------------------------------------------------------------
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
if caminho_arquivo.lower().endswith(".pdf"):
linhas_dict = self._extrai_pdf(caminho_arquivo)
else:
linhas_dict = self._extrai_csv(caminho_arquivo)
lancamentos, auditoria_extracao = self._parseia_lancamentos(linhas_dict)
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
return individuos, auditoria_extracao
def _extrai_csv(self, caminho_csv: str) -> List[Dict[str, str]]:
# O export da Unimed vem em Latin-1 (ISO-8859-1), não UTF-8.
with open(caminho_csv, encoding="latin-1", newline="") as f:
return list(csv.DictReader(f, delimiter=";"))
def _extrai_pdf(self, caminho_pdf: str) -> List[Dict[str, str]]:
raise NotImplementedError(
"Extração de PDF da Unimed ainda não implementada: preciso de um "
"PDF real da Unimed (com o layout de colunas de verdade) para "
"validar o parsing, como foi feito com a Amil. Por ora, use o "
"export em CSV com as mesmas colunas (Id. Titular, Nome "
"Beneficiário, Tipo Benef., Valor, Tipo, ...)."
)
# ------------------------------------------------------------------
# Normalização
# ------------------------------------------------------------------
def _parseia_lancamentos(
self, linhas: List[Dict[str, str]]
) -> Tuple[List[Lancamento], List[ItemAuditoria]]:
lancamentos = []
auditoria = []
for row in linhas:
nome = (row.get("Nome Beneficiário") or "").strip()
if not nome:
continue # linha vazia/rodapé
id_benef = (row.get("Id. Benef.") or "").strip()
id_titular = (row.get("Id. Titular") or "").strip()
tipo_benef = (row.get("Tipo Benef.") or "").strip()
tipo_pessoa = "T" if tipo_benef == "1" else "D"
valor = _valor_para_float(row.get("Valor") or "0")
tipo_lancamento = _normaliza_tipo_despesa(row.get("Tipo") or "")
if not tipo_lancamento:
auditoria.append(ItemAuditoria(
motivo="TIPO_INVALIDO",
numero_beneficiario=id_benef,
nome=nome,
cpf="",
tipo=tipo_pessoa,
valor=valor,
detalhe=(
f"Coluna 'Tipo' com valor não reconhecido: "
f"'{(row.get('Tipo') or '').strip()}'. Não lançado em "
f"nenhum arquivo de saída — revisar manualmente."
),
))
continue
rubrica = (row.get("Descrição Serviço") or tipo_lancamento).strip()
lancamentos.append(Lancamento(
numero_beneficiario=id_benef,
nome=nome,
cpf="",
tipo=tipo_pessoa,
rubrica=rubrica,
valor=valor,
tipo_lancamento=tipo_lancamento,
numero_titular=id_titular,
))
return lancamentos, auditoria
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
"""
Agrupa por (indivíduo, tipo_lancamento): uma pessoa pode aparecer
com um total de mensalidade E um total de coparticipação
separados no mesmo mês.
"""
individuos: Dict[Tuple[str, str], Individuo] = {}
ordem = []
for lc in lancamentos:
chave = (lc.numero_beneficiario, lc.tipo_lancamento)
if chave not in individuos:
individuos[chave] = Individuo(
numero_beneficiario=lc.numero_beneficiario,
nome=lc.nome,
cpf=lc.cpf,
tipo=lc.tipo,
tipo_lancamento=lc.tipo_lancamento,
numero_titular=lc.numero_titular,
)
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]