Inclusão de regra de desconto máximo do empregado.

This commit is contained in:
Gabriel 2026-08-27 17:04:46 -03:00
parent 8942907947
commit eb61b4f431
7 changed files with 191 additions and 15 deletions

View File

@ -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_<tipo>_<pessoa>` (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.

View File

@ -53,7 +53,14 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.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`)

View File

@ -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:

View File

@ -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

View File

@ -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;

View File

@ -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]");

View File

@ -600,6 +600,13 @@
<input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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).</p>
</div>
</div>
</div>
<div class="ips-custeio-pessoa">
@ -617,6 +624,13 @@
<input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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).</p>
</div>
</div>
</div>
</div>
@ -642,6 +656,13 @@
<input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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).</p>
</div>
</div>
</div>
<div class="ips-custeio-pessoa">
@ -659,6 +680,13 @@
<input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">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).</p>
</div>
</div>
</div>
</div>