From eb61b4f431e7ba2a38666da6f5d0c80be6418fd5 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 17:04:46 -0300 Subject: [PATCH] =?UTF-8?q?Inclus=C3=A3o=20de=20regra=20de=20desconto=20m?= =?UTF-8?q?=C3=A1ximo=20do=20empregado.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal_api/planos_saude/CHANGELOG.md | 7 +++ portal_api/planos_saude/CLAUDE.md | 9 +++- portal_api/planos_saude/matcher.py | 29 ++++++++++--- portal_api/serializers.py | 57 ++++++++++++++++++++++--- static/css/importacao-plano-saude.css | 15 +++++++ static/js/importacao-plano-saude.js | 61 ++++++++++++++++++++++++++- templates/importacao-plano-saude.html | 28 ++++++++++++ 7 files changed, 191 insertions(+), 15 deletions(-) diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index 0abac5e..cfbd7c8 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -305,3 +305,10 @@ Usuário reportou (empresa Questor 1970, "Rede Brasil de Mídia OOH LTDA", compe Causa: `_ITEM_COPARTICIPACAO_RE` exigia fronteira de palavra (`\b`) dos dois lados do código de "Tipo Serviço" (CON/EXA/HOS/CLI/ODO/MED). Neste arquivo, esse código vem colado sem espaço nenhum ao final do nome do Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO GUSTAVEXA 40316572..." — mesmo estilo de coluna colada já visto no "Beneficiario" desde a rodada 83, só que numa coluna diferente), então a fronteira à esquerda nunca era satisfeita. O mesmo arquivo revelou, de quebra, mais dois valores não previstos: o grau de dependência "OUTROS DEP" (ausente de `_GRAUS_DEPENDENCIA`) e o código de tipo de serviço "CIR" (cirurgia — "Implante de dispositivo"). Corrigido em `operadoras/unimed/saude.py`: `_ITEM_COPARTICIPACAO_RE` perdeu a fronteira de palavra à esquerda (mantida só à direita, pra não casar um código no meio de outra palavra) e ganhou "CIR"; "OUTROS DEP" foi acrescentado a `_GRAUS_DEPENDENCIA` — sem esse segundo ajuste, mesmo com o regex do item corrigido, a coparticipação dessa dependente cairia por engano na pessoa anterior do bloco (mesmo bug do "COMPANHEIRO" truncado, ver acima). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 2 beneficiários (KARLA VANESSA R$247,74 + RAPHAELA SOUZ R$183,28), somando R$431,02 — bate exatamente com "Total da Familia: 431,02" impresso no relatório; reconfirmado, sem regressão, que a mensalidade da mesma competência continua extraindo os mesmos 3 beneficiários de antes. + +### "Regra específica": novo critério "Limite de desconto do empregado" + +Usuário pediu uma terceira modalidade dentro de "Regra específica" (Cadastro de Regras): até então só existiam critérios que protegem o gasto da EMPRESA (`limite_valor`: teto de quanto ela cobre; `percentual`: fração do valor custeada por ela) — faltava a direção oposta, um teto de quanto é **descontado do empregado**, com a empresa absorvendo o restante sem limite algum (exemplo dado: mensalidade de R$150/R$200, desconto sempre limitado a R$10, empresa cobre R$140/R$190). + +- **Backend**: `_monta_regra_custeio()` (`serializers.py`) ganhou um quarto parâmetro (`limite_desconto_empregado_bruto`) e passou a montar `regra["limite_desconto_empregado"]`; `matcher._calcula_valores()` ganhou um branch novo que, quando esse campo vem preenchido, calcula `valor_empregado = min(valor_total, limite_desconto_empregado)` e deriva `valor_empresa` como o complemento — **mutuamente exclusivo** com `limite_valor`/`percentual` (validado explicitamente em `_monta_regra_custeio`, erro claro se os dois grupos vierem preenchidos juntos), porque os dois protegem lados opostos do valor (teto da empresa vs. teto do empregado) e misturá-los não tem uma resolução determinística única quando entram em conflito. `ImportacaoPlanoSaudeCreateSerializer` ganhou os 4 campos `limite_desconto_empregado__` (mesmo padrão de `limite_valor_.../percentual_...` já existentes); `RegraCusteioPlanoSaudeSerializer.validate()` passou o novo campo adiante também. Nenhuma migração — continua dentro do mesmo `JSONField` (`custeio_por_tipo`), só um campo novo dentro do dict de cada combinação tipo×pessoa quando `modo="especifica"`. +- **Frontend** (`importacao-plano-saude.html`/`.js`): terceiro campo "Limite de desconto do empregado" acrescentado às 4 caixas de "Regra específica" (mensalidade/coparticipação × titular/dependente), num agrupamento visual separado (`.ips-regra-especifica__alt`, linha divisória) dos dois campos existentes, com hint próprio explicando a exclusividade. `atualizarExclusividadeRegraEspecifica()` (nova) desabilita ao vivo um grupo de campos assim que o outro é preenchido (não deixa o usuário sequer tentar preencher os dois) — chamada a cada tecla digitada nos três campos e sempre que o formulário é limpo (`limparCusteioForm()`) ou repopulado a partir de uma regra salva (`aplicarCusteio()`). `coletarCusteioAtual()`, `mensagemErroCusteio()`, `resumoModoPessoa()` (resumo só-leitura de "Nova Importação") e `montarFormDataDeRegra()` atualizados pra ler/validar/exibir/enviar o campo novo. diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index 65b2049..0a0e064 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -53,7 +53,14 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme - **Toda resolução de família (titular/dependente) é feita por matrícula, nunca por nome** — a coluna "Usuário" do relatório de mensalidade é estreita e trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana, confirmado inspecionando os limites reais de x0/x1 do PDF: o nome pára exatamente na borda da coluna seguinte), então comparar o nome truncado da mensalidade com o nome completo do extrato de coparticipação para resolver `numero_titular`/`tipo` não seria confiável. Em vez disso, `_pessoa_por_matricula` (matrícula -> nome/tipo/numero_titular) é populado só ao processar a tabela de **mensalidade** (onde a família já vem corretamente resolvida por ordem de bloco: titular sempre antes dos próprios dependentes) e reaproveitado em `finaliza()` pra resolver os dois candidatos de coparticipação — que só carregam matrícula + valor, nada de nome. Um beneficiário com coparticipação mas ausente de toda tabela de mensalidade desta importação (arquivo daquele contrato não anexado) vira um `ItemAuditoria` explícito (`NAO_CADASTRADO`), nunca é descartado silenciosamente. Nomes truncados na mensalidade em si seguem o fluxo normal (`NOME_DIVERGENTE` em auditoria, resolvido manualmente uma vez via "Vincular pessoa" — nunca por aproximação). - Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, 2 contratos — 183237 e 183210/"Estadual"): mensalidade batendo exatamente com os totais impressos (R$ 4.076,41 + R$ 808,98 = R$ 4.885,39, 12 beneficiários) e coparticipação batendo com R$ 1.067,17 (3 beneficiários), confirmando que a tabela embutida (também extraída, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada. -**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, "limite_desconto_empregado": float|None}`). + +Dentro da regra específica, dois grupos de critério, **mutuamente exclusivos** entre si (validado em `_monta_regra_custeio`, `serializers.py`, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — `atualizarExclusividadeRegraEspecifica()`, `importacao-plano-saude.js`): + +- **`limite_valor`/`percentual`** protegem o gasto da **empresa**: `limite_valor` é um teto de quanto ela cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada por ela (o resto vira desconto). Podem vir 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. +- **`limite_desconto_empregado`** protege o gasto do **empregado** (adicionado a pedido do usuário, ex.: mensalidade de R$150/R$200 com desconto sempre limitado a R$10, a empresa absorve o restante — R$140/R$190): teto de quanto é descontado dele, sem limite algum pro que sobra pra empresa. Direção oposta da anterior — combinar os dois grupos não teria uma resolução determinística única quando entrassem em conflito (ex.: um teto de empresa que por si só implicaria um desconto maior que o teto de empregado permitido), por isso o formulário nunca deixa preencher os dois grupos ao mesmo tempo pra uma mesma combinação tipo×pessoa. + +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 o valor "protegido" (empresa ou empregado, conforme o grupo de critério usado) é arredondado primeiro e o outro é derivado como o complemento exato (`valor_total` menos o protegido, 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`) diff --git a/portal_api/planos_saude/matcher.py b/portal_api/planos_saude/matcher.py index b226592..478dcee 100644 --- a/portal_api/planos_saude/matcher.py +++ b/portal_api/planos_saude/matcher.py @@ -76,17 +76,26 @@ def _calcula_valores(valor_total: float, regra: Optional[dict]) -> Tuple[float, """ Divide `valor_total` entre (valor_empresa, valor_empregado) conforme `regra` (`{"modo": "empresa"|"empregado"|"especifica", "limite_valor": - float|None, "percentual": float|None}`): + float|None, "percentual": float|None, "limite_desconto_empregado": + float|None}`): - "empresa" -> tudo em valor_empresa. - "empregado" -> tudo em valor (desconto do empregado) — comportamento padrão de antes desta funcionalidade existir. - - "especifica" -> `limite_valor` é o teto de quanto a empresa cobre - (excedente vira desconto do empregado); `percentual` é a fração do - valor do mês que a empresa cobre (o resto vira desconto). Pode vir só - um dos dois ou os dois — quando os dois vêm juntos, prevalece o mais - restritivo (o menor valor entre os dois critérios), decisão explícita - do usuário ("pode ser aplicado apenas uma destas regras ou as duas"). + - "especifica" -> duas famílias de critério, mutuamente exclusivas + (garantido por `_monta_regra_custeio`/`serializers.py`, nunca as duas + preenchidas ao mesmo tempo): + - `limite_valor`/`percentual` protegem o gasto da EMPRESA: `limite_valor` + é o teto de quanto ela cobre (excedente vira desconto do empregado); + `percentual` é a fração do valor do mês que ela cobre (o resto vira + desconto). Pode vir só um dos dois ou os dois — quando os dois vêm + juntos, prevalece o mais restritivo (o menor valor entre os dois + critérios), decisão explícita do usuário ("pode ser aplicado apenas + uma destas regras ou as duas"). + - `limite_desconto_empregado` protege o gasto do EMPREGADO: teto de + quanto é descontado dele (o restante, sem limite, fica com a + empresa) — direção oposta da anterior, por isso não se combina com + ela. """ regra = regra or REGRA_CUSTEIO_PADRAO modo = regra.get("modo", "empregado") @@ -96,6 +105,12 @@ def _calcula_valores(valor_total: float, regra: Optional[dict]) -> Tuple[float, if modo != "especifica": return 0.0, valor_total + limite_desconto_empregado = regra.get("limite_desconto_empregado") + if limite_desconto_empregado is not None: + valor_empregado = round(max(0.0, min(valor_total, limite_desconto_empregado)), 2) + valor_empresa = round(valor_total - valor_empregado, 2) + return valor_empresa, valor_empregado + candidatos = [valor_total] percentual = regra.get("percentual") if percentual is not None: diff --git a/portal_api/serializers.py b/portal_api/serializers.py index 28d3f6e..01c6e85 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -469,13 +469,28 @@ class FuncaoTelefoniaSerializer(serializers.ModelSerializer): return value -def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percentual_bruto: str) -> dict[str, Any]: +def _monta_regra_custeio( + chave: str, + modo: str | None, + limite_bruto: str, + percentual_bruto: str, + limite_desconto_empregado_bruto: str = "", +) -> dict[str, Any]: """Valida e monta uma única regra de custeio (uma combinação tipo de lançamento × tipo de beneficiário) — reaproveitado por ImportacaoPlanoSaudeCreateSerializer (form da nova importação) e RegraCusteioPlanoSaudeSerializer (banco de regras salvas), já que as duas telas usam exatamente a mesma regra de negócio de custeio (ver "Regra de - custeio" no formulário de nova importação).""" + custeio" no formulário de nova importação). + + `limite_valor`/`percentual` protegem o gasto da EMPRESA (tetos de quanto + ela cobre, o excedente vira desconto do empregado) e são combináveis + entre si (vale o mais restritivo). `limite_desconto_empregado` protege o + gasto do EMPREGADO (teto de quanto é descontado dele, o restante fica + com a empresa) — direção oposta, por isso é mutuamente exclusivo com os + outros dois: misturar um teto do lado da empresa com um teto do lado do + empregado não tem uma resolução determinística única quando os dois + conflitam (ver `_calcula_valores` em matcher.py).""" if not modo: raise serializers.ValidationError( {f"custeio_{chave}": "Informe como esse tipo é custeado para titular e dependente."} @@ -487,12 +502,33 @@ def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percen limite_bruto = (limite_bruto or "").strip() percentual_bruto = (percentual_bruto or "").strip() - if not limite_bruto and not percentual_bruto: + limite_desconto_empregado_bruto = (limite_desconto_empregado_bruto or "").strip() + + if limite_desconto_empregado_bruto and (limite_bruto or percentual_bruto): raise serializers.ValidationError( - {f"custeio_{chave}": "Informe o limite de valor e/ou o percentual de custeio da empresa."} + { + f"custeio_{chave}": ( + "O limite de desconto do empregado não pode ser combinado com o limite de " + "valor/percentual custeado pela empresa." + ) + } + ) + if not limite_bruto and not percentual_bruto and not limite_desconto_empregado_bruto: + raise serializers.ValidationError( + { + f"custeio_{chave}": ( + "Informe o limite de valor e/ou o percentual de custeio da empresa, ou o limite " + "de desconto do empregado." + ) + } ) - regra: dict[str, Any] = {"modo": "especifica", "limite_valor": None, "percentual": None} + regra: dict[str, Any] = { + "modo": "especifica", + "limite_valor": None, + "percentual": None, + "limite_desconto_empregado": None, + } if limite_bruto: limite = parse_valor_br(limite_bruto) if limite < 0: @@ -503,6 +539,11 @@ def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percen if not (0 <= percentual <= 100): raise serializers.ValidationError({f"percentual_{chave}": "Informe um percentual entre 0 e 100."}) regra["percentual"] = percentual + if limite_desconto_empregado_bruto: + limite_desconto = parse_valor_br(limite_desconto_empregado_bruto) + if limite_desconto < 0: + raise serializers.ValidationError({f"limite_desconto_empregado_{chave}": "Informe um valor válido."}) + regra["limite_desconto_empregado"] = limite_desconto return regra @@ -567,12 +608,16 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer): # do formulário, igual aos outros valores monetários do pipeline. limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True) percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True) + limite_desconto_empregado_mensalidade_titular = serializers.CharField(required=False, allow_blank=True) limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True) percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True) + limite_desconto_empregado_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True) limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True) percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True) + limite_desconto_empregado_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True) limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True) percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True) + limite_desconto_empregado_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()] @@ -628,6 +673,7 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer): attrs.get(f"custeio_{chave}"), attrs.get(f"limite_valor_{chave}", ""), attrs.get(f"percentual_{chave}", ""), + attrs.get(f"limite_desconto_empregado_{chave}", ""), ) custeio_por_tipo[tipo] = custeio_por_pessoa @@ -1091,6 +1137,7 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer): entrada.get("modo"), _valor_custeio_para_texto_br(entrada.get("limite_valor")), _valor_custeio_para_texto_br(entrada.get("percentual")), + _valor_custeio_para_texto_br(entrada.get("limite_desconto_empregado")), ) custeio_validado[tipo] = custeio_por_pessoa diff --git a/static/css/importacao-plano-saude.css b/static/css/importacao-plano-saude.css index fc8d042..1fc1e05 100644 --- a/static/css/importacao-plano-saude.css +++ b/static/css/importacao-plano-saude.css @@ -655,6 +655,21 @@ margin-top: var(--space-2); } +/* Agrupa "Limite de desconto do empregado" visualmente separado dos dois + campos do lado da empresa acima (linha divisória) — são mutuamente + exclusivos (ver atualizarExclusividadeRegraEspecifica(), JS), então o + divisor reforça que são dois "modos" alternativos, não um conjunto só. */ +.ips-regra-especifica__alt { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--border-subtle); +} + +.ips-regra-especifica input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + /* Resumo por tipo, acima de cada tabela da revisão */ .ips-resumo { display: flex; diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index d13dfb3..026c33e 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -882,13 +882,38 @@ document.addEventListener("DOMContentLoaded", async () => { const regraBox = document.getElementById(`ips-regra-especifica-${chave}`); const limiteInput = document.getElementById(`ips-limite-${chave}`); const percentualInput = document.getElementById(`ips-percentual-${chave}`); + const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`); if (regraBox) regraBox.hidden = true; if (limiteInput) limiteInput.value = ""; if (percentualInput) percentualInput.value = ""; + if (limiteDescontoInput) limiteDescontoInput.value = ""; + atualizarExclusividadeRegraEspecifica(chave); }); }); } + // "Limite de desconto do empregado" é mutuamente exclusivo com "Limite de + // valor custeado pela empresa"/"% de custeio da empresa" dentro da mesma + // combinação tipo×pessoa (pedido explícito do usuário) — os dois lados + // protegem partes opostas do valor (teto de quanto a empresa paga vs. teto + // de quanto o empregado paga), então misturar os dois não tem um resultado + // determinístico único (ver `_calcula_valores`/`_monta_regra_custeio` no + // backend, que valida a mesma exclusividade). Preencher um lado desabilita + // visualmente o outro, refletindo no formulário a mesma regra que o + // backend aplica — não limpa o valor do lado desabilitado, só impede + // digitar mais nele enquanto o outro lado estiver preenchido. + function atualizarExclusividadeRegraEspecifica(chave) { + const limiteInput = document.getElementById(`ips-limite-${chave}`); + const percentualInput = document.getElementById(`ips-percentual-${chave}`); + const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`); + if (!limiteInput || !percentualInput || !limiteDescontoInput) return; + const descontoPreenchido = limiteDescontoInput.value.trim() !== ""; + const empresaPreenchido = limiteInput.value.trim() !== "" || percentualInput.value.trim() !== ""; + limiteInput.disabled = descontoPreenchido; + percentualInput.disabled = descontoPreenchido; + limiteDescontoInput.disabled = empresaPreenchido; + } + // "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os // inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo // custeio_por_tipo já vem validado/parseado como float pelo backend. @@ -926,6 +951,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (escolhido.value === "especifica") { entrada.limite_valor = document.getElementById(`ips-limite-${chave}`).value.trim(); entrada.percentual = document.getElementById(`ips-percentual-${chave}`).value.trim(); + entrada.limite_desconto_empregado = document.getElementById(`ips-limite-desconto-${chave}`).value.trim(); } custeio_por_tipo[tipo][pessoa] = entrada; }); @@ -954,7 +980,13 @@ document.addEventListener("DOMContentLoaded", async () => { if (escolhido.value === "especifica") { const limite = document.getElementById(`ips-limite-${chave}`).value.trim(); const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim(); - if (!limite && !percentual) return `Informe o limite de valor e/ou o percentual de custeio para ${rotulo}.`; + const limiteDesconto = document.getElementById(`ips-limite-desconto-${chave}`).value.trim(); + if (!limite && !percentual && !limiteDesconto) { + return `Informe o limite de valor, o percentual de custeio ou o limite de desconto do empregado para ${rotulo}.`; + } + if (limiteDesconto && (limite || percentual)) { + return `${rotulo}: o limite de desconto do empregado não pode ser combinado com o limite de valor/percentual da empresa.`; + } } } } @@ -1000,21 +1032,28 @@ document.addEventListener("DOMContentLoaded", async () => { const regraBox = document.getElementById(`ips-regra-especifica-${chave}`); const limiteInput = document.getElementById(`ips-limite-${chave}`); const percentualInput = document.getElementById(`ips-percentual-${chave}`); + const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`); document.querySelectorAll(`input[name="ips-custeio-${chave}"]`).forEach((r) => (r.checked = false)); if (limiteInput) limiteInput.value = ""; if (percentualInput) percentualInput.value = ""; + if (limiteDescontoInput) limiteDescontoInput.value = ""; if (regraBox) regraBox.hidden = true; const entrada = custeioPorTipo[tipo] && custeioPorTipo[tipo][pessoa]; - if (!entrada || !entrada.modo) return; + if (!entrada || !entrada.modo) { + atualizarExclusividadeRegraEspecifica(chave); + return; + } const radio = document.querySelector(`input[name="ips-custeio-${chave}"][value="${entrada.modo}"]`); if (radio) radio.checked = true; if (entrada.modo === "especifica") { if (limiteInput) limiteInput.value = formatarNumeroBr(entrada.limite_valor); if (percentualInput) percentualInput.value = formatarNumeroBr(entrada.percentual); + if (limiteDescontoInput) limiteDescontoInput.value = formatarNumeroBr(entrada.limite_desconto_empregado); if (regraBox) regraBox.hidden = false; } + atualizarExclusividadeRegraEspecifica(chave); }); }); } @@ -1210,6 +1249,9 @@ document.addEventListener("DOMContentLoaded", async () => { if (entrada.percentual !== null && entrada.percentual !== undefined) { partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`); } + if (entrada.limite_desconto_empregado !== null && entrada.limite_desconto_empregado !== undefined) { + partes.push(`desconto do empregado limitado a R$ ${formatarNumeroBr(entrada.limite_desconto_empregado)}`); + } return `regra específica (${partes.join(", ") || "sem detalhe"})`; } return entrada.modo; @@ -1299,6 +1341,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (entrada.modo === "especifica") { formData.append(`limite_valor_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_valor)); formData.append(`percentual_${tipo}_${pessoa}`, formatarNumeroBr(entrada.percentual)); + formData.append(`limite_desconto_empregado_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_desconto_empregado)); } }); }); @@ -2428,6 +2471,20 @@ document.addEventListener("DOMContentLoaded", async () => { }); }); + // Duplo custeio da "Regra específica" (limite/percentual do lado da + // empresa x limite de desconto do lado do empregado) é mutuamente + // exclusivo — ver atualizarExclusividadeRegraEspecifica(). Reavalia a cada + // tecla digitada nos três campos de cada combinação tipo×pessoa. + PID_IPS_TIPOS.forEach((tipo) => { + PID_IPS_PESSOAS.forEach((pessoa) => { + const chave = `${tipo}-${pessoa}`; + [`ips-limite-${chave}`, `ips-percentual-${chave}`, `ips-limite-desconto-${chave}`].forEach((id) => { + const input = document.getElementById(id); + if (input) input.addEventListener("input", () => atualizarExclusividadeRegraEspecifica(chave)); + }); + }); + }); + if (tabsEl) { tabsEl.addEventListener("click", (event) => { const btn = event.target.closest("[data-ips-tab]"); diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index fa7f576..ccb8870 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -600,6 +600,13 @@

Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.

+
+ +

Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).

+
@@ -617,6 +624,13 @@

Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.

+
+ +

Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).

+
@@ -642,6 +656,13 @@

Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.

+
+ +

Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).

+
@@ -659,6 +680,13 @@

Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.

+
+ +

Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).

+