inclusão da operadora da ottimizza e regra especifica da mesma.

This commit is contained in:
Gabriel 2026-08-26 11:19:51 -03:00
parent 5ceffab0db
commit 7e91ba5e60
11 changed files with 635 additions and 154 deletions

View File

@ -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/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) ├── 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) ├── 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/<nome>/<arquivo>.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. Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.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/<nome>/<arquivo>.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 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 - <competência>") 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`. **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`) ### 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). 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`. - **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 `<input type="file">` 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 `<input type="file">`, 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 `<input>` 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. - **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, 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 `<input>` 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`. 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. - **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 `"<código> - <nome>"` direto do array `regras` já carregado, que já vem com `nome_empresa` resolvido pelo backend. - **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 `"<código> - <nome>"` 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=<chave>` 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. **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".
- **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").
- **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. - **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. - **`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. - **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()`. - **`_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. - **`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 ## CSS — organização entre arquivos

View File

@ -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;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 1889 ALEXSANDRA SOBRINHO DOS SANTOS CORREA 475.338.938-32 5775 01/03/2026 0 0
3 1889 ALINE MALAQUIAS DOS SANTOS OSTROWSKI 116.050.538-13 5775 01/03/2026 0 0
4 1889 ANA CAROLINE SITTA 081.814.409-27 5775 01/03/2026 0 0
5 1889 ANA PAULA DA COSTA POLICARPO 701.230.256-99 5775 01/03/2026 0 0
6 1889 ANA PAULA MURUSSI PEDROSO 028.444.820-66 5775 01/03/2026 0 0
7 1889 ANDRESSA NOGUEIRA CONCEICAO 122.944.789-08 5775 01/03/2026 0 0
8 1889 ARTHUR VALENTIM ONEDA 122.241.619-05 5775 01/03/2026 0 0
9 1889 BRENDA COSTA SOUZA 026.931.212-99 5775 01/03/2026 0 0
10 1889 BRENDA LEONIDIA D OLIVEIRA 012.255.939-80 5775 01/03/2026 0 0
11 1889 CAMILA TREVISO DE ARRUDA CAMARGO 087.958.229-41 5775 01/05/2026 0 0
12 1889 DANIEL DARLY MAIA DA SILVA 057.204.063-60 5775 01/03/2026 0 0
13 1889 DENIS DA SILVA MIRANDA 038.638.182-88 5775 01/03/2026 0 0
14 1889 EDUARDO BORGES 135.877.979-19 5775 01/07/2026 0 0
15 1889 EDUARDO MAYORKA CARDOSO 090.879.809-14 5775 01/03/2026 ELOISA LUCIANO MAYORKA CARDOSO 103.321.859-66 0 0
16 1889 EDUARDO MAYORKA CARDOSO 090.879.809-14 5775 01/03/2026 0 0
17 1889 ESTER COSTA DA SILVA 224.222.748-38 5775 01/03/2026 0 0
18 1889 FELIPE GABRIEL HAERTHEL 090.158.439-88 5775 01/03/2026 0 0
19 1889 FELIPE VIEIRA BAEHR 128.705.509-52 5775 01/07/2026 0 0
20 1889 FERNANDA SCHUBERT LONHESKI 124.486.689-06 5775 01/03/2026 0 0
21 1889 GABRIELA DE MEDEIROS BENTO BUZZI 114.020.649-42 5775 01/03/2026 0 0
22 1889 GABRIELA SCHIRMER 091.044.139-11 5775 01/03/2026 0 0
23 1889 GABRIELA SENHORAES VECCHIO 105.087.569-95 5775 01/03/2026 0 0
24 1889 GUILHERME DE OLIVEIRA ALVES 187.462.227-23 5775 01/03/2026 0 0
25 1889 GUSTAVO ALVES DA SILVA 118.468.069-84 5775 01/07/2026 0 0
26 1889 GUSTAVO CELSO BOZZANO 131.128.029-42 5775 01/03/2026 0 0
27 1889 IGOR DANIEL DE FATIMA SILVA 139.225.456-61 5775 01/03/2026 0 0
28 1889 ISABELLE COSTA DE MELO 521.432.518-58 5775 01/03/2026 0 0
29 1889 JACQUELINE DIAS BARBOSA 144.610.697-70 5775 01/07/2026 0 0
30 1889 JAMILY DOS SANTOS SOUSA 705.818.672-38 5775 01/03/2026 0 0
31 1889 JARBAS DE MORAIS FERREIRA JUNIOR 022.988.173-40 5775 01/03/2026 0 0
32 1889 JEFTER JOSUE XAVIER FERREIRA 022.479.226-19 5775 01/03/2026 0 0
33 1889 JEMIMA CRISTINA DA MAIA 137.791.479-80 5775 01/03/2026 0 0
34 1889 JESSICA ROSA DA SILVA 435.730.628-06 5775 01/03/2026 0 0
35 1889 JHONATAN LUIZ MORFIM 105.533.019-42 5775 01/07/2026 0 0
36 1889 JOAO PAULO RODRIGUES 108.143.759-60 5775 01/03/2026 0 0
37 1889 JOAO PEDRO LABUSSIERE FRANCA 865.883.405-08 5775 01/05/2026 0 0
38 1889 JOAO WALTER RIBEIRO 109.418.109-92 5775 01/03/2026 0 0
39 1889 JOSE LUCAS DE ALMEIDA BEILER 122.754.287-98 5775 01/03/2026 0 0
40 1889 JULIA ALVES DE SANTANA 140.505.279-10 5775 01/07/2026 0 0
41 1889 KARINE CAROLINA BLASKA 101.938.429-80 5775 01/03/2026 0 0
42 1889 LEONARDO AIRAM VIEIRA 105.518.279-98 5775 01/03/2026 0 0
43 1889 LETICIA DE MELO SILVA 104.059.196-54 5775 01/03/2026 0 0
44 1889 LINARA CARDOSO CIRILO 013.458.862-25 5775 01/03/2026 DEBORAH CARDOSO CIRILO 023.252.769-53 0 0
45 1889 LINARA CARDOSO CIRILO 013.458.862-25 5775 01/03/2026 0 0
46 1889 LODEMAR SANSAO JUNIOR 108.585.329-21 5775 01/05/2026 0 0
47 1889 LUANA RODRIGUES COELHO 512.533.848-60 5775 01/07/2026 0 0
48 1889 LUAN VICTOR DE RAMOS LUCIANO 077.485.059-00 5775 01/07/2026 0 0
49 1889 LUCAS ANDERTON ZILZ 098.116.759-45 5775 01/03/2026 0 0
50 1889 LUCAS LIEBL 105.519.349-99 5775 01/03/2026 0 0
51 1889 MATHEUS JOAO TAVARES RIBEIRO 164.971.746-64 5775 01/05/2026 0 0
52 1889 MATHEUS LAUTHARTE CARLOTTO 043.067.300-00 5775 01/05/2026 ISIS CARLOTTO 143.321.089-49 0 0
53 1889 MATHEUS LAUTHARTE CARLOTTO 043.067.300-00 5775 01/05/2026 0 0
54 1889 MAYRON NUNES CIRILO 946.483.522-20 5775 01/03/2026 0 0
55 1889 MONALIZA LOBATO GONCALVES 118.094.647-22 5775 01/03/2026 0 0
56 1889 NARA ELISA RODRIGUES BOCK 110.767.779-37 5775 01/03/2026 0 0
57 1889 PEDRO FATTORE DE MEDEIROS SILVA 354.820.648-40 5775 01/05/2026 0 0
58 1889 RAFAELLA SIMAO DIAS 521.088.518-60 5775 01/03/2026 0 0
59 1889 RITIELY SILVESTRE FELIPE 134.340.996-97 5775 01/03/2026 0 0
60 1889 RODRIGO HEINZEN DE MORAES 115.363.889-47 5775 01/03/2026 0 0
61 1889 RUAN MULLER COSTA GONZAGA 135.446.486-95 5775 01/03/2026 0 0
62 1889 SAMUEL SOUSA OLIVEIRA SANTOS 064.354.393-70 5775 01/03/2026 0 0
63 1889 SUELEN CARDOZO LEMOS 016.697.210-00 5775 01/08/2026 LOUISE LEMOS EIGAT 163.037.249-81 0 0
64 1889 SUELEN CARDOZO LEMOS 016.697.210-00 5775 01/08/2026 0 0
65 1889 VICTORIA RAQUEL LIMA MUSTAFA 030.331.932-18 5775 01/03/2026 0 0
66 1889 VICTORIA RAQUEL LIMA MUSTAFA 030.331.932-18 5775 01/03/2026 CLARA LUNA LIZ MUSTAFA PESSOA 085.395.182-95 0 0
67 1889 VITORIA APARECIDA BERNARDES BRAGA 019.977.056-56 5775 01/07/2026 0 0

View File

@ -32,12 +32,16 @@ Regras de negócio combinadas com o cliente:
- Beneficiário sem cadastro correspondente na planilha padrão (plano - 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. 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 Segundo acréscimo: `regra_empresa_fn` (opcional, implementado nas duas
estratégia "nome" — ver `_casa_por_nome`) substitui `regra_custeio` por estratégias — `_casa_por_nome` acumula por FAMÍLIA, `_casa_por_cpf`
uma função de custeio calculada por FAMÍLIA inteira em vez de por pessoa, acumula tudo de uma vez já que essa estratégia não tem noção de família)
pra regras especiais que não cabem no desenho normal (ver substitui `regra_custeio` por uma função de custeio especial cadastrada em
portal_api.planos_saude.regras_empresa) — resolvida e validada em código pra regras que não cabem no desenho normal titular/dependente ×
pipeline.processa_importacao antes de chegar aqui. 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 Terceiro acréscimo: `vinculos_por_nome` (opcional, só na estratégia
"nome") — um "DE/PARA" persistente (`portal_api.models.VinculoNomeOperadora`, "nome") — um "DE/PARA" persistente (`portal_api.models.VinculoNomeOperadora`,
@ -189,10 +193,24 @@ def _casa_por_cpf(
individuos: List[Individuo], individuos: List[Individuo],
linhas_sistema: List[LinhaSistema], linhas_sistema: List[LinhaSistema],
regra_custeio: Optional[dict] = None, regra_custeio: Optional[dict] = None,
regra_empresa_fn: Optional[Callable[[List[Tuple[LinhaSistema, float]], str], None]] = None,
tipo_lancamento: str = "",
**_ignorado, **_ignorado,
) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]: ) -> 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) titulares, dependentes = _indexa_por_cpf(linhas_sistema)
auditoria: List[ItemAuditoria] = [] auditoria: List[ItemAuditoria] = []
linhas_e_valores: List[Tuple[LinhaSistema, float]] = []
for ind in individuos: for ind in individuos:
if ind.valor_total < 0: if ind.valor_total < 0:
@ -207,8 +225,14 @@ def _casa_por_cpf(
auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema.")) auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema."))
continue continue
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)) _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 # Casamento por CPF nunca precisa do DE/PARA de nomes divergentes (ver
# _casa_por_nome) — o CPF já é exato por natureza. # _casa_por_nome) — o CPF já é exato por natureza.
return linhas_sistema, auditoria, [] return linhas_sistema, auditoria, []
@ -237,14 +261,15 @@ def _casa_por_nome(
coparticipação) — sem esse mapa global, a família "perderia" o coparticipação) — sem esse mapa global, a família "perderia" o
titular ao filtrar por tipo antes de casar. titular ao filtrar por tipo antes de casar.
`regra_empresa_fn`, quando informada (só usada para "mensalidade" — `regra_empresa_fn`, quando informada (pra qualquer tipo de lançamento
ver portal_api.planos_saude.regras_empresa), substitui o custeio coberto pela regra — ver portal_api.planos_saude.regras_empresa),
normal por pessoa (`regra_custeio`/`_aplica_regra_custeio`): em vez de substitui o custeio normal por pessoa (`regra_custeio`/
aplicar a regra linha a linha assim que cada membro é resolvido, as `_aplica_regra_custeio`): em vez de aplicar a regra linha a linha
linhas/valores da família inteira são acumulados primeiro em assim que cada membro é resolvido, as linhas/valores da família
`linhas_e_valores` e só then passados de uma vez pra `regra_empresa_fn` inteira são acumulados primeiro em `linhas_e_valores` e só depois
no fim do laço da família — ela decide como dividir entre as linhas passados de uma vez pra `regra_empresa_fn` no fim do laço da família
(normalmente um teto por família, não por pessoa). — 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 `vinculos_por_nome` (nome normalizado, como veio do arquivo da
operadora, -> VinculoNome) é o "DE/PARA" persistente: quando um titular 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)) _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: 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 return linhas_sistema, auditoria, vinculos_aplicados

View File

@ -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 - <competência>", 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]

View File

@ -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.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade
from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude 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.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.saude import UnimedSaude
from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude
from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude
@ -74,6 +75,11 @@ OPERADORAS = {
"nome": "SulAmérica Odonto", "nome": "SulAmérica Odonto",
"parser": SulAmericaOdontoMensalidade, "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). fontes usar é de quem chama, em views.py).
`regra_empresa_key`, quando informado, substitui o custeio configurável `regra_empresa_key`, quando informado, substitui o custeio configurável
de "mensalidade" por uma regra especial cadastrada em dos tipos de lançamento que a regra cobre (`REGRAS_EMPRESA[chave]
portal_api.planos_saude.regras_empresa (ex.: teto de custeio por ["tipos_lancamento"]` — ver portal_api.planos_saude.regras_empresa) por
família) — `custeio_por_tipo["mensalidade"]` é ignorado nesse caso uma regra especial cadastrada em código (ex.: teto de custeio por
(ImportacaoPlanoSaudeCreateSerializer já garante que vem vazio). família, ou um critério fixo por tipo de beneficiário) —
Levanta RegraEmpresaIncompativelError se a regra não servir pra esta `custeio_por_tipo[tipo]` desses tipos é ignorado nesse caso
operadora/planilha (propagada pra fora, não é um erro de arquivo). (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 `vinculos_por_nome` (opcional, só usado pela estratégia "nome" — ver
matcher._casa_por_nome) é o "DE/PARA" de nomes divergentes já matcher._casa_por_nome) é o "DE/PARA" de nomes divergentes já
@ -188,11 +198,10 @@ def processa_importacao(
individuos = _agrega_individuos_entre_arquivos(individuos) individuos = _agrega_individuos_entre_arquivos(individuos)
regra_empresa_fn = None regra_empresa_fn = None
regra_empresa_tipos: Tuple[str, ...] = ()
if regra_empresa_key: if regra_empresa_key:
# regra_empresa só se aplica a "mensalidade" (ver mais abaixo e em regra_empresa_fn, regra_empresa_tipos = valida_regra_empresa(
# ImportacaoPlanoSaudeCreateSerializer.validate()). regra_empresa_key, parser_operadora.chave_casamento_para_tipo, linhas_sistema_template, tipos_selecionados
regra_empresa_fn = valida_regra_empresa(
regra_empresa_key, parser_operadora.chave_casamento_para_tipo("mensalidade"), linhas_sistema_template
) )
individuos_por_tipo: Dict[str, list] = {} individuos_por_tipo: Dict[str, list] = {}
@ -222,7 +231,7 @@ def processa_importacao(
parser_operadora.chave_casamento_para_tipo(tipo_lancamento), parser_operadora.chave_casamento_para_tipo(tipo_lancamento),
regra_custeio=custeio_por_tipo[tipo_lancamento], regra_custeio=custeio_por_tipo[tipo_lancamento],
nomes_titular_por_numero=nomes_titular_por_numero, 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, vinculos_por_nome=vinculos_por_nome,
tipo_lancamento=tipo_lancamento, tipo_lancamento=tipo_lancamento,
) )

View File

@ -1,21 +1,28 @@
""" """
Regras de custeio especiais por empresa ("Regra empresa" na tela de nova Regras de custeio especiais por empresa ("Regra especial da empresa" na tela
importação, terceiro item de "Tipo de importação" ao lado de Mensalidade/ de nova importação/Cadastro de Regras) — ao contrário de
Coparticipação) — ao contrário de RegraCusteioPlanoSaude (banco de regras RegraCusteioPlanoSaude (banco de regras salvas, editável pelo usuário,
salvas, editável pelo usuário, sempre no formato "por tipo de lançamento × sempre no formato "por tipo de lançamento × titular/dependente"), as regras
titular/dependente"), as regras aqui são cadastradas diretamente no código aqui são cadastradas diretamente no código pelo desenvolvedor quando o
pelo desenvolvedor quando o cliente repassa uma regra negociada com uma cliente repassa uma regra negociada com uma empresa específica que não se
empresa específica que não se encaixa nesse formato — tipicamente porque encaixa nesse formato — tipicamente porque são calculadas por FAMÍLIA
são calculadas por FAMÍLIA (titular + todos os dependentes juntos), não (titular + todos os dependentes juntos), não por pessoa, ou porque o
por pessoa. Nunca expostas para o usuário cadastrar pela tela; só critério de quem paga o quê não é um percentual/teto simples. Nunca
"Selecionar regra" na tela de nova importação, que lista as chaves já expostas para o usuário cadastrar pela tela; só "Selecionar regra" no
registradas aqui (GET /api/importacoes-plano-saude/regras-empresa/). 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 Cada regra declara, além da função `aplica`, quais tipos de lançamento ela
segue o custeio normal configurado na tela (por isso REGRAS_EMPRESA não cobre (`tipos_lancamento` — nem toda regra cobre os dois; a Tecnomyl abaixo
tem nada de coparticipação), e é por isso que "Regra empresa" e só cobre "mensalidade", coparticipação dela segue o custeio normal
"Mensalidade" são mutuamente exclusivos no formulário: as duas são formas configurado na importação) e qual estratégia de casamento ela exige
alternativas de configurar o MESMO tipo de lançamento "mensalidade". (`chave_casamento` — "nome" pra regras que dependem de agrupar uma família
inteira, como a Tecnomyl; "cpf" pra regras que decidem por pessoa, sem
precisar de família, como a Ottimizza abaixo). `aplica(linhas_e_valores,
tipo_lancamento)` recebe sempre os dois argumentos — o segundo permite que
uma mesma regra se comporte diferente por tipo de lançamento (ex.: a
Ottimizza abaixo aplica um critério pra mensalidade e outro, fixo, pra
coparticipação).
""" """
from typing import Any, Callable, Dict, List, Tuple from typing import Any, Callable, Dict, List, Tuple
@ -25,15 +32,16 @@ from portal_api.planos_saude.modelos import LinhaSistema
class RegraEmpresaIncompativelError(Exception): class RegraEmpresaIncompativelError(Exception):
"""Regra empresa selecionada não é compatível com esta importação """Regra empresa selecionada não é compatível com esta importação
(operadora sem casamento por nome, ou planilha padrão de uma empresa (tipo de lançamento não coberto pela regra, operadora com estratégia de
diferente daquela pra qual a regra foi cadastrada) — views.py devolve casamento diferente da que a regra exige, ou planilha padrão de uma
esta mensagem direto pro usuário, em vez do erro genérico de "formato empresa diferente daquela pra qual a regra foi cadastrada) — views.py
de arquivo não conforme".""" 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: def _eh_linha_titular(linha: Any) -> bool:
"""Mesmo critério de `LinhaSistema.eh_linha_titular()`, mas duck-typed — """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 criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model
Django, no recálculo pós "Vincular pessoa" — ver Django, no recálculo pós "Vincular pessoa" — ver
`views._recalcula_familia_regra_empresa`), que não tem esse método.""" `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) 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 """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 por família (titular + dependentes juntos, não por pessoa), repassada
pelo cliente em 08/2026: família com mensalidade total acima do teto 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 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) _aplica_teto_familia(linhas_e_valores, teto=661.61)
# Pra cadastrar uma regra nova: escrever a função `_regra_...(linhas_e_valores)` def _regra_sulamerica_5775_ottimizza(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
# acima (ou reaproveitar `_aplica_teto_familia` se for só um teto por família) """Ottimizza (código 1889) na SulAmérica (5775) — critério fixo, sem
# e registrar aqui. `codigo_empresa` é o código da empresa na planilha padrão depender de teto/percentual: a mensalidade do titular é 100% custeada
# pra qual a regra foi negociada — usado só pra travar contra aplicar a regra pela empresa e a do dependente é 100% descontada do empregado; toda
# errada numa planilha de outra empresa (ver `valida_regra_empresa` abaixo). coparticipação (titular ou dependente) é 100% descontada do empregado.
# `observacoes` é opcional (texto livre explicando a regra em português) — Confirmado contra a planilha "Informações Plano de Saúde" real da
# exibida só-leitura no topo da tela de revisão (ver "regra_empresa_observacoes" Ottimizza (competência 08/2026): toda linha de titular só vem com
# em ImportacaoPlanoSaudeDetailSerializer) pra o colaborador conferir a regra "Benefício Mensalidade" preenchido, toda linha de dependente só vem com
# aplicada sem precisar abrir o código. "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] = { REGRAS_EMPRESA: Dict[str, dict] = {
"unimed_1778_tecnomyl": { "unimed_1778_tecnomyl": {
"label": "1778 - Unimed (Tecnomyl)", "label": "1778 - Unimed (Tecnomyl)",
"codigo_empresa": "1778", "codigo_empresa": "1778",
"operadora": "unimed_saude", "operadora": "unimed_saude",
"chave_casamento": "nome",
"tipos_lancamento": ("mensalidade",),
"aplica": _regra_unimed_1778_tecnomyl, "aplica": _regra_unimed_1778_tecnomyl,
"observacoes": ( "observacoes": (
"A Tecnomyl oferece uma ajuda de custo de até R$ 661,61 por família " "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." "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 [ 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() for chave, dados in REGRAS_EMPRESA.items()
] ]
def valida_regra_empresa( def valida_regra_empresa(
regra_empresa_key: str, chave_casamento: str, linhas_sistema: List[LinhaSistema] regra_empresa_key: str,
) -> Callable[[List[Tuple[Any, float]]], None]: 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 """Valida que a regra empresa escolhida pode ser aplicada nesta
importação e devolve a função de aplicação já resolvida — chamado por importação e devolve `(aplica, tipos_cobertos)` já resolvidos —
pipeline.processa_importacao antes de rodar o casamento de mensalidade.""" `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) regra = REGRAS_EMPRESA.get(regra_empresa_key)
if regra is None: if regra is None:
raise RegraEmpresaIncompativelError(f"Regra empresa desconhecida: {regra_empresa_key!r}.") 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( 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") codigo_esperado = regra.get("codigo_empresa")
if codigo_esperado and not any(l.codigo_empresa == codigo_esperado for l in linhas_sistema): if codigo_esperado and not any(l.codigo_empresa == codigo_esperado for l in linhas_sistema):
raise RegraEmpresaIncompativelError( raise RegraEmpresaIncompativelError(
f"A regra \"{regra['label']}\" foi cadastrada para a empresa código {codigo_esperado}, " 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." "mas a planilha padrão anexada não tem nenhuma linha com esse código."
) )
return regra["aplica"] return regra["aplica"], tipos_cobertos

View File

@ -580,17 +580,32 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."}) raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."})
regra_empresa_key = (attrs.get("regra_empresa") or "").strip() regra_empresa_key = (attrs.get("regra_empresa") or "").strip()
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_key: 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}"}) 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( raise serializers.ValidationError(
{"regra_empresa": "'Regra empresa' exige o tipo de importação 'Mensalidade' selecionado."} {
"regra_empresa": (
f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
"mas nenhum desses tipos de importação está selecionado."
) )
parser_classe = OPERADORAS[attrs["operadora"]]["parser"] }
if parser_classe.chave_casamento != "nome": )
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( raise serializers.ValidationError(
{"regra_empresa": "Esta operadora não é compatível com 'Regra empresa' (requer casamento por nome)."} {
"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] = {} custeio_por_tipo: dict[str, Any] = {}
@ -598,10 +613,10 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
if tipo not in TIPOS_LANCAMENTO_VALIDOS: if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"}) raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"})
# "Regra empresa" substitui o custeio manual de mensalidade — # "Regra empresa" substitui o custeio manual dos tipos que ela
# sem titular/dependente pra configurar aqui (ver models.py # cobre — sem titular/dependente pra configurar aqui (ver
# ImportacaoPlanoSaude.regra_empresa). # models.py ImportacaoPlanoSaude.regra_empresa).
if tipo == "mensalidade" and regra_empresa_key: if tipo in regra_empresa_tipos:
custeio_por_tipo[tipo] = {} custeio_por_tipo[tipo] = {}
continue continue
@ -1026,11 +1041,18 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
if not isinstance(custeio_bruto, dict): if not isinstance(custeio_bruto, dict):
raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."}) raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."})
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_chave: if regra_empresa_chave:
regra_re = REGRAS_EMPRESA[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( 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: if regra_re["codigo_empresa"] != codigo_empresa:
raise serializers.ValidationError( raise serializers.ValidationError(
@ -1048,10 +1070,10 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
custeio_validado: dict[str, Any] = {} custeio_validado: dict[str, Any] = {}
for tipo in tipos: for tipo in tipos:
# "Regra empresa" substitui o custeio manual de mensalidade — # "Regra empresa" substitui o custeio manual dos tipos que ela
# sem titular/dependente pra configurar aqui (mesmo padrão de # cobre — sem titular/dependente pra configurar aqui (mesmo
# ImportacaoPlanoSaudeCreateSerializer.validate()). # padrão de ImportacaoPlanoSaudeCreateSerializer.validate()).
if tipo == "mensalidade" and regra_empresa_chave: if tipo in regra_empresa_tipos:
custeio_validado[tipo] = {} custeio_validado[tipo] = {}
continue continue

View File

@ -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]: def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict[str, Any]:
"""Roda o parser da operadora escolhida (`OperadoraParser.extrai`) sobre o """Roda o parser da operadora escolhida (`OperadoraParser.extrai`) sobre o
arquivo recém-anexado — mesma extração usada em processa_importacao, só arquivo recém-anexado — mesma extração usada em processa_importacao, só
que descartada em seguida (nada é salvo/persistido aqui).""" que descartada em seguida (nada é salvo/persistido aqui). O sufixo do
sufixo = ".pdf" if (arquivo.name or "").lower().endswith(".pdf") else ".csv" 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) caminho = _salva_arquivo_temporario(arquivo, sufixo)
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key] operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
try: try:
@ -1297,26 +1305,29 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet):
def _recalcula_familia_regra_empresa(importacao: ImportacaoPlanoSaude, linha: ImportacaoPlanoSaudeLinha) -> None: def _recalcula_familia_regra_empresa(importacao: ImportacaoPlanoSaude, linha: ImportacaoPlanoSaudeLinha) -> None:
"""Reaplica a regra empresa (`planos_saude.regras_empresa`) pra TODA a """Reaplica a regra empresa (`planos_saude.regras_empresa`) pra TODA a
família de `linha` (mesmo `nome_func`, mesmo `tipo_lancamento`) — chamado família de `linha` (mesmo `nome_func`, mesmo `tipo_lancamento` — o
depois de "Vincular pessoa" (ImportacaoPlanoSaudeAuditoriaViewSet.resolver) próprio `linha.tipo_lancamento`, não mais fixo em "mensalidade" desde
resolver um item de auditoria de mensalidade numa importação com que a regra empresa passou a poder cobrir também "coparticipacao", ver
`regra_empresa` configurada: o valor recém-vinculado muda o total da REGRAS_EMPRESA) — chamado depois de "Vincular pessoa"
família, então o teto (`regras_empresa._aplica_teto_familia`, ou (ImportacaoPlanoSaudeAuditoriaViewSet.resolver) resolver um item de
equivalente) precisa ser redistribuído entre TODAS as linhas da família auditoria numa importação com `regra_empresa` configurada: o valor
de novo — nunca só a que acabou de ser vinculada, senão o resultado recém-vinculado muda o total da família, então a regra precisa ser
ignora a regra empresa e cai no custeio padrão (100% desconto do reaplicada a TODAS as linhas da família de novo — nunca só a que
empregado), que é exatamente o bug que isso corrige. O valor "bruto" de acabou de ser vinculada, senão o resultado ignora a regra empresa e
cada linha já lançada é recuperado como `valor_empresa + valor` — essa cai no custeio padrão (100% desconto do empregado), que é exatamente
soma sempre preserva o total da mensalidade, independente de qual split o bug que isso corrige. O valor "bruto" de cada linha já lançada é
foi aplicado antes.""" 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) regra = planos_saude_regras_empresa.REGRAS_EMPRESA.get(importacao.regra_empresa)
if not regra: if not regra:
return return
linhas_familia = list( 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] 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"]) 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": 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."}) 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 # "Regra empresa" é calculada por FAMÍLIA inteira (ver
# regras_empresa.py), não por pessoa — não dá pra aplicar só # regras_empresa.py), não por pessoa — não dá pra aplicar só
# nesta linha isoladamente (senão cairia no custeio padrão, # nesta linha isoladamente (senão cairia no custeio padrão,

View File

@ -355,9 +355,10 @@ document.addEventListener("DOMContentLoaded", async () => {
let vincularLinhaSelecionada = null; let vincularLinhaSelecionada = null;
let operadorasCache = []; let operadorasCache = [];
// Regras empresa (portal_api.planos_saude.regras_empresa) — registro fixo // Regras empresa (portal_api.planos_saude.regras_empresa) — registro fixo
// no código; regraEmpresaSelecionada guarda {key, label} da regra // no código; regraEmpresaSelecionada guarda {key, label, tipos_lancamento}
// atualmente escolhida no checkbox "Mensalidade usa regra especial" (ver // da regra atualmente escolhida no checkbox "Regra especial da empresa"
// ips-regra-empresa-modal), dentro do formulário de Cadastro de Regras. // (ver ips-regra-empresa-modal), dentro do formulário de Cadastro de
// Regras.
let regrasEmpresaCache = []; let regrasEmpresaCache = [];
let regraEmpresaSelecionada = null; let regraEmpresaSelecionada = null;
// Banco de regras de custeio cadastradas por empresa+operadora — fonte // Banco de regras de custeio cadastradas por empresa+operadora — fonte
@ -769,10 +770,10 @@ document.addEventListener("DOMContentLoaded", async () => {
renderList(); renderList();
} }
// "Regra empresa" (terceiro tipo de importação, mutuamente exclusivo com // "Regra especial da empresa" ("Regra empresa" — cobre um subconjunto de
// "Mensalidade" — as duas são formas alternativas de configurar o mesmo // tipos de lançamento, mensalidade e/ou coparticipação conforme a regra
// tipo de lançamento "mensalidade", ver regras_empresa.py) — zera o // escolhida, ver regras_empresa.py) — zera o checkbox, esconde a caixa e
// checkbox, esconde a caixa e limpa a seleção em memória. // limpa a seleção em memória.
function renderRegraEmpresaAtual() { function renderRegraEmpresaAtual() {
if (!regraEmpresaAtual) return; if (!regraEmpresaAtual) return;
regraEmpresaAtual.textContent = regraEmpresaSelecionada regraEmpresaAtual.textContent = regraEmpresaSelecionada
@ -780,11 +781,48 @@ document.addEventListener("DOMContentLoaded", async () => {
: "Nenhuma regra selecionada."; : "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() { function limparRegraEmpresa() {
if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = false; if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = false;
if (regraEmpresaBox) regraEmpresaBox.hidden = true; if (regraEmpresaBox) regraEmpresaBox.hidden = true;
regraEmpresaSelecionada = null; regraEmpresaSelecionada = null;
renderRegraEmpresaAtual(); renderRegraEmpresaAtual();
aplicarTiposRegraEmpresa();
} }
// Zera a parte de tipo/custeio do formulário de Cadastro de Regras // 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". // combinação tipo×pessoa) — usado ao entrar no modo "+ Nova regra".
function limparCusteioForm() { function limparCusteioForm() {
formTipoMensalidade.checked = false; formTipoMensalidade.checked = false;
formTipoMensalidade.disabled = false;
formTipoCoparticipacao.checked = false; formTipoCoparticipacao.checked = false;
formTipoCoparticipacao.disabled = false;
limparRegraEmpresa(); limparRegraEmpresa();
custeioMensalidade.hidden = true; custeioMensalidade.hidden = true;
custeioCoparticipacao.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. * regra atual..." quanto (via os mesmos campos) pro submit da importação.
*/ */
function coletarCusteioAtual() { function coletarCusteioAtual() {
const tiposRegraEmpresa = regraEmpresaTiposAtuais();
const tipos_lancamento = []; const tipos_lancamento = [];
if (formTipoMensalidade.checked || formTipoRegraEmpresa.checked) tipos_lancamento.push("mensalidade"); if (formTipoMensalidade.checked || tiposRegraEmpresa.includes("mensalidade")) tipos_lancamento.push("mensalidade");
if (formTipoCoparticipacao.checked) tipos_lancamento.push("coparticipacao"); if (formTipoCoparticipacao.checked || tiposRegraEmpresa.includes("coparticipacao")) tipos_lancamento.push("coparticipacao");
const custeio_por_tipo = {}; const custeio_por_tipo = {};
PID_IPS_TIPOS.forEach((tipo) => { PID_IPS_TIPOS.forEach((tipo) => {
custeio_por_tipo[tipo] = {}; custeio_por_tipo[tipo] = {};
// "Regra empresa" substitui o custeio manual de mensalidade — não há // "Regra especial da empresa" substitui o custeio manual dos tipos
// titular/dependente pra ler aqui (ver regraEmpresaSelecionada). // que ela cobre — não há titular/dependente pra ler aqui pra esses
if (tipo === "mensalidade" && formTipoRegraEmpresa.checked) return; // tipos (ver regraEmpresaTiposAtuais()).
if (tiposRegraEmpresa.includes(tipo)) return;
PID_IPS_PESSOAS.forEach((pessoa) => { PID_IPS_PESSOAS.forEach((pessoa) => {
const chave = `${tipo}-${pessoa}`; const chave = `${tipo}-${pessoa}`;
const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`); const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`);
@ -861,10 +903,11 @@ document.addEventListener("DOMContentLoaded", async () => {
function mensagemErroCusteio(tipos) { function mensagemErroCusteio(tipos) {
if (!tipos.length) return "Selecione ao menos um tipo de importação."; if (!tipos.length) return "Selecione ao menos um tipo de importação.";
if (formTipoRegraEmpresa.checked && !regraEmpresaSelecionada) { 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) { for (const tipo of tipos) {
if (tipo === "mensalidade" && formTipoRegraEmpresa.checked) continue; if (tiposRegraEmpresa.includes(tipo)) continue;
for (const pessoa of PID_IPS_PESSOAS) { for (const pessoa of PID_IPS_PESSOAS) {
const chave = `${tipo}-${pessoa}`; const chave = `${tipo}-${pessoa}`;
const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`; const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`;
@ -887,20 +930,30 @@ document.addEventListener("DOMContentLoaded", async () => {
function aplicarCusteio(regra) { function aplicarCusteio(regra) {
const tipos = Array.isArray(regra.tipos_lancamento) ? regra.tipos_lancamento : []; const tipos = Array.isArray(regra.tipos_lancamento) ? regra.tipos_lancamento : [];
// Repõe o custeio de uma regra dentro do formulário de Cadastro de // Repõe o custeio de uma regra dentro do formulário de Cadastro de
// Regras — mutuamente exclusivo entre "mensalidade manual" e "mensalidade // Regras — a regra especial (se houver) trava os tipos que ela cobre,
// usa regra especial" (ver limparRegraEmpresa()). // os demais tipos selecionados seguem custeio manual (ver
// aplicarTiposRegraEmpresa()).
limparRegraEmpresa(); limparRegraEmpresa();
if (regra.regra_empresa_chave) { if (regra.regra_empresa_chave) {
const re = regrasEmpresaCache.find((r) => r.key === 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 (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = true;
if (regraEmpresaBox) regraEmpresaBox.hidden = false; if (regraEmpresaBox) regraEmpresaBox.hidden = false;
renderRegraEmpresaAtual(); renderRegraEmpresaAtual();
} }
formTipoMensalidade.checked = tipos.includes("mensalidade") && !regra.regra_empresa_chave; 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; custeioMensalidade.hidden = !formTipoMensalidade.checked;
}
if (!tiposRegraEmpresa.includes("coparticipacao")) {
formTipoCoparticipacao.checked = tipos.includes("coparticipacao"); formTipoCoparticipacao.checked = tipos.includes("coparticipacao");
custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked; custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked;
}
const custeioPorTipo = regra.custeio_por_tipo || {}; const custeioPorTipo = regra.custeio_por_tipo || {};
PID_IPS_TIPOS.forEach((tipo) => { PID_IPS_TIPOS.forEach((tipo) => {
@ -1124,6 +1177,15 @@ document.addEventListener("DOMContentLoaded", async () => {
return entrada.modo; 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) { function renderResumoRegra(regra) {
if (!impResumo) return; if (!impResumo) return;
if (!regra) { if (!regra) {
@ -1132,12 +1194,13 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
const tipos = regra.tipos_lancamento || []; const tipos = regra.tipos_lancamento || [];
const custeioPorTipo = regra.custeio_por_tipo || {}; const custeioPorTipo = regra.custeio_por_tipo || {};
const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave);
impResumo.hidden = false; impResumo.hidden = false;
if (impResumoTipos) impResumoTipos.textContent = `Tipos cobertos por esta regra: ${tiposLabel(tipos)}`; if (impResumoTipos) impResumoTipos.textContent = `Tipos cobertos por esta regra: ${tiposLabel(tipos)}`;
if (impResumoMensalidade) { if (impResumoMensalidade) {
if (tipos.includes("mensalidade")) { if (tipos.includes("mensalidade")) {
if (regra.regra_empresa_chave) { if (tiposRegraEmpresa.includes("mensalidade")) {
const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); 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}`; impResumoMensalidade.textContent = `Mensalidade: usa regra especial da empresa — ${re ? re.label : regra.regra_empresa_chave}`;
} else { } else {
@ -1153,9 +1216,14 @@ document.addEventListener("DOMContentLoaded", async () => {
if (impResumoCoparticipacao) { if (impResumoCoparticipacao) {
if (tipos.includes("coparticipacao")) { if (tipos.includes("coparticipacao")) {
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 titular = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.titular);
const dependente = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.dependente); const dependente = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.dependente);
impResumoCoparticipacao.textContent = `Coparticipação — Titular: ${titular}. Dependente: ${dependente}.`; impResumoCoparticipacao.textContent = `Coparticipação — Titular: ${titular}. Dependente: ${dependente}.`;
}
impResumoCoparticipacao.hidden = false; impResumoCoparticipacao.hidden = false;
} else { } else {
impResumoCoparticipacao.hidden = true; impResumoCoparticipacao.hidden = true;
@ -1184,8 +1252,9 @@ document.addEventListener("DOMContentLoaded", async () => {
if (regra.regra_empresa_chave) { if (regra.regra_empresa_chave) {
formData.append("regra_empresa", regra.regra_empresa_chave); formData.append("regra_empresa", regra.regra_empresa_chave);
} }
const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave);
(regra.tipos_lancamento || []).forEach((tipo) => { (regra.tipos_lancamento || []).forEach((tipo) => {
if (tipo === "mensalidade" && regra.regra_empresa_chave) return; if (tiposRegraEmpresa.includes(tipo)) return;
PID_IPS_PESSOAS.forEach((pessoa) => { PID_IPS_PESSOAS.forEach((pessoa) => {
const entrada = (regra.custeio_por_tipo && regra.custeio_por_tipo[tipo] && regra.custeio_por_tipo[tipo][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 || ""); formData.append(`custeio_${tipo}_${pessoa}`, entrada.modo || "");
@ -2086,10 +2155,10 @@ document.addEventListener("DOMContentLoaded", async () => {
if (formTipoMensalidade) { if (formTipoMensalidade) {
formTipoMensalidade.addEventListener("change", () => { 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; 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) { if (formTipoCoparticipacao) {
@ -2100,15 +2169,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (formTipoRegraEmpresa) { if (formTipoRegraEmpresa) {
formTipoRegraEmpresa.addEventListener("change", () => { formTipoRegraEmpresa.addEventListener("change", () => {
if (regraEmpresaBox) regraEmpresaBox.hidden = !formTipoRegraEmpresa.checked; if (regraEmpresaBox) regraEmpresaBox.hidden = !formTipoRegraEmpresa.checked;
if (formTipoRegraEmpresa.checked) { if (!formTipoRegraEmpresa.checked) regraEmpresaSelecionada = null;
// 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(); 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) { if (regraEmpresaSelecionarBtn) {
@ -2127,6 +2194,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!regra) return; if (!regra) return;
regraEmpresaSelecionada = regra; regraEmpresaSelecionada = regra;
renderRegraEmpresaAtual(); renderRegraEmpresaAtual();
aplicarTiposRegraEmpresa();
if (regraEmpresaModal) regraEmpresaModal.hidden = true; if (regraEmpresaModal) regraEmpresaModal.hidden = true;
}); });
} }

View File

@ -437,7 +437,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo(s) Selecionar arquivo(s)
</label> </label>
<input type="file" id="ips-form-arquivo" accept=".csv,.pdf" class="ips-file-field__input" multiple /> <input type="file" id="ips-form-arquivo" accept=".csv,.pdf,.xlsx" class="ips-file-field__input" multiple />
</div> </div>
<ul class="ips-arquivo-list" id="ips-form-arquivo-list"></ul> <ul class="ips-arquivo-list" id="ips-form-arquivo-list"></ul>
</div> </div>
@ -666,10 +666,10 @@
<div class="ips-tipo-option"> <div class="ips-tipo-option">
<label class="modal-checkbox"> <label class="modal-checkbox">
<input type="checkbox" id="ips-form-tipo-regra-empresa" /> <input type="checkbox" id="ips-form-tipo-regra-empresa" />
Mensalidade usa regra especial da empresa Regra especial da empresa
</label> </label>
<div class="ips-regra-empresa-box" id="ips-regra-empresa-box" hidden> <div class="ips-regra-empresa-box" id="ips-regra-empresa-box" hidden>
<p class="ips-regra-empresa-box__hint">Mensalidade custeada por uma regra especial cadastrada pela Inovação — substitui o custeio manual de titular/dependente acima.</p> <p class="ips-regra-empresa-box__hint">Os tipos de lançamento cobertos por esta regra (mensalidade e/ou coparticipação, conforme a regra escolhida) são custeados automaticamente por ela — substitui o custeio manual de titular/dependente acima, só para esses tipos.</p>
<p class="ips-regra-empresa-box__atual" id="ips-regra-empresa-atual">Nenhuma regra selecionada.</p> <p class="ips-regra-empresa-box__atual" id="ips-regra-empresa-atual">Nenhuma regra selecionada.</p>
<button type="button" class="btn-outline" id="ips-regra-empresa-selecionar-btn">Selecionar regra</button> <button type="button" class="btn-outline" id="ips-regra-empresa-selecionar-btn">Selecionar regra</button>
</div> </div>