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 .conciliacao_fornecedores import alertas as conciliacao_alertas from .conciliacao_fornecedores import exportacao as conciliacao_exportacao from .conciliacao_fornecedores import motor as conciliacao_motor from .conciliacao_fornecedores import parser as conciliacao_parser from .custo_contratacao.calculo import calcula_custo_empregado from .custo_contratacao.pdf import gera_pdf_simulacao from .dashboard_contabil import chaves as dashboard_contabil_chaves 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 regras as dashboard_contabil_regras 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 CodigoEmpresaAusenteError as ContabilCodigoEmpresaAusenteError from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError from .models import ( AcessoGeral, AcessoGeralSecao, AjudaAplicacao, CategoriaEvento, CompromissoAgenda, ConciliacaoFornecedor, ConciliacaoFornecedorConta, ConciliacaoFornecedorLancamento, ConciliacaoFornecedorVinculo, 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, ConciliacaoFornecedorCreateSerializer, ConciliacaoFornecedorListSerializer, ConciliacaoFornecedorValidarSerializer, ConciliacaoFornecedorVincularSerializer, ContabilAchadoAjusteSerializer, ContabilAchadoOcultoSerializer, ContabilAchadoValidarSerializer, 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 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", "id")) linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id")) 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 ) 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(''), IndicadorContabilDefinicao.ICONE_TENDENCIA_ALTA: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_TENDENCIA_BAIXA: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_PERCENTUAL: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_PIZZA: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_ATIVIDADE: mark_safe(''), IndicadorContabilDefinicao.ICONE_MOEDA: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_ALVO: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_CAMADAS: mark_safe( '' '' ), IndicadorContabilDefinicao.ICONE_CARTAO: mark_safe( '' ), IndicadorContabilDefinicao.ICONE_SELO: mark_safe( '' ), } 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). Apuração sem indicadores (`tem_indicadores`, leiaute Contabit) devolve `indicadores_grupos` vazio, sem nem calcular.""" indicadores_grupos: list[dict[str, Any]] = [] if apuracao.tem_indicadores: dados = _contabil_coleta_dados_indicadores(apuracao) valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados) indicadores_grupos = _contabil_agrupa_indicadores_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": indicadores_grupos, "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, chaves: list[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`+`chaves` (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. `chaves` é uma **lista alinhada por posição** com `itens`, e não uma função por item, porque a chave de uma linha da DRE/Análise Vertical depende dos grupos acima dela (ver `dashboard_contabil.chaves`).""" 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 chaves is not None: observacoes = observacoes_por_chave.get(chaves[i], []) 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(apuracao: ContabilApuracao, 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, caminho na árvore + nível na DRE/Análise Vertical), pra uma observação seguir a mesma conta de uma competência pra outra. Precisa da `apuracao` inteira, e não só do alvo, porque a chave de uma linha da DRE/Análise Vertical depende dos grupos acima dela (ver `dashboard_contabil.chaves.caminhos_linhas()`) — uma linha isolada não sabe em que ramo está.""" if alvo_tipo == ContabilObservacao.ALVO_CONTA: return ContabilObservacao.chave_conta(alvo.codigo, alvo.descricao) irmas = ( apuracao.linhas_dre.order_by("ordem", "id") if alvo_tipo == ContabilObservacao.ALVO_DRE else apuracao.linhas_analise_vertical.order_by("ordem", "id") ) linhas = list(irmas) chaves = dashboard_contabil_chaves.chaves_linhas(linhas) for linha, chave in zip(linhas, chaves): if linha.pk == alvo.pk: return chave # Inalcançável na prática (`_resolve_alvo()` já garantiu que o alvo é # desta apuração), mas um fallback é melhor do que estourar na criação da # observação. 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_agrupa_por_chave(pares: Iterable[tuple[str, Any]]) -> dict[str, list[Any]]: """Agrupa conta/linha já salva por chave natural, em **fila** e não em registro único. Bug real: estas três funções montavam `{chave: linha}` por dict comprehension. Quando duas linhas produziam a mesma chave, o dict ficava só com a última, e a outra virava um fantasma — nunca atualizada (não estava no dict) e nunca excluída (o laço final varre o dict, não a tabela), então sobrevivia a todo reprocessamento com o valor congelado da primeira importação, sem nem o alerta de `alterada_reprocessamento`. Encontrado em produção com "DESPESAS COM PESSOAL" repetida em dois ramos da DRE: removida de um deles no PDF novo, a linha continuava na tela. A chave da DRE/Análise Vertical passou a incluir o caminho na árvore (ver `dashboard_contabil.chaves`), o que remove a colisão daquele caso, mas a fila continua sendo a forma certa de montar isto: duas linhas irmãs idênticas no mesmo ramo ainda colidem, e aí o certo é casar uma com cada ocorrência do PDF novo (na ordem de leitura) e excluir o que sobrar. """ filas: dict[str, list[Any]] = {} for chave, item in pares: filas.setdefault(chave, []).append(item) return filas def _contabil_proxima_da_fila(filas: dict[str, list[Any]], chave: str) -> Any | None: """Próxima conta/linha salva com esta chave, consumindo-a da fila — o que sobrar nas filas no fim da sincronização é o que não veio no PDF novo.""" fila = filas.get(chave) return fila.pop(0) if fila else None def _contabil_exclui_sobras(filas: dict[str, list[Any]]) -> list[tuple[str, Any]]: """Exclui toda conta/linha que nenhuma linha do PDF novo reclamou, e devolve os pares `(chave, registro excluído)` — é a partir daí que o reprocessamento monta o aviso de "conta removida" na aba Observações (ver `regras.achados_itens_removidos()`).""" removidos: list[tuple[str, Any]] = [] for chave, fila in filas.items(): for antiga in fila: removidos.append((chave, antiga)) antiga.delete() return removidos def _contabil_rotulo_linha_removida(chave: str) -> str: """Rótulo de uma linha da DRE/Análise Vertical que sumiu, a partir da chave natural: o caminho na árvore sem o nível (`"A > B > C|2"` vira `"A > B > C"`). O caminho inteiro, e não só a descrição, porque o mesmo rótulo aparece em ramos diferentes — sem o ramo, o aviso não diria qual das duas linhas saiu.""" return chave.rpartition("|")[0] or chave def _contabil_sincroniza_contas( apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida] ) -> list[tuple[str, str]]: """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 pro caminho na árvore. Observação nunca é afetada por aqui: ela não mora mais na linha, e sim em `ContabilObservacao` (histórico por empresa+conta). Devolve `(origem, rótulo)` de cada conta excluída, pro reprocessamento virar apontamento na aba Observações.""" antigas = _contabil_agrupa_por_chave( (ContabilObservacao.chave_conta(conta.codigo, conta.descricao), conta) for conta in apuracao.contas.order_by("ordem", "id") ) for indice, extraida in enumerate(contas_extraidas): tipo = extraida.tipo or "A" chave = ContabilObservacao.chave_conta(extraida.codigo, extraida.descricao) antiga = _contabil_proxima_da_fila(antigas, chave) if antiga is not None: 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, ) return [ (dashboard_contabil_regras.ORIGEM_BALANCETE, f"{conta.codigo} {conta.descricao}") for _, conta in _contabil_exclui_sobras(antigas) ] def _contabil_sincroniza_linhas_dre( apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida] ) -> list[tuple[str, str]]: """Mesmo espírito de `_contabil_sincroniza_contas()`, sobre `ContabilLinhaDre` — sem código de classificação, a chave natural é o **caminho na árvore** mais o nível (ver `dashboard_contabil.chaves`), que é o que distingue o mesmo rótulo repetido em ramos diferentes no mesmo nível ("DESPESAS COM PESSOAL" sob Vendas e sob Administrativas). Devolve `(origem, rótulo)` de cada linha excluída, como `_contabil_sincroniza_contas()`.""" salvas = list(apuracao.linhas_dre.order_by("ordem", "id")) antigas = _contabil_agrupa_por_chave(zip(dashboard_contabil_chaves.chaves_linhas(salvas), salvas)) chaves_extraidas = dashboard_contabil_chaves.chaves_linhas(linhas_extraidas) for chave, linha in zip(chaves_extraidas, linhas_extraidas): antiga = _contabil_proxima_da_fila(antigas, chave) if antiga is not None: 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, ) return [ (dashboard_contabil_regras.ORIGEM_DRE, _contabil_rotulo_linha_removida(chave)) for chave, _ in _contabil_exclui_sobras(antigas) ] 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] ) -> list[tuple[str, str]]: """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). Mesmo retorno de `(origem, rótulo)` das linhas excluídas.""" salvas = list(apuracao.linhas_analise_vertical.order_by("ordem", "id")) antigas = _contabil_agrupa_por_chave(zip(dashboard_contabil_chaves.chaves_linhas(salvas), salvas)) chaves_extraidas = dashboard_contabil_chaves.chaves_linhas(linhas_extraidas) for chave, linha in zip(chaves_extraidas, linhas_extraidas): antiga = _contabil_proxima_da_fila(antigas, chave) novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores] if antiga is not None: 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, ) return [ (dashboard_contabil_regras.ORIGEM_ANALISE_VERTICAL, _contabil_rotulo_linha_removida(chave)) for chave, _ in _contabil_exclui_sobras(antigas) ] 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 (mais os avisos de conta/linha removida, que o chamador junta em `achados_detectados` — ver `regras.achados_itens_removidos()`), mesmo `bulk_create` de `create()` (ver acima). Decisão revisada explicitamente pelo usuário (substitui a decisão anterior de preservar `status`/`observacao_contador`/`tratado_por`/ `tratado_em`/`oculto_no_relatorio` — ver histórico no CHANGELOG.md do pacote): um apontamento automático que não dispara mais com os dados novos precisa **sumir** da aba Observações, sinalizando ao contador que aquela inconsistência não existe mais, em vez de continuar pendurado (mesmo "tratado") como se ainda precisasse de atenção. Todo achado que ainda dispara nasce de novo como `pendente`, mesmo que já tivesse sido tratado antes do reprocessamento — o contador revisa de novo com os valores atuais, não reaproveita uma justificativa escrita sobre dados que já mudaram. **Isolado do resto do reprocessamento**: observações do contador (`ContabilObservacao`, ver o model) não vivem aqui, são casadas por chave natural (empresa+conta) e continuam intactas através de qualquer reprocessamento — só o apontamento automático é recriado.""" contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()} av_por_ordem = {linha.ordem: linha for linha in apuracao.linhas_analise_vertical.all()} apuracao.achados.all().delete() ContabilAchado.objects.bulk_create( [ ContabilAchado( apuracao=apuracao, conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None, linha_analise_vertical=( av_por_ordem.get(achado.ordem_linha_analise_vertical) if achado.ordem_linha_analise_vertical is not None else None ), regra=achado.regra, severidade=achado.severidade, titulo=achado.titulo, mensagem=achado.mensagem, valor_referencia=achado.valor_referencia, ) for achado in achados_detectados ] ) class ContabilApuracaoViewSet(viewsets.ModelViewSet): """Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet. `create()` roda `portal_api.dashboard_contabil.pipeline` de forma síncrona sobre o PDF anexado: extrai o cabeçalho/contas/DRE, busca o histórico já persistido da mesma empresa e roda o motor de regras — tudo isso **antes** de gravar qualquer coisa no banco, já que `codigo_empresa`/`competencia` (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de IndicadorApuracao).""" http_method_names = ["get", "post", "delete", "head", "options"] queryset = ContabilApuracao.objects.all() def get_permissions(self) -> list[BasePermission]: return [PermissaoApp("relatorios", "dashboard-contabil")] def get_queryset(self) -> QuerySet[ContabilApuracao]: queryset = super().get_queryset() if self.action == "retrieve": queryset = queryset.prefetch_related( "contas", "linhas_dre", "linhas_analise_vertical", "achados__conta", "achados__linha_analise_vertical" ) elif self.action == "list": queryset = queryset.prefetch_related("reprocessamentos__reprocessado_por") return queryset def get_serializer_class(self) -> type[ModelSerializer]: if self.action == "list": return ContabilApuracaoListSerializer return ContabilApuracaoDetailSerializer def perform_destroy(self, instance: ContabilApuracao) -> None: """Análise **Concluída** não pode mais ser excluída — pedido explícito do usuário, mesma trava de "concluiu, não se mexe mais" que já valia pra reprocessar e pra editar observação/achado (`_contabil_garante_em_revisao`). Mensagem própria em vez de reusar o helper porque a dele fala em editar observações/achados, que não é o caso aqui. Esconder o botão na lista é só UX — quem decide é isto, inclusive pra uma tela aberta antes de outra pessoa concluir a análise.""" if instance.status == ContabilApuracao.STATUS_CONCLUIDA: raise ValidationError( {"detail": "Esta análise já foi concluída — não é mais possível excluí-la."} ) instance.arquivo.delete(save=False) instance.delete() def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: entrada = ContabilApuracaoCreateSerializer(data=request.data) entrada.is_valid(raise_exception=True) arquivo = entrada.validated_data["arquivo"] conteudo = arquivo.read() try: resultado = dashboard_contabil_pipeline.processa_apuracao( io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name ) except ContabilExtracaoInvalidaError as exc: return _contabil_resposta_extracao_invalida(exc) 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, leiaute=resultado.extracao.leiaute, ) 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 o setor de 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, arquivo.name ) except ContabilExtracaoInvalidaError as exc: return _contabil_resposta_extracao_invalida(exc) 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.codigo_empresa} - {cabecalho.nome_empresa} " f"({nova_competencia:%m/%Y}), diferente desta análise ({apuracao.codigo_empresa} - " f"{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.leiaute = resultado.extracao.leiaute apuracao.arquivo = ContentFile(conteudo, name=arquivo.name) apuracao.save() novo_arquivo_salvo = True # Conta/linha que sumiu do arquivo novo vira apontamento na # aba Observações (pedido explícito do usuário): a exclusão # em si é silenciosa, e uma linha desaparecer entre um # arquivo e outro é exatamente o tipo de mudança que o # contador precisa conferir. Os três sincronizadores # devolvem `(origem, rótulo)` do que excluíram. removidos = [ *_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 + dashboard_contabil_regras.achados_itens_removidos(removidos) ) 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 o setor de 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", "id")) linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id")) linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem", "id")) 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 chaves_contas = [ContabilObservacao.chave_conta(c.codigo, c.descricao) for c in contas] chaves_dre = dashboard_contabil_chaves.chaves_linhas(linhas_dre) chaves_av = dashboard_contabil_chaves.chaves_linhas(linhas_analise_vertical) mapa_conta_por_chave = dict(zip(chaves_contas, (c.id for c in contas))) mapa_dre_por_chave = dict(zip(chaves_dre, (l.id for l in linhas_dre))) mapa_av_por_chave = dict(zip(chaves_av, (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], chaves_contas, ), "linhas_dre": _contabil_arvore_contexto( linhas_dre, "linha", lambda l: max(0, l.nivel), 16, observacoes_por_tipo[ContabilObservacao.ALVO_DRE], chaves_dre, ), "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], chaves_av, ), "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() if not apuracao.tem_indicadores: # Leiaute Contabit: a aba "Dashboard" nem mostra a seção (ver # `ContabilApuracao.tem_indicadores`), isto só garante que uma # chamada direta também não calcule nada. return Response( { "indicadores": {}, "indicadores_ocultos": apuracao.indicadores_ocultos, "indicadores_selecionados": apuracao.indicadores_selecionados, "metadados": {}, } ) 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", "id")) 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", "id")) 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_resposta_extracao_invalida(exc: ContabilExtracaoInvalidaError) -> Response: """400 de `create()`/`reprocessar()` quando o PDF não pôde ser lido. Código da empresa ausente no nome do arquivo (leiaute Contabit) tem mensagem própria: o PDF está certo, só o nome do arquivo precisa mudar.""" if isinstance(exc, ContabilCodigoEmpresaAusenteError): detalhe = str(exc) else: detalhe = ( "O arquivo não está no formato esperado (Balancete + DRE do Questor ou do Contabit). " "Contate o setor de Inovação." ) return Response({"detail": detalhe}, status=status.HTTP_400_BAD_REQUEST) 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(apuracao, 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) @action(detail=True, methods=["post"]) def validar(self, request: Request, pk: str | None = None) -> Response: """Botão "Validar" das variações atípicas da Análise Vertical (pedido explícito do usuário): `validado=true` trata o apontamento (`status="tratado"`) **sem** exigir `observacao_contador`, e `validado=false` devolve a "pendente". O texto sobre essas variações não vai no apontamento: é uma `ContabilObservacao` da própria linha da Análise Vertical, com histórico entre competências e o "mostrar ao cliente" de sempre. As demais regras continuam só pelo PATCH com justificativa obrigatória.""" achado = self.get_object() _contabil_garante_em_revisao(achado.apuracao) if achado.regra != "variacao_atipica_dre": raise ValidationError({"detail": "Só variações atípicas da Análise Vertical são validadas por aqui."}) entrada = ContabilAchadoValidarSerializer(data=request.data) entrada.is_valid(raise_exception=True) if entrada.validated_data["validado"]: achado.status = ContabilAchado.STATUS_TRATADO achado.tratado_por = request.user achado.tratado_em = timezone.now() else: achado.status = ContabilAchado.STATUS_PENDENTE achado.tratado_por = None achado.tratado_em = None achado.save(update_fields=["status", "tratado_por", "tratado_em"]) 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) # --- Conciliação de Fornecedores (Utilitários) ----------------------------- # Ver portal_api/conciliacao_fornecedores/CLAUDE.md. Os vínculos # (automáticos na criação, manuais depois) são gravados; status/alertas de # cada conta são recalculados a cada leitura por # `conciliacao_fornecedores.alertas.analisa_conta()`. _CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores") _CONCILIACAO_ROTULO_STATUS = { conciliacao_alertas.STATUS_CONCILIADA: "Conciliada", conciliacao_alertas.STATUS_PENDENTE: "Pendente de pagamento", conciliacao_alertas.STATUS_ATENCAO: "Atenção", conciliacao_alertas.STATUS_DIVERGENTE: "Divergente", } _CONCILIACAO_ROTULO_SEVERIDADE = {"alta": "Alta", "media": "Média", "baixa": "Baixa"} _ConciliacaoContaCarregada = tuple[ ConciliacaoFornecedorConta, list[ConciliacaoFornecedorLancamento], list[ConciliacaoFornecedorVinculo], conciliacao_alertas.AnaliseConta, ] def _conciliacao_rotulo_contrapartida(lanc: ConciliacaoFornecedorLancamento) -> str: return " - ".join(p for p in (lanc.contrapartida_codigo, lanc.contrapartida_descricao.strip()) if p) def _conciliacao_analisa( conciliacao: ConciliacaoFornecedor, conta: ConciliacaoFornecedorConta, lancamentos: list[ConciliacaoFornecedorLancamento], vinculos: list[ConciliacaoFornecedorVinculo], ) -> conciliacao_alertas.AnaliseConta: return conciliacao_alertas.analisa_conta( [ conciliacao_alertas.ItemAnalise( id=lanc.id, data=lanc.data, natureza=lanc.natureza, valor=lanc.valor, vinculo_id=lanc.vinculo_id, descricao=lanc.historico, ) for lanc in lancamentos ], [ conciliacao_alertas.VinculoAnalise( id=v.id, tipo=v.tipo, diferenca=v.diferenca, data_titulo=min((lanc.data for lanc in lancamentos if lanc.vinculo_id == v.id and lanc.natureza == "C"), default=None), ) for v in vinculos ], nome_conta=conta.fornecedor, saldo_final=conta.saldo_final, saldo_final_arquivo=conta.saldo_final_arquivo, data_base=conciliacao.data_base, ) def _conciliacao_sincroniza_validacao_automatica( conta: ConciliacaoFornecedorConta, analise: conciliacao_alertas.AnaliseConta ) -> bool: """Conta com situação "Conciliada" já nasce validada (pedido do usuário). A validação automática é a que tem `validada_por` nulo: se a conta deixa de estar conciliada (ex.: um vínculo desfeito), ela é retirada; a validação feita por uma pessoa nunca é mexida aqui. Devolve se mudou (sem salvar).""" conciliada = analise.status == conciliacao_alertas.STATUS_CONCILIADA if conciliada and not conta.validada: conta.validada, conta.validada_por, conta.validada_em = True, None, timezone.now() return True if not conciliada and conta.validada and conta.validada_por_id is None: conta.validada, conta.validada_em = False, None return True return False def _conciliacao_conta_payload( conta: ConciliacaoFornecedorConta, lancamentos: list[ConciliacaoFornecedorLancamento], vinculos: list[ConciliacaoFornecedorVinculo], analise: conciliacao_alertas.AnaliseConta, ) -> dict[str, Any]: def _situacao_saldo_parcial(vinculo: ConciliacaoFornecedorVinculo) -> dict[str, Any] | None: item = analise.situacoes.get(-vinculo.id) if vinculo.tipo != conciliacao_motor.TIPO_PARCIAL or item is None: return None return { "categoria": item.categoria, "valor_em_aberto": str(item.valor_em_aberto), "descricao": item.descricao, "sugestao": item.sugestao, } def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None: item = analise.situacoes.get(lanc.id) if item is None: return None return { "categoria": item.categoria, "valor_em_aberto": str(item.valor_em_aberto), "descricao": item.descricao, "sugestao": item.sugestao, } return { "id": conta.id, "conta_numero": conta.conta_numero, "classificacao": conta.classificacao, "fornecedor": conta.fornecedor, "saldo_inicial": str(conta.saldo_inicial), "saldo_final": str(conta.saldo_final), "validada": conta.validada, "validada_por_nome": conta.validada_por.nome if conta.validada_por else None, "validada_automatica": conta.validada and conta.validada_por_id is None, "validada_em": conta.validada_em.isoformat() if conta.validada_em else None, "observacao": conta.observacao, "status": analise.status, "status_rotulo": _CONCILIACAO_ROTULO_STATUS[analise.status], "transitoria": conciliacao_alertas.eh_conta_transitoria(conta.fornecedor), "alertas": [dataclasses.asdict(a) for a in analise.alertas], "total_em_aberto": str(analise.total_em_aberto), "total_vencido": str(analise.total_vencido), "total_excedente": str(analise.total_excedente), "total_nao_vinculado": str(analise.total_nao_vinculado), "qtd_pendentes": sum(1 for lanc in lancamentos if lanc.vinculo_id is None), "vinculos": [ { "id": v.id, "tipo": v.tipo, "tipo_rotulo": v.get_tipo_display(), "diferenca": str(v.diferenca), "criado_por_nome": v.criado_por.nome if v.criado_por else None, # pagamento parcial: saldo que continua em aberto "situacao": _situacao_saldo_parcial(v), } for v in vinculos ], "lancamentos": [ { "id": lanc.id, "data": lanc.data.isoformat(), "eh_saldo_anterior": lanc.eh_saldo_anterior, "sequencia": lanc.sequencia, "historico": lanc.historico, "contrapartida": _conciliacao_rotulo_contrapartida(lanc), "natureza": lanc.natureza, "valor": str(lanc.valor), "saldo_apos": str(lanc.saldo_apos) if lanc.saldo_apos is not None else None, "origem": lanc.origem, "usuario": lanc.usuario, "vinculo_id": lanc.vinculo_id, "situacao": situacao(lanc), } for lanc in lancamentos ], } def _conciliacao_carrega( conciliacao: ConciliacaoFornecedor, contas: list[ConciliacaoFornecedorConta] | None = None ) -> list[_ConciliacaoContaCarregada]: """Contas + lançamentos + vínculos + análise, com 3 consultas no total (não uma por conta).""" if contas is None: contas = list(conciliacao.contas.select_related("validada_por").order_by("ordem", "id")) ids = [c.id for c in contas] lancs_por_conta: dict[int, list[ConciliacaoFornecedorLancamento]] = {i: [] for i in ids} for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta_id__in=ids).order_by("ordem", "id"): lancs_por_conta[lanc.conta_id].append(lanc) vinc_por_conta: dict[int, list[ConciliacaoFornecedorVinculo]] = {i: [] for i in ids} for vinc in ConciliacaoFornecedorVinculo.objects.filter(conta_id__in=ids).select_related("criado_por"): vinc_por_conta[vinc.conta_id].append(vinc) return [ ( conta, lancs_por_conta[conta.id], vinc_por_conta[conta.id], _conciliacao_analisa(conciliacao, conta, lancs_por_conta[conta.id], vinc_por_conta[conta.id]), ) for conta in contas ] def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> dict[str, Any]: por_status = {chave: 0 for chave in _CONCILIACAO_ROTULO_STATUS} total_aberto = total_vencido = total_excedente = total_nao_vinculado = Decimal("0") validadas = 0 contas_por_alerta = {"credito_vencido": 0, "diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0} for conta, _, _, analise in carregado: por_status[analise.status] += 1 validadas += conta.validada total_aberto += analise.total_em_aberto total_vencido += analise.total_vencido total_excedente += analise.total_excedente total_nao_vinculado += analise.total_nao_vinculado for tipo in {a.tipo for a in analise.alertas}: if tipo in contas_por_alerta: contas_por_alerta[tipo] += 1 return { "total_contas": len(carregado), "por_status": por_status, "validadas": validadas, "total_em_aberto": str(total_aberto), "total_vencido": str(total_vencido), "total_excedente": str(total_excedente), "total_nao_vinculado": str(total_nao_vinculado), "contas_vencidas": contas_por_alerta["credito_vencido"], "contas_com_diferenca": contas_por_alerta["diferenca_vinculo"], "contas_pagamento_sem_titulo": contas_por_alerta["pagamento_sem_titulo"], "contas_transitorias": contas_por_alerta["conta_transitoria"], } def _conciliacao_valida_conciliadas(conciliacao: ConciliacaoFornecedor) -> int: """Aplica a validação automática em todas as contas (criação da conciliação). Devolve quantas mudaram.""" alteradas = [ conta for conta, _, _, analise in _conciliacao_carrega(conciliacao) if _conciliacao_sincroniza_validacao_automatica(conta, analise) ] ConciliacaoFornecedorConta.objects.bulk_update(alteradas, ["validada", "validada_por", "validada_em"]) return len(alteradas) def _conciliacao_atualiza_resumo(conciliacao: ConciliacaoFornecedor) -> dict[str, Any]: resumo = _conciliacao_calcula_resumo(_conciliacao_carrega(conciliacao)) conciliacao.resumo = resumo conciliacao.save(update_fields=["resumo"]) return resumo def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_validacao: bool = True) -> Response: """Depois de um ajuste manual: devolve a conta reanalisada e o resumo atualizado da conciliação (os cards do topo dependem dele). Depois de vincular/desfazer, reaplica a validação automática; depois de o contador marcar/desmarcar à mão (`sincroniza_validacao=False`), não.""" conciliacao = conta.conciliacao conta = ConciliacaoFornecedorConta.objects.select_related("validada_por").get(pk=conta.pk) [carregada] = _conciliacao_carrega(conciliacao, [conta]) if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]): conta.save(update_fields=["validada", "validada_por", "validada_em"]) resumo = _conciliacao_atualiza_resumo(conciliacao) return Response({"conta": _conciliacao_conta_payload(*carregada), "resumo": resumo}) def _conciliacao_grava_contas( conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido, tolerancia: conciliacao_motor.Tolerancia, ) -> None: """Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo anterior diferente de zero vira um lançamento `eh_saldo_anterior`, primeiro da conta, para participar da conciliação.""" contas = ConciliacaoFornecedorConta.objects.bulk_create( [ ConciliacaoFornecedorConta( conciliacao=conciliacao, ordem=extraida.ordem, conta_numero=extraida.conta_numero, classificacao=extraida.classificacao, fornecedor=extraida.fornecedor[:255], saldo_inicial=extraida.saldo_inicial, saldo_final=extraida.saldo_final_calculado, saldo_final_arquivo=extraida.saldo_final_arquivo, ) for extraida in razao.contas ] ) lancamentos: list[ConciliacaoFornecedorLancamento] = [] for conta, extraida in zip(contas, razao.contas): linhas: list[ConciliacaoFornecedorLancamento] = [] if extraida.saldo_inicial: linhas.append( ConciliacaoFornecedorLancamento( conta=conta, ordem=0, eh_saldo_anterior=True, data=extraida.data_saldo_inicial or razao.periodo_inicio or conciliacao.data_base, historico="Saldo anterior", natureza="C" if extraida.saldo_inicial > 0 else "D", valor=abs(extraida.saldo_inicial), saldo_apos=extraida.saldo_inicial, ) ) for lanc in extraida.lancamentos: linhas.append( ConciliacaoFornecedorLancamento( conta=conta, ordem=len(linhas), data=lanc.data, sequencia=lanc.sequencia[:20], historico=lanc.historico[:500], contrapartida_codigo=lanc.contrapartida_codigo[:20], contrapartida_descricao=lanc.contrapartida_descricao[:255], natureza=lanc.natureza, valor=lanc.valor, saldo_apos=lanc.saldo_apos, usuario=lanc.usuario[:100], origem=lanc.origem[:10], ) ) sugeridos = conciliacao_motor.concilia( [ conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor) for pos, linha in enumerate(linhas) ], tolerancia, ) vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create( [ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos] ) for vinculo, sugerido in zip(vinculos, sugeridos): for pos in sugerido.indices: linhas[pos].vinculo = vinculo lancamentos += linhas ConciliacaoFornecedorLancamento.objects.bulk_create(lancamentos, batch_size=2000) def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes: contas_xlsx: list[conciliacao_exportacao.ContaXlsx] = [] pendencias: list[conciliacao_exportacao.PendenciaXlsx] = [] alertas_xlsx: list[conciliacao_exportacao.AlertaXlsx] = [] for conta, lancs, vincs, analise in _conciliacao_carrega(conciliacao): contas_xlsx.append( conciliacao_exportacao.ContaXlsx( conta_numero=conta.conta_numero, classificacao=conta.classificacao, fornecedor=conta.fornecedor, saldo_inicial=conta.saldo_inicial, saldo_final=conta.saldo_final, total_em_aberto=analise.total_em_aberto, total_vencido=analise.total_vencido, total_excedente=analise.total_excedente, situacao=_CONCILIACAO_ROTULO_STATUS[analise.status], validada=conta.validada, observacao=conta.observacao, ) ) for vinc in vincs: saldo = analise.situacoes.get(-vinc.id) if vinc.tipo != conciliacao_motor.TIPO_PARCIAL or saldo is None or not saldo.valor_em_aberto: continue titulo = min((l for l in lancs if l.vinculo_id == vinc.id and l.natureza == "C"), key=lambda l: l.data) pendencias.append( conciliacao_exportacao.PendenciaXlsx( conta_numero=conta.conta_numero, fornecedor=conta.fornecedor, data=titulo.data, sequencia=titulo.sequencia, historico=f"{titulo.historico} (pagamento parcial vinculado)", natureza="Crédito", valor=titulo.valor, valor_em_aberto=saldo.valor_em_aberto, situacao=saldo.descricao, ) ) for lanc in lancs: situacao = analise.situacoes.get(lanc.id) if situacao is None: continue pendencias.append( conciliacao_exportacao.PendenciaXlsx( conta_numero=conta.conta_numero, fornecedor=conta.fornecedor, data=lanc.data, sequencia=lanc.sequencia, historico=lanc.historico, natureza="Crédito" if lanc.natureza == "C" else "Débito", valor=lanc.valor, valor_em_aberto=situacao.valor_em_aberto, situacao=f"{situacao.descricao} Sugestão: {situacao.sugestao}" if situacao.sugestao else situacao.descricao, ) ) for alerta in analise.alertas: alertas_xlsx.append( conciliacao_exportacao.AlertaXlsx( conta_numero=conta.conta_numero, fornecedor=conta.fornecedor, severidade=_CONCILIACAO_ROTULO_SEVERIDADE[alerta.severidade], mensagem=alerta.mensagem, ) ) periodo = ( f"{conciliacao.periodo_inicio:%d/%m/%Y} a {conciliacao.periodo_fim:%d/%m/%Y}" if conciliacao.periodo_inicio and conciliacao.periodo_fim else "sem lançamentos" ) return conciliacao_exportacao.gera_xlsx( empresa=f"{conciliacao.codigo_empresa} - {conciliacao.nome_empresa or 'Empresa não encontrada no Questor'}", data_base=conciliacao.data_base, periodo=periodo, contas=contas_xlsx, pendencias=pendencias, alertas=alertas_xlsx, ) class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet): """Histórico (GET lista, filtrável por `?codigo_empresa=`), criação (multipart: só `codigo_empresa` + `arquivo`; data-base e tolerâncias são internas), detalhe completo e exclusão.""" http_method_names = ["get", "post", "delete", "head", "options"] serializer_class = ConciliacaoFornecedorListSerializer def get_permissions(self) -> list[BasePermission]: return [PermissaoApp(*_CONCILIACAO_PERMISSAO)] def get_queryset(self) -> QuerySet[ConciliacaoFornecedor]: queryset = ConciliacaoFornecedor.objects.select_related("criado_por") codigo = normalizar_codigo_empresa(self.request.query_params.get("codigo_empresa") or "") if codigo: queryset = queryset.filter(codigo_empresa=codigo) return queryset def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response: conciliacao = self.get_object() carregado = _conciliacao_carrega(conciliacao) dados = dict(ConciliacaoFornecedorListSerializer(conciliacao).data) dados["resumo"] = _conciliacao_calcula_resumo(carregado) dados["contas"] = [_conciliacao_conta_payload(*item) for item in carregado] return Response(dados) def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: entrada = ConciliacaoFornecedorCreateSerializer(data=request.data) entrada.is_valid(raise_exception=True) dados = entrada.validated_data arquivo = dados["arquivo"] conteudo = arquivo.read() try: razao = conciliacao_parser.extrai_razao(conteudo, arquivo.name) except conciliacao_parser.ArquivoRazaoInvalidoError as exc: raise ValidationError({"arquivo": str(exc)}) tolerancia = conciliacao_motor.Tolerancia( conciliacao_motor.TOLERANCIA_VALOR_PADRAO, conciliacao_motor.TOLERANCIA_PERCENTUAL_PADRAO ) # Nome resolvido antes da transação: é uma consulta a banco externo. nome_empresa = resolve_nome_empresa(dados["codigo_empresa"]) or "" conciliacao: ConciliacaoFornecedor | None = None try: with transaction.atomic(): conciliacao = ConciliacaoFornecedor( codigo_empresa=dados["codigo_empresa"], nome_empresa=nome_empresa, data_base=timezone.localdate(), periodo_inicio=razao.periodo_inicio, periodo_fim=razao.periodo_fim, nome_arquivo=arquivo.name[:255], tolerancia_valor=tolerancia.valor, tolerancia_percentual=tolerancia.percentual, criado_por=request.user, ) conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False) conciliacao.save() _conciliacao_grava_contas(conciliacao, razao, tolerancia) _conciliacao_valida_conciliadas(conciliacao) _conciliacao_atualiza_resumo(conciliacao) except Exception: # upload de FileField não é transacional if conciliacao is not None and conciliacao.arquivo: conciliacao.arquivo.delete(save=False) raise return Response(ConciliacaoFornecedorListSerializer(conciliacao).data, status=status.HTTP_201_CREATED) def perform_destroy(self, instance: ConciliacaoFornecedor) -> None: arquivo = instance.arquivo with transaction.atomic(): instance.delete() if arquivo: arquivo.delete(save=False) @action(detail=False, methods=["get"], url_path="nome-empresa") def nome_empresa(self, request: Request) -> Response: """Nome da empresa no Questor pelo código digitado (mesma consulta e cache de "Cadastro de Regras" do Plano de Saúde). `nome_empresa: null` tanto para código inexistente quanto para Questor inacessível.""" 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)}) @action(detail=True, methods=["get"], url_path="exportar-xlsx") def exportar_xlsx(self, request: Request, pk: str | None = None) -> HttpResponse: conciliacao = self.get_object() nome = f"conciliacao_fornecedores_{conciliacao.codigo_empresa}_{conciliacao.data_base:%Y%m%d}.xlsx" resposta = HttpResponse( _conciliacao_gera_xlsx(conciliacao), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) resposta["Content-Disposition"] = f'attachment; filename="{nome}"' return resposta def _conciliacao_tipo_vinculo_manual( conciliacao: ConciliacaoFornecedor, lancs: list[ConciliacaoFornecedorLancamento] ) -> tuple[str, Decimal]: """Tipo e diferença de um vínculo manual. Créditos maiores que os débitos além da tolerância: pagamento parcial, o saldo continua em aberto. Dentro da tolerância (ou pago a maior), fecha como vínculo com diferença (juros/desconto). Usado ao criar e ao acrescentar lançamentos.""" diferenca = sum((lanc.valor if lanc.natureza == "C" else -lanc.valor for lanc in lancs), Decimal("0")) tolerancia = conciliacao_motor.Tolerancia(conciliacao.tolerancia_valor, conciliacao.tolerancia_percentual) total_creditos = sum((lanc.valor for lanc in lancs if lanc.natureza == "C"), Decimal("0")) limite = Decimal(tolerancia.limite_centavos(int(total_creditos * 100))) / 100 tipo = conciliacao_motor.TIPO_PARCIAL if diferenca > limite else conciliacao_motor.TIPO_MANUAL return tipo, diferenca class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet): """Ajustes manuais numa conta: vincular lançamentos pendentes e marcar a conta como validada (com observação).""" queryset = ConciliacaoFornecedorConta.objects.select_related("conciliacao", "validada_por") def get_permissions(self) -> list[BasePermission]: return [PermissaoApp(*_CONCILIACAO_PERMISSAO)] @action(detail=True, methods=["post"]) def vincular(self, request: Request, pk: str | None = None) -> Response: conta = self.get_object() entrada = ConciliacaoFornecedorVincularSerializer(data=request.data) entrada.is_valid(raise_exception=True) ids = set(entrada.validated_data["lancamentos"]) with transaction.atomic(): lancs = list(ConciliacaoFornecedorLancamento.objects.select_for_update().filter(conta=conta, id__in=ids)) if len(lancs) != len(ids): raise ValidationError({"lancamentos": "Há lançamentos que não pertencem a esta conta."}) if any(lanc.vinculo_id is not None for lanc in lancs): raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."}) if len(lancs) < 2 or {lanc.natureza for lanc in lancs} != {"C", "D"}: raise ValidationError({"lancamentos": "Selecione pelo menos um débito e um crédito."}) tipo, diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, lancs) vinculo = ConciliacaoFornecedorVinculo.objects.create( conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user ) ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo) return _conciliacao_resposta_conta(conta) @action(detail=True, methods=["post"]) def validar(self, request: Request, pk: str | None = None) -> Response: conta = self.get_object() entrada = ConciliacaoFornecedorValidarSerializer(data=request.data) entrada.is_valid(raise_exception=True) dados = entrada.validated_data conta.validada = dados["validada"] conta.validada_por = request.user if conta.validada else None conta.validada_em = timezone.now() if conta.validada else None if "observacao" in dados: conta.observacao = dados["observacao"].strip() conta.save(update_fields=["validada", "validada_por", "validada_em", "observacao"]) return _conciliacao_resposta_conta(conta, sincroniza_validacao=False) class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet): """`DELETE` desfaz o vínculo: os lançamentos voltam a pendente. `adicionar/` acrescenta lançamentos pendentes a um vínculo manual.""" queryset = ConciliacaoFornecedorVinculo.objects.select_related("conta__conciliacao") def get_permissions(self) -> list[BasePermission]: return [PermissaoApp(*_CONCILIACAO_PERMISSAO)] def destroy(self, request: Request, pk: str | None = None) -> Response: vinculo = self.get_object() conta = vinculo.conta with transaction.atomic(): vinculo.delete() return _conciliacao_resposta_conta(conta) @action(detail=True, methods=["post"]) def adicionar(self, request: Request, pk: str | None = None) -> Response: """Acrescenta lançamentos pendentes da mesma conta a um vínculo manual (ex.: um segundo pagamento de um título já vinculado como pagamento parcial), sem desfazer o vínculo. Tipo e diferença são recalculados com a mesma regra da criação; o vínculo passa a ser do usuário atual.""" vinculo = self.get_object() if vinculo.tipo not in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL): raise ValidationError({"detail": "Só é possível acrescentar lançamentos a um vínculo manual."}) entrada = ConciliacaoFornecedorVincularSerializer(data=request.data) entrada.is_valid(raise_exception=True) ids = set(entrada.validated_data["lancamentos"]) conta = vinculo.conta with transaction.atomic(): novos = list(ConciliacaoFornecedorLancamento.objects.select_for_update().filter(conta=conta, id__in=ids)) if len(novos) != len(ids): raise ValidationError({"lancamentos": "Há lançamentos que não pertencem a esta conta."}) if any(lanc.vinculo_id is not None for lanc in novos): raise ValidationError({"lancamentos": "Há lançamentos que já estão vinculados. Desfaça o vínculo antes."}) ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo) todos = list(ConciliacaoFornecedorLancamento.objects.filter(vinculo=vinculo)) vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos) vinculo.criado_por = request.user vinculo.save(update_fields=["tipo", "diferenca", "criado_por"]) return _conciliacao_resposta_conta(conta)