portal_publico/portal_api/views.py

5087 lines
251 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import calendar
import csv
import dataclasses
import hashlib
import io
import itertools
import os
import re
import tempfile
import zipfile
from datetime import date, timedelta
from decimal import Decimal
from typing import Any, Callable, 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.template.loader import render_to_string
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.dateparse import parse_date
from django.utils.text import slugify
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import SAFE_METHODS, AllowAny, BasePermission, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from . import catalogo
from .custo_contratacao.calculo import calcula_custo_empregado
from .custo_contratacao.pdf import gera_pdf_simulacao
from .dashboard_contabil import exportacao as dashboard_contabil_exportacao
from .dashboard_contabil import formula as dashboard_contabil_formula
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
from .dashboard_contabil import modelos as dashboard_contabil_modelos
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf
from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
from .models import (
AcessoGeral,
AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento,
CompromissoAgenda,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
ContabilObservacao,
ContabilObservacaoEdicao,
Departamento,
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeArquivoOperadora,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
ImportacaoPlanoSaudeDePaula,
ImportacaoPlanoSaudeDePaulaAlteracao,
ImportacaoPlanoSaudeDePaulaArquivoOperadora,
ImportacaoPlanoSaudeDePaulaAuditoria,
ImportacaoPlanoSaudeDePaulaLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
IndicadorApuracaoResposta,
IndicadorContabilComponente,
IndicadorContabilDefinicao,
IndicadorCriterio,
IndicadorDepartamento,
IndicadorDepartamentoGerente,
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NaoConformidadeImportacao,
NCAcao,
NCAcompanhamento,
NCOcorrencia,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
RegraCusteioPlanoSaudeDePaula,
TelefoneExterno,
Usuario,
VinculoNomeOperadora,
VinculoNomeOperadoraDePaula,
WidgetUsuario,
)
from .indicadores import calculo as indicadores_calculo
from .indicadores import departamentos as indicadores_departamentos
from .indicadores import pipeline as indicadores_pipeline
from .indicadores import recibo as indicadores_recibo
from .nao_conformidades import classificacao as nc_classificacao
from .nao_conformidades import diff as nc_diff
from .nao_conformidades import pipeline as nc_pipeline
from .permissions import PermissaoApp, PodeGerenciarPermissoes
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .planos_saude import matcher as planos_saude_matcher
from .planos_saude import pipeline as planos_saude_pipeline
from .planos_saude import regras_empresa as planos_saude_regras_empresa
from .planos_saude.leiaute_sistema import CABECALHO as PLANO_SAUDE_CABECALHO
from .planos_saude.leiaute_sistema import formata_valor_br, le_planilha_padrao, parse_valor_br
from .planos_saude.modelos import LinhaSistema
from .planos_saude.modelos import VinculoNome as VinculoNomePuro
from .planos_saude.modelos import normaliza_nome
from .planos_saude.questor_planilha import busca_linhas_questor, linhas_para_csv_bytes
from .planos_saude.regras_empresa import RegraEmpresaIncompativelError
from .serializers import (
AcessoGeralSecaoSerializer,
AcessoGeralSerializer,
AjudaAplicacaoSerializer,
CategoriaEventoSerializer,
CompromissoAgendaSerializer,
ContabilAchadoAjusteSerializer,
ContabilAchadoOcultoSerializer,
ContabilAchadoSerializer,
ContabilApuracaoCreateSerializer,
ContabilApuracaoDetailSerializer,
ContabilApuracaoListSerializer,
ContabilContaSerializer,
ContabilIndicadoresOcultosSerializer,
ContabilIndicadoresSelecionadosSerializer,
ContabilResumoFechamentoSerializer,
ContabilLinhaAnaliseVerticalSerializer,
ContabilLinhaDreSerializer,
ContabilObservacaoApuracaoSerializer,
ContabilObservacaoCreateSerializer,
ContabilObservacaoSerializer,
ContabilObservacaoUpdateSerializer,
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,
IndicadorContabilDefinicaoInputSerializer,
IndicadorContabilDefinicaoSerializer,
IndicadorCriterioSerializer,
IndicadorDepartamentoGerenteSerializer,
IndicadorDepartamentoSerializer,
IndicadorPercentualTipoSerializer,
LinkFerramentaFavoritoSerializer,
LinkFerramentaSerializer,
NaoConformidadeImportacaoCreateSerializer,
NaoConformidadeImportacaoSerializer,
NCAcaoDetailSerializer,
NCAcaoSerializer,
NCOcorrenciaDetailSerializer,
NCOcorrenciaSerializer,
NotificacaoDispensadaSerializer,
ParametroFiscalCustoContratacaoSerializer,
PerfilAcessoSerializer,
PerfilResumoSerializer,
RamalAusenciaSerializer,
RamalSerializer,
RegraCusteioPlanoSaudeSerializer,
RegraCusteioPlanoSaudeDePaulaSerializer,
SimulacaoCustoContratacaoEntradaSerializer,
TelefoneExternoSerializer,
UsuarioListSerializer,
UsuarioResumoSerializer,
UsuarioSerializer,
WidgetUsuarioSerializer,
)
@ensure_csrf_cookie
@api_view(["GET"])
@permission_classes([AllowAny])
def csrf_view(request: Request) -> Response:
"""Garante que o cookie csrftoken exista antes do primeiro POST (login)."""
return Response({"csrfToken": get_token(request)})
@api_view(["POST"])
@permission_classes([AllowAny])
def login_view(request: Request) -> Response:
username = (request.data.get("username") or "").strip()
senha = request.data.get("password") or ""
usuario = authenticate(request, username=username, password=senha)
if usuario is not None:
login(request, usuario)
return Response({"id": usuario.id, "username": usuario.username, "nome": usuario.nome})
# authenticate() já recusa usuário inativo mesmo com senha certa (ModelBackend.user_can_authenticate),
# então só dá pra saber que é esse o motivo checando a senha manualmente aqui, sem revelar o motivo
# de outras falhas (usuário inexistente ou senha errada continuam com a mensagem genérica).
inativo = Usuario.objects.filter(username=username, is_active=False).first()
if inativo and inativo.check_password(senha):
return Response(
{"detail": "Este usuário está inativo. Contate a Integração e Inovação."},
status=status.HTTP_403_FORBIDDEN,
)
return Response({"detail": "Login ou senha inválidos."}, status=status.HTTP_401_UNAUTHORIZED)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout_view(request: Request) -> Response:
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
def permissoes_efetivas(perfis: Iterable[PerfilAcesso]) -> dict[str, Any]:
"""União das permissões de todos os perfis vinculados a um usuário —
mesma lógica que antes vivia em access.js (pidApplyAccessVisibility),
agora calculada uma única vez no servidor."""
efetivas = {}
for modulo in catalogo.MODULES:
key = modulo["key"]
efetivas[key] = {
"enabled": any(p.permissoes.get(key, {}).get("enabled") for p in perfis),
"apps": {
app_key: any(p.permissoes.get(key, {}).get("apps", {}).get(app_key) for p in perfis)
for app_key in catalogo.app_keys_for(key)
},
}
return efetivas
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def me_view(request: Request) -> Response:
usuario = request.user
perfis = list(usuario.perfis.all())
return Response(
{
"id": usuario.id,
"username": usuario.username,
"nome": usuario.nome,
"perfis": PerfilResumoSerializer(perfis, many=True).data,
"departamentos": DepartamentoSerializer(usuario.departamentos.all(), many=True).data,
"gerencia_permissoes": usuario.gerencia_permissoes(),
"eh_perfil_inovacao": usuario.eh_perfil_inovacao(),
"permissoes_efetivas": permissoes_efetivas(perfis),
"lideranca": usuario.lideranca,
"liderados": UsuarioResumoSerializer(usuario.liderados.all(), many=True).data,
}
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def usuarios_resumo_view(request: Request) -> Response:
"""Lista enxuta (id/nome) de usuários ativos, pra alimentar o seletor de
liderados em "Gerenciar Usuário" — não usa PodeGerenciarPermissoes de
propósito, mesmo padrão de RamalViewSet.usuarios_disponiveis."""
usuarios = Usuario.objects.filter(is_active=True).order_by("nome")
return Response(UsuarioResumoSerializer(usuarios, many=True).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def departamentos_resumo_view(request: Request) -> Response:
"""Lista enxuta (id/nome) de departamentos, pra alimentar os botões de filtro
por departamento do modal de consulta rápida de Ramais — não usa
PodeGerenciarPermissoes (como DepartamentoViewSet), só a mesma permissão de
visualização do próprio modal (`ramais-visualizar`)."""
if not request.user.permissao_app("ramais", "ramais-visualizar"):
raise PermissionDenied("Você não tem acesso à consulta de Ramais.")
departamentos = Departamento.objects.all().order_by("nome")
return Response(DepartamentoSerializer(departamentos, many=True).data)
@api_view(["PATCH"])
@permission_classes([IsAuthenticated])
def meus_liderados_view(request: Request) -> Response:
"""Permite que o próprio usuário marcado como `lideranca` gerencie quem está
sob sua liderança, sem depender da tela administrativa de Usuários — grava
na mesma relação `Usuario.liderados` usada lá."""
usuario = request.user
if not usuario.lideranca:
raise PermissionDenied("Seu usuário não está marcado como gerente ou coordenador.")
liderados_ids = request.data.get("liderados") or []
liderados = Usuario.objects.filter(id__in=liderados_ids).exclude(id=usuario.id)
usuario.liderados.set(liderados)
return Response(UsuarioResumoSerializer(usuario.liderados.all(), many=True).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def trocar_senha_view(request: Request) -> Response:
usuario = request.user
senha_atual = request.data.get("senha_atual") or ""
nova_senha = request.data.get("nova_senha") or ""
if not usuario.check_password(senha_atual):
return Response({"detail": "Senha atual incorreta."}, status=status.HTTP_400_BAD_REQUEST)
if not nova_senha:
return Response({"detail": "Informe a nova senha."}, status=status.HTTP_400_BAD_REQUEST)
usuario.set_password(nova_senha)
usuario.save()
return Response({"detail": "Senha alterada com sucesso."})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def catalogo_view(request: Request) -> Response:
return Response(catalogo.catalogo_payload())
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def feriados_view(request: Request) -> Response:
"""Feriados nacionais + estaduais do Paraná (sede em Foz do Iguaçu) pro ano
pedido, via lib `holidays` — só a categoria "public" (feriados de verdade,
não os pontos facultativos tipo Carnaval/Corpus Christi). Não há feriado
municipal aqui de propósito: nenhuma lib cobre isso por município e o
usuário decidiu deixar de fora por enquanto (ver CLAUDE.md)."""
try:
ano = int(request.query_params.get("ano", timezone.localdate().year))
except ValueError:
raise ValidationError({"ano": "Informe um ano válido."})
# `language` explícito — sem isso, a lib pode cair pro locale do processo do
# servidor (que nem sempre é pt_BR) em vez do `default_language` da classe.
feriados_br = holidays.Brazil(years=ano, subdiv="PR", language="pt_BR")
return Response([{"data": str(data), "nome": nome} for data, nome in sorted(feriados_br.items())])
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def simulacao_custo_contratacao_view(request: Request) -> HttpResponse:
"""Ferramenta "Simulação de Custo de Contratação" (Geradoc) — cálculo
pontual, sem persistência: recebe os dados do formulário, calcula e
devolve o PDF direto na resposta (ver CLAUDE.md). Só cobre o regime
Empregado CLT por ora."""
if not request.user.permissao_app("geradoc", "simulacao-custo-contratacao"):
raise PermissionDenied("Você não tem acesso à Simulação de Custo de Contratação.")
entrada_serializer = SimulacaoCustoContratacaoEntradaSerializer(data=request.data)
entrada_serializer.is_valid(raise_exception=True)
parametros = ParametroFiscalCustoContratacao.atual().para_calculo()
resultado = calcula_custo_empregado(entrada_serializer.to_entrada(), parametros)
pdf_bytes = gera_pdf_simulacao(resultado, timezone.localtime().strftime("%d/%m/%Y %H:%M"))
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = 'inline; filename="simulacao-custo-contratacao.pdf"'
return response
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def parametros_fiscais_custo_contratacao_view(request: Request) -> Response:
"""Tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela
Simulação de Custo de Contratação — mesma permissão de quem usa a
simulação (sem par visualizar/editar dedicado). Editar aqui vale pra
todas as simulações seguintes; não há histórico de versões anteriores."""
if not request.user.permissao_app("geradoc", "simulacao-custo-contratacao"):
raise PermissionDenied("Você não tem acesso à Simulação de Custo de Contratação.")
parametros = ParametroFiscalCustoContratacao.atual()
if request.method == "GET":
return Response(ParametroFiscalCustoContratacaoSerializer(parametros).data)
serializer = ParametroFiscalCustoContratacaoSerializer(parametros, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def ajuda_aplicacao_view(request: Request, app_key: str) -> Response:
"""Texto de "Mais informações" de uma aplicação (botão de interrogação ao
lado do nome, ver CLAUDE.md) — visualização livre a qualquer autenticado;
edição restrita a quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado,
checagem fixa (`Usuario.eh_perfil_inovacao()`), não uma flag na árvore de
permissões."""
ajuda = AjudaAplicacao.para_app(app_key)
if request.method == "GET":
return Response(AjudaAplicacaoSerializer(ajuda).data)
if not request.user.eh_perfil_inovacao():
raise PermissionDenied("Somente o perfil Inovação pode editar este texto.")
serializer = AjudaAplicacaoSerializer(ajuda, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save(atualizado_por=request.user)
return Response(serializer.data)
class PerfilAcessoViewSet(viewsets.ModelViewSet):
queryset = PerfilAcesso.objects.all().order_by("codigo")
serializer_class = PerfilAcessoSerializer
permission_classes = [PodeGerenciarPermissoes]
class DepartamentoViewSet(viewsets.ModelViewSet):
queryset = Departamento.objects.all().order_by("nome")
serializer_class = DepartamentoSerializer
permission_classes = [PodeGerenciarPermissoes]
def perform_destroy(self, instance: Departamento) -> None:
vinculados = list(instance.usuarios.all())
if vinculados:
nomes = ", ".join(u.nome or u.username for u in vinculados)
raise ValidationError(f"Não é possível excluir: departamento vinculado a {nomes}.")
instance.delete()
class UsuarioViewSet(viewsets.ModelViewSet):
queryset = Usuario.objects.all().order_by("nome")
permission_classes = [PodeGerenciarPermissoes]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("list", "retrieve"):
return UsuarioListSerializer
return UsuarioSerializer
class CategoriaEventoViewSet(viewsets.ModelViewSet):
queryset = CategoriaEvento.objects.all()
serializer_class = CategoriaEventoSerializer
def get_permissions(self) -> list[BasePermission]:
if self.request.method in SAFE_METHODS:
return [IsAuthenticated()]
return [PermissaoApp("calendario-individual", "calendario-individual-criar-evento")]
class CompromissoAgendaViewSet(viewsets.ModelViewSet):
serializer_class = CompromissoAgendaSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self) -> QuerySet[CompromissoAgenda]:
usuario = self.request.user
departamentos_ids = list(usuario.departamentos.values_list("id", flat=True))
return (
CompromissoAgenda.objects.filter(
Q(dono=usuario)
| Q(visibilidade=CompromissoAgenda.VISIBILIDADE_TODOS)
| Q(
visibilidade=CompromissoAgenda.VISIBILIDADE_DEPARTAMENTO,
departamento_compartilhado_id__in=departamentos_ids,
)
# Gerente/coordenador (`lideranca`) também vê os compromissos "somente eu"
# de quem está sob sua liderança — `sou_dono` no serializer continua
# False pra esses, então o frontend sabe que não pode editar/excluir.
| Q(visibilidade=CompromissoAgenda.VISIBILIDADE_SOMENTE_EU, dono__in=usuario.liderados.all())
)
.distinct()
.order_by("data", "horario")
)
def perform_create(self, serializer: CompromissoAgendaSerializer) -> None:
serializer.save(dono=self.request.user)
def get_object(self) -> CompromissoAgenda:
obj = super().get_object()
metodo_de_escrita = self.request.method not in ("GET", "HEAD", "OPTIONS")
if metodo_de_escrita and obj.dono_id != self.request.user.id:
raise PermissionDenied("Só quem criou o compromisso pode editá-lo ou excluí-lo.")
return obj
class FavoritoViewSet(viewsets.ModelViewSet):
serializer_class = FavoritoSerializer
permission_classes = [IsAuthenticated]
lookup_field = "app_id"
lookup_value_regex = "[^/]+"
def get_queryset(self) -> QuerySet[Favorito]:
return Favorito.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: FavoritoSerializer) -> None:
app_id = serializer.validated_data.get("app_id")
maior_ordem = Favorito.objects.filter(usuario=self.request.user).aggregate(Max("ordem"))["ordem__max"]
favorito, _criado = Favorito.objects.get_or_create(
usuario=self.request.user,
app_id=app_id,
defaults={"ordem": 0 if maior_ordem is None else maior_ordem + 1},
)
serializer.instance = favorito
class NotificacaoDispensadaViewSet(viewsets.ModelViewSet):
serializer_class = NotificacaoDispensadaSerializer
permission_classes = [IsAuthenticated]
lookup_field = "notif_id"
lookup_value_regex = "[^/]+"
def get_queryset(self) -> QuerySet[NotificacaoDispensada]:
return NotificacaoDispensada.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: NotificacaoDispensadaSerializer) -> None:
notif_id = serializer.validated_data.get("notif_id")
dispensada, _criada = NotificacaoDispensada.objects.get_or_create(
usuario=self.request.user, notif_id=notif_id
)
serializer.instance = dispensada
class LinkFerramentaViewSet(viewsets.ModelViewSet):
queryset = LinkFerramenta.objects.all().order_by("ordem", "id")
serializer_class = LinkFerramentaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "links-ferramentas-visualizar" if self.request.method in SAFE_METHODS else "links-ferramentas-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def perform_create(self, serializer: LinkFerramentaSerializer) -> None:
maior_ordem = LinkFerramenta.objects.aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class LinkFerramentaFavoritoViewSet(viewsets.ModelViewSet):
"""Favorito pessoal de um cartão — só influencia a ordem de exibição em
Links & Ferramentas (e o widget de favoritos), nunca o `ordem` compartilhado."""
serializer_class = LinkFerramentaFavoritoSerializer
lookup_field = "link_id"
lookup_value_regex = "[0-9]+"
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")]
def get_queryset(self) -> QuerySet[LinkFerramentaFavorito]:
return LinkFerramentaFavorito.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: LinkFerramentaFavoritoSerializer) -> None:
link = serializer.validated_data.get("link")
favorito, _criado = LinkFerramentaFavorito.objects.get_or_create(usuario=self.request.user, link=link)
serializer.instance = favorito
class AcessoGeralSecaoViewSet(viewsets.ModelViewSet):
"""Seções do cadastro "Acessos Gerais" — mesmo padrão visualizar/editar de
LinkFerramentaViewSet, com as chaves dedicadas dessa aplicação. `get_queryset`
também filtra por `perfis_restritos`: uma seção com perfis marcados só aparece
pra quem tem pelo menos um deles vinculado, além da permissão de módulo."""
serializer_class = AcessoGeralSecaoSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "acessos-gerais-visualizar" if self.request.method in SAFE_METHODS else "acessos-gerais-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def get_queryset(self) -> QuerySet[AcessoGeralSecao]:
perfis_ids = list(self.request.user.perfis.values_list("codigo", flat=True))
return (
AcessoGeralSecao.objects.filter(Q(perfis_restritos__isnull=True) | Q(perfis_restritos__codigo__in=perfis_ids))
.distinct()
.order_by("ordem", "id")
)
def perform_create(self, serializer: AcessoGeralSecaoSerializer) -> None:
maior_ordem = AcessoGeralSecao.objects.aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class AcessoGeralViewSet(viewsets.ModelViewSet):
"""Linhas dentro de uma seção de "Acessos Gerais" — `ordem` é escopada por
seção (reordenar só compara linhas da mesma `secao`, igual a `perform_create`
abaixo calcula o próximo valor). `get_queryset` aplica o mesmo filtro de
`perfis_restritos` da seção-mãe, pra uma linha nunca vazar de uma seção que o
usuário não teria como ver."""
serializer_class = AcessoGeralSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "acessos-gerais-visualizar" if self.request.method in SAFE_METHODS else "acessos-gerais-editar"
return [PermissaoApp("links-ferramentas", app_key)]
def get_queryset(self) -> QuerySet[AcessoGeral]:
perfis_ids = list(self.request.user.perfis.values_list("codigo", flat=True))
return (
AcessoGeral.objects.filter(
Q(secao__perfis_restritos__isnull=True) | Q(secao__perfis_restritos__codigo__in=perfis_ids)
)
.distinct()
.order_by("secao__ordem", "secao_id", "ordem", "id")
)
def perform_create(self, serializer: AcessoGeralSerializer) -> None:
secao = serializer.validated_data.get("secao")
maior_ordem = AcessoGeral.objects.filter(secao=secao).aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
class RamalViewSet(viewsets.ModelViewSet):
"""`list()` mescla duas fontes num diretório só: todo `Usuario` ativo (linha
montada direto do cadastro, ramal fica "pendente" até ser preenchido) e as linhas
avulsas de `Ramal` (sem conta de sistema por trás). `create`/`retrieve`/`update`/
`destroy` seguem sendo CRUD normal do DRF, mas só valem pras linhas avulsas —
editar o ramal de um usuário de verdade é a action `atualizar_ramal_usuario`
abaixo, que grava direto em `Usuario.ramal`."""
queryset = Ramal.objects.all().order_by("nome")
serializer_class = RamalSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "ramais-visualizar" if self.request.method in SAFE_METHODS else "ramais-editar"
return [PermissaoApp("ramais", app_key)]
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
agora = timezone.localtime()
hoje = agora.date()
linhas = []
usuarios = Usuario.objects.filter(is_active=True).prefetch_related("departamentos", "ausencias_ramal")
for usuario in usuarios:
ausencia_ativa = next((a for a in usuario.ausencias_ramal.all() if a.esta_ativa(agora)), None)
nascimento = usuario.data_aniversario
linhas.append(
{
"id": f"usuario-{usuario.id}",
"tipo": "usuario",
"usuario_id": usuario.id,
"avulso_id": None,
"nome_exibicao": usuario.nome,
"departamento_exibicao": ", ".join(d.nome for d in usuario.departamentos.all()),
"numero_exibicao": usuario.ramal,
"usuario_ausente": ausencia_ativa is not None,
"usuario_ausencia_ativa_id": ausencia_ativa.id if ausencia_ativa else None,
"usuario_aniversariante": bool(nascimento) and (nascimento.month, nascimento.day) == (hoje.month, hoje.day),
}
)
for ramal in Ramal.objects.all():
linhas.append(
{
"id": f"avulso-{ramal.id}",
"tipo": "avulso",
"usuario_id": None,
"avulso_id": ramal.id,
"nome_exibicao": ramal.nome,
"departamento_exibicao": ramal.departamento,
"numero_exibicao": ramal.numero,
"usuario_ausente": False,
"usuario_ausencia_ativa_id": None,
"usuario_aniversariante": False,
}
)
linhas.sort(key=lambda linha: (linha["nome_exibicao"] or "").lower())
return Response(linhas)
@action(detail=False, methods=["get"], url_path="usuarios")
def usuarios_disponiveis(self, request: Request) -> Response:
"""Alimenta o <select> "Lista de Usuários" do modal de Criar Ausência — não
reaproveita /api/usuarios/ porque aquele endpoint é restrito a quem tem
gerencia_permissoes, e a permissão de Ramais é deliberadamente desacoplada
disso (ver PermissaoApp)."""
usuarios = Usuario.objects.filter(is_active=True).order_by("nome")
return Response([{"id": u.id, "nome": u.nome} for u in usuarios])
@action(detail=False, methods=["patch"], url_path=r"usuarios/(?P<usuario_id>\d+)")
def atualizar_ramal_usuario(self, request: Request, usuario_id: str | None = None) -> Response:
"""Grava o ramal de um colaborador de verdade direto em Usuario.ramal — é como
a tela "demonstra" a alteração refletindo no cadastro do usuário."""
usuario = get_object_or_404(Usuario, pk=usuario_id, is_active=True)
usuario.ramal = (request.data.get("numero") or "").strip()
usuario.save(update_fields=["ramal"])
return Response({"id": usuario.id, "numero": usuario.ramal})
class RamalAusenciaViewSet(viewsets.ModelViewSet):
"""Ausências são parte da subtela "Ramais" — mesma permissão dedicada dessa aba
(`ramais-visualizar`/`ramais-editar`), não das outras subtelas de Ramais."""
queryset = RamalAusencia.objects.select_related("usuario").all()
serializer_class = RamalAusenciaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "ramais-visualizar" if self.request.method in SAFE_METHODS else "ramais-editar"
return [PermissaoApp("ramais", app_key)]
class TelefoneExternoViewSet(viewsets.ModelViewSet):
"""Subtela "Telefones Externos" dentro de Ramais — permissão dedicada dessa aba
(`telefones-externos-visualizar`/`telefones-externos-editar`)."""
queryset = TelefoneExterno.objects.all().order_by("nome")
serializer_class = TelefoneExternoSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "telefones-externos-visualizar" if self.request.method in SAFE_METHODS else "telefones-externos-editar"
return [PermissaoApp("ramais", app_key)]
class FuncaoTelefoniaViewSet(viewsets.ModelViewSet):
"""Subtela "Funções de Telefonia" dentro de Ramais — permissão dedicada dessa
aba (`funcoes-telefonia-visualizar`/`funcoes-telefonia-editar`)."""
queryset = FuncaoTelefonia.objects.all().order_by("comando")
serializer_class = FuncaoTelefoniaSerializer
def get_permissions(self) -> list[BasePermission]:
app_key = "funcoes-telefonia-visualizar" if self.request.method in SAFE_METHODS else "funcoes-telefonia-editar"
return [PermissaoApp("ramais", app_key)]
class WidgetUsuarioViewSet(viewsets.ModelViewSet):
serializer_class = WidgetUsuarioSerializer
permission_classes = [IsAuthenticated]
lookup_field = "tipo"
def get_queryset(self) -> QuerySet[WidgetUsuario]:
return WidgetUsuario.objects.filter(usuario=self.request.user)
def perform_create(self, serializer: WidgetUsuarioSerializer) -> None:
tipo = serializer.validated_data.get("tipo")
maior_ordem = WidgetUsuario.objects.filter(usuario=self.request.user).aggregate(Max("ordem"))["ordem__max"]
widget, _criado = WidgetUsuario.objects.get_or_create(
usuario=self.request.user,
tipo=tipo,
defaults={"ordem": 0 if maior_ordem is None else maior_ordem + 1},
)
serializer.instance = widget
def _salva_arquivo_temporario(arquivo: UploadedFile, sufixo: str) -> str:
"""Grava um upload num arquivo temporário em disco (apagado pelo chamador)
— `le_planilha_padrao`/`OperadoraParser.extrai` esperam um caminho de
arquivo, não um objeto de upload do Django em memória."""
with tempfile.NamedTemporaryFile(delete=False, suffix=sufixo) as tmp:
for chunk in arquivo.chunks():
tmp.write(chunk)
return tmp.name
def _valida_planilha_padrao(arquivo: UploadedFile) -> dict[str, Any]:
"""Roda o mesmo leitor usado em processa_importacao (`le_planilha_padrao`)
sobre o arquivo recém-anexado, só pra confirmar o leiaute antes de
precisar do arquivo da operadora também — nada é salvo/persistido aqui."""
caminho = _salva_arquivo_temporario(arquivo, ".csv")
try:
linhas = le_planilha_padrao(caminho)
except Exception:
return {
"valido": False,
"mensagem": (
"Este arquivo não parece ser a planilha padrão exportada do Questor "
f"(esperado um CSV separado por ';' com as colunas {', '.join(PLANO_SAUDE_CABECALHO)})."
),
}
finally:
os.remove(caminho)
if not linhas:
return {"valido": False, "mensagem": "A planilha está vazia — nenhum beneficiário cadastrado nela."}
return {"valido": True, "mensagem": f"{len(linhas)} beneficiário(s) encontrado(s) na planilha."}
def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict[str, Any]:
"""Roda o parser da operadora escolhida (`OperadoraParser.extrai`) sobre o
arquivo recém-anexado — mesma extração usada em processa_importacao, só
que descartada em seguida (nada é salvo/persistido aqui). O sufixo do
arquivo temporário precisa refletir a extensão real do upload (não só
".pdf"/".csv" fixos) porque alguns parsers dependem dela pra abrir o
arquivo — `openpyxl.load_workbook` (ex.: SulAmérica 5775, .xlsx) recusa
abrir um arquivo cujo sufixo não seja .xlsx/.xlsm/.xltx/.xltm, mesmo que
o conteúdo seja válido (bug real: um .xlsx salvo com sufixo ".csv"
levantava `InvalidFileException`, fazendo a pré-validação sempre falhar
pra essa operadora com uma mensagem genérica de "arquivo não reconhecido").
"""
sufixo = os.path.splitext(arquivo.name or "")[1] or ".tmp"
caminho = _salva_arquivo_temporario(arquivo, sufixo)
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
try:
parser = operadora_info["parser"]()
individuos, _ = parser.extrai(caminho)
# Algumas operadoras (ex.: Unimed Cascavel) não devolvem os dados de
# um arquivo direto em extrai() — retêm num estado interno e só
# resolvem em finaliza() (OperadoraParser.finaliza(), chamado pelo
# pipeline depois de ver TODOS os arquivos da importação real, pra
# decidir entre fontes de dado que se sobrepõem sem contar em
# dobro). Chamado aqui também, com o arquivo sozinho, só pra essa
# pré-validação enxergar que algo FOI encontrado nele — mesmo que a
# resolução completa (casamento por família) só aconteça de verdade
# quando os demais arquivos da importação também estiverem
# presentes. Não tem efeito nenhum pra quem não sobrescreve
# finaliza() (devolve sempre vazio).
individuos_finais, auditoria_final = parser.finaliza()
except Exception:
return {
"valido": False,
"mensagem": (
f"Não foi possível reconhecer este arquivo como um relatório da operadora "
f"{planos_saude_pipeline.label_operadora(operadora_key)} — confira se é o arquivo certo e se o formato "
"(PDF ou CSV, conforme esperado por essa operadora) está correto."
),
}
finally:
os.remove(caminho)
total_encontrado = len(individuos) + len(individuos_finais) + len(auditoria_final)
if not total_encontrado:
return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."}
return {"valido": True, "mensagem": f"{total_encontrado} lançamento(s) encontrado(s) no arquivo."}
def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -> bytes:
"""Mesmo formato de portal_api.planos_saude.leiaute_sistema.linhas_para_csv_bytes,
só que a partir de ImportacaoPlanoSaudeLinha (registros já no banco, com as
edições do usuário) em vez de LinhaSistema (dataclass em memória)."""
buffer = io.StringIO()
escritor = csv.writer(buffer, delimiter=";")
escritor.writerow(PLANO_SAUDE_CABECALHO)
for l in linhas:
escritor.writerow([
l.codigo_empresa, l.nome_func, l.cpf_func, l.codigo_out_emp,
l.data_inicial, l.nome_dependente, l.cpf_dependente,
l.valor_empresa, l.valor, l.descricao,
])
return buffer.getvalue().encode("utf-8-sig")
def _nome_base_arquivo_gerado_plano_saude(importacao: Any, todas_linhas: Iterable[Any], prefixo_fallback: str) -> str:
""""<código empresa> - <código operadora>" (ex.: "1970 - 5060") pra nomear
o(s) arquivo(s) que gerar() devolve — antes era sempre "<prefixo_fallback>_<id>",
que não dava pra distinguir duas execuções da mesma empresa com operadoras
diferentes. Todas as linhas de uma importação compartilham o mesmo código de
empresa (mesma premissa de _codigo_empresa_da_importacao em serializers.py),
então a primeira linha não vazia já identifica a empresa da execução inteira.
Cai pro nome genérico de sempre (com o id, pra continuar único) se a empresa
ou a operadora não puderem ser identificadas (ex.: nenhuma linha com código
de empresa preenchido)."""
codigo_empresa = next((l.codigo_empresa for l in todas_linhas if l.codigo_empresa), "")
codigo_operadora = planos_saude_pipeline.OPERADORAS.get(importacao.operadora, {}).get("codigo_operadora", "")
if codigo_empresa and codigo_operadora:
return f"{codigo_empresa} - {codigo_operadora}"
return f"{prefixo_fallback}_{importacao.id}"
# Campos editáveis de ImportacaoPlanoSaudeLinha (sem tipo_lancamento/ordem,
# guardados à parte em ImportacaoPlanoSaudeAlteracao) — usado tanto pra
# detectar qual campo mudou num PATCH (ImportacaoPlanoSaudeLinhaViewSet.perform_update)
# quanto pra montar o snapshot de _snapshot_linha_plano_saude abaixo.
PLANO_SAUDE_CAMPOS_ALTERACAO = [
"codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial",
"nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao",
"tipo_pessoa",
]
def _snapshot_linha_plano_saude(linha: ImportacaoPlanoSaudeLinha) -> dict[str, Any]:
"""Espelha os campos editáveis de uma ImportacaoPlanoSaudeLinha (+ ordem) pra
guardar em ImportacaoPlanoSaudeAlteracao.dados_linha — usado tanto pra
identificar a linha na aba "Alterações" quanto pra recriá-la ao reverter
uma exclusão (ImportacaoPlanoSaudeAlteracaoViewSet.reverter)."""
dados = {campo: getattr(linha, campo) for campo in PLANO_SAUDE_CAMPOS_ALTERACAO}
dados["ordem"] = linha.ordem
return dados
def _garante_importacao_em_revisao(importacao: ImportacaoPlanoSaude) -> None:
"""Bloqueia qualquer edição de linha/auditoria/alteração de uma
ImportacaoPlanoSaude já `concluida` — `ImportacaoPlanoSaudeViewSet.reabrir`
é o único jeito de voltar a editar (volta o status pra `revisao`).
Chamada em todo ponto que grava algo dentro de uma importação já criada
(linha/auditoria/alteração), exceto `gerar()` em si, que só lê o que já
está salvo e pode ser chamado de novo pra rebaixar o mesmo arquivo
mesmo depois de concluída."""
if importacao.status == ImportacaoPlanoSaude.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": 'Esta importação já foi concluída. Clique em "Editar" para reabri-la antes de alterar.'}
)
def _carrega_vinculos_por_nome(
operadora_key: str, linhas_sistema_template: list[LinhaSistema]
) -> dict[str, VinculoNomePuro]:
"""Monta o "DE/PARA" (ver VinculoNomeOperadora em models.py e
"Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) pra passar em
`planos_saude_pipeline.processa_importacao(vinculos_por_nome=...)` —
busca todo VinculoNomeOperadora desta operadora cujo `codigo_empresa`
(normalizado) apareça em algum `LinhaSistema` da planilha padrão desta
importação, e devolve um dict {nome_arquivo_operadora (já normalizado):
VinculoNomePuro} pronto pro matcher.py (pacote sem ORM, por isso o dict
é montado aqui, não lá). Uma planilha normalmente tem um único
`codigo_empresa`, mas o filtro cobre todos os distintos por segurança."""
codigos = {
normalizar_codigo_empresa(linha.codigo_empresa)
for linha in linhas_sistema_template
if linha.codigo_empresa
}
if not codigos:
return {}
vinculos = VinculoNomeOperadora.objects.filter(operadora=operadora_key)
return {
vinculo.nome_arquivo_operadora: VinculoNomePuro(
id=vinculo.id,
nome_func_destino=vinculo.nome_func_destino,
nome_dependente_destino=vinculo.nome_dependente_destino,
)
for vinculo in vinculos
if normalizar_codigo_empresa(vinculo.codigo_empresa) in codigos
}
class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
"""Ferramenta "Importação de Plano de Saúde" (Utilitários) — permissão de
toggle único (sem par visualizar/editar, ver catalogo.py "utilitarios"), então
qualquer usuário autorizado pode criar, revisar e gerar, sem conceito de "dono"
(mesmo espírito de LinkFerramenta/AcessoGeral). `create()` roda o pipeline em
portal_api.planos_saude de forma síncrona e já devolve a importação com
linhas/auditoria persistidas; `gerar()` só formata o que já está salvo (isto é,
já reflete qualquer edição feita na revisão), não reprocessa os arquivos
originais. `destroy()` remove a importação do histórico (botão de excluir na
listagem) — apaga a planilha padrão e todos os arquivos da operadora
(`arquivos_operadora`, um ou mais) de MEDIA_ROOT antes de excluir o
registro (as linhas/itens de auditoria somem sozinhos via CASCADE)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ImportacaoPlanoSaude.objects.all().order_by("-criado_em")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
def perform_destroy(self, instance: ImportacaoPlanoSaude) -> None:
for arquivo in instance.arquivos_operadora.all():
arquivo.arquivo.delete(save=False)
instance.planilha_padrao.delete(save=False)
instance.delete()
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return ImportacaoPlanoSaudeListSerializer
return ImportacaoPlanoSaudeDetailSerializer
@action(detail=False, methods=["get"])
def operadoras(self, request: Request) -> Response:
"""Fonte única pro <select> de operadora do formulário de nova importação —
evita duplicar a lista em JS; adicionar uma operadora nova em
planos_saude.pipeline.OPERADORAS já basta pra aparecer aqui."""
return Response(planos_saude_pipeline.lista_operadoras())
@action(detail=False, methods=["get"], url_path="regras-empresa")
def regras_empresa(self, request: Request) -> Response:
"""Fonte única pro botão "Selecionar regra" do checkbox "Regra empresa"
(terceiro tipo de importação, ao lado de Mensalidade/Coparticipação) —
registro fixo no código (portal_api.planos_saude.regras_empresa), sem
cadastro pela tela; cadastrar uma regra nova lá já basta pra aparecer
aqui."""
return Response(planos_saude_regras_empresa.lista_regras_empresa())
@action(detail=False, methods=["post"], url_path="validar-arquivo")
def validar_arquivo(self, request: Request) -> Response:
"""Pré-validação de UM arquivo (planilha padrão OU arquivo da
operadora), chamada pelo frontend assim que o colaborador anexa cada
um — roda o mesmo parser usado em create()/processa_importacao sobre
um arquivo temporário (nada é persistido), pra apontar especificamente
qual dos dois documentos está fora do padrão esperado, sem precisar
esperar os dois anexados e o "Processar" pra descobrir. Sempre 200 —
`{"valido": bool, "mensagem": str}` — mesmo quando o arquivo está
errado, já que esse é um resultado esperado da validação, não um erro
de requisição."""
arquivo = request.FILES.get("arquivo")
if not arquivo:
raise ValidationError({"arquivo": "Envie um arquivo."})
tipo = request.data.get("tipo")
if tipo == "planilha":
return Response(_valida_planilha_padrao(arquivo))
if tipo == "operadora":
operadora_key = request.data.get("operadora")
if operadora_key not in planos_saude_pipeline.OPERADORAS:
raise ValidationError({"operadora": "Selecione a operadora antes de anexar o arquivo."})
return Response(_valida_arquivo_operadora(arquivo, operadora_key))
raise ValidationError({"tipo": "Informe 'planilha' ou 'operadora'."})
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = ImportacaoPlanoSaudeCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
tipos = dados["tipos_lancamento_lista"]
custeio_por_tipo = dados["custeio_por_tipo"]
operadora_key = dados["operadora"]
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
regra_custeio_obj = dados.get("regra_custeio_salva")
competencia = dados.get("competencia")
planilha_padrao_arquivo = dados.get("planilha_padrao")
linhas_sistema_template = None
planilha_padrao_gerada = None
if not planilha_padrao_arquivo:
# Sem upload: busca a planilha padrão no Questor, ANTES de criar
# a importação — uma falha de conexão/consulta aqui nunca deixa
# nada órfão pra limpar (diferente do caminho de upload, que só
# sabe se o arquivo é válido depois de já ter salvo o registro).
codigo_empresa_regra = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
codigo_operadora = operadora_info["codigo_operadora"]
try:
linhas_sistema_template = busca_linhas_questor(codigo_empresa_regra, codigo_operadora, competencia)
except Exception:
return Response(
{
"detail": (
"Não foi possível buscar a planilha padrão no Questor agora. "
"Tente novamente em alguns instantes ou anexe a planilha manualmente."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if not linhas_sistema_template:
return Response(
{
"detail": (
f"Nenhum beneficiário encontrado no Questor para a empresa {regra_custeio_obj.codigo_empresa} "
f"com a operadora {operadora_info['nome']} na competência informada."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
planilha_padrao_gerada = ContentFile(
linhas_para_csv_bytes(linhas_sistema_template),
name=f"questor_{codigo_empresa_regra}_{competencia:%Y-%m}.csv",
)
importacao = ImportacaoPlanoSaude.objects.create(
operadora=operadora_key,
nome_operadora=planos_saude_pipeline.label_operadora(operadora_key),
tipos_lancamento=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa=dados["regra_empresa"],
regra_custeio_salva=regra_custeio_obj,
criado_por=request.user,
competencia=competencia,
planilha_padrao=planilha_padrao_arquivo or planilha_padrao_gerada,
)
arquivos_operadora = [
ImportacaoPlanoSaudeArquivoOperadora.objects.create(importacao=importacao, arquivo=arquivo, ordem=ordem)
for ordem, arquivo in enumerate(dados["arquivo_operadora"])
]
def _limpa_arquivos_orfaos() -> None:
for arquivo in arquivos_operadora:
arquivo.arquivo.delete(save=False)
importacao.planilha_padrao.delete(save=False)
importacao.delete()
try:
if planilha_padrao_arquivo:
linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path)
vinculos_por_nome = _carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)
resultado = planos_saude_pipeline.processa_importacao(
operadora_key=operadora_key,
caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
linhas_sistema_template=linhas_sistema_template,
tipos_selecionados=tipos,
custeio_por_tipo=custeio_por_tipo,
regra_empresa_key=dados["regra_empresa"] or None,
vinculos_por_nome=vinculos_por_nome,
)
except RegraEmpresaIncompativelError as exc:
# Diferente do genérico abaixo: aqui o problema não é o arquivo em
# si, é a combinação operadora/planilha × regra empresa escolhida
# — vale a pena mostrar a mensagem específica pro usuário.
_limpa_arquivos_orfaos()
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
# Arquivo ilegível (PDF num layout desconhecido, planilha padrão fora do
# leiaute esperado etc.) — não deixa órfão nem a importação nem os
# arquivos já salvos em MEDIA_ROOT.
_limpa_arquivos_orfaos()
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Trava de segurança: a regra de custeio aplicada foi cadastrada pra
# uma empresa específica (RegraCusteioPlanoSaude.codigo_empresa) —
# confere que a planilha padrão anexada de fato tem alguma linha
# dessa empresa, senão a importação segue com o custeio da empresa
# errada sem nenhum aviso. Vale mesmo quando a regra também tem
# `regra_empresa_chave` (onde é redundante com a checagem que
# `regras_empresa.valida_regra_empresa()` já faz dentro do try acima)
# — protege contra o registro em REGRAS_EMPRESA ficar dessincronizado
# da RegraCusteioPlanoSaude correspondente. Quando a planilha veio do
# Questor (não upload), essa conferência é redundante — a consulta já
# filtrou pelo próprio `codigo_empresa` da regra — então é pulada.
if planilha_padrao_arquivo and regra_custeio_obj and regra_custeio_obj.codigo_empresa:
todas_linhas = [linha for linhas in resultado.linhas_por_tipo.values() for linha in linhas]
# A planilha padrão traz o código cru do Questor (pode ter zero à
# esquerda, ex. "092"), enquanto RegraCusteioPlanoSaude.codigo_empresa
# já é sempre canônico (sem zero à esquerda, ver normalizar_codigo_empresa)
# — comparar sem normalizar os dois lados rejeitaria uma planilha
# correta só por essa diferença de formatação.
codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
_limpa_arquivos_orfaos()
return Response(
{
"detail": (
f'A regra "{regra_custeio_obj.nome}" foi cadastrada para a empresa código '
f"{regra_custeio_obj.codigo_empresa}, mas a planilha padrão anexada não tem "
"nenhuma linha com esse código."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
linhas_specs = [
(tipo, ordem, linha)
for tipo, linhas in resultado.linhas_por_tipo.items()
for ordem, linha in enumerate(linhas)
]
linhas_bulk = ImportacaoPlanoSaudeLinha.objects.bulk_create([
ImportacaoPlanoSaudeLinha(
importacao=importacao,
tipo_lancamento=tipo,
ordem=ordem,
codigo_empresa=linha.codigo_empresa,
nome_func=linha.nome_func,
cpf_func=linha.cpf_func,
codigo_out_emp=linha.codigo_out_emp,
data_inicial=linha.data_inicial,
nome_dependente=linha.nome_dependente,
cpf_dependente=linha.cpf_dependente,
valor_empresa=linha.valor_empresa,
valor=linha.valor,
descricao=linha.descricao,
tipo_pessoa=linha.tipo_pessoa,
)
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()}
nome_base = _nome_base_arquivo_gerado_plano_saude(
importacao, itertools.chain.from_iterable(linhas_por_tipo.values()), "importacao_plano_saude"
)
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="{nome_base} - {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="{nome_base}.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. `tipo_pessoa` também precisa ser
# gravado aqui — esta linha nunca passou pelo casamento
# automático (senão não estaria em branco pra "Vincular
# pessoa"), então uma regra que distinga dependente de agregado
# (ex.: Amil/Tecnomyl) só sabe o tipo de `item.tipo`.
linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor))
linha.tipo_pessoa = item.tipo
linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"])
_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,
tipo_pessoa=linha.tipo_pessoa,
)
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()}
nome_base = _nome_base_arquivo_gerado_plano_saude(
importacao, itertools.chain.from_iterable(linhas_por_tipo.values()), "importacao_plano_saude_de_paula"
)
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="{nome_base} - {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="{nome_base}.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:
# Ver ImportacaoPlanoSaudeAuditoriaViewSet.resolver — mesmo
# motivo pra gravar `tipo_pessoa` aqui.
linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor))
linha.tipo_pessoa = item.tipo
linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"])
_recalcula_familia_regra_empresa_de_paula(item.importacao, linha)
else:
regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})
linha.valor_empresa, linha.valor = planos_saude_matcher.valores_formatados_para_pessoa(
float(item.valor), regra_por_pessoa, item.tipo
)
linha.save(update_fields=["valor_empresa", "valor"])
item.resolvida = True
item.linha_vinculada = linha
item.save(update_fields=["resolvida", "linha_vinculada"])
codigo_empresa_norm = normalizar_codigo_empresa(linha.codigo_empresa)
if codigo_empresa_norm:
destino = {"nome_func_destino": "", "nome_dependente_destino": ""}
if item.tipo == "T":
destino["nome_func_destino"] = linha.nome_func
else:
destino["nome_dependente_destino"] = linha.nome_dependente
VinculoNomeOperadoraDePaula.objects.update_or_create(
operadora=item.importacao.operadora,
codigo_empresa=codigo_empresa_norm,
nome_arquivo_operadora=normaliza_nome(item.nome),
defaults={**destino, "criado_por": request.user},
)
return Response(ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(item).data)
class RegraCusteioPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
"""Ver `RegraCusteioPlanoSaudeViewSet` — mesmo cadastro, escopo De Paula."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = RegraCusteioPlanoSaudeDePaula.objects.all()
serializer_class = RegraCusteioPlanoSaudeDePaulaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
def perform_create(self, serializer: RegraCusteioPlanoSaudeDePaulaSerializer) -> None:
serializer.save(criado_por=self.request.user)
@action(detail=False, methods=["get"], url_path="nome-empresa")
def nome_empresa(self, request: Request) -> Response:
codigo = normalizar_codigo_empresa(request.query_params.get("codigo_empresa") or "")
if not codigo:
return Response({"detail": "Informe codigo_empresa."}, status=status.HTTP_400_BAD_REQUEST)
return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
class IndicadorPercentualTipoViewSet(viewsets.ModelViewSet):
"""Cadastro dos percentuais individual/grupo/departamento por tipo de
colaborador (ver IndicadorPercentualTipo em models.py) — nunca editado
in-place, só criado com um `vigente_desde` novo (histórico completo,
decisão explícita do usuário). Mesma permissão de toggle único do
Indicador de Desempenho."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = IndicadorPercentualTipo.objects.all()
serializer_class = IndicadorPercentualTipoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def perform_create(self, serializer: IndicadorPercentualTipoSerializer) -> None:
serializer.save(criado_por=self.request.user)
class IndicadorCriterioViewSet(viewsets.ModelViewSet):
"""CRUD do cadastro genérico de critérios do Indicador de Desempenho (ver
IndicadorCriterio em models.py) — nome/peso/período/papel livres, editável
pelo RH em vez de fixado no código."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorCriterio.objects.all()
serializer_class = IndicadorCriterioSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorDepartamentoViewSet(viewsets.ModelViewSet):
"""CRUD do cadastro de departamentos do Indicador de Desempenho (ver
IndicadorDepartamento em models.py) — cada departamento tem seu próprio
conjunto de critérios/percentuais e sua própria meta de Departamento na
apuração."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorDepartamento.objects.prefetch_related("gerentes")
serializer_class = IndicadorDepartamentoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorDepartamentoGerenteViewSet(viewsets.ModelViewSet):
"""CRUD da relação gerente→departamento (ver IndicadorDepartamentoGerente
em models.py / portal_api.indicadores.departamentos) — mantida
manualmente pela própria aplicação (Configurações → Departamentos →
"Gerenciar Gerentes"); `nome_gerente` é único, então o próprio banco
recusa mapear o mesmo gerente pra dois departamentos ao mesmo tempo."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorDepartamentoGerente.objects.select_related("departamento")
serializer_class = IndicadorDepartamentoGerenteSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
"""Apuração mensal do Indicador de Desempenho (Fiscontábil, Geradoc) —
permissão de toggle único (sem par visualizar/editar), mesmo espírito de
ImportacaoPlanoSaudeViewSet. `create()` roda `portal_api.indicadores.pipeline`
de forma síncrona sobre as duas planilhas anexadas e já persiste
colaboradores/empresas/respostas (os 3 critérios automáticos já vêm
pré-calculados; os demais entram como "Não se aplica" pra o RH revisar).
`gerar()` só formata os PDFs a partir do que já está salvo — reflete
qualquer ajuste manual feito na revisão, sem reprocessar as planilhas."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = IndicadorApuracao.objects.all().order_by("-competencia", "-criado_em")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_queryset(self) -> QuerySet[IndicadorApuracao]:
queryset = super().get_queryset()
if self.action == "retrieve":
# IndicadorApuracaoDetailSerializer aninha colaboradores/empresas/
# respostas (e o SerializerMethodField `composicao_individual`
# acessa `empresas`/`respostas` de novo por colaborador) — sem
# prefetch, cada acesso a esses related managers dispara uma
# query nova por colaborador.
queryset = queryset.prefetch_related("colaboradores__empresas", "colaboradores__respostas__criterio")
return queryset
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return IndicadorApuracaoListSerializer
return IndicadorApuracaoDetailSerializer
def perform_destroy(self, instance: IndicadorApuracao) -> None:
instance.planilha_tareffa.delete(save=False)
instance.planilha_honorarios.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = IndicadorApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
apuracao: IndicadorApuracao | None = None
try:
with transaction.atomic():
apuracao = IndicadorApuracao.objects.create(
competencia=dados["competencia"],
criado_por=request.user,
planilha_tareffa=dados["planilha_tareffa"],
planilha_honorarios=dados["planilha_honorarios"],
)
# Critérios (e seus critérios automáticos) são agrupados por
# departamento — cada departamento pode ter uma regra
# diferente (ver CLAUDE.md); o pipeline resolve o
# departamento de cada colaborador pelo `gerente` (via
# `mapa_gerentes`) e só usa os critérios automáticos
# daquele departamento pra pré-calcular as respostas.
mapa_gerentes = indicadores_departamentos.carrega_mapa_gerentes()
criterios_ativos = list(IndicadorCriterio.objects.filter(ativo=True))
criterios_por_departamento: dict[int, list[IndicadorCriterio]] = {}
criterios_automaticos_por_departamento: dict[
int, list[indicadores_pipeline.CriterioAutomaticoConfig]
] = {}
for criterio in criterios_ativos:
criterios_por_departamento.setdefault(criterio.departamento_id, []).append(criterio)
if criterio.calculo_automatico:
criterios_automaticos_por_departamento.setdefault(criterio.departamento_id, []).append(
indicadores_pipeline.CriterioAutomaticoConfig(
criterio_id=criterio.id,
tipo_calculo=criterio.calculo_automatico,
limiar_percentual=criterio.limiar_percentual,
)
)
resultado = indicadores_pipeline.processa_apuracao(
caminho_tareffa=apuracao.planilha_tareffa.path,
caminho_honorarios=apuracao.planilha_honorarios.path,
gerente_departamento=mapa_gerentes,
criterios_automaticos_por_departamento=criterios_automaticos_por_departamento,
)
apuracao.avisos = resultado.avisos
apuracao.save(update_fields=["avisos"])
for colaborador_calculado in resultado.colaboradores:
colaborador = IndicadorApuracaoColaborador.objects.create(
apuracao=apuracao,
nome=colaborador_calculado.nome,
gerente=colaborador_calculado.gerente,
departamento_id=colaborador_calculado.departamento_id,
)
IndicadorApuracaoEmpresa.objects.bulk_create(
[
IndicadorApuracaoEmpresa(
colaborador=colaborador,
codigo_empresa=empresa.codigo_empresa,
nome_empresa=empresa.nome_empresa,
honorario=empresa.honorario,
honorario_nao_encontrado=empresa.honorario_nao_encontrado,
tipo=empresa.tipo,
)
for empresa in colaborador_calculado.empresas
]
)
respostas_automaticas = {
resposta.criterio_id: resposta for resposta in colaborador_calculado.respostas_automaticas
}
# Só os critérios do departamento deste colaborador (ver
# comentário acima) — não mais todos os `criterios_ativos`
# da apuração. Colaborador sem departamento resolvido
# (gerente não mapeado) não recebe nenhuma resposta.
criterios_deste_departamento = criterios_por_departamento.get(
colaborador_calculado.departamento_id, []
)
IndicadorApuracaoResposta.objects.bulk_create(
[
IndicadorApuracaoResposta(
colaborador=colaborador,
criterio=criterio,
valor=(
respostas_automaticas[criterio.id].valor
if criterio.id in respostas_automaticas
# Grupo/Departamento não têm "não se aplica" real — o
# colaborador sempre faz parte de um grupo e do
# departamento (ver CLAUDE.md) — então nascem "Sim" por
# padrão, cabendo ao RH marcar "Não" quando a meta não
# foi cumprida. Individual (manual, sem cálculo
# automático) continua "Não se aplica" por padrão, já
# que pode genuinamente não valer pro papel do
# colaborador (ver `papel_aplicavel`).
else (
"SIM"
if criterio.grupo in (IndicadorCriterio.GRUPO_GRUPO, IndicadorCriterio.GRUPO_DEPARTAMENTO)
else "NAO_SE_APLICA"
)
),
valor_automatico=(
respostas_automaticas[criterio.id].valor
if criterio.id in respostas_automaticas
else ""
),
percentual_calculado=(
respostas_automaticas[criterio.id].percentual_calculado
if criterio.id in respostas_automaticas
else None
),
)
for criterio in criterios_deste_departamento
]
)
indicadores_calculo.recalcula_colaborador(colaborador)
except Exception:
# Planilha fora do leiaute esperado, ou qualquer outra falha ao
# persistir o resultado — o `transaction.atomic()` já desfez tudo
# no banco; só falta apagar os 2 arquivos gravados em MEDIA_ROOT
# (upload de FileField não é transacional) pra não deixar lixo.
if apuracao is not None:
apuracao.planilha_tareffa.delete(save=False)
apuracao.planilha_honorarios.delete(save=False)
return Response(
{
"detail": (
"O formato de um dos arquivos não está conforme o esperado. "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IndicadorApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def gerar(self, request: Request, pk: str | None = None) -> HttpResponse:
"""ZIP com um PDF de recibo por colaborador, formatado a partir do que
já está salvo (reflete qualquer ajuste manual feito na revisão).
`colaborador_ids` (opcional, lista de ids) restringe a geração a só
esses colaboradores — o modal "Gerar Recibos" (`indicador-desempenho.js`)
deixa escolher um colaborador só, alguns específicos, por departamento
ou todos de uma vez, reaproveitando o mesmo checklist com busca/filtro
por departamento do "Ajuste Indicador em Lote". A apuração só é marcada
`concluida` quando a seleção cobre **todos** os colaboradores (sem
`colaborador_ids`, ou uma seleção que bate com o total) — gerar um
recibo avulso pra conferência não deve marcar a apuração inteira como
fechada."""
apuracao = self.get_object()
todos_colaboradores = list(
apuracao.colaboradores.all()
.select_related("departamento")
.prefetch_related("empresas", "respostas__criterio")
)
if not todos_colaboradores:
raise ValidationError({"detail": "Esta apuração não tem nenhum colaborador."})
colaborador_ids = request.data.get("colaborador_ids")
if colaborador_ids:
ids_validos = {c.id for c in todos_colaboradores}
try:
ids_pedidos = {int(colaborador_id) for colaborador_id in colaborador_ids}
except (TypeError, ValueError):
raise ValidationError({"colaborador_ids": "Ids inválidos."})
if not ids_pedidos.issubset(ids_validos):
raise ValidationError({"colaborador_ids": "Algum colaborador informado não pertence a esta apuração."})
colaboradores = [c for c in todos_colaboradores if c.id in ids_pedidos]
else:
colaboradores = todos_colaboradores
if not colaboradores:
raise ValidationError({"colaborador_ids": "Selecione ao menos um colaborador."})
if {c.id for c in colaboradores} == {c.id for c in todos_colaboradores}:
apuracao.status = IndicadorApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now()
apuracao.save(update_fields=["status", "concluida_em"])
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for colaborador in colaboradores:
nome_arquivo = re.sub(r"[^A-Za-z0-9]+", "_", colaborador.nome).strip("_") or f"colaborador_{colaborador.id}"
zf.writestr(f"{nome_arquivo}.pdf", indicadores_recibo.gera_pdf_recibo(colaborador, apuracao))
response = HttpResponse(buffer.getvalue(), content_type="application/zip")
response["Content-Disposition"] = f'attachment; filename="recibos_indicador_{apuracao.id}.zip"'
return response
def _colaboradores_do_gerente(self, apuracao: IndicadorApuracao, gerente: str) -> list[IndicadorApuracaoColaborador]:
colaboradores = list(apuracao.colaboradores.filter(gerente=gerente))
if not colaboradores:
raise ValidationError({"gerente": "Nenhum colaborador encontrado para esse gerente nesta apuração."})
return colaboradores
def _colaboradores_do_departamento(
self, apuracao: IndicadorApuracao, departamento_id: int
) -> list[IndicadorApuracaoColaborador]:
colaboradores = list(apuracao.colaboradores.filter(departamento_id=departamento_id))
if not colaboradores:
raise ValidationError(
{"departamento": "Nenhum colaborador encontrado para esse departamento nesta apuração."}
)
return colaboradores
@action(detail=True, methods=["post"], url_path="ajustar-grupo")
def ajustar_grupo(self, request: Request, pk: str | None = None) -> Response:
"""Ajuste manual do percentual Grupo — "cada gerente representa um
grupo" (ver CLAUDE.md): em vez de ajustar colaborador a colaborador,
aplica o mesmo valor a todos os colaboradores daquele `gerente` nesta
apuração de uma vez, já que o percentual conceitualmente é do grupo,
não de uma pessoa."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteGrupoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_gerente(apuracao, entrada.validated_data["gerente"])
for colaborador in colaboradores:
colaborador.pct_grupo = entrada.validated_data["pct_grupo"]
colaborador.pct_grupo_ajustado_manualmente = True
colaborador.save(update_fields=["pct_grupo", "pct_grupo_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="recalcular-grupo")
def recalcular_grupo(self, request: Request, pk: str | None = None) -> Response:
apuracao = self.get_object()
entrada = IndicadorApuracaoRecalcularGrupoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_gerente(apuracao, entrada.validated_data["gerente"])
for colaborador in colaboradores:
indicadores_calculo.limpa_ajuste_grupo(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="ajustar-departamento")
def ajustar_departamento(self, request: Request, pk: str | None = None) -> Response:
"""Ajuste manual do percentual Departamento — cada `IndicadorDepartamento`
(Fisco/Contábil, Rocket, Gerentes, ...) tem sua própria meta (ver
CLAUDE.md): aplica o mesmo valor a todos os colaboradores do
`departamento` informado nesta apuração de uma vez, não colaborador a
colaborador."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteDepartamentoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_departamento(apuracao, entrada.validated_data["departamento"])
for colaborador in colaboradores:
colaborador.pct_departamento = entrada.validated_data["pct_departamento"]
colaborador.pct_departamento_ajustado_manualmente = True
colaborador.save(update_fields=["pct_departamento", "pct_departamento_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="recalcular-departamento")
def recalcular_departamento(self, request: Request, pk: str | None = None) -> Response:
apuracao = self.get_object()
entrada = IndicadorApuracaoRecalcularDepartamentoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaboradores = self._colaboradores_do_departamento(apuracao, entrada.validated_data["departamento"])
for colaborador in colaboradores:
indicadores_calculo.limpa_ajuste_departamento(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"], url_path="ajustar-honorario-empresa")
def ajustar_honorario_empresa(self, request: Request, pk: str | None = None) -> Response:
"""Preenche (ou corrige) o honorário de uma empresa de uma vez pra
**todos** os colaboradores desta apuração que a têm (mesmo
`codigo_empresa`) — ver CLAUDE.md/`IndicadorApuracaoAjusteHonorarioEmpresaSerializer`.
Cobre as linhas com `honorario_nao_encontrado=True` (preenchimento
inicial, modal "Empresas sem Honorário") e as com
`honorario_ajustado_manualmente=True` (correção de um valor já
ajustado, modal "Empresas Ajustadas Manualmente") — nunca uma empresa
cujo honorário só veio certo da planilha e nunca foi mexido."""
apuracao = self.get_object()
entrada = IndicadorApuracaoAjusteHonorarioEmpresaSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
empresas = list(
IndicadorApuracaoEmpresa.objects.filter(
Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True),
colaborador__apuracao=apuracao,
codigo_empresa=entrada.validated_data["codigo_empresa"],
).select_related("colaborador")
)
if not empresas:
raise ValidationError(
{"codigo_empresa": "Nenhuma empresa com honorário pendente ou ajustado com esse código nesta apuração."}
)
for empresa in empresas:
empresa.honorario = entrada.validated_data["honorario"]
empresa.honorario_nao_encontrado = False
empresa.honorario_ajustado_manualmente = True
empresa.save(update_fields=["honorario", "honorario_nao_encontrado", "honorario_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(empresa.colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
class IndicadorApuracaoColaboradorViewSet(viewsets.ModelViewSet):
"""Ajuste manual do percentual Individual de um colaborador já calculado
pelo pipeline — os registros nascem todos juntos em
`IndicadorApuracaoViewSet.create()`, então não há create() nem destroy()
aqui, só GET/PATCH (ajuste) e a action `recalcular` (reverte pro modo
automático). Grupo e Departamento não são ajustados por aqui: são
editados em bloco por `IndicadorApuracaoViewSet.ajustar_grupo`/
`ajustar_departamento` (ver CLAUDE.md — "cada gerente representa um
grupo", Departamento vale pra toda a apuração)."""
http_method_names = ["get", "patch", "post", "head", "options"]
queryset = IndicadorApuracaoColaborador.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return IndicadorApuracaoColaboradorAjusteSerializer
return IndicadorApuracaoColaboradorSerializer
def perform_update(self, serializer: IndicadorApuracaoColaboradorAjusteSerializer) -> None:
colaborador: IndicadorApuracaoColaborador = self.get_object()
colaborador.pct_individual = serializer.validated_data["pct_individual"]
colaborador.pct_individual_ajustado_manualmente = True
colaborador.save(update_fields=["pct_individual", "pct_individual_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(colaborador)
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
colaborador = self.get_object()
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
@action(detail=True, methods=["post"])
def recalcular(self, request: Request, pk: str | None = None) -> Response:
"""Reverte o percentual Individual pro modo automático (limpa
qualquer ajuste manual anterior) e recalcula a partir das respostas
atuais."""
colaborador = self.get_object()
indicadores_calculo.limpa_ajuste_individual(colaborador)
indicadores_calculo.recalcula_colaborador(colaborador)
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
@action(detail=True, methods=["post"], url_path="marcar-validado")
def marcar_validado(self, request: Request, pk: str | None = None) -> Response:
"""Checklist de revisão do RH (`validado`) — não recalcula nada, só
marca/desmarca que este colaborador já foi conferido."""
colaborador = self.get_object()
entrada = IndicadorApuracaoColaboradorValidadoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
colaborador.validado = entrada.validated_data["validado"]
colaborador.save(update_fields=["validado"])
return Response(IndicadorApuracaoColaboradorSerializer(colaborador).data)
class IndicadorApuracaoEmpresaViewSet(viewsets.ModelViewSet):
"""Preenchimento manual do honorário de uma empresa com
`honorario_nao_encontrado=True` (código sem casamento na planilha de
Honorários Por Cliente) — os registros nascem todos juntos em
`IndicadorApuracaoViewSet.create()`, então não há create() nem destroy()
aqui, só GET/PATCH (+ a action `trocar-responsavel`, que precisa de POST
na lista de métodos mesmo sem create() próprio — `http_method_names` é
checado por `View.dispatch()` antes do roteamento de qualquer action)."""
http_method_names = ["get", "patch", "post", "head", "options"]
queryset = IndicadorApuracaoEmpresa.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return IndicadorApuracaoEmpresaAjusteSerializer
return IndicadorApuracaoEmpresaSerializer
def perform_update(self, serializer: IndicadorApuracaoEmpresaAjusteSerializer) -> None:
empresa: IndicadorApuracaoEmpresa = self.get_object()
empresa.honorario = serializer.validated_data["honorario"]
empresa.honorario_nao_encontrado = False
empresa.honorario_ajustado_manualmente = True
empresa.save(update_fields=["honorario", "honorario_nao_encontrado", "honorario_ajustado_manualmente"])
indicadores_calculo.recalcula_colaborador(empresa.colaborador)
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
empresa = self.get_object()
return Response(IndicadorApuracaoEmpresaSerializer(empresa).data)
@action(detail=True, methods=["post"], url_path="trocar-responsavel")
def trocar_responsavel(self, request: Request, pk: str | None = None) -> Response:
"""Reatribui esta linha (empresa+tipo) pra outro colaborador da mesma
apuração — ex.: "Fiscal" da empresa X estava com Fulano, passa a ser
de Beltrano. Recalcula os dois colaboradores (o que perdeu a empresa
e o que ganhou), já que o conjunto de `empresas` de cada um mudou."""
empresa = self.get_object()
apuracao = empresa.colaborador.apuracao
entrada = IndicadorApuracaoEmpresaTrocarResponsavelSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
novo_colaborador = IndicadorApuracaoColaborador.objects.filter(
id=entrada.validated_data["colaborador_id"], apuracao=apuracao
).first()
if novo_colaborador is None:
raise ValidationError({"colaborador_id": "Colaborador não encontrado nesta apuração."})
if novo_colaborador.id == empresa.colaborador_id:
raise ValidationError({"colaborador_id": "Este já é o responsável atual."})
if IndicadorApuracaoEmpresa.objects.filter(
colaborador=novo_colaborador, codigo_empresa=empresa.codigo_empresa, tipo=empresa.tipo
).exists():
raise ValidationError({"colaborador_id": "Este colaborador já é responsável por esta empresa/tipo."})
colaborador_antigo = empresa.colaborador
empresa.colaborador = novo_colaborador
empresa.save(update_fields=["colaborador"])
indicadores_calculo.recalcula_colaborador(colaborador_antigo)
indicadores_calculo.recalcula_colaborador(novo_colaborador)
return Response(IndicadorApuracaoDetailSerializer(apuracao).data)
class IndicadorApuracaoRespostaViewSet(viewsets.ModelViewSet):
"""Edição de uma resposta de critério já existente — os registros nascem
todos juntos em `IndicadorApuracaoViewSet.create()`, então não há create()
nem destroy() aqui, só GET/PATCH (individual) e `aplicar_em_lote` (a
"múltipla seleção" pedida pelo usuário: aplicar o mesmo valor a várias
respostas de uma vez). `post` precisa estar na lista mesmo sem create()
própria — `aplicar_em_lote` é uma action `@action(methods=["post"])`, mas
`http_method_names` é checado por `View.dispatch()` antes de qualquer
roteamento de action; sem "post" aqui, toda chamada à action cai em 405
("Método 'POST' não é permitido"), mesmo com o método certo na action."""
http_method_names = ["get", "post", "patch", "head", "options"]
queryset = IndicadorApuracaoResposta.objects.select_related("criterio", "colaborador")
serializer_class = IndicadorApuracaoRespostaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("geradoc", "indicador-desempenho")]
def perform_update(self, serializer: IndicadorApuracaoRespostaSerializer) -> None:
resposta = serializer.save()
indicadores_calculo.recalcula_colaborador(resposta.colaborador)
@action(detail=False, methods=["post"], url_path="aplicar-em-lote")
def aplicar_em_lote(self, request: Request) -> Response:
entrada = IndicadorApuracaoRespostaLoteSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
respostas = list(
IndicadorApuracaoResposta.objects.filter(id__in=dados["resposta_ids"]).select_related("colaborador")
)
if not respostas:
raise ValidationError({"resposta_ids": "Nenhuma resposta encontrada com esses ids."})
colaboradores_afetados = {resposta.colaborador_id: resposta.colaborador for resposta in respostas}
IndicadorApuracaoResposta.objects.filter(id__in=[resposta.id for resposta in respostas]).update(
valor=dados["valor"], ajustado_manualmente=True
)
for colaborador in colaboradores_afetados.values():
indicadores_calculo.recalcula_colaborador(colaborador)
return Response({"atualizadas": len(respostas)})
# ===================== Não Conformidades (Relatórios > Qualidade) =====================
def _campos_nc_ocorrencia(ocorrencia_extraida: Any) -> dict[str, Any]:
"""Campos "crus" vindos do export — nunca inclui os campos de tratativa
interna (status_tratativa/tratado_*/snapshot_tratativa/reaberto_*), que só
mudam via as actions marcar-tratado/reabrir ou pela lógica de reabertura
automática abaixo."""
return {
"data_emissao": ocorrencia_extraida.data_emissao,
"assunto": ocorrencia_extraida.assunto,
"tipo_ocorrencia": ocorrencia_extraida.tipo_ocorrencia,
"pessoas_relacionadas": ocorrencia_extraida.pessoas_relacionadas,
"origem": ocorrencia_extraida.origem,
"fornecedores_relacionados": ocorrencia_extraida.fornecedores_relacionados,
"clientes_relacionados": ocorrencia_extraida.clientes_relacionados,
"area": ocorrencia_extraida.area,
"setor": ocorrencia_extraida.setor,
"riscos_relacionados": ocorrencia_extraida.riscos_relacionados,
"data_relato": ocorrencia_extraida.data_relato,
"relato": ocorrencia_extraida.relato,
"emissor_relato": ocorrencia_extraida.emissor_relato,
"representante_gerente": ocorrencia_extraida.representante_gerente,
"prazo_finalizar": ocorrencia_extraida.prazo_finalizar,
"indicado_analise": ocorrencia_extraida.indicado_analise,
"tipos_causa": ocorrencia_extraida.tipos_causa,
"descricao_analise": ocorrencia_extraida.descricao_analise,
"responsavel_analise": ocorrencia_extraida.responsavel_analise,
"data_analise": ocorrencia_extraida.data_analise,
"analise_sem_acao_detectada": ocorrencia_extraida.tem_linha_analise_sem_acao,
"data_finalizacao": ocorrencia_extraida.data_finalizacao,
"fase": ocorrencia_extraida.fase,
}
def _campos_nc_acao(acao_extraida: Any) -> dict[str, Any]:
ultimo = acao_extraida.ultimo_acompanhamento
return {
"data_emissao": acao_extraida.data_emissao,
"tipo_acao": acao_extraida.tipo_acao,
"acao_texto": acao_extraida.acao_texto,
"data_conclusao": acao_extraida.data_conclusao,
"prazo_prorrogado": acao_extraida.prazo_prorrogado,
"justificativa_prorrogacao": acao_extraida.justificativa_prorrogacao,
"executor": acao_extraida.executor,
"emissor_acao": acao_extraida.emissor_acao,
"indicado_autorizar": acao_extraida.indicado_autorizar,
"responsavel_autorizacao": acao_extraida.responsavel_autorizacao,
"data_finalizacao": acao_extraida.data_finalizacao,
"dias_finalizacao": acao_extraida.dias_finalizacao,
"situacao": acao_extraida.situacao,
"fase": acao_extraida.fase,
"vencimento_efetivo": acao_extraida.vencimento_efetivo,
"ultimo_acompanhamento_em": ultimo.data if ultimo else None,
"ultimo_acompanhamento_eh_prorrogacao": ultimo.eh_prorrogacao if ultimo else False,
}
def _hash_acompanhamento(entrada: Any) -> str:
bruto = f"{entrada.data.isoformat()}|{entrada.autor}|{entrada.texto}"
return hashlib.sha256(bruto.encode("utf-8")).hexdigest()
def _snapshot_nc_ocorrencia_persistida(ocorrencia: NCOcorrencia) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_ocorrencia()`, mas a
partir do que já está salvo no banco — usado quando a Qualidade marca uma
ocorrência como tratada manualmente pela tela (sem ter o dataclass
recém-extraído de uma importação à mão)."""
return {
"tem_analise": bool(ocorrencia.descricao_analise.strip()),
"hash_analise": nc_diff.hash_texto(ocorrencia.descricao_analise),
"codigos_acao": sorted(ocorrencia.acoes.values_list("codigo", flat=True)),
}
def _snapshot_nc_acao_persistida(acao: NCAcao) -> dict[str, Any]:
"""Mesma forma de `nao_conformidades.diff.snapshot_acao()`, a partir do
que já está salvo no banco (ver `_snapshot_nc_ocorrencia_persistida`)."""
return {
"ultimo_acompanhamento_em": acao.ultimo_acompanhamento_em.isoformat()
if acao.ultimo_acompanhamento_em
else None,
"situacao": acao.situacao,
"fase": acao.fase,
"prazo_prorrogado": acao.prazo_prorrogado.isoformat() if acao.prazo_prorrogado else None,
"data_conclusao": acao.data_conclusao.isoformat() if acao.data_conclusao else None,
}
def _aplica_upsert_nao_conformidades(resultado: Any, agora: Any) -> dict[str, Any]:
"""Upsert de NCOcorrencia/NCAcao/NCAcompanhamento a partir do resultado do
pipeline — em lote (`bulk_create`/`bulk_update`), não um `.save()` por
item. Uma exportação real do Sigsistem já chegou a ~1700 ocorrências/
~3400 ações/~9000 acompanhamentos — a versão anterior (um
`update_or_create`/`get_or_create` por item) fazia ~2 idas ao banco por
item, quase 28 mil no total, ~60s numa máquina local. Isso não é só
lento: em produção (gunicorn atrás de nginx, timeout padrão de worker de
30s) uma requisição de 60s é derrubada no meio, e o navegador só vê uma
resposta genérica de erro — sem nenhum problema real de dado por trás.
Aqui viram ~8 idas ao banco no total: pré-carrega o que já existe (2
querysets), aplica tudo em lote (`bulk_create`/`bulk_update` por model,
com `batch_size` pra não estourar o limite de parâmetros de uma única
query do Postgres num lote desse tamanho).
A decisão de reabertura (`nao_conformidades.diff`) continua rodando por
item em memória — é só comparação de dicts/hash, não é o gargalo; só a
gravação em si virou lote. É por isso que a view (não o pacote puro)
continua sendo dona desta função: ela precisa saber o que já está
persistido pra decidir "novo" vs. "atualizar" vs. "reabrir"."""
resumo: dict[str, Any] = {
"ocorrencias_novas": 0,
"ocorrencias_atualizadas": 0,
"acoes_novas": 0,
"acoes_atualizadas": 0,
"acompanhamentos_novos": 0,
"itens_reabertos": [],
}
# ---------- Ocorrências ----------
ocorrencias_existentes = {
oc.codigo: oc
for oc in NCOcorrencia.objects.filter(codigo__in=[o.codigo for o in resultado.ocorrencias])
}
ocorrencias_novas: list[NCOcorrencia] = []
ocorrencias_para_atualizar: list[NCOcorrencia] = []
mapa_ocorrencias: dict[int, NCOcorrencia] = {}
for ocorrencia_extraida in resultado.ocorrencias:
campos = _campos_nc_ocorrencia(ocorrencia_extraida)
existente = ocorrencias_existentes.get(ocorrencia_extraida.codigo)
if existente is None:
nova = NCOcorrencia(codigo=ocorrencia_extraida.codigo, ultima_importacao_em=agora, **campos)
ocorrencias_novas.append(nova)
mapa_ocorrencias[ocorrencia_extraida.codigo] = nova
resumo["ocorrencias_novas"] += 1
continue
mapa_ocorrencias[ocorrencia_extraida.codigo] = existente
# Reimportações periódicas trazem de volta o histórico inteiro, não só
# o que mudou — pular quem não mudou nada evita reescrever milhares
# de linhas idênticas a cada importação (é o caso comum; só uma
# fração pequena do arquivo costuma ter novidade real).
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCOcorrencia.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_ocorrencia(
existente.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida)
)
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCOcorrencia.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "ocorrencia", "codigo": existente.codigo, "motivo": motivo_reabertura}
)
ocorrencias_para_atualizar.append(existente)
resumo["ocorrencias_atualizadas"] += 1
if ocorrencias_novas:
NCOcorrencia.objects.bulk_create(ocorrencias_novas, batch_size=500)
if ocorrencias_para_atualizar:
# Lista de campos derivada do próprio dict de `_campos_nc_ocorrencia`
# (nunca hardcoded solta) — impossível ficar dessincronizada se
# aquela função ganhar/perder um campo no futuro.
campos_bulk = list(_campos_nc_ocorrencia(resultado.ocorrencias[0]).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCOcorrencia.objects.bulk_update(ocorrencias_para_atualizar, campos_bulk, batch_size=500)
# ---------- Ações ----------
acoes_existentes = {
(a.ocorrencia_id, a.codigo): a
for a in NCAcao.objects.filter(ocorrencia_id__in=[oc.id for oc in ocorrencias_existentes.values()])
}
acoes_novas: list[NCAcao] = []
acoes_para_atualizar: list[NCAcao] = []
mapa_acoes: dict[tuple[int, int], NCAcao] = {}
for ocorrencia_extraida in resultado.ocorrencias:
ocorrencia = mapa_ocorrencias[ocorrencia_extraida.codigo]
for acao_extraida in ocorrencia_extraida.acoes:
campos = _campos_nc_acao(acao_extraida)
existente = acoes_existentes.get((ocorrencia.id, acao_extraida.codigo))
if existente is None:
nova = NCAcao(
ocorrencia=ocorrencia, codigo=acao_extraida.codigo, ultima_importacao_em=agora, **campos
)
acoes_novas.append(nova)
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = nova
resumo["acoes_novas"] += 1
continue
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = existente
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCAcao.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_acao(
existente.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida)
)
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCAcao.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{
"tipo": "acao",
"codigo": f"{ocorrencia_extraida.codigo}/{acao_extraida.codigo}",
"motivo": motivo_reabertura,
}
)
acoes_para_atualizar.append(existente)
resumo["acoes_atualizadas"] += 1
if acoes_novas:
NCAcao.objects.bulk_create(acoes_novas, batch_size=500)
if acoes_para_atualizar:
acao_exemplo = next(o.acoes[0] for o in resultado.ocorrencias if o.acoes)
campos_bulk_acao = list(_campos_nc_acao(acao_exemplo).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCAcao.objects.bulk_update(acoes_para_atualizar, campos_bulk_acao, batch_size=500)
# ---------- Acompanhamentos (append-only, deduplicados por hash) ----------
# `bulk_create(... , ignore_conflicts=True)`: ids de ações novas nunca
# colidem com hashes já existentes (pk nova, nunca usada antes) — o
# `ignore_conflicts` é rede de segurança só pra duplicata dentro do
# próprio arquivo importado, não pro caso normal.
hashes_existentes = set(
NCAcompanhamento.objects.filter(acao_id__in=[a.id for a in acoes_existentes.values()]).values_list(
"acao_id", "hash_entrada"
)
)
acompanhamentos_novos: list[NCAcompanhamento] = []
hashes_no_lote: set[tuple[int, str]] = set()
for ocorrencia_extraida in resultado.ocorrencias:
for acao_extraida in ocorrencia_extraida.acoes:
acao = mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)]
for entrada in acao_extraida.acompanhamentos:
hash_entrada = _hash_acompanhamento(entrada)
chave = (acao.id, hash_entrada)
if chave in hashes_existentes or chave in hashes_no_lote:
continue
hashes_no_lote.add(chave)
acompanhamentos_novos.append(
NCAcompanhamento(
acao=acao,
data=entrada.data,
autor=entrada.autor,
texto=entrada.texto,
eh_prorrogacao=entrada.eh_prorrogacao,
hash_entrada=hash_entrada,
)
)
if acompanhamentos_novos:
NCAcompanhamento.objects.bulk_create(acompanhamentos_novos, batch_size=500, ignore_conflicts=True)
resumo["acompanhamentos_novos"] = len(acompanhamentos_novos)
return resumo
class NaoConformidadeImportacaoViewSet(viewsets.ModelViewSet):
"""Ferramenta "Não Conformidades" (Relatórios > Qualidade) — permissão de
toggle único, mesmo espírito de ImportacaoPlanoSaudeViewSet/
IndicadorApuracaoViewSet. `create()` roda `portal_api.nao_conformidades.
pipeline` de forma síncrona sobre os dois arquivos exportados do Sigsistem
e faz o upsert de NCOcorrencia/NCAcao/NCAcompanhamento. Diferente das
outras duas ferramentas, este registro é só um log de auditoria — não é
"dono" das ocorrências/ações (entidades contínuas, upsertadas por código):
`destroy()` remove só o log e os 2 arquivos, nunca as ocorrências/ações já
persistidas (ver NaoConformidadeImportacao em models.py)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = NaoConformidadeImportacao.objects.all()
serializer_class = NaoConformidadeImportacaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def perform_destroy(self, instance: NaoConformidadeImportacao) -> None:
instance.arquivo_ocorrencias.delete(save=False)
instance.arquivo_acoes.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = NaoConformidadeImportacaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
with transaction.atomic():
importacao = NaoConformidadeImportacao.objects.create(
arquivo_ocorrencias=dados["arquivo_ocorrencias"],
arquivo_acoes=dados["arquivo_acoes"],
criado_por=request.user,
)
resultado = nc_pipeline.processa_importacao(
importacao.arquivo_ocorrencias.path, importacao.arquivo_acoes.path
)
resumo = _aplica_upsert_nao_conformidades(resultado, timezone.now())
importacao.resumo = resumo
importacao.avisos = resultado.avisos
importacao.save(update_fields=["resumo", "avisos"])
return Response(NaoConformidadeImportacaoSerializer(importacao).data, status=status.HTTP_201_CREATED)
class NCOcorrenciaViewSet(viewsets.ModelViewSet):
"""Ocorrências persistidas (upsertadas a cada importação, ver
NaoConformidadeImportacaoViewSet.create()) — só leitura + as duas actions
de tratativa da Qualidade; não há create()/edição de campo aqui, os dados
sempre vêm do Sigsistem via importação."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCOcorrencia.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCOcorrenciaDetailSerializer
return NCOcorrenciaSerializer
def get_queryset(self) -> QuerySet[NCOcorrencia]:
queryset = super().get_queryset()
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
if params.get("sem_analise") == "true":
# Exclui ocorrência que o Sigsistem já fechou sem exigir análise
# (ex.: "Elogios de Clientes") — bug real: sem isso, essas
# ficavam "Sem Análise" pra sempre, exigindo "Marcar como
# tratado" manual pra algo que já está resolvido no Sigsistem.
# Duas formas de "já fechada" (a mesma ocorrência real já
# apareceu nas duas): (a) não tem nenhuma ação — nesse caso quem
# carrega "Fase" é a própria ocorrência (ver
# nao_conformidades/leiaute_ocorrencias.py); (b) tem ação(ões), e
# TODAS elas já têm `data_finalizacao` preenchida — nesse caso é
# a ação que carrega a finalização, não a ocorrência.
queryset = (
queryset.filter(descricao_analise="")
.annotate(
total_acoes=Count("acoes", distinct=True),
acoes_abertas=Count("acoes", filter=Q(acoes__data_finalizacao__isnull=True), distinct=True),
)
.exclude(Q(fase=nc_classificacao.FASE_FINALIZADA) | Q(total_acoes__gt=0, acoes_abertas=0))
)
if params.get("analise_sem_acao") == "true":
# Exclui ocorrência já finalizada no Sigsistem sem nunca ter tido
# uma ação (decisão explícita do usuário, mesmo sabendo que boa
# parte são NC/Reclamação de Cliente reais sem Ação Corretiva
# formal — confia que o indicador "NCs sem ação corretiva" do
# dashboard, que não filtra por finalizada, continua cobrindo
# esses casos pra quem olha o Dashboard).
queryset = queryset.filter(analise_sem_acao_detectada=True).exclude(
fase=nc_classificacao.FASE_FINALIZADA
)
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(Q(assunto__icontains=termo) | Q(codigo__icontains=termo))
if self.action == "retrieve":
queryset = queryset.prefetch_related("acoes")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_TRATADO
ocorrencia.tratado_em = timezone.now()
ocorrencia.tratado_por = request.user
ocorrencia.snapshot_tratativa = _snapshot_nc_ocorrencia_persistida(ocorrencia)
ocorrencia.reaberto_em = None
ocorrencia.reaberto_motivo = ""
ocorrencia.save()
return Response(NCOcorrenciaSerializer(ocorrencia).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
ocorrencia = self.get_object()
ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE
ocorrencia.snapshot_tratativa = None
ocorrencia.reaberto_em = timezone.now()
ocorrencia.reaberto_motivo = "Reaberta manualmente."
ocorrencia.save()
return Response(NCOcorrenciaSerializer(ocorrencia).data)
def _status_prazo_q(bucket: str, hoje: date) -> Q:
"""Devolve a condição de UM bucket como `Q`, pra poder combinar vários
buckets com OR (`_filtra_status_prazo` abaixo) — o frontend permite
marcar mais de um ao mesmo tempo (ex.: "Vencidas" + "Vence em 7 dias").
Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()`
como filtros de data sobre `vencimento_efetivo` (o cálculo em si roda em
Python, não dá pra usar direto num `.filter()`).
Todo bucket de prazo (vencida/vence em X dias/no prazo/sem vencimento)
exclui ações já finalizadas — bug real corrigido: um export com
histórico (`data_finalizacao` preenchida) tem `vencimento_efetivo` no
passado mesmo pra ação já concluída há muito tempo, o que fazia a
esmagadora maioria aparecer como "vencida" sem estar realmente em
aberto."""
if bucket == nc_classificacao.STATUS_FINALIZADA:
return Q(data_finalizacao__isnull=False)
aberta = Q(data_finalizacao__isnull=True)
if bucket == nc_classificacao.STATUS_NAO_FINALIZADA:
return aberta
if bucket == nc_classificacao.STATUS_VENCIDA:
return aberta & Q(vencimento_efetivo__lt=hoje)
if bucket == nc_classificacao.STATUS_VENCE_7:
return aberta & Q(vencimento_efetivo__gte=hoje, vencimento_efetivo__lte=hoje + timedelta(days=7))
if bucket == nc_classificacao.STATUS_VENCE_30:
return aberta & Q(
vencimento_efetivo__gt=hoje + timedelta(days=7), vencimento_efetivo__lte=hoje + timedelta(days=30)
)
if bucket == nc_classificacao.STATUS_NO_PRAZO:
return aberta & Q(vencimento_efetivo__gt=hoje + timedelta(days=30))
if bucket == nc_classificacao.STATUS_SEM_VENCIMENTO:
return aberta & Q(vencimento_efetivo__isnull=True)
raise ValidationError({"status_prazo": "Valor inválido."})
def _filtra_status_prazo(queryset: QuerySet[NCAcao], buckets: list[str], hoje: date) -> QuerySet[NCAcao]:
"""Combina um ou mais buckets de prazo com OR (filtro múltiplo do
frontend — ver `?status_prazo=` repetido em `NCAcaoViewSet.get_queryset`)."""
combinado = Q()
for bucket in buckets:
combinado |= _status_prazo_q(bucket, hoje)
return queryset.filter(combinado)
class NCAcaoViewSet(viewsets.ModelViewSet):
"""Ações persistidas — só leitura + tratativa (mesmo espírito de
NCOcorrenciaViewSet)."""
http_method_names = ["get", "post", "head", "options"]
queryset = NCAcao.objects.select_related("ocorrencia")
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "nao-conformidades")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "retrieve":
return NCAcaoDetailSerializer
return NCAcaoSerializer
def get_queryset(self) -> QuerySet[NCAcao]:
queryset = super().get_queryset()
params = self.request.query_params
if params.get("status_tratativa"):
queryset = queryset.filter(status_tratativa=params["status_tratativa"])
status_prazo_list = params.getlist("status_prazo")
if status_prazo_list:
queryset = _filtra_status_prazo(queryset, status_prazo_list, timezone.localdate())
if params.get("search"):
termo = params["search"]
queryset = queryset.filter(
Q(acao_texto__icontains=termo)
| Q(executor__icontains=termo)
| Q(ocorrencia__codigo__icontains=termo)
| Q(ocorrencia__clientes_relacionados__icontains=termo)
)
if self.action == "retrieve":
queryset = queryset.prefetch_related("acompanhamentos")
return queryset
@action(detail=True, methods=["post"], url_path="marcar-tratado")
def marcar_tratado(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_TRATADO
acao.tratado_em = timezone.now()
acao.tratado_por = request.user
acao.snapshot_tratativa = _snapshot_nc_acao_persistida(acao)
acao.reaberto_em = None
acao.reaberto_motivo = ""
acao.save()
return Response(NCAcaoSerializer(acao).data)
@action(detail=True, methods=["post"])
def reabrir(self, request: Request, pk: str | None = None) -> Response:
acao = self.get_object()
acao.status_tratativa = NCAcao.STATUS_PENDENTE
acao.snapshot_tratativa = None
acao.reaberto_em = timezone.now()
acao.reaberto_motivo = "Reaberta manualmente."
acao.save()
return Response(NCAcaoSerializer(acao).data)
def _subtrai_meses(data_ref: date, meses: int) -> date:
mes_total = data_ref.month - 1 - meses
ano = data_ref.year + mes_total // 12
mes = mes_total % 12 + 1
dia = min(data_ref.day, calendar.monthrange(ano, mes)[1])
return date(ano, mes, dia)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def nao_conformidades_dashboard_view(request: Request) -> Response:
"""Indicadores quantitativos de Não Conformidades — contagens "abertas
agora" (ocorrências/ações/sem análise/análise sem ação/NCs sem ação
corretiva) nunca são filtradas por período (são sempre o estado atual);
só os rankings de motivo/cliente/colaborador aceitam `?meses=` (default 6,
generaliza a regra fixa "últimos 6 meses" da skill original) ou
`?data_inicio=&data_fim=` (AAAA-MM-DD) explícitos."""
if not request.user.permissao_app("relatorios", "nao-conformidades"):
raise PermissionDenied("Você não tem permissão para acessar Não Conformidades.")
hoje = timezone.localdate()
data_inicio_param = request.query_params.get("data_inicio")
data_fim_param = request.query_params.get("data_fim")
if data_inicio_param and data_fim_param:
data_inicio = parse_date(data_inicio_param)
data_fim = parse_date(data_fim_param)
if data_inicio is None or data_fim is None:
raise ValidationError({"detail": "data_inicio/data_fim devem estar no formato AAAA-MM-DD."})
else:
try:
meses = int(request.query_params.get("meses") or 6)
except ValueError:
raise ValidationError({"meses": "Informe um número inteiro de meses."})
data_fim = hoje
data_inicio = _subtrai_meses(hoje, meses)
ocorrencias_periodo = NCOcorrencia.objects.filter(data_emissao__gte=data_inicio, data_emissao__lte=data_fim)
status_counts = {chave: 0 for chave in nc_classificacao.STATUS_PRAZO_LABELS}
for venc, finalizada_em in NCAcao.objects.values_list("vencimento_efetivo", "data_finalizacao"):
status_counts[nc_classificacao.status_prazo(venc, hoje, finalizada=bool(finalizada_em))] += 1
ncs_sem_corretiva = 0
for ocorrencia in NCOcorrencia.objects.filter(tipo_ocorrencia__in=nc_classificacao.TIPOS_NC).prefetch_related(
"acoes"
):
categorias = {nc_classificacao.categoria_acao(a.tipo_acao) for a in ocorrencia.acoes.all()}
if nc_classificacao.CATEGORIA_CORRETIVA not in categorias:
ncs_sem_corretiva += 1
motivos = (
ocorrencias_periodo.exclude(tipos_causa="")
.values("tipos_causa")
.annotate(quantidade=Count("id"))
.order_by("-quantidade")[:15]
)
# Clientes/Colaboradores: conta TODOS os tipos de ocorrência (decisão do
# usuário — a clareza vem de quebrar por tipo com cor no frontend, não de
# restringir o filtro a NC/Reclamação como a v1 fazia só pra clientes).
filtro_nc = Count("id", filter=Q(tipo_ocorrencia="Não Conformidade"))
filtro_reclamacao = Count("id", filter=Q(tipo_ocorrencia="Reclamação de Cliente"))
clientes = (
ocorrencias_periodo.exclude(clientes_relacionados="")
.values("clientes_relacionados")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
colaboradores = (
ocorrencias_periodo.exclude(indicado_analise="")
.values("indicado_analise")
.annotate(nc=filtro_nc, reclamacao=filtro_reclamacao, total=Count("id"))
.order_by("-total")[:15]
)
tempo_analise = NCOcorrencia.objects.filter(
data_analise__isnull=False, data_emissao__isnull=False
).aggregate(
media=Avg(ExpressionWrapper(F("data_analise") - F("data_emissao"), output_field=DurationField())),
n=Count("id"),
)
tempo_abertura_acao = NCAcao.objects.filter(
data_emissao__isnull=False, ocorrencia__data_analise__isnull=False
).aggregate(
media=Avg(ExpressionWrapper(F("data_emissao") - F("ocorrencia__data_analise"), output_field=DurationField())),
n=Count("id"),
)
tempo_ciclo_completo = NCAcao.objects.filter(
data_finalizacao__isnull=False, ocorrencia__data_emissao__isnull=False
).aggregate(
media=Avg(
ExpressionWrapper(F("data_finalizacao") - F("ocorrencia__data_emissao"), output_field=DurationField())
),
n=Count("id"),
)
def _dias(intervalo) -> float | None:
return round(intervalo.total_seconds() / 86400, 1) if intervalo is not None else None
# Tempo de execução (abertura → finalização da ação) — só sobre ações com
# Data de Finalização preenchida (ver leiaute_ocorrencias.py). "Correção"/
# "Ação Corretiva" continuam agrupadas pela heurística de
# categoria_acao() (o texto real varia — "Correção", "Correção -
# Financeiro", "Correção - Desconto Indicadores" etc. — mas são a mesma
# categoria pra fins de NC/Reclamação). O resto ("Outros" pra
# categoria_acao()) é detalhado pelo texto real do "Tipo de Ação" em vez
# de virar um "Outros" só — pedido explícito do usuário, pra ver cada
# tipo separado (Oportunidade de Melhoria, Planejamento Estratégico
# etc.), já que esses não têm variação de texto na amostra real.
execucao_dias_por_tipo = NCAcao.objects.filter(
data_finalizacao__isnull=False, data_emissao__isnull=False
).annotate(
dias=ExpressionWrapper(F("data_finalizacao") - F("data_emissao"), output_field=DurationField())
).values_list("tipo_acao", "dias")
todos_dias: list[float] = []
dias_por_categoria: dict[str, list[float]] = {}
for tipo_acao, dias in execucao_dias_por_tipo:
valor = dias.total_seconds() / 86400
todos_dias.append(valor)
categoria = nc_classificacao.categoria_acao(tipo_acao)
chave = categoria if categoria != nc_classificacao.CATEGORIA_OUTROS else (tipo_acao or "Sem tipo informado")
dias_por_categoria.setdefault(chave, []).append(valor)
execucao_por_categoria = []
for categoria in (nc_classificacao.CATEGORIA_CORRECAO, nc_classificacao.CATEGORIA_CORRETIVA):
valores = dias_por_categoria.pop(categoria, None)
if valores:
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)
# O restante (tipos reais fora de Correção/Ação Corretiva), do mais pro
# menos frequente.
for categoria, valores in sorted(dias_por_categoria.items(), key=lambda item: -len(item[1])):
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)
return Response(
{
"ocorrencias_abertas": NCOcorrencia.objects.count(),
"acoes_abertas_por_status": status_counts,
"sem_analise": NCOcorrencia.objects.filter(descricao_analise="").count(),
"analise_sem_acao": NCOcorrencia.objects.filter(analise_sem_acao_detectada=True).count(),
"ncs_sem_acao_corretiva": ncs_sem_corretiva,
"motivos_abertura": [{"causa": m["tipos_causa"], "quantidade": m["quantidade"]} for m in motivos],
"clientes_maior_incidencia": [
{
"cliente": c["clientes_relacionados"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in clientes
],
"colaboradores_maior_incidencia": [
{
"colaborador": c["indicado_analise"],
"nc": c["nc"],
"reclamacao": c["reclamacao"],
"outros": c["total"] - c["nc"] - c["reclamacao"],
"total": c["total"],
}
for c in colaboradores
],
"tempos_medios": {
"preenchimento_analise_dias": _dias(tempo_analise["media"]),
"preenchimento_analise_n": tempo_analise["n"],
"abertura_acao_dias": _dias(tempo_abertura_acao["media"]),
"abertura_acao_n": tempo_abertura_acao["n"],
"execucao_acao_dias": round(sum(todos_dias) / len(todos_dias), 1) if todos_dias else None,
"execucao_acao_n": len(todos_dias),
"execucao_por_categoria": execucao_por_categoria,
"ciclo_completo_dias": _dias(tempo_ciclo_completo["media"]),
"ciclo_completo_n": tempo_ciclo_completo["n"],
},
"periodo": {"data_inicio": data_inicio.isoformat(), "data_fim": data_fim.isoformat()},
}
)
def _contabil_monta_historico(cabecalho: ContabilCabecalhoExtraido) -> list[ContabilSnapshotHistorico]:
"""Histórico da mesma empresa (até 2 apurações anteriores, mais recente
primeiro) já persistidas — usado pelas regras de variação mês a mês
(`dashboard_contabil.regras`). Só uma consulta simples ao ORM, sem
depender de nenhum relatório complementar anexado (ver CLAUDE.md do
pacote)."""
competencia_atual = cabecalho.periodo_fim.replace(day=1)
anteriores = (
ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia__lt=competencia_atual)
.order_by("-competencia")
.prefetch_related("contas", "linhas_dre")[:2]
)
return [
ContabilSnapshotHistorico(
competencia=apuracao.competencia,
contas={conta.codigo: conta.saldo_atual for conta in apuracao.contas.all()},
linhas_dre={linha.descricao: linha.valor for linha in apuracao.linhas_dre.all()},
)
for apuracao in anteriores
]
def _contabil_monta_historico_completo(codigo_empresa: str, competencia_atual: date) -> list[ContabilSnapshotHistorico]:
"""Mesma ideia de `_contabil_monta_historico`, mas **sem** o `[:2]` —
histórico completo da empresa (mais recente primeiro), usado só pelo
relatório "Gerar Dashboard" (`ContabilApuracaoViewSet.dashboard`), pro
gráfico de evolução e pro cálculo de Depreciação/Amortização do mês
(`dashboard_contabil.indicadores`). As regras de auditoria continuam
usando `_contabil_monta_historico` (capado em 2), sem mudar."""
anteriores = (
ContabilApuracao.objects.filter(codigo_empresa=codigo_empresa, competencia__lt=competencia_atual)
.order_by("-competencia")
.prefetch_related("contas", "linhas_dre")
)
return [
ContabilSnapshotHistorico(
competencia=apuracao.competencia,
contas={conta.codigo: conta.saldo_atual for conta in apuracao.contas.all()},
linhas_dre={linha.descricao: linha.valor for linha in apuracao.linhas_dre.all()},
)
for apuracao in anteriores
]
@dataclasses.dataclass
class _ContabilDadosIndicadores:
"""Dados brutos de uma apuração já resolvidos do banco, compartilhados
pelo cálculo dos indicadores de sistema (`_contabil_calcula_indicadores`)
e dos personalizados (`_contabil_calcula_indicadores_personalizados`) —
evita duas idas ao banco pelos mesmos dados quando as duas telas que
calculam indicador (`dashboard()`/`indicadores()`) pedem os dois
cálculos juntos."""
contas_atuais: dict[str, Decimal]
contas_anteriores: dict[str, Decimal] | None
dre_atual: dict[str, Decimal]
resultado_liquido: Decimal
historico_completo: list[ContabilSnapshotHistorico]
def _contabil_coleta_dados_indicadores(apuracao: ContabilApuracao) -> _ContabilDadosIndicadores:
contas = list(apuracao.contas.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
contas_atuais = {conta.codigo: conta.saldo_atual for conta in contas}
dre_atual = {linha.descricao: linha.valor for linha in linhas_dre}
resultado_liquido = linhas_dre[-1].valor if linhas_dre else Decimal(0)
historico_completo = _contabil_monta_historico_completo(apuracao.codigo_empresa, apuracao.competencia)
# `variacao_conta` (componente de indicador personalizado, ver abaixo)
# só compara contra a apuração anterior imediata — mesma convenção já
# usada pra Depreciação/Amortização em `indicadores.calcula_indicadores`
# (`historico[0]`).
contas_anteriores = historico_completo[0].contas if historico_completo else None
return _ContabilDadosIndicadores(contas_atuais, contas_anteriores, dre_atual, resultado_liquido, historico_completo)
def _contabil_calcula_indicadores(
dados: _ContabilDadosIndicadores,
) -> dashboard_contabil_indicadores.IndicadoresFinanceiros:
"""**Não é mais chamada por `ContabilApuracaoViewSet.dashboard()`/
`.indicadores()`** desde que os 11 indicadores "de sistema" foram
migrados pra `IndicadorContabilDefinicao` de verdade — mantida só como
referência/auditoria (ver docstring do módulo `dashboard_contabil.
indicadores`), útil pra recalcular e conferir manualmente se algum
valor exibido um dia parecer suspeito."""
return dashboard_contabil_indicadores.calcula_indicadores(
dados.contas_atuais, dados.dre_atual, dados.resultado_liquido, dados.historico_completo
)
def _contabil_resolve_componente_personalizado(
componente: IndicadorContabilComponente,
dados: _ContabilDadosIndicadores,
valores: dict[str, Decimal | None],
) -> Decimal | None:
"""Um componente de indicador personalizado (`IndicadorContabilComponente`),
resolvido pro valor que entra na fórmula (ver `dashboard_contabil.formula`).
Contas somadas em valor absoluto — mesma convenção de
`dashboard_contabil.indicadores._saldo()` (Passivo/Patrimônio Líquido
vêm negativos no relatório); linhas da DRE somadas com o sinal já
impresso (a DRE não segue essa convenção de valor absoluto)."""
if componente.tipo == IndicadorContabilComponente.TIPO_CONTAS:
if not componente.contas_codigos:
return None
return sum(
(abs(dados.contas_atuais.get(codigo, Decimal(0))) for codigo in componente.contas_codigos), Decimal(0)
)
if componente.tipo == IndicadorContabilComponente.TIPO_LINHA_DRE:
if not componente.linhas_dre_descricoes:
return None
return sum(
(dados.dre_atual.get(descricao, Decimal(0)) for descricao in componente.linhas_dre_descricoes),
Decimal(0),
)
if componente.tipo == IndicadorContabilComponente.TIPO_VARIACAO_CONTA:
if dados.contas_anteriores is None or not componente.contas_codigos:
return None
atual = sum(
(abs(dados.contas_atuais.get(codigo, Decimal(0))) for codigo in componente.contas_codigos), Decimal(0)
)
anterior = sum(
(abs(dados.contas_anteriores.get(codigo, Decimal(0))) for codigo in componente.contas_codigos),
Decimal(0),
)
return atual - anterior
if componente.tipo == IndicadorContabilComponente.TIPO_INDICADOR:
return valores.get(componente.indicador_referenciado)
if componente.tipo == IndicadorContabilComponente.TIPO_RESULTADO_LIQUIDO:
return dados.resultado_liquido
return None
def _contabil_calcula_indicadores_personalizados(
dados: _ContabilDadosIndicadores, indicadores_selecionados: list[str]
) -> dict[str, Decimal | None]:
"""Resolve todo `IndicadorContabilDefinicao` **aplicável a esta
apuração** contra os dados dela — `padrao=True` sempre entra;
`padrao=False` só entra se sua chave estiver em `indicadores_selecionados`
(`ContabilApuracao.indicadores_selecionados`, ver "Gerenciar Indicadores"
na aba "Dashboard").
Iterativo, não uma ordem topológica "de verdade": a cada rodada calcula
todo indicador cujos componentes tipo="indicador" já têm valor
conhecido (já resolvido numa rodada anterior), repete até não sobrar
progresso — cobre encadeamento entre indicadores (um referenciando o
outro, inclusive um dos antigos 11 "de sistema", hoje registros normais
aqui). Um indicador cuja dependência nunca resolve (referência quebrada,
referência a um indicador não padrão não selecionado nesta apuração, ou
ciclo entre dois indicadores) fica com valor `None` — nunca levanta erro
pro chamador, uma fórmula mal configurada não pode derrubar o cálculo do
relatório inteiro."""
definicoes = {
d.chave: d
for d in IndicadorContabilDefinicao.objects.prefetch_related("componentes").all()
if d.padrao or d.chave in indicadores_selecionados
}
valores: dict[str, Decimal | None] = {}
pendentes = dict(definicoes)
progrediu = True
while pendentes and progrediu:
progrediu = False
for chave in list(pendentes):
definicao = pendentes[chave]
componentes = list(definicao.componentes.all())
dependencias = [
c.indicador_referenciado for c in componentes if c.tipo == IndicadorContabilComponente.TIPO_INDICADOR
]
if any(dependencia not in valores for dependencia in dependencias):
continue
valores_componentes = {
componente.chave: _contabil_resolve_componente_personalizado(componente, dados, valores)
for componente in componentes
}
try:
valores[chave] = dashboard_contabil_formula.avalia_formula(definicao.formula, valores_componentes)
except dashboard_contabil_formula.FormulaInvalidaError:
valores[chave] = None
del pendentes[chave]
progrediu = True
for chave in pendentes:
valores[chave] = None
return {chave: valores[chave] for chave in definicoes}
def _contabil_formata_indicador(valor: Decimal | None, formato: str) -> str:
"""Formata o valor de um indicador do relatório "Gerar Dashboard" —
todo indicador (inclusive os 11 antigos "de sistema", migrados pra
`IndicadorContabilDefinicao` na rodada em que este comentário foi
escrito) vem de uma definição com `formato` livre, e o Django Template
Language não permite escolher um filtro (`moeda`/`percentual`/`indice`
de `contabil_extras.py`) por nome vindo de uma variável — por isso todo
valor chega no contexto já como texto pronto. Reaproveita as mesmas 3
funções (um filtro de template continua sendo uma função Python comum,
só registrada — chamável direto)."""
if formato == IndicadorContabilDefinicao.FORMATO_MOEDA:
return dashboard_contabil_extras.moeda(valor)
if formato == IndicadorContabilDefinicao.FORMATO_PERCENTUAL:
return dashboard_contabil_extras.percentual(valor)
return dashboard_contabil_extras.indice(valor)
def _contabil_numero_bruto(valor: Decimal | None) -> str:
"""Espelha o filtro `numero_bruto` de `contabil_extras.py` — valor cru
(sem formatação BR), só pra alimentar `data-count` da animação de
contagem dos cards (ver `pidDcrAnimaContadores()` no template)."""
return "" if valor is None else str(float(valor))
_CONTABIL_INDICADOR_DATA_FORMAT = {
IndicadorContabilDefinicao.FORMATO_MOEDA: "moeda",
IndicadorContabilDefinicao.FORMATO_PERCENTUAL: "pct",
IndicadorContabilDefinicao.FORMATO_INDICE: "idx",
}
# Réplica visual do que os 11 indicadores "de sistema" tinham antes de
# virarem `IndicadorContabilDefinicao` de verdade (migração da mesma
# rodada) — cor por sinal/threshold só onde já era seguro sem inventar
# limiar novo (ver CLAUDE.md do pacote, "Fórmulas"), destaque dourado
# alternado, e as 2 notas fixas (a de EBITDA é dinâmica, tratada à parte
# onde os cards são montados). Indicador personalizado criado depois disso
# não entra em nenhum desses dicts — nasce sem cor/destaque/nota, mesmo
# espírito de antes.
_CONTABIL_INDICADOR_COR_REGRA = {
"roa": "sign",
"roe": "sign",
"ebit": "sign",
"ebitda": "sign",
"liquidez_corrente": "liquidez",
"liquidez_seca": "liquidez",
"liquidez_geral": "liquidez",
}
_CONTABIL_INDICADOR_DOURADO = {"ebit", "ebitda", "composicao_endividamento", "grau_endividamento", "ipl"}
_CONTABIL_INDICADOR_NOTA = {
"kanitz": "Estimativa — fórmula padrão, não calibrada",
"liquidez_geral": "Aproximado — ver observação no relatório técnico",
}
_CONTABIL_ORDEM_GRUPOS_INDICADORES = ["Indicadores de Resultado", "Indicadores de Liquidez e Endividamento"]
# Desenho (miolo do <svg>) de cada opção de `IndicadorContabilDefinicao.icone`
# pro card do relatório "Gerar Dashboard" — ver comentário no model sobre a
# cópia irmã deste dict em `PID_DC_INDICADOR_ICONES`
# (static/js/dashboard-contabil.js), mantida em sincronia manualmente.
# `mark_safe` é seguro aqui: são só literais Python fixos neste arquivo,
# nunca texto vindo de usuário/banco.
_CONTABIL_ICONES_SVG = {
IndicadorContabilDefinicao.ICONE_BARRAS: mark_safe('<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>'),
IndicadorContabilDefinicao.ICONE_TENDENCIA_ALTA: mark_safe(
'<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>'
),
IndicadorContabilDefinicao.ICONE_TENDENCIA_BAIXA: mark_safe(
'<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>'
),
IndicadorContabilDefinicao.ICONE_PERCENTUAL: mark_safe(
'<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>'
),
IndicadorContabilDefinicao.ICONE_PIZZA: mark_safe(
'<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'
),
IndicadorContabilDefinicao.ICONE_ATIVIDADE: mark_safe('<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'),
IndicadorContabilDefinicao.ICONE_MOEDA: mark_safe(
'<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>'
),
IndicadorContabilDefinicao.ICONE_ALVO: mark_safe(
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>'
),
IndicadorContabilDefinicao.ICONE_CAMADAS: mark_safe(
'<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/>'
'<polyline points="2 12 12 17 22 12"/>'
),
IndicadorContabilDefinicao.ICONE_CARTAO: mark_safe(
'<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>'
),
IndicadorContabilDefinicao.ICONE_SELO: mark_safe(
'<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>'
),
}
def _contabil_monta_cards_indicadores(
apuracao: ContabilApuracao, valores: dict[str, Decimal | None]
) -> list[dict[str, Any]]:
"""Monta a lista plana de cards de indicador (de qualquer
`IndicadorContabilDefinicao` ativa nesta apuração — padrão, ou não
padrão selecionada — e não escondida) pro relatório "Gerar Dashboard",
já com valor formatado/cru/regra de cor/nota prontos pro template (ver
`_contabil_agrupa_indicadores_cards()` logo abaixo pra o passo
seguinte, agrupar por `grupo`)."""
ocultos = apuracao.indicadores_ocultos
selecionados = apuracao.indicadores_selecionados
cards = []
for definicao in IndicadorContabilDefinicao.objects.order_by("nome"):
if not (definicao.padrao or definicao.chave in selecionados):
continue
if definicao.chave in ocultos:
continue
valor = valores.get(definicao.chave)
nota = _CONTABIL_INDICADOR_NOTA.get(definicao.chave, "")
if definicao.chave == "ebitda" and valor is None:
nota = "Sem apuração anterior desta empresa"
cards.append(
{
"chave": definicao.chave,
"nome": definicao.nome,
"descricao": definicao.descricao,
# Fórmula mostrada ao cliente no relatório: usa o texto livre
# `formula_exibicao` quando cadastrado; cai pra `formula`
# (a expressão técnica de cálculo, em snake_case) só como
# fallback pra indicador que ainda não teve esse campo
# preenchido — nunca fica sem nenhuma fórmula visível.
"formula": definicao.formula_exibicao.strip() or definicao.formula,
"grupo": definicao.grupo,
"valor_formatado": _contabil_formata_indicador(valor, definicao.formato),
"valor_bruto": _contabil_numero_bruto(valor),
"data_format": _CONTABIL_INDICADOR_DATA_FORMAT.get(definicao.formato, "idx"),
"cor_regra": _CONTABIL_INDICADOR_COR_REGRA.get(definicao.chave, ""),
"dourado": definicao.chave in _CONTABIL_INDICADOR_DOURADO,
"nota": nota,
"icone_svg": _CONTABIL_ICONES_SVG.get(definicao.icone, _CONTABIL_ICONES_SVG[IndicadorContabilDefinicao.ICONE_BARRAS]),
}
)
for indice, card in enumerate(cards):
card["indice_animacao"] = indice
return cards
def _contabil_agrupa_indicadores_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Agrupa os cards já montados por `grupo` — "Indicadores de Resultado"
e "Indicadores de Liquidez e Endividamento" (os 2 grupos que os 11
migrados usam) sempre primeiro, nessa ordem; qualquer outro grupo
(ex. "Indicadores Personalizados", ou um nome livre que um indicador
novo venha a usar no futuro) entra depois, em ordem alfabética."""
grupos: dict[str, list[dict[str, Any]]] = {}
for card in cards:
grupos.setdefault(card["grupo"], []).append(card)
def ordenacao(nome_grupo: str) -> tuple[int, str]:
if nome_grupo in _CONTABIL_ORDEM_GRUPOS_INDICADORES:
return (_CONTABIL_ORDEM_GRUPOS_INDICADORES.index(nome_grupo), nome_grupo)
return (len(_CONTABIL_ORDEM_GRUPOS_INDICADORES), nome_grupo)
return [{"nome": nome, "cards": grupos[nome]} for nome in sorted(grupos, key=ordenacao)]
def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]:
"""Indicadores (já agrupados em cards) + observações visíveis ao cliente
+ achados com observação do contador — o conteúdo da aba "Resumo" do
relatório "Gerar Dashboard", reaproveitado tal e qual pelo PDF do Resumo
(`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve
`observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular
`ancora` — isso é específico de cada consumidor (o relatório HTML separa
e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista)."""
dados = _contabil_coleta_dados_indicadores(apuracao)
valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id"))
# Observações que o contador marcou pra mostrar ao cliente, já no recorte
# de vigência desta competência (ver `ContabilObservacao`) — inclui as
# herdadas de competências anteriores ainda não encerradas, que é
# justamente o ponto do histórico.
observacoes_visiveis = [
observacao
for observacao in ContabilObservacao.vigentes_para(
apuracao.codigo_empresa, apuracao.competencia
).select_related("criado_por")
if observacao.mostrar_ao_cliente
]
return {
"indicadores_grupos": _contabil_agrupa_indicadores_cards(cards),
"observacoes_visiveis": observacoes_visiveis,
# Cards que o contador escondeu na aba "Dashboard" da tela de revisão
# (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima;
# achado oculto/sem observação não entra no relatório gerado — o
# achado em si continua listado normal na aba Achados da revisão, só
# esta seção de observações é que o omite.
"achados_com_observacao": [
achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio
],
}
# A partir deste nível (equivalente ao 3º segmento de código de classificação,
# ex. "1.01.01") o Balancete/D.R.E. nascem recolhidos, tanto na tela de
# revisão (dashboard-contabil.js) quanto no relatório "Gerar Dashboard" —
# pedido explícito do usuário pra reduzir a poluição visual da árvore
# completa; o contador expande sob demanda pra ver o nível analítico abaixo.
_CONTABIL_NIVEL_ABERTO_PADRAO = 2
def _contabil_arvore_contexto(
itens: list[Any],
chave: str,
nivel_fn: Callable[[Any], int],
px_por_nivel: int,
observacoes_por_chave: dict[str, list[ContabilObservacao]] | None = None,
chave_fn: Callable[[Any], str] | None = None,
) -> list[dict[str, Any]]:
"""Monta o contexto de uma árvore recolhível (Balancete/DRE) pro
relatório "Gerar Dashboard" — mesmo algoritmo de `dcContaNivel()`/
`renderContas()`/`renderDre()` em `dashboard-contabil.js` ("tem filhos" =
o próximo item tem nível maior), mas calculado aqui no servidor porque o
relatório é HTML estático renderizado uma vez, não uma tela que
re-renderiza a cada clique — o JS do relatório só alterna `hidden` nas
linhas já prontas (ver `dashboard-contabil-relatorio.html`). `colapsado_padrao`
marca os itens que devem nascer recolhidos (ver `_CONTABIL_NIVEL_ABERTO_PADRAO`).
`observacoes_por_chave`+`chave_fn` (opcionais, usados pelo relatório)
penduram em cada item as observações vigentes daquela conta/linha que o
contador marcou pra mostrar ao cliente — casadas pela chave natural do
histórico (`ContabilObservacao.alvo_chave`), não por id de linha."""
niveis = [nivel_fn(item) for item in itens]
contexto = []
for i, item in enumerate(itens):
tem_filhos = i + 1 < len(itens) and niveis[i + 1] > niveis[i]
observacoes: list[ContabilObservacao] = []
if observacoes_por_chave is not None and chave_fn is not None:
observacoes = observacoes_por_chave.get(chave_fn(item), [])
contexto.append(
{
chave: item,
"nivel": niveis[i],
"nivel_px": niveis[i] * px_por_nivel,
"tem_filhos": tem_filhos,
"colapsado_padrao": tem_filhos and niveis[i] >= _CONTABIL_NIVEL_ABERTO_PADRAO,
"observacoes": observacoes,
}
)
return contexto
def _contabil_chave_alvo(alvo_tipo: str, alvo: Any) -> str:
"""Chave natural de uma conta/linha pro histórico de observações — as
mesmas usadas por `_contabil_sincroniza_*()` no reprocessamento
(`(codigo, descricao)` no Balancete, `(descricao, nivel)` na DRE/Análise
Vertical), pra uma observação seguir a mesma conta de uma competência pra
outra."""
if alvo_tipo == ContabilObservacao.ALVO_CONTA:
return ContabilObservacao.chave_conta(alvo.codigo, alvo.descricao)
return ContabilObservacao.chave_linha(alvo.descricao, alvo.nivel)
def _contabil_observacoes_por_chave(apuracao: ContabilApuracao, alvo_tipo: str) -> dict[str, list[ContabilObservacao]]:
"""Observações vigentes desta empresa/competência (ver
`ContabilObservacao.vigentes_para()`), agrupadas pela chave natural do
alvo — um dict pra casar com cada conta/linha da apuração sem uma query
por linha."""
agrupadas: dict[str, list[ContabilObservacao]] = {}
consulta = (
ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia)
.filter(alvo_tipo=alvo_tipo)
.select_related("criado_por", "encerrada_por")
)
for observacao in consulta:
agrupadas.setdefault(observacao.alvo_chave, []).append(observacao)
return agrupadas
def _contabil_sincroniza_contas(
apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida]
) -> None:
"""Resincroniza `ContabilConta` a partir de um reprocessamento
(`ContabilApuracaoViewSet.reprocessar()`) — casa pelo par
`(codigo, descricao)` (chave natural já usada pelo histórico de
observações, ver `ContabilObservacao.chave_conta()`; `codigo` sozinho
não é único, várias contas analíticas podem compartilhar a mesma
classificação) e atualiza os registros **no lugar** (mesmo `id`), pra
achados que referenciam essas contas nunca perderem a FK. Conta sem
mudança real mantém `validado`/`alterada_reprocessamento` como estavam;
conta com algum campo divergente da versão anterior volta pra
`validado=False` e `alterada_reprocessamento=True`, guardando o
`saldo_atual` de antes em `valor_anterior_reprocessamento` (só pro
tooltip do badge no frontend). Uma conta renomeada (mesmo código, outra
descrição) é tratada como uma conta diferente — a antiga é excluída e uma
nova é criada, mesmo trade-off que `_contabil_sincroniza_linhas_dre()`
já aceita pra `(descricao, nivel)`. Observação nunca é afetada por aqui:
ela não mora mais na linha, e sim em `ContabilObservacao` (histórico por
empresa+conta)."""
antigas = {(conta.codigo, conta.descricao): conta for conta in apuracao.contas.all()}
vistos: set[tuple[str, str]] = set()
for indice, extraida in enumerate(contas_extraidas):
tipo = extraida.tipo or "A"
chave = (extraida.codigo, extraida.descricao)
antiga = antigas.get(chave)
if antiga is not None:
vistos.add(chave)
alterou = (
antiga.tipo != tipo
or antiga.saldo_anterior != extraida.saldo_anterior
or antiga.debito != extraida.debito
or antiga.credito != extraida.credito
or antiga.saldo_atual != extraida.saldo_atual
)
antiga.valor_anterior_reprocessamento = antiga.saldo_atual if alterou else None
antiga.ordem = indice
antiga.conta_numero = extraida.conta_numero
antiga.tipo = tipo
antiga.saldo_anterior = extraida.saldo_anterior
antiga.debito = extraida.debito
antiga.credito = extraida.credito
antiga.saldo_atual = extraida.saldo_atual
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilConta.objects.create(
apuracao=apuracao,
ordem=indice,
conta_numero=extraida.conta_numero,
codigo=extraida.codigo,
descricao=extraida.descricao,
tipo=tipo,
saldo_anterior=extraida.saldo_anterior,
debito=extraida.debito,
credito=extraida.credito,
saldo_atual=extraida.saldo_atual,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
def _contabil_sincroniza_linhas_dre(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida]
) -> None:
"""Mesmo espírito de `_contabil_sincroniza_contas()`, sobre
`ContabilLinhaDre` — sem código de classificação, a chave natural é
`(descricao, nivel)` (o par que já desambigua a maioria das descrições
repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS"
aparecendo em mais de um nível)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_dre.all()}
vistos: set[tuple[str, int]] = set()
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or antiga.valor != linha.valor
antiga.valor_anterior_reprocessamento = antiga.valor if alterou else None
antiga.ordem = linha.ordem
antiga.totalizador = linha.totalizador
antiga.valor = linha.valor
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilLinhaDre.objects.create(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
valor=linha.valor,
totalizador=linha.totalizador,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
def _contabil_valores_analise_vertical_iguais(
valores_salvos: list[dict[str, str]], valores_novos: list[dashboard_contabil_modelos.ValorMensalAnaliseVertical]
) -> bool:
if len(valores_salvos) != len(valores_novos):
return False
return all(
Decimal(salvo["valor"]) == novo.valor and Decimal(salvo["percentual"]) == novo.percentual
for salvo, novo in zip(valores_salvos, valores_novos)
)
def _contabil_sincroniza_linhas_analise_vertical(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaAnaliseVerticalExtraida]
) -> None:
"""Mesmo espírito de `_contabil_sincroniza_linhas_dre()`, sobre
`ContabilLinhaAnaliseVertical` — além de `totalizador`, compara a lista
inteira de `valores` (convertendo de volta pra `Decimal`, já que é
gravada como texto no JSONField)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_analise_vertical.all()}
vistos: set[tuple[str, int]] = set()
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores]
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or not _contabil_valores_analise_vertical_iguais(
antiga.valores, linha.valores
)
antiga.valores_anterior_reprocessamento = antiga.valores if alterou else []
antiga.ordem = linha.ordem
antiga.totalizador = linha.totalizador
antiga.valores = novos_valores
antiga.validado = False if alterou else antiga.validado
antiga.alterada_reprocessamento = alterou
antiga.save()
else:
ContabilLinhaAnaliseVertical.objects.create(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
totalizador=linha.totalizador,
valores=novos_valores,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
def _contabil_recria_achados(
apuracao: ContabilApuracao, achados_detectados: list[dashboard_contabil_modelos.AchadoDetectado]
) -> None:
"""Recria do zero todo `ContabilAchado` de um reprocessamento — apaga
**todos** os achados existentes da apuração e recria a partir do motor de
regras rodado sobre o PDF novo, mesmo `bulk_create` de `create()` (ver
acima). Decisão revisada explicitamente pelo usuário (substitui a
decisão anterior de preservar `status`/`observacao_contador`/`tratado_por`/
`tratado_em`/`oculto_no_relatorio` — ver histórico no CHANGELOG.md do
pacote): um apontamento automático que não dispara mais com os dados
novos precisa **sumir** da aba Observações, sinalizando ao contador que
aquela inconsistência não existe mais, em vez de continuar pendurado
(mesmo "tratado") como se ainda precisasse de atenção. Todo achado que
ainda dispara nasce de novo como `pendente`, mesmo que já tivesse sido
tratado antes do reprocessamento — o contador revisa de novo com os
valores atuais, não reaproveita uma justificativa escrita sobre dados que
já mudaram.
**Isolado do resto do reprocessamento**: observações do contador
(`ContabilObservacao`, ver o model) não vivem aqui, são casadas por chave
natural (empresa+conta) e continuam intactas através de qualquer
reprocessamento — só o apontamento automático é recriado."""
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
av_por_ordem = {linha.ordem: linha for linha in apuracao.linhas_analise_vertical.all()}
apuracao.achados.all().delete()
ContabilAchado.objects.bulk_create(
[
ContabilAchado(
apuracao=apuracao,
conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None,
linha_analise_vertical=(
av_por_ordem.get(achado.ordem_linha_analise_vertical)
if achado.ordem_linha_analise_vertical is not None
else None
),
regra=achado.regra,
severidade=achado.severidade,
titulo=achado.titulo,
mensagem=achado.mensagem,
valor_referencia=achado.valor_referencia,
)
for achado in achados_detectados
]
)
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
`create()` roda `portal_api.dashboard_contabil.pipeline` de forma síncrona
sobre o PDF anexado: extrai o cabeçalho/contas/DRE, busca o histórico já
persistido da mesma empresa e roda o motor de regras — tudo isso **antes**
de gravar qualquer coisa no banco, já que `codigo_empresa`/`competencia`
(a chave natural da apuração) só são conhecidos depois de ler o PDF, não
informados pelo usuário no upload (diferente de IndicadorApuracao)."""
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ContabilApuracao.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def get_queryset(self) -> QuerySet[ContabilApuracao]:
queryset = super().get_queryset()
if self.action == "retrieve":
queryset = queryset.prefetch_related(
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta", "achados__linha_analise_vertical"
)
elif self.action == "list":
queryset = queryset.prefetch_related("reprocessamentos__reprocessado_por")
return queryset
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action == "list":
return ContabilApuracaoListSerializer
return ContabilApuracaoDetailSerializer
def perform_destroy(self, instance: ContabilApuracao) -> None:
"""Análise **Concluída** não pode mais ser excluída — pedido explícito
do usuário, mesma trava de "concluiu, não se mexe mais" que já valia
pra reprocessar e pra editar observação/achado
(`_contabil_garante_em_revisao`). Mensagem própria em vez de reusar o
helper porque a dele fala em editar observações/achados, que não é o
caso aqui. Esconder o botão na lista é só UX — quem decide é isto,
inclusive pra uma tela aberta antes de outra pessoa concluir a
análise."""
if instance.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": "Esta análise já foi concluída — não é mais possível excluí-la."}
)
instance.arquivo.delete(save=False)
instance.delete()
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = ContabilApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
arquivo = entrada.validated_data["arquivo"]
conteudo = arquivo.read()
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico
)
except ContabilExtracaoInvalidaError:
return Response(
{
"detail": (
"O arquivo não está no formato esperado (Balancete + DRE do Questor). "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
cabecalho = resultado.extracao.cabecalho
competencia = cabecalho.periodo_fim.replace(day=1)
if ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia=competencia).exists():
return Response(
{"detail": f"Já existe uma análise para {cabecalho.nome_empresa} na competência {competencia:%m/%Y}."},
status=status.HTTP_400_BAD_REQUEST,
)
apuracao: ContabilApuracao | None = None
try:
with transaction.atomic():
apuracao = ContabilApuracao.objects.create(
codigo_empresa=cabecalho.codigo_empresa,
nome_empresa=cabecalho.nome_empresa,
cnpj=cabecalho.cnpj,
competencia=competencia,
periodo_inicio=cabecalho.periodo_inicio,
periodo_fim=cabecalho.periodo_fim,
arquivo=ContentFile(conteudo, name=arquivo.name),
criado_por=request.user,
analise_vertical_meses=resultado.extracao.meses_analise_vertical,
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
)
ContabilConta.objects.bulk_create(
[
ContabilConta(
apuracao=apuracao,
ordem=indice,
conta_numero=conta.conta_numero,
codigo=conta.codigo,
descricao=conta.descricao,
tipo=conta.tipo,
saldo_anterior=conta.saldo_anterior,
debito=conta.debito,
credito=conta.credito,
saldo_atual=conta.saldo_atual,
)
for indice, conta in enumerate(resultado.extracao.contas)
]
)
ContabilLinhaDre.objects.bulk_create(
[
ContabilLinhaDre(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
valor=linha.valor,
totalizador=linha.totalizador,
)
for linha in resultado.extracao.linhas_dre
]
)
ContabilLinhaAnaliseVertical.objects.bulk_create(
[
ContabilLinhaAnaliseVertical(
apuracao=apuracao,
ordem=linha.ordem,
descricao=linha.descricao,
nivel=linha.nivel,
totalizador=linha.totalizador,
valores=[
{"valor": str(valor_mensal.valor), "percentual": str(valor_mensal.percentual)}
for valor_mensal in linha.valores
],
)
for linha in resultado.extracao.linhas_analise_vertical
]
)
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
av_por_ordem = {linha.ordem: linha for linha in apuracao.linhas_analise_vertical.all()}
ContabilAchado.objects.bulk_create(
[
ContabilAchado(
apuracao=apuracao,
conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None,
linha_analise_vertical=(
av_por_ordem.get(achado.ordem_linha_analise_vertical)
if achado.ordem_linha_analise_vertical is not None
else None
),
regra=achado.regra,
severidade=achado.severidade,
titulo=achado.titulo,
mensagem=achado.mensagem,
valor_referencia=achado.valor_referencia,
)
for achado in resultado.achados
]
)
except Exception:
if apuracao is not None:
apuracao.arquivo.delete(save=False)
return Response(
{"detail": "Não foi possível salvar a análise. Contate a Integração e Inovação."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ContabilApuracaoDetailSerializer(apuracao)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def reprocessar(self, request: Request, pk: str | None = None) -> Response:
"""Reanexa um novo PDF pra **mesma** empresa/competência (ex.: o
arquivo original tinha um erro/estava incompleto) e resincroniza
conta/linha por conta/linha, em vez de excluir e recriar tudo do
zero — pedido explícito do usuário, pra preservar observações do
contador:
- Conta/linha já existente (casada por `codigo`, no Balancete, ou por
`(descricao, nivel)`, na DRE/Análise Vertical — mesma convenção já
usada pelo histórico de variação em `regras.py`) tem seus campos
brutos (saldo/valor/ordem) atualizados **no mesmo registro**
(mesmo `id`). Observação não é preservada "por cuidado", ela
simplesmente não vive aqui — mora em `ContabilObservacao`, casada
por chave natural. Se algum valor realmente mudou, `validado` volta pra
`False` e `alterada_reprocessamento` vira `True` (frontend mostra
um alerta); se nada mudou, os dois continuam como estavam.
- Conta/linha que só existe na versão nova é criada normalmente
(`alterada_reprocessamento=False` — não tem "antes" pra comparar).
- Conta/linha que só existia na versão antiga (sumiu do PDF novo) é
excluída.
- **Todo achado (apontamento automático de auditoria) é recriado do
zero** — `_contabil_recria_achados()` apaga os achados existentes e
gera um conjunto novo a partir das regras rodadas sobre o PDF novo,
todos nascendo `pendente` (decisão revisada explicitamente pelo
usuário: um apontamento que não dispara mais deve sumir da aba
Observações, não continuar pendurado "tratado" — ver
CHANGELOG.md do pacote pra decisão anterior que esta substitui).
Observação/comentário do contador nas contas (`ContabilObservacao`)
**não** é achado, continua intocada.
- Cada reprocessamento bem-sucedido grava um `ContabilApuracaoReprocessamento`
(quem + quando), pra o contador ver o histórico completo e a
contagem antes de decidir se reprocessa de novo (modal "Reprocessar
análise" da lista) — pedido explícito do usuário.
Bloqueado numa apuração "Concluída" (`_contabil_garante_em_revisao`,
mesmo gate de qualquer edição) e numa competência/empresa diferente
da já cadastrada (reprocessar não é pra trocar de empresa — isso é
uma análise nova). Ver `_contabil_sincroniza_*` logo abaixo."""
apuracao = self.get_object()
_contabil_garante_em_revisao(apuracao)
entrada = ContabilApuracaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
arquivo = entrada.validated_data["arquivo"]
conteudo = arquivo.read()
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico
)
except ContabilExtracaoInvalidaError:
return Response(
{
"detail": (
"O arquivo não está no formato esperado (Balancete + DRE do Questor). "
"Contate a Integração e Inovação."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
cabecalho = resultado.extracao.cabecalho
nova_competencia = cabecalho.periodo_fim.replace(day=1)
if cabecalho.codigo_empresa != apuracao.codigo_empresa or nova_competencia != apuracao.competencia:
return Response(
{
"detail": (
f"O arquivo anexado é de {cabecalho.nome_empresa} ({nova_competencia:%m/%Y}), "
f"diferente desta análise ({apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). "
"Reprocessar exige o mesmo período/empresa — pra outra competência, crie uma nova análise."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
caminho_arquivo_antigo = apuracao.arquivo.name
novo_arquivo_salvo = False
try:
with transaction.atomic():
apuracao.nome_empresa = cabecalho.nome_empresa
apuracao.cnpj = cabecalho.cnpj
apuracao.periodo_inicio = cabecalho.periodo_inicio
apuracao.periodo_fim = cabecalho.periodo_fim
apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical
apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica
apuracao.arquivo = ContentFile(conteudo, name=arquivo.name)
apuracao.save()
novo_arquivo_salvo = True
_contabil_sincroniza_contas(apuracao, resultado.extracao.contas)
_contabil_sincroniza_linhas_dre(apuracao, resultado.extracao.linhas_dre)
_contabil_sincroniza_linhas_analise_vertical(apuracao, resultado.extracao.linhas_analise_vertical)
_contabil_recria_achados(apuracao, resultado.achados)
ContabilApuracaoReprocessamento.objects.create(apuracao=apuracao, reprocessado_por=request.user)
except Exception:
if novo_arquivo_salvo:
apuracao.arquivo.delete(save=False)
return Response(
{"detail": "Não foi possível reprocessar a análise. Contate a Integração e Inovação."},
status=status.HTTP_400_BAD_REQUEST,
)
# Só chega aqui se a transação inteira deu certo — o arquivo antigo
# não é mais referenciado por nenhuma linha do banco, então é seguro
# apagar seu conteúdo físico agora (não é transacional, por isso o
# cuidado de só apagar depois do commit).
if caminho_arquivo_antigo and caminho_arquivo_antigo != apuracao.arquivo.name:
apuracao.arquivo.storage.delete(caminho_arquivo_antigo)
apuracao = ContabilApuracao.objects.prefetch_related(
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta"
).get(pk=apuracao.pk)
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["post"])
def concluir(self, request: Request, pk: str | None = None) -> Response:
"""Trava a apuração pra edição (observações/achados) — mesmo espírito
de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta
v1 (diferente de planos_saude) — `reprocessar()` acima é o jeito de
corrigir uma apuração ainda "Em revisão" com um PDF novo, sem
perder observações/achados já tratados."""
apuracao = self.get_object()
apuracao.status = ContabilApuracao.STATUS_CONCLUIDA
apuracao.concluida_em = timezone.now()
apuracao.save(update_fields=["status", "concluida_em"])
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
@action(detail=True, methods=["get"])
def observacoes(self, request: Request, pk: str | None = None) -> Response:
"""Observações vigentes nesta apuração (ver `ContabilObservacao`) —
as escritas nesta competência mais as herdadas de competências
anteriores que ainda não foram encerradas. O frontend casa cada uma
com a conta/linha pela dupla `alvo_tipo`+`alvo_chave`, não por id de
linha (a linha é recriada a cada apuração, a observação não)."""
apuracao = self.get_object()
consulta = (
ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia)
.select_related("criado_por", "encerrada_por")
.prefetch_related("edicoes__editado_por")
)
serializada = ContabilObservacaoSerializer(
consulta, many=True, context={"competencia": apuracao.competencia}
)
return Response(serializada.data)
@action(detail=True, methods=["get"])
def dashboard(self, request: Request, pk: str | None = None) -> HttpResponse:
"""Gera o relatório "Gerar Dashboard": um documento HTML autocontido,
com a marca do escritório (não a "P.I.D." do Portal — ver "Logos" no
CLAUDE.md raiz), pensado pra abrir em nova aba/imprimir/enviar ao
administrador da empresa. Só leitura — diferente de `concluir`, não
muda `status`, pode ser chamado a qualquer momento. **GET, não POST**
(diferente do padrão `/gerar/` de outras ferramentas, que mutam
estado ou recebem multipart): o frontend abre a URL direto numa nova
aba (`window.open`), navegação de verdade, não um blob — precisava
ser GET pra isso funcionar (ver `dashboard-contabil.js`)."""
apuracao = self.get_object()
contas = list(apuracao.contas.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem"))
dados_resumo = _contabil_dados_resumo(apuracao)
observacoes_visiveis = dados_resumo["observacoes_visiveis"]
observacoes_por_tipo: dict[str, dict[str, list[ContabilObservacao]]] = {
ContabilObservacao.ALVO_CONTA: {},
ContabilObservacao.ALVO_DRE: {},
ContabilObservacao.ALVO_ANALISE_VERTICAL: {},
}
for observacao in observacoes_visiveis:
observacoes_por_tipo[observacao.alvo_tipo].setdefault(observacao.alvo_chave, []).append(observacao)
# `ancora` (setado direto no objeto, não um campo do model) é o mesmo
# `data-dcr-id` da linha correspondente na árvore (`conta-{id}`/
# `linha-{id}`/`av-{id}`) — permite o clique numa observação da seção
# "Observações do Balancete/D.R.E./Análise Vertical" (movida pra cima
# da tabela) rolar até a conta/linha que ela referencia (ver
# `pidDcrObsResumo()` no template). `None` quando a conta/linha não
# existe mais nesta apuração (ex.: observação histórica de uma conta
# que saiu do plano) — a seção mostra o texto normalmente, só sem
# virar link, "se houver" a conta.
def _com_ancora(
observacoes: list[ContabilObservacao], mapa: dict[str, int], prefixo: str
) -> list[ContabilObservacao]:
for observacao in observacoes:
alvo_id = mapa.get(observacao.alvo_chave)
observacao.ancora = f"{prefixo}-{alvo_id}" if alvo_id is not None else None
return observacoes
mapa_conta_por_chave = {ContabilObservacao.chave_conta(c.codigo, c.descricao): c.id for c in contas}
mapa_dre_por_chave = {ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_dre}
mapa_av_por_chave = {
ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_analise_vertical
}
contexto = {
"apuracao": apuracao,
"indicadores_grupos": dados_resumo["indicadores_grupos"],
"contas": _contabil_arvore_contexto(
contas,
"conta",
lambda c: c.codigo.count("."),
18,
observacoes_por_tipo[ContabilObservacao.ALVO_CONTA],
lambda c: ContabilObservacao.chave_conta(c.codigo, c.descricao),
),
"linhas_dre": _contabil_arvore_contexto(
linhas_dre,
"linha",
lambda l: max(0, l.nivel),
16,
observacoes_por_tipo[ContabilObservacao.ALVO_DRE],
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
),
"analise_vertical_meses": apuracao.analise_vertical_meses,
"linhas_analise_vertical": _contabil_arvore_contexto(
linhas_analise_vertical,
"linha",
lambda l: max(0, l.nivel),
16,
observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL],
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
),
"observacoes_contas": _com_ancora(
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA],
mapa_conta_por_chave,
"conta",
),
"observacoes_dre": _com_ancora(
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_DRE],
mapa_dre_por_chave,
"linha",
),
"observacoes_analise_vertical": _com_ancora(
[
observacao
for observacao in observacoes_visiveis
if observacao.alvo_tipo == ContabilObservacao.ALVO_ANALISE_VERTICAL
],
mapa_av_por_chave,
"av",
),
"achados_com_observacao": dados_resumo["achados_com_observacao"],
"gerado_em": timezone.now(),
}
html = render_to_string("dashboard-contabil-relatorio.html", contexto)
return HttpResponse(html, content_type="text/html; charset=utf-8")
@action(detail=True, methods=["get"])
def indicadores(self, request: Request, pk: str | None = None) -> Response:
"""Valores de todo `IndicadorContabilDefinicao` ativo nesta apuração
(padrão, ou não padrão selecionado — ver "Gerenciar Indicadores"),
pra alimentar a aba "Dashboard" da tela de revisão — pré-visualização
de quais cards vão pro relatório antes de gerá-lo de fato, junto da
lista do que já está escondido (`indicadores_ocultos`, ver
`indicadores_ocultos()` abaixo) e dos metadados de exibição de cada
um (nome/grupo/formato/descrição/fórmula em texto — usados pelo
botão "Ver fórmula"). Os antigos 11 indicadores "de sistema"
(ROA/ROE/Kanitz/...) foram migrados pra `IndicadorContabilDefinicao`
de verdade — não têm mais tratamento especial aqui, ver CLAUDE.md do
pacote."""
apuracao = self.get_object()
dados = _contabil_coleta_dados_indicadores(apuracao)
selecionados = apuracao.indicadores_selecionados
valores = _contabil_calcula_indicadores_personalizados(dados, selecionados)
# Só os indicadores **ativos nesta apuração** entram aqui — a lista
# completa (padrão + não padrão) fica na listagem própria de
# definições (`IndicadorContabilDefinicaoViewSet`, usada pelo hub
# "Gerenciar Indicadores").
metadados = {}
for definicao in IndicadorContabilDefinicao.objects.filter(
Q(padrao=True) | Q(chave__in=selecionados)
).order_by("nome"):
metadados[definicao.chave] = {
"nome": definicao.nome,
"grupo": definicao.grupo,
"formato": definicao.formato,
"descricao": definicao.descricao,
"formula_texto": definicao.formula,
"definicao_id": definicao.id,
}
return Response(
{
"indicadores": valores,
"indicadores_ocultos": apuracao.indicadores_ocultos,
"indicadores_selecionados": selecionados,
"metadados": metadados,
}
)
@action(detail=True, methods=["post"], url_path="indicadores-ocultos")
def indicadores_ocultos(self, request: Request, pk: str | None = None) -> Response:
"""Substitui a lista inteira de cards de indicador escondidos do
relatório "Gerar Dashboard" (aba "Dashboard" da tela de revisão) —
o frontend já tem a lista atual (via `indicadores()` acima), só
alterna uma chave e reenvia a lista inteira, mesmo espírito de
`ContabilAchadoViewSet.alternar_oculto()` pras observações."""
apuracao = self.get_object()
_contabil_garante_em_revisao(apuracao)
entrada = ContabilIndicadoresOcultosSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
apuracao.indicadores_ocultos = entrada.validated_data["indicadores_ocultos"]
apuracao.save(update_fields=["indicadores_ocultos"])
return Response({"indicadores_ocultos": apuracao.indicadores_ocultos})
@action(detail=True, methods=["post"], url_path="indicadores-selecionados")
def indicadores_selecionados(self, request: Request, pk: str | None = None) -> Response:
"""Substitui a lista inteira de indicadores **não padrão** ativados
nesta apuração de uma vez (mesmo espírito de `indicadores_ocultos()`
acima) — indicador com `padrao=True` não precisa passar por aqui,
já entra sempre. Ver "Gerenciar Indicadores" na aba "Dashboard"."""
apuracao = self.get_object()
_contabil_garante_em_revisao(apuracao)
entrada = ContabilIndicadoresSelecionadosSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
apuracao.indicadores_selecionados = entrada.validated_data["indicadores_selecionados"]
apuracao.save(update_fields=["indicadores_selecionados"])
return Response({"indicadores_selecionados": apuracao.indicadores_selecionados})
@action(detail=True, methods=["post"], url_path="resumo-fechamento")
def resumo_fechamento(self, request: Request, pk: str | None = None) -> Response:
"""Substitui o texto inteiro do "Resumo do Fechamento" (aba
"Dashboard" da tela de revisão) — considerações/análises livres do
contador sobre o fechamento, aparece no relatório "Gerar Dashboard"
antes dos cards de indicador. Mesmo espírito de
`indicadores_ocultos()`/`indicadores_selecionados()` acima: substitui
o campo inteiro de uma vez, sanitizado por
`ContabilResumoFechamentoSerializer` (nh3, mesma allowlist de
`AcessoGeral.observacoes`/`AjudaAplicacao.texto`)."""
apuracao = self.get_object()
_contabil_garante_em_revisao(apuracao)
entrada = ContabilResumoFechamentoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
apuracao.resumo_fechamento = entrada.validated_data["resumo_fechamento"]
apuracao.save(update_fields=["resumo_fechamento"])
return Response({"resumo_fechamento": apuracao.resumo_fechamento})
@action(detail=True, methods=["post"], url_path="pre-visualizar-indicador")
def pre_visualizar_indicador(self, request: Request, pk: str | None = None) -> Response:
"""Calcula, contra **esta** apuração, o valor de cada componente e o
resultado final de uma fórmula de indicador ainda em edição — pro
contador conferir se pegou a conta/linha certa antes de salvar de
vez (pedido explícito do usuário: ver os valores buscados, não só a
fórmula em texto). Não persiste nada, nem exige que o indicador já
exista — mesma validação de `IndicadorContabilDefinicaoInputSerializer`
usada por criar/editar, só que o resultado não é salvo em lugar
nenhum."""
apuracao = self.get_object()
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados_formulario = entrada.validated_data
dados = _contabil_coleta_dados_indicadores(apuracao)
valores_existentes = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
componentes = [
IndicadorContabilComponente(
chave=c["chave"],
tipo=c["tipo"],
contas_codigos=c.get("contas_codigos") or [],
linhas_dre_descricoes=c.get("linhas_dre_descricoes") or [],
indicador_referenciado=c.get("indicador_referenciado") or "",
)
for c in dados_formulario["componentes"]
]
valores_componentes = {
componente.chave: _contabil_resolve_componente_personalizado(componente, dados, valores_existentes)
for componente in componentes
}
erro = None
try:
resultado = dashboard_contabil_formula.avalia_formula(dados_formulario["formula"], valores_componentes)
except dashboard_contabil_formula.FormulaInvalidaError as exc:
resultado = None
erro = str(exc)
return Response(
{
"componentes": [
{
"chave": componente.chave,
"valor_formatado": dashboard_contabil_extras.indice(valores_componentes[componente.chave]),
}
for componente in componentes
],
"resultado_formatado": _contabil_formata_indicador(resultado, dados_formulario["formato"]),
"erro": erro,
}
)
@action(detail=True, methods=["get"], url_path="exportar-xlsx")
def exportar_xlsx(self, request: Request, pk: str | None = None) -> HttpResponse:
"""Exporta Balancete ou DRE em XLSX a partir do relatório "Gerar
Dashboard" (`?parte=balancete` ou `?parte=dre`) — mesma lógica de
árvore/indentação do relatório HTML, mas gerada com
`dashboard_contabil.exportacao` (openpyxl), sem tocar no ORM lá
dentro (view resolve os dados, a função só monta a planilha)."""
apuracao = self.get_object()
parte = request.query_params.get("parte")
if parte not in ("balancete", "dre"):
raise ValidationError({"parte": "Informe 'balancete' ou 'dre'."})
if parte == "balancete":
contas = list(apuracao.contas.order_by("ordem"))
conteudo = dashboard_contabil_exportacao.gera_xlsx_balancete(
apuracao.nome_empresa,
apuracao.cnpj,
apuracao.competencia,
[
dashboard_contabil_exportacao.LinhaBalanceteXlsx(
codigo=conta.codigo,
descricao=conta.descricao,
tipo=conta.tipo,
nivel=conta.codigo.count("."),
saldo_anterior=conta.saldo_anterior,
debito=conta.debito,
credito=conta.credito,
saldo_atual=conta.saldo_atual,
)
for conta in contas
],
)
nome_arquivo = f"balancete_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
else:
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
conteudo = dashboard_contabil_exportacao.gera_xlsx_dre(
apuracao.nome_empresa,
apuracao.cnpj,
apuracao.competencia,
[
dashboard_contabil_exportacao.LinhaDreXlsx(
descricao=linha.descricao,
nivel=max(0, linha.nivel),
totalizador=linha.totalizador,
valor=linha.valor,
)
for linha in linhas_dre
],
)
nome_arquivo = f"dre_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
response = HttpResponse(
conteudo, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
response["Content-Disposition"] = f'attachment; filename="{nome_arquivo}"'
return response
@action(detail=True, methods=["get"], url_path="resumo-pdf")
def resumo_pdf(self, request: Request, pk: str | None = None) -> HttpResponse:
"""PDF com o Resumo do Fechamento + Indicadores + Observações — o
mesmo conteúdo da aba "Resumo" do relatório "Gerar Dashboard" (ver
`_contabil_dados_resumo()`), avulso, sem o resto do relatório
(Balancete/D.R.E./Análise Vertical). GET, mesmo motivo de
`dashboard()` acima (abre via `window.open`, não fetch+blob)."""
apuracao = self.get_object()
dados_resumo = _contabil_dados_resumo(apuracao)
pdf_bytes = dashboard_contabil_resumo_pdf.gera_pdf_resumo(
apuracao, dados_resumo, timezone.localtime().strftime("%d/%m/%Y %H:%M")
)
nome_arquivo = f"resumo-{apuracao.codigo_empresa}-{apuracao.competencia:%m-%Y}.pdf"
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{nome_arquivo}"'
return response
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError(
{"detail": "Esta análise já foi concluída — não é mais possível editar observações ou achados."}
)
class ContabilContaViewSet(viewsets.ModelViewSet):
"""Contas do balancete de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilConta.objects.all()
serializer_class = ContabilContaSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilContaSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilLinhaDreViewSet(viewsets.ModelViewSet):
"""Linhas da DRE de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui, mesmo espírito de `ContabilContaViewSet`."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilLinhaDre.objects.all()
serializer_class = ContabilLinhaDreSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilLinhaDreSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilLinhaAnaliseVerticalViewSet(viewsets.ModelViewSet):
"""Linhas da Análise Vertical de uma apuração — nascem todas juntas em
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
contador) aqui, mesmo espírito de `ContabilLinhaDreViewSet`."""
http_method_names = ["get", "patch", "head", "options"]
queryset = ContabilLinhaAnaliseVertical.objects.all()
serializer_class = ContabilLinhaAnaliseVerticalSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def perform_update(self, serializer: ContabilLinhaAnaliseVerticalSerializer) -> None:
_contabil_garante_em_revisao(serializer.instance.apuracao)
serializer.save()
class ContabilObservacaoViewSet(viewsets.ModelViewSet):
"""Histórico de observações do contador (ver `ContabilObservacao`) — uma
thread por empresa+conta, que atravessa competências em vez de morrer
junto com a apuração (era um campo de `ContabilConta`/`ContabilLinhaDre`/
`ContabilLinhaAnaliseVertical` até a migração `0071`).
Não tem `list`/`retrieve` de propósito: o frontend sempre lê pelo recorte
de vigência de uma apuração (`ContabilApuracaoViewSet.observacoes()`),
nunca a tabela inteira.
O que cada método pode mexer:
- `create` escreve uma observação nova na apuração aberta (precisa estar
"Em revisão"); empresa/competência/chave do alvo saem do servidor.
- `partial_update` aceita `texto` **só enquanto a apuração de origem
estiver em revisão** (depois disso a observação é histórica, e o texto/
autor/data ficam congelados) e `mostrar_ao_cliente` sempre — decisão
confirmada com o usuário: o bloqueio protege o registro histórico, mas
mostrar ou não ao cliente é uma decisão editorial de cada relatório,
inclusive de uma competência posterior. Toda mudança real de `texto`
grava um `ContabilObservacaoEdicao` (texto anterior/novo, autor, data)
— é o que alimenta o selo "Editada" e o histórico de edições na tela.
Qualquer usuário com acesso à ferramenta pode editar/mostrar-ocultar
qualquer observação (não só quem criou), enquanto a apuração de origem
estiver em revisão.
- `destroy` **voltou a ter um botão na tela** (rodada 145, revertendo a
decisão de uma rodada anterior de tirar o botão) — mesma regra de
`_garante_texto_editavel` (só apaga o que ainda é da competência
aberta; uma observação histórica nunca se apaga, se encerra) **mais**
uma restrição nova: só quem criou a observação (`criado_por_id ==
request.user.id`) pode excluí-la, senão `PermissionDenied` — diferente
de `partial_update`, que continua liberado pra qualquer um do time.
- `encerrar`/`reativar` ligam/desligam `encerrada_em_competencia` — é o
"ocultar das próximas execuções": continua visível na competência em
que foi encerrada e some a partir da seguinte."""
http_method_names = ["post", "patch", "delete", "head", "options"]
queryset = ContabilObservacao.objects.select_related("criado_por", "encerrada_por", "apuracao_origem")
serializer_class = ContabilObservacaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def _resolve_alvo(self, apuracao: ContabilApuracao, alvo_tipo: str, alvo_id: int) -> Any:
"""Conta/linha **desta** apuração que recebeu a observação — pegar o
objeto (em vez de confiar numa chave enviada pelo cliente) garante
que a chave natural gravada é sempre a real e que o alvo pertence
mesmo à apuração informada."""
modelos = {
ContabilObservacao.ALVO_CONTA: ContabilConta,
ContabilObservacao.ALVO_DRE: ContabilLinhaDre,
ContabilObservacao.ALVO_ANALISE_VERTICAL: ContabilLinhaAnaliseVertical,
}
alvo = modelos[alvo_tipo].objects.filter(pk=alvo_id, apuracao=apuracao).first()
if alvo is None:
raise ValidationError({"alvo_id": "Conta ou linha não encontrada nesta análise."})
return alvo
def _garante_texto_editavel(self, observacao: ContabilObservacao) -> None:
origem = observacao.apuracao_origem
if origem is None or origem.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError(
{
"detail": (
"Esta observação já faz parte do histórico, o texto não pode mais ser alterado. "
"Registre uma observação nova ou encerre esta para as próximas competências."
)
}
)
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = ContabilObservacaoCreateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
apuracao: ContabilApuracao = dados["apuracao"]
_contabil_garante_em_revisao(apuracao)
alvo = self._resolve_alvo(apuracao, dados["alvo_tipo"], dados["alvo_id"])
observacao = ContabilObservacao.objects.create(
codigo_empresa=apuracao.codigo_empresa,
alvo_tipo=dados["alvo_tipo"],
alvo_chave=_contabil_chave_alvo(dados["alvo_tipo"], alvo),
alvo_rotulo=alvo.descricao,
apuracao_origem=apuracao,
competencia_origem=apuracao.competencia,
texto=dados["texto"].strip(),
mostrar_ao_cliente=dados["mostrar_ao_cliente"],
criado_por=request.user,
)
serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia})
return Response(serializada.data, status=status.HTTP_201_CREATED)
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
observacao: ContabilObservacao = self.get_object()
entrada = ContabilObservacaoUpdateSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
campos: list[str] = []
if "texto" in dados:
self._garante_texto_editavel(observacao)
texto_novo = dados["texto"].strip()
if texto_novo != observacao.texto:
ContabilObservacaoEdicao.objects.create(
observacao=observacao,
texto_anterior=observacao.texto,
texto_novo=texto_novo,
editado_por=request.user,
)
observacao.texto = texto_novo
campos.append("texto")
if "mostrar_ao_cliente" in dados:
observacao.mostrar_ao_cliente = dados["mostrar_ao_cliente"]
campos.append("mostrar_ao_cliente")
observacao.save(update_fields=[*campos, "atualizado_em"])
competencia = observacao.apuracao_origem.competencia if observacao.apuracao_origem else None
return Response(ContabilObservacaoSerializer(observacao, context={"competencia": competencia}).data)
def perform_destroy(self, instance: ContabilObservacao) -> None:
self._garante_texto_editavel(instance)
if instance.criado_por_id != self.request.user.id:
raise PermissionDenied("Só quem registrou esta observação pode excluí-la.")
instance.delete()
@action(detail=True, methods=["post"])
def encerrar(self, request: Request, pk: str | None = None) -> Response:
""""Ocultar das próximas execuções": a observação continua visível na
competência informada (e em todas as anteriores — o histórico nunca é
reescrito) e some a partir da seguinte."""
observacao = self.get_object()
entrada = ContabilObservacaoApuracaoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
apuracao: ContabilApuracao = entrada.validated_data["apuracao"]
_contabil_garante_em_revisao(apuracao)
observacao.encerrada_em_competencia = apuracao.competencia
observacao.encerrada_por = request.user
observacao.encerrada_em = timezone.now()
observacao.save(update_fields=["encerrada_em_competencia", "encerrada_por", "encerrada_em", "atualizado_em"])
serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia})
return Response(serializada.data)
@action(detail=True, methods=["post"])
def reativar(self, request: Request, pk: str | None = None) -> Response:
"""Desfaz um `encerrar()` — a observação volta a valer daqui pra
frente. Existe pro contador não ficar preso a um clique errado; o
texto continua intocado nos dois caminhos."""
observacao = self.get_object()
entrada = ContabilObservacaoApuracaoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
apuracao: ContabilApuracao = entrada.validated_data["apuracao"]
_contabil_garante_em_revisao(apuracao)
observacao.encerrada_em_competencia = None
observacao.encerrada_por = None
observacao.encerrada_em = None
observacao.save(update_fields=["encerrada_em_competencia", "encerrada_por", "encerrada_em", "atualizado_em"])
serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia})
return Response(serializada.data)
class ContabilAchadoViewSet(viewsets.ModelViewSet):
"""Achados de auditoria gerados automaticamente na criação da apuração —
GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) +
POST em `alternar_oculto()` (ocultar a observação do relatório, sem
mexer no status)."""
http_method_names = ["get", "patch", "post", "head", "options"]
queryset = ContabilAchado.objects.all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("update", "partial_update"):
return ContabilAchadoAjusteSerializer
return ContabilAchadoSerializer
def perform_update(self, serializer: ContabilAchadoAjusteSerializer) -> None:
achado: ContabilAchado = self.get_object()
_contabil_garante_em_revisao(achado.apuracao)
achado.status = serializer.validated_data["status"]
achado.observacao_contador = serializer.validated_data["observacao_contador"]
achado.tratado_por = self.request.user
achado.tratado_em = timezone.now()
achado.save(update_fields=["status", "observacao_contador", "tratado_por", "tratado_em"])
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
super().update(request, *args, **kwargs)
achado = self.get_object()
return Response(ContabilAchadoSerializer(achado).data)
@action(detail=True, methods=["post"], url_path="alternar-oculto")
def alternar_oculto(self, request: Request, pk: str | None = None) -> Response:
"""Mostra/esconde a observação deste achado na seção "Observações"
do relatório "Gerar Dashboard" (aba "Dashboard" da tela de revisão)
— separado de `update()`/`ContabilAchadoAjusteSerializer` porque
isso não deve exigir mudar status nem justificativa: um achado
continua "Pendente" e ainda assim pode ter a observação escondida,
se ela um dia for preenchida sem mudar o status."""
achado = self.get_object()
_contabil_garante_em_revisao(achado.apuracao)
entrada = ContabilAchadoOcultoSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
achado.oculto_no_relatorio = entrada.validated_data["oculto_no_relatorio"]
achado.save(update_fields=["oculto_no_relatorio"])
return Response(ContabilAchadoSerializer(achado).data)
def _gera_chave_indicador_contabil(nome: str) -> str:
"""Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do
`nome` (nunca aceita do cliente — ver `IndicadorContabilDefinicaoInputSerializer`,
mesmo espírito de `RegraCusteioPlanoSaude.nome` sempre derivado, ver
CLAUDE.md raiz). `slugify()` já normaliza acentos/maiúsculas e troca
espaços por hífen; troca hífen por "_" pra ficar num padrão snake_case
(ex. "roa", "liquidez_corrente" — os antigos 11 indicadores "de
sistema" foram migrados com esse mesmo padrão de chave) e ser um
identificador Python válido, já que a chave da definição pode também
ser usada como `indicador_referenciado` dentro de uma fórmula.
Desempata com sufixo numérico se já existir uma definição com a mesma
chave."""
base = slugify(nome).replace("-", "_") or "indicador"
if base[0].isdigit():
base = f"indicador_{base}"
chave = base
sufixo = 2
while IndicadorContabilDefinicao.objects.filter(chave=chave).exists():
chave = f"{base}_{sufixo}"
sufixo += 1
return chave
class IndicadorContabilDefinicaoViewSet(viewsets.ModelViewSet):
"""CRUD dos indicadores do Dashboard Contábil (aba "Dashboard" da tela
de revisão, hub "Gerenciar Indicadores") — inclusive os antigos 11
indicadores "de sistema" (ROA/ROE/Kanitz/...), migrados pra registros
normais aqui (ver CLAUDE.md do pacote, "Migração dos 11 indicadores de
sistema"). Mesma permissão de toggle único do resto da ferramenta —
qualquer contador com acesso ao Dashboard Contábil pode criar/editar/
excluir qualquer indicador, não é uma tela administrativa restrita ao
perfil "Inovação"."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = IndicadorContabilDefinicao.objects.prefetch_related("componentes").all()
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("relatorios", "dashboard-contabil")]
def get_serializer_class(self) -> type[ModelSerializer]:
if self.action in ("create", "update", "partial_update"):
return IndicadorContabilDefinicaoInputSerializer
return IndicadorContabilDefinicaoSerializer
def _salva_componentes(self, definicao: IndicadorContabilDefinicao, componentes: list[dict]) -> None:
# Substitui **todos** os componentes de uma vez a cada
# criação/edição (nunca um PATCH incremental por componente) — ver
# docstring de `IndicadorContabilDefinicaoInputSerializer`.
definicao.componentes.all().delete()
IndicadorContabilComponente.objects.bulk_create(
[
IndicadorContabilComponente(
definicao=definicao,
chave=componente["chave"],
tipo=componente["tipo"],
contas_codigos=componente.get("contas_codigos") or [],
linhas_dre_descricoes=componente.get("linhas_dre_descricoes") or [],
indicador_referenciado=componente.get("indicador_referenciado") or "",
)
for componente in componentes
]
)
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
with transaction.atomic():
definicao = IndicadorContabilDefinicao.objects.create(
chave=_gera_chave_indicador_contabil(dados["nome"]),
nome=dados["nome"],
descricao=dados.get("descricao", ""),
formula=dados["formula"],
formula_exibicao=dados.get("formula_exibicao", ""),
formato=dados["formato"],
icone=dados.get("icone", IndicadorContabilDefinicao.ICONE_BARRAS),
padrao=dados.get("padrao", True),
criado_por=request.user,
)
self._salva_componentes(definicao, dados["componentes"])
return Response(IndicadorContabilDefinicaoSerializer(definicao).data, status=status.HTTP_201_CREATED)
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
definicao = self.get_object()
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data, instance=definicao)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
with transaction.atomic():
definicao.nome = dados["nome"]
definicao.descricao = dados.get("descricao", "")
definicao.formula = dados["formula"]
definicao.formula_exibicao = dados.get("formula_exibicao", "")
definicao.formato = dados["formato"]
definicao.icone = dados.get("icone", IndicadorContabilDefinicao.ICONE_BARRAS)
definicao.padrao = dados.get("padrao", True)
definicao.save(
update_fields=[
"nome",
"descricao",
"formula",
"formula_exibicao",
"formato",
"icone",
"padrao",
"atualizado_em",
]
)
self._salva_componentes(definicao, dados["componentes"])
return Response(IndicadorContabilDefinicaoSerializer(definicao).data)
def perform_destroy(self, instance: IndicadorContabilDefinicao) -> None:
# Bloqueia exclusão se outro indicador (personalizado) referencia
# este pela fórmula — evitar deixar uma fórmula alheia quebrada
# silenciosamente (resultado viraria None sem nenhum aviso).
dependentes = (
IndicadorContabilComponente.objects.filter(
tipo=IndicadorContabilComponente.TIPO_INDICADOR, indicador_referenciado=instance.chave
)
.exclude(definicao=instance)
.select_related("definicao")
)
nomes = sorted({componente.definicao.nome for componente in dependentes})
if nomes:
raise ValidationError(
{"detail": f"Não é possível excluir: usado na fórmula de {', '.join(nomes)}."}
)
chave = instance.chave
instance.delete()
# Limpa a chave excluída de toda apuração que a tinha selecionada
# (não padrão) ou oculta do relatório — sem isso a chave fica órfã
# em `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos`
# e a próxima tentativa de alternar QUALQUER indicador no hub daquela
# apuração falharia (a validação reenvia a lista inteira a cada
# alternância). Ver bug real, rodada 106.
for apuracao in ContabilApuracao.objects.filter(
Q(indicadores_selecionados__contains=[chave]) | Q(indicadores_ocultos__contains=[chave])
):
campos_alterados = []
if chave in apuracao.indicadores_selecionados:
apuracao.indicadores_selecionados = [
c for c in apuracao.indicadores_selecionados if c != chave
]
campos_alterados.append("indicadores_selecionados")
if chave in apuracao.indicadores_ocultos:
apuracao.indicadores_ocultos = [c for c in apuracao.indicadores_ocultos if c != chave]
campos_alterados.append("indicadores_ocultos")
if campos_alterados:
apuracao.save(update_fields=campos_alterados)