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