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 .models import ( AcessoGeral, AcessoGeralSecao, AjudaAplicacao, CategoriaEvento, CompromissoAgenda, 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, 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 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, } 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 — e é aqui, não no pacote puro, que mora a decisão de reabrir um item que já estava tratado (ver `nao_conformidades.diff`), porque só a view sabe o que já está persistido.""" resumo: dict[str, Any] = { "ocorrencias_novas": 0, "ocorrencias_atualizadas": 0, "acoes_novas": 0, "acoes_atualizadas": 0, "acompanhamentos_novos": 0, "itens_reabertos": [], } for ocorrencia_extraida in resultado.ocorrencias: ocorrencia, criada = NCOcorrencia.objects.update_or_create( codigo=ocorrencia_extraida.codigo, defaults={**_campos_nc_ocorrencia(ocorrencia_extraida), "ultima_importacao_em": agora}, ) resumo["ocorrencias_novas" if criada else "ocorrencias_atualizadas"] += 1 if not criada and ocorrencia.status_tratativa == NCOcorrencia.STATUS_TRATADO and ocorrencia.snapshot_tratativa: motivo = nc_diff.decide_reabertura_ocorrencia( ocorrencia.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida) ) if motivo: ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE ocorrencia.reaberto_em = agora ocorrencia.reaberto_motivo = motivo ocorrencia.snapshot_tratativa = None resumo["itens_reabertos"].append( {"tipo": "ocorrencia", "codigo": ocorrencia.codigo, "motivo": motivo} ) ocorrencia.save() for acao_extraida in ocorrencia_extraida.acoes: acao, acao_criada = NCAcao.objects.update_or_create( ocorrencia=ocorrencia, codigo=acao_extraida.codigo, defaults={**_campos_nc_acao(acao_extraida), "ultima_importacao_em": agora}, ) resumo["acoes_novas" if acao_criada else "acoes_atualizadas"] += 1 for entrada in acao_extraida.acompanhamentos: _, criado_acomp = NCAcompanhamento.objects.get_or_create( acao=acao, hash_entrada=_hash_acompanhamento(entrada), defaults={"data": entrada.data, "autor": entrada.autor, "texto": entrada.texto, "eh_prorrogacao": entrada.eh_prorrogacao}, ) if criado_acomp: resumo["acompanhamentos_novos"] += 1 if not acao_criada and acao.status_tratativa == NCAcao.STATUS_TRATADO and acao.snapshot_tratativa: motivo = nc_diff.decide_reabertura_acao( acao.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida) ) if motivo: acao.status_tratativa = NCAcao.STATUS_PENDENTE acao.reaberto_em = agora acao.reaberto_motivo = motivo acao.snapshot_tratativa = None resumo["itens_reabertos"].append( {"tipo": "acao", "codigo": f"{ocorrencia.codigo}/{acao.codigo}", "motivo": motivo} ) acao.save() 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": queryset = queryset.filter(descricao_analise="") if params.get("analise_sem_acao") == "true": queryset = queryset.filter(analise_sem_acao_detectada=True) 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 _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) -> QuerySet[NCAcao]: """Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()` como filtros de data sobre `vencimento_efetivo`, pra poder filtrar no banco (o cálculo em si roda em Python, não dá pra usar direto num `.filter()`).""" if bucket == nc_classificacao.STATUS_VENCIDA: return queryset.filter(vencimento_efetivo__lt=hoje) if bucket == nc_classificacao.STATUS_VENCE_7: return queryset.filter(vencimento_efetivo__gte=hoje, vencimento_efetivo__lte=hoje + timedelta(days=7)) if bucket == nc_classificacao.STATUS_VENCE_30: return queryset.filter( vencimento_efetivo__gt=hoje + timedelta(days=7), vencimento_efetivo__lte=hoje + timedelta(days=30) ) if bucket == nc_classificacao.STATUS_NO_PRAZO: return queryset.filter(vencimento_efetivo__gt=hoje + timedelta(days=30)) if bucket == nc_classificacao.STATUS_SEM_VENCIMENTO: return queryset.filter(vencimento_efetivo__isnull=True) raise ValidationError({"status_prazo": "Valor inválido."}) 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 = params.get("status_prazo") if status_prazo: queryset = _filtra_status_prazo(queryset, status_prazo, 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 in NCAcao.objects.values_list("vencimento_efetivo", flat=True): status_counts[nc_classificacao.status_prazo(venc, hoje)] += 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). Quebrado por # categoria_acao() em Python (Correção/Ação Corretiva/Outros variam demais # entre si — de 1 a quase 800 dias na amostra real — pra uma média só fazer # sentido sozinha). 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) dias_por_categoria.setdefault(nc_classificacao.categoria_acao(tipo_acao), []).append(valor) ordem_categorias = [ nc_classificacao.CATEGORIA_CORRECAO, nc_classificacao.CATEGORIA_CORRETIVA, nc_classificacao.CATEGORIA_OUTROS, ] execucao_por_categoria = [] for categoria in ordem_categorias: valores = dias_por_categoria.get(categoria) if not valores: continue 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()}, } )