301 lines
16 KiB
Python
301 lines
16 KiB
Python
"""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"])
|