portal_publico/portal_api/indicadores/calculo.py

301 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Recálculo dos percentuais "atingido" e dos valores em R$ de um colaborador
de uma apuração — chamado tanto na criação da apuração (com os valores
automáticos do pipeline) quanto depois de qualquer ajuste manual de uma
`IndicadorApuracaoResposta` (individual ou em lote).
Convenção de escala: todo campo "percentual"/"pct_*" deste app guarda um
número de 0 a 100 (ex.: 90 = 90%), nunca uma fração — dividimos por 100 só no
ponto de uso, na fórmula de valor."""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from portal_api.models import (
IndicadorApuracaoColaborador,
IndicadorCriterio,
IndicadorPercentualTipo,
)
VALORES_QUE_CONTAM_COMO_ATINGIDO = {"SIM"}
VALORES_EXCLUIDOS_DO_CALCULO = {"NAO_SE_APLICA"}
CEM = Decimal("100")
CENTAVO = Decimal("0.01")
def _fracao_atingida(resposta: Any) -> Decimal:
"""Critério automático (tem `percentual_calculado`) que bateu ou passou o
`limiar_percentual` do critério (a meta, ex.: 90%) conta crédito cheio —
bater a meta é bater a meta, não importa se foi por pouco (91,30% de uma
meta de 90%) ou com folga; não faz sentido pesar mais quem passou mais da
meta na média. Abaixo do limiar, conta o percentual real medido, mesmo
que o RH tenha marcado SIM por cima manualmente — forçar "Sim" num
critério que mediu 57% (abaixo da meta) não deve "arredondar" pra 100% na
média, senão o número perde sentido. Pra dar crédito cheio nesse caso
(abaixo da meta, mas o RH quer contar como atingido), o RH ajusta o
percentual agregado (individual/grupo/departamento) direto (ver
IndicadorApuracaoColaboradorViewSet/IndicadorApuracaoViewSet), não o
critério individual. Critério manual (sem percentual_calculado) segue
binário: SIM conta cheio, qualquer outro valor (NÃO/NÃO FAZ) conta como
falha."""
if resposta.percentual_calculado is not None:
if resposta.percentual_calculado >= resposta.criterio.limiar_percentual:
return Decimal("1")
return resposta.percentual_calculado / CEM
return Decimal("1") if resposta.valor in VALORES_QUE_CONTAM_COMO_ATINGIDO else Decimal("0")
def _respostas_consideradas(respostas: list) -> list:
return [resposta for resposta in respostas if resposta.valor not in VALORES_EXCLUIDOS_DO_CALCULO]
def _pct_atingido(respostas: list) -> Decimal:
"""Média ponderada pelo peso do critério (ver `_fracao_atingida` pra como
cada resposta contribui) — NÃO SE APLICA é excluído do denominador. Sem
nenhum critério aplicável, considera 100% (neutro) em vez de zerar o valor
de quem ainda não tem critério configurado pro grupo."""
consideradas = _respostas_consideradas(respostas)
peso_total = sum((resposta.criterio.peso for resposta in consideradas), Decimal("0"))
if peso_total == 0:
return CEM
peso_atingido = sum((resposta.criterio.peso * _fracao_atingida(resposta) for resposta in consideradas), Decimal("0"))
return (peso_atingido / peso_total) * CEM
def _peso_medio_nivel(respostas: list) -> Decimal:
"""Peso "do nível" (individual/grupo/departamento) inteiro, usado só pra
compor o percentual final do Indicador Individual (ver
`recalcula_colaborador`) — média do peso dos critérios considerados
nesse nível nesta competência (mesmo filtro de NÃO SE APLICA de
`_pct_atingido`). É média, não soma: o nível entra na composição final
como se ele mesmo fosse um "critério" só, com esse peso médio, ao lado
dos outros dois níveis — por isso não soma pesos de critérios paralelos
dentro do mesmo nível (ex.: os 3 critérios automáticos de Individual têm
peso 60 cada; o nível "Individual" entra na composição com peso 60, não
180). Sem nenhum critério aplicável, o nível não pesa nada (0) na
composição, em vez de distorcê-la com um peso arbitrário."""
consideradas = _respostas_consideradas(respostas)
if not consideradas:
return Decimal("0")
peso_total = sum((resposta.criterio.peso for resposta in consideradas), Decimal("0"))
return peso_total / len(consideradas)
def _combina_niveis(*niveis: tuple[Decimal, Decimal]) -> Decimal:
"""Combina (percentual, peso_do_nível) de Individual/Grupo/Departamento
num único percentual — média ponderada pelo peso de cada nível. Sem
nenhum nível com peso (caso extremo, nenhum critério cadastrado em lugar
nenhum), considera 100% (neutro), mesmo critério de `_pct_atingido`."""
peso_total = sum((peso for _, peso in niveis), Decimal("0"))
if peso_total == 0:
return CEM
peso_atingido = sum((pct * peso for pct, peso in niveis), Decimal("0"))
return peso_atingido / peso_total
def percentual_vigente(tipo: str, competencia, departamento_id: int | None) -> IndicadorPercentualTipo | None:
"""Sem `departamento_id` (colaborador cujo gerente não está mapeado a
nenhum departamento — ver `portal_api.indicadores.departamentos`), não há
o que buscar: a query com `departamento_id=None` sempre devolve vazio, já
que `IndicadorPercentualTipo.departamento` é obrigatório."""
return (
IndicadorPercentualTipo.objects.filter(
tipo=tipo, departamento_id=departamento_id, vigente_desde__lte=competencia
)
.order_by("-vigente_desde")
.first()
)
def respostas_aplicaveis(
colaborador: IndicadorApuracaoColaborador,
grupo: str,
respostas: list | None = None,
tipos_do_colaborador: set[str] | None = None,
) -> list:
"""Respostas do colaborador que contam pro nível `grupo` (individual/
grupo/departamento) nesta apuração — período compatível com a competência
e, só pra Individual, papel aplicável a algum dos tipos do colaborador.
Extraída de `recalcula_colaborador` pra ser reaproveitada também por
`composicao_individual` (exibição), sem duplicar o filtro. `respostas`/
`tipos_do_colaborador` podem ser passados prontos (quem chama em loop,
uma vez por nível, evita reconsultar `colaborador.empresas.all()` a cada
chamada)."""
if respostas is None:
respostas = list(colaborador.respostas.select_related("criterio"))
if tipos_do_colaborador is None:
tipos_do_colaborador = {empresa.tipo for empresa in colaborador.empresas.all()}
periodo_atual = colaborador.apuracao.periodo_criterio()
aplicaveis = []
for resposta in respostas:
criterio = resposta.criterio
if criterio.grupo != grupo:
continue
if criterio.periodo not in (IndicadorCriterio.PERIODO_TODOS, periodo_atual):
continue
if grupo == IndicadorCriterio.GRUPO_INDIVIDUAL and criterio.papel_aplicavel:
if criterio.papel_aplicavel not in tipos_do_colaborador:
continue
aplicaveis.append(resposta)
return aplicaveis
def composicao_individual(colaborador: IndicadorApuracaoColaborador) -> dict[str, Any]:
"""Detalhamento de como `pct_individual` (o "Total Indicador" exibido na
tela) foi composto a partir dos 3 níveis — só pra exibição (card de
revisão e recibo em PDF), reconstruído com a mesma lógica de
`recalcula_colaborador`, nunca usado pra recalcular nada. O percentual de
Individual aqui é o valor **bruto**, antes da composição (diferente de
`colaborador.pct_individual`, que já é o "Total"); Grupo/Departamento já
são os valores efetivos armazenados (automáticos ou ajustados
manualmente), os mesmos usados de fato na composição. Se
`pct_individual_ajustado_manualmente` for `True`, o "Total" não é a média
ponderada dos 3 níveis abaixo — foi sobrescrito manualmente pelo RH/pela
Direção (ver `ajustado_manualmente` no retorno). `total_calculado` é
sempre essa média ponderada, mesmo quando `total` foi sobrescrito —
é o que o recibo em PDF mostra no banner principal (o percentual que o
colaborador de fato atingiu), com o valor ajustado aparecendo à parte,
já rotulado como decisão da Direção.
Usa `.all()` (não `.select_related("criterio")`) de propósito — chamada
uma vez por colaborador ao serializar uma apuração inteira, precisa
reaproveitar o `prefetch_related("colaboradores__respostas__criterio")`
de `IndicadorApuracaoViewSet.get_queryset` (que já resolve `criterio` sem
query extra); `.select_related()` criaria uma queryset nova e ignoraria
esse cache, voltando a consultar o banco uma vez por colaborador."""
respostas = list(colaborador.respostas.all())
tipos_do_colaborador = {empresa.tipo for empresa in colaborador.empresas.all()}
respostas_individual = respostas_aplicaveis(
colaborador, IndicadorCriterio.GRUPO_INDIVIDUAL, respostas, tipos_do_colaborador
)
respostas_grupo = respostas_aplicaveis(colaborador, IndicadorCriterio.GRUPO_GRUPO, respostas, tipos_do_colaborador)
respostas_departamento = respostas_aplicaveis(
colaborador, IndicadorCriterio.GRUPO_DEPARTAMENTO, respostas, tipos_do_colaborador
)
percentual_individual_bruto = _pct_atingido(respostas_individual)
peso_individual = _peso_medio_nivel(respostas_individual)
peso_grupo = _peso_medio_nivel(respostas_grupo)
peso_departamento = _peso_medio_nivel(respostas_departamento)
return {
"individual": {"percentual": percentual_individual_bruto, "peso": peso_individual},
"grupo": {"percentual": colaborador.pct_grupo, "peso": peso_grupo},
"departamento": {"percentual": colaborador.pct_departamento, "peso": peso_departamento},
"total": colaborador.pct_individual,
# Igual a `total`, exceto quando `pct_individual_ajustado_manualmente`
# é True — nesse caso `total` é o valor que a Direção decidiu pagar,
# e `total_calculado` é o percentual que o colaborador de fato mediu
# (a mesma composição que `recalcula_colaborador` teria gravado se
# não houvesse ajuste manual). Usado no recibo em PDF pra mostrar os
# dois números lado a lado (ver CLAUDE.md).
"total_calculado": _combina_niveis(
(percentual_individual_bruto, peso_individual),
(colaborador.pct_grupo, peso_grupo),
(colaborador.pct_departamento, peso_departamento),
),
"ajustado_manualmente": colaborador.pct_individual_ajustado_manualmente,
}
def recalcula_colaborador(colaborador: IndicadorApuracaoColaborador) -> None:
apuracao = colaborador.apuracao
respostas = list(colaborador.respostas.select_related("criterio"))
tipos_do_colaborador = {empresa.tipo for empresa in colaborador.empresas.all()}
respostas_individual = respostas_aplicaveis(
colaborador, IndicadorCriterio.GRUPO_INDIVIDUAL, respostas, tipos_do_colaborador
)
respostas_grupo = respostas_aplicaveis(colaborador, IndicadorCriterio.GRUPO_GRUPO, respostas, tipos_do_colaborador)
respostas_departamento = respostas_aplicaveis(
colaborador, IndicadorCriterio.GRUPO_DEPARTAMENTO, respostas, tipos_do_colaborador
)
# Grupo/Departamento continuam sendo só a média dos próprios critérios
# (cada um só ajustado manualmente em bloco — ver IndicadorApuracaoViewSet
# .ajustar_grupo/ajustar_departamento, não aqui). Precisam ser calculados
# ANTES de Individual, que os usa na composição final abaixo.
if not colaborador.pct_grupo_ajustado_manualmente:
colaborador.pct_grupo = _pct_atingido(respostas_grupo)
if not colaborador.pct_departamento_ajustado_manualmente:
colaborador.pct_departamento = _pct_atingido(respostas_departamento)
# Individual é o percentual final do colaborador — não é só a média dos
# critérios de nível Individual, é a composição dos 3 níveis (Individual/
# Grupo/Departamento), cada um pesando conforme o peso dos seus próprios
# critérios (ver _peso_medio_nivel/_combina_niveis). Ex.: Individual com
# critérios peso 60 atingindo 57,14%, Grupo peso 10 atingindo 100% e
# Departamento peso 30 atingindo 100% resulta em (57,14×60 + 100×10 +
# 100×30) / (60+10+30) = 74,28% — decisão explícita do usuário, ver
# CLAUDE.md. Usa colaborador.pct_grupo/pct_departamento já calculados
# acima (efetivos, sejam eles automáticos ou ajustados manualmente).
if not colaborador.pct_individual_ajustado_manualmente:
colaborador.pct_individual = _combina_niveis(
(_pct_atingido(respostas_individual), _peso_medio_nivel(respostas_individual)),
(colaborador.pct_grupo, _peso_medio_nivel(respostas_grupo)),
(colaborador.pct_departamento, _peso_medio_nivel(respostas_departamento)),
)
percentuais_por_tipo = {
tipo: percentual_vigente(tipo, apuracao.competencia, colaborador.departamento_id)
for tipo in tipos_do_colaborador
}
valor_total_colaborador = Decimal("0")
for empresa in colaborador.empresas.all():
percentuais = percentuais_por_tipo.get(empresa.tipo)
honorario_ajustado = (empresa.honorario * (colaborador.pct_individual / CEM)).quantize(
CENTAVO, rounding=ROUND_HALF_UP
)
empresa.honorario_ajustado = honorario_ajustado
if percentuais is None:
empresa.valor_individual = Decimal("0")
empresa.valor_grupo = Decimal("0")
empresa.valor_departamento = Decimal("0")
else:
valor_individual = honorario_ajustado * (percentuais.percentual_individual / CEM)
valor_grupo = valor_individual * (percentuais.percentual_grupo / CEM) * (colaborador.pct_grupo / CEM)
valor_departamento = (
valor_individual
* (percentuais.percentual_departamento / CEM)
* (colaborador.pct_departamento / CEM)
)
empresa.valor_individual = valor_individual.quantize(CENTAVO, rounding=ROUND_HALF_UP)
empresa.valor_grupo = valor_grupo.quantize(CENTAVO, rounding=ROUND_HALF_UP)
empresa.valor_departamento = valor_departamento.quantize(CENTAVO, rounding=ROUND_HALF_UP)
empresa.valor_total = empresa.valor_individual + empresa.valor_grupo + empresa.valor_departamento
empresa.save(
update_fields=["honorario_ajustado", "valor_individual", "valor_grupo", "valor_departamento", "valor_total"]
)
valor_total_colaborador += empresa.valor_total
colaborador.valor_total = valor_total_colaborador
colaborador.save(update_fields=["pct_individual", "pct_grupo", "pct_departamento", "valor_total"])
def limpa_ajuste_individual(colaborador: IndicadorApuracaoColaborador) -> None:
"""Reverte só o percentual Individual pro modo automático (usado pela
action `recalcular` de IndicadorApuracaoColaboradorViewSet) — não
recalcula por si só, quem chama ainda precisa rodar `recalcula_colaborador`
depois pra repor o valor calculado."""
colaborador.pct_individual_ajustado_manualmente = False
colaborador.save(update_fields=["pct_individual_ajustado_manualmente"])
def limpa_ajuste_grupo(colaborador: IndicadorApuracaoColaborador) -> None:
"""Reverte só o percentual Grupo pro modo automático — chamado uma vez por
colaborador do mesmo gerente pela action `recalcular_grupo` de
IndicadorApuracaoViewSet (Grupo é editado/revertido em bloco por gerente,
não colaborador a colaborador — ver CLAUDE.md)."""
colaborador.pct_grupo_ajustado_manualmente = False
colaborador.save(update_fields=["pct_grupo_ajustado_manualmente"])
def limpa_ajuste_departamento(colaborador: IndicadorApuracaoColaborador) -> None:
"""Reverte só o percentual Departamento pro modo automático — chamado uma
vez por colaborador da apuração pela action `recalcular_departamento` de
IndicadorApuracaoViewSet (Departamento é editado/revertido em bloco pra
toda a apuração, não colaborador a colaborador — ver CLAUDE.md)."""
colaborador.pct_departamento_ajustado_manualmente = False
colaborador.save(update_fields=["pct_departamento_ajustado_manualmente"])