1565 lines
76 KiB
Python
1565 lines
76 KiB
Python
import csv
|
||
import io
|
||
import os
|
||
import re
|
||
import tempfile
|
||
import zipfile
|
||
from typing import Any, Iterable
|
||
|
||
import holidays
|
||
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,
|
||
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 .planos_saude import matcher as planos_saude_matcher
|
||
from .planos_saude import pipeline as planos_saude_pipeline
|
||
from .planos_saude.leiaute_sistema import CABECALHO as PLANO_SAUDE_CABECALHO
|
||
from .planos_saude.leiaute_sistema import le_planilha_padrao
|
||
from .serializers import (
|
||
AcessoGeralSecaoSerializer,
|
||
AcessoGeralSerializer,
|
||
CategoriaEventoSerializer,
|
||
CompromissoAgendaSerializer,
|
||
DepartamentoSerializer,
|
||
FavoritoSerializer,
|
||
FuncaoTelefoniaSerializer,
|
||
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"{operadora_info['label']} — 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")
|
||
|
||
|
||
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=["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]
|
||
|
||
importacao = ImportacaoPlanoSaude.objects.create(
|
||
operadora=operadora_key,
|
||
nome_operadora=operadora_info["label"],
|
||
tipos_lancamento=tipos,
|
||
custeio_por_tipo=custeio_por_tipo,
|
||
criado_por=request.user,
|
||
planilha_padrao=dados["planilha_padrao"],
|
||
arquivo_operadora=dados["arquivo_operadora"],
|
||
)
|
||
|
||
try:
|
||
resultado = planos_saude_pipeline.processa_importacao(
|
||
operadora_key=operadora_key,
|
||
caminho_arquivo_operadora=importacao.arquivo_operadora.path,
|
||
caminho_planilha_padrao=importacao.planilha_padrao.path,
|
||
tipos_selecionados=tipos,
|
||
custeio_por_tipo=custeio_por_tipo,
|
||
)
|
||
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,
|
||
)
|
||
|
||
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
|
||
|
||
|
||
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, decisão explícita do usuário. 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."""
|
||
|
||
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"]
|
||
tipo = serializer.validated_data["tipo_lancamento"]
|
||
maior_ordem = ImportacaoPlanoSaudeLinha.objects.filter(
|
||
importacao=importacao, tipo_lancamento=tipo
|
||
).aggregate(Max("ordem"))["ordem__max"]
|
||
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
|
||
|
||
|
||
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()
|
||
|
||
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."})
|
||
|
||
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)
|
||
|
||
|
||
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)})
|