5075 lines
251 KiB
Python
5075 lines
251 KiB
Python
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:
|
||
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)
|