3552 lines
173 KiB
Python
3552 lines
173 KiB
Python
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)
|