""" 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]