portal_publico/portal_api/views.py

3552 lines
173 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 calendar
import csv
import hashlib
import io
import os
import re
import tempfile
import zipfile
from datetime import date, timedelta
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 Avg, Count, DurationField, ExpressionWrapper, F, 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.utils.dateparse import parse_date
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 .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
from .models import (
AcessoGeral,
AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento,
CompromissoAgenda,
ContabilAchado,
ContabilApuracao,
ContabilConta,
ContabilLinhaDre,
Departamento,
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeArquivoOperadora,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
ImportacaoPlanoSaudeDePaula,
ImportacaoPlanoSaudeDePaulaAlteracao,
ImportacaoPlanoSaudeDePaulaArquivoOperadora,
ImportacaoPlanoSaudeDePaulaAuditoria,
ImportacaoPlanoSaudeDePaulaLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
IndicadorApuracaoResposta,
IndicadorCriterio,
IndicadorDepartamento,
IndicadorDepartamentoGerente,
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NaoConformidadeImportacao,
NCAcao,
NCAcompanhamento,
NCOcorrencia,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
RegraCusteioPlanoSaudeDePaula,
TelefoneExterno,
Usuario,
VinculoNomeOperadora,
VinculoNomeOperadoraDePaula,
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 .nao_conformidades import classificacao as nc_classificacao
from .nao_conformidades import diff as nc_diff
from .nao_conformidades import pipeline as nc_pipeline
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.modelos import LinhaSistema
from .planos_saude.modelos import VinculoNome as VinculoNomePuro
from .planos_saude.modelos import normaliza_nome
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,
AjudaAplicacaoSerializer,
CategoriaEventoSerializer,
CompromissoAgendaSerializer,
ContabilAchadoAjusteSerializer,
ContabilAchadoSerializer,
ContabilApuracaoCreateSerializer,
ContabilApuracaoDetailSerializer,
ContabilApuracaoListSerializer,
ContabilContaSerializer,
ContabilLinhaDreSerializer,
DepartamentoSerializer,
FavoritoSerializer,
FuncaoTelefoniaSerializer,
ImportacaoPlanoSaudeAlteracaoSerializer,
ImportacaoPlanoSaudeAuditoriaSerializer,
ImportacaoPlanoSaudeCreateSerializer,
ImportacaoPlanoSaudeDetailSerializer,
ImportacaoPlanoSaudeLinhaCreateSerializer,
ImportacaoPlanoSaudeLinhaSerializer,
ImportacaoPlanoSaudeListSerializer,
ImportacaoPlanoSaudeDePaulaAlteracaoSerializer,
ImportacaoPlanoSaudeDePaulaAuditoriaSerializer,
ImportacaoPlanoSaudeDePaulaCreateSerializer,
ImportacaoPlanoSaudeDePaulaDetailSerializer,
ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer,
ImportacaoPlanoSaudeDePaulaLinhaSerializer,
ImportacaoPlanoSaudeDePaulaListSerializer,
IndicadorApuracaoAjusteDepartamentoSerializer,
IndicadorApuracaoAjusteGrupoSerializer,
IndicadorApuracaoAjusteHonorarioEmpresaSerializer,
IndicadorApuracaoColaboradorAjusteSerializer,
IndicadorApuracaoColaboradorSerializer,
IndicadorApuracaoColaboradorValidadoSerializer,
IndicadorApuracaoCreateSerializer,
IndicadorApuracaoDetailSerializer,
IndicadorApuracaoEmpresaAjusteSerializer,
IndicadorApuracaoEmpresaSerializer,
IndicadorApuracaoEmpresaTrocarResponsavelSerializer,
IndicadorApuracaoListSerializer,
IndicadorApuracaoRecalcularDepartamentoSerializer,
IndicadorApuracaoRecalcularGrupoSerializer,
IndicadorApuracaoRespostaLoteSerializer,
IndicadorApuracaoRespostaSerializer,
IndicadorCriterioSerializer,
IndicadorDepartamentoGerenteSerializer,
IndicadorDepartamentoSerializer,
IndicadorPercentualTipoSerializer,
LinkFerramentaFavoritoSerializer,
LinkFerramentaSerializer,
NaoConformidadeImportacaoCreateSerializer,
NaoConformidadeImportacaoSerializer,
NCAcaoDetailSerializer,
NCAcaoSerializer,
NCOcorrenciaDetailSerializer,
NCOcorrenciaSerializer,
NotificacaoDispensadaSerializer,
ParametroFiscalCustoContratacaoSerializer,
PerfilAcessoSerializer,
PerfilResumoSerializer,
RamalAusenciaSerializer,
RamalSerializer,
RegraCusteioPlanoSaudeSerializer,
RegraCusteioPlanoSaudeDePaulaSerializer,
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(),
"eh_perfil_inovacao": usuario.eh_perfil_inovacao(),
"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)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def ajuda_aplicacao_view(request: Request, app_key: str) -> Response:
"""Texto de "Mais informações" de uma aplicação (botão de interrogação ao
lado do nome, ver CLAUDE.md) — visualização livre a qualquer autenticado;
edição restrita a quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado,
checagem fixa (`Usuario.eh_perfil_inovacao()`), não uma flag na árvore de
permissões."""
ajuda = AjudaAplicacao.para_app(app_key)
if request.method == "GET":
return Response(AjudaAplicacaoSerializer(ajuda).data)
if not request.user.eh_perfil_inovacao():
raise PermissionDenied("Somente o perfil Inovação pode editar este texto.")
serializer = AjudaAplicacaoSerializer(ajuda, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save(atualizado_por=request.user)
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). O sufixo do
arquivo temporário precisa refletir a extensão real do upload (não só
".pdf"/".csv" fixos) porque alguns parsers dependem dela pra abrir o
arquivo — `openpyxl.load_workbook` (ex.: SulAmérica 5775, .xlsx) recusa
abrir um arquivo cujo sufixo não seja .xlsx/.xlsm/.xltx/.xltm, mesmo que
o conteúdo seja válido (bug real: um .xlsx salvo com sufixo ".csv"
levantava `InvalidFileException`, fazendo a pré-validação sempre falhar
pra essa operadora com uma mensagem genérica de "arquivo não reconhecido").
"""
sufixo = os.path.splitext(arquivo.name or "")[1] or ".tmp"
caminho = _salva_arquivo_temporario(arquivo, sufixo)
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
try:
parser = operadora_info["parser"]()
individuos, _ = parser.extrai(caminho)
# Algumas operadoras (ex.: Unimed Cascavel) não devolvem os dados de
# um arquivo direto em extrai() — retêm num estado interno e só
# resolvem em finaliza() (OperadoraParser.finaliza(), chamado pelo
# pipeline depois de ver TODOS os arquivos da importação real, pra
# decidir entre fontes de dado que se sobrepõem sem contar em
# dobro). Chamado aqui também, com o arquivo sozinho, só pra essa
# pré-validação enxergar que algo FOI encontrado nele — mesmo que a
# resolução completa (casamento por família) só aconteça de verdade
# quando os demais arquivos da importação também estiverem
# presentes. Não tem efeito nenhum pra quem não sobrescreve
# finaliza() (devolve sempre vazio).
individuos_finais, auditoria_final = parser.finaliza()
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)
total_encontrado = len(individuos) + len(individuos_finais) + len(auditoria_final)
if not total_encontrado:
return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."}
return {"valido": True, "mensagem": f"{total_encontrado} 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.'}
)
def _carrega_vinculos_por_nome(
operadora_key: str, linhas_sistema_template: list[LinhaSistema]
) -> dict[str, VinculoNomePuro]:
"""Monta o "DE/PARA" (ver VinculoNomeOperadora em models.py e
"Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) pra passar em
`planos_saude_pipeline.processa_importacao(vinculos_por_nome=...)` —
busca todo VinculoNomeOperadora desta operadora cujo `codigo_empresa`
(normalizado) apareça em algum `LinhaSistema` da planilha padrão desta
importação, e devolve um dict {nome_arquivo_operadora (já normalizado):
VinculoNomePuro} pronto pro matcher.py (pacote sem ORM, por isso o dict
é montado aqui, não lá). Uma planilha normalmente tem um único
`codigo_empresa`, mas o filtro cobre todos os distintos por segurança."""
codigos = {
normalizar_codigo_empresa(linha.codigo_empresa)
for linha in linhas_sistema_template
if linha.codigo_empresa
}
if not codigos:
return {}
vinculos = VinculoNomeOperadora.objects.filter(operadora=operadora_key)
return {
vinculo.nome_arquivo_operadora: VinculoNomePuro(
id=vinculo.id,
nome_func_destino=vinculo.nome_func_destino,
nome_dependente_destino=vinculo.nome_dependente_destino,
)
for vinculo in vinculos
if normalizar_codigo_empresa(vinculo.codigo_empresa) in codigos
}
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 a planilha padrão e todos os arquivos da operadora
(`arquivos_operadora`, um ou mais) 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:
for arquivo in instance.arquivos_operadora.all():
arquivo.arquivo.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,
)
arquivos_operadora = [
ImportacaoPlanoSaudeArquivoOperadora.objects.create(importacao=importacao, arquivo=arquivo, ordem=ordem)
for ordem, arquivo in enumerate(dados["arquivo_operadora"])
]
def _limpa_arquivos_orfaos() -> None:
for arquivo in arquivos_operadora:
arquivo.arquivo.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
try:
if planilha_padrao_arquivo:
linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path)
vinculos_por_nome = _carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)
resultado = planos_saude_pipeline.processa_importacao(
operadora_key=operadora_key,
caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
linhas_sistema_template=linhas_sistema_template,
tipos_selecionados=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa_key=dados["regra_empresa"] or None,
vinculos_por_nome=vinculos_por_nome,
)
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.
_limpa_arquivos_orfaos()
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.
_limpa_arquivos_orfaos()
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a 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):
_limpa_arquivos_orfaos()
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_specs = [
(tipo, ordem, linha)
for tipo, linhas in resultado.linhas_por_tipo.items()
for ordem, linha in enumerate(linhas)
]
linhas_bulk = ImportacaoPlanoSaudeLinha.objects.bulk_create([
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, ordem, linha in linhas_specs
])
# Correlaciona cada VinculoAplicado (matcher.py, sem `id` de banco no
# momento em que resolveu o casamento) com a ImportacaoPlanoSaudeLinha
# já persistida — `indice_linha` é o mesmo índice usado como `ordem`
# acima, dentro do mesmo tipo_lancamento (ver VinculoAplicado em
# planos_saude/modelos.py). Gera um ImportacaoPlanoSaudeAlteracao por
# vínculo aplicado, exibido na aba "Alterações" com um botão "Apagar
# vínculo" (ver ImportacaoPlanoSaudeAlteracaoViewSet.reverter).
linha_por_tipo_ordem = {
(tipo, ordem): linha_obj for (tipo, ordem, _), linha_obj in zip(linhas_specs, linhas_bulk)
}
alteracoes_vinculo_bulk = []
for vinculo_aplicado in resultado.vinculos_aplicados:
linha_obj = linha_por_tipo_ordem.get((vinculo_aplicado.tipo_lancamento, vinculo_aplicado.indice_linha))
if linha_obj is None:
continue
alteracoes_vinculo_bulk.append(ImportacaoPlanoSaudeAlteracao(
importacao=importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO,
linha=linha_obj,
tipo_lancamento=vinculo_aplicado.tipo_lancamento,
valor_novo=vinculo_aplicado.nome_arquivo_operadora,
dados_linha=_snapshot_linha_plano_saude(linha_obj),
vinculo_nome_id=vinculo_aplicado.vinculo_id,
usuario=request.user,
))
ImportacaoPlanoSaudeAlteracao.objects.bulk_create(alteracoes_vinculo_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`; vínculo automático de nome
(botão "Apagar vínculo" na aba Alterações) zera o valor lançado nessa
linha (redistribuindo a regra empresa da família de novo, se
aplicável — mesma lógica de `_recalcula_familia_regra_empresa`) E
apaga o `VinculoNomeOperadora` (ver "Vínculos de nome salvos
(DE/PARA)" no CLAUDE.md), pra essa divergência voltar a cair em
auditoria numa importação futura em vez de ser reaplicada sozinha.
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,
)
elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO:
if not alteracao.linha_id:
raise ValidationError({"detail": "A linha desta alteração não existe mais."})
alteracao.linha.valor_empresa = "0"
alteracao.linha.valor = "0"
alteracao.linha.save(update_fields=["valor_empresa", "valor"])
if alteracao.tipo_lancamento == "mensalidade" and alteracao.importacao.regra_empresa:
_recalcula_familia_regra_empresa(alteracao.importacao, alteracao.linha)
if alteracao.vinculo_nome_id:
alteracao.vinculo_nome.delete()
alteracao.vinculo_nome = None
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` — o
próprio `linha.tipo_lancamento`, não mais fixo em "mensalidade" desde
que a regra empresa passou a poder cobrir também "coparticipacao", ver
REGRAS_EMPRESA) — chamado depois de "Vincular pessoa"
(ImportacaoPlanoSaudeAuditoriaViewSet.resolver) resolver um item de
auditoria numa importação com `regra_empresa` configurada: o valor
recém-vinculado muda o total da família, então a regra precisa ser
reaplicada a 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 do lançamento, 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=linha.tipo_lancamento, 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, linha.tipo_lancamento)
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.
Também grava (ou atualiza) um `VinculoNomeOperadora` — o "DE/PARA"
(ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) que faz essa
MESMA divergência ser resolvida automaticamente em importações
futuras da mesma operadora+empresa, sem precisar vincular de novo
(`ImportacaoPlanoSaudeViewSet.create`/`_carrega_vinculos_por_nome`).
Continua não sendo aproximação — só existe porque este humano
confirmou explicitamente esta divergência agora."""
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."})
regra_empresa_dados = (
planos_saude_regras_empresa.REGRAS_EMPRESA.get(item.importacao.regra_empresa)
if item.importacao.regra_empresa
else None
)
regra_empresa_cobre_este_tipo = bool(
regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",))
)
if regra_empresa_cobre_este_tipo:
# "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"])
codigo_empresa_norm = normalizar_codigo_empresa(linha.codigo_empresa)
if codigo_empresa_norm:
destino = {"nome_func_destino": "", "nome_dependente_destino": ""}
if item.tipo == "T":
destino["nome_func_destino"] = linha.nome_func
else:
destino["nome_dependente_destino"] = linha.nome_dependente
VinculoNomeOperadora.objects.update_or_create(
operadora=item.importacao.operadora,
codigo_empresa=codigo_empresa_norm,
nome_arquivo_operadora=normaliza_nome(item.nome),
defaults={**destino, "criado_por": request.user},
)
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)})
# --- "Importação de Plano de Saúde - De Paula" ----------------------------
# Mesmas views acima, sobre os models "DePaula" (tabelas próprias, plano de
# saúde dos colaboradores do escritório, não de clientes — ver CLAUDE.md em
# portal_api/planos_saude/), com permissão independente
# (PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")). O
# pipeline de extração (planos_saude_pipeline/matcher/regras_empresa) e os
# helpers puros (_salva_arquivo_temporario, _valida_planilha_padrao,
# _valida_arquivo_operadora, _monta_csv_linhas_plano_saude,
# _snapshot_linha_plano_saude) são reaproveitados sem duplicação.
def _garante_importacao_de_paula_em_revisao(importacao: ImportacaoPlanoSaudeDePaula) -> None:
"""Ver `_garante_importacao_em_revisao` — mesma trava, escopo De Paula."""
if importacao.status == ImportacaoPlanoSaudeDePaula.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": 'Esta importação já foi concluída. Clique em "Editar" para reabri-la antes de alterar.'}
)
def _carrega_vinculos_por_nome_de_paula(
operadora_key: str, linhas_sistema_template: list[LinhaSistema]
) -> dict[str, VinculoNomePuro]:
"""Ver `_carrega_vinculos_por_nome` — mesmo DE/PARA, lendo de
`VinculoNomeOperadoraDePaula` (tabela própria, nunca cruza com o DE/PARA
da aplicação original)."""
codigos = {
normalizar_codigo_empresa(linha.codigo_empresa)
for linha in linhas_sistema_template
if linha.codigo_empresa
}
if not codigos:
return {}
vinculos = VinculoNomeOperadoraDePaula.objects.filter(operadora=operadora_key)
return {
vinculo.nome_arquivo_operadora: VinculoNomePuro(
id=vinculo.id,
nome_func_destino=vinculo.nome_func_destino,
nome_dependente_destino=vinculo.nome_dependente_destino,
)
for vinculo in vinculos
if normalizar_codigo_empresa(vinculo.codigo_empresa) in codigos
}
class ImportacaoPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
"""Ver `ImportacaoPlanoSaudeViewSet` — mesmo fluxo, escopo De Paula."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ImportacaoPlanoSaudeDePaula.objects.all().order_by("-criado_em")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
def perform_destroy(self, instance: ImportacaoPlanoSaudeDePaula) -> None:
for arquivo in instance.arquivos_operadora.all():
arquivo.arquivo.delete(save=False)
instance.planilha_padrao.delete(save=False)
instance.delete()
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return ImportacaoPlanoSaudeDePaulaListSerializer
return ImportacaoPlanoSaudeDePaulaDetailSerializer
@action(detail=False, methods=["get"])
def operadoras(self, request: Request) -> Response:
return Response(planos_saude_pipeline.lista_operadoras())
@action(detail=False, methods=["get"], url_path="regras-empresa")
def regras_empresa(self, request: Request) -> Response:
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:
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 = ImportacaoPlanoSaudeDePaulaCreateSerializer(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:
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 = ImportacaoPlanoSaudeDePaula.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,
)
arquivos_operadora = [
ImportacaoPlanoSaudeDePaulaArquivoOperadora.objects.create(
importacao=importacao, arquivo=arquivo, ordem=ordem
)
for ordem, arquivo in enumerate(dados["arquivo_operadora"])
]
def _limpa_arquivos_orfaos() -> None:
for arquivo in arquivos_operadora:
arquivo.arquivo.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
try:
if planilha_padrao_arquivo:
linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path)
vinculos_por_nome = _carrega_vinculos_por_nome_de_paula(operadora_key, linhas_sistema_template)
resultado = planos_saude_pipeline.processa_importacao(
operadora_key=operadora_key,
caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
linhas_sistema_template=linhas_sistema_template,
tipos_selecionados=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa_key=dados["regra_empresa"] or None,
vinculos_por_nome=vinculos_por_nome,
)
except RegraEmpresaIncompativelError as exc:
_limpa_arquivos_orfaos()
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
_limpa_arquivos_orfaos()
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
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]
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):
_limpa_arquivos_orfaos()
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_specs = [
(tipo, ordem, linha)
for tipo, linhas in resultado.linhas_por_tipo.items()
for ordem, linha in enumerate(linhas)
]
linhas_bulk = ImportacaoPlanoSaudeDePaulaLinha.objects.bulk_create([
ImportacaoPlanoSaudeDePaulaLinha(
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, ordem, linha in linhas_specs
])
linha_por_tipo_ordem = {
(tipo, ordem): linha_obj for (tipo, ordem, _), linha_obj in zip(linhas_specs, linhas_bulk)
}
alteracoes_vinculo_bulk = []
for vinculo_aplicado in resultado.vinculos_aplicados:
linha_obj = linha_por_tipo_ordem.get((vinculo_aplicado.tipo_lancamento, vinculo_aplicado.indice_linha))
if linha_obj is None:
continue
alteracoes_vinculo_bulk.append(ImportacaoPlanoSaudeDePaulaAlteracao(
importacao=importacao,
tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_VINCULO_AUTOMATICO,
linha=linha_obj,
tipo_lancamento=vinculo_aplicado.tipo_lancamento,
valor_novo=vinculo_aplicado.nome_arquivo_operadora,
dados_linha=_snapshot_linha_plano_saude(linha_obj),
vinculo_nome_id=vinculo_aplicado.vinculo_id,
usuario=request.user,
))
ImportacaoPlanoSaudeDePaulaAlteracao.objects.bulk_create(alteracoes_vinculo_bulk)
auditoria_bulk = [
ImportacaoPlanoSaudeDePaulaAuditoria(
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
]
ImportacaoPlanoSaudeDePaulaAuditoria.objects.bulk_create(auditoria_bulk)
serializer = ImportacaoPlanoSaudeDePaulaDetailSerializer(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:
importacao = self.get_object()
linhas_por_tipo: dict[str, list[ImportacaoPlanoSaudeDePaulaLinha]] = {}
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 = ImportacaoPlanoSaudeDePaula.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_de_paula_{importacao.id}.zip"'
return response
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
importacao = self.get_object()
importacao.status = ImportacaoPlanoSaudeDePaula.STATUS_REVISAO
importacao.concluida_em = None
importacao.save(update_fields=["status", "concluida_em"])
serializer = ImportacaoPlanoSaudeDePaulaDetailSerializer(importacao)
return Response(serializer.data)
class ImportacaoPlanoSaudeDePaulaLinhaViewSet(viewsets.ModelViewSet):
"""Ver `ImportacaoPlanoSaudeLinhaViewSet` — mesmo fluxo, escopo De Paula."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = ImportacaoPlanoSaudeDePaulaLinha.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "create":
return ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer
return ImportacaoPlanoSaudeDePaulaLinhaSerializer
def perform_create(self, serializer: ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer) -> None:
importacao = serializer.validated_data["importacao"]
_garante_importacao_de_paula_em_revisao(importacao)
tipo = serializer.validated_data["tipo_lancamento"]
maior_ordem = ImportacaoPlanoSaudeDePaulaLinha.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)
ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
importacao=importacao,
tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_INCLUSAO,
linha=linha,
tipo_lancamento=tipo,
dados_linha=_snapshot_linha_plano_saude(linha),
usuario=self.request.user,
)
def perform_update(self, serializer: ImportacaoPlanoSaudeDePaulaLinhaSerializer) -> None:
linha_anterior = serializer.instance
_garante_importacao_de_paula_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():
ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
importacao=linha.importacao,
tipo=ImportacaoPlanoSaudeDePaulaAlteracao.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: ImportacaoPlanoSaudeDePaulaLinha) -> None:
_garante_importacao_de_paula_em_revisao(instance.importacao)
ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
importacao=instance.importacao,
tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EXCLUSAO,
tipo_lancamento=instance.tipo_lancamento,
dados_linha=_snapshot_linha_plano_saude(instance),
usuario=self.request.user,
)
instance.delete()
class ImportacaoPlanoSaudeDePaulaAlteracaoViewSet(viewsets.GenericViewSet):
"""Ver `ImportacaoPlanoSaudeAlteracaoViewSet` — mesma reversão, escopo De Paula."""
http_method_names = ["post", "head", "options"]
queryset = ImportacaoPlanoSaudeDePaulaAlteracao.objects.all()
serializer_class = ImportacaoPlanoSaudeDePaulaAlteracaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
@action(detail=True, methods=["post"])
def reverter(self, request: Request, pk: str | None = None) -> Response:
alteracao = self.get_object()
_garante_importacao_de_paula_em_revisao(alteracao.importacao)
if alteracao.revertida:
raise ValidationError({"detail": "Esta alteração já foi revertida."})
if alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.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 == ImportacaoPlanoSaudeDePaulaAlteracao.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 == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EXCLUSAO:
dados = dict(alteracao.dados_linha or {})
alteracao.linha = ImportacaoPlanoSaudeDePaulaLinha.objects.create(
importacao=alteracao.importacao,
tipo_lancamento=alteracao.tipo_lancamento,
**dados,
)
elif alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_VINCULO_AUTOMATICO:
if not alteracao.linha_id:
raise ValidationError({"detail": "A linha desta alteração não existe mais."})
alteracao.linha.valor_empresa = "0"
alteracao.linha.valor = "0"
alteracao.linha.save(update_fields=["valor_empresa", "valor"])
if alteracao.tipo_lancamento == "mensalidade" and alteracao.importacao.regra_empresa:
_recalcula_familia_regra_empresa_de_paula(alteracao.importacao, alteracao.linha)
if alteracao.vinculo_nome_id:
alteracao.vinculo_nome.delete()
alteracao.vinculo_nome = None
alteracao.revertida = True
alteracao.revertida_em = timezone.now()
alteracao.save()
return Response(ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(alteracao).data)
def _recalcula_familia_regra_empresa_de_paula(
importacao: ImportacaoPlanoSaudeDePaula, linha: ImportacaoPlanoSaudeDePaulaLinha
) -> None:
"""Ver `_recalcula_familia_regra_empresa` — mesma redistribuição por
família, escopo De Paula (`bulk_update` precisa do model certo)."""
regra = planos_saude_regras_empresa.REGRAS_EMPRESA.get(importacao.regra_empresa)
if not regra:
return
linhas_familia = list(
importacao.linhas.filter(
tipo_lancamento=linha.tipo_lancamento, 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, linha.tipo_lancamento)
ImportacaoPlanoSaudeDePaulaLinha.objects.bulk_update(linhas_familia, ["valor_empresa", "valor"])
class ImportacaoPlanoSaudeDePaulaAuditoriaViewSet(viewsets.GenericViewSet):
"""Ver `ImportacaoPlanoSaudeAuditoriaViewSet` — mesma resolução manual,
escopo De Paula (grava em `VinculoNomeOperadoraDePaula`, nunca no DE/PARA
da aplicação original)."""
http_method_names = ["post", "head", "options"]
queryset = ImportacaoPlanoSaudeDePaulaAuditoria.objects.all()
serializer_class = ImportacaoPlanoSaudeDePaulaAuditoriaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
@action(detail=True, methods=["post"])
def resolver(self, request: Request, pk: str | None = None) -> Response:
item = self.get_object()
_garante_importacao_de_paula_em_revisao(item.importacao)
if item.motivo not in ImportacaoPlanoSaudeDePaulaAuditoria.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(
ImportacaoPlanoSaudeDePaulaLinha, 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_empresa_dados = (
planos_saude_regras_empresa.REGRAS_EMPRESA.get(item.importacao.regra_empresa)
if item.importacao.regra_empresa
else None
)
regra_empresa_cobre_este_tipo = bool(
regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",))
)
if regra_empresa_cobre_este_tipo:
linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor))
linha.save(update_fields=["valor_empresa", "valor"])
_recalcula_familia_regra_empresa_de_paula(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"])
codigo_empresa_norm = normalizar_codigo_empresa(linha.codigo_empresa)
if codigo_empresa_norm:
destino = {"nome_func_destino": "", "nome_dependente_destino": ""}
if item.tipo == "T":
destino["nome_func_destino"] = linha.nome_func
else:
destino["nome_dependente_destino"] = linha.nome_dependente
VinculoNomeOperadoraDePaula.objects.update_or_create(
operadora=item.importacao.operadora,
codigo_empresa=codigo_empresa_norm,
nome_arquivo_operadora=normaliza_nome(item.nome),
defaults={**destino, "criado_por": request.user},
)
return Response(ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(item).data)
class RegraCusteioPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
"""Ver `RegraCusteioPlanoSaudeViewSet` — mesmo cadastro, escopo De Paula."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = RegraCusteioPlanoSaudeDePaula.objects.all()
serializer_class = RegraCusteioPlanoSaudeDePaulaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
def perform_create(self, serializer: RegraCusteioPlanoSaudeDePaulaSerializer) -> None:
serializer.save(criado_por=self.request.user)
@action(detail=False, methods=["get"], url_path="nome-empresa")
def nome_empresa(self, request: Request) -> Response:
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)})
# ===================== Não Conformidades (Relatórios > Qualidade) =====================
def _campos_nc_ocorrencia(ocorrencia_extraida: Any) -> dict[str, Any]:
"""Campos "crus" vindos do export — nunca inclui os campos de tratativa
interna (status_tratativa/tratado_*/snapshot_tratativa/reaberto_*), que só
mudam via as actions marcar-tratado/reabrir ou pela lógica de reabertura
automática abaixo."""
return {
"data_emissao": ocorrencia_extraida.data_emissao,
"assunto": ocorrencia_extraida.assunto,
"tipo_ocorrencia": ocorrencia_extraida.tipo_ocorrencia,
"pessoas_relacionadas": ocorrencia_extraida.pessoas_relacionadas,
"origem": ocorrencia_extraida.origem,
"fornecedores_relacionados": ocorrencia_extraida.fornecedores_relacionados,
"clientes_relacionados": ocorrencia_extraida.clientes_relacionados,
"area": ocorrencia_extraida.area,
"setor": ocorrencia_extraida.setor,
"riscos_relacionados": ocorrencia_extraida.riscos_relacionados,
"data_relato": ocorrencia_extraida.data_relato,
"relato": ocorrencia_extraida.relato,
"emissor_relato": ocorrencia_extraida.emissor_relato,
"representante_gerente": ocorrencia_extraida.representante_gerente,
"prazo_finalizar": ocorrencia_extraida.prazo_finalizar,
"indicado_analise": ocorrencia_extraida.indicado_analise,
"tipos_causa": ocorrencia_extraida.tipos_causa,
"descricao_analise": ocorrencia_extraida.descricao_analise,
"responsavel_analise": ocorrencia_extraida.responsavel_analise,
"data_analise": ocorrencia_extraida.data_analise,
"analise_sem_acao_detectada": ocorrencia_extraida.tem_linha_analise_sem_acao,
"data_finalizacao": ocorrencia_extraida.data_finalizacao,
"fase": ocorrencia_extraida.fase,
}
def _campos_nc_acao(acao_extraida: Any) -> dict[str, Any]:
ultimo = acao_extraida.ultimo_acompanhamento
return {
"data_emissao": acao_extraida.data_emissao,
"tipo_acao": acao_extraida.tipo_acao,
"acao_texto": acao_extraida.acao_texto,
"data_conclusao": acao_extraida.data_conclusao,
"prazo_prorrogado": acao_extraida.prazo_prorrogado,
"justificativa_prorrogacao": acao_extraida.justificativa_prorrogacao,
"executor": acao_extraida.executor,
"emissor_acao": acao_extraida.emissor_acao,
"indicado_autorizar": acao_extraida.indicado_autorizar,
"responsavel_autorizacao": acao_extraida.responsavel_autorizacao,
"data_finalizacao": acao_extraida.data_finalizacao,
"dias_finalizacao": acao_extraida.dias_finalizacao,
"situacao": acao_extraida.situacao,
"fase": acao_extraida.fase,
"vencimento_efetivo": acao_extraida.vencimento_efetivo,
"ultimo_acompanhamento_em": ultimo.data if ultimo else None,
"ultimo_acompanhamento_eh_prorrogacao": ultimo.eh_prorrogacao if ultimo else False,
}
def _hash_acompanhamento(entrada: Any) -> str:
bruto = f"{entrada.data.isoformat()}|{entrada.autor}|{entrada.texto}"
return hashlib.sha256(bruto.encode("utf-8")).hexdigest()
def _snapshot_nc_ocorrencia_persistida(ocorrencia: NCOcorrencia) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_ocorrencia()`, mas a
partir do que já está salvo no banco — usado quando a Qualidade marca uma
ocorrência como tratada manualmente pela tela (sem ter o dataclass
recém-extraído de uma importação à mão)."""
return {
"tem_analise": bool(ocorrencia.descricao_analise.strip()),
"hash_analise": nc_diff.hash_texto(ocorrencia.descricao_analise),
"codigos_acao": sorted(ocorrencia.acoes.values_list("codigo", flat=True)),
}
def _snapshot_nc_acao_persistida(acao: NCAcao) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_acao()`, a partir do
que já está salvo no banco (ver `_snapshot_nc_ocorrencia_persistida`)."""
return {
"ultimo_acompanhamento_em": acao.ultimo_acompanhamento_em.isoformat()
if acao.ultimo_acompanhamento_em
else None,
"situacao": acao.situacao,
"fase": acao.fase,
"prazo_prorrogado": acao.prazo_prorrogado.isoformat() if acao.prazo_prorrogado else None,
"data_conclusao": acao.data_conclusao.isoformat() if acao.data_conclusao else None,
}
def _aplica_upsert_nao_conformidades(resultado: Any, agora: Any) -> dict[str, Any]:
"""Upsert de NCOcorrencia/NCAcao/NCAcompanhamento a partir do resultado do
pipeline — em lote (`bulk_create`/`bulk_update`), não um `.save()` por
item. Uma exportação real do Sigsistem já chegou a ~1700 ocorrências/
~3400 ações/~9000 acompanhamentos — a versão anterior (um
`update_or_create`/`get_or_create` por item) fazia ~2 idas ao banco por
item, quase 28 mil no total, ~60s numa máquina local. Isso não é só
lento: em produção (gunicorn atrás de nginx, timeout padrão de worker de
30s) uma requisição de 60s é derrubada no meio, e o navegador só vê uma
resposta genérica de erro — sem nenhum problema real de dado por trás.
Aqui viram ~8 idas ao banco no total: pré-carrega o que já existe (2
querysets), aplica tudo em lote (`bulk_create`/`bulk_update` por model,
com `batch_size` pra não estourar o limite de parâmetros de uma única
query do Postgres num lote desse tamanho).
A decisão de reabertura (`nao_conformidades.diff`) continua rodando por
item em memória — é só comparação de dicts/hash, não é o gargalo; só a
gravação em si virou lote. É por isso que a view (não o pacote puro)
continua sendo dona desta função: ela precisa saber o que já está
persistido pra decidir "novo" vs. "atualizar" vs. "reabrir"."""
resumo: dict[str, Any] = {
"ocorrencias_novas": 0,
"ocorrencias_atualizadas": 0,
"acoes_novas": 0,
"acoes_atualizadas": 0,
"acompanhamentos_novos": 0,
"itens_reabertos": [],
}
# ---------- Ocorrências ----------
ocorrencias_existentes = {
oc.codigo: oc
for oc in NCOcorrencia.objects.filter(codigo__in=[o.codigo for o in resultado.ocorrencias])
}
ocorrencias_novas: list[NCOcorrencia] = []
ocorrencias_para_atualizar: list[NCOcorrencia] = []
mapa_ocorrencias: dict[int, NCOcorrencia] = {}
for ocorrencia_extraida in resultado.ocorrencias:
campos = _campos_nc_ocorrencia(ocorrencia_extraida)
existente = ocorrencias_existentes.get(ocorrencia_extraida.codigo)
if existente is None:
nova = NCOcorrencia(codigo=ocorrencia_extraida.codigo, ultima_importacao_em=agora, **campos)
ocorrencias_novas.append(nova)
mapa_ocorrencias[ocorrencia_extraida.codigo] = nova
resumo["ocorrencias_novas"] += 1
continue
mapa_ocorrencias[ocorrencia_extraida.codigo] = existente
# Reimportações periódicas trazem de volta o histórico inteiro, não só
# o que mudou — pular quem não mudou nada evita reescrever milhares
# de linhas idênticas a cada importação (é o caso comum; só uma
# fração pequena do arquivo costuma ter novidade real).
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCOcorrencia.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_ocorrencia(
existente.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida)
)
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCOcorrencia.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "ocorrencia", "codigo": existente.codigo, "motivo": motivo_reabertura}
)
ocorrencias_para_atualizar.append(existente)
resumo["ocorrencias_atualizadas"] += 1
if ocorrencias_novas:
NCOcorrencia.objects.bulk_create(ocorrencias_novas, batch_size=500)
if ocorrencias_para_atualizar:
# Lista de campos derivada do próprio dict de `_campos_nc_ocorrencia`
# (nunca hardcoded solta) — impossível ficar dessincronizada se
# aquela função ganhar/perder um campo no futuro.
campos_bulk = list(_campos_nc_ocorrencia(resultado.ocorrencias[0]).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCOcorrencia.objects.bulk_update(ocorrencias_para_atualizar, campos_bulk, batch_size=500)
# ---------- Ações ----------
acoes_existentes = {
(a.ocorrencia_id, a.codigo): a
for a in NCAcao.objects.filter(ocorrencia_id__in=[oc.id for oc in ocorrencias_existentes.values()])
}
acoes_novas: list[NCAcao] = []
acoes_para_atualizar: list[NCAcao] = []
mapa_acoes: dict[tuple[int, int], NCAcao] = {}
for ocorrencia_extraida in resultado.ocorrencias:
ocorrencia = mapa_ocorrencias[ocorrencia_extraida.codigo]
for acao_extraida in ocorrencia_extraida.acoes:
campos = _campos_nc_acao(acao_extraida)
existente = acoes_existentes.get((ocorrencia.id, acao_extraida.codigo))
if existente is None:
nova = NCAcao(
ocorrencia=ocorrencia, codigo=acao_extraida.codigo, ultima_importacao_em=agora, **campos
)
acoes_novas.append(nova)
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = nova
resumo["acoes_novas"] += 1
continue
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = existente
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCAcao.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_acao(
existente.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida)
)
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCAcao.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{
"tipo": "acao",
"codigo": f"{ocorrencia_extraida.codigo}/{acao_extraida.codigo}",
"motivo": motivo_reabertura,
}
)
acoes_para_atualizar.append(existente)
resumo["acoes_atualizadas"] += 1
if acoes_novas:
NCAcao.objects.bulk_create(acoes_novas, batch_size=500)
if acoes_para_atualizar:
acao_exemplo = next(o.acoes[0] for o in resultado.ocorrencias if o.acoes)
campos_bulk_acao = list(_campos_nc_acao(acao_exemplo).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCAcao.objects.bulk_update(acoes_para_atualizar, campos_bulk_acao, batch_size=500)
# ---------- Acompanhamentos (append-only, deduplicados por hash) ----------
# `bulk_create(... , ignore_conflicts=True)`: ids de ações novas nunca
# colidem com hashes já existentes (pk nova, nunca usada antes) — o
# `ignore_conflicts` é rede de segurança só pra duplicata dentro do
# próprio arquivo importado, não pro caso normal.
hashes_existentes = set(
NCAcompanhamento.objects.filter(acao_id__in=[a.id for a in acoes_existentes.values()]).values_list(
"acao_id", "hash_entrada"
)
)
acompanhamentos_novos: list[NCAcompanhamento] = []
hashes_no_lote: set[tuple[int, str]] = set()
for ocorrencia_extraida in resultado.ocorrencias:
for acao_extraida in ocorrencia_extraida.acoes:
acao = mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)]
for entrada in acao_extraida.acompanhamentos:
hash_entrada = _hash_acompanhamento(entrada)
chave = (acao.id, hash_entrada)
if chave in hashes_existentes or chave in hashes_no_lote:
continue
hashes_no_lote.add(chave)
acompanhamentos_novos.append(
NCAcompanhamento(
acao=acao,
data=entrada.data,
autor=entrada.autor,
texto=entrada.texto,
eh_prorrogacao=entrada.eh_prorrogacao,
hash_entrada=hash_entrada,
)
)
if acompanhamentos_novos:
NCAcompanhamento.objects.bulk_create(acompanhamentos_novos, batch_size=500, ignore_conflicts=True)
resumo["acompanhamentos_novos"] = len(acompanhamentos_novos)
return resumo
class NaoConformidadeImportacaoViewSet(viewsets.ModelViewSet):
"""Ferramenta "Não Conformidades" (Relatórios > Qualidade) — permissão de
toggle único, mesmo espírito de ImportacaoPlanoSaudeViewSet/
IndicadorApuracaoViewSet. `create()` roda `portal_api.nao_conformidades.
pipeline` de forma síncrona sobre os dois arquivos exportados do Sigsistem
e faz o upsert de NCOcorrencia/NCAcao/NCAcompanhamento. Diferente das
outras duas ferramentas, este registro é só um log de auditoria — não é
"dono" das ocorrências/ações (entidades contínuas, upsertadas por código):
`destroy()` remove só o log e os 2 arquivos, nunca as ocorrências/ações já
persistidas (ver NaoConformidadeImportacao em models.py)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = NaoConformidadeImportacao.objects.all()
serializer_class = NaoConformidadeImportacaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def perform_destroy(self, instance: NaoConformidadeImportacao) -> None:
instance.arquivo_ocorrencias.delete(save=False)
instance.arquivo_acoes.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = NaoConformidadeImportacaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
with transaction.atomic():
importacao = NaoConformidadeImportacao.objects.create(
arquivo_ocorrencias=dados["arquivo_ocorrencias"],
arquivo_acoes=dados["arquivo_acoes"],
criado_por=request.user,
)
resultado = nc_pipeline.processa_importacao(
importacao.arquivo_ocorrencias.path, importacao.arquivo_acoes.path
)
resumo = _aplica_upsert_nao_conformidades(resultado, timezone.now())
importacao.resumo = resumo
importacao.avisos = resultado.avisos
importacao.save(update_fields=["resumo", "avisos"])
return Response(NaoConformidadeImportacaoSerializer(importacao).data, status=status.HTTP_201_CREATED)
class NCOcorrenciaViewSet(viewsets.ModelViewSet):
"""Ocorrências persistidas (upsertadas a cada importação, ver
NaoConformidadeImportacaoViewSet.create()) — só leitura + as duas actions
de tratativa da Qualidade; não há create()/edição de campo aqui, os dados
sempre vêm do Sigsistem via importação."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCOcorrencia.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCOcorrenciaDetailSerializer
return NCOcorrenciaSerializer
def get_queryset(self) -> QuerySet[NCOcorrencia]:
queryset = super().get_queryset()
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
if params.get("sem_analise") == "true":
# Exclui ocorrência que o Sigsistem já fechou sem exigir análise
# (ex.: "Elogios de Clientes") — bug real: sem isso, essas
# ficavam "Sem Análise" pra sempre, exigindo "Marcar como
# tratado" manual pra algo que já está resolvido no Sigsistem.
# Duas formas de "já fechada" (a mesma ocorrência real já
# apareceu nas duas): (a) não tem nenhuma ação — nesse caso quem
# carrega "Fase" é a própria ocorrência (ver
# nao_conformidades/leiaute_ocorrencias.py); (b) tem ação(ões), e
# TODAS elas já têm `data_finalizacao` preenchida — nesse caso é
# a ação que carrega a finalização, não a ocorrência.
queryset = (
queryset.filter(descricao_analise="")
.annotate(
total_acoes=Count("acoes", distinct=True),
acoes_abertas=Count("acoes", filter=Q(acoes__data_finalizacao__isnull=True), distinct=True),
)
.exclude(Q(fase=nc_classificacao.FASE_FINALIZADA) | Q(total_acoes__gt=0, acoes_abertas=0))
)
if params.get("analise_sem_acao") == "true":
# Exclui ocorrência já finalizada no Sigsistem sem nunca ter tido
# uma ação (decisão explícita do usuário, mesmo sabendo que boa
# parte são NC/Reclamação de Cliente reais sem Ação Corretiva
# formal — confia que o indicador "NCs sem ação corretiva" do
# dashboard, que não filtra por finalizada, continua cobrindo
# esses casos pra quem olha o Dashboard).
queryset = queryset.filter(analise_sem_acao_detectada=True).exclude(
fase=nc_classificacao.FASE_FINALIZADA
)
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(Q(assunto__icontains=termo) | Q(codigo__icontains=termo))
if self.action == "retrieve":
queryset = queryset.prefetch_related("acoes")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_TRATADO
ocorrencia.tratado_em = timezone.now()
ocorrencia.tratado_por = request.user
ocorrencia.snapshot_tratativa = _snapshot_nc_ocorrencia_persistida(ocorrencia)
ocorrencia.reaberto_em = None
ocorrencia.reaberto_motivo = ""
ocorrencia.save()
return Response(NCOcorrenciaSerializer(ocorrencia).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE
ocorrencia.snapshot_tratativa = None
ocorrencia.reaberto_em = timezone.now()
ocorrencia.reaberto_motivo = "Reaberta manualmente."
ocorrencia.save()
return Response(NCOcorrenciaSerializer(ocorrencia).data)
def _status_prazo_q(bucket: str, hoje: date) -> Q:
"""Devolve a condição de UM bucket como `Q`, pra poder combinar vários
buckets com OR (`_filtra_status_prazo` abaixo) — o frontend permite
marcar mais de um ao mesmo tempo (ex.: "Vencidas" + "Vence em 7 dias").
Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()`
como filtros de data sobre `vencimento_efetivo` (o cálculo em si roda em
Python, não dá pra usar direto num `.filter()`).
Todo bucket de prazo (vencida/vence em X dias/no prazo/sem vencimento)
exclui ações já finalizadas — bug real corrigido: um export com
histórico (`data_finalizacao` preenchida) tem `vencimento_efetivo` no
passado mesmo pra ação já concluída há muito tempo, o que fazia a
esmagadora maioria aparecer como "vencida" sem estar realmente em
aberto."""
if bucket == nc_classificacao.STATUS_FINALIZADA:
return Q(data_finalizacao__isnull=False)
aberta = Q(data_finalizacao__isnull=True)
if bucket == nc_classificacao.STATUS_NAO_FINALIZADA:
return aberta
if bucket == nc_classificacao.STATUS_VENCIDA:
return aberta & Q(vencimento_efetivo__lt=hoje)
if bucket == nc_classificacao.STATUS_VENCE_7:
return aberta & Q(vencimento_efetivo__gte=hoje, vencimento_efetivo__lte=hoje + timedelta(days=7))
if bucket == nc_classificacao.STATUS_VENCE_30:
return aberta & Q(
vencimento_efetivo__gt=hoje + timedelta(days=7), vencimento_efetivo__lte=hoje + timedelta(days=30)
)
if bucket == nc_classificacao.STATUS_NO_PRAZO:
return aberta & Q(vencimento_efetivo__gt=hoje + timedelta(days=30))
if bucket == nc_classificacao.STATUS_SEM_VENCIMENTO:
return aberta & Q(vencimento_efetivo__isnull=True)
raise ValidationError({"status_prazo": "Valor inválido."})
def _filtra_status_prazo(queryset: QuerySet[NCAcao], buckets: list[str], hoje: date) -> QuerySet[NCAcao]:
"""Combina um ou mais buckets de prazo com OR (filtro múltiplo do
frontend — ver `?status_prazo=` repetido em `NCAcaoViewSet.get_queryset`)."""
combinado = Q()
for bucket in buckets:
combinado |= _status_prazo_q(bucket, hoje)
return queryset.filter(combinado)
class NCAcaoViewSet(viewsets.ModelViewSet):
"""Ações persistidas — só leitura + tratativa (mesmo espírito de
NCOcorrenciaViewSet)."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCAcao.objects.select_related("ocorrencia")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCAcaoDetailSerializer
return NCAcaoSerializer
def get_queryset(self) -> QuerySet[NCAcao]:
queryset = super().get_queryset()
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
status_prazo_list = params.getlist("status_prazo")
if status_prazo_list:
queryset = _filtra_status_prazo(queryset, status_prazo_list, timezone.localdate())
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(
Q(acao_texto__icontains=termo)
| Q(executor__icontains=termo)
| Q(ocorrencia__codigo__icontains=termo)
| Q(ocorrencia__clientes_relacionados__icontains=termo)
)
if self.action == "retrieve":
queryset = queryset.prefetch_related("acompanhamentos")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_TRATADO
acao.tratado_em = timezone.now()
acao.tratado_por = request.user
acao.snapshot_tratativa = _snapshot_nc_acao_persistida(acao)
acao.reaberto_em = None
acao.reaberto_motivo = ""
acao.save()
return Response(NCAcaoSerializer(acao).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_PENDENTE
acao.snapshot_tratativa = None
acao.reaberto_em = timezone.now()
acao.reaberto_motivo = "Reaberta manualmente."
acao.save()
return Response(NCAcaoSerializer(acao).data)
def _subtrai_meses(data_ref: date, meses: int) -> date:
mes_total = data_ref.month - 1 - meses
ano = data_ref.year + mes_total // 12
mes = mes_total % 12 + 1
dia = min(data_ref.day, calendar.monthrange(ano, mes)[1])
return date(ano, mes, dia)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def nao_conformidades_dashboard_view(request: Request) -> Response:
"""Indicadores quantitativos de Não Conformidades — contagens "abertas
agora" (ocorrências/ações/sem análise/análise sem ação/NCs sem ação
corretiva) nunca são filtradas por período (são sempre o estado atual);
só os rankings de motivo/cliente/colaborador aceitam `?meses=` (default 6,
generaliza a regra fixa "últimos 6 meses" da skill original) ou
`?data_inicio=&data_fim=` (AAAA-MM-DD) explícitos."""
if not request.user.permissao_app("relatorios", "nao-conformidades"):
raise PermissionDenied("Você não tem permissão para acessar Não Conformidades.")
hoje = timezone.localdate()
data_inicio_param = request.query_params.get("data_inicio")
data_fim_param = request.query_params.get("data_fim")
if data_inicio_param and data_fim_param:
data_inicio = parse_date(data_inicio_param)
data_fim = parse_date(data_fim_param)
if data_inicio is None or data_fim is None:
raise ValidationError({"detail": "data_inicio/data_fim devem estar no formato AAAA-MM-DD."})
else:
try:
meses = int(request.query_params.get("meses") or 6)
except ValueError:
raise ValidationError({"meses": "Informe um número inteiro de meses."})
data_fim = hoje
data_inicio = _subtrai_meses(hoje, meses)
ocorrencias_periodo = NCOcorrencia.objects.filter(data_emissao__gte=data_inicio, data_emissao__lte=data_fim)
status_counts = {chave: 0 for chave in nc_classificacao.STATUS_PRAZO_LABELS}
for venc, finalizada_em in NCAcao.objects.values_list("vencimento_efetivo", "data_finalizacao"):
status_counts[nc_classificacao.status_prazo(venc, hoje, finalizada=bool(finalizada_em))] += 1
ncs_sem_corretiva = 0
for ocorrencia in NCOcorrencia.objects.filter(tipo_ocorrencia__in=nc_classificacao.TIPOS_NC).prefetch_related(
"acoes"
):
categorias = {nc_classificacao.categoria_acao(a.tipo_acao) for a in ocorrencia.acoes.all()}
if nc_classificacao.CATEGORIA_CORRETIVA not in categorias:
ncs_sem_corretiva += 1
motivos = (
ocorrencias_periodo.exclude(tipos_causa="")
.values("tipos_causa")
.annotate(quantidade=Count("id"))
.order_by("-quantidade")[:15]
)
# Clientes/Colaboradores: conta TODOS os tipos de ocorrência (decisão do
# usuário — a clareza vem de quebrar por tipo com cor no frontend, não de
# restringir o filtro a NC/Reclamação como a v1 fazia só pra clientes).
filtro_nc = Count("id", filter=Q(tipo_ocorrencia="Não Conformidade"))
filtro_reclamacao = Count("id", filter=Q(tipo_ocorrencia="Reclamação de Cliente"))
clientes = (
ocorrencias_periodo.exclude(clientes_relacionados="")
.values("clientes_relacionados")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
colaboradores = (
ocorrencias_periodo.exclude(indicado_analise="")
.values("indicado_analise")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
tempo_analise = NCOcorrencia.objects.filter(
data_analise__isnull=False, data_emissao__isnull=False
).aggregate(
media=Avg(ExpressionWrapper(F("data_analise") - F("data_emissao"), output_field=DurationField())),
n=Count("id"),
)
tempo_abertura_acao = NCAcao.objects.filter(
data_emissao__isnull=False, ocorrencia__data_analise__isnull=False
).aggregate(
media=Avg(ExpressionWrapper(F("data_emissao") - F("ocorrencia__data_analise"), output_field=DurationField())),
n=Count("id"),
)
tempo_ciclo_completo = NCAcao.objects.filter(
data_finalizacao__isnull=False, ocorrencia__data_emissao__isnull=False
).aggregate(
media=Avg(
ExpressionWrapper(F("data_finalizacao") - F("ocorrencia__data_emissao"), output_field=DurationField())
),
n=Count("id"),
)
def _dias(intervalo) -> float | None:
return round(intervalo.total_seconds() / 86400, 1) if intervalo is not None else None
# Tempo de execução (abertura → finalização da ação) — só sobre ações com
# Data de Finalização preenchida (ver leiaute_ocorrencias.py). "Correção"/
# "Ação Corretiva" continuam agrupadas pela heurística de
# categoria_acao() (o texto real varia — "Correção", "Correção -
# Financeiro", "Correção - Desconto Indicadores" etc. — mas são a mesma
# categoria pra fins de NC/Reclamação). O resto ("Outros" pra
# categoria_acao()) é detalhado pelo texto real do "Tipo de Ação" em vez
# de virar um "Outros" só — pedido explícito do usuário, pra ver cada
# tipo separado (Oportunidade de Melhoria, Planejamento Estratégico
# etc.), já que esses não têm variação de texto na amostra real.
execucao_dias_por_tipo = NCAcao.objects.filter(
data_finalizacao__isnull=False, data_emissao__isnull=False
).annotate(
dias=ExpressionWrapper(F("data_finalizacao") - F("data_emissao"), output_field=DurationField())
).values_list("tipo_acao", "dias")
todos_dias: list[float] = []
dias_por_categoria: dict[str, list[float]] = {}
for tipo_acao, dias in execucao_dias_por_tipo:
valor = dias.total_seconds() / 86400
todos_dias.append(valor)
categoria = nc_classificacao.categoria_acao(tipo_acao)
chave = categoria if categoria != nc_classificacao.CATEGORIA_OUTROS else (tipo_acao or "Sem tipo informado")
dias_por_categoria.setdefault(chave, []).append(valor)
execucao_por_categoria = []
for categoria in (nc_classificacao.CATEGORIA_CORRECAO, nc_classificacao.CATEGORIA_CORRETIVA):
valores = dias_por_categoria.pop(categoria, None)
if valores:
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)
# O restante (tipos reais fora de Correção/Ação Corretiva), do mais pro
# menos frequente.
for categoria, valores in sorted(dias_por_categoria.items(), key=lambda item: -len(item[1])):
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)
return Response(
{
"ocorrencias_abertas": NCOcorrencia.objects.count(),
"acoes_abertas_por_status": status_counts,
"sem_analise": NCOcorrencia.objects.filter(descricao_analise="").count(),
"analise_sem_acao": NCOcorrencia.objects.filter(analise_sem_acao_detectada=True).count(),
"ncs_sem_acao_corretiva": ncs_sem_corretiva,
"motivos_abertura": [{"causa": m["tipos_causa"], "quantidade": m["quantidade"]} for m in motivos],
"clientes_maior_incidencia": [
{
"cliente": c["clientes_relacionados"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in clientes
],
"colaboradores_maior_incidencia": [
{
"colaborador": c["indicado_analise"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in colaboradores
],
"tempos_medios": {
"preenchimento_analise_dias": _dias(tempo_analise["media"]),
"preenchimento_analise_n": tempo_analise["n"],
"abertura_acao_dias": _dias(tempo_abertura_acao["media"]),
"abertura_acao_n": tempo_abertura_acao["n"],
"execucao_acao_dias": round(sum(todos_dias) / len(todos_dias), 1) if todos_dias else None,
"execucao_acao_n": len(todos_dias),
"execucao_por_categoria": execucao_por_categoria,
"ciclo_completo_dias": _dias(tempo_ciclo_completo["media"]),
"ciclo_completo_n": tempo_ciclo_completo["n"],
},
"periodo": {"data_inicio": data_inicio.isoformat(), "data_fim": data_fim.isoformat()},
}
)
def _contabil_monta_historico(cabecalho: ContabilCabecalhoExtraido) -> list[ContabilSnapshotHistorico]:
"""Histórico da mesma empresa (até 2 apurações anteriores, mais recente
primeiro) já persistidas — usado pelas regras de variação mês a mês
(`dashboard_contabil.regras`). Só uma consulta simples ao ORM, sem
depender de nenhum relatório complementar anexado (ver CLAUDE.md do
pacote)."""
competencia_atual = cabecalho.periodo_fim.replace(day=1)
anteriores = (
ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia__lt=competencia_atual)
.order_by("-competencia")
.prefetch_related("contas", "linhas_dre")[:2]
)
return [
ContabilSnapshotHistorico(
competencia=apuracao.competencia,
contas={conta.codigo: conta.saldo_atual for conta in apuracao.contas.all()},
linhas_dre={linha.descricao: linha.valor for linha in apuracao.linhas_dre.all()},
)
for apuracao in anteriores
]
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
`create()` roda `portal_api.dashboard_contabil.pipeline` de forma síncrona
sobre o PDF anexado: extrai o cabeçalho/contas/DRE, busca o histórico já
persistido da mesma empresa e roda o motor de regras — tudo isso **antes**
de gravar qualquer coisa no banco, já que `codigo_empresa`/`competencia`
(a chave natural da apuração) só são conhecidos depois de ler o PDF, não
informados pelo usuário no upload (diferente de IndicadorApuracao)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ContabilApuracao.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def get_queryset(self) -> QuerySet[ContabilApuracao]:
queryset = super().get_queryset()
if self.action == "retrieve":
queryset = queryset.prefetch_related("contas", "linhas_dre", "achados__conta")
return queryset
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return ContabilApuracaoListSerializer
return ContabilApuracaoDetailSerializer
def perform_destroy(self, instance: ContabilApuracao) -> None:
instance.arquivo.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = ContabilApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
arquivo = entrada.validated_data["arquivo"]
conteudo = arquivo.read()
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico
)
except ContabilExtracaoInvalidaError:
return Response(
{
"detail": (
"O arquivo não está no formato esperado (Balancete + DRE do Questor). "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
cabecalho = resultado.extracao.cabecalho
competencia = cabecalho.periodo_fim.replace(day=1)
if ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia=competencia).exists():
return Response(
{"detail": f"Já existe uma análise para {cabecalho.nome_empresa} na competência {competencia:%m/%Y}."},
status=status.HTTP_400_BAD_REQUEST,
)
apuracao: ContabilApuracao | None = None
try:
with transaction.atomic():
apuracao = ContabilApuracao.objects.create(
codigo_empresa=cabecalho.codigo_empresa,
nome_empresa=cabecalho.nome_empresa,
cnpj=cabecalho.cnpj,
competencia=competencia,
periodo_inicio=cabecalho.periodo_inicio,
periodo_fim=cabecalho.periodo_fim,
arquivo=ContentFile(conteudo, name=arquivo.name),
criado_por=request.user,
)
ContabilConta.objects.bulk_create(
[
ContabilConta(
apuracao=apuracao,
ordem=indice,
conta_numero=conta.conta_numero,
codigo=conta.codigo,
descricao=conta.descricao,
tipo=conta.tipo,
saldo_anterior=conta.saldo_anterior,
debito=conta.debito,
credito=conta.credito,
saldo_atual=conta.saldo_atual,
)
for indice, conta in enumerate(resultado.extracao.contas)
]
)
ContabilLinhaDre.objects.bulk_create(
[
ContabilLinhaDre(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
valor=linha.valor,
totalizador=linha.totalizador,
)
for linha in resultado.extracao.linhas_dre
]
)
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
ContabilAchado.objects.bulk_create(
[
ContabilAchado(
apuracao=apuracao,
conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None,
regra=achado.regra,
severidade=achado.severidade,
titulo=achado.titulo,
mensagem=achado.mensagem,
valor_referencia=achado.valor_referencia,
)
for achado in resultado.achados
]
)
except Exception:
if apuracao is not None:
apuracao.arquivo.delete(save=False)
return Response(
{"detail": "Não foi possível salvar a análise. Contate a Integração e Inovação."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ContabilApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def concluir(self, request: Request, pk: str | None = None) -> Response:
"""Trava a apuração pra edição (observações/achados) — mesmo espírito
de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta
v1 (diferente de planos_saude): reprocessar exige excluir e reanexar
o PDF."""
apuracao = self.get_object()
apuracao.status = ContabilApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now()
apuracao.save(update_fields=["status", "concluida_em"])
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": "Esta análise já foi concluída — não é mais possível editar observações ou achados."}
)
class ContabilContaViewSet(viewsets.ModelViewSet):
"""Contas do balancete de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilConta.objects.all()
serializer_class = ContabilContaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilContaSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilLinhaDreViewSet(viewsets.ModelViewSet):
"""Linhas da DRE de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui, mesmo espírito de `ContabilContaViewSet`."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilLinhaDre.objects.all()
serializer_class = ContabilLinhaDreSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilLinhaDreSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilAchadoViewSet(viewsets.ModelViewSet):
"""Achados de auditoria gerados automaticamente na criação da apuração —
só GET/PATCH (marcar como tratado/ignorado, sempre com justificativa)."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilAchado.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return ContabilAchadoAjusteSerializer
return ContabilAchadoSerializer
def perform_update(self, serializer: ContabilAchadoAjusteSerializer) -> None:
achado: ContabilAchado = self.get_object()
_contabil_garante_em_revisao(achado.apuracao)
achado.status = serializer.validated_data["status"]
achado.observacao_contador = serializer.validated_data["observacao_contador"]
achado.tratado_por = self.request.user
achado.tratado_em = timezone.now()
achado.save(update_fields=["status", "observacao_contador", "tratado_por", "tratado_em"])
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
achado = self.get_object()
return Response(ContabilAchadoSerializer(achado).data)