From 7e91ba5e60197d3df9d8e14a392aa7441ddf440d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 26 Aug 2026 11:19:51 -0300 Subject: [PATCH] =?UTF-8?q?inclus=C3=A3o=20da=20operadora=20da=20ottimizza?= =?UTF-8?q?=20e=20regra=20especifica=20da=20mesma.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 29 ++- ...ormacoes_Plano_de_Saude_-_AGOSTO_2026.xlsx | Bin 0 -> 12054 bytes .../planilha_padrao/questor_1889_2026-08.csv | 67 +++++++ portal_api/planos_saude/matcher.py | 57 ++++-- .../operadoras/sulamerica/saude.py | 181 ++++++++++++++++++ portal_api/planos_saude/pipeline.py | 31 +-- portal_api/planos_saude/regras_empresa.py | 163 ++++++++++++---- portal_api/serializers.py | 58 ++++-- portal_api/views.py | 53 +++-- static/js/importacao-plano-saude.js | 144 ++++++++++---- templates/importacao-plano-saude.html | 6 +- 11 files changed, 635 insertions(+), 154 deletions(-) create mode 100644 media/planos_saude/operadora/Informacoes_Plano_de_Saude_-_AGOSTO_2026.xlsx create mode 100644 media/planos_saude/planilha_padrao/questor_1889_2026-08.csv create mode 100644 portal_api/planos_saude/operadoras/sulamerica/saude.py 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 0000000000000000000000000000000000000000..48fdf4d3823c5727d2011e08aabebccf37d7cc25 GIT binary patch literal 12054 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 B5^Dee literal 0 HcmV?d00001 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 @@