portal_publico/portal_api/planos_saude/modelos.py

142 lines
5.2 KiB
Python

"""
Modelos de dados usados por todo o pipeline de importação de plano de
saúde, independente da operadora. Porta quase 1:1 de
projects/project/core/modelos.py (ver projects/importacao-planos-saude.skill
para as regras de negócio por trás destas decisões) — a única diferença é
`LinhaSistema` não guardar mais `_linha_original` (não é usado fora do CLI
original) e passar a ter `id` (preenchido pela view a partir da
ImportacaoPlanoSaudeLinha ao editar uma importação já existente).
"""
import unicodedata
from dataclasses import dataclass, field
from typing import Optional
def normaliza_cpf(cpf: str) -> str:
"""Remove pontuação, deixando só os dígitos. Usado como chave de casamento."""
return "".join(ch for ch in cpf if ch.isdigit())
def normaliza_nome(nome: str) -> str:
"""
Normaliza um nome para comparação: maiúsculas, sem acento, sem
espaços duplicados/nas pontas. Usado como chave de casamento quando
a operadora não manda CPF (ex: Unimed) — por isso é estrito por
design: 'SCHAEFER' e 'SCHAFER' são tratados como NOMES DIFERENTES.
Divergências de grafia devem cair em auditoria, nunca ser resolvidas
por aproximação automática.
"""
sem_acento = unicodedata.normalize("NFKD", nome or "")
sem_acento = "".join(c for c in sem_acento if not unicodedata.combining(c))
return " ".join(sem_acento.upper().split())
@dataclass
class Lancamento:
"""Uma linha crua extraída do arquivo da operadora (uma rubrica/serviço)."""
numero_beneficiario: str # identificador do indivíduo no arquivo da operadora
nome: str
cpf: str # pode vir vazio quando a operadora não informa CPF (ex: Unimed)
tipo: str # 'T' = Titular, 'D' = Dependente, 'A' = Agregado
rubrica: str
valor: float # já convertido para float, com sinal (negativo = desconto/devolução)
tipo_lancamento: str = "mensalidade" # 'mensalidade' ou 'coparticipacao'
dependencia: Optional[str] = None # ex: "Conjuge", "Filho(a)", "Pai/Mãe"
numero_titular: Optional[str] = None # id do titular da família no arquivo da operadora
@dataclass
class Individuo:
"""
Um beneficiário (titular ou dependente) já com o valor do mês
agregado PARA UM tipo de lançamento (mensalidade OU coparticipação).
Uma mesma pessoa pode gerar dois objetos Individuo no mesmo mês —
um de cada tipo — quando a operadora manda os dois juntos (ex: Unimed).
"""
numero_beneficiario: str
nome: str
cpf: str
tipo: str
tipo_lancamento: str = "mensalidade"
valor_total: float = 0.0
rubricas: list = field(default_factory=list) # histórico de rubricas somadas
numero_titular: Optional[str] = None # para casamento por família (chave_casamento='nome')
@property
def cpf_normalizado(self) -> str:
return normaliza_cpf(self.cpf)
@property
def nome_normalizado(self) -> str:
return normaliza_nome(self.nome)
@dataclass
class LinhaSistema:
"""Uma linha da planilha padrão do sistema (leiaute de importação)."""
codigo_empresa: str
nome_func: str
cpf_func: str
codigo_out_emp: str
data_inicial: str
nome_dependente: str
cpf_dependente: str
valor_empresa: str
valor: str
descricao: str
def cpf_func_normalizado(self) -> str:
return normaliza_cpf(self.cpf_func)
def cpf_dependente_normalizado(self) -> str:
return normaliza_cpf(self.cpf_dependente) if self.cpf_dependente else ""
def nome_func_normalizado(self) -> str:
return normaliza_nome(self.nome_func)
def nome_dependente_normalizado(self) -> str:
return normaliza_nome(self.nome_dependente) if self.nome_dependente else ""
def eh_linha_titular(self) -> bool:
"""Linha 'do titular': dependente vazio."""
return not self.cpf_dependente.strip() and not self.nome_dependente.strip()
@dataclass
class ItemAuditoria:
motivo: str # 'VALOR_NEGATIVO' | 'NAO_CADASTRADO' | 'NOME_DIVERGENTE' | 'TIPO_INVALIDO'
numero_beneficiario: str
nome: str
cpf: str
tipo: str
valor: float
tipo_lancamento: str = ""
detalhe: str = ""
@dataclass
class VinculoNome:
"""Representação "pura" (sem ORM) de um VinculoNomeOperadora salvo
(portal_api.models) — só os campos que matcher.py precisa pra resolver
automaticamente um nome divergente já confirmado antes, sem esse pacote
(deliberadamente sem ORM) depender do Django. Montado por
ImportacaoPlanoSaudeViewSet.create() (views.py) a partir de uma consulta
real ao banco, e passado adiante até `_casa_por_nome`."""
id: int
nome_func_destino: str
nome_dependente_destino: str
@dataclass
class VinculoAplicado:
"""Registra que um Individuo foi casado com uma LinhaSistema através de
um VinculoNome já salvo (nome divergente, resolvido automaticamente),
não por nome exato — usado por views.py pra gerar um
ImportacaoPlanoSaudeAlteracao (tipo "vinculo_automatico") depois que as
linhas forem persistidas (ainda não têm `id` no momento em que
matcher.py roda)."""
indice_linha: int # índice de `linha` dentro de linhas_sistema, pra esse tipo_lancamento
tipo_lancamento: str
vinculo_id: int
nome_arquivo_operadora: str