169 lines
6.9 KiB
Python
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]
|