261 lines
14 KiB
Python
261 lines
14 KiB
Python
"""
|
||
Regras de custeio especiais por empresa ("Regra especial da empresa" na tela
|
||
de nova importação/Cadastro de Regras) — ao contrário de
|
||
RegraCusteioPlanoSaude (banco de regras salvas, editável pelo usuário,
|
||
sempre no formato "por tipo de lançamento × titular/dependente"), as regras
|
||
aqui são cadastradas diretamente no código pelo desenvolvedor quando o
|
||
cliente repassa uma regra negociada com uma empresa específica que não se
|
||
encaixa nesse formato — tipicamente porque são calculadas por FAMÍLIA
|
||
(titular + todos os dependentes juntos), não por pessoa, ou porque o
|
||
critério de quem paga o quê não é um percentual/teto simples. Nunca
|
||
expostas para o usuário cadastrar pela tela; só "Selecionar regra" no
|
||
Cadastro de Regras, que lista as chaves já registradas aqui
|
||
(GET /api/importacoes-plano-saude/regras-empresa/).
|
||
|
||
Cada regra declara, além da função `aplica`, quais tipos de lançamento ela
|
||
cobre (`tipos_lancamento` — nem toda regra cobre os dois; a Tecnomyl abaixo
|
||
só cobre "mensalidade", coparticipação dela segue o custeio normal
|
||
configurado na importação) e qual estratégia de casamento ela exige
|
||
(`chave_casamento` — "nome" pra regras que dependem de agrupar uma família
|
||
inteira, como a Tecnomyl; "cpf" pra regras que decidem por pessoa, sem
|
||
precisar de família, como a Ottimizza abaixo). `aplica(linhas_e_valores,
|
||
tipo_lancamento)` recebe sempre os dois argumentos — o segundo permite que
|
||
uma mesma regra se comporte diferente por tipo de lançamento (ex.: a
|
||
Ottimizza abaixo aplica um critério pra mensalidade e outro, fixo, pra
|
||
coparticipação).
|
||
"""
|
||
from typing import Any, Callable, Dict, List, Tuple
|
||
|
||
from portal_api.planos_saude.leiaute_sistema import formata_valor_br
|
||
from portal_api.planos_saude.modelos import LinhaSistema
|
||
|
||
|
||
class RegraEmpresaIncompativelError(Exception):
|
||
"""Regra empresa selecionada não é compatível com esta importação
|
||
(tipo de lançamento não coberto pela regra, operadora com estratégia de
|
||
casamento diferente da que a regra exige, ou planilha padrão de uma
|
||
empresa diferente daquela pra qual a regra foi cadastrada) — views.py
|
||
devolve esta mensagem direto pro usuário, em vez do erro genérico de
|
||
"formato de arquivo não conforme"."""
|
||
|
||
|
||
def _eh_linha_titular(linha: Any) -> bool:
|
||
"""Mesmo critério de `LinhaSistema.eh_linha_titular()`, mas duck-typed —
|
||
as regras abaixo rodam tanto contra `LinhaSistema` (pipeline, na
|
||
criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model
|
||
Django, no recálculo pós "Vincular pessoa" — ver
|
||
`views._recalcula_familia_regra_empresa`), que não tem esse método."""
|
||
return not (linha.nome_dependente or "").strip() and not (linha.cpf_dependente or "").strip()
|
||
|
||
|
||
def _aplica_teto_familia(linhas_e_valores: List[Tuple[Any, float]], teto: float) -> None:
|
||
"""Divide o teto de custeio da empresa (`teto`) entre as linhas de UMA
|
||
família (titular + dependentes) — decisão explícita do cliente:
|
||
DEPENDENTES TÊM PRIORIDADE no uso do teto (são cobertos primeiro, na
|
||
ordem em que aparecem — cada um recebe `min(seu valor, o que sobrou do
|
||
teto)`), e o TITULAR absorve por último o que sobrar do teto (o
|
||
"residual"). Se os dependentes sozinhos já consumirem o teto inteiro, o
|
||
titular fica com desconto integral (`valor_empresa=0`) e, se ainda
|
||
sobrar dependente sem cobrir depois disso, esse dependente também é
|
||
parcialmente descontado — ao contrário de uma divisão proporcional
|
||
(tentada numa primeira versão e revertida): o pedido do cliente é
|
||
"abater primeiro o valor dos dependentes", não repartir o teto
|
||
igualmente entre todos."""
|
||
dependentes = [(linha, valor) for linha, valor in linhas_e_valores if not _eh_linha_titular(linha)]
|
||
titulares = [(linha, valor) for linha, valor in linhas_e_valores if _eh_linha_titular(linha)]
|
||
|
||
teto_restante = teto
|
||
for linha, valor in dependentes + titulares:
|
||
valor = max(0.0, valor)
|
||
valor_empresa = round(min(valor, max(0.0, teto_restante)), 2)
|
||
valor_empregado = round(valor - valor_empresa, 2)
|
||
teto_restante = round(teto_restante - valor_empresa, 2)
|
||
linha.valor_empresa = formata_valor_br(valor_empresa)
|
||
linha.valor = formata_valor_br(valor_empregado)
|
||
|
||
|
||
def _regra_unimed_1778_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
|
||
"""Tecnomyl (código 1778 na Unimed) — ajuda de custo de até R$ 661,61
|
||
por família (titular + dependentes juntos, não por pessoa), repassada
|
||
pelo cliente em 08/2026: família com mensalidade total acima do teto
|
||
tem o excedente descontado do empregado; igual ou abaixo do teto, a
|
||
empresa cobre 100% e o empregado não paga nada. Só cobre "mensalidade"
|
||
(ver `tipos_lancamento` no registro abaixo) — `tipo_lancamento` nunca
|
||
varia de fato aqui, mas o parâmetro é obrigatório pra toda regra."""
|
||
_aplica_teto_familia(linhas_e_valores, teto=661.61)
|
||
|
||
|
||
def _regra_amil_898_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
|
||
"""Amil Odonto (código 898) na Tecnomyl, repassada pelo cliente em
|
||
09/2026: a empresa custeia 100% da mensalidade de titular e dependente
|
||
direto (cônjuge, filho(a)); agregados — avós, tios, sobrinhos, sogros e
|
||
similares — têm a mensalidade 100% descontada do empregado, no MESMO
|
||
valor (a regra é só sobre quem paga, não sobre um teto/percentual, por
|
||
isso cobre os dois planos do contrato — E200 a R$ 20,81/pessoa e
|
||
"DENTAL 200" a R$ 16,01/pessoa — sem precisar de nenhum valor fixo
|
||
aqui). Confirmado contra o arquivo real da competência 08/2026: todo
|
||
titular/dependente/agregado paga exatamente o mesmo valor por pessoa
|
||
dentro do mesmo plano, e a discriminação titular/dependente/agregado do
|
||
relatório (coluna "Tipo": T/D/A) bate exatamente com os exemplos do
|
||
cliente (avós, sogros, sobrinhos = agregado).
|
||
|
||
Ao contrário da Tecnomyl-Unimed (`_aplica_teto_familia`, um teto por
|
||
família), esta regra é por PESSOA, sem nenhuma interação entre membros
|
||
da família — usa `linha.tipo_pessoa` ('T'/'D'/'A', gravado pelo
|
||
matcher no momento do casamento a partir do Individuo.tipo original do
|
||
arquivo da operadora — ver `LinhaSistema.tipo_pessoa` — já que a
|
||
planilha padrão do sistema não distingue dependente de agregado por si
|
||
só). Uma linha sem `tipo_pessoa` preenchido (nunca passou pelo
|
||
casamento automático — ex.: incluída manualmente via "Adicionar linha")
|
||
cai no fallback "D": custeada pela empresa, o mesmo comportamento que
|
||
já valeria pra ela antes desta regra existir."""
|
||
for linha, valor in linhas_e_valores:
|
||
valor = max(0.0, valor)
|
||
tipo_pessoa = getattr(linha, "tipo_pessoa", "") or "D"
|
||
eh_agregado = tipo_pessoa == "A"
|
||
linha.valor_empresa = formata_valor_br(0.0 if eh_agregado else valor)
|
||
linha.valor = formata_valor_br(valor if eh_agregado else 0.0)
|
||
|
||
|
||
def _regra_sulamerica_5775_ottimizza(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
|
||
"""Ottimizza (código 1889) na SulAmérica (5775) — critério fixo, sem
|
||
depender de teto/percentual: a mensalidade do titular é 100% custeada
|
||
pela empresa e a do dependente é 100% descontada do empregado; toda
|
||
coparticipação (titular ou dependente) é 100% descontada do empregado.
|
||
Confirmado contra a planilha "Informações Plano de Saúde" real da
|
||
Ottimizza (competência 08/2026): toda linha de titular só vem com
|
||
"Benefício Mensalidade" preenchido, toda linha de dependente só vem com
|
||
"Desconto Mensalidade", e "Benefício Coparticipação" nunca aparece
|
||
preenchido em nenhuma linha (só "Desconto Coparticipação") — mesmo
|
||
critério, só que aplicado pelo Portal em vez de lido campo a campo da
|
||
planilha (ver `operadoras/sulamerica/saude.py`, que só soma os dois
|
||
valores de cada tipo e deixa a divisão pra esta regra)."""
|
||
custeada_pela_empresa = tipo_lancamento == "mensalidade"
|
||
for linha, valor in linhas_e_valores:
|
||
valor = max(0.0, valor)
|
||
eh_titular = custeada_pela_empresa and _eh_linha_titular(linha)
|
||
valor_empresa = valor if eh_titular else 0.0
|
||
valor_empregado = 0.0 if eh_titular else valor
|
||
linha.valor_empresa = formata_valor_br(valor_empresa)
|
||
linha.valor = formata_valor_br(valor_empregado)
|
||
|
||
|
||
# Pra cadastrar uma regra nova: escrever a função `_regra_...(linhas_e_valores,
|
||
# tipo_lancamento)` acima (ou reaproveitar `_aplica_teto_familia` se for só
|
||
# um teto por família) e registrar aqui. `codigo_empresa` é o código da
|
||
# empresa na planilha padrão pra qual a regra foi negociada — usado só pra
|
||
# travar contra aplicar a regra errada numa planilha de outra empresa (ver
|
||
# `valida_regra_empresa` abaixo). `chave_casamento` é a estratégia que a
|
||
# OPERADORA precisa usar pra esta regra fazer sentido ("nome" pra regras que
|
||
# agrupam família — a função só recebe as linhas de UMA família por vez;
|
||
# "cpf" pra regras por pessoa, sem agrupamento — a função recebe TODAS as
|
||
# linhas casadas do tipo de uma vez). `tipos_lancamento` é a tupla de tipos
|
||
# que esta regra cobre — os demais tipos selecionados na importação seguem
|
||
# o custeio normal configurado (`custeio_por_tipo`), sem relação com a
|
||
# regra. `observacoes` é opcional (texto livre explicando a regra em
|
||
# português) — exibida só-leitura no topo da tela de revisão (ver
|
||
# "regra_empresa_observacoes" em ImportacaoPlanoSaudeDetailSerializer) pra o
|
||
# colaborador conferir a regra aplicada sem precisar abrir o código.
|
||
REGRAS_EMPRESA: Dict[str, dict] = {
|
||
"unimed_1778_tecnomyl": {
|
||
"label": "1778 - Unimed (Tecnomyl)",
|
||
"codigo_empresa": "1778",
|
||
"operadora": "unimed_saude",
|
||
"chave_casamento": "nome",
|
||
"tipos_lancamento": ("mensalidade",),
|
||
"aplica": _regra_unimed_1778_tecnomyl,
|
||
"observacoes": (
|
||
"A Tecnomyl oferece uma ajuda de custo de até R$ 661,61 por família "
|
||
"(titular + dependentes juntos, independente da quantidade de "
|
||
"dependentes). Os dependentes são custeados primeiro; o titular "
|
||
"absorve o valor residual do teto, e o excedente (se houver) é "
|
||
"descontado do empregado. Coparticipação segue o custeio normal "
|
||
"configurado nesta importação, sem relação com esta regra."
|
||
),
|
||
},
|
||
"amil_898_tecnomyl": {
|
||
"label": "1778 - Amil (Tecnomyl)",
|
||
"codigo_empresa": "1778",
|
||
"operadora": "amil_odonto_mensalidade_898",
|
||
"chave_casamento": "cpf",
|
||
"tipos_lancamento": ("mensalidade",),
|
||
"aplica": _regra_amil_898_tecnomyl,
|
||
"observacoes": (
|
||
"A Tecnomyl arca com o valor integral da mensalidade de titular e "
|
||
"dependente direto (cônjuge, filho(a)). Agregados — avós, tios, "
|
||
"sobrinhos, sogros e similares — têm a mensalidade 100% "
|
||
"descontada do empregado, no mesmo valor por pessoa. Cobre os "
|
||
"planos do contrato (E200 e DENTAL 200), cada um no seu próprio "
|
||
"valor de mensalidade."
|
||
),
|
||
},
|
||
"sulamerica_5775_ottimizza": {
|
||
"label": "1889 - SulAmérica (5775)",
|
||
"codigo_empresa": "1889",
|
||
"operadora": "sulamerica_saude",
|
||
"chave_casamento": "cpf",
|
||
"tipos_lancamento": ("mensalidade", "coparticipacao"),
|
||
"aplica": _regra_sulamerica_5775_ottimizza,
|
||
"observacoes": (
|
||
"A Ottimizza tem um critério fixo na SulAmérica: a mensalidade do "
|
||
"titular é 100% custeada pela empresa, a do dependente é 100% "
|
||
"descontada do empregado, e toda coparticipação (titular ou "
|
||
"dependente) é 100% descontada do empregado."
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
def lista_regras_empresa() -> List[Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"key": chave,
|
||
"label": dados["label"],
|
||
"observacoes": dados.get("observacoes", ""),
|
||
"tipos_lancamento": list(dados.get("tipos_lancamento", ("mensalidade",))),
|
||
}
|
||
for chave, dados in REGRAS_EMPRESA.items()
|
||
]
|
||
|
||
|
||
def valida_regra_empresa(
|
||
regra_empresa_key: str,
|
||
chave_casamento_por_tipo: Callable[[str], str],
|
||
linhas_sistema: List[LinhaSistema],
|
||
tipos_selecionados: List[str],
|
||
) -> Tuple[Callable[[List[Tuple[Any, float]], str], None], Tuple[str, ...]]:
|
||
"""Valida que a regra empresa escolhida pode ser aplicada nesta
|
||
importação e devolve `(aplica, tipos_cobertos)` já resolvidos —
|
||
`tipos_cobertos` é a interseção entre o que a regra cobre e o que foi
|
||
selecionado nesta importação (pode ser um subconjunto: uma regra que
|
||
cobre mensalidade+coparticipação numa importação que só selecionou
|
||
mensalidade só se aplica à mensalidade). Chamado por
|
||
pipeline.processa_importacao antes do casamento."""
|
||
regra = REGRAS_EMPRESA.get(regra_empresa_key)
|
||
if regra is None:
|
||
raise RegraEmpresaIncompativelError(f"Regra empresa desconhecida: {regra_empresa_key!r}.")
|
||
|
||
tipos_da_regra = regra.get("tipos_lancamento", ("mensalidade",))
|
||
tipos_cobertos = tuple(t for t in tipos_da_regra if t in tipos_selecionados)
|
||
if not tipos_cobertos:
|
||
raise RegraEmpresaIncompativelError(
|
||
f"A regra \"{regra['label']}\" cobre {'/'.join(tipos_da_regra)}, mas nenhum desses "
|
||
"tipos de importação está selecionado."
|
||
)
|
||
|
||
casamento_exigido = regra.get("chave_casamento", "nome")
|
||
for tipo in tipos_cobertos:
|
||
if chave_casamento_por_tipo(tipo) != casamento_exigido:
|
||
raise RegraEmpresaIncompativelError(
|
||
f"Esta regra especial exige casamento por {casamento_exigido!r} "
|
||
f"— esta operadora não é compatível para o tipo {tipo!r}."
|
||
)
|
||
|
||
codigo_esperado = regra.get("codigo_empresa")
|
||
if codigo_esperado and not any(l.codigo_empresa == codigo_esperado for l in linhas_sistema):
|
||
raise RegraEmpresaIncompativelError(
|
||
f"A regra \"{regra['label']}\" foi cadastrada para a empresa código {codigo_esperado}, "
|
||
"mas a planilha padrão anexada não tem nenhuma linha com esse código."
|
||
)
|
||
return regra["aplica"], tipos_cobertos
|