Revisão da tela de cadastro de regras - Plano de Saúde.

This commit is contained in:
Gabriel 2026-08-21 16:05:48 -03:00
parent 160459ddf6
commit 7fc771aafb
24 changed files with 1988 additions and 653 deletions

8
.claude/settings.json Normal file
View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(python -c ' *)",
"Bash(.venv/Scripts/python.exe manage.py shell -c ' *)"
]
}
}

View File

@ -148,7 +148,7 @@ Um único app, `portal_api/`:
| `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` | | `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` |
| `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) | | `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) |
| `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo | | `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo |
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio salvas (`nome`+`operadora`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`, ver "Regras de custeio salvas" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono" | | `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (`codigo_empresa`+`operadora`, únicos juntos+`regra_empresa_chave`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`; `nome` é sempre derivado, nunca aceito do cliente — ver "Cadastro de Regras" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" |
| `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo | | `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo |
| `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado | | `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado |
| `/api/indicadores-percentuais-tipo/` | GET/POST/DELETE | histórico de percentuais individual/grupo/departamento por tipo de colaborador (`IndicadorPercentualTipo`) — nunca editado in-place, só criado com `vigente_desde` novo; mesma permissão de toggle único `apps["indicador-desempenho"]` em `permissoes["geradoc"]` | | `/api/indicadores-percentuais-tipo/` | GET/POST/DELETE | histórico de percentuais individual/grupo/departamento por tipo de colaborador (`IndicadorPercentualTipo`) — nunca editado in-place, só criado com `vigente_desde` novo; mesma permissão de toggle único `apps["indicador-desempenho"]` em `permissoes["geradoc"]` |
@ -190,7 +190,7 @@ Um único app, `portal_api/`:
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver seção própria abaixo). | | `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver seção própria abaixo). |
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver seção "Acessos Gerais" abaixo). | | `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver seção "Acessos Gerais" abaixo). |
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver seção "Ramais" abaixo). | | `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver seção "Ramais" abaixo). |
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + nova importação (upload) + revisão/geração do arquivo de lançamento de plano de saúde (ver seção "Importação de Plano de Saúde" abaixo). | | `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver seção "Importação de Plano de Saúde" abaixo). |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver seção "Simulação de Custo de Contratação" abaixo). | | `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver seção "Simulação de Custo de Contratação" abaixo). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver seção própria abaixo). | | `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver seção própria abaixo). |
@ -535,13 +535,16 @@ portal_api/planos_saude/
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome) ├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome) ├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome) ├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descriçã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/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/Bradessaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, 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.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradessaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**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`)
@ -594,30 +597,47 @@ Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora
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`.
### Regras de custeio salvas ### Cadastro de Regras (separado da execução da importação)
Substituiu o antigo par de botões "Exportar regra"/"Importar regra" (baixava/lia um `.json` manualmente, sem nenhuma persistência) por um banco de regras de verdade no Postgres (`RegraCusteioPlanoSaude`) — pedido explícito do usuário pra poder nomear uma regra (ex.: "092 - Unimed"), escolhê-la numa lista em importações futuras, editá-la depois e anotar uma observação livre (ex.: "Empresa não desconta plano do empregado XX"). Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora:
- **Campos**: `nome` (obrigatório), `operadora` (opcional — a `key` de `planos_saude.pipeline.OPERADORAS`, não o label; só usada pra pré-selecionar o `<select>` de operadora ao aplicar a regra, nunca bloqueia aplicar uma regra com uma operadora diferente da atual), `tipos_lancamento`/`custeio_por_tipo` (exatamente o mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`, ver acima) e `observacoes` (texto livre). - **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `"<código> - <nome>"` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora".
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` chamam a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa — sem isso, a regra de negócio de custeio (parsing BR, faixa 0–100 do percentual, "ao menos um de limite/percentual") viveria duplicada em dois serializers e podia divergir com o tempo. A única diferença entre os dois pontos de entrada é o formato de payload: `ImportacaoPlanoSaudeCreateSerializer` recebe campos multipart achatados (`custeio_mensalidade_titular`, `limite_valor_mensalidade_titular`...), `RegraCusteioPlanoSaudeSerializer` recebe o `custeio_por_tipo` já aninhado como JSON puro. - **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `<código - nome>` · `<operadora>`" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`).
- **`limite_valor`/`percentual` sempre em texto BR na entrada, float|None persistido**: igual ao resto do módulo, esses dois campos chegam como string BR (`"150,00"`) tanto no create de uma importação quanto no banco de regras — mas uma regra salva, uma vez lida de volta pelo `GET`, já vem com esses valores como `float` (formato final persistido). Pra `RegraCusteioPlanoSaudeSerializer` aceitar os dois formatos sem corromper o valor (`"150.0"` seria lido errado como 15000 por `parse_valor_br`, que só entende separador de milhar `.`/decimal `,`), `_valor_custeio_para_texto_br()` normaliza um float de volta pra texto BR (`formata_valor_br`) antes de repassar pro parser — isso é o que permite reenviar uma regra sem edição (ex.: só mudando o nome) sem precisar reformatar nada no frontend. - **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar".
- **Frontend** (`importacao-plano-saude.js`, seção "Regra de custeio salva" — **primeiro** campo do formulário de Nova Importação, antes até de "Operadora": decisão explícita do usuário, já que aplicar uma regra já preenche a operadora junto, então escolher a regra é o primeiro passo natural do fluxo, não um apêndice no final): um combobox pesquisável (`#ips-regra-combo` — `<input>` `#ips-regra-search` + lista flutuante `#ips-regra-combo-list`, filtra por nome a cada tecla; era um `<select>` simples, trocado quando o banco de regras cresceu o bastante pra não caber numa lista sem busca) + "Aplicar" preenche o formulário inteiro (tipos de lançamento + custeio de cada combinação + operadora, se ainda existir na lista) a partir de uma regra salva — os campos continuam 100% editáveis depois, é só um preenchimento em massa (`aplicarCusteio()`/`aplicarRegraNoFormulario()`), mesmo espírito do antigo "Importar regra". `regraSelecionadaId` (JS) rastreia o que está de fato escolhido no combobox — digitar de novo no campo invalida a seleção anterior até o usuário clicar numa regra da lista, pra "Aplicar" nunca usar uma regra desatualizada em relação ao texto exibido. **"Limpar seleção"** (`#ips-regra-limpar-btn`, ao lado de "Aplicar" — adicionado pro caso de aplicar a regra errada por engano) chama `limparRegraSelecionada()` (zera o rastreamento — combobox, `regraSelecionadaId`/`regraAplicadaId`, observações), `limparCusteioForm()` (desfaz o que a regra preencheu: desmarca tipos de lançamento, radios de custeio e campos de limite/percentual de cada combinação tipo×pessoa) **e** `limparOperadoraSelecionada()` (limpa também a Operadora, já que aplicar uma regra pode ter preenchido esse campo junto — ver combobox de Operadora abaixo) — as duas primeiras foram extraídas de dentro de `resetForm()` justamente pra serem reaproveitadas aqui, e as três juntas são exatamente o que `resetForm()` também chama; não mexe nos arquivos já anexados, só no que uma regra aplicada de fato preenche em massa. **"Ver regras salvas"** (`#ips-regras-modal`) continua no topo, junto de Aplicar/Limpar; **"Salvar regra atual..."** (`#ips-regra-salvar-btn`) foi movido pro **final do formulário** (depois de "Tipo de importação", antes do botão "Processar" — `.ips-regra-salvar-field` em `importacao-plano-saude.css`), decisão explícita do usuário: salvar só faz sentido depois de parametrizar o custeio, é o último passo do fluxo de criar/editar uma regra, não algo que deveria ficar ao lado de Aplicar/Ver regras salvas no topo. Continua abrindo o mesmo modal (`#ips-regra-save-modal`) pra nomear/descrever a configuração atualmente preenchida (validada antes com a mesma `mensagemErroCusteio()` usada pelo botão "Processar", reaproveitada pelas duas ações) — **editar uma regra existente é literalmente aplicá-la, ajustar o que quiser no formulário, e salvar de novo**: o modal nasce em modo "atualizar a regra selecionada" sempre que a regra atualmente refletida no formulário (`regraAplicadaId`) ainda existir, com uma checkbox pra optar por "criar uma nova regra" em vez de sobrescrever. "Ver regras salvas" lista todas as regras (nome, operadora, tipos, observações) com ações de Aplicar/Excluir — não duplica a grade de custeio num modal separado, de propósito, pra não manter dois lugares editáveis da mesma coisa. - **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema.
- **Classes `.ips-combo`/`.ips-combo__list`/`.ips-combo__item`/`.ips-combo__empty` (`importacao-plano-saude.css`) são genéricas**, não específicas de "Regra de custeio salva" — reaproveitadas também pelo campo "Operadora" (`#ips-operadora-combo`/`#ips-operadora-search`/`#ips-operadora-combo-list`), que virou o mesmo tipo de combobox pesquisável (era um `<select>` simples) pra permitir buscar pelo código/nome da operadora, já que `label` agora vem prefixado com o código de cadastro no Questor (ver `GET /operadoras/` acima). Diferença de implementação: como Operadora não tem um botão "Aplicar" separado (é o próprio campo do formulário, não uma configuração aplicada em massa), o valor de fato submetido viaja num `<input type="hidden" id="ips-form-operadora">` — escolher um item da lista (`selecionarOperadora()`) já grava o valor na hora e revalida o arquivo da operadora já anexado (`validadorArquivo.revalidarSeAnexado()`), mesmo efeito que o antigo evento `change` do `<select>` disparava. Mesmo cuidado de invalidar a seleção ao digitar de novo, até escolher um item da lista. `limparOperadoraSelecionada()` (limpa o `<input type="hidden">` + o texto de busca) é chamada tanto por `resetForm()` quanto por "Limpar seleção" da regra — decisão explícita do usuário: como aplicar uma regra pode ter preenchido a Operadora junto, desfazer a seleção da regra também desfaz o que ela preencheu ali. - **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado).
- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`, pra `RegraCusteioPlanoSaude`) registra qual regra (se alguma) estava aplicada no formulário no momento do "Processar" — só informativo, não influencia `custeio_por_tipo` (que já é o que de fato vale pro processamento) nem o processamento em si. Preenchido no submit com `regraAplicadaId` (JS) quando "Regra empresa" **não** está marcada (as duas rastreiam coisas diferentes e nunca são enviadas juntas — marcar "Regra empresa" já limpa `regraAplicadaId` via `limparRegraSelecionada()`, e aplicar uma regra de custeio salva já limpa "Regra empresa" via `limparRegraEmpresa()` dentro de `aplicarCusteio()`). É o que alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão (ver "Observações da regra, só-leitura na tela de Revisão" acima) — pedido do usuário depois de notar que a observação de uma regra de custeio salva (ex.: "092 - Unimed") não aparecia lá, só a de "Regra empresa". - **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `"<código> - <nome>"`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `"<código> - <nome>"` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um `<select>` nativo) — clicar na seta mostra a lista completa de novo, ou, se já houver algo selecionado, as **outras** opções cadastradas (sem repetir a já escolhida). Existe porque só focar o campo com um valor já preenchido filtra a lista pelo texto atual, então só mostraria de novo o item já selecionado — a seta é o jeito de "trocar fácil" pedido pelo usuário, no mesmo espírito de um filtro de BI (clicar, ver todas as opções, escolher outra).
O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/coparticipação × titular/dependente/regra específica) e as funções JS que o operam (`coletarCusteioAtual()`, `mensagemErroCusteio()`, `aplicarCusteio()`, `limparCusteioForm()`) foram **movidos** (não duplicados) de "Nova Importação" pro modal de Cadastro — mesmos ids de DOM, mesma lógica, só relocados; "Nova Importação" monta o `FormData` do submit direto a partir do objeto `regraResolvidaAtual` em memória (`montarFormDataDeRegra()`), não mais lendo inputs (que não existem mais ali).
- **Campos de `RegraCusteioPlanoSaude`**: `codigo_empresa` (obrigatório — o código do cliente/empresa; **não confundir** com o código de cadastro da operadora no Questor, que já aparece dentro do label de `pipeline.OPERADORAS`, ex. `"5060 - Unimed Saúde"` — são códigos diferentes), `operadora` (obrigatória agora, validada contra `pipeline.OPERADORAS`), `regra_empresa_chave` (ver "Regra empresa" abaixo), `tipos_lancamento`/`custeio_por_tipo` (mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`) e `observacoes`. `Meta.unique_together = [["codigo_empresa", "operadora"]]` — validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia). `nome` **deixou de ser digitado** pelo usuário — é sempre derivado em `RegraCusteioPlanoSaudeSerializer.validate()` como `"<codigo_empresa> - <nome da operadora sem o código dela>"` (campo `read_only=True` na API); mantido como campo de model só pra não precisar tocar em todo lugar que já lê `.nome`/`regra_custeio_salva_nome`.
- **Migração em 3 passos** (mesmo padrão já usado pra `IndicadorDepartamento`, migrations `0033`/`0034`/`0035`): `0041` adiciona `codigo_empresa`/`regra_empresa_chave` (blank) + torna `operadora` obrigatória; `0042` (RunPython) faz o backfill de `codigo_empresa` a partir do `nome` existente (`nome.split(" - ", 1)[0].strip()`); `0043` torna `codigo_empresa` obrigatório e adiciona o `unique_together`. `Meta.ordering` usa `[Length("codigo_empresa"), "codigo_empresa", "operadora"]` (mesmo padrão de `IndicadorApuracaoEmpresa`) pra ordenar o código como número, não como string.
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` continuam chamando a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa. `UniqueTogetherValidator` é declarado explicitamente em `Meta.validators` (não só o automático do DRF), pra manter a mensagem de erro em português.
- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`) registra qual regra foi aplicada numa importação — agora praticamente sempre preenchida (já que "Nova Importação" só resolve custeio a partir de uma regra cadastrada), mas o campo continua opcional a nível de API (a garantia de "sempre passar por uma regra cadastrada" é uma trava de UI, não uma obrigatoriedade no backend). Alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão, como antes.
- **Trava de conferência do código de empresa** (`ImportacaoPlanoSaudeViewSet.create()`, depois do processamento e antes do `bulk_create` das linhas): se `regra_custeio_salva` está presente, confere que ao menos uma linha da planilha padrão processada tem `codigo_empresa` igual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outros `except` desse método) e devolve 400 com mensagem clara — evita aplicar a regra de uma empresa a uma planilha de outra por engano. Vale pra toda regra aplicada, inclusive as com `regra_empresa_chave` (onde é redundante com a checagem que `regras_empresa.valida_regra_empresa()` já faz — proteção extra contra o registro em `REGRAS_EMPRESA` ficar dessincronizado da `RegraCusteioPlanoSaude` correspondente).
**Nome da empresa (Questor)** — primeiro consumidor real do pacote `database/` (ver [[project_database_package]] na memória): resolve e cacheia localmente o nome de uma empresa a partir do seu `codigo_empresa`, pra mostrar `"<código> - <nome>"` em vez de só o código nas telas acima.
- **`EmpresaQuestor`** (models.py, migração `0044`): `codigo_empresa` (único) + `nome_empresa`, um cache local simples — sem relação de FK com `RegraCusteioPlanoSaude` (é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmo `codigo_empresa` com operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha de `EmpresaQuestor`).
- **`portal_api/empresas_questor.py`, `resolve_nome_empresa(codigo_empresa)`**: olha o cache primeiro; só na ausência dele consulta o Questor (`database.connection.DatabaseConnection("questor")` — chave em **minúsculas**, `DatabaseSettings` normaliza as chaves de `DATABASE__<NOME>__*` do `.env` assim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executando `sqls.questor.QuestorSQL.consulta_nome_empresa()` (`select codigoempresa, nomeempresa from empresa where codigoempresa = :codigo_empresa` — a consulta exata fornecida pelo usuário, só parametrizada), e persiste o resultado antes de devolver — nunca precisa repetir a consulta pro mesmo código depois. Qualquer falha (código inexistente, `codigoempresa` do Questor é `smallint` e um código fora da faixa numérica levanta `DataError`, banco inacessível) é capturada e devolve `None` — nunca propaga a exceção, já que isso é só informativo, nunca bloqueia cadastrar/editar/excluir uma regra.
- **`normalizar_codigo_empresa(valor)`** (mesmo arquivo): remove zero à esquerda (`"092"` → `"92"`) — decisão explícita do usuário, pra sempre ter um único código canônico por empresa (o `codigoempresa` do Questor é `smallint`, então "092"/"92" já eram a mesma linha lá; sem normalizar no Portal, apareciam como duas empresas "diferentes"). Aplicada em toda entrada de `codigo_empresa` vinda de fora: `resolve_nome_empresa()`, `RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa()` (o que é de fato salvo em `RegraCusteioPlanoSaude.codigo_empresa`), a action `nome-empresa` (devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência em `ImportacaoPlanoSaudeViewSet.create()` (normaliza os dois lados antes de comparar, já que o código bruto da planilha pode ter zero à esquerda enquanto o da regra não tem mais). **Nunca** aplicada a `ImportacaoPlanoSaudeLinha.codigo_empresa` em si (precisa continuar exatamente como veio da planilha, pra não alterar o que é reexportado) — só normalizada no momento de uma comparação/exibição pontual (ver `_nome_empresa_cacheado()`, que normaliza antes de consultar `EmpresaQuestor` a partir do código cru de uma linha).
- **`sqls/questor.py`** (pacote novo na raiz do projeto, ao lado de `database/` — seguindo a convenção "uma pasta `sqls/` por projeto consumidor, um arquivo por banco" já documentada na memória): classe `QuestorSQL`, hoje só `consulta_nome_empresa()`. Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendo `SQLQuery(sql=dedent(...), params={...})`; **nunca** usar `execute`/`execute_returning` desses bancos sem autorização explícita (ver [[feedback_bancos_externos_somente_leitura]]).
- **Correção em `database/settings.py`** (arquivo compartilhado, não específico desta ferramenta): `SUPPORTED_DRIVERS["postgresql"]` apontava pra `"postgresql+psycopg2"`, mas o `.venv` do Portal só tem `psycopg` (v3) instalado, não `psycopg2` — `ModuleNotFoundError` ao tentar conectar. Corrigido pra `"postgresql+psycopg"` (dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalar `psycopg2-binary` à parte. Se `database/` for reaproveitado por outro projeto que dependa especificamente de `psycopg2` (comportamento antigo), essa mudança precisaria ser revisitada — não é o caso hoje.
- **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.
### Regra empresa (custeio especial de mensalidade por família) ### Regra empresa (custeio especial de mensalidade por família)
Terceiro checkbox de "Tipo de importação" (ao lado de Mensalidade/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" (ver "Regras de custeio salvas" acima), tipicamente porque são calculadas por **família inteira** (titular + todos os dependentes somados), não por pessoa. **Sem relação nenhuma com `RegraCusteioPlanoSaude`** — decisão explícita do usuário: é um registro fixo no código (`portal_api/planos_saude/regras_empresa.py`), cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova, nunca pela tela. 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.
- **Mutuamente exclusivo com "Mensalidade"**: as duas são formas alternativas de configurar o **mesmo** tipo de lançamento `"mensalidade"` — marcar "Regra empresa" desmarca e esconde os radios de Mensalidade (e vice-versa), tanto no frontend (`importacao-plano-saude.js`, handlers de `change` dos dois checkboxes + `aplicarCusteio()`/`limparRegraEmpresa()`) quanto implicitamente no backend (`ImportacaoPlanoSaudeCreateSerializer.validate()` grava `custeio_por_tipo["mensalidade"] = {}` quando `regra_empresa` vem preenchido, ignorando `custeio_mensalidade_titular`/`dependente`). O `tipo_lancamento` persistido na `ImportacaoPlanoSaudeLinha` continua sendo `"mensalidade"` de qualquer forma — o CSV gerado (`mensalidade.csv`) não muda de nome nem de formato, já que é isso que o Questor espera importar; "Regra empresa" é só uma forma alternativa de **calcular** o mesmo valor, não um tipo de lançamento novo. - **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.
- **Registro** (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`): cada entrada tem `label` (exibido no modal "Selecionar regra"), `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada — trava contra aplicar a regra errada numa planilha de outra empresa, ver validação abaixo), `operadora` (só informativo), `aplica` (a função que faz o cálculo) e `observacoes` (opcional, texto livre explicando a regra). 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). - **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").
- **Observações da regra, só-leitura na tela de Revisão** (`#ips-review-regra-empresa-obs`, entre o cabeçalho "Revisão" e as abas Mensalidade/Coparticipação/Auditoria/Alterações — decisão explícita do usuário sobre onde posicionar): `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` (`SerializerMethodField`) resolve `REGRAS_EMPRESA[obj.regra_empresa]["observacoes"]` a cada carregamento da importação — não é um campo persistido na `ImportacaoPlanoSaude`, sempre reflete o texto atual do registro no código (se a observação do registro mudar depois, importações antigas passam a mostrar o texto novo também, já que não há snapshot). `abrirRevisao()` (`importacao-plano-saude.js`) só mostra o bloco (`hidden` por padrão) quando o campo vem preenchido — a maioria das regras pode não ter `observacoes` cadastrada. **Mesmo bloco reaproveitado pra "Regra de custeio salva"** (ver `ImportacaoPlanoSaude.regra_custeio_salva` e "Regras de custeio salvas" abaixo): se não há `regra_empresa_observacoes` mas a importação tem `regra_custeio_salva_observacoes` (a observação real da `RegraCusteioPlanoSaude` aplicada, via `CharField(source="regra_custeio_salva.observacoes", default=None)`), o mesmo bloco exibe essa observação, com o rótulo trocado pra "Observações da regra de custeio salva — `<nome>`". As duas fontes nunca vêm preenchidas ao mesmo tempo (aplicar uma regra de custeio salva sempre desliga "Regra empresa" e vice-versa, ver `aplicarCusteio()`/handler de `formTipoRegraEmpresa` acima). - **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. - **Primeira regra**: `unimed_1778_tecnomyl` — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026.
- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade. - **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. - **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" só cobre `"mensalidade"`; se o usuário também marcar "Coparticipação", ela segue o custeio normal configurado na própria tela (radios titular/dependente), sem nenhuma ligação com a regra empresa. - **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.
- **Frontend** (`importacao-plano-saude.js`): checkbox "Regra empresa" revela uma caixa (`#ips-regra-empresa-box`) com o nome da regra atualmente escolhida + botão "Selecionar regra", que abre um modal simples (`#ips-regra-empresa-modal`, lista `.ips-regra-row` sem "Excluir"/"Editar" — é um registro fixo) buscado de `GET /operadoras/regras-empresa/` uma vez por abertura do formulário (`regrasEmpresaCache`). `regraEmpresaSelecionada` (JS, `{key, label}`) é lido no submit (`formData.append("regra_empresa", ...)`) e em `mensagemErroCusteio()` (exige uma regra escolhida se o checkbox estiver marcado). `limparCusteioForm()`/`limparRegraEmpresa()` resetam o checkbox/caixa/seleção junto com o resto do custeio — inclusive quando "Limpar seleção" da regra de custeio salva é clicado, ou quando uma regra de custeio salva é aplicada (`aplicarCusteio()` sempre desliga "Regra empresa" antes de configurar mensalidade manualmente).
## CSS — organização entre arquivos ## CSS — organização entre arquivos

View File

@ -8,7 +8,7 @@ BASE_DIR = Path(__file__).resolve().parents[2]
ENV_FILE_PATH = BASE_DIR / ".env" ENV_FILE_PATH = BASE_DIR / ".env"
SUPPORTED_DRIVERS = { SUPPORTED_DRIVERS = {
"postgresql": "postgresql+psycopg2", "postgresql": "postgresql+psycopg",
"mysql": "mysql+pymysql", "mysql": "mysql+pymysql",
"sqlserver": "mssql+pyodbc", "sqlserver": "mssql+pyodbc",
"sqlite": "sqlite" "sqlite": "sqlite"

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,17 @@
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3755;01/01/2026;LAURA BASSO BENITEZ;150.028.919-18;0;0;
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3755;01/01/2026;ANGELO MATHEUS BASSO BENITEZ;150.028.879-96;0;0;
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3755;01/01/2026;;;0;0;
1684;LUIZ AGNALDO NOGOCEKI;007.397.429-30;3755;01/01/2026;;;0;0;
1684;ANA PAULA FABRI KONART;031.029.339-12;3755;01/01/2026;;;0;0;
1684;LUCIANA SEVERO SCHITZ;044.810.799-67;3755;01/05/2026;GABRIEL SEVERO SCHITZ;097.527.819-34;0;0;
1684;LUCIANA SEVERO SCHITZ;044.810.799-67;3755;01/05/2026;;;0;0;
1684;LUCIANA SEVERO SCHITZ;044.810.799-67;3755;01/05/2026;DAVI SEVERO SCHITZ;137.347.779-25;0;0;
1684;CLEIDIANE CASAGRANDE MACHADO;054.236.429-81;3755;01/01/2026;;;0;0;
1684;CLEIDIANE CASAGRANDE MACHADO;054.236.429-81;3755;01/01/2026;MIGUEL CASAGRANDE MACHADO;131.884.309-06;0;0;
1684;MARCO ANTONIO MERTIG DRESLING DESIDERIO;062.891.039-89;3755;01/01/2026;;;0;0;
1684;DOUGLAS WILLIAN DE MELO;081.295.649-47;3755;01/06/2024;MAIARA CRISTINA DOS SANTOS VICENTE;077.993.259-50;0;0;
1684;DOUGLAS WILLIAN DE MELO;081.295.649-47;3755;01/06/2024;;;0;0;
1684;TATIANE DA SILVA;322.352.038-41;3755;01/01/2026;PEDRO HENRIQUE SILVA GONCALVES;152.166.939-20;0;0;
1684;TATIANE DA SILVA;322.352.038-41;3755;01/01/2026;;;0;0;
1684;LEILA DA SILVA VEIGA;931.427.909-00;3755;01/01/2026;;;0;0;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3755 01/01/2026 LAURA BASSO BENITEZ 150.028.919-18 0 0
3 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3755 01/01/2026 ANGELO MATHEUS BASSO BENITEZ 150.028.879-96 0 0
4 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3755 01/01/2026 0 0
5 1684 LUIZ AGNALDO NOGOCEKI 007.397.429-30 3755 01/01/2026 0 0
6 1684 ANA PAULA FABRI KONART 031.029.339-12 3755 01/01/2026 0 0
7 1684 LUCIANA SEVERO SCHITZ 044.810.799-67 3755 01/05/2026 GABRIEL SEVERO SCHITZ 097.527.819-34 0 0
8 1684 LUCIANA SEVERO SCHITZ 044.810.799-67 3755 01/05/2026 0 0
9 1684 LUCIANA SEVERO SCHITZ 044.810.799-67 3755 01/05/2026 DAVI SEVERO SCHITZ 137.347.779-25 0 0
10 1684 CLEIDIANE CASAGRANDE MACHADO 054.236.429-81 3755 01/01/2026 0 0
11 1684 CLEIDIANE CASAGRANDE MACHADO 054.236.429-81 3755 01/01/2026 MIGUEL CASAGRANDE MACHADO 131.884.309-06 0 0
12 1684 MARCO ANTONIO MERTIG DRESLING DESIDERIO 062.891.039-89 3755 01/01/2026 0 0
13 1684 DOUGLAS WILLIAN DE MELO 081.295.649-47 3755 01/06/2024 MAIARA CRISTINA DOS SANTOS VICENTE 077.993.259-50 0 0
14 1684 DOUGLAS WILLIAN DE MELO 081.295.649-47 3755 01/06/2024 0 0
15 1684 TATIANE DA SILVA 322.352.038-41 3755 01/01/2026 PEDRO HENRIQUE SILVA GONCALVES 152.166.939-20 0 0
16 1684 TATIANE DA SILVA 322.352.038-41 3755 01/01/2026 0 0
17 1684 LEILA DA SILVA VEIGA 931.427.909-00 3755 01/01/2026 0 0

View File

@ -0,0 +1,32 @@
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3759;01/07/2025;LAURA BASSO BENITEZ;150.028.919-18;0;0;
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3759;01/07/2025;ANGELO MATHEUS BASSO BENITEZ;150.028.879-96;0;0;
1684;SHIRLEY BAPTISTA BASSO BENITEZ;005.806.599-76;3759;01/07/2025;;;0;0;
1684;MATHEUS DIAS MARONI;007.382.310-45;3759;01/03/2026;;;0;0;
1684;LUIZ AGNALDO NOGOCEKI;007.397.429-30;3759;01/03/2026;;;0;0;
1684;FERNANDA CABRAL STORMOSKI;008.351.839-80;3759;01/03/2026;;;0;0;
1684;NATIELE DAIANA GONCALVES;009.915.879-52;3759;01/04/2026;;;0;0;
1684;CLARICE RODRIGUES;028.700.589-50;3759;01/03/2026;;;0;0;
1684;ANA PAULA FABRI KONART;031.029.339-12;3759;01/03/2026;;;0;0;
1684;CAROLINE CORREIA TERRINHA;031.036.229-61;3759;01/03/2026;;;0;0;
1684;LUCIANA SEVERO SCHITZ;044.810.799-67;3759;01/03/2026;;;0;0;
1684;RAFAELA DUGONSKI;053.055.939-05;3759;01/03/2026;;;0;0;
1684;RAFAELA DUGONSKI;053.055.939-05;3759;01/03/2026;LUIZA DUGONSKI LAZZARETTI;134.337.279-88;0;0;
1684;RAFAELA DUGONSKI;053.055.939-05;3759;01/03/2026;SAMUEL DUGONSKI LAZZARETTI;094.284.119-06;0;0;
1684;CLEIDIANE CASAGRANDE MACHADO;054.236.429-81;3759;01/03/2026;;;0;0;
1684;CLEIDIANE CASAGRANDE MACHADO;054.236.429-81;3759;01/03/2026;MIGUEL CASAGRANDE MACHADO;131.884.309-06;0;0;
1684;TARCISO BIANCHINI BRITO;056.601.209-03;3759;01/03/2026;;;0;0;
1684;MARCO ANTONIO MERTIG DRESLING DESIDERIO;062.891.039-89;3759;01/03/2026;;;0;0;
1684;MIRIAN BARBOSA PERCILIANA;065.744.019-17;3759;01/03/2026;;;0;0;
1684;DOUGLAS WILLIAN DE MELO;081.295.649-47;3759;01/03/2026;MAIARA CRISTINA DOS SANTOS VICENTE;077.993.259-50;0;0;
1684;DOUGLAS WILLIAN DE MELO;081.295.649-47;3759;01/03/2026;;;0;0;
1684;LUCAS JOSE BARQUEZ HONORIO;097.333.289-10;3759;01/07/2026;;;0;0;
1684;LORENA BEATRIZ VIEIRA DA CUNHA;108.451.469-98;3759;01/03/2026;;;0;0;
1684;CAMILLY VITORIA DA SILVA;119.250.469-07;3759;01/03/2026;;;0;0;
1684;BRENDA LUISA CUSTODIO DA SILVA DO NASCIMENTO;120.153.999-41;3759;01/04/2026;;;0;0;
1684;DIEGO DE GOIS VIVEIROS DE ASSIS;228.617.048-70;3759;01/03/2026;MARINA ROSEMBAUM MASUCCI;339.864.788-09;0;0;
1684;DIEGO DE GOIS VIVEIROS DE ASSIS;228.617.048-70;3759;01/03/2026;;;0;0;
1684;TATIANE DA SILVA;322.352.038-41;3759;01/03/2026;;;0;0;
1684;EDUARDO JONES SILVEIRA PAIS;812.492.760-04;3759;01/03/2026;;;0;0;
1684;ANDRE RICARDO PINTO DE SOUZA;855.460.462-87;3759;01/03/2026;;;0;0;
1684;LEILA DA SILVA VEIGA;931.427.909-00;3759;01/03/2026;;;0;0;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3759 01/07/2025 LAURA BASSO BENITEZ 150.028.919-18 0 0
3 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3759 01/07/2025 ANGELO MATHEUS BASSO BENITEZ 150.028.879-96 0 0
4 1684 SHIRLEY BAPTISTA BASSO BENITEZ 005.806.599-76 3759 01/07/2025 0 0
5 1684 MATHEUS DIAS MARONI 007.382.310-45 3759 01/03/2026 0 0
6 1684 LUIZ AGNALDO NOGOCEKI 007.397.429-30 3759 01/03/2026 0 0
7 1684 FERNANDA CABRAL STORMOSKI 008.351.839-80 3759 01/03/2026 0 0
8 1684 NATIELE DAIANA GONCALVES 009.915.879-52 3759 01/04/2026 0 0
9 1684 CLARICE RODRIGUES 028.700.589-50 3759 01/03/2026 0 0
10 1684 ANA PAULA FABRI KONART 031.029.339-12 3759 01/03/2026 0 0
11 1684 CAROLINE CORREIA TERRINHA 031.036.229-61 3759 01/03/2026 0 0
12 1684 LUCIANA SEVERO SCHITZ 044.810.799-67 3759 01/03/2026 0 0
13 1684 RAFAELA DUGONSKI 053.055.939-05 3759 01/03/2026 0 0
14 1684 RAFAELA DUGONSKI 053.055.939-05 3759 01/03/2026 LUIZA DUGONSKI LAZZARETTI 134.337.279-88 0 0
15 1684 RAFAELA DUGONSKI 053.055.939-05 3759 01/03/2026 SAMUEL DUGONSKI LAZZARETTI 094.284.119-06 0 0
16 1684 CLEIDIANE CASAGRANDE MACHADO 054.236.429-81 3759 01/03/2026 0 0
17 1684 CLEIDIANE CASAGRANDE MACHADO 054.236.429-81 3759 01/03/2026 MIGUEL CASAGRANDE MACHADO 131.884.309-06 0 0
18 1684 TARCISO BIANCHINI BRITO 056.601.209-03 3759 01/03/2026 0 0
19 1684 MARCO ANTONIO MERTIG DRESLING DESIDERIO 062.891.039-89 3759 01/03/2026 0 0
20 1684 MIRIAN BARBOSA PERCILIANA 065.744.019-17 3759 01/03/2026 0 0
21 1684 DOUGLAS WILLIAN DE MELO 081.295.649-47 3759 01/03/2026 MAIARA CRISTINA DOS SANTOS VICENTE 077.993.259-50 0 0
22 1684 DOUGLAS WILLIAN DE MELO 081.295.649-47 3759 01/03/2026 0 0
23 1684 LUCAS JOSE BARQUEZ HONORIO 097.333.289-10 3759 01/07/2026 0 0
24 1684 LORENA BEATRIZ VIEIRA DA CUNHA 108.451.469-98 3759 01/03/2026 0 0
25 1684 CAMILLY VITORIA DA SILVA 119.250.469-07 3759 01/03/2026 0 0
26 1684 BRENDA LUISA CUSTODIO DA SILVA DO NASCIMENTO 120.153.999-41 3759 01/04/2026 0 0
27 1684 DIEGO DE GOIS VIVEIROS DE ASSIS 228.617.048-70 3759 01/03/2026 MARINA ROSEMBAUM MASUCCI 339.864.788-09 0 0
28 1684 DIEGO DE GOIS VIVEIROS DE ASSIS 228.617.048-70 3759 01/03/2026 0 0
29 1684 TATIANE DA SILVA 322.352.038-41 3759 01/03/2026 0 0
30 1684 EDUARDO JONES SILVEIRA PAIS 812.492.760-04 3759 01/03/2026 0 0
31 1684 ANDRE RICARDO PINTO DE SOUZA 855.460.462-87 3759 01/03/2026 0 0
32 1684 LEILA DA SILVA VEIGA 931.427.909-00 3759 01/03/2026 0 0

View File

@ -6,6 +6,7 @@ from .models import (
AcessoGeralSecao, AcessoGeralSecao,
CompromissoAgenda, CompromissoAgenda,
Departamento, Departamento,
EmpresaQuestor,
Favorito, Favorito,
FuncaoTelefonia, FuncaoTelefonia,
ImportacaoPlanoSaude, ImportacaoPlanoSaude,
@ -150,6 +151,12 @@ class RegraCusteioPlanoSaudeAdmin(admin.ModelAdmin):
search_fields = ("nome", "operadora") search_fields = ("nome", "operadora")
@admin.register(EmpresaQuestor)
class EmpresaQuestorAdmin(admin.ModelAdmin):
list_display = ("codigo_empresa", "nome_empresa", "atualizado_em")
search_fields = ("codigo_empresa", "nome_empresa")
@admin.register(ParametroFiscalCustoContratacao) @admin.register(ParametroFiscalCustoContratacao)
class ParametroFiscalCustoContratacaoAdmin(admin.ModelAdmin): class ParametroFiscalCustoContratacaoAdmin(admin.ModelAdmin):
list_display = ("__str__", "atualizado_em") list_display = ("__str__", "atualizado_em")

View File

@ -0,0 +1,70 @@
"""Resolve e cacheia localmente o nome de uma empresa cadastrada no Questor
(banco externo, fora do Django ORM — ver `database/` na raiz do projeto) —
usado por "Cadastro de Regras" (Importação de Plano de Saúde) pra mostrar
"<código> - <nome>" em vez de só o código. Ver `EmpresaQuestor` em
models.py e `sqls/questor.py` pra consulta em si.
`DatabaseConnection` espera a chave em minúsculas ("questor", não
"QUESTOR") — `database.settings.DatabaseSettings` guarda as chaves de
`DATABASE__<nome>__*` do `.env` já normalizadas em minúsculo.
"""
import logging
from database.connection import DatabaseConnection
from sqls.questor import QuestorSQL
from .models import EmpresaQuestor
logger = logging.getLogger(__name__)
def normalizar_codigo_empresa(valor: str) -> str:
"""Remove zeros à esquerda de um código de empresa (ex.: "092" -> "92")
— o Questor guarda `codigoempresa` como número (`smallint`), então "092"
e "92" já eram a mesma empresa lá (a comparação SQL já ignora o zero à
esquerda); esta função garante que o Portal também trate os dois como
um único código canônico, evitando duas regras "diferentes" pra mesma
empresa por causa de zero à esquerda ter sido digitado ou não. Usada em
todo lugar que recebe um `codigo_empresa` de fora (formulário de "+
Nova regra", conferência da planilha anexada) — nunca aplicada a
`ImportacaoPlanoSaudeLinha.codigo_empresa` em si, que precisa
permanecer exatamente como veio da planilha (só normalizada no momento
da comparação). Não numérico (não deveria acontecer) volta só com
espaços removidos, sem alteração."""
valor = (valor or "").strip()
return str(int(valor)) if valor.isdigit() else valor
def resolve_nome_empresa(codigo_empresa: str) -> str | None:
"""Devolve o nome da empresa pro código informado — primeiro olha o
cache local (`EmpresaQuestor`); só consulta o Questor de fato na
primeira vez que um código aparece, e persiste o resultado pra nunca
precisar repetir a consulta depois (ver docstring de `EmpresaQuestor`).
Devolve `None` se o código não existir no Questor ou se o banco estiver
inacessível — nunca propaga a exceção, já que isso é usado só pra
exibição (nunca bloqueia cadastrar/editar uma regra)."""
codigo_empresa = normalizar_codigo_empresa(codigo_empresa)
if not codigo_empresa:
return None
cache = EmpresaQuestor.objects.filter(codigo_empresa=codigo_empresa).first()
if cache:
return cache.nome_empresa
try:
with DatabaseConnection("questor") as db:
linha = db.select_mappings_query(QuestorSQL.consulta_nome_empresa(codigo_empresa), one=True)
except Exception:
logger.exception("Falha ao consultar nome da empresa %s no Questor", codigo_empresa)
return None
if not linha:
return None
nome_empresa = (linha["nomeempresa"] or "").strip()
if not nome_empresa:
return None
EmpresaQuestor.objects.update_or_create(
codigo_empresa=codigo_empresa, defaults={"nome_empresa": nome_empresa}
)
return nome_empresa

View File

@ -0,0 +1,33 @@
# Generated by Django 6.0.7 on 2026-08-21 13:19
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0040_importacaoplanosaude_regra_custeio_salva'),
]
operations = [
migrations.AlterModelOptions(
name='regracusteioplanosaude',
options={'ordering': [django.db.models.functions.text.Length('codigo_empresa'), 'codigo_empresa', 'operadora'], 'verbose_name': 'Regra de custeio de plano de saúde', 'verbose_name_plural': 'Regras de custeio de plano de saúde'},
),
migrations.AddField(
model_name='regracusteioplanosaude',
name='codigo_empresa',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='Código da empresa'),
),
migrations.AddField(
model_name='regracusteioplanosaude',
name='regra_empresa_chave',
field=models.CharField(blank=True, max_length=50, verbose_name='Regra empresa (mensalidade)'),
),
migrations.AlterField(
model_name='regracusteioplanosaude',
name='operadora',
field=models.CharField(max_length=50, verbose_name='Operadora'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 6.0.7 on 2026-08-21 13:21
from django.db import migrations
def preenche_codigo_empresa(apps, schema_editor):
"""Até esta migração, o `nome` de cada RegraCusteioPlanoSaude seguia a
convenção livre "<código empresa> - <texto>" (ex.: "092 - Unimed") — o
novo campo `codigo_empresa` extrai essa mesma informação, já validada
contra os 12 registros reais antes de escrever este backfill (nenhum
tem formato fora do padrão "<código> - ...")."""
RegraCusteioPlanoSaude = apps.get_model("portal_api", "RegraCusteioPlanoSaude")
for regra in RegraCusteioPlanoSaude.objects.all():
regra.codigo_empresa = regra.nome.split(" - ", 1)[0].strip()
regra.save(update_fields=["codigo_empresa"])
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0041_regracusteioplanosaude_codigo_empresa'),
]
operations = [
migrations.RunPython(preenche_codigo_empresa, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 6.0.7 on 2026-08-21 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0042_backfill_regracusteioplanosaude_codigo_empresa'),
]
operations = [
migrations.AlterField(
model_name='regracusteioplanosaude',
name='codigo_empresa',
field=models.CharField(max_length=20, verbose_name='Código da empresa'),
),
migrations.AlterUniqueTogether(
name='regracusteioplanosaude',
unique_together={('codigo_empresa', 'operadora')},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-08-21 14:15
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0043_regracusteioplanosaude_codigo_empresa_obrigatorio'),
]
operations = [
migrations.CreateModel(
name='EmpresaQuestor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('codigo_empresa', models.CharField(max_length=20, unique=True, verbose_name='Código da empresa')),
('nome_empresa', models.CharField(max_length=255, verbose_name='Nome da empresa')),
('criado_em', models.DateTimeField(auto_now_add=True, verbose_name='Criado em')),
('atualizado_em', models.DateTimeField(auto_now=True, verbose_name='Atualizado em')),
],
options={
'verbose_name': 'Empresa (Questor)',
'verbose_name_plural': 'Empresas (Questor)',
'ordering': [django.db.models.functions.text.Length('codigo_empresa'), 'codigo_empresa'],
},
),
]

View File

@ -716,18 +716,65 @@ class ImportacaoPlanoSaudeAlteracao(models.Model):
return f"{self.importacao} — {self.get_tipo_display()}" return f"{self.importacao} — {self.get_tipo_display()}"
class EmpresaQuestor(models.Model):
"""Cache local do nome de uma empresa cadastrada no Questor (banco
externo, fora do Django, acessado só-leitura via
`database.connection.DatabaseConnection` + `sqls.questor.QuestorSQL` —
ver `portal_api.empresas_questor.resolve_nome_empresa`), indexado por
`codigo_empresa`. Resolvido uma única vez (na primeira vez que um
`codigo_empresa` aparece em "Cadastro de Regras" — seja digitado em "+
Nova regra" ou já existente numa regra antiga sem cache ainda) e
reaproveitado depois — a consulta ao Questor não precisa se repetir a
cada exibição, só na primeira. Se o nome oficial mudar no Questor, o
cache não atualiza sozinho (sem esse mecanismo hoje); não é um problema
esperado com frequência pra justificar essa complexidade agora."""
codigo_empresa = models.CharField("Código da empresa", max_length=20, unique=True)
nome_empresa = models.CharField("Nome da empresa", max_length=255)
criado_em = models.DateTimeField("Criado em", auto_now_add=True)
atualizado_em = models.DateTimeField("Atualizado em", auto_now=True)
class Meta:
verbose_name = "Empresa (Questor)"
verbose_name_plural = "Empresas (Questor)"
ordering = [Length("codigo_empresa"), "codigo_empresa"]
def __str__(self) -> str:
return f"{self.codigo_empresa} - {self.nome_empresa}"
class RegraCusteioPlanoSaude(models.Model): class RegraCusteioPlanoSaude(models.Model):
"""Regra de custeio salva (ex.: "092 - Unimed") pra reaplicar em """Regra de custeio cadastrada pra uma empresa+operadora (ex.: empresa
importações futuras de Plano de Saúde, sem redigitar o custeio de cada "092" + Unimed), reaplicada nas importações futuras de Plano de Saúde
combinação tipo de lançamento × tipo de beneficiário — substitui o antigo dessa combinação — cadastro e edição vivem só na tela "Cadastro de
fluxo de exportar/importar um arquivo `.json` manualmente. Guarda o mesmo Regras" (`importacao-plano-saude.js`), separada da tela de execução
par `tipos_lancamento`/`custeio_por_tipo` de `ImportacaoPlanoSaude`, no ("Nova Importação"), que só resolve e aplica a regra já existente, sem
mesmo formato (ver `RegraCusteioPlanoSaudeSerializer` em serializers.py). editá-la. Guarda o mesmo par `tipos_lancamento`/`custeio_por_tipo` de
Lista compartilhada, sem "dono" — mesma permissão de toggle único da `ImportacaoPlanoSaude`, no mesmo formato (ver
própria ferramenta (`PermissaoApp("utilitarios", "importacao-plano-saude")`).""" `RegraCusteioPlanoSaudeSerializer` em serializers.py). Lista
compartilhada, sem "dono" — mesma permissão de toggle único da própria
ferramenta (`PermissaoApp("utilitarios", "importacao-plano-saude")`).
`codigo_empresa`+`operadora` são únicos juntos (`Meta.unique_together`)
— uma única regra por empresa+operadora, decisão validada contra os
dados reais existentes antes de impor a restrição. `nome` não é mais
digitado pelo usuário: é sempre derivado em
`RegraCusteioPlanoSaudeSerializer.validate()` como
"<codigo_empresa> - <nome da operadora>" (cuidado: o "código" dentro do
label de `pipeline.OPERADORAS` é o código de cadastro da OPERADORA no
Questor, não o `codigo_empresa` do cliente — são códigos diferentes,
não confundir ao compor o nome)."""
nome = models.CharField("Nome", max_length=100) nome = models.CharField("Nome", max_length=100)
operadora = models.CharField("Operadora", max_length=50, blank=True) codigo_empresa = models.CharField("Código da empresa", max_length=20)
operadora = models.CharField("Operadora", max_length=50)
# Quando preenchida, indica que o tipo de lançamento "mensalidade" desta
# regra usa o algoritmo especial por família de
# `planos_saude.regras_empresa.REGRAS_EMPRESA[chave]` em vez do custeio
# manual titular/dependente — unifica o antigo checkbox "Regra empresa"
# (que era um caminho paralelo na tela de Nova Importação) dentro do
# cadastro por empresa+operadora.
regra_empresa_chave = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True)
tipos_lancamento = models.JSONField("Tipos de lançamento", default=list) tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict) custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
observacoes = models.TextField("Observações", blank=True) observacoes = models.TextField("Observações", blank=True)
@ -740,7 +787,8 @@ class RegraCusteioPlanoSaude(models.Model):
class Meta: class Meta:
verbose_name = "Regra de custeio de plano de saúde" verbose_name = "Regra de custeio de plano de saúde"
verbose_name_plural = "Regras de custeio de plano de saúde" verbose_name_plural = "Regras de custeio de plano de saúde"
ordering = ["nome"] unique_together = [["codigo_empresa", "operadora"]]
ordering = [Length("codigo_empresa"), "codigo_empresa", "operadora"]
def __str__(self) -> str: def __str__(self) -> str:
return self.nome return self.nome

View File

@ -0,0 +1,225 @@
"""
Bradesco Dental (3759) - Fatura Técnica, Mensalidade + Coparticipação
(coparticipação ainda não validada com dado real, ver particularidade 5).
Formato recebido: PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura
Técnica". A operadora aparece no boleto (página 1) como "Bradsaude S.A." —
o mesmo plano odontológico da empresa era cadastrado no Questor como
"ODONTOPREV S.A." (`CODIGOOUTEMP=3759` na planilha padrão) e o próprio
boleto avisa: "Este boleto refere-se ao seu plano odontológico, antes
cobrado como Odontoprev e agora identificado temporariamente como
Bradsaude" — é a mesma operadora (código 3759), só rebatizada.
IMPORTANTE — ao contrário dos demais parsers deste pacote, este NÃO foi
testado rodando `pdfplumber`/`docling` contra o PDF real (o arquivo só
chegou anexado numa conversa, sem ficar disponível em disco pra teste
direto). O parser abaixo foi escrito a partir do texto extraído do PDF
mostrado nessa conversa, que já veio bem tabular/alinhado (bem diferente
do "sem nenhum texto selecionável" da Bradesco Saúde, ver
`operadoras/bradesco/saude.py`) — isso sugere que este PDF TEM texto
selecionável e que `pdfplumber` deve funcionar, mas isso precisa ser
confirmado na prática (botão "Selecionar arquivo" da tela de Nova
Importação, que já roda este mesmo parser antes de qualquer coisa ser
persistida). Se a extração vier vazia (`page.chars`/`extract_text()` sem
nada, mesmo sintoma da Bradesco Saúde), troque para a técnica de OCR via
`docling` documentada em `saude.py` — não assuma que `pdfplumber` vai
funcionar só porque funcionou nos outros PDFs deste pacote.
Layout da tabela de beneficiários (página 2 em diante — a página 1 é
sempre o boleto, sem nenhuma linha de beneficiário; as páginas finais são
só o texto legal "MENSAGENS", ver `_pdf_para_linhas`): Certif. | Nome
Beneficiário | Subfatura Nº | Data Nascimento | Sexo | Est. Civil |
Paren. | Plano | Data Início | Mov | Mês/Ano | Valor | Part. Benef. — as
três últimas vêm sob um cabeçalho mesclado "Lançamento" (mesmo desenho da
Bradesco Saúde).
Particularidades identificadas inspecionando o texto extraído do PDF real
(competência 08/2026, empresa FRT Consolidadora Ltda):
1. NÃO HÁ CPF NESTE ARQUIVO. Casamento com a planilha padrão é por NOME
(chave_casamento = "nome"), igual à Bradesco Saúde/Itamed/Unimed/
Dental Uni.
2. Titular x dependente é decidido pela coluna "Certif." (não por uma
coluna "Tipo" dedicada, confirmado pelo usuário): o valor vem no
formato "<família>/<sufixo>" (ex.: "0000019/00", "0000019/01") —
sufixo "00" é sempre o titular, qualquer outro sufixo é dependente da
mesma família. `numero_beneficiario` é o "Certif." completo (já é
único por pessoa); `numero_titular` de um dependente é deduzido como
"<família>/00" — mesma técnica da Bradesco Saúde.
3. UM MESMO BENEFICIÁRIO PODE GERAR VÁRIAS LINHAS DE LANÇAMENTO — não só
por rubricas diferentes (como na Amil), mas por MOVIMENTAÇÃO
retroativa: quando alguém é incluído ou cancelado com efeito
retroativo, o relatório imprime uma linha extra por mês afetado, com
um código de "Mov" (CM=Cancelamento no Mês, CR=Cancelamento
Retroativo, IR=Inclusão Retroativa, IM=Inclusão no Mês) e o respectivo
Mês/Ano — ex.: um beneficiário cancelado no mês pode ter a linha
normal (identificando ele) e mais 1–3 linhas de continuação, cada uma
com seu Mês/Ano e Valor próprios (valores de cancelamento vêm
negativos). Essas linhas de continuação NÃO repetem "Certif."/"Nome
Beneficiário" (ficam em branco) — pertencem ao ÚLTIMO beneficiário
identificado antes delas. Validado batendo a soma de todas as linhas
(identificação + continuação) contra o total da fatura (R$ 949,05) e
contra a tabela "Resumo" do boleto (Lançamentos=37, que é exatamente
32 beneficiários + 5 linhas de continuação) — todas as linhas de um
mesmo indivíduo são somadas (`_agrega_por_individuo_e_tipo`), mesmo
espírito de "somar todas as rubricas do mesmo indivíduo" já usado pela
Amil.
4. TRUQUE DE EXTRAÇÃO — não é preciso reconhecer a coluna "Mov" nem
"Plano"/"Data Início" pra achar Mês/Ano, Valor e Part. Benef.: nas
duas formas de linha (identificação ou continuação), esses três
campos são SEMPRE os 3 últimos tokens da linha, na mesma ordem,
independente de haver ou não um código de movimento antes deles
(confirmado nos dois casos reais: uma linha de identificação com "IR"
embutido tem os mesmos 3 últimos tokens que uma linha comum sem
nenhum código). Por isso `_parseia_lancamentos` nunca tenta separar
Plano/Data Início/Mov — só localiza o Certif. (se houver, no início da
linha) e os 3 últimos tokens.
5. Valores negativos vêm com um "-" DEPOIS do número (ex.: "30,78-"),
mesma convenção da Amil — usados nas linhas de cancelamento.
6. "Part. Benef." sempre veio "0,00" no único arquivo de referência (sem
coparticipação neste plano até o momento) — por isso, na prática,
nenhum `Individuo` de `tipo_lancamento="coparticipacao"` é gerado
ainda (mesmo espírito da Bradesco Saúde). Fica pronto para o dia em
que aparecer um valor real ali, mas ISSO NÃO FOI VALIDADO com nenhum
arquivo real com coparticipação.
7. A página do boleto (página 1) e as páginas finais de "MENSAGENS" nunca
têm um token no formato "<dígitos>/<2 dígitos>" no início da linha —
por isso são ignoradas naturalmente pelo parser, sem precisar de
nenhuma lógica de detecção de página. Como reforço (e pra nunca
confundir texto legal com dado real), a extração para de processar
linhas assim que encontra "MENSAGENS" sozinho numa linha.
"""
import re
from typing import Dict, List, Optional, Tuple
import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
from portal_api.planos_saude.operadoras.base import OperadoraParser
_CERTIF_RE = re.compile(r"^(?P<familia>\d+)/(?P<sufixo>\d{2})$")
_DATA_NASC_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$")
_MES_ANO_RE = re.compile(r"^\d{2}/\d{4}$")
_MONEY_RE = re.compile(r"^\d{1,3}(?:\.\d{3})*,\d{2}-?$")
_MARCADOR_MENSAGENS_RE = re.compile(r"^MENSAGENS$")
def _valor_para_float(texto: str) -> float:
"""'30,78' -> 30.78 '30,78-' -> -30.78 '1.234,56' -> 1234.56"""
negativo = texto.endswith("-")
texto = texto.rstrip("-").replace(".", "").replace(",", ".")
valor = float(texto)
return -valor if negativo else valor
class BradescoDentalOdontoMensalidade(OperadoraParser):
nome_operadora = "BRADESCO DENTAL"
chave_casamento = "nome" # PDF não traz CPF, só "Certif." por pessoa
def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]:
linhas: List[str] = []
with pdfplumber.open(caminho_pdf) as pdf:
for page in pdf.pages:
texto = page.extract_text(layout=True, x_density=6, y_density=10) or ""
for linha in texto.splitlines():
if _MARCADOR_MENSAGENS_RE.match(linha.strip()):
# a partir daqui só há texto legal — ver particularidade 7.
return linhas
linhas.append(linha)
return linhas
def _parseia_lancamentos(self, linhas: List[str]) -> List[Lancamento]:
lancamentos: List[Lancamento] = []
certif_atual: Optional[str] = None
nome_atual: Optional[str] = None
tipo_atual: Optional[str] = None
numero_titular_atual: Optional[str] = None
for linha in linhas:
tokens = linha.split()
if len(tokens) < 3:
continue
m_certif = _CERTIF_RE.match(tokens[0])
if m_certif:
idx_data_nasc = next(
(i for i, t in enumerate(tokens) if i >= 2 and _DATA_NASC_RE.match(t)),
None,
)
if idx_data_nasc is None:
continue # linha com "Certif." mas sem nome+nascimento reconhecíveis — ruído
nome_atual = " ".join(tokens[1:idx_data_nasc])
certif_atual = tokens[0]
familia, sufixo = m_certif.group("familia"), m_certif.group("sufixo")
tipo_atual = "T" if sufixo == "00" else "D"
numero_titular_atual = None if tipo_atual == "T" else f"{familia}/00"
elif certif_atual is None:
continue # ruído antes do primeiro beneficiário (boleto, cabeçalho da tabela)
# ver particularidade 4: os 3 últimos tokens são sempre
# Mês/Ano, Valor e Part. Benef., com ou sem código de Mov.
mes_ano, valor_txt, part_benef_txt = tokens[-3], tokens[-2], tokens[-1]
if not (
_MES_ANO_RE.match(mes_ano)
and _MONEY_RE.match(valor_txt)
and _MONEY_RE.match(part_benef_txt)
):
continue # não é uma linha de lançamento (ex.: cabeçalho da própria tabela)
lancamentos.append(Lancamento(
numero_beneficiario=certif_atual,
nome=nome_atual,
cpf="",
tipo=tipo_atual,
rubrica=f"Mensalidade {mes_ano}",
valor=_valor_para_float(valor_txt),
tipo_lancamento="mensalidade",
numero_titular=numero_titular_atual,
))
part_benef = _valor_para_float(part_benef_txt)
if part_benef != 0.0:
# ver particularidade 6 — nunca visto com valor real ainda.
lancamentos.append(Lancamento(
numero_beneficiario=certif_atual,
nome=nome_atual,
cpf="",
tipo=tipo_atual,
rubrica=f"Coparticipação {mes_ano}",
valor=part_benef,
tipo_lancamento="coparticipacao",
numero_titular=numero_titular_atual,
))
return lancamentos
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
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,
numero_titular=lc.numero_titular,
)
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]
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
linhas = self._pdf_para_linhas(caminho_arquivo)
lancamentos = self._parseia_lancamentos(linhas)
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
return individuos, [] # Bradesco Dental não gera itens de auditoria na extração

View File

@ -16,6 +16,7 @@ from portal_api.planos_saude.matcher import casa_individuos_com_planilha
from portal_api.planos_saude.modelos import ItemAuditoria, LinhaSistema from portal_api.planos_saude.modelos import ItemAuditoria, LinhaSistema
from portal_api.planos_saude.regras_empresa import valida_regra_empresa from portal_api.planos_saude.regras_empresa import valida_regra_empresa
from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdontoMensalidade from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdontoMensalidade
from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade
from portal_api.planos_saude.operadoras.bradesco.saude import BradescoSaude 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
@ -51,6 +52,10 @@ OPERADORAS = {
"label": "1386 - Bradesco Saúde", "label": "1386 - Bradesco Saúde",
"parser": BradescoSaude, "parser": BradescoSaude,
}, },
"bradesco_dental_odonto_mensalidade": {
"label": "3759 - Bradessaude Odonto",
"parser": BradescoDentalOdontoMensalidade,
},
} }

View File

@ -3,8 +3,10 @@ from typing import Any
import nh3 import nh3
from django.db.models import Q from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from . import catalogo from . import catalogo
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .indicadores import calculo as indicadores_calculo from .indicadores import calculo as indicadores_calculo
from .models import ( from .models import (
AcessoGeral, AcessoGeral,
@ -12,6 +14,7 @@ from .models import (
CategoriaEvento, CategoriaEvento,
CompromissoAgenda, CompromissoAgenda,
Departamento, Departamento,
EmpresaQuestor,
Favorito, Favorito,
FuncaoTelefonia, FuncaoTelefonia,
ImportacaoPlanoSaude, ImportacaoPlanoSaude,
@ -705,6 +708,35 @@ class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
return dados.get("nome_dependente") or dados.get("nome_func") or None return dados.get("nome_dependente") or dados.get("nome_func") or None
def _codigo_empresa_da_importacao(obj: ImportacaoPlanoSaude) -> str:
"""Todas as linhas de uma importação vêm da mesma planilha padrão, então
compartilham o mesmo código de empresa — pega o primeiro não vazio (uma
linha incluída manualmente pode nascer sem esse campo preenchido).
Compartilhado por `ImportacaoPlanoSaudeListSerializer` e
`ImportacaoPlanoSaudeDetailSerializer`."""
linha = obj.linhas.exclude(codigo_empresa="").first()
return linha.codigo_empresa if linha else ""
def _nome_empresa_cacheado(codigo_empresa: str) -> str | None:
"""Só lê o cache local (`EmpresaQuestor`) — nunca consulta o Questor de
novo aqui. Usado na tela de Revisão, onde o nome já deveria ter sido
resolvido antes (ao cadastrar/editar a regra de custeio aplicada, ver
`RegraCusteioPlanoSaudeSerializer.get_nome_empresa`); diferente desse
outro ponto, não vale a pena pagar o custo de uma consulta ao Questor
aqui, numa tela consultada com muito mais frequência. Normaliza o
código antes de buscar (ver `normalizar_codigo_empresa`) porque este
aqui vem cru da planilha padrão (`ImportacaoPlanoSaudeLinha`, pode ter
zero à esquerda tipo "092"), enquanto a chave em `EmpresaQuestor` já é
sempre canônica ("92") — sem normalizar aqui, a busca erraria por uma
diferença que nem devia importar."""
codigo_empresa = normalizar_codigo_empresa(codigo_empresa)
if not codigo_empresa:
return None
cache = EmpresaQuestor.objects.filter(codigo_empresa=codigo_empresa).first()
return cache.nome_empresa if cache else None
class ImportacaoPlanoSaudeListSerializer(serializers.ModelSerializer): class ImportacaoPlanoSaudeListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None) criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
codigo_empresa = serializers.SerializerMethodField() codigo_empresa = serializers.SerializerMethodField()
@ -724,11 +756,7 @@ class ImportacaoPlanoSaudeListSerializer(serializers.ModelSerializer):
] ]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaude) -> str: def get_codigo_empresa(self, obj: ImportacaoPlanoSaude) -> str:
"""Todas as linhas de uma importação vêm da mesma planilha padrão, então return _codigo_empresa_da_importacao(obj)
compartilham o mesmo código de empresa — pega o primeiro não vazio (uma
linha incluída manualmente pode nascer sem esse campo preenchido)."""
linha = obj.linhas.exclude(codigo_empresa="").first()
return linha.codigo_empresa if linha else ""
class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer): class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
@ -737,6 +765,8 @@ class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
itens_auditoria = ImportacaoPlanoSaudeAuditoriaSerializer(many=True, read_only=True) itens_auditoria = ImportacaoPlanoSaudeAuditoriaSerializer(many=True, read_only=True)
alteracoes = ImportacaoPlanoSaudeAlteracaoSerializer(many=True, read_only=True) alteracoes = ImportacaoPlanoSaudeAlteracaoSerializer(many=True, read_only=True)
resumo_por_tipo = serializers.SerializerMethodField() resumo_por_tipo = serializers.SerializerMethodField()
codigo_empresa = serializers.SerializerMethodField()
nome_empresa = serializers.SerializerMethodField()
regra_empresa_label = serializers.SerializerMethodField() regra_empresa_label = serializers.SerializerMethodField()
regra_empresa_observacoes = serializers.SerializerMethodField() regra_empresa_observacoes = serializers.SerializerMethodField()
regra_custeio_salva_nome = serializers.CharField(source="regra_custeio_salva.nome", read_only=True, default=None) regra_custeio_salva_nome = serializers.CharField(source="regra_custeio_salva.nome", read_only=True, default=None)
@ -750,6 +780,8 @@ class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
"id", "id",
"operadora", "operadora",
"nome_operadora", "nome_operadora",
"codigo_empresa",
"nome_empresa",
"tipos_lancamento", "tipos_lancamento",
"custeio_por_tipo", "custeio_por_tipo",
"regra_empresa", "regra_empresa",
@ -768,6 +800,12 @@ class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
"resumo_por_tipo", "resumo_por_tipo",
] ]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaude) -> str:
return _codigo_empresa_da_importacao(obj)
def get_nome_empresa(self, obj: ImportacaoPlanoSaude) -> str | None:
return _nome_empresa_cacheado(self.get_codigo_empresa(obj))
def get_regra_empresa_label(self, obj: ImportacaoPlanoSaude) -> str | None: def get_regra_empresa_label(self, obj: ImportacaoPlanoSaude) -> str | None:
"""Rótulo pra exibir na revisão (ex.: "1778 - Unimed (Tecnomyl)") — """Rótulo pra exibir na revisão (ex.: "1778 - Unimed (Tecnomyl)") —
REGRAS_EMPRESA é um registro fixo no código, não uma tabela, então REGRAS_EMPRESA é um registro fixo no código, não uma tabela, então
@ -807,24 +845,47 @@ class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
return resumo return resumo
def _nome_operadora_sem_codigo(operadora_key: str) -> str:
"""`pipeline.OPERADORAS[chave]["label"]` vem no formato "<código de
cadastro da operadora no Questor> - <Nome>" (ex.: "5060 - Unimed
Saúde") — esse código é da OPERADORA, não do `codigo_empresa` do
cliente que contratou o plano; usado só pra compor o `nome` derivado de
RegraCusteioPlanoSaude sem repetir esse código, que não tem relação com
a empresa."""
label = OPERADORAS[operadora_key]["label"]
return label.split(" - ", 1)[1] if " - " in label else label
class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer): class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
"""Banco de regras de custeio salvas (ex.: "092 - Unimed") — substitui o """Banco de regras de custeio cadastradas por empresa+operadora (ex.:
antigo fluxo de exportar/importar um arquivo `.json`. `custeio_por_tipo` empresa "092" + Unimed) — cadastro/edição só na tela "Cadastro de
chega do frontend no mesmo "formato de formulário" já usado por Regras" (`importacao-plano-saude.js`), separada da execução da
coletarRegraCusteioAtual() (limite_valor/percentual como texto BR, ex. importação. `custeio_por_tipo` chega do frontend no mesmo "formato de
"150,00") e é validado/normalizado aqui pro formato final (float|None) formulário" já usado por coletarCusteioAtual() (limite_valor/percentual
antes de salvar, via `_monta_regra_custeio` — mesma regra de negócio de como texto BR, ex. "150,00") e é validado/normalizado aqui pro formato
ImportacaoPlanoSaudeCreateSerializer, pra nunca divergir entre os dois final (float|None) antes de salvar, via `_monta_regra_custeio` — mesma
pontos de entrada.""" regra de negócio de ImportacaoPlanoSaudeCreateSerializer, pra nunca
divergir entre os dois pontos de entrada.
`nome` é sempre derivado aqui (nunca aceito do cliente — ver
RegraCusteioPlanoSaude.nome) e `codigo_empresa`+`operadora` são
validados como únicos juntos via `UniqueTogetherValidator` explícito
(em vez de confiar só no validador automático do DRF), pra manter a
mensagem de erro em português consistente com o resto do app."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None) criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
nome = serializers.CharField(read_only=True)
nome_empresa = serializers.SerializerMethodField()
class Meta: class Meta:
model = RegraCusteioPlanoSaude model = RegraCusteioPlanoSaude
fields = [ fields = [
"id", "id",
"nome", "nome",
"codigo_empresa",
"nome_empresa",
"operadora", "operadora",
"regra_empresa_chave",
"tipos_lancamento", "tipos_lancamento",
"custeio_por_tipo", "custeio_por_tipo",
"observacoes", "observacoes",
@ -833,11 +894,39 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
"atualizado_em", "atualizado_em",
] ]
read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"] read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"]
validators = [
UniqueTogetherValidator(
queryset=RegraCusteioPlanoSaude.objects.all(),
fields=["codigo_empresa", "operadora"],
message="Já existe uma regra de custeio cadastrada para essa empresa com essa operadora.",
)
]
def validate_nome(self, value: str) -> str: def get_nome_empresa(self, obj: RegraCusteioPlanoSaude) -> str | None:
value = value.strip() """Resolve (e cacheia, se ainda não tiver — ver
`empresas_questor.resolve_nome_empresa`) o nome da empresa desta
regra a partir do Questor. Regras cadastradas antes deste campo
existir ainda não têm nada em `EmpresaQuestor` — resolver aqui, na
leitura, preenche o cache sozinho na primeira vez que a lista é
carregada, sem precisar de nenhum backfill manual."""
return resolve_nome_empresa(obj.codigo_empresa)
def validate_codigo_empresa(self, value: str) -> str:
value = normalizar_codigo_empresa(value)
if not value: if not value:
raise serializers.ValidationError("Informe um nome para a regra.") raise serializers.ValidationError("Informe o código da empresa.")
return value
def validate_operadora(self, value: str) -> str:
value = (value or "").strip()
if value not in OPERADORAS:
raise serializers.ValidationError(f"Operadora inválida: {value}")
return value
def validate_regra_empresa_chave(self, value: str) -> str:
value = (value or "").strip()
if value and value not in REGRAS_EMPRESA:
raise serializers.ValidationError(f"Regra empresa desconhecida: {value}")
return value return value
def validate_tipos_lancamento(self, value: Any) -> list[str]: def validate_tipos_lancamento(self, value: Any) -> list[str]:
@ -852,14 +941,46 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
tipos = attrs.get("tipos_lancamento") tipos = attrs.get("tipos_lancamento")
if tipos is None: if tipos is None:
tipos = self.instance.tipos_lancamento if self.instance else [] tipos = self.instance.tipos_lancamento if self.instance else []
codigo_empresa = attrs.get("codigo_empresa", self.instance.codigo_empresa if self.instance else None)
operadora_key = attrs.get("operadora", self.instance.operadora if self.instance else None)
regra_empresa_chave = attrs.get(
"regra_empresa_chave", self.instance.regra_empresa_chave if self.instance else ""
)
custeio_bruto = attrs.get("custeio_por_tipo") custeio_bruto = attrs.get("custeio_por_tipo")
if custeio_bruto is None: if custeio_bruto is None:
custeio_bruto = self.instance.custeio_por_tipo if self.instance else {} custeio_bruto = self.instance.custeio_por_tipo if self.instance else {}
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."})
if regra_empresa_chave:
regra_re = REGRAS_EMPRESA[regra_empresa_chave]
if "mensalidade" not in tipos:
raise serializers.ValidationError(
{"regra_empresa_chave": "Exige o tipo de importação 'Mensalidade' selecionado."}
)
if regra_re["codigo_empresa"] != codigo_empresa:
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"Esta regra especial foi cadastrada para a empresa código "
f"{regra_re['codigo_empresa']}, não para {codigo_empresa}."
)
}
)
if regra_re["operadora"] != operadora_key:
raise serializers.ValidationError(
{"regra_empresa_chave": "Esta regra especial foi cadastrada para outra operadora."}
)
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 —
# sem titular/dependente pra configurar aqui (mesmo padrão de
# ImportacaoPlanoSaudeCreateSerializer.validate()).
if tipo == "mensalidade" and regra_empresa_chave:
custeio_validado[tipo] = {}
continue
por_tipo = custeio_bruto.get(tipo) or {} por_tipo = custeio_bruto.get(tipo) or {}
if not isinstance(por_tipo, dict): if not isinstance(por_tipo, dict):
raise serializers.ValidationError({"custeio_por_tipo": f"Formato inválido para o tipo {tipo}."}) raise serializers.ValidationError({"custeio_por_tipo": f"Formato inválido para o tipo {tipo}."})
@ -878,6 +999,7 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
custeio_validado[tipo] = custeio_por_pessoa custeio_validado[tipo] = custeio_por_pessoa
attrs["custeio_por_tipo"] = custeio_validado attrs["custeio_por_tipo"] = custeio_validado
attrs["nome"] = f"{codigo_empresa} - {_nome_operadora_sem_codigo(operadora_key)}"
return attrs return attrs

View File

@ -64,6 +64,7 @@ from .indicadores import departamentos as indicadores_departamentos
from .indicadores import pipeline as indicadores_pipeline from .indicadores import pipeline as indicadores_pipeline
from .indicadores import recibo as indicadores_recibo from .indicadores import recibo as indicadores_recibo
from .permissions import PermissaoApp, PodeGerenciarPermissoes from .permissions import PermissaoApp, PodeGerenciarPermissoes
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .planos_saude import matcher as planos_saude_matcher from .planos_saude import matcher as planos_saude_matcher
from .planos_saude import pipeline as planos_saude_pipeline from .planos_saude import pipeline as planos_saude_pipeline
from .planos_saude import regras_empresa as planos_saude_regras_empresa from .planos_saude import regras_empresa as planos_saude_regras_empresa
@ -869,6 +870,39 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Trava de segurança: a regra de custeio aplicada foi cadastrada pra
# uma empresa específica (RegraCusteioPlanoSaude.codigo_empresa) —
# confere que a planilha padrão anexada de fato tem alguma linha
# dessa empresa, senão a importação segue com o custeio da empresa
# errada sem nenhum aviso. Vale mesmo quando a regra também tem
# `regra_empresa_chave` (onde é redundante com a checagem que
# `regras_empresa.valida_regra_empresa()` já faz dentro do try acima)
# — protege contra o registro em REGRAS_EMPRESA ficar dessincronizado
# da RegraCusteioPlanoSaude correspondente.
regra_custeio_obj = dados.get("regra_custeio_salva")
if regra_custeio_obj and regra_custeio_obj.codigo_empresa:
todas_linhas = [linha for linhas in resultado.linhas_por_tipo.values() for linha in linhas]
# A planilha padrão traz o código cru do Questor (pode ter zero à
# esquerda, ex. "092"), enquanto RegraCusteioPlanoSaude.codigo_empresa
# já é sempre canônico (sem zero à esquerda, ver normalizar_codigo_empresa)
# — comparar sem normalizar os dois lados rejeitaria uma planilha
# correta só por essa diferença de formatação.
codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
importacao.arquivo_operadora.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
return Response(
{
"detail": (
f'A regra "{regra_custeio_obj.nome}" foi cadastrada para a empresa código '
f"{regra_custeio_obj.codigo_empresa}, mas a planilha padrão anexada não tem "
"nenhuma linha com esse código."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
linhas_bulk = [ linhas_bulk = [
ImportacaoPlanoSaudeLinha( ImportacaoPlanoSaudeLinha(
importacao=importacao, importacao=importacao,
@ -1184,6 +1218,18 @@ class RegraCusteioPlanoSaudeViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer: RegraCusteioPlanoSaudeSerializer) -> None: def perform_create(self, serializer: RegraCusteioPlanoSaudeSerializer) -> None:
serializer.save(criado_por=self.request.user) serializer.save(criado_por=self.request.user)
@action(detail=False, methods=["get"], url_path="nome-empresa")
def nome_empresa(self, request: Request) -> Response:
"""Resolve (e cacheia) o nome de uma empresa no Questor a partir do
código digitado em "+ Nova regra" — ver empresas_questor.py. Devolve
`nome_empresa: null` tanto pra "código não existe no Questor" quanto
pra "Questor inacessível agora"; o frontend não usa isso como
bloqueio, só como exibição."""
codigo = normalizar_codigo_empresa(request.query_params.get("codigo_empresa") or "")
if not codigo:
return Response({"detail": "Informe codigo_empresa."}, status=status.HTTP_400_BAD_REQUEST)
return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
class IndicadorPercentualTipoViewSet(viewsets.ModelViewSet): class IndicadorPercentualTipoViewSet(viewsets.ModelViewSet):
"""Cadastro dos percentuais individual/grupo/departamento por tipo de """Cadastro dos percentuais individual/grupo/departamento por tipo de

0
sqls/__init__.py Normal file
View File

22
sqls/questor.py Normal file
View File

@ -0,0 +1,22 @@
from textwrap import dedent
from database.query import SQLQuery
class QuestorSQL:
"""Consultas ao banco do Questor (`DatabaseConnection("QUESTOR")`) — só
leitura, nunca `execute`/`execute_returning` (ver
[[feedback_bancos_externos_somente_leitura]] na memória do projeto)."""
@staticmethod
def consulta_nome_empresa(codigo_empresa: str) -> SQLQuery:
sql = dedent(r"""
select
e.codigoempresa,
e.nomeempresa
from
empresa e
where
e.codigoempresa = :codigo_empresa
""")
return SQLQuery(sql=sql, params={"codigo_empresa": codigo_empresa})

View File

@ -23,6 +23,16 @@
margin-top: 2px; margin-top: 2px;
} }
/* "Revisão <código - nome da empresa>" — pedido explícito do usuário pra
identificar de cara qual empresa está sendo importada, sem precisar
abrir a aba de linhas. Cor mais discreta que o próprio "Revisão", pra
não competir com o título. */
.ips-review-empresa {
font-size: 0.85rem;
font-weight: 400;
color: var(--text-muted);
}
.ips-empty { .ips-empty {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
@ -209,47 +219,69 @@
margin-top: var(--space-7); margin-top: var(--space-7);
} }
/* Regra de custeio salva — fica no início do modal, antes até de "Operadora"
(decisão explícita do usuário: aplicar uma regra já preenche a operadora
junto, então escolher a regra é o primeiro passo natural do fluxo, não um
apêndice no final). Por vir antes de tudo, a borda de separação fica
embaixo (não em cima), pra separar do campo "Operadora" logo abaixo. */
.ips-regra-field {
margin-bottom: var(--space-5);
padding-bottom: var(--space-5);
border-bottom: 1px solid var(--border-subtle);
}
.ips-regra-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.ips-regra-actions button,
.ips-regra-actions label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ips-regra-actions__hint { .ips-regra-actions__hint {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
} }
/* Combobox pesquisável genérico — usado tanto por "Regra de custeio salva" /* Combobox pesquisável genérico — usado pelos pares Empresa/Operadora de
(#ips-regra-combo) quanto por "Operadora" (#ips-operadora-combo). O "Nova Importação" (#ips-imp-*) e "Cadastro de Regras" (#ips-regracad-*).
<input> já herda o estilo padrão de .modal-field input (components.css); O <input> já herda o estilo padrão de .modal-field input
aqui só cuida do posicionamento (ocupa o espaço de um <select>) e da (components.css); aqui só cuida do posicionamento (ocupa o espaço de um
lista flutuante de resultados filtrados. */ <select>) e da lista flutuante de resultados filtrados. */
.ips-combo { .ips-combo {
position: relative; position: relative;
flex: 1; flex: 1;
min-width: 220px; min-width: 220px;
} }
/* Espaço reservado pros dois botões embutidos (.ips-combo__clear e
.ips-combo__toggle abaixo) — mesmo padrão de .modal-field select
(components.css), que já reserva espaço pra seta customizada do lado
direito. */
.ips-combo__input {
padding-right: 56px !important;
}
.ips-combo__clear,
.ips-combo__toggle {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
z-index: 1;
}
/* "x" pra limpar (só aparece com algo selecionado/digitado) fica mais
perto do texto; a seta "ver todos" (.ips-combo__toggle abaixo, sempre
visível) fica na borda — mesma posição de uma seta de <select> nativo. */
.ips-combo__clear {
right: 30px;
}
.ips-combo__toggle {
right: var(--space-2);
}
.ips-combo__clear:hover,
.ips-combo__toggle:hover {
color: var(--text-primary);
}
.ips-combo__toggle:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ips-combo__list { .ips-combo__list {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
@ -282,28 +314,7 @@
color: var(--text-muted); color: var(--text-muted);
} }
.ips-regra-observacoes { /* Lista de regras salvas — reaproveita .checklist-box
margin: var(--space-2) 0 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-canvas);
border: 1px solid var(--border-subtle);
font-size: 0.82rem;
color: var(--text-muted);
white-space: pre-wrap;
}
/* "Salvar regra atual..." fica no final do formulário (não mais junto de
Aplicar/Limpar/Ver regras salvas no topo) — salvar só faz sentido depois
de parametrizar o custeio abaixo, é o último passo do fluxo de
parametrização de uma regra nova. */
.ips-regra-salvar-field {
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
/* Lista de regras salvas (#ips-regras-modal) — reaproveita .checklist-box
como moldura rolável, mas cada linha tem seus próprios botões de ação em como moldura rolável, mas cada linha tem seus próprios botões de ação em
vez do checkbox usado pelos outros consumidores de .checklist-box. */ vez do checkbox usado pelos outros consumidores de .checklist-box. */
.ips-regras-lista { .ips-regras-lista {
@ -702,6 +713,159 @@ body.is-resizing-column * {
white-space: nowrap; white-space: nowrap;
} }
/* Modal "Cadastro de Regras" (#ips-regracad-modal) — o formulário de
custeio relocado pra dentro dele (checkboxes + radios titular/dependente
de dois tipos + observações) é bem mais alto que qualquer outro modal do
app; sem isso, `.modal-card` (components.css) cresce livremente e o
conteúdo de baixo (Observações, botões de ação) fica cortado fora da
viewport em telas mais baixas/com zoom — aqui o cartão vira ele mesmo a
área rolável, em vez do overlay inteiro. */
#ips-regracad-modal .modal-card {
max-height: 90vh;
overflow-y: auto;
}
/* #ips-regra-empresa-modal é aberto por cima de #ips-regracad-modal (ao
clicar "Selecionar regra" dentro do formulário de custeio) — como os dois
são .modal-overlay com o mesmo z-index (components.css), a ordem no DOM
decidiria quem fica por cima, e #ips-regra-empresa-modal vem ANTES de
#ips-regracad-modal no HTML, então ficava escondido atrás dele. Sobe o
z-index explicitamente pra sempre renderizar acima, independente da
ordem no DOM. */
#ips-regra-empresa-modal {
z-index: 110;
}
/* Toolbar com os comboboxes de Empresa/Operadora (navegar) + "+ Nova
regra"; o bloco .ips-regracad-form reaproveita o mesmo
.ips-tipo-field/.ips-custeio-radios de antes, só que relocado pra dentro
deste modal. */
/* Empresa (com o nome resolvido do Questor ao lado, quando existe) fica
sempre numa linha própria, com a Operadora numa linha abaixo — pedido
explícito do usuário, tanto aqui quanto em "+ Nova regra" abaixo, pra
não competir visualmente com o nome da empresa. */
.ips-regracad-toolbar {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.ips-regracad-toolbar__row {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.ips-regracad-toolbar .modal-field {
margin-bottom: 0;
flex: 1;
min-width: 220px;
}
.ips-regracad-toolbar__row .btn-outline {
flex-shrink: 0;
}
/* Indica claramente se o formulário abaixo está editando uma regra
existente ou criando uma nova — pedido explícito do usuário, pra não
confundir os dois modos (ver regracadCarregarParaEdicao()/
regracadEntrarModoNovo() em importacao-plano-saude.js). */
.ips-regracad-modo {
margin: 0 0 var(--space-3);
font-size: 0.85rem;
font-weight: 700;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-canvas);
border: 1px solid var(--border-subtle);
}
.ips-regracad-modo--editando {
color: var(--accent);
}
.ips-regracad-modo--novo {
color: var(--gold);
}
.ips-regracad-novo {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
.ips-regracad-novo .modal-field {
flex: 1;
min-width: 220px;
}
.ips-regracad-novo__empresa-row {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
/* Nome da empresa resolvido no Questor (ver empresas_questor.py), exibido
ao lado do campo "Código da empresa" — os três estados (buscando/
encontrado/não encontrado) só diferem na cor do texto. */
.ips-regracad-novo__empresa-nome {
margin: 0 0 10px;
font-size: 0.88rem;
color: var(--text-secondary);
flex: 1;
min-width: 220px;
}
.ips-regracad-novo__empresa-nome--buscando {
color: var(--text-muted);
}
.ips-regracad-novo__empresa-nome--nao-encontrada {
color: var(--text-muted);
font-style: italic;
}
/* Resumo só-leitura da regra resolvida em "Nova Importação" — nenhum campo
editável aqui, só texto explicando o que a regra cadastrada configura. */
.ips-imp-resumo {
margin: var(--space-4) 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-sm);
background: var(--bg-canvas);
border: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ips-imp-resumo__tipos {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.ips-imp-resumo__linha {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
}
.ips-imp-resumo__obs {
margin: 0;
font-size: 0.82rem;
color: var(--text-muted);
white-space: pre-wrap;
}
.ips-vincular-resumo { .ips-vincular-resumo {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);

File diff suppressed because it is too large Load Diff

View File

@ -301,11 +301,17 @@
<h2>Importação de Plano de Saúde</h2> <h2>Importação de Plano de Saúde</h2>
<p class="ips-subtitle">Gere o arquivo de lançamento de mensalidade/coparticipação no leiaute do sistema, a partir do relatório da operadora.</p> <p class="ips-subtitle">Gere o arquivo de lançamento de mensalidade/coparticipação no leiaute do sistema, a partir do relatório da operadora.</p>
</div> </div>
<div class="ips-header-actions">
<button type="button" class="btn-outline" id="ips-regracad-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v4h4" stroke-linejoin="round"/></svg>
Cadastro de Regras
</button>
<button type="button" class="btn-solid" id="ips-new-btn"> <button type="button" class="btn-solid" id="ips-new-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
Nova Importação Nova Importação
</button> </button>
</div> </div>
</div>
<div class="ips-list-bulk-actions" id="ips-list-bulk-actions" hidden> <div class="ips-list-bulk-actions" id="ips-list-bulk-actions" hidden>
<span id="ips-list-bulk-count"></span> <span id="ips-list-bulk-count"></span>
@ -344,29 +350,43 @@
</div> </div>
<div class="ips-form"> <div class="ips-form">
<div class="modal-field ips-regra-field"> <div class="modal-field">
<label>Regra de custeio salva</label> <label for="ips-imp-empresa-search">Empresa</label>
<p class="ips-regra-actions__hint">Aplique uma regra salva pra preencher a operadora e o custeio abaixo automaticamente — os campos continuam editáveis normalmente depois. Selecionou a regra errada? Use "Limpar seleção" pra desfazer.</p> <p class="ips-regra-actions__hint">Só aparecem empresas com regra de custeio já cadastrada — cadastre ou edite uma regra em "Cadastro de Regras" antes de importar uma empresa nova.</p>
<div class="ips-regra-actions"> <div class="ips-combo" id="ips-imp-empresa-combo">
<div class="ips-combo" id="ips-regra-combo"> <input type="text" id="ips-imp-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" />
<input type="text" id="ips-regra-search" class="ips-combo__input" placeholder="Selecionar regra salva..." autocomplete="off" /> <input type="hidden" id="ips-imp-empresa-valor" />
<div class="ips-combo__list" id="ips-regra-combo-list" hidden></div> <button type="button" class="ips-combo__clear" id="ips-imp-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
<button type="button" class="ips-combo__toggle" id="ips-imp-empresa-toggle-btn" aria-label="Ver todas as empresas cadastradas" title="Ver todas as empresas cadastradas">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ips-combo__list" id="ips-imp-empresa-combo-list" hidden></div>
</div> </div>
<button type="button" class="btn-outline" id="ips-regra-aplicar-btn">Aplicar</button>
<button type="button" class="btn-outline" id="ips-regra-limpar-btn">Limpar seleção</button>
<button type="button" class="btn-outline" id="ips-regras-gerenciar-btn">Ver regras salvas</button>
</div> </div>
<p class="ips-regra-observacoes" id="ips-regra-observacoes" hidden></p> <div class="modal-field">
<label for="ips-imp-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-imp-operadora-combo">
<input type="text" id="ips-imp-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled />
<input type="hidden" id="ips-form-operadora" />
<button type="button" class="ips-combo__clear" id="ips-imp-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
<button type="button" class="ips-combo__toggle" id="ips-imp-operadora-toggle-btn" aria-label="Ver todas as operadoras cadastradas para esta empresa" title="Ver todas as operadoras cadastradas para esta empresa" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ips-combo__list" id="ips-imp-operadora-combo-list" hidden></div>
</div>
</div> </div>
<div class="modal-field"> <div class="ips-imp-resumo" id="ips-imp-resumo" hidden>
<label for="ips-operadora-search">Operadora</label> <p class="ips-imp-resumo__tipos" id="ips-imp-resumo-tipos"></p>
<div class="ips-combo" id="ips-operadora-combo"> <p class="ips-imp-resumo__linha" id="ips-imp-resumo-mensalidade" hidden></p>
<input type="text" id="ips-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora..." autocomplete="off" /> <p class="ips-imp-resumo__linha" id="ips-imp-resumo-coparticipacao" hidden></p>
<input type="hidden" id="ips-form-operadora" /> <p class="ips-imp-resumo__obs" id="ips-imp-resumo-observacoes" hidden></p>
<div class="ips-combo__list" id="ips-operadora-combo-list" hidden></div>
</div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label>Arquivos</label> <label>Arquivos</label>
<div class="ips-upload-box"> <div class="ips-upload-box">
@ -403,6 +423,139 @@
</div> </div>
</div> </div>
<p class="modal-error" id="ips-form-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-form-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="ips-form-submit-btn">Processar</button>
</div>
</div>
</div>
<div id="ips-review-view" hidden>
<div class="ips-header">
<div>
<h2>Revisão <span class="ips-review-empresa" id="ips-review-empresa" hidden></span></h2>
<p class="ips-subtitle" id="ips-review-subtitle"></p>
</div>
<div class="ips-header-actions">
<button type="button" class="btn-outline" id="ips-review-back-btn">Voltar</button>
<button type="button" class="btn-solid" id="ips-gerar-btn">Gerar Arquivo</button>
</div>
</div>
<div class="ips-regra-empresa-observacoes" id="ips-review-regra-empresa-obs" hidden>
<p class="ips-regra-empresa-observacoes__label" id="ips-review-regra-empresa-obs-label"></p>
<p class="ips-regra-empresa-observacoes__texto" id="ips-review-regra-empresa-obs-texto"></p>
</div>
<p class="modal-error" id="ips-review-error"></p>
<div class="pa-tabs" id="ips-tabs"></div>
<div id="ips-tab-panels"></div>
</div>
</div>
</main>
</div>
</div>
<div class="modal-overlay" id="ips-vincular-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Vincular pessoa</h2>
<p class="ips-vincular-resumo" id="ips-vincular-resumo"></p>
<div class="modal-field">
<label for="ips-vincular-search">Buscar na planilha padrão</label>
<input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" />
</div>
<div class="checklist-box" id="ips-vincular-lista"></div>
<p class="modal-error" id="ips-vincular-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-vincular-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="ips-vincular-confirm-btn" disabled>Vincular</button>
</div>
</div>
</div>
<div class="modal-overlay" id="ips-regra-empresa-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Selecionar regra empresa</h2>
<p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Integração e Inovação, por família — usadas aqui como o modo "mensalidade" de uma regra de custeio.</p>
<div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div>
<p class="modal-error" id="ips-regra-empresa-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-regra-empresa-cancel-btn">Fechar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="ips-regracad-modal" hidden>
<div class="modal-card modal-card--wide">
<h2 class="modal-card__title">Cadastro de Regras de Custeio</h2>
<p class="ips-regra-actions__hint">Cadastre e edite aqui as regras de custeio por empresa e operadora — a tela de Nova Importação só aplica uma regra já cadastrada, sem editar nada.</p>
<div class="ips-regracad-toolbar" id="ips-regracad-toolbar">
<div class="ips-regracad-toolbar__row">
<div class="modal-field">
<label for="ips-regracad-empresa-search">Empresa</label>
<div class="ips-combo" id="ips-regracad-empresa-combo">
<input type="text" id="ips-regracad-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" />
<input type="hidden" id="ips-regracad-empresa-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
<button type="button" class="ips-combo__toggle" id="ips-regracad-empresa-toggle-btn" aria-label="Ver todas as empresas cadastradas" title="Ver todas as empresas cadastradas">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ips-combo__list" id="ips-regracad-empresa-combo-list" hidden></div>
</div>
</div>
<button type="button" class="btn-outline" id="ips-regracad-nova-btn">+ Nova regra</button>
</div>
<div class="ips-regracad-toolbar__row">
<div class="modal-field">
<label for="ips-regracad-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-operadora-combo">
<input type="text" id="ips-regracad-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled />
<input type="hidden" id="ips-regracad-operadora-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button>
<button type="button" class="ips-combo__toggle" id="ips-regracad-operadora-toggle-btn" aria-label="Ver todas as operadoras cadastradas para esta empresa" title="Ver todas as operadoras cadastradas para esta empresa" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ips-combo__list" id="ips-regracad-operadora-combo-list" hidden></div>
</div>
</div>
<button type="button" class="btn-outline" id="ips-regracad-nova-operadora-btn" hidden>+ Nova operadora</button>
</div>
</div>
<p class="ips-empty" id="ips-regracad-vazio">Selecione uma empresa e uma operadora pra visualizar/editar a regra, ou clique em "+ Nova regra".</p>
<p class="ips-regracad-modo" id="ips-regracad-modo" hidden></p>
<div class="ips-regracad-novo" id="ips-regracad-novo-view" hidden>
<div class="ips-regracad-novo__empresa-row">
<div class="modal-field">
<label for="ips-regracad-novo-codigo-empresa">Código da empresa</label>
<input type="text" id="ips-regracad-novo-codigo-empresa" placeholder="Ex.: 92" autocomplete="off" />
</div>
<p class="ips-regracad-novo__empresa-nome" id="ips-regracad-novo-empresa-nome" hidden></p>
</div>
<div class="modal-field">
<label for="ips-regracad-novo-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-novo-operadora-combo">
<input type="text" id="ips-regracad-novo-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora..." autocomplete="off" />
<input type="hidden" id="ips-regracad-novo-operadora-valor" />
<button type="button" class="ips-combo__toggle" id="ips-regracad-novo-operadora-toggle-btn" aria-label="Ver todas as operadoras" title="Ver todas as operadoras">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="ips-combo__list" id="ips-regracad-novo-operadora-combo-list" hidden></div>
</div>
<p class="modal-error" id="ips-regracad-novo-operadora-duplicada" hidden>Já existe uma regra cadastrada para esta empresa com esta operadora — edite a regra existente em vez de criar outra.</p>
</div>
</div>
<div class="ips-regracad-form" id="ips-regracad-form-view" hidden>
<div class="modal-field ips-tipo-field"> <div class="modal-field ips-tipo-field">
<label>Tipo de importação</label> <label>Tipo de importação</label>
<div class="ips-tipo-option"> <div class="ips-tipo-option">
@ -492,7 +645,7 @@
<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" />
Regra empresa Mensalidade usa 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 Integração e Inovação — substitui o custeio manual de titular/dependente acima.</p> <p class="ips-regra-empresa-box__hint">Mensalidade custeada por uma regra especial cadastrada pela Integração e Inovação — substitui o custeio manual de titular/dependente acima.</p>
@ -502,109 +655,30 @@
</div> </div>
</div> </div>
<div class="modal-field ips-regra-salvar-field">
<label>Salvar como regra de custeio</label>
<p class="ips-regra-actions__hint">Depois de parametrizar o custeio acima, salve como uma regra nova (ou atualize a regra aplicada) pra reaproveitar em importações futuras.</p>
<button type="button" class="btn-outline" id="ips-regra-salvar-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" stroke-linejoin="round"/><path d="M17 21v-8H7v8M7 3v5h5" stroke-linejoin="round"/></svg>
Salvar regra atual...
</button>
</div>
<p class="modal-error" id="ips-form-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-form-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="ips-form-submit-btn">Processar</button>
</div>
</div>
</div>
<div id="ips-review-view" hidden>
<div class="ips-header">
<div>
<h2>Revisão</h2>
<p class="ips-subtitle" id="ips-review-subtitle"></p>
</div>
<div class="ips-header-actions">
<button type="button" class="btn-outline" id="ips-review-back-btn">Voltar</button>
<button type="button" class="btn-solid" id="ips-gerar-btn">Gerar Arquivo</button>
</div>
</div>
<div class="ips-regra-empresa-observacoes" id="ips-review-regra-empresa-obs" hidden>
<p class="ips-regra-empresa-observacoes__label" id="ips-review-regra-empresa-obs-label"></p>
<p class="ips-regra-empresa-observacoes__texto" id="ips-review-regra-empresa-obs-texto"></p>
</div>
<p class="modal-error" id="ips-review-error"></p>
<div class="pa-tabs" id="ips-tabs"></div>
<div id="ips-tab-panels"></div>
</div>
</div>
</main>
</div>
</div>
<div class="modal-overlay" id="ips-vincular-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Vincular pessoa</h2>
<p class="ips-vincular-resumo" id="ips-vincular-resumo"></p>
<div class="modal-field"> <div class="modal-field">
<label for="ips-vincular-search">Buscar na planilha padrão</label> <label for="ips-regracad-observacoes">Observações</label>
<input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" /> <textarea id="ips-regracad-observacoes" rows="3" placeholder="Ex.: Empresa não desconta plano do empregado XX."></textarea>
</div> </div>
<div class="checklist-box" id="ips-vincular-lista"></div> </div>
<p class="modal-error" id="ips-vincular-error"></p>
<p class="modal-error" id="ips-regracad-error"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="ips-vincular-cancel-btn">Cancelar</button> <button type="button" class="btn-outline" id="ips-regracad-fechar-btn">Fechar</button>
<button type="button" class="btn-solid" id="ips-vincular-confirm-btn" disabled>Vincular</button> <button type="button" class="btn-outline" id="ips-regracad-novo-cancelar-btn" hidden>Cancelar</button>
<button type="button" class="btn-danger-outline" id="ips-regracad-excluir-btn" hidden>Excluir regra</button>
<button type="button" class="btn-solid" id="ips-regracad-salvar-btn" hidden>Salvar alterações</button>
<button type="button" class="btn-solid" id="ips-regracad-criar-btn" hidden>Criar regra</button>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-overlay" id="ips-regras-modal" hidden> <div class="modal-overlay" id="ips-regracad-confirm-fechar-modal" hidden>
<div class="modal-card"> <div class="modal-card">
<h2 class="modal-card__title">Regras de custeio salvas</h2> <h2 class="modal-card__title">Fechar o cadastro de regras?</h2>
<div class="checklist-box ips-regras-lista" id="ips-regras-lista"></div> <p class="modal-card__subtitle">Alterações não salvas serão perdidas.</p>
<p class="modal-error" id="ips-regras-error"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="ips-regras-fechar-btn">Fechar</button> <button type="button" class="btn-outline" id="ips-regracad-confirm-fechar-cancelar-btn">Cancelar</button>
</div> <button type="button" class="btn-danger-outline" id="ips-regracad-confirm-fechar-ok-btn">Fechar sem salvar</button>
</div>
</div>
<div class="modal-overlay" id="ips-regra-empresa-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Selecionar regra empresa</h2>
<p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Integração e Inovação — sem relação com o banco de regras de custeio salvas acima.</p>
<div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div>
<p class="modal-error" id="ips-regra-empresa-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-regra-empresa-cancel-btn">Fechar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="ips-regra-save-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title" id="ips-regra-save-title">Salvar regra de custeio</h2>
<div class="modal-field">
<label for="ips-regra-save-nome">Nome</label>
<input type="text" id="ips-regra-save-nome" placeholder="Ex.: 092 - Unimed" autocomplete="off" />
</div>
<div class="modal-field">
<label for="ips-regra-save-observacoes">Observações</label>
<textarea id="ips-regra-save-observacoes" rows="3" placeholder="Ex.: Empresa não desconta plano do empregado XX."></textarea>
</div>
<label class="modal-checkbox" id="ips-regra-save-atualizar-field" hidden>
<input type="checkbox" id="ips-regra-save-atualizar" />
<span id="ips-regra-save-atualizar-label">Atualizar a regra selecionada</span>
</label>
<p class="modal-error" id="ips-regra-save-error"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-regra-save-cancel-btn">Cancelar</button>
<button type="button" class="btn-solid" id="ips-regra-save-confirm-btn">Salvar</button>
</div> </div>
</div> </div>
</div> </div>