inclusão da operadora da ottimizza e regra especifica da mesma.
This commit is contained in:
parent
5ceffab0db
commit
7e91ba5e60
25
CLAUDE.md
25
CLAUDE.md
@ -568,7 +568,8 @@ portal_api/planos_saude/
|
|||||||
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
|
├── bradesco/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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
67
media/planos_saude/planilha_padrao/questor_1889_2026-08.csv
Normal file
67
media/planos_saude/planilha_padrao/questor_1889_2026-08.csv
Normal 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;
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|||||||
181
portal_api/planos_saude/operadoras/sulamerica/saude.py
Normal file
181
portal_api/planos_saude/operadoras/sulamerica/saude.py
Normal 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]
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user