diff --git a/CLAUDE.md b/CLAUDE.md index 88247bb..4c99de6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -568,7 +568,8 @@ portal_api/planos_saude/ ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) ├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo) - └── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) + ├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) + └── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo) ``` Pra adicionar uma operadora nova: criar `operadoras//.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. @@ -581,6 +582,8 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme **SulAmérica Odonto (4726)** — um único PDF, sem coparticipação (relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto" da própria operadora — é só uma cobrança fixa periódica por beneficiário, não há linha de serviço/atendimento nenhuma). Ao contrário das Unimeds, este relatório traz **CPF de todo mundo** (casamento por CPF, mais seguro) e um campo textual explícito de "Grau parentesco" (TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...) — nenhuma suposição sobre numeração de carteirinha foi necessária aqui. Validado rodando `pdfplumber` e o `pipeline.processa_importacao` completo contra o arquivo real (15 beneficiários, R$ 437,40 no total — bate exatamente com "Total R$ 437,40" impresso no relatório) e a planilha padrão real da empresa 792 (13 dos 15 beneficiários casaram certo por CPF; os 2 ausentes da planilha de teste foram corretamente para auditoria "CPF não encontrado", não ignorados). **Cada família tem um "totalizador" impresso ao final** (ex.: "R$ 87,48" somando os 3 beneficiários de uma família) — por pedido explícito do usuário, esse total **nunca é usado**: o lançamento é sempre feito pela coluna "Valor" de cada linha de beneficiário individual (R$ 29,16 no exemplo), a mesma lógica de "usar o valor por linha, ignorar o subtotal impresso" já aplicada à Unimed do Paraná/Vitória. Particularidade de extração: quando o nome de um beneficiário (ou da família, no cabeçalho) ultrapassa a largura da coluna, o próprio relatório **corta o texto sem reticências e sem terminar de completar a última palavra** (ex.: "DANIELE MARTINS FERREIRA DA SILVA" sai como "DANIELE MARTINS FERREIRA DA" + "SILV" cortado, perdendo o "A" final) — como o casamento é por CPF, isso nunca afeta a correção do lançamento (nome é só exibição), então o parser não tenta reconstruir o nome quebrado, só usa a primeira linha física de cada beneficiário (onde já estão código/CPF/data nascimento/grau/valor completos, nenhum desses quebra, só o nome às vezes). +**SulAmérica Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - ") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha. + **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`. ### Modelos (`portal_api/models.py`) @@ -684,6 +687,7 @@ Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — m Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma). - **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também vira `valido=False`. + - **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão. - **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do ``, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva. Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`. @@ -717,18 +721,23 @@ O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/copartic - **Resolução automática pra regras já existentes**: `RegraCusteioPlanoSaudeSerializer.get_nome_empresa()` chama `resolve_nome_empresa()` a cada leitura (não só ao criar/editar) — então regras cadastradas antes deste campo existir tiveram o nome resolvido e cacheado sozinho, na primeira vez que a lista foi carregada depois do deploy, sem precisar de nenhum backfill manual. Já `ImportacaoPlanoSaudeDetailSerializer.get_nome_empresa()` (tela de Revisão) só lê o cache (`_nome_empresa_cacheado()`, sem chamar `resolve_nome_empresa()`) — essa tela é consultada com muito mais frequência, e o nome já deveria estar cacheado desde que a regra foi cadastrada/editada, então não vale pagar o custo de uma consulta ao Questor ali. - **Frontend** (`importacao-plano-saude.js`): `GET /api/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=X` (`pidBuscarNomeEmpresaPlanoSaude`) é chamado tanto num debounce de 350ms a cada tecla digitada no campo "Código da empresa" de "+ Nova regra" (`agendarAtualizarNomeEmpresaNovo()`, pedido explícito do usuário pra não precisar esperar o campo perder o foco) quanto no `blur` (imediato, cancela o debounce pendente) — a função de fato (`atualizarNomeEmpresaNovo()`) mostra "Buscando nome da empresa...", depois o nome resolvido ou "Empresa não encontrada no Questor.", e **reescreve o próprio campo** com o `codigo_empresa` normalizado devolvido pela resposta (ex.: usuário digita "092", campo passa a mostrar "92" assim que resolve). Os comboboxes de "Empresa" (Cadastro de Regras e Nova Importação) não fazem nenhuma chamada nova — `labelEmpresa()` monta `" - "` direto do array `regras` já carregado, que já vem com `nome_empresa` resolvido pelo backend. -### Regra empresa (custeio especial de mensalidade por família) +### Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação) -Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente", tipicamente porque são calculadas por **família inteira** (titular + todos os dependentes somados), não por pessoa. O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — mas **desde a rodada do Cadastro de Regras separado, a escolha de USAR uma regra especial deixou de ser um checkbox independente na tela de importação e passou a viver dentro do cadastro por empresa+operadora**: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio. +Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio. -- **Mutuamente exclusivo com custeio manual de mensalidade, agora por campo da regra**: no formulário de Cadastro de Regras, marcar "Mensalidade usa regra especial da empresa" (`#ips-form-tipo-regra-empresa`, ids preservados do checkbox antigo) desmarca e esconde os radios titular/dependente de Mensalidade (e vice-versa) — mesma exclusividade de antes, só que dentro do cadastro em vez de na execução. No backend, `RegraCusteioPlanoSaudeSerializer.validate()` exige `"mensalidade"` em `tipos_lancamento` quando `regra_empresa_chave` é preenchida, confere que `REGRAS_EMPRESA[chave]["codigo_empresa"]`/`["operadora"]` batem com os da própria regra (trava contra vincular o algoritmo de uma empresa a outra por engano) e zera `custeio_por_tipo["mensalidade"]`. No submit de "Nova Importação", `montarFormDataDeRegra()` envia `regra_empresa=` a partir de `regraResolvidaAtual.regra_empresa_chave` — o restante do pipeline (`ImportacaoPlanoSaudeCreateSerializer.validate()`, `pipeline.processa_importacao`, `regras_empresa.valida_regra_empresa`) **não mudou**, só a origem do valor no frontend. -- **Registro** (`REGRAS_EMPRESA`): cada entrada tem `label`, `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada), `operadora`, `aplica` (função que faz o cálculo) e `observacoes`. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (`GET /api/importacoes-plano-saude/regras-empresa/` já reflete o registro, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação"). +**Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação". + +- **Mutuamente exclusivo por TIPO, não em bloco**: no formulário de Cadastro de Regras, escolher uma regra especial trava (marca + desabilita + esconde os radios titular/dependente) só os checkboxes "Mensalidade"/"Coparticipação" que essa regra específica cobre — `aplicarTiposRegraEmpresa()` em `importacao-plano-saude.js`, chamada sempre que a regra selecionada muda (ao marcar/desmarcar o checkbox, ou ao escolher uma regra no picker). Uma regra que só cobre mensalidade (Tecnomyl) deixa "Coparticipação" livre pra configuração manual normalmente — mesmo comportamento de antes pra essa regra específica; a novidade é só que agora isso é decidido pelos `tipos_lancamento` de CADA regra, não fixo no código do formulário. Um checkbox travado (`.disabled`) não dispara `change` por clique do usuário, então a exclusividade mútua não precisa de nenhuma lógica extra nos handlers de "Mensalidade"/"Coparticipação" — só o handler de "Regra especial da empresa"/a seleção no picker chamam `aplicarTiposRegraEmpresa()`. +- No backend, `RegraCusteioPlanoSaudeSerializer.validate()`/`ImportacaoPlanoSaudeCreateSerializer.validate()` calculam `regra_empresa_tipos` (interseção entre `REGRAS_EMPRESA[chave]["tipos_lancamento"]` e os tipos selecionados — erro claro se vier vazia), conferem `chave_casamento_para_tipo(tipo) == REGRAS_EMPRESA[chave]["chave_casamento"]` pra cada tipo coberto (não mais um "exige nome" hardcoded) e zeram `custeio_por_tipo[tipo]` só pros tipos em `regra_empresa_tipos` (os demais tipos selecionados continuam com custeio manual normal). `regras_empresa.valida_regra_empresa(regra_empresa_key, chave_casamento_por_tipo, linhas_sistema, tipos_selecionados)` devolve `(aplica, tipos_cobertos)` — `pipeline.processa_importacao` passa `regra_empresa_fn` pra `casa_individuos_com_planilha` só quando `tipo_lancamento in tipos_cobertos`. +- **`matcher._casa_por_cpf` passou a suportar `regra_empresa_fn`** (antes só `_casa_por_nome` suportava) — sem agrupar por família (essa estratégia não tem esse conceito): acumula `(linha, valor_total)` de todo indivíduo casado por CPF e chama `regra_empresa_fn(linhas_e_valores, tipo_lancamento)` uma vez só, no fim, com todos os pares do tipo de lançamento inteiro. `aplica(linhas_e_valores, tipo_lancamento)` é a assinatura de toda regra agora (segundo argumento novo) — permite uma mesma função se comportar diferente por tipo (ver Ottimizza abaixo); a Tecnomyl recebe o parâmetro mas ignora (só é chamada pra "mensalidade" mesmo, via `tipos_lancamento`). +- **Registro** (`REGRAS_EMPRESA`): cada entrada tem `label`, `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada), `operadora`, `chave_casamento`, `tipos_lancamento`, `aplica` (função que faz o cálculo) e `observacoes`. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (`GET /api/importacoes-plano-saude/regras-empresa/` já reflete o registro, incluindo `tipos_lancamento`, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação"). - **Observações da regra, só-leitura na tela de Revisão** (`#ips-review-regra-empresa-obs`) — inalterado: `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` resolve `REGRAS_EMPRESA[obj.regra_empresa]["observacoes"]` a cada carregamento; o mesmo bloco cai pra `regra_custeio_salva_observacoes` quando não há regra empresa. -- **Primeira regra**: `unimed_1778_tecnomyl` — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026. -- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade. -- **Só funciona com casamento por nome** (`chave_casamento == "nome"`, ex.: Unimed) — a agregação por família depende do agrupamento que `matcher._casa_por_nome` já faz (por `numero_titular`); `_casa_por_cpf` não tem esse agrupamento e não foi estendida pra suportar (não havia necessidade ainda). `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se a operadora escolhida não for compatível, e também recusa se a planilha padrão anexada não tiver nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra da Tecnomyl na planilha de outra empresa por engano (a mesma ideia da trava geral descrita acima, só que específica pra este mecanismo e mais antiga). -- **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda 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` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`. -- **Coparticipação nunca é afetada**: `regra_empresa_chave` só cobre `"mensalidade"`; se a regra também cobrir `"coparticipacao"`, ela segue o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra empresa. +- **`unimed_1778_tecnomyl`** — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026. `chave_casamento="nome"` (precisa agrupar família), `tipos_lancamento=("mensalidade",)` — coparticipação dela segue sempre o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra. + - **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade. + - **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda 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` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`. +- **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação). +- **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes). +- **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto. ## CSS — organização entre arquivos diff --git a/media/planos_saude/operadora/Informacoes_Plano_de_Saude_-_AGOSTO_2026.xlsx b/media/planos_saude/operadora/Informacoes_Plano_de_Saude_-_AGOSTO_2026.xlsx new file mode 100644 index 0000000..48fdf4d Binary files /dev/null and b/media/planos_saude/operadora/Informacoes_Plano_de_Saude_-_AGOSTO_2026.xlsx differ diff --git a/media/planos_saude/planilha_padrao/questor_1889_2026-08.csv b/media/planos_saude/planilha_padrao/questor_1889_2026-08.csv new file mode 100644 index 0000000..523f765 --- /dev/null +++ b/media/planos_saude/planilha_padrao/questor_1889_2026-08.csv @@ -0,0 +1,67 @@ +CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO +1889;ALEXSANDRA SOBRINHO DOS SANTOS CORREA;475.338.938-32;5775;01/03/2026;;;0;0; +1889;ALINE MALAQUIAS DOS SANTOS OSTROWSKI;116.050.538-13;5775;01/03/2026;;;0;0; +1889;ANA CAROLINE SITTA;081.814.409-27;5775;01/03/2026;;;0;0; +1889;ANA PAULA DA COSTA POLICARPO;701.230.256-99;5775;01/03/2026;;;0;0; +1889;ANA PAULA MURUSSI PEDROSO;028.444.820-66;5775;01/03/2026;;;0;0; +1889;ANDRESSA NOGUEIRA CONCEICAO;122.944.789-08;5775;01/03/2026;;;0;0; +1889;ARTHUR VALENTIM ONEDA;122.241.619-05;5775;01/03/2026;;;0;0; +1889;BRENDA COSTA SOUZA;026.931.212-99;5775;01/03/2026;;;0;0; +1889;BRENDA LEONIDIA D OLIVEIRA;012.255.939-80;5775;01/03/2026;;;0;0; +1889;CAMILA TREVISO DE ARRUDA CAMARGO;087.958.229-41;5775;01/05/2026;;;0;0; +1889;DANIEL DARLY MAIA DA SILVA;057.204.063-60;5775;01/03/2026;;;0;0; +1889;DENIS DA SILVA MIRANDA;038.638.182-88;5775;01/03/2026;;;0;0; +1889;EDUARDO BORGES;135.877.979-19;5775;01/07/2026;;;0;0; +1889;EDUARDO MAYORKA CARDOSO;090.879.809-14;5775;01/03/2026;ELOISA LUCIANO MAYORKA CARDOSO;103.321.859-66;0;0; +1889;EDUARDO MAYORKA CARDOSO;090.879.809-14;5775;01/03/2026;;;0;0; +1889;ESTER COSTA DA SILVA;224.222.748-38;5775;01/03/2026;;;0;0; +1889;FELIPE GABRIEL HAERTHEL;090.158.439-88;5775;01/03/2026;;;0;0; +1889;FELIPE VIEIRA BAEHR;128.705.509-52;5775;01/07/2026;;;0;0; +1889;FERNANDA SCHUBERT LONHESKI;124.486.689-06;5775;01/03/2026;;;0;0; +1889;GABRIELA DE MEDEIROS BENTO BUZZI;114.020.649-42;5775;01/03/2026;;;0;0; +1889;GABRIELA SCHIRMER;091.044.139-11;5775;01/03/2026;;;0;0; +1889;GABRIELA SENHORAES VECCHIO;105.087.569-95;5775;01/03/2026;;;0;0; +1889;GUILHERME DE OLIVEIRA ALVES;187.462.227-23;5775;01/03/2026;;;0;0; +1889;GUSTAVO ALVES DA SILVA;118.468.069-84;5775;01/07/2026;;;0;0; +1889;GUSTAVO CELSO BOZZANO;131.128.029-42;5775;01/03/2026;;;0;0; +1889;IGOR DANIEL DE FATIMA SILVA;139.225.456-61;5775;01/03/2026;;;0;0; +1889;ISABELLE COSTA DE MELO;521.432.518-58;5775;01/03/2026;;;0;0; +1889;JACQUELINE DIAS BARBOSA;144.610.697-70;5775;01/07/2026;;;0;0; +1889;JAMILY DOS SANTOS SOUSA;705.818.672-38;5775;01/03/2026;;;0;0; +1889;JARBAS DE MORAIS FERREIRA JUNIOR;022.988.173-40;5775;01/03/2026;;;0;0; +1889;JEFTER JOSUE XAVIER FERREIRA;022.479.226-19;5775;01/03/2026;;;0;0; +1889;JEMIMA CRISTINA DA MAIA;137.791.479-80;5775;01/03/2026;;;0;0; +1889;JESSICA ROSA DA SILVA;435.730.628-06;5775;01/03/2026;;;0;0; +1889;JHONATAN LUIZ MORFIM;105.533.019-42;5775;01/07/2026;;;0;0; +1889;JOAO PAULO RODRIGUES;108.143.759-60;5775;01/03/2026;;;0;0; +1889;JOAO PEDRO LABUSSIERE FRANCA;865.883.405-08;5775;01/05/2026;;;0;0; +1889;JOAO WALTER RIBEIRO;109.418.109-92;5775;01/03/2026;;;0;0; +1889;JOSE LUCAS DE ALMEIDA BEILER;122.754.287-98;5775;01/03/2026;;;0;0; +1889;JULIA ALVES DE SANTANA;140.505.279-10;5775;01/07/2026;;;0;0; +1889;KARINE CAROLINA BLASKA;101.938.429-80;5775;01/03/2026;;;0;0; +1889;LEONARDO AIRAM VIEIRA;105.518.279-98;5775;01/03/2026;;;0;0; +1889;LETICIA DE MELO SILVA;104.059.196-54;5775;01/03/2026;;;0;0; +1889;LINARA CARDOSO CIRILO;013.458.862-25;5775;01/03/2026;DEBORAH CARDOSO CIRILO;023.252.769-53;0;0; +1889;LINARA CARDOSO CIRILO;013.458.862-25;5775;01/03/2026;;;0;0; +1889;LODEMAR SANSAO JUNIOR;108.585.329-21;5775;01/05/2026;;;0;0; +1889;LUANA RODRIGUES COELHO;512.533.848-60;5775;01/07/2026;;;0;0; +1889;LUAN VICTOR DE RAMOS LUCIANO;077.485.059-00;5775;01/07/2026;;;0;0; +1889;LUCAS ANDERTON ZILZ;098.116.759-45;5775;01/03/2026;;;0;0; +1889;LUCAS LIEBL;105.519.349-99;5775;01/03/2026;;;0;0; +1889;MATHEUS JOAO TAVARES RIBEIRO;164.971.746-64;5775;01/05/2026;;;0;0; +1889;MATHEUS LAUTHARTE CARLOTTO;043.067.300-00;5775;01/05/2026;ISIS CARLOTTO;143.321.089-49;0;0; +1889;MATHEUS LAUTHARTE CARLOTTO;043.067.300-00;5775;01/05/2026;;;0;0; +1889;MAYRON NUNES CIRILO;946.483.522-20;5775;01/03/2026;;;0;0; +1889;MONALIZA LOBATO GONCALVES;118.094.647-22;5775;01/03/2026;;;0;0; +1889;NARA ELISA RODRIGUES BOCK;110.767.779-37;5775;01/03/2026;;;0;0; +1889;PEDRO FATTORE DE MEDEIROS SILVA;354.820.648-40;5775;01/05/2026;;;0;0; +1889;RAFAELLA SIMAO DIAS;521.088.518-60;5775;01/03/2026;;;0;0; +1889;RITIELY SILVESTRE FELIPE;134.340.996-97;5775;01/03/2026;;;0;0; +1889;RODRIGO HEINZEN DE MORAES;115.363.889-47;5775;01/03/2026;;;0;0; +1889;RUAN MULLER COSTA GONZAGA;135.446.486-95;5775;01/03/2026;;;0;0; +1889;SAMUEL SOUSA OLIVEIRA SANTOS;064.354.393-70;5775;01/03/2026;;;0;0; +1889;SUELEN CARDOZO LEMOS;016.697.210-00;5775;01/08/2026;LOUISE LEMOS EIGAT;163.037.249-81;0;0; +1889;SUELEN CARDOZO LEMOS;016.697.210-00;5775;01/08/2026;;;0;0; +1889;VICTORIA RAQUEL LIMA MUSTAFA;030.331.932-18;5775;01/03/2026;;;0;0; +1889;VICTORIA RAQUEL LIMA MUSTAFA;030.331.932-18;5775;01/03/2026;CLARA LUNA LIZ MUSTAFA PESSOA;085.395.182-95;0;0; +1889;VITORIA APARECIDA BERNARDES BRAGA;019.977.056-56;5775;01/07/2026;;;0;0; diff --git a/portal_api/planos_saude/matcher.py b/portal_api/planos_saude/matcher.py index 4a5c17b..b226592 100644 --- a/portal_api/planos_saude/matcher.py +++ b/portal_api/planos_saude/matcher.py @@ -32,12 +32,16 @@ Regras de negócio combinadas com o cliente: - Beneficiário sem cadastro correspondente na planilha padrão (plano não cadastrado para ele) também NÃO é lançado — vai para a auditoria. -Segundo acréscimo: `regra_empresa_fn` (opcional, só implementado na -estratégia "nome" — ver `_casa_por_nome`) substitui `regra_custeio` por -uma função de custeio calculada por FAMÍLIA inteira em vez de por pessoa, -pra regras especiais que não cabem no desenho normal (ver -portal_api.planos_saude.regras_empresa) — resolvida e validada em -pipeline.processa_importacao antes de chegar aqui. +Segundo acréscimo: `regra_empresa_fn` (opcional, implementado nas duas +estratégias — `_casa_por_nome` acumula por FAMÍLIA, `_casa_por_cpf` +acumula tudo de uma vez já que essa estratégia não tem noção de família) +substitui `regra_custeio` por uma função de custeio especial cadastrada em +código pra regras que não cabem no desenho normal titular/dependente × +empresa/empregado/específica (ver portal_api.planos_saude.regras_empresa) +— resolvida e validada em pipeline.processa_importacao antes de chegar +aqui, e recebe `tipo_lancamento` como segundo argumento (a mesma função +pode se comportar diferente pra mensalidade e coparticipação, ver +REGRAS_EMPRESA). Terceiro acréscimo: `vinculos_por_nome` (opcional, só na estratégia "nome") — um "DE/PARA" persistente (`portal_api.models.VinculoNomeOperadora`, @@ -189,10 +193,24 @@ def _casa_por_cpf( individuos: List[Individuo], linhas_sistema: List[LinhaSistema], regra_custeio: Optional[dict] = None, + regra_empresa_fn: Optional[Callable[[List[Tuple[LinhaSistema, float]], str], None]] = None, + tipo_lancamento: str = "", **_ignorado, ) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]: + """ + `regra_empresa_fn`, quando informada, substitui o custeio normal por + pessoa (`regra_custeio`/`_aplica_regra_custeio`) — mesmo acréscimo já + documentado em `_casa_por_nome`, só que sem noção de família (o + casamento por CPF já resolve cada indivíduo direto, sem precisar + agrupar titular+dependentes primeiro): todo par (linha, valor) já + casado é acumulado e passado de uma vez só pra `regra_empresa_fn` no + fim, depois de todo o casamento — não linha a linha, já que a função + pode precisar enxergar todos os pares juntos (ex.: uma regra por + família, mesmo sem a estrutura de família explícita desta estratégia). + """ titulares, dependentes = _indexa_por_cpf(linhas_sistema) auditoria: List[ItemAuditoria] = [] + linhas_e_valores: List[Tuple[LinhaSistema, float]] = [] for ind in individuos: if ind.valor_total < 0: @@ -207,7 +225,13 @@ def _casa_por_cpf( auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema.")) continue - _aplica_regra_custeio(linha_destino, ind.valor_total, _regra_para_pessoa(regra_custeio, ind.tipo)) + if regra_empresa_fn is not None: + linhas_e_valores.append((linha_destino, ind.valor_total)) + else: + _aplica_regra_custeio(linha_destino, ind.valor_total, _regra_para_pessoa(regra_custeio, ind.tipo)) + + if regra_empresa_fn is not None and linhas_e_valores: + regra_empresa_fn(linhas_e_valores, tipo_lancamento) # Casamento por CPF nunca precisa do DE/PARA de nomes divergentes (ver # _casa_por_nome) — o CPF já é exato por natureza. @@ -237,14 +261,15 @@ def _casa_por_nome( coparticipação) — sem esse mapa global, a família "perderia" o titular ao filtrar por tipo antes de casar. - `regra_empresa_fn`, quando informada (só usada para "mensalidade" — - ver portal_api.planos_saude.regras_empresa), substitui o custeio - normal por pessoa (`regra_custeio`/`_aplica_regra_custeio`): em vez de - aplicar a regra linha a linha assim que cada membro é resolvido, as - linhas/valores da família inteira são acumulados primeiro em - `linhas_e_valores` e só then passados de uma vez pra `regra_empresa_fn` - no fim do laço da família — ela decide como dividir entre as linhas - (normalmente um teto por família, não por pessoa). + `regra_empresa_fn`, quando informada (pra qualquer tipo de lançamento + coberto pela regra — ver portal_api.planos_saude.regras_empresa), + substitui o custeio normal por pessoa (`regra_custeio`/ + `_aplica_regra_custeio`): em vez de aplicar a regra linha a linha + assim que cada membro é resolvido, as linhas/valores da família + inteira são acumulados primeiro em `linhas_e_valores` e só depois + passados de uma vez pra `regra_empresa_fn` no fim do laço da família + — ela decide como dividir entre as linhas (normalmente um teto por + família, não por pessoa). `vinculos_por_nome` (nome normalizado, como veio do arquivo da operadora, -> VinculoNome) é o "DE/PARA" persistente: quando um titular @@ -360,7 +385,7 @@ def _casa_por_nome( _aplica_regra_custeio(linha_dep, m.valor_total, _regra_para_pessoa(regra_custeio, m.tipo)) if regra_empresa_fn is not None and linhas_e_valores_familia: - regra_empresa_fn(linhas_e_valores_familia) + regra_empresa_fn(linhas_e_valores_familia, tipo_lancamento) return linhas_sistema, auditoria, vinculos_aplicados diff --git a/portal_api/planos_saude/operadoras/sulamerica/saude.py b/portal_api/planos_saude/operadoras/sulamerica/saude.py new file mode 100644 index 0000000..aa03109 --- /dev/null +++ b/portal_api/planos_saude/operadoras/sulamerica/saude.py @@ -0,0 +1,181 @@ +""" +SulAmérica (código de operadora 5775) - Saúde, mensalidade + coparticipação. + +Diferente de `odonto_mensalidade.py` (código 4726, mesma operadora mas outro +contrato/relatório): este arquivo é uma planilha (.xlsx) já pronta, +"Informações Plano de Saúde - ", com uma linha por beneficiário +e colunas separadas de "Benefício"/"Desconto" (o que a operadora já sugere +como custeado pela empresa vs. descontado do empregado). Colunas confirmadas +contra o arquivo real (competência 08/2026, empresa 1889 - Ottimizza): + + Empr. | Cod. | Tipo Plano | CPF | Nome | Benefício Mensalidade | + Desconto Mensalidade | Benefício Coparticipação | Desconto Coparticipação + +Decisão explícita do usuário: este parser **não trata** a divisão entre +empresa/empregado — só extrai a planilha e soma "Benefício Mensalidade" + +"Desconto Mensalidade" num único valor de mensalidade, e "Benefício +Coparticipação" + "Desconto Coparticipação" num único valor de +coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` +usado por toda outra operadora deste pacote). Quem decide como esse total +se divide entre empresa e empregado é a "Regra empresa" cadastrada para +"1889 - SulAmérica (5775)" (ver `portal_api.planos_saude.regras_empresa`), +selecionável em "Regra especial da empresa" no Cadastro de Regras — não este +parser. + +`numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — no arquivo +real, uma mesma pessoa (LOUISE LEMOS EIGAT) apareceu em duas linhas com o +mesmo "Desconto Mensalidade", uma delas com "Cod." salvo como número em vez +de texto no Excel (perdendo precisão nos últimos dígitos, "...118" virou +"...100"). Como o casamento desta operadora é sempre por CPF (nunca por +"Cod."), usar o CPF como chave de agregação evita depender de uma coluna que +já se mostrou pouco confiável, e garante que linhas duplicadas da mesma +pessoa sejam somadas (mesma regra geral de "nunca tratar cada linha +isoladamente" já aplicada a toda operadora deste pacote) em vez de uma +sobrescrever a outra silenciosamente. +""" +import unicodedata +from typing import Dict, List, Optional, Tuple + +import openpyxl + +from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento, normaliza_cpf +from portal_api.planos_saude.operadoras.base import OperadoraParser + +_COLUNAS_ESPERADAS = { + "cpf": "cpf", + "nome": "nome", + "tipo plano": "tipo", + "beneficio mensalidade": "beneficio_mensalidade", + "desconto mensalidade": "desconto_mensalidade", + "beneficio coparticipacao": "beneficio_coparticipacao", + "desconto coparticipacao": "desconto_coparticipacao", +} + + +def _normaliza_cabecalho(valor: object) -> str: + texto = str(valor or "") + sem_acento = unicodedata.normalize("NFKD", texto) + sem_acento = "".join(c for c in sem_acento if not unicodedata.combining(c)) + return " ".join(sem_acento.strip().lower().split()) + + +def _indice_colunas(planilha) -> Dict[str, int]: + primeira_linha = next(planilha.iter_rows(min_row=1, max_row=1, values_only=True)) + cabecalhos = {_normaliza_cabecalho(valor): posicao for posicao, valor in enumerate(primeira_linha)} + indice: Dict[str, int] = {} + faltando = [] + for cabecalho_esperado, chave in _COLUNAS_ESPERADAS.items(): + if cabecalho_esperado not in cabecalhos: + faltando.append(cabecalho_esperado) + continue + indice[chave] = cabecalhos[cabecalho_esperado] + if faltando: + raise ValueError( + "Planilha da SulAmérica (5775) fora do leiaute esperado — " + f"coluna(s) não encontrada(s): {', '.join(faltando)}." + ) + return indice + + +def _texto(valor: object) -> str: + return str(valor).strip() if valor is not None else "" + + +def _para_float(valor: object) -> Optional[float]: + if valor is None: + return None + if isinstance(valor, (int, float)): + return float(valor) + texto = str(valor).strip() + if not texto: + return None + if "," in texto: + texto = texto.replace(".", "").replace(",", ".") + try: + return float(texto) + except ValueError: + return None + + +class SulAmericaSaude(OperadoraParser): + nome_operadora = "SULAMÉRICA SAÚDE" + chave_casamento = "cpf" + + def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]: + workbook = openpyxl.load_workbook(caminho_arquivo, data_only=True, read_only=True) + try: + planilha = workbook.worksheets[0] + indice = _indice_colunas(planilha) + + lancamentos_mensalidade: List[Lancamento] = [] + lancamentos_coparticipacao: List[Lancamento] = [] + for linha in planilha.iter_rows(min_row=2, values_only=True): + cpf = _texto(linha[indice["cpf"]]) + if not cpf: + continue + + nome = _texto(linha[indice["nome"]]) + tipo = "T" if _texto(linha[indice["tipo"]]).upper().startswith("TITULAR") else "D" + numero_beneficiario = normaliza_cpf(cpf) + + valor_mensalidade = ( + (_para_float(linha[indice["beneficio_mensalidade"]]) or 0.0) + + (_para_float(linha[indice["desconto_mensalidade"]]) or 0.0) + ) + if valor_mensalidade: + lancamentos_mensalidade.append(Lancamento( + numero_beneficiario=numero_beneficiario, + nome=nome, + cpf=cpf, + tipo=tipo, + rubrica="Mensalidade", + valor=valor_mensalidade, + tipo_lancamento="mensalidade", + )) + + valor_coparticipacao = ( + (_para_float(linha[indice["beneficio_coparticipacao"]]) or 0.0) + + (_para_float(linha[indice["desconto_coparticipacao"]]) or 0.0) + ) + if valor_coparticipacao: + lancamentos_coparticipacao.append(Lancamento( + numero_beneficiario=numero_beneficiario, + nome=nome, + cpf=cpf, + tipo=tipo, + rubrica="Coparticipação", + valor=valor_coparticipacao, + tipo_lancamento="coparticipacao", + )) + + individuos = self._agrega_por_individuo_e_tipo(lancamentos_mensalidade) + individuos += self._agrega_por_individuo_e_tipo(lancamentos_coparticipacao) + return individuos, [] + finally: + # Modo read_only mantém o arquivo memory-mapped até close() ser + # chamado — sem isso, o Windows bloqueia excluir a importação + # depois (mesmo cuidado já documentado em indicadores/leiaute.py). + workbook.close() + + def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]: + """Soma linhas duplicadas da MESMA pessoa (mesmo CPF, já normalizado + como `numero_beneficiario`) dentro do mesmo tipo de lançamento — ver + docstring do módulo pro caso real encontrado (LOUISE LEMOS EIGAT, + duas linhas com o mesmo valor). Mesmo padrão de agregação já usado + pelos demais parsers deste pacote (ex.: `sulamerica/odonto_mensalidade.py`).""" + individuos: Dict[Tuple[str, str], Individuo] = {} + ordem: List[Tuple[str, str]] = [] + 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, + ) + 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] diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index bb6341f..0ff6f4d 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -20,6 +20,7 @@ from portal_api.planos_saude.operadoras.bradesco.saude import BradescoSaude from portal_api.planos_saude.operadoras.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade +from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude @@ -74,6 +75,11 @@ OPERADORAS = { "nome": "SulAmérica Odonto", "parser": SulAmericaOdontoMensalidade, }, + "sulamerica_saude": { + "codigo_operadora": "5775", + "nome": "SulAmérica", + "parser": SulAmericaSaude, + }, } @@ -164,12 +170,16 @@ def processa_importacao( fontes usar é de quem chama, em views.py). `regra_empresa_key`, quando informado, substitui o custeio configurável - de "mensalidade" por uma regra especial cadastrada em - portal_api.planos_saude.regras_empresa (ex.: teto de custeio por - família) — `custeio_por_tipo["mensalidade"]` é ignorado nesse caso - (ImportacaoPlanoSaudeCreateSerializer já garante que vem vazio). - Levanta RegraEmpresaIncompativelError se a regra não servir pra esta - operadora/planilha (propagada pra fora, não é um erro de arquivo). + dos tipos de lançamento que a regra cobre (`REGRAS_EMPRESA[chave] + ["tipos_lancamento"]` — ver portal_api.planos_saude.regras_empresa) por + uma regra especial cadastrada em código (ex.: teto de custeio por + família, ou um critério fixo por tipo de beneficiário) — + `custeio_por_tipo[tipo]` desses tipos é ignorado nesse caso + (ImportacaoPlanoSaudeCreateSerializer já garante que vem vazio). Um + tipo selecionado que a regra NÃO cobre continua seguindo + `custeio_por_tipo[tipo]` normalmente. Levanta RegraEmpresaIncompativelError + se a regra não servir pra esta operadora/planilha (propagada pra fora, + não é um erro de arquivo). `vinculos_por_nome` (opcional, só usado pela estratégia "nome" — ver matcher._casa_por_nome) é o "DE/PARA" de nomes divergentes já @@ -188,11 +198,10 @@ def processa_importacao( individuos = _agrega_individuos_entre_arquivos(individuos) regra_empresa_fn = None + regra_empresa_tipos: Tuple[str, ...] = () if regra_empresa_key: - # regra_empresa só se aplica a "mensalidade" (ver mais abaixo e em - # ImportacaoPlanoSaudeCreateSerializer.validate()). - regra_empresa_fn = valida_regra_empresa( - regra_empresa_key, parser_operadora.chave_casamento_para_tipo("mensalidade"), linhas_sistema_template + regra_empresa_fn, regra_empresa_tipos = valida_regra_empresa( + regra_empresa_key, parser_operadora.chave_casamento_para_tipo, linhas_sistema_template, tipos_selecionados ) individuos_por_tipo: Dict[str, list] = {} @@ -222,7 +231,7 @@ def processa_importacao( parser_operadora.chave_casamento_para_tipo(tipo_lancamento), regra_custeio=custeio_por_tipo[tipo_lancamento], nomes_titular_por_numero=nomes_titular_por_numero, - regra_empresa_fn=regra_empresa_fn if tipo_lancamento == "mensalidade" else None, + regra_empresa_fn=regra_empresa_fn if tipo_lancamento in regra_empresa_tipos else None, vinculos_por_nome=vinculos_por_nome, tipo_lancamento=tipo_lancamento, ) diff --git a/portal_api/planos_saude/regras_empresa.py b/portal_api/planos_saude/regras_empresa.py index 292284f..2f8599c 100644 --- a/portal_api/planos_saude/regras_empresa.py +++ b/portal_api/planos_saude/regras_empresa.py @@ -1,21 +1,28 @@ """ -Regras de custeio especiais por empresa ("Regra empresa" na tela de nova -importação, terceiro item de "Tipo de importação" ao lado de Mensalidade/ -Coparticipação) — 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. Nunca expostas para o usuário cadastrar pela tela; só -"Selecionar regra" na tela de nova importação, que lista as chaves já -registradas aqui (GET /api/importacoes-plano-saude/regras-empresa/). +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/). -Só se aplica ao tipo de lançamento "mensalidade" — coparticipação sempre -segue o custeio normal configurado na tela (por isso REGRAS_EMPRESA não -tem nada de coparticipação), e é por isso que "Regra empresa" e -"Mensalidade" são mutuamente exclusivos no formulário: as duas são formas -alternativas de configurar o MESMO tipo de lançamento "mensalidade". +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 @@ -25,15 +32,16 @@ from portal_api.planos_saude.modelos import LinhaSistema class RegraEmpresaIncompativelError(Exception): """Regra empresa selecionada não é compatível com esta importação - (operadora sem casamento por nome, 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".""" + (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 — - `_aplica_teto_familia` roda tanto contra `LinhaSistema` (pipeline, na + 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.""" @@ -66,29 +74,63 @@ def _aplica_teto_familia(linhas_e_valores: List[Tuple[Any, float]], teto: float) linha.valor = formata_valor_br(valor_empregado) -def _regra_unimed_1778_tecnomyl(linhas_e_valores: List[Tuple[Any, float]]) -> None: +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.""" + 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) -# Pra cadastrar uma regra nova: escrever a função `_regra_...(linhas_e_valores)` -# 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). -# `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. +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 " @@ -99,33 +141,72 @@ REGRAS_EMPRESA: Dict[str, dict] = { "configurado nesta importação, sem relação com esta regra." ), }, + "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, str]]: +def lista_regras_empresa() -> List[Dict[str, Any]]: return [ - {"key": chave, "label": dados["label"], "observacoes": dados.get("observacoes", "")} + { + "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: str, linhas_sistema: List[LinhaSistema] -) -> Callable[[List[Tuple[Any, float]]], None]: + 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 a função de aplicação já resolvida — chamado por - pipeline.processa_importacao antes de rodar o casamento de mensalidade.""" + 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}.") - if chave_casamento != "nome": + + 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( - "Esta operadora não é compatível com 'Regra empresa' (requer casamento por nome)." + 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"] + return regra["aplica"], tipos_cobertos diff --git a/portal_api/serializers.py b/portal_api/serializers.py index c43b5a1..28d3f6e 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -580,28 +580,43 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer): raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."}) regra_empresa_key = (attrs.get("regra_empresa") or "").strip() + regra_empresa_tipos: tuple[str, ...] = () if regra_empresa_key: - if regra_empresa_key not in REGRAS_EMPRESA: + regra_re = REGRAS_EMPRESA.get(regra_empresa_key) + if regra_re is None: raise serializers.ValidationError({"regra_empresa": f"Regra empresa desconhecida: {regra_empresa_key}"}) - if "mensalidade" not in tipos: + regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos) + if not regra_empresa_tipos: raise serializers.ValidationError( - {"regra_empresa": "'Regra empresa' exige o tipo de importação 'Mensalidade' selecionado."} - ) - parser_classe = OPERADORAS[attrs["operadora"]]["parser"] - if parser_classe.chave_casamento != "nome": - raise serializers.ValidationError( - {"regra_empresa": "Esta operadora não é compatível com 'Regra empresa' (requer casamento por nome)."} + { + "regra_empresa": ( + f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, " + "mas nenhum desses tipos de importação está selecionado." + ) + } ) + parser_instancia = OPERADORAS[attrs["operadora"]]["parser"]() + casamento_exigido = regra_re.get("chave_casamento", "nome") + for tipo in regra_empresa_tipos: + if parser_instancia.chave_casamento_para_tipo(tipo) != casamento_exigido: + raise serializers.ValidationError( + { + "regra_empresa": ( + f"Esta operadora não é compatível com \"{regra_re['label']}\" " + f"(exige casamento por {casamento_exigido!r})." + ) + } + ) custeio_por_tipo: dict[str, Any] = {} for tipo in tipos: if tipo not in TIPOS_LANCAMENTO_VALIDOS: raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"}) - # "Regra empresa" substitui o custeio manual de mensalidade — - # sem titular/dependente pra configurar aqui (ver models.py - # ImportacaoPlanoSaude.regra_empresa). - if tipo == "mensalidade" and regra_empresa_key: + # "Regra empresa" substitui o custeio manual dos tipos que ela + # cobre — sem titular/dependente pra configurar aqui (ver + # models.py ImportacaoPlanoSaude.regra_empresa). + if tipo in regra_empresa_tipos: custeio_por_tipo[tipo] = {} continue @@ -1026,11 +1041,18 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer): if not isinstance(custeio_bruto, dict): raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."}) + regra_empresa_tipos: tuple[str, ...] = () if regra_empresa_chave: regra_re = REGRAS_EMPRESA[regra_empresa_chave] - if "mensalidade" not in tipos: + regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos) + if not regra_empresa_tipos: raise serializers.ValidationError( - {"regra_empresa_chave": "Exige o tipo de importação 'Mensalidade' selecionado."} + { + "regra_empresa_chave": ( + f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, " + "mas nenhum desses tipos de importação está selecionado." + ) + } ) if regra_re["codigo_empresa"] != codigo_empresa: raise serializers.ValidationError( @@ -1048,10 +1070,10 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer): custeio_validado: dict[str, Any] = {} for tipo in tipos: - # "Regra empresa" substitui o custeio manual de mensalidade — - # sem titular/dependente pra configurar aqui (mesmo padrão de - # ImportacaoPlanoSaudeCreateSerializer.validate()). - if tipo == "mensalidade" and regra_empresa_chave: + # "Regra empresa" substitui o custeio manual dos tipos que ela + # cobre — sem titular/dependente pra configurar aqui (mesmo + # padrão de ImportacaoPlanoSaudeCreateSerializer.validate()). + if tipo in regra_empresa_tipos: custeio_validado[tipo] = {} continue diff --git a/portal_api/views.py b/portal_api/views.py index 9d18828..2daa222 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -718,8 +718,16 @@ def _valida_planilha_padrao(arquivo: UploadedFile) -> dict[str, Any]: def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict[str, Any]: """Roda o parser da operadora escolhida (`OperadoraParser.extrai`) sobre o arquivo recém-anexado — mesma extração usada em processa_importacao, só - que descartada em seguida (nada é salvo/persistido aqui).""" - sufixo = ".pdf" if (arquivo.name or "").lower().endswith(".pdf") else ".csv" + que descartada em seguida (nada é salvo/persistido aqui). O sufixo do + arquivo temporário precisa refletir a extensão real do upload (não só + ".pdf"/".csv" fixos) porque alguns parsers dependem dela pra abrir o + arquivo — `openpyxl.load_workbook` (ex.: SulAmérica 5775, .xlsx) recusa + abrir um arquivo cujo sufixo não seja .xlsx/.xlsm/.xltx/.xltm, mesmo que + o conteúdo seja válido (bug real: um .xlsx salvo com sufixo ".csv" + levantava `InvalidFileException`, fazendo a pré-validação sempre falhar + pra essa operadora com uma mensagem genérica de "arquivo não reconhecido"). + """ + sufixo = os.path.splitext(arquivo.name or "")[1] or ".tmp" caminho = _salva_arquivo_temporario(arquivo, sufixo) operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key] try: @@ -1297,26 +1305,29 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet): def _recalcula_familia_regra_empresa(importacao: ImportacaoPlanoSaude, linha: ImportacaoPlanoSaudeLinha) -> None: """Reaplica a regra empresa (`planos_saude.regras_empresa`) pra TODA a - família de `linha` (mesmo `nome_func`, mesmo `tipo_lancamento`) — chamado - depois de "Vincular pessoa" (ImportacaoPlanoSaudeAuditoriaViewSet.resolver) - resolver um item de auditoria de mensalidade numa importação com - `regra_empresa` configurada: o valor recém-vinculado muda o total da - família, então o teto (`regras_empresa._aplica_teto_familia`, ou - equivalente) precisa ser redistribuído entre TODAS as linhas da família - de novo — nunca só a que acabou de ser vinculada, senão o resultado - ignora a regra empresa e cai no custeio padrão (100% desconto do - empregado), que é exatamente o bug que isso corrige. O valor "bruto" de - cada linha já lançada é recuperado como `valor_empresa + valor` — essa - soma sempre preserva o total da mensalidade, independente de qual split - foi aplicado antes.""" + família de `linha` (mesmo `nome_func`, mesmo `tipo_lancamento` — o + próprio `linha.tipo_lancamento`, não mais fixo em "mensalidade" desde + que a regra empresa passou a poder cobrir também "coparticipacao", ver + REGRAS_EMPRESA) — chamado depois de "Vincular pessoa" + (ImportacaoPlanoSaudeAuditoriaViewSet.resolver) resolver um item de + auditoria numa importação com `regra_empresa` configurada: o valor + recém-vinculado muda o total da família, então a regra precisa ser + reaplicada a TODAS as linhas da família de novo — nunca só a que + acabou de ser vinculada, senão o resultado ignora a regra empresa e + cai no custeio padrão (100% desconto do empregado), que é exatamente + o bug que isso corrige. O valor "bruto" de cada linha já lançada é + recuperado como `valor_empresa + valor` — essa soma sempre preserva o + total do lançamento, independente de qual split foi aplicado antes.""" regra = planos_saude_regras_empresa.REGRAS_EMPRESA.get(importacao.regra_empresa) if not regra: return linhas_familia = list( - importacao.linhas.filter(tipo_lancamento="mensalidade", nome_func=linha.nome_func).order_by("ordem", "id") + importacao.linhas.filter( + tipo_lancamento=linha.tipo_lancamento, nome_func=linha.nome_func + ).order_by("ordem", "id") ) linhas_e_valores = [(l, parse_valor_br(l.valor_empresa) + parse_valor_br(l.valor)) for l in linhas_familia] - regra["aplica"](linhas_e_valores) + regra["aplica"](linhas_e_valores, linha.tipo_lancamento) ImportacaoPlanoSaudeLinha.objects.bulk_update(linhas_familia, ["valor_empresa", "valor"]) @@ -1377,7 +1388,15 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet): if linha.valor != "0" or linha.valor_empresa != "0": raise ValidationError({"linha_id": "Essa linha já tem um valor lançado — escolha uma linha ainda em branco."}) - if item.tipo_lancamento == "mensalidade" and item.importacao.regra_empresa: + regra_empresa_dados = ( + planos_saude_regras_empresa.REGRAS_EMPRESA.get(item.importacao.regra_empresa) + if item.importacao.regra_empresa + else None + ) + regra_empresa_cobre_este_tipo = bool( + regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",)) + ) + if regra_empresa_cobre_este_tipo: # "Regra empresa" é calculada por FAMÍLIA inteira (ver # regras_empresa.py), não por pessoa — não dá pra aplicar só # nesta linha isoladamente (senão cairia no custeio padrão, diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index d97148e..e9a7281 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -355,9 +355,10 @@ document.addEventListener("DOMContentLoaded", async () => { let vincularLinhaSelecionada = null; let operadorasCache = []; // Regras empresa (portal_api.planos_saude.regras_empresa) — registro fixo - // no código; regraEmpresaSelecionada guarda {key, label} da regra - // atualmente escolhida no checkbox "Mensalidade usa regra especial" (ver - // ips-regra-empresa-modal), dentro do formulário de Cadastro de Regras. + // no código; regraEmpresaSelecionada guarda {key, label, tipos_lancamento} + // da regra atualmente escolhida no checkbox "Regra especial da empresa" + // (ver ips-regra-empresa-modal), dentro do formulário de Cadastro de + // Regras. let regrasEmpresaCache = []; let regraEmpresaSelecionada = null; // Banco de regras de custeio cadastradas por empresa+operadora — fonte @@ -769,10 +770,10 @@ document.addEventListener("DOMContentLoaded", async () => { renderList(); } - // "Regra empresa" (terceiro tipo de importação, mutuamente exclusivo com - // "Mensalidade" — as duas são formas alternativas de configurar o mesmo - // tipo de lançamento "mensalidade", ver regras_empresa.py) — zera o - // checkbox, esconde a caixa e limpa a seleção em memória. + // "Regra especial da empresa" ("Regra empresa" — cobre um subconjunto de + // tipos de lançamento, mensalidade e/ou coparticipação conforme a regra + // escolhida, ver regras_empresa.py) — zera o checkbox, esconde a caixa e + // limpa a seleção em memória. function renderRegraEmpresaAtual() { if (!regraEmpresaAtual) return; regraEmpresaAtual.textContent = regraEmpresaSelecionada @@ -780,11 +781,48 @@ document.addEventListener("DOMContentLoaded", async () => { : "Nenhuma regra selecionada."; } + // Tipos de lançamento efetivamente cobertos agora mesmo pela regra + // especial selecionada — [] se o checkbox estiver desmarcado ou nenhuma + // regra tiver sido escolhida ainda (ver GET .../regras-empresa/, que + // devolve tipos_lancamento por regra). + function regraEmpresaTiposAtuais() { + if (!formTipoRegraEmpresa.checked || !regraEmpresaSelecionada) return []; + return regraEmpresaSelecionada.tipos_lancamento || []; + } + + // Trava (marca + desabilita + esconde o custeio) o checkbox de cada tipo + // coberto pela regra especial atual, e libera de volta (desmarca + + // reabilita) qualquer tipo que estava travado por uma regra anterior mas + // deixou de estar coberto (regra trocada, ou "Regra especial" desmarcada) + // — chamada sempre que a seleção de regra muda. Mutuamente exclusivo por + // TIPO, não em bloco: uma regra que só cobre mensalidade (ex.: Tecnomyl) + // deixa "Coparticipação" livre pra configuração manual normalmente. + function aplicarTiposRegraEmpresa() { + const tipos = regraEmpresaTiposAtuais(); + [ + { checkbox: formTipoMensalidade, custeio: custeioMensalidade, tipo: "mensalidade" }, + { checkbox: formTipoCoparticipacao, custeio: custeioCoparticipacao, tipo: "coparticipacao" }, + ].forEach(({ checkbox, custeio, tipo }) => { + if (!checkbox) return; + if (tipos.includes(tipo)) { + checkbox.checked = true; + checkbox.disabled = true; + custeio.hidden = true; + custeio.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false)); + } else if (checkbox.disabled) { + checkbox.checked = false; + checkbox.disabled = false; + custeio.hidden = true; + } + }); + } + function limparRegraEmpresa() { if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = false; if (regraEmpresaBox) regraEmpresaBox.hidden = true; regraEmpresaSelecionada = null; renderRegraEmpresaAtual(); + aplicarTiposRegraEmpresa(); } // Zera a parte de tipo/custeio do formulário de Cadastro de Regras @@ -792,7 +830,9 @@ document.addEventListener("DOMContentLoaded", async () => { // combinação tipo×pessoa) — usado ao entrar no modo "+ Nova regra". function limparCusteioForm() { formTipoMensalidade.checked = false; + formTipoMensalidade.disabled = false; formTipoCoparticipacao.checked = false; + formTipoCoparticipacao.disabled = false; limparRegraEmpresa(); custeioMensalidade.hidden = true; custeioCoparticipacao.hidden = true; @@ -828,16 +868,18 @@ document.addEventListener("DOMContentLoaded", async () => { * regra atual..." quanto (via os mesmos campos) pro submit da importação. */ function coletarCusteioAtual() { + const tiposRegraEmpresa = regraEmpresaTiposAtuais(); const tipos_lancamento = []; - if (formTipoMensalidade.checked || formTipoRegraEmpresa.checked) tipos_lancamento.push("mensalidade"); - if (formTipoCoparticipacao.checked) tipos_lancamento.push("coparticipacao"); + if (formTipoMensalidade.checked || tiposRegraEmpresa.includes("mensalidade")) tipos_lancamento.push("mensalidade"); + if (formTipoCoparticipacao.checked || tiposRegraEmpresa.includes("coparticipacao")) tipos_lancamento.push("coparticipacao"); const custeio_por_tipo = {}; PID_IPS_TIPOS.forEach((tipo) => { custeio_por_tipo[tipo] = {}; - // "Regra empresa" substitui o custeio manual de mensalidade — não há - // titular/dependente pra ler aqui (ver regraEmpresaSelecionada). - if (tipo === "mensalidade" && formTipoRegraEmpresa.checked) return; + // "Regra especial da empresa" substitui o custeio manual dos tipos + // que ela cobre — não há titular/dependente pra ler aqui pra esses + // tipos (ver regraEmpresaTiposAtuais()). + if (tiposRegraEmpresa.includes(tipo)) return; PID_IPS_PESSOAS.forEach((pessoa) => { const chave = `${tipo}-${pessoa}`; const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`); @@ -861,10 +903,11 @@ document.addEventListener("DOMContentLoaded", async () => { function mensagemErroCusteio(tipos) { if (!tipos.length) return "Selecione ao menos um tipo de importação."; if (formTipoRegraEmpresa.checked && !regraEmpresaSelecionada) { - return "Selecione a regra empresa a aplicar."; + return "Selecione a regra especial a aplicar."; } + const tiposRegraEmpresa = regraEmpresaTiposAtuais(); for (const tipo of tipos) { - if (tipo === "mensalidade" && formTipoRegraEmpresa.checked) continue; + if (tiposRegraEmpresa.includes(tipo)) continue; for (const pessoa of PID_IPS_PESSOAS) { const chave = `${tipo}-${pessoa}`; const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`; @@ -887,20 +930,30 @@ document.addEventListener("DOMContentLoaded", async () => { function aplicarCusteio(regra) { const tipos = Array.isArray(regra.tipos_lancamento) ? regra.tipos_lancamento : []; // Repõe o custeio de uma regra dentro do formulário de Cadastro de - // Regras — mutuamente exclusivo entre "mensalidade manual" e "mensalidade - // usa regra especial" (ver limparRegraEmpresa()). + // Regras — a regra especial (se houver) trava os tipos que ela cobre, + // os demais tipos selecionados seguem custeio manual (ver + // aplicarTiposRegraEmpresa()). limparRegraEmpresa(); if (regra.regra_empresa_chave) { const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); - regraEmpresaSelecionada = re || { key: regra.regra_empresa_chave, label: regra.regra_empresa_chave }; + regraEmpresaSelecionada = re || { key: regra.regra_empresa_chave, label: regra.regra_empresa_chave, tipos_lancamento: tipos }; if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = true; if (regraEmpresaBox) regraEmpresaBox.hidden = false; renderRegraEmpresaAtual(); } - formTipoMensalidade.checked = tipos.includes("mensalidade") && !regra.regra_empresa_chave; - custeioMensalidade.hidden = !formTipoMensalidade.checked; - formTipoCoparticipacao.checked = tipos.includes("coparticipacao"); - custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked; + const tiposRegraEmpresa = regraEmpresaTiposAtuais(); + aplicarTiposRegraEmpresa(); + // aplicarTiposRegraEmpresa() já cuidou dos tipos cobertos pela regra + // especial (checked+disabled+custeio escondido) — só os tipos NÃO + // cobertos ainda precisam refletir o `tipos_lancamento` salvo aqui. + if (!tiposRegraEmpresa.includes("mensalidade")) { + formTipoMensalidade.checked = tipos.includes("mensalidade"); + custeioMensalidade.hidden = !formTipoMensalidade.checked; + } + if (!tiposRegraEmpresa.includes("coparticipacao")) { + formTipoCoparticipacao.checked = tipos.includes("coparticipacao"); + custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked; + } const custeioPorTipo = regra.custeio_por_tipo || {}; PID_IPS_TIPOS.forEach((tipo) => { @@ -1124,6 +1177,15 @@ document.addEventListener("DOMContentLoaded", async () => { return entrada.modo; } + // Tipos de lançamento cobertos pela regra especial de código `chave` + // (busca em regrasEmpresaCache, já carregado de GET .../regras-empresa/) + // — [] se `chave` for vazia ou não encontrada. + function tiposCobertosPorRegraEmpresaChave(chave) { + if (!chave) return []; + const re = regrasEmpresaCache.find((r) => r.key === chave); + return (re && re.tipos_lancamento) || []; + } + function renderResumoRegra(regra) { if (!impResumo) return; if (!regra) { @@ -1132,12 +1194,13 @@ document.addEventListener("DOMContentLoaded", async () => { } const tipos = regra.tipos_lancamento || []; const custeioPorTipo = regra.custeio_por_tipo || {}; + const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave); impResumo.hidden = false; if (impResumoTipos) impResumoTipos.textContent = `Tipos cobertos por esta regra: ${tiposLabel(tipos)}`; if (impResumoMensalidade) { if (tipos.includes("mensalidade")) { - if (regra.regra_empresa_chave) { + if (tiposRegraEmpresa.includes("mensalidade")) { const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); impResumoMensalidade.textContent = `Mensalidade: usa regra especial da empresa — ${re ? re.label : regra.regra_empresa_chave}`; } else { @@ -1153,9 +1216,14 @@ document.addEventListener("DOMContentLoaded", async () => { if (impResumoCoparticipacao) { if (tipos.includes("coparticipacao")) { - const titular = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.titular); - const dependente = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.dependente); - impResumoCoparticipacao.textContent = `Coparticipação — Titular: ${titular}. Dependente: ${dependente}.`; + if (tiposRegraEmpresa.includes("coparticipacao")) { + const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); + impResumoCoparticipacao.textContent = `Coparticipação: usa regra especial da empresa — ${re ? re.label : regra.regra_empresa_chave}`; + } else { + const titular = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.titular); + const dependente = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.dependente); + impResumoCoparticipacao.textContent = `Coparticipação — Titular: ${titular}. Dependente: ${dependente}.`; + } impResumoCoparticipacao.hidden = false; } else { impResumoCoparticipacao.hidden = true; @@ -1184,8 +1252,9 @@ document.addEventListener("DOMContentLoaded", async () => { if (regra.regra_empresa_chave) { formData.append("regra_empresa", regra.regra_empresa_chave); } + const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave); (regra.tipos_lancamento || []).forEach((tipo) => { - if (tipo === "mensalidade" && regra.regra_empresa_chave) return; + if (tiposRegraEmpresa.includes(tipo)) return; PID_IPS_PESSOAS.forEach((pessoa) => { const entrada = (regra.custeio_por_tipo && regra.custeio_por_tipo[tipo] && regra.custeio_por_tipo[tipo][pessoa]) || {}; formData.append(`custeio_${tipo}_${pessoa}`, entrada.modo || ""); @@ -2086,10 +2155,10 @@ document.addEventListener("DOMContentLoaded", async () => { if (formTipoMensalidade) { formTipoMensalidade.addEventListener("change", () => { + // Enquanto travado por uma regra especial (ver aplicarTiposRegraEmpresa()), + // o checkbox fica disabled e este handler não dispara por clique do + // usuário — não precisa de exclusividade mútua explícita aqui. custeioMensalidade.hidden = !formTipoMensalidade.checked; - // Mutuamente exclusivo com "Regra empresa" — as duas são formas - // alternativas de configurar o mesmo tipo de lançamento "mensalidade". - if (formTipoMensalidade.checked) limparRegraEmpresa(); }); } if (formTipoCoparticipacao) { @@ -2100,15 +2169,13 @@ document.addEventListener("DOMContentLoaded", async () => { if (formTipoRegraEmpresa) { formTipoRegraEmpresa.addEventListener("change", () => { if (regraEmpresaBox) regraEmpresaBox.hidden = !formTipoRegraEmpresa.checked; - if (formTipoRegraEmpresa.checked) { - // Mesma exclusividade mútua do outro lado (formTipoMensalidade acima). - formTipoMensalidade.checked = false; - custeioMensalidade.hidden = true; - custeioMensalidade.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false)); - } else { - regraEmpresaSelecionada = null; - renderRegraEmpresaAtual(); - } + if (!formTipoRegraEmpresa.checked) regraEmpresaSelecionada = null; + renderRegraEmpresaAtual(); + // Enquanto nenhuma regra foi escolhida ainda (checkbox recém-marcado, + // regraEmpresaSelecionada ainda null), regraEmpresaTiposAtuais() é [] + // e nada é travado — "Mensalidade"/"Coparticipação" continuam livres + // até o usuário de fato selecionar uma regra no picker. + aplicarTiposRegraEmpresa(); }); } if (regraEmpresaSelecionarBtn) { @@ -2127,6 +2194,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (!regra) return; regraEmpresaSelecionada = regra; renderRegraEmpresaAtual(); + aplicarTiposRegraEmpresa(); if (regraEmpresaModal) regraEmpresaModal.hidden = true; }); } diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index c25c1c4..c9e36f5 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -437,7 +437,7 @@ Selecionar arquivo(s) - +
    @@ -666,10 +666,10 @@