portal_publico/portal_api/views.py

1868 lines
93 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.

import csv
import io
import os
import re
import tempfile
import zipfile
from typing import Any, Iterable
import holidays
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.db.models import Max, Q, QuerySet
from django.contrib.auth import authenticate, login, logout
from django.http import HttpResponse
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import SAFE_METHODS, AllowAny, BasePermission, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from . import catalogo
from .custo_contratacao.calculo import calcula_custo_empregado
from .custo_contratacao.pdf import gera_pdf_simulacao
from .models import (
AcessoGeral,
AcessoGeralSecao,
CategoriaEvento,
CompromissoAgenda,
Departamento,
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
IndicadorApuracaoResposta,
IndicadorCriterio,
IndicadorDepartamento,
IndicadorDepartamentoGerente,
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
TelefoneExterno,
Usuario,
WidgetUsuario,
)
from .indicadores import calculo as indicadores_calculo
from .indicadores import departamentos as indicadores_departamentos
from .indicadores import pipeline as indicadores_pipeline
from .indicadores import recibo as indicadores_recibo
from .permissions import PermissaoApp, PodeGerenciarPermissoes
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .planos_saude import matcher as planos_saude_matcher
from .planos_saude import pipeline as planos_saude_pipeline
from .planos_saude import regras_empresa as planos_saude_regras_empresa
from .planos_saude.leiaute_sistema import CABECALHO as PLANO_SAUDE_CABECALHO
from .planos_saude.leiaute_sistema import formata_valor_br, le_planilha_padrao, parse_valor_br
from .planos_saude.questor_planilha import busca_linhas_questor, linhas_para_csv_bytes
from .planos_saude.regras_empresa import RegraEmpresaIncompativelError
from .serializers import (
AcessoGeralSecaoSerializer,
AcessoGeralSerializer,
CategoriaEventoSerializer,
CompromissoAgendaSerializer,
DepartamentoSerializer,
FavoritoSerializer,
FuncaoTelefoniaSerializer,
ImportacaoPlanoSaudeAlteracaoSerializer,
ImportacaoPlanoSaudeAuditoriaSerializer,
ImportacaoPlanoSaudeCreateSerializer,
ImportacaoPlanoSaudeDetailSerializer,
ImportacaoPlanoSaudeLinhaCreateSerializer,
ImportacaoPlanoSaudeLinhaSerializer,
ImportacaoPlanoSaudeListSerializer,
IndicadorApuracaoAjusteDepartamentoSerializer,
IndicadorApuracaoAjusteGrupoSerializer,
IndicadorApuracaoAjusteHonorarioEmpresaSerializer,
IndicadorApuracaoColaboradorAjusteSerializer,
IndicadorApuracaoColaboradorSerializer,
IndicadorApuracaoColaboradorValidadoSerializer,
IndicadorApuracaoCreateSerializer,
IndicadorApuracaoDetailSerializer,
IndicadorApuracaoEmpresaAjusteSerializer,
IndicadorApuracaoEmpresaSerializer,
IndicadorApuracaoEmpresaTrocarResponsavelSerializer,
IndicadorApuracaoListSerializer,
IndicadorApuracaoRecalcularDepartamentoSerializer,
IndicadorApuracaoRecalcularGrupoSerializer,
IndicadorApuracaoRespostaLoteSerializer,
IndicadorApuracaoRespostaSerializer,
IndicadorCriterioSerializer,
IndicadorDepartamentoGerenteSerializer,
IndicadorDepartamentoSerializer,
IndicadorPercentualTipoSerializer,
LinkFerramentaFavoritoSerializer,
LinkFerramentaSerializer,
NotificacaoDispensadaSerializer,
ParametroFiscalCustoContratacaoSerializer,
PerfilAcessoSerializer,
PerfilResumoSerializer,
RamalAusenciaSerializer,
RamalSerializer,
RegraCusteioPlanoSaudeSerializer,
SimulacaoCustoContratacaoEntradaSerializer,
TelefoneExternoSerializer,
UsuarioListSerializer,
UsuarioResumoSerializer,
UsuarioSerializer,
WidgetUsuarioSerializer,
)
@ensure_csrf_cookie
@api_view(["GET"])
@permission_classes([AllowAny])
def csrf_view(request: Request) -> Response:
"""Garante que o cookie csrftoken exista antes do primeiro POST (login)."""
return Response({"csrfToken": get_token(request)})
@api_view(["POST"])
@permission_classes([AllowAny])
def login_view(request: Request) -> Response:
username = (request.data.get("username") or "").strip()
senha = request.data.get("password") or ""
usuario = authenticate(request, username=username, password=senha)
if usuario is not None:
login(request, usuario)
return Response({"id": usuario.id, "username": usuario.username, "nome": usuario.nome})
# authenticate() já recusa usuário inativo mesmo com senha certa (ModelBackend.user_can_authenticate),
# então só dá pra saber que é esse o motivo checando a senha manualmente aqui, sem revelar o motivo
# de outras falhas (usuário inexistente ou senha errada continuam com a mensagem genérica).
inativo = Usuario.objects.filter(username=username, is_active=False).first()
if inativo and inativo.check_password(senha):
return Response(
{"detail": "Este usuário está inativo. Contate a Integração e Inovação."},
status=status.HTTP_403_FORBIDDEN,
)
return Response({"detail": "Login ou senha inválidos."}, status=status.HTTP_401_UNAUTHORIZED)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout_view(request: Request) -> Response:
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
def permissoes_efetivas(perfis: Iterable[PerfilAcesso]) -> dict[str, Any]:
"""União das permissões de todos os perfis vinculados a um usuário —
mesma lógica que antes vivia em access.js (pidApplyAccessVisibility),
agora calculada uma única vez no servidor."""
efetivas = {}
for modulo in catalogo.MODULES:
key = modulo["key"]
efetivas[key] = {
"enabled": any(p.permissoes.get(key, {}).get("enabled") for p in perfis),
"apps": {
app_key: any(p.permissoes.get(key, {}).get("apps", {}).get(app_key) for p in perfis)
for app_key in catalogo.app_keys_for(key)
},
}
return efetivas
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def me_view(request: Request) -> Response:
usuario = request.user
perfis = list(usuario.perfis.all())
return Response(
{
"id": usuario.id,
"username": usuario.username,
"nome": usuario.nome,
"perfis": PerfilResumoSerializer(perfis, many=True).data,
"departamentos": DepartamentoSerializer(usuario.departamentos.all(), many=True).data,
"gerencia_permissoes": usuario.gerencia_permissoes(),
"permissoes_efetivas": permissoes_efetivas(perfis),
"lideranca": usuario.lideranca,
"liderados": UsuarioResumoSerializer(usuario.liderados.all(), many=True).data,
}
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def usuarios_resumo_view(request: Request) -> Response:
"""Lista enxuta (id/nome) de usuários ativos, pra alimentar o seletor de
liderados em "Gerenciar Usuário" — não usa PodeGerenciarPermissoes de
propósito, mesmo padrão de RamalViewSet.usuarios_disponiveis."""
usuarios = Usuario.objects.filter(is_active=True).order_by("nome")
return Response(UsuarioResumoSerializer(usuarios, many=True).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def departamentos_resumo_view(request: Request) -> Response:
"""Lista enxuta (id/nome) de departamentos, pra alimentar os botões de filtro
por departamento do modal de consulta rápida de Ramais — não usa
PodeGerenciarPermissoes (como DepartamentoViewSet), só a mesma permissão de
visualização do próprio modal (`ramais-visualizar`)."""
if not request.user.permissao_app("ramais", "ramais-visualizar"):
raise PermissionDenied("Você não tem acesso à consulta de Ramais.")
departamentos = Departamento.objects.all().order_by("nome")
return Response(DepartamentoSerializer(departamentos, many=True).data)
@api_view(["PATCH"])
@permission_classes([IsAuthenticated])
def meus_liderados_view(request: Request) -> Response:
"""Permite que o próprio usuário marcado como `lideranca` gerencie quem está
sob sua liderança, sem depender da tela administrativa de Usuários — grava
na mesma relação `Usuario.liderados` usada lá."""
usuario = request.user
if not usuario.lideranca:
raise PermissionDenied("Seu usuário não está marcado como gerente ou coordenador.")
liderados_ids = request.data.get("liderados") or []
liderados = Usuario.objects.filter(id__in=liderados_ids).exclude(id=usuario.id)
usuario.liderados.set(liderados)
return Response(UsuarioResumoSerializer(usuario.liderados.all(), many=True).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def trocar_senha_view(request: Request) -> Response:
usuario = request.user
senha_atual = request.data.get("senha_atual") or ""
nova_senha = request.data.get("nova_senha") or ""
if not usuario.check_password(senha_atual):
return Response({"detail": "Senha atual incorreta."}, status=status.HTTP_400_BAD_REQUEST)
if not nova_senha:
return Response({"detail": "Informe a nova senha."}, status=status.HTTP_400_BAD_REQUEST)
usuario.set_password(nova_senha)
usuario.save()
return Response({"detail": "Senha alterada com sucesso."})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def catalogo_view(request: Request) -> Response:
return Response(catalogo.catalogo_payload())
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def feriados_view(request: Request) -> Response:
"""Feriados nacionais + estaduais do Paraná (sede em Foz do Iguaçu) pro ano
pedido, via lib `holidays` — só a categoria "public" (feriados de verdade,
não os pontos facultativos tipo Carnaval/Corpus Christi). Não há feriado
municipal aqui de propósito: nenhuma lib cobre isso por município e o
usuário decidiu deixar de fora por enquanto (ver CLAUDE.md)."""
try:
ano = int(request.query_params.get("ano", timezone.localdate().year))
except ValueError:
raise ValidationError({"ano": "Informe um ano válido."})
# `language` explícito — sem isso, a lib pode cair pro locale do processo do
# servidor (que nem sempre é pt_BR) em vez do `default_language` da classe.
feriados_br = holidays.Brazil(years=ano, subdiv="PR", language="pt_BR")
return Response([{"data": str(data), "nome": nome} for data, nome in sorted(feriados_br.items())])
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def simulacao_custo_contratacao_view(request: Request) -> HttpResponse:
"""Ferramenta "Simulação de Custo de Contratação" (Geradoc) — cálculo
pontual, sem persistência: recebe os dados do formulário, calcula e
devolve o PDF direto na resposta (ver CLAUDE.md). Só cobre o regime
Empregado CLT por ora."""
if not request.user.permissao_app("geradoc", "simulacao-custo-contratacao"):
raise PermissionDenied("Você não tem acesso à Simulação de Custo de Contratação.")
entrada_serializer = SimulacaoCustoContratacaoEntradaSerializer(data=request.data)
entrada_serializer.is_valid(raise_exception=True)
parametros = ParametroFiscalCustoContratacao.atual().para_calculo()
resultado = calcula_custo_empregado(entrada_serializer.to_entrada(), parametros)
pdf_bytes = gera_pdf_simulacao(resultado, timezone.localtime().strftime("%d/%m/%Y %H:%M"))
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = 'inline; filename="simulacao-custo-contratacao.pdf"'
return response
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def parametros_fiscais_custo_contratacao_view(request: Request) -> Response:
"""Tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela
Simulação de Custo de Contratação — mesma permissão de quem usa a
simulação (sem par visualizar/editar dedicado). Editar aqui vale pra
todas as simulações seguintes; não há histórico de versões anteriores."""
if not request.user.permissao_app("geradoc", "simulacao-custo-contratacao"):
raise PermissionDenied("Você não tem acesso à Simulação de Custo de Contratação.")
parametros = ParametroFiscalCustoContratacao.atual()
if request.method == "GET":
return Response(ParametroFiscalCustoContratacaoSerializer(parametros).data)
serializer = ParametroFiscalCustoContratacaoSerializer(parametros, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class PerfilAcessoViewSet(viewsets.ModelViewSet):
queryset = PerfilAcesso.objects.all().order_by("codigo")
serializer_class = PerfilAcessoSerializer
permission_classes = [PodeGerenciarPermissoes]
class DepartamentoViewSet(viewsets.ModelViewSet):
queryset = Departamento.objects.all().order_by("nome")
serializer_class = DepartamentoSerializer
permission_classes = [PodeGerenciarPermissoes]
def perform_destroy(self, instance: Departamento) -> None:
vinculados = list(instance.usuarios.all())
if vinculados:
nomes = ", ".join(u.nome or u.username for u in vinculados)
raise ValidationError(f"Não é possível excluir: departamento vinculado a {nomes}.")
instance.delete()
class UsuarioViewSet(viewsets.ModelViewSet):
queryset = Usuario.objects.all().order_by("nome")
permission_classes = [PodeGerenciarPermissoes]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("list", "retrieve"):
return UsuarioListSerializer
return UsuarioSerializer
class CategoriaEventoViewSet(viewsets.ModelViewSet):
queryset = CategoriaEvento.objects.all()
serializer_class = CategoriaEventoSerializer
def get_permissions(self) -> list[BasePermission]:
if self.request.method in SAFE_METHODS:
return [IsAuthenticated()]
return [PermissaoApp("calendario-individual", "calendario-individual-criar-evento")]
class CompromissoAgendaViewSet(viewsets.ModelViewSet):
serializer_class = CompromissoAgendaSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self) -> QuerySet[CompromissoAgenda]:
usuario = self.request.user
departamentos_ids = list(usuario.departamentos.values_list("id", flat=True))
return (
CompromissoAgenda.objects.filter(
Q(dono=usuario)
| Q(visibilidade=CompromissoAgenda.VISIBILIDADE_TODOS)
| Q(
visibilidade=CompromissoAgenda.VISIBILIDADE_DEPARTAMENTO,
departamento_compartilhado_id__in=departamentos_ids,
)
# Gerente/coordenador (`lideranca`) também vê os compromissos "somente eu"
# de quem está sob sua liderança — `sou_dono` no serializer continua
# False pra esses, então o frontend sabe que não pode editar/excluir.
| Q(visibilidade=CompromissoAgenda.VISIBILIDADE_SOMENTE_EU, dono__in=usuario.liderados.all())
)
.distinct()
.order_by("data", "horario")
)
def perform_create(self, serializer: CompromissoAgendaSerializer) -> None:
serializer.save(dono=self.request.user)
def get_object(self) -> CompromissoAgenda:
obj = super().get_object()
metodo_de_escrita = self.request.method not in ("GET", "HEAD", "OPTIONS")
if metodo_de_escrita and obj.dono_id != self.request.user.id:
raise PermissionDenied("Só quem criou o compromisso pode editá-lo ou excluí-lo.")
return obj
class FavoritoViewSet(viewsets.ModelViewSet):
serializer_class = FavoritoSerializer
permission_classes = [IsAuthenticated]
lookup_field = "app_id"
lookup_value_regex = "[^/]+"
def get_queryset(self) -> QuerySet[Favorito]:
return Favorito.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: FavoritoSerializer) -> None:
app_id = serializer.validated_data.get("app_id")
maior_ordem = Favorito.objects.filter(usuario=self.request.user).aggregate(Max("ordem"))["ordem__max"]
favorito, _criado = Favorito.objects.get_or_create(
usuario=self.request.user,
app_id=app_id,
defaults={"ordem": 0 if maior_ordem is None else maior_ordem + 1},
)
serializer.instance = favorito
class NotificacaoDispensadaViewSet(viewsets.ModelViewSet):
serializer_class = NotificacaoDispensadaSerializer
permission_classes = [IsAuthenticated]
lookup_field = "notif_id"
lookup_value_regex = "[^/]+"
def get_queryset(self) -> QuerySet[NotificacaoDispensada]:
return NotificacaoDispensada.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: NotificacaoDispensadaSerializer) -> None:
notif_id = serializer.validated_data.get("notif_id")
dispensada, _criada = NotificacaoDispensada.objects.get_or_create(
usuario=self.request.user, notif_id=notif_id
)
serializer.instance = dispensada
class LinkFerramentaViewSet(viewsets.ModelViewSet):
queryset = LinkFerramenta.objects.all().order_by("ordem", "id")
serializer_class = LinkFerramentaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "links-ferramentas-visualizar" if self.request.method in SAFE_METHODS else "links-ferramentas-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def perform_create(self, serializer: LinkFerramentaSerializer) -> None:
maior_ordem = LinkFerramenta.objects.aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class LinkFerramentaFavoritoViewSet(viewsets.ModelViewSet):
"""Favorito pessoal de um cartão — só influencia a ordem de exibição em
Links & Ferramentas (e o widget de favoritos), nunca o `ordem` compartilhado."""
serializer_class = LinkFerramentaFavoritoSerializer
lookup_field = "link_id"
lookup_value_regex = "[0-9]+"
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")]
def get_queryset(self) -> QuerySet[LinkFerramentaFavorito]:
return LinkFerramentaFavorito.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: LinkFerramentaFavoritoSerializer) -> None:
link = serializer.validated_data.get("link")
favorito, _criado = LinkFerramentaFavorito.objects.get_or_create(usuario=self.request.user, link=link)
serializer.instance = favorito
class AcessoGeralSecaoViewSet(viewsets.ModelViewSet):
"""Seções do cadastro "Acessos Gerais" — mesmo padrão visualizar/editar de
LinkFerramentaViewSet, com as chaves dedicadas dessa aplicação. `get_queryset`
também filtra por `perfis_restritos`: uma seção com perfis marcados só aparece
pra quem tem pelo menos um deles vinculado, além da permissão de módulo."""
serializer_class = AcessoGeralSecaoSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "acessos-gerais-visualizar" if self.request.method in SAFE_METHODS else "acessos-gerais-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def get_queryset(self) -> QuerySet[AcessoGeralSecao]:
perfis_ids = list(self.request.user.perfis.values_list("codigo", flat=True))
return (
AcessoGeralSecao.objects.filter(Q(perfis_restritos__isnull=True) | Q(perfis_restritos__codigo__in=perfis_ids))
.distinct()
.order_by("ordem", "id")
)
def perform_create(self, serializer: AcessoGeralSecaoSerializer) -> None:
maior_ordem = AcessoGeralSecao.objects.aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class AcessoGeralViewSet(viewsets.ModelViewSet):
"""Linhas dentro de uma seção de "Acessos Gerais" — `ordem` é escopada por
seção (reordenar só compara linhas da mesma `secao`, igual a `perform_create`
abaixo calcula o próximo valor). `get_queryset` aplica o mesmo filtro de
`perfis_restritos` da seção-mãe, pra uma linha nunca vazar de uma seção que o
usuário não teria como ver."""
serializer_class = AcessoGeralSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "acessos-gerais-visualizar" if self.request.method in SAFE_METHODS else "acessos-gerais-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def get_queryset(self) -> QuerySet[AcessoGeral]:
perfis_ids = list(self.request.user.perfis.values_list("codigo", flat=True))
return (
AcessoGeral.objects.filter(
Q(secao__perfis_restritos__isnull=True) | Q(secao__perfis_restritos__codigo__in=perfis_ids)
)
.distinct()
.order_by("secao__ordem", "secao_id", "ordem", "id")
)
def perform_create(self, serializer: AcessoGeralSerializer) -> None:
secao = serializer.validated_data.get("secao")
maior_ordem = AcessoGeral.objects.filter(secao=secao).aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class RamalViewSet(viewsets.ModelViewSet):
"""`list()` mescla duas fontes num diretório só: todo `Usuario` ativo (linha
montada direto do cadastro, ramal fica "pendente" até ser preenchido) e as linhas
avulsas de `Ramal` (sem conta de sistema por trás). `create`/`retrieve`/`update`/
`destroy` seguem sendo CRUD normal do DRF, mas só valem pras linhas avulsas —
editar o ramal de um usuário de verdade é a action `atualizar_ramal_usuario`
abaixo, que grava direto em `Usuario.ramal`."""
queryset = Ramal.objects.all().order_by("nome")
serializer_class = RamalSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "ramais-visualizar" if self.request.method in SAFE_METHODS else "ramais-editar"
return [PermissaoApp("ramais", app_key)]
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
agora = timezone.localtime()
hoje = agora.date()
linhas = []
usuarios = Usuario.objects.filter(is_active=True).prefetch_related("departamentos", "ausencias_ramal")
for usuario in usuarios:
ausencia_ativa = next((a for a in usuario.ausencias_ramal.all() if a.esta_ativa(agora)), None)
nascimento = usuario.data_aniversario
linhas.append(
{
"id": f"usuario-{usuario.id}",
"tipo": "usuario",
"usuario_id": usuario.id,
"avulso_id": None,
"nome_exibicao": usuario.nome,
"departamento_exibicao": ", ".join(d.nome for d in usuario.departamentos.all()),
"numero_exibicao": usuario.ramal,
"usuario_ausente": ausencia_ativa is not None,
"usuario_ausencia_ativa_id": ausencia_ativa.id if ausencia_ativa else None,
"usuario_aniversariante": bool(nascimento) and (nascimento.month, nascimento.day) == (hoje.month, hoje.day),
}
)
for ramal in Ramal.objects.all():
linhas.append(
{
"id": f"avulso-{ramal.id}",
"tipo": "avulso",
"usuario_id": None,
"avulso_id": ramal.id,
"nome_exibicao": ramal.nome,
"departamento_exibicao": ramal.departamento,
"numero_exibicao": ramal.numero,
"usuario_ausente": False,
"usuario_ausencia_ativa_id": None,
"usuario_aniversariante": False,
}
)
linhas.sort(key=lambda linha: (linha["nome_exibicao"] or "").lower())
return Response(linhas)
@action(detail=False, methods=["get"], url_path="usuarios")
def usuarios_disponiveis(self, request: Request) -> Response:
"""Alimenta o <select> "Lista de Usuários" do modal de Criar Ausência — não
reaproveita /api/usuarios/ porque aquele endpoint é restrito a quem tem
gerencia_permissoes, e a permissão de Ramais é deliberadamente desacoplada
disso (ver PermissaoApp)."""
usuarios = Usuario.objects.filter(is_active=True).order_by("nome")
return Response([{"id": u.id, "nome": u.nome} for u in usuarios])
@action(detail=False, methods=["patch"], url_path=r"usuarios/(?P<usuario_id>\d+)")
def atualizar_ramal_usuario(self, request: Request, usuario_id: str | None = None) -> Response:
"""Grava o ramal de um colaborador de verdade direto em Usuario.ramal — é como
a tela "demonstra" a alteração refletindo no cadastro do usuário."""
usuario = get_object_or_404(Usuario, pk=usuario_id, is_active=True)
usuario.ramal = (request.data.get("numero") or "").strip()
usuario.save(update_fields=["ramal"])
return Response({"id": usuario.id, "numero": usuario.ramal})
class RamalAusenciaViewSet(viewsets.ModelViewSet):
"""Ausências são parte da subtela "Ramais" — mesma permissão dedicada dessa aba
(`ramais-visualizar`/`ramais-editar`), não das outras subtelas de Ramais."""
queryset = RamalAusencia.objects.select_related("usuario").all()
serializer_class = RamalAusenciaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "ramais-visualizar" if self.request.method in SAFE_METHODS else "ramais-editar"
return [PermissaoApp("ramais", app_key)]
class TelefoneExternoViewSet(viewsets.ModelViewSet):
"""Subtela "Telefones Externos" dentro de Ramais — permissão dedicada dessa aba
(`telefones-externos-visualizar`/`telefones-externos-editar`)."""
queryset = TelefoneExterno.objects.all().order_by("nome")
serializer_class = TelefoneExternoSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "telefones-externos-visualizar" if self.request.method in SAFE_METHODS else "telefones-externos-editar"
return [PermissaoApp("ramais", app_key)]
class FuncaoTelefoniaViewSet(viewsets.ModelViewSet):
"""Subtela "Funções de Telefonia" dentro de Ramais — permissão dedicada dessa
aba (`funcoes-telefonia-visualizar`/`funcoes-telefonia-editar`)."""
queryset = FuncaoTelefonia.objects.all().order_by("comando")
serializer_class = FuncaoTelefoniaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "funcoes-telefonia-visualizar" if self.request.method in SAFE_METHODS else "funcoes-telefonia-editar"
return [PermissaoApp("ramais", app_key)]
class WidgetUsuarioViewSet(viewsets.ModelViewSet):
serializer_class = WidgetUsuarioSerializer
permission_classes = [IsAuthenticated]
lookup_field = "tipo"
def get_queryset(self) -> QuerySet[WidgetUsuario]:
return WidgetUsuario.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: WidgetUsuarioSerializer) -> None:
tipo = serializer.validated_data.get("tipo")
maior_ordem = WidgetUsuario.objects.filter(usuario=self.request.user).aggregate(Max("ordem"))["ordem__max"]
widget, _criado = WidgetUsuario.objects.get_or_create(
usuario=self.request.user,
tipo=tipo,
defaults={"ordem": 0 if maior_ordem is None else maior_ordem + 1},
)
serializer.instance = widget
def _salva_arquivo_temporario(arquivo: UploadedFile, sufixo: str) -> str:
"""Grava um upload num arquivo temporário em disco (apagado pelo chamador)
— `le_planilha_padrao`/`OperadoraParser.extrai` esperam um caminho de
arquivo, não um objeto de upload do Django em memória."""
with tempfile.NamedTemporaryFile(delete=False, suffix=sufixo) as tmp:
for chunk in arquivo.chunks():
tmp.write(chunk)
return tmp.name
def _valida_planilha_padrao(arquivo: UploadedFile) -> dict[str, Any]:
"""Roda o mesmo leitor usado em processa_importacao (`le_planilha_padrao`)
sobre o arquivo recém-anexado, só pra confirmar o leiaute antes de
precisar do arquivo da operadora também — nada é salvo/persistido aqui."""
caminho = _salva_arquivo_temporario(arquivo, ".csv")
try:
linhas = le_planilha_padrao(caminho)
except Exception:
return {
"valido": False,
"mensagem": (
"Este arquivo não parece ser a planilha padrão exportada do Questor "
f"(esperado um CSV separado por ';' com as colunas {', '.join(PLANO_SAUDE_CABECALHO)})."
),
}
finally:
os.remove(caminho)
if not linhas:
return {"valido": False, "mensagem": "A planilha está vazia — nenhum beneficiário cadastrado nela."}
return {"valido": True, "mensagem": f"{len(linhas)} beneficiário(s) encontrado(s) na planilha."}
def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict[str, Any]:
"""Roda o parser da operadora escolhida (`OperadoraParser.extrai`) sobre o
arquivo recém-anexado — mesma extração usada em processa_importacao, só
que descartada em seguida (nada é salvo/persistido aqui)."""
sufixo = ".pdf" if (arquivo.name or "").lower().endswith(".pdf") else ".csv"
caminho = _salva_arquivo_temporario(arquivo, sufixo)
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
try:
individuos, _ = operadora_info["parser"]().extrai(caminho)
except Exception:
return {
"valido": False,
"mensagem": (
f"Não foi possível reconhecer este arquivo como um relatório da operadora "
f"{planos_saude_pipeline.label_operadora(operadora_key)} — confira se é o arquivo certo e se o formato "
"(PDF ou CSV, conforme esperado por essa operadora) está correto."
),
}
finally:
os.remove(caminho)
if not individuos:
return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."}
return {"valido": True, "mensagem": f"{len(individuos)} lançamento(s) encontrado(s) no arquivo."}
def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -> bytes:
"""Mesmo formato de portal_api.planos_saude.leiaute_sistema.linhas_para_csv_bytes,
só que a partir de ImportacaoPlanoSaudeLinha (registros já no banco, com as
edições do usuário) em vez de LinhaSistema (dataclass em memória)."""
buffer = io.StringIO()
escritor = csv.writer(buffer, delimiter=";")
escritor.writerow(PLANO_SAUDE_CABECALHO)
for l in linhas:
escritor.writerow([
l.codigo_empresa, l.nome_func, l.cpf_func, l.codigo_out_emp,
l.data_inicial, l.nome_dependente, l.cpf_dependente,
l.valor_empresa, l.valor, l.descricao,
])
return buffer.getvalue().encode("utf-8-sig")
# Campos editáveis de ImportacaoPlanoSaudeLinha (sem tipo_lancamento/ordem,
# guardados à parte em ImportacaoPlanoSaudeAlteracao) — usado tanto pra
# detectar qual campo mudou num PATCH (ImportacaoPlanoSaudeLinhaViewSet.perform_update)
# quanto pra montar o snapshot de _snapshot_linha_plano_saude abaixo.
PLANO_SAUDE_CAMPOS_ALTERACAO = [
"codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial",
"nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao",
]
def _snapshot_linha_plano_saude(linha: ImportacaoPlanoSaudeLinha) -> dict[str, Any]:
"""Espelha os campos editáveis de uma ImportacaoPlanoSaudeLinha (+ ordem) pra
guardar em ImportacaoPlanoSaudeAlteracao.dados_linha — usado tanto pra
identificar a linha na aba "Alterações" quanto pra recriá-la ao reverter
uma exclusão (ImportacaoPlanoSaudeAlteracaoViewSet.reverter)."""
dados = {campo: getattr(linha, campo) for campo in PLANO_SAUDE_CAMPOS_ALTERACAO}
dados["ordem"] = linha.ordem
return dados
def _garante_importacao_em_revisao(importacao: ImportacaoPlanoSaude) -> None:
"""Bloqueia qualquer edição de linha/auditoria/alteração de uma
ImportacaoPlanoSaude já `concluida` — `ImportacaoPlanoSaudeViewSet.reabrir`
é o único jeito de voltar a editar (volta o status pra `revisao`).
Chamada em todo ponto que grava algo dentro de uma importação já criada
(linha/auditoria/alteração), exceto `gerar()` em si, que só lê o que já
está salvo e pode ser chamado de novo pra rebaixar o mesmo arquivo
mesmo depois de concluída."""
if importacao.status == ImportacaoPlanoSaude.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": 'Esta importação já foi concluída. Clique em "Editar" para reabri-la antes de alterar.'}
)
class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
"""Ferramenta "Importação de Plano de Saúde" (Utilitários) — permissão de
toggle único (sem par visualizar/editar, ver catalogo.py "utilitarios"), então
qualquer usuário autorizado pode criar, revisar e gerar, sem conceito de "dono"
(mesmo espírito de LinkFerramenta/AcessoGeral). `create()` roda o pipeline em
portal_api.planos_saude de forma síncrona e já devolve a importação com
linhas/auditoria persistidas; `gerar()` só formata o que já está salvo (isto é,
já reflete qualquer edição feita na revisão), não reprocessa os arquivos
originais. `destroy()` remove a importação do histórico (botão de excluir na
listagem) — apaga os dois arquivos anexados de MEDIA_ROOT antes de excluir o
registro (as linhas/itens de auditoria somem sozinhos via CASCADE)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ImportacaoPlanoSaude.objects.all().order_by("-criado_em")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
def perform_destroy(self, instance: ImportacaoPlanoSaude) -> None:
instance.arquivo_operadora.delete(save=False)
instance.planilha_padrao.delete(save=False)
instance.delete()
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return ImportacaoPlanoSaudeListSerializer
return ImportacaoPlanoSaudeDetailSerializer
@action(detail=False, methods=["get"])
def operadoras(self, request: Request) -> Response:
"""Fonte única pro <select> de operadora do formulário de nova importação —
evita duplicar a lista em JS; adicionar uma operadora nova em
planos_saude.pipeline.OPERADORAS já basta pra aparecer aqui."""
return Response(planos_saude_pipeline.lista_operadoras())
@action(detail=False, methods=["get"], url_path="regras-empresa")
def regras_empresa(self, request: Request) -> Response:
"""Fonte única pro botão "Selecionar regra" do checkbox "Regra empresa"
(terceiro tipo de importação, ao lado de Mensalidade/Coparticipação) —
registro fixo no código (portal_api.planos_saude.regras_empresa), sem
cadastro pela tela; cadastrar uma regra nova lá já basta pra aparecer
aqui."""
return Response(planos_saude_regras_empresa.lista_regras_empresa())
@action(detail=False, methods=["post"], url_path="validar-arquivo")
def validar_arquivo(self, request: Request) -> Response:
"""Pré-validação de UM arquivo (planilha padrão OU arquivo da
operadora), chamada pelo frontend assim que o colaborador anexa cada
um — roda o mesmo parser usado em create()/processa_importacao sobre
um arquivo temporário (nada é persistido), pra apontar especificamente
qual dos dois documentos está fora do padrão esperado, sem precisar
esperar os dois anexados e o "Processar" pra descobrir. Sempre 200 —
`{"valido": bool, "mensagem": str}` — mesmo quando o arquivo está
errado, já que esse é um resultado esperado da validação, não um erro
de requisição."""
arquivo = request.FILES.get("arquivo")
if not arquivo:
raise ValidationError({"arquivo": "Envie um arquivo."})
tipo = request.data.get("tipo")
if tipo == "planilha":
return Response(_valida_planilha_padrao(arquivo))
if tipo == "operadora":
operadora_key = request.data.get("operadora")
if operadora_key not in planos_saude_pipeline.OPERADORAS:
raise ValidationError({"operadora": "Selecione a operadora antes de anexar o arquivo."})
return Response(_valida_arquivo_operadora(arquivo, operadora_key))
raise ValidationError({"tipo": "Informe 'planilha' ou 'operadora'."})
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = ImportacaoPlanoSaudeCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
tipos = dados["tipos_lancamento_lista"]
custeio_por_tipo = dados["custeio_por_tipo"]
operadora_key = dados["operadora"]
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
regra_custeio_obj = dados.get("regra_custeio_salva")
competencia = dados.get("competencia")
planilha_padrao_arquivo = dados.get("planilha_padrao")
linhas_sistema_template = None
planilha_padrao_gerada = None
if not planilha_padrao_arquivo:
# Sem upload: busca a planilha padrão no Questor, ANTES de criar
# a importação — uma falha de conexão/consulta aqui nunca deixa
# nada órfão pra limpar (diferente do caminho de upload, que só
# sabe se o arquivo é válido depois de já ter salvo o registro).
codigo_empresa_regra = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
codigo_operadora = operadora_info["codigo_operadora"]
try:
linhas_sistema_template = busca_linhas_questor(codigo_empresa_regra, codigo_operadora, competencia)
except Exception:
return Response(
{
"detail": (
"Não foi possível buscar a planilha padrão no Questor agora. "
"Tente novamente em alguns instantes ou anexe a planilha manualmente."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if not linhas_sistema_template:
return Response(
{
"detail": (
f"Nenhum beneficiário encontrado no Questor para a empresa {regra_custeio_obj.codigo_empresa} "
f"com a operadora {operadora_info['nome']} na competência informada."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
planilha_padrao_gerada = ContentFile(
linhas_para_csv_bytes(linhas_sistema_template),
name=f"questor_{codigo_empresa_regra}_{competencia:%Y-%m}.csv",
)
importacao = ImportacaoPlanoSaude.objects.create(
operadora=operadora_key,
nome_operadora=planos_saude_pipeline.label_operadora(operadora_key),
tipos_lancamento=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa=dados["regra_empresa"],
regra_custeio_salva=regra_custeio_obj,
criado_por=request.user,
competencia=competencia,
planilha_padrao=planilha_padrao_arquivo or planilha_padrao_gerada,
arquivo_operadora=dados["arquivo_operadora"],
)
try:
if planilha_padrao_arquivo:
linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path)
resultado = planos_saude_pipeline.processa_importacao(
operadora_key=operadora_key,
caminho_arquivo_operadora=importacao.arquivo_operadora.path,
linhas_sistema_template=linhas_sistema_template,
tipos_selecionados=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa_key=dados["regra_empresa"] or None,
)
except RegraEmpresaIncompativelError as exc:
# Diferente do genérico abaixo: aqui o problema não é o arquivo em
# si, é a combinação operadora/planilha × regra empresa escolhida
# — vale a pena mostrar a mensagem específica pro usuário.
importacao.arquivo_operadora.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
# Arquivo ilegível (PDF num layout desconhecido, planilha padrão fora do
# leiaute esperado etc.) — não deixa órfão nem a importação nem os
# arquivos já salvos em MEDIA_ROOT.
importacao.arquivo_operadora.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Trava de segurança: a regra de custeio aplicada foi cadastrada pra
# uma empresa específica (RegraCusteioPlanoSaude.codigo_empresa) —
# confere que a planilha padrão anexada de fato tem alguma linha
# dessa empresa, senão a importação segue com o custeio da empresa
# errada sem nenhum aviso. Vale mesmo quando a regra também tem
# `regra_empresa_chave` (onde é redundante com a checagem que
# `regras_empresa.valida_regra_empresa()` já faz dentro do try acima)
# — protege contra o registro em REGRAS_EMPRESA ficar dessincronizado
# da RegraCusteioPlanoSaude correspondente. Quando a planilha veio do
# Questor (não upload), essa conferência é redundante — a consulta já
# filtrou pelo próprio `codigo_empresa` da regra — então é pulada.
if planilha_padrao_arquivo and regra_custeio_obj and regra_custeio_obj.codigo_empresa:
todas_linhas = [linha for linhas in resultado.linhas_por_tipo.values() for linha in linhas]
# A planilha padrão traz o código cru do Questor (pode ter zero à
# esquerda, ex. "092"), enquanto RegraCusteioPlanoSaude.codigo_empresa
# já é sempre canônico (sem zero à esquerda, ver normalizar_codigo_empresa)
# — comparar sem normalizar os dois lados rejeitaria uma planilha
# correta só por essa diferença de formatação.
codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
importacao.arquivo_operadora.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
return Response(
{
"detail": (
f'A regra "{regra_custeio_obj.nome}" foi cadastrada para a empresa código '
f"{regra_custeio_obj.codigo_empresa}, mas a planilha padrão anexada não tem "
"nenhuma linha com esse código."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
linhas_bulk = [
ImportacaoPlanoSaudeLinha(
importacao=importacao,
tipo_lancamento=tipo,
ordem=ordem,
codigo_empresa=linha.codigo_empresa,
nome_func=linha.nome_func,
cpf_func=linha.cpf_func,
codigo_out_emp=linha.codigo_out_emp,
data_inicial=linha.data_inicial,
nome_dependente=linha.nome_dependente,
cpf_dependente=linha.cpf_dependente,
valor_empresa=linha.valor_empresa,
valor=linha.valor,
descricao=linha.descricao,
)
for tipo, linhas in resultado.linhas_por_tipo.items()
for ordem, linha in enumerate(linhas)
]
ImportacaoPlanoSaudeLinha.objects.bulk_create(linhas_bulk)
auditoria_bulk = [
ImportacaoPlanoSaudeAuditoria(
importacao=importacao,
motivo=item.motivo,
tipo_lancamento=item.tipo_lancamento,
numero_beneficiario=item.numero_beneficiario,
nome=item.nome,
cpf=item.cpf,
tipo=item.tipo,
valor=item.valor,
detalhe=item.detalhe,
)
for item in resultado.auditoria
]
ImportacaoPlanoSaudeAuditoria.objects.bulk_create(auditoria_bulk)
serializer = ImportacaoPlanoSaudeDetailSerializer(importacao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def gerar(self, request: Request, pk: str | None = None) -> HttpResponse:
"""Monta o(s) CSV(s) finais a partir das linhas já salvas (com qualquer
edição feita na revisão) — 1 tipo de lançamento vira um único .csv; 2 tipos
(mensalidade + coparticipação) viram um .zip com um .csv por tipo. Pode ser
chamada de novo pra regerar depois de mais edições."""
importacao = self.get_object()
linhas_por_tipo: dict[str, list[ImportacaoPlanoSaudeLinha]] = {}
for linha in importacao.linhas.order_by("tipo_lancamento", "ordem", "id"):
linhas_por_tipo.setdefault(linha.tipo_lancamento, []).append(linha)
arquivos = {tipo: _monta_csv_linhas_plano_saude(linhas) for tipo, linhas in linhas_por_tipo.items()}
importacao.status = ImportacaoPlanoSaude.STATUS_CONCLUIDA
importacao.concluida_em = timezone.now()
importacao.save(update_fields=["status", "concluida_em"])
if len(arquivos) == 1:
(tipo, conteudo), = arquivos.items()
response = HttpResponse(conteudo, content_type="text/csv; charset=utf-8-sig")
response["Content-Disposition"] = f'attachment; filename="{tipo}.csv"'
return response
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for tipo, conteudo in arquivos.items():
zf.writestr(f"{tipo}.csv", conteudo)
response = HttpResponse(buffer.getvalue(), content_type="application/zip")
response["Content-Disposition"] = f'attachment; filename="importacao_plano_saude_{importacao.id}.zip"'
return response
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
"""Contrapartida de gerar(): volta uma importação `concluida` pra
`revisao`, liberando de novo a edição de linhas/auditoria/alterações
(ver `_garante_importacao_em_revisao`) — botão "Editar" na tela de
Revisão, visível só quando `status == "concluida"`."""
importacao = self.get_object()
importacao.status = ImportacaoPlanoSaude.STATUS_REVISAO
importacao.concluida_em = None
importacao.save(update_fields=["status", "concluida_em"])
serializer = ImportacaoPlanoSaudeDetailSerializer(importacao)
return Response(serializer.data)
class ImportacaoPlanoSaudeLinhaViewSet(viewsets.ModelViewSet):
"""Edição das linhas de uma importação já criada — todos os campos (não só os
valores) são editáveis na tela de revisão (com a restrição de UI + backend
de `_garante_importacao_em_revisao`: nada disso é permitido depois que a
importação foi concluída, até reabri-la). Mesma permissão de toggle único
de ImportacaoPlanoSaudeViewSet; qualquer usuário autorizado pode editar
linha de qualquer importação, sem conceito de "dono".
`create()` permite incluir manualmente uma linha nova (botão "Adicionar linha"
na revisão) — nasce em branco/"0" e entra automaticamente no CSV gerado, já
que `gerar()` (na outra view) lê todas as linhas da importação sem distinguir
origem. `destroy()` (botão de remover ao lado de cada linha, qualquer uma —
gerada pelo pipeline ou incluída manualmente) é o CRUD padrão do DRF, sem
override: a linha simplesmente some da importação e do CSV gerado depois.
As três operações (criar/editar/excluir) também gravam um
ImportacaoPlanoSaudeAlteracao — histórico exibido na aba "Alterações" da
revisão, revertível via ImportacaoPlanoSaudeAlteracaoViewSet.reverter."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = ImportacaoPlanoSaudeLinha.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "create":
return ImportacaoPlanoSaudeLinhaCreateSerializer
return ImportacaoPlanoSaudeLinhaSerializer
def perform_create(self, serializer: ImportacaoPlanoSaudeLinhaCreateSerializer) -> None:
importacao = serializer.validated_data["importacao"]
_garante_importacao_em_revisao(importacao)
tipo = serializer.validated_data["tipo_lancamento"]
maior_ordem = ImportacaoPlanoSaudeLinha.objects.filter(
importacao=importacao, tipo_lancamento=tipo
).aggregate(Max("ordem"))["ordem__max"]
linha = serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_INCLUSAO,
linha=linha,
tipo_lancamento=tipo,
dados_linha=_snapshot_linha_plano_saude(linha),
usuario=self.request.user,
)
def perform_update(self, serializer: ImportacaoPlanoSaudeLinhaSerializer) -> None:
# serializer.instance ainda reflete os valores ANTES do save() abaixo —
# é o que permite comparar campo a campo o que de fato mudou.
linha_anterior = serializer.instance
_garante_importacao_em_revisao(linha_anterior.importacao)
valores_anteriores = {
campo: getattr(linha_anterior, campo)
for campo in PLANO_SAUDE_CAMPOS_ALTERACAO
if campo in serializer.validated_data and serializer.validated_data[campo] != getattr(linha_anterior, campo)
}
linha = serializer.save()
for campo, valor_anterior in valores_anteriores.items():
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=linha.importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_EDICAO,
linha=linha,
tipo_lancamento=linha.tipo_lancamento,
campo=campo,
valor_anterior=valor_anterior,
valor_novo=getattr(linha, campo),
dados_linha=_snapshot_linha_plano_saude(linha),
usuario=self.request.user,
)
def perform_destroy(self, instance: ImportacaoPlanoSaudeLinha) -> None:
_garante_importacao_em_revisao(instance.importacao)
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=instance.importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_EXCLUSAO,
tipo_lancamento=instance.tipo_lancamento,
dados_linha=_snapshot_linha_plano_saude(instance),
usuario=self.request.user,
)
instance.delete()
class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet):
"""Só a reversão de uma alteração já registrada (ver ImportacaoPlanoSaudeAlteracao
em models.py) — não há list/create/update/destroy porque os registros só
nascem via ImportacaoPlanoSaudeLinhaViewSet (edição/inclusão/exclusão de
linha) e já chegam ao frontend aninhados em
ImportacaoPlanoSaudeDetailSerializer.alteracoes. Mesma permissão de
toggle único das outras views de plano de saúde."""
http_method_names = ["post", "head", "options"]
queryset = ImportacaoPlanoSaudeAlteracao.objects.all()
serializer_class = ImportacaoPlanoSaudeAlteracaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
@action(detail=True, methods=["post"])
def reverter(self, request: Request, pk: str | None = None) -> Response:
"""Desfaz uma alteração específica: edição volta o campo pro valor
anterior; inclusão remove a linha incluída; exclusão recria a linha a
partir do snapshot salvo em `dados_linha`. Idempotente — recusa
reverter de novo uma alteração já revertida (`revertida=True`), e a
própria reversão não gera um novo registro de alteração (evita um
loop de "reverter a reversão")."""
alteracao = self.get_object()
_garante_importacao_em_revisao(alteracao.importacao)
if alteracao.revertida:
raise ValidationError({"detail": "Esta alteração já foi revertida."})
if alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_EDICAO:
if not alteracao.linha_id:
raise ValidationError({"detail": "A linha desta alteração não existe mais."})
setattr(alteracao.linha, alteracao.campo, alteracao.valor_anterior)
alteracao.linha.save(update_fields=[alteracao.campo])
elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_INCLUSAO:
if not alteracao.linha_id:
raise ValidationError({"detail": "Esta linha já não existe mais."})
alteracao.linha.delete()
alteracao.linha = None
elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_EXCLUSAO:
dados = dict(alteracao.dados_linha or {})
alteracao.linha = ImportacaoPlanoSaudeLinha.objects.create(
importacao=alteracao.importacao,
tipo_lancamento=alteracao.tipo_lancamento,
**dados,
)
alteracao.revertida = True
alteracao.revertida_em = timezone.now()
alteracao.save()
return Response(ImportacaoPlanoSaudeAlteracaoSerializer(alteracao).data)
def _recalcula_familia_regra_empresa(importacao: ImportacaoPlanoSaude, linha: ImportacaoPlanoSaudeLinha) -> None:
"""Reaplica a regra empresa (`planos_saude.regras_empresa`) pra TODA a
família de `linha` (mesmo `nome_func`, mesmo `tipo_lancamento`) — chamado
depois de "Vincular pessoa" (ImportacaoPlanoSaudeAuditoriaViewSet.resolver)
resolver um item de auditoria de mensalidade numa importação com
`regra_empresa` configurada: o valor recém-vinculado muda o total da
família, então o teto (`regras_empresa._aplica_teto_familia`, ou
equivalente) precisa ser redistribuído entre TODAS as linhas da família
de novo — nunca só a que acabou de ser vinculada, senão o resultado
ignora a regra empresa e cai no custeio padrão (100% desconto do
empregado), que é exatamente o bug que isso corrige. O valor "bruto" de
cada linha já lançada é recuperado como `valor_empresa + valor` — essa
soma sempre preserva o total da mensalidade, independente de qual split
foi aplicado antes."""
regra = planos_saude_regras_empresa.REGRAS_EMPRESA.get(importacao.regra_empresa)
if not regra:
return
linhas_familia = list(
importacao.linhas.filter(tipo_lancamento="mensalidade", nome_func=linha.nome_func).order_by("ordem", "id")
)
linhas_e_valores = [(l, parse_valor_br(l.valor_empresa) + parse_valor_br(l.valor)) for l in linhas_familia]
regra["aplica"](linhas_e_valores)
ImportacaoPlanoSaudeLinha.objects.bulk_update(linhas_familia, ["valor_empresa", "valor"])
class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet):
"""Só a resolução manual de um item de auditoria (ver
`ImportacaoPlanoSaudeAuditoria.MOTIVOS_RESOLVIVEIS`/`resolvida`/
`linha_vinculada` em models.py) — não há list/create/update/destroy
porque os itens em si só nascem via o pipeline (`ImportacaoPlanoSaudeViewSet.create`)
e já chegam ao frontend aninhados em `ImportacaoPlanoSaudeDetailSerializer.itens_auditoria`.
Mesma permissão de toggle único das outras views de plano de saúde."""
http_method_names = ["post", "head", "options"]
queryset = ImportacaoPlanoSaudeAuditoria.objects.all()
serializer_class = ImportacaoPlanoSaudeAuditoriaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
@action(detail=True, methods=["post"])
def resolver(self, request: Request, pk: str | None = None) -> Response:
"""Confirma que este item (nome divergente ou titular/dependente não
encontrado por nome) é, na verdade, a pessoa de uma linha específica
já carregada na planilha padrão — aplica o `valor` do item nessa
linha (dividido pela mesma regra de custeio já salva pra esse tipo de
lançamento × titular/dependente) e marca o item como resolvido. Só
aceita linhas ainda em branco (valor=valor_empresa="0"), pra nunca
sobrescrever sem querer um lançamento que já casou automaticamente
com outra pessoa do arquivo da operadora."""
item = self.get_object()
_garante_importacao_em_revisao(item.importacao)
if item.motivo not in ImportacaoPlanoSaudeAuditoria.MOTIVOS_RESOLVIVEIS:
raise ValidationError({"detail": "Esse item de auditoria não pode ser resolvido manualmente."})
if item.resolvida:
raise ValidationError({"detail": "Este item já foi resolvido."})
linha_id = request.data.get("linha_id")
if not linha_id:
raise ValidationError({"linha_id": "Informe a linha da planilha padrão correspondente."})
linha = get_object_or_404(ImportacaoPlanoSaudeLinha, pk=linha_id, importacao_id=item.importacao_id)
if linha.tipo_lancamento != item.tipo_lancamento:
raise ValidationError({"linha_id": "A linha escolhida não é do mesmo tipo de lançamento deste item."})
linha_eh_titular = not linha.nome_dependente.strip() and not linha.cpf_dependente.strip()
if linha_eh_titular != (item.tipo == "T"):
raise ValidationError(
{"linha_id": "A linha escolhida não corresponde ao mesmo tipo de beneficiário (titular/dependente) deste item."}
)
if linha.valor != "0" or linha.valor_empresa != "0":
raise ValidationError({"linha_id": "Essa linha já tem um valor lançado — escolha uma linha ainda em branco."})
if item.tipo_lancamento == "mensalidade" and item.importacao.regra_empresa:
# "Regra empresa" é calculada por FAMÍLIA inteira (ver
# regras_empresa.py), não por pessoa — não dá pra aplicar só
# nesta linha isoladamente (senão cairia no custeio padrão,
# 100% desconto do empregado). Grava o valor bruto aqui primeiro
# (valor_empresa="0" é só um placeholder) e reaplica a regra em
# toda a família, que recupera o valor bruto de cada linha como
# valor_empresa + valor.
linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor))
linha.save(update_fields=["valor_empresa", "valor"])
_recalcula_familia_regra_empresa(item.importacao, linha)
else:
regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})
linha.valor_empresa, linha.valor = planos_saude_matcher.valores_formatados_para_pessoa(
float(item.valor), regra_por_pessoa, item.tipo
)
linha.save(update_fields=["valor_empresa", "valor"])
item.resolvida = True
item.linha_vinculada = linha
item.save(update_fields=["resolvida", "linha_vinculada"])
return Response(ImportacaoPlanoSaudeAuditoriaSerializer(item).data)
class RegraCusteioPlanoSaudeViewSet(viewsets.ModelViewSet):
"""Banco de regras de custeio salvas (ex.: "092 - Unimed") — substitui o
antigo fluxo de exportar/importar um arquivo `.json` na tela de Nova
Importação. Lista compartilhada, sem "dono" — mesma permissão de toggle
único das outras views de plano de saúde."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = RegraCusteioPlanoSaude.objects.all()
serializer_class = RegraCusteioPlanoSaudeSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
def perform_create(self, serializer: RegraCusteioPlanoSaudeSerializer) -> None:
serializer.save(criado_por=self.request.user)
@action(detail=False, methods=["get"], url_path="nome-empresa")
def nome_empresa(self, request: Request) -> Response:
"""Resolve (e cacheia) o nome de uma empresa no Questor a partir do
código digitado em "+ Nova regra" — ver empresas_questor.py. Devolve
`nome_empresa: null` tanto pra "código não existe no Questor" quanto
pra "Questor inacessível agora"; o frontend não usa isso como
bloqueio, só como exibição."""
codigo = normalizar_codigo_empresa(request.query_params.get("codigo_empresa") or "")
if not codigo:
return Response({"detail": "Informe codigo_empresa."}, status=status.HTTP_400_BAD_REQUEST)
return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
class IndicadorPercentualTipoViewSet(viewsets.ModelViewSet):
"""Cadastro dos percentuais individual/grupo/departamento por tipo de
colaborador (ver IndicadorPercentualTipo em models.py) — nunca editado
in-place, só criado com um `vigente_desde` novo (histórico completo,
decisão explícita do usuário). Mesma permissão de toggle único do
Indicador de Desempenho."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = IndicadorPercentualTipo.objects.all()
serializer_class = IndicadorPercentualTipoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def perform_create(self, serializer: IndicadorPercentualTipoSerializer) -> None:
serializer.save(criado_por=self.request.user)
class IndicadorCriterioViewSet(viewsets.ModelViewSet):
"""CRUD do cadastro genérico de critérios do Indicador de Desempenho (ver
IndicadorCriterio em models.py) — nome/peso/período/papel livres, editável
pelo RH em vez de fixado no código."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorCriterio.objects.all()
serializer_class = IndicadorCriterioSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorDepartamentoViewSet(viewsets.ModelViewSet):
"""CRUD do cadastro de departamentos do Indicador de Desempenho (ver
IndicadorDepartamento em models.py) — cada departamento tem seu próprio
conjunto de critérios/percentuais e sua própria meta de Departamento na
apuração."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorDepartamento.objects.prefetch_related("gerentes")
serializer_class = IndicadorDepartamentoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorDepartamentoGerenteViewSet(viewsets.ModelViewSet):
"""CRUD da relação gerente→departamento (ver IndicadorDepartamentoGerente
em models.py / portal_api.indicadores.departamentos) — mantida
manualmente pela própria aplicação (Configurações → Departamentos →
"Gerenciar Gerentes"); `nome_gerente` é único, então o próprio banco
recusa mapear o mesmo gerente pra dois departamentos ao mesmo tempo."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorDepartamentoGerente.objects.select_related("departamento")
serializer_class = IndicadorDepartamentoGerenteSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
"""Apuração mensal do Indicador de Desempenho (Fiscontábil, Geradoc) —
permissão de toggle único (sem par visualizar/editar), mesmo espírito de
ImportacaoPlanoSaudeViewSet. `create()` roda `portal_api.indicadores.pipeline`
de forma síncrona sobre as duas planilhas anexadas e já persiste
colaboradores/empresas/respostas (os 3 critérios automáticos já vêm
pré-calculados; os demais entram como "Não se aplica" pra o RH revisar).
`gerar()` só formata os PDFs a partir do que já está salvo — reflete
qualquer ajuste manual feito na revisão, sem reprocessar as planilhas."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = IndicadorApuracao.objects.all().order_by("-competencia", "-criado_em")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_queryset(self) -> QuerySet[IndicadorApuracao]:
queryset = super().get_queryset()
if self.action == "retrieve":
# IndicadorApuracaoDetailSerializer aninha colaboradores/empresas/
# respostas (e o SerializerMethodField `composicao_individual`
# acessa `empresas`/`respostas` de novo por colaborador) — sem
# prefetch, cada acesso a esses related managers dispara uma
# query nova por colaborador.
queryset = queryset.prefetch_related("colaboradores__empresas", "colaboradores__respostas__criterio")
return queryset
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return IndicadorApuracaoListSerializer
return IndicadorApuracaoDetailSerializer
def perform_destroy(self, instance: IndicadorApuracao) -> None:
instance.planilha_tareffa.delete(save=False)
instance.planilha_honorarios.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = IndicadorApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
apuracao: IndicadorApuracao | None = None
try:
with transaction.atomic():
apuracao = IndicadorApuracao.objects.create(
competencia=dados["competencia"],
criado_por=request.user,
planilha_tareffa=dados["planilha_tareffa"],
planilha_honorarios=dados["planilha_honorarios"],
)
# Critérios (e seus critérios automáticos) são agrupados por
# departamento — cada departamento pode ter uma regra
# diferente (ver CLAUDE.md); o pipeline resolve o
# departamento de cada colaborador pelo `gerente` (via
# `mapa_gerentes`) e só usa os critérios automáticos
# daquele departamento pra pré-calcular as respostas.
mapa_gerentes = indicadores_departamentos.carrega_mapa_gerentes()
criterios_ativos = list(IndicadorCriterio.objects.filter(ativo=True))
criterios_por_departamento: dict[int, list[IndicadorCriterio]] = {}
criterios_automaticos_por_departamento: dict[
int, list[indicadores_pipeline.CriterioAutomaticoConfig]
] = {}
for criterio in criterios_ativos:
criterios_por_departamento.setdefault(criterio.departamento_id, []).append(criterio)
if criterio.calculo_automatico:
criterios_automaticos_por_departamento.setdefault(criterio.departamento_id, []).append(
indicadores_pipeline.CriterioAutomaticoConfig(
criterio_id=criterio.id,
tipo_calculo=criterio.calculo_automatico,
limiar_percentual=criterio.limiar_percentual,
)
)
resultado = indicadores_pipeline.processa_apuracao(
caminho_tareffa=apuracao.planilha_tareffa.path,
caminho_honorarios=apuracao.planilha_honorarios.path,
gerente_departamento=mapa_gerentes,
criterios_automaticos_por_departamento=criterios_automaticos_por_departamento,
)
apuracao.avisos = resultado.avisos
apuracao.save(update_fields=["avisos"])
for colaborador_calculado in resultado.colaboradores:
colaborador = IndicadorApuracaoColaborador.objects.create(
apuracao=apuracao,
nome=colaborador_calculado.nome,
gerente=colaborador_calculado.gerente,
departamento_id=colaborador_calculado.departamento_id,
)
IndicadorApuracaoEmpresa.objects.bulk_create(
[
IndicadorApuracaoEmpresa(
colaborador=colaborador,
codigo_empresa=empresa.codigo_empresa,
nome_empresa=empresa.nome_empresa,
honorario=empresa.honorario,
honorario_nao_encontrado=empresa.honorario_nao_encontrado,
tipo=empresa.tipo,
)
for empresa in colaborador_calculado.empresas
]
)
respostas_automaticas = {
resposta.criterio_id: resposta for resposta in colaborador_calculado.respostas_automaticas
}
# Só os critérios do departamento deste colaborador (ver
# comentário acima) — não mais todos os `criterios_ativos`
# da apuração. Colaborador sem departamento resolvido
# (gerente não mapeado) não recebe nenhuma resposta.
criterios_deste_departamento = criterios_por_departamento.get(
colaborador_calculado.departamento_id, []
)
IndicadorApuracaoResposta.objects.bulk_create(
[
IndicadorApuracaoResposta(
colaborador=colaborador,
criterio=criterio,
valor=(
respostas_automaticas[criterio.id].valor
if criterio.id in respostas_automaticas
# Grupo/Departamento não têm "não se aplica" real — o
# colaborador sempre faz parte de um grupo e do
# departamento (ver CLAUDE.md) — então nascem "Sim" por
# padrão, cabendo ao RH marcar "Não" quando a meta não
# foi cumprida. Individual (manual, sem cálculo
# automático) continua "Não se aplica" por padrão, já
# que pode genuinamente não valer pro papel do
# colaborador (ver `papel_aplicavel`).
else (
"SIM"
if criterio.grupo in (IndicadorCriterio.GRUPO_GRUPO, IndicadorCriterio.GRUPO_DEPARTAMENTO)
else "NAO_SE_APLICA"
)
),
valor_automatico=(
respostas_automaticas[criterio.id].valor
if criterio.id in respostas_automaticas
else ""
),
percentual_calculado=(
respostas_automaticas[criterio.id].percentual_calculado
if criterio.id in respostas_automaticas
else None
),
)
for criterio in criterios_deste_departamento
]
)
indicadores_calculo.recalcula_colaborador(colaborador)
except Exception:
# Planilha fora do leiaute esperado, ou qualquer outra falha ao
# persistir o resultado — o `transaction.atomic()` já desfez tudo
# no banco; só falta apagar os 2 arquivos gravados em MEDIA_ROOT
# (upload de FileField não é transacional) pra não deixar lixo.
if apuracao is not None:
apuracao.planilha_tareffa.delete(save=False)
apuracao.planilha_honorarios.delete(save=False)
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IndicadorApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def gerar(self, request: Request, pk: str | None = None) -> HttpResponse:
"""ZIP com um PDF de recibo por colaborador, formatado a partir do que
já está salvo (reflete qualquer ajuste manual feito na revisão).
`colaborador_ids` (opcional, lista de ids) restringe a geração a só
esses colaboradores — o modal "Gerar Recibos" (`indicador-desempenho.js`)
deixa escolher um colaborador só, alguns específicos, por departamento
ou todos de uma vez, reaproveitando o mesmo checklist com busca/filtro
por departamento do "Ajuste Indicador em Lote". A apuração só é marcada
`concluida` quando a seleção cobre **todos** os colaboradores (sem
`colaborador_ids`, ou uma seleção que bate com o total) — gerar um
recibo avulso pra conferência não deve marcar a apuração inteira como
fechada."""
apuracao = self.get_object()
todos_colaboradores = list(
apuracao.colaboradores.all()
.select_related("departamento")
.prefetch_related("empresas", "respostas__criterio")
)
if not todos_colaboradores:
raise ValidationError({"detail": "Esta apuração não tem nenhum colaborador."})
colaborador_ids = request.data.get("colaborador_ids")
if colaborador_ids:
ids_validos = {c.id for c in todos_colaboradores}
try:
ids_pedidos = {int(colaborador_id) for colaborador_id in colaborador_ids}
except (TypeError, ValueError):
raise ValidationError({"colaborador_ids": "Ids inválidos."})
if not ids_pedidos.issubset(ids_validos):
raise ValidationError({"colaborador_ids": "Algum colaborador informado não pertence a esta apuração."})
colaboradores = [c for c in todos_colaboradores if c.id in ids_pedidos]
else:
colaboradores = todos_colaboradores
if not colaboradores:
raise ValidationError({"colaborador_ids": "Selecione ao menos um colaborador."})
if {c.id for c in colaboradores} == {c.id for c in todos_colaboradores}:
apuracao.status = IndicadorApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now()
apuracao.save(update_fields=["status", "concluida_em"])
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for colaborador in colaboradores:
nome_arquivo = re.sub(r"[^A-Za-z0-9]+", "_", colaborador.nome).strip("_") or f"colaborador_{colaborador.id}"
zf.writestr(f"{nome_arquivo}.pdf", indicadores_recibo.gera_pdf_recibo(colaborador, apuracao))
response = HttpResponse(buffer.getvalue(), content_type="application/zip")
response["Content-Disposition"] = f'attachment; filename="recibos_indicador_{apuracao.id}.zip"'
return response
def _colaboradores_do_gerente(self, apuracao: IndicadorApuracao, gerente: str) -> list[IndicadorApuracaoColaborador]:
colaboradores = list(apuracao.colaboradores.filter(gerente=gerente))
if not colaboradores:
raise ValidationError({"gerente": "Nenhum colaborador encontrado para esse gerente nesta apuração."})
return colaboradores
def _colaboradores_do_departamento(
self, apuracao: IndicadorApuracao, departamento_id: int
) -> list[IndicadorApuracaoColaborador]:
colaboradores = list(apuracao.colaboradores.filter(departamento_id=departamento_id))
if not colaboradores:
raise ValidationError(
{"departamento": "Nenhum colaborador encontrado para esse departamento nesta apuração."}
)
return colaboradores
@action(detail=True, methods=["post"], url_path="ajustar-grupo")
def ajustar_grupo(self, request: Request, pk: str | None = None) -> Response:
"""Ajuste manual do percentual Grupo — "cada gerente representa um
grupo" (ver CLAUDE.md): em vez de ajustar colaborador a colaborador,
aplica o mesmo valor a todos os colaboradores daquele `gerente` nesta
apuração de uma vez, já que o percentual conceitualmente é do grupo,
não de uma pessoa."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteGrupoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_gerente(apuracao, entrada.validated_data["gerente"])
for colaborador in colaboradores:
colaborador.pct_grupo = entrada.validated_data["pct_grupo"]
colaborador.pct_grupo_ajustado_manualmente = True
colaborador.save(update_fields=["pct_grupo", "pct_grupo_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="recalcular-grupo")
def recalcular_grupo(self, request: Request, pk: str | None = None) -> Response:
apuracao = self.get_object()
entrada = IndicadorApuracaoRecalcularGrupoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_gerente(apuracao, entrada.validated_data["gerente"])
for colaborador in colaboradores:
indicadores_calculo.limpa_ajuste_grupo(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="ajustar-departamento")
def ajustar_departamento(self, request: Request, pk: str | None = None) -> Response:
"""Ajuste manual do percentual Departamento — cada `IndicadorDepartamento`
(Fisco/Contábil, Rocket, Gerentes, ...) tem sua própria meta (ver
CLAUDE.md): aplica o mesmo valor a todos os colaboradores do
`departamento` informado nesta apuração de uma vez, não colaborador a
colaborador."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteDepartamentoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_departamento(apuracao, entrada.validated_data["departamento"])
for colaborador in colaboradores:
colaborador.pct_departamento = entrada.validated_data["pct_departamento"]
colaborador.pct_departamento_ajustado_manualmente = True
colaborador.save(update_fields=["pct_departamento", "pct_departamento_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="recalcular-departamento")
def recalcular_departamento(self, request: Request, pk: str | None = None) -> Response:
apuracao = self.get_object()
entrada = IndicadorApuracaoRecalcularDepartamentoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_departamento(apuracao, entrada.validated_data["departamento"])
for colaborador in colaboradores:
indicadores_calculo.limpa_ajuste_departamento(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="ajustar-honorario-empresa")
def ajustar_honorario_empresa(self, request: Request, pk: str | None = None) -> Response:
"""Preenche (ou corrige) o honorário de uma empresa de uma vez pra
**todos** os colaboradores desta apuração que a têm (mesmo
`codigo_empresa`) — ver CLAUDE.md/`IndicadorApuracaoAjusteHonorarioEmpresaSerializer`.
Cobre as linhas com `honorario_nao_encontrado=True` (preenchimento
inicial, modal "Empresas sem Honorário") e as com
`honorario_ajustado_manualmente=True` (correção de um valor já
ajustado, modal "Empresas Ajustadas Manualmente") — nunca uma empresa
cujo honorário só veio certo da planilha e nunca foi mexido."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteHonorarioEmpresaSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
empresas = list(
IndicadorApuracaoEmpresa.objects.filter(
Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True),
colaborador__apuracao=apuracao,
codigo_empresa=entrada.validated_data["codigo_empresa"],
).select_related("colaborador")
)
if not empresas:
raise ValidationError(
{"codigo_empresa": "Nenhuma empresa com honorário pendente ou ajustado com esse código nesta apuração."}
)
for empresa in empresas:
empresa.honorario = entrada.validated_data["honorario"]
empresa.honorario_nao_encontrado = False
empresa.honorario_ajustado_manualmente = True
empresa.save(update_fields=["honorario", "honorario_nao_encontrado", "honorario_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(empresa.colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
class IndicadorApuracaoColaboradorViewSet(viewsets.ModelViewSet):
"""Ajuste manual do percentual Individual de um colaborador já calculado
pelo pipeline — os registros nascem todos juntos em
`IndicadorApuracaoViewSet.create()`, então não há create() nem destroy()
aqui, só GET/PATCH (ajuste) e a action `recalcular` (reverte pro modo
automático). Grupo e Departamento não são ajustados por aqui: são
editados em bloco por `IndicadorApuracaoViewSet.ajustar_grupo`/
`ajustar_departamento` (ver CLAUDE.md — "cada gerente representa um
grupo", Departamento vale pra toda a apuração)."""
http_method_names = ["get", "patch", "post", "head", "options"]
queryset = IndicadorApuracaoColaborador.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return IndicadorApuracaoColaboradorAjusteSerializer
return IndicadorApuracaoColaboradorSerializer
def perform_update(self, serializer: IndicadorApuracaoColaboradorAjusteSerializer) -> None:
colaborador: IndicadorApuracaoColaborador = self.get_object()
colaborador.pct_individual = serializer.validated_data["pct_individual"]
colaborador.pct_individual_ajustado_manualmente = True
colaborador.save(update_fields=["pct_individual", "pct_individual_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
colaborador = self.get_object()
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
@action(detail=True, methods=["post"])
def recalcular(self, request: Request, pk: str | None = None) -> Response:
"""Reverte o percentual Individual pro modo automático (limpa
qualquer ajuste manual anterior) e recalcula a partir das respostas
atuais."""
colaborador = self.get_object()
indicadores_calculo.limpa_ajuste_individual(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
@action(detail=True, methods=["post"], url_path="marcar-validado")
def marcar_validado(self, request: Request, pk: str | None = None) -> Response:
"""Checklist de revisão do RH (`validado`) — não recalcula nada, só
marca/desmarca que este colaborador já foi conferido."""
colaborador = self.get_object()
entrada = IndicadorApuracaoColaboradorValidadoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaborador.validado = entrada.validated_data["validado"]
colaborador.save(update_fields=["validado"])
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
class IndicadorApuracaoEmpresaViewSet(viewsets.ModelViewSet):
"""Preenchimento manual do honorário de uma empresa com
`honorario_nao_encontrado=True` (código sem casamento na planilha de
Honorários Por Cliente) — os registros nascem todos juntos em
`IndicadorApuracaoViewSet.create()`, então não há create() nem destroy()
aqui, só GET/PATCH (+ a action `trocar-responsavel`, que precisa de POST
na lista de métodos mesmo sem create() próprio — `http_method_names` é
checado por `View.dispatch()` antes do roteamento de qualquer action)."""
http_method_names = ["get", "patch", "post", "head", "options"]
queryset = IndicadorApuracaoEmpresa.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return IndicadorApuracaoEmpresaAjusteSerializer
return IndicadorApuracaoEmpresaSerializer
def perform_update(self, serializer: IndicadorApuracaoEmpresaAjusteSerializer) -> None:
empresa: IndicadorApuracaoEmpresa = self.get_object()
empresa.honorario = serializer.validated_data["honorario"]
empresa.honorario_nao_encontrado = False
empresa.honorario_ajustado_manualmente = True
empresa.save(update_fields=["honorario", "honorario_nao_encontrado", "honorario_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(empresa.colaborador)
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
empresa = self.get_object()
return Response(IndicadorApuracaoEmpresaSerializer(empresa).data)
@action(detail=True, methods=["post"], url_path="trocar-responsavel")
def trocar_responsavel(self, request: Request, pk: str | None = None) -> Response:
"""Reatribui esta linha (empresa+tipo) pra outro colaborador da mesma
apuração — ex.: "Fiscal" da empresa X estava com Fulano, passa a ser
de Beltrano. Recalcula os dois colaboradores (o que perdeu a empresa
e o que ganhou), já que o conjunto de `empresas` de cada um mudou."""
empresa = self.get_object()
apuracao = empresa.colaborador.apuracao
entrada = IndicadorApuracaoEmpresaTrocarResponsavelSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
novo_colaborador = IndicadorApuracaoColaborador.objects.filter(
id=entrada.validated_data["colaborador_id"], apuracao=apuracao
).first()
if novo_colaborador is None:
raise ValidationError({"colaborador_id": "Colaborador não encontrado nesta apuração."})
if novo_colaborador.id == empresa.colaborador_id:
raise ValidationError({"colaborador_id": "Este já é o responsável atual."})
if IndicadorApuracaoEmpresa.objects.filter(
colaborador=novo_colaborador, codigo_empresa=empresa.codigo_empresa, tipo=empresa.tipo
).exists():
raise ValidationError({"colaborador_id": "Este colaborador já é responsável por esta empresa/tipo."})
colaborador_antigo = empresa.colaborador
empresa.colaborador = novo_colaborador
empresa.save(update_fields=["colaborador"])
indicadores_calculo.recalcula_colaborador(colaborador_antigo)
indicadores_calculo.recalcula_colaborador(novo_colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
class IndicadorApuracaoRespostaViewSet(viewsets.ModelViewSet):
"""Edição de uma resposta de critério já existente — os registros nascem
todos juntos em `IndicadorApuracaoViewSet.create()`, então não há create()
nem destroy() aqui, só GET/PATCH (individual) e `aplicar_em_lote` (a
"múltipla seleção" pedida pelo usuário: aplicar o mesmo valor a várias
respostas de uma vez). `post` precisa estar na lista mesmo sem create()
própria — `aplicar_em_lote` é uma action `@action(methods=["post"])`, mas
`http_method_names` é checado por `View.dispatch()` antes de qualquer
roteamento de action; sem "post" aqui, toda chamada à action cai em 405
("Método 'POST' não é permitido"), mesmo com o método certo na action."""
http_method_names = ["get", "post", "patch", "head", "options"]
queryset = IndicadorApuracaoResposta.objects.select_related("criterio", "colaborador")
serializer_class = IndicadorApuracaoRespostaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def perform_update(self, serializer: IndicadorApuracaoRespostaSerializer) -> None:
resposta = serializer.save()
indicadores_calculo.recalcula_colaborador(resposta.colaborador)
@action(detail=False, methods=["post"], url_path="aplicar-em-lote")
def aplicar_em_lote(self, request: Request) -> Response:
entrada = IndicadorApuracaoRespostaLoteSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
respostas = list(
IndicadorApuracaoResposta.objects.filter(id__in=dados["resposta_ids"]).select_related("colaborador")
)
if not respostas:
raise ValidationError({"resposta_ids": "Nenhuma resposta encontrada com esses ids."})
colaboradores_afetados = {resposta.colaborador_id: resposta.colaborador for resposta in respostas}
IndicadorApuracaoResposta.objects.filter(id__in=[resposta.id for resposta in respostas]).update(
valor=dados["valor"], ajustado_manualmente=True
)
for colaborador in colaboradores_afetados.values():
indicadores_calculo.recalcula_colaborador(colaborador)
return Response({"atualizadas": len(respostas)})