713 lines
26 KiB
Python
713 lines
26 KiB
Python
# /SGMP_PROD/solicitacoes/services.py
|
|
|
|
import logging
|
|
from collections import Counter
|
|
from datetime import date, datetime
|
|
from typing import Any, Dict, Optional, Tuple
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from .models import (
|
|
PessoaRM,
|
|
Solicitacao,
|
|
Desligamento,
|
|
AdmissaoSubstituicao,
|
|
AdmissaoAumentoQuadro,
|
|
Movimentacao,
|
|
Aprovacao,
|
|
Parecer,
|
|
UsuarioSistema,
|
|
TipoSolicitacao,
|
|
StatusSolicitacao,
|
|
DecisaoAprovacao,
|
|
EtapaAprovacao,
|
|
)
|
|
from .intf_sqlserver import listar_para_selecionar_colaborador, verificar_estabilidades_colaborador
|
|
from .intf_winthor import buscar_colaborador_oracle
|
|
|
|
# Configuração do Logger para rastreabilidade
|
|
logger = logging.getLogger(__name__)
|
|
STATUS_POR_ETAPA = {
|
|
EtapaAprovacao.HEAD: StatusSolicitacao.ENVIADA,
|
|
EtapaAprovacao.DIRETORIA: StatusSolicitacao.FINALIZADA,
|
|
}
|
|
|
|
# --- Exceções de Domínio ---
|
|
# Usar exceções customizadas torna o código mais explícito e fácil de
|
|
# tratar nas camadas superiores (views, APIs).
|
|
|
|
class SolicitacaoError(Exception):
|
|
"""Classe base para erros relacionados a solicitações."""
|
|
pass
|
|
|
|
class ValidacaoError(SolicitacaoError):
|
|
"""Lançada quando uma regra de negócio é violada."""
|
|
pass
|
|
|
|
class PermissaoError(SolicitacaoError):
|
|
"""Lançada quando um usuário tenta executar uma ação não permitida."""
|
|
pass
|
|
|
|
def sincronizar_colaboradores_rm() -> Tuple[int, int]:
|
|
"""
|
|
Executa a sincronização de colaboradores do TOTVS RM para o SGMP.
|
|
|
|
Esta função é responsável por:
|
|
1. Buscar todos os colaboradores ativos no RM.
|
|
2. Usar 'update_or_create' para inserir novos ou atualizar existentes.
|
|
3. Tentar, de forma resiliente, enriquecer o dado com a matrícula do Winthor.
|
|
|
|
A lógica de integração fica isolada aqui, protegendo o resto do sistema
|
|
de sua complexidade.
|
|
|
|
Retorna:
|
|
Uma tupla (criados, atualizados) com a contagem de registros.
|
|
"""
|
|
logger.info("Iniciando sincronização de colaboradores com o TOTVS RM.")
|
|
dados_rm = listar_para_selecionar_colaborador()
|
|
|
|
criados = 0
|
|
atualizados = 0
|
|
agora = timezone.now()
|
|
|
|
for row in dados_rm:
|
|
id_rm = f"{row['CODCOLIGADA']}-{row['CHAPA']}"
|
|
cpf = row.get('CPF')
|
|
matricula_winthor = None
|
|
|
|
# --- Tratamento de Integração Satélite (Winthor) ---
|
|
# A busca no Winthor é "best-effort". Uma falha aqui não deve
|
|
# impedir a sincronização principal com o RM.
|
|
if cpf:
|
|
try:
|
|
dados_winthor = buscar_colaborador_oracle(cpf)
|
|
if dados_winthor:
|
|
matricula_winthor = dados_winthor.get('matricula')
|
|
except Exception as e:
|
|
logger.error(f"Falha ao buscar CPF {cpf} no Winthor: {e}")
|
|
|
|
# O 'update_or_create' é atômico por padrão e a forma mais segura
|
|
# e eficiente de realizar a sincronização.
|
|
_, created = PessoaRM.objects.update_or_create(
|
|
id_rm=id_rm,
|
|
defaults={
|
|
"matricula": row["CHAPA"],
|
|
"nome": row["NOME"],
|
|
"cpf": cpf,
|
|
"cargo": row["FUNCAO"],
|
|
"setor": row["SECAO"],
|
|
"centro_custo": row["CODSECAO"],
|
|
"data_admissao": row["DATAADMISSAO"],
|
|
"situacao": row["CODSITUACAO"],
|
|
"cod_funcao": row["CODFUNCAO"],
|
|
"salario": row["SALARIO"],
|
|
"cod_sindicato": row["CODSINDICATO"],
|
|
"saldo_banco_horas_minutos": row.get("SALDO_MINUTOS"),
|
|
"inicio_periodo_banco_horas": row.get("INICIOPER"),
|
|
"fim_periodo_banco_horas": row.get("FIMPER"),
|
|
"matricula_winthor": matricula_winthor,
|
|
"sincronizado_em": agora,
|
|
},
|
|
)
|
|
|
|
if created:
|
|
criados += 1
|
|
else:
|
|
atualizados += 1
|
|
|
|
logger.info(f"Sincronização concluída. Criados: {criados}, Atualizados: {atualizados}.")
|
|
return criados, atualizados
|
|
|
|
|
|
def _status_e_envio_inicial(solicitante: UsuarioSistema) -> Tuple[str, Optional[datetime]]:
|
|
"""
|
|
Diretoria cria já como ENVIADA (pula rascunho e aprovação do Head).
|
|
Demais perfis iniciam em RASCUNHO.
|
|
"""
|
|
if solicitante.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
|
|
return StatusSolicitacao.ENVIADA, timezone.now()
|
|
return StatusSolicitacao.RASCUNHO, None
|
|
|
|
|
|
# --- Service de Gestão de Solicitações (Core do Domínio) ---
|
|
@transaction.atomic
|
|
def criar_solicitacao_desligamento(
|
|
solicitante: UsuarioSistema,
|
|
funcionario: PessoaRM,
|
|
tipo_desligamento: str,
|
|
aviso_previo: str,
|
|
motivo: str,
|
|
data_prevista_desligamento: date,
|
|
observacoes: str = "",
|
|
arquivo_pedido = None
|
|
) -> Solicitacao:
|
|
"""
|
|
Cria uma solicitação de Desligamento de forma transacional.
|
|
|
|
Validações:
|
|
- Garante que não existe outra solicitação em aberto para o mesmo funcionário.
|
|
- Verifica estabilidades legais/operacionais que podem bloquear o desligamento.
|
|
|
|
O decorador @transaction.atomic garante que a criação da Solicitação
|
|
e do Desligamento ocorram juntas. Se uma falhar, a outra é revertida.
|
|
"""
|
|
if Solicitacao.objects.filter(
|
|
funcionario=funcionario,
|
|
status__in=[
|
|
StatusSolicitacao.RASCUNHO,
|
|
StatusSolicitacao.ENVIADA,
|
|
StatusSolicitacao.APROVADA_GG,
|
|
StatusSolicitacao.APROVADA_CONTROLADORIA,
|
|
StatusSolicitacao.APROVADA_DIRETORIA
|
|
]
|
|
).exists():
|
|
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
|
|
|
# Verifica estabilidades que bloqueiam o desligamento
|
|
estabilidades = verificar_estabilidades_colaborador(funcionario.id_rm)
|
|
estabilidades_bloqueantes = [e for e in estabilidades if e.get('bloqueado', False)]
|
|
|
|
if estabilidades_bloqueantes:
|
|
mensagens = [e['mensagem'] for e in estabilidades_bloqueantes]
|
|
raise ValidacaoError(
|
|
"Desligamento bloqueado por estabilidade legal/operacional:\n" +
|
|
"\n".join(f"• {msg}" for msg in mensagens)
|
|
)
|
|
|
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
|
create_kwargs = {
|
|
"tipo": TipoSolicitacao.DESLIGAMENTO,
|
|
"solicitante": solicitante,
|
|
"funcionario": funcionario,
|
|
"status": status_inicial,
|
|
}
|
|
if enviada_em is not None:
|
|
create_kwargs["enviada_em"] = enviada_em
|
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
|
|
|
Desligamento.objects.create(
|
|
solicitacao=solicitacao,
|
|
tipo_desligamento=tipo_desligamento,
|
|
aviso_previo=aviso_previo,
|
|
motivo=motivo,
|
|
data_prevista_desligamento=data_prevista_desligamento,
|
|
observacoes=observacoes,
|
|
arquivo_pedido=arquivo_pedido
|
|
)
|
|
|
|
logger.info(f"Solicitação de Desligamento {solicitacao.id} criada para {funcionario.nome} por {solicitante.nome}.")
|
|
return solicitacao
|
|
|
|
@transaction.atomic
|
|
def criar_solicitacao_aumento_quadro(
|
|
solicitante: UsuarioSistema,
|
|
dados_admissao: Dict[str, Any]
|
|
) -> Solicitacao:
|
|
"""
|
|
Cria uma solicitação de Admissão por Aumento de Quadro.
|
|
|
|
Este tipo de solicitação não possui um 'funcionario' vinculado
|
|
inicialmente, pois representa a criação de uma nova vaga.
|
|
"""
|
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
|
create_kwargs = {
|
|
"tipo": TipoSolicitacao.ADMISSAO_AUMENTO,
|
|
"solicitante": solicitante,
|
|
"funcionario": None,
|
|
"status": status_inicial,
|
|
}
|
|
if enviada_em is not None:
|
|
create_kwargs["enviada_em"] = enviada_em
|
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
|
|
|
AdmissaoAumentoQuadro.objects.create(
|
|
solicitacao=solicitacao,
|
|
**dados_admissao
|
|
)
|
|
|
|
logger.info(f"Solicitação de Aumento de Quadro {solicitacao.id} criada por {solicitante.nome}.")
|
|
return solicitacao
|
|
|
|
@transaction.atomic
|
|
def criar_solicitacao_substituicao(
|
|
solicitante: UsuarioSistema,
|
|
funcionario_substituido: PessoaRM,
|
|
dados_admissao: Dict[str, Any],
|
|
) -> Solicitacao:
|
|
"""
|
|
Cria uma solicitação de Admissão por Substituição.
|
|
|
|
Regras:
|
|
- Deve existir um funcionário sendo substituído
|
|
- Não pode haver outra solicitação ativa para esse funcionário
|
|
- Permite funcionários desligados, pois a substituição é exatamente para substituir quem já saiu
|
|
"""
|
|
|
|
# Não valida situação desligada para substituição, pois é esperado que o funcionário
|
|
# substituído possa estar desligado (essa é a razão da substituição)
|
|
|
|
if Solicitacao.objects.filter(
|
|
funcionario=funcionario_substituido,
|
|
status__in=[
|
|
StatusSolicitacao.RASCUNHO,
|
|
StatusSolicitacao.ENVIADA,
|
|
StatusSolicitacao.APROVADA_GG,
|
|
StatusSolicitacao.APROVADA_CONTROLADORIA,
|
|
StatusSolicitacao.APROVADA_DIRETORIA,
|
|
],
|
|
).exists():
|
|
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
|
|
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
|
create_kwargs = {
|
|
"tipo": TipoSolicitacao.ADMISSAO_SUBSTITUICAO,
|
|
"solicitante": solicitante,
|
|
"funcionario": funcionario_substituido,
|
|
"status": status_inicial,
|
|
}
|
|
if enviada_em is not None:
|
|
create_kwargs["enviada_em"] = enviada_em
|
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
|
|
|
AdmissaoSubstituicao.objects.create(
|
|
solicitacao=solicitacao,
|
|
**dados_admissao,
|
|
)
|
|
|
|
logger.info(
|
|
f"Solicitação de Substituição {solicitacao.id} criada para "
|
|
f"{funcionario_substituido.nome} por {solicitante.nome}."
|
|
)
|
|
|
|
return solicitacao
|
|
|
|
@transaction.atomic
|
|
def criar_solicitacao_movimentacao(
|
|
solicitante: UsuarioSistema,
|
|
funcionario: PessoaRM,
|
|
dados_movimentacao: Dict[str, Any],
|
|
) -> Solicitacao:
|
|
"""
|
|
Cria uma solicitação de Movimentação Interna.
|
|
|
|
Regras:
|
|
- Funcionário deve estar ativo
|
|
- Não pode haver outra solicitação ativa para o mesmo funcionário
|
|
"""
|
|
|
|
if funcionario.situacao == 'D':
|
|
raise ValidacaoError("Não é possível movimentar um funcionário desligado.")
|
|
|
|
if Solicitacao.objects.filter(
|
|
funcionario=funcionario,
|
|
status__in=[
|
|
StatusSolicitacao.RASCUNHO,
|
|
StatusSolicitacao.ENVIADA,
|
|
StatusSolicitacao.APROVADA_GG,
|
|
StatusSolicitacao.APROVADA_CONTROLADORIA,
|
|
StatusSolicitacao.APROVADA_DIRETORIA,
|
|
],
|
|
).exists():
|
|
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
|
|
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
|
create_kwargs = {
|
|
"tipo": TipoSolicitacao.MOVIMENTACAO,
|
|
"solicitante": solicitante,
|
|
"funcionario": funcionario,
|
|
"status": status_inicial,
|
|
}
|
|
if enviada_em is not None:
|
|
create_kwargs["enviada_em"] = enviada_em
|
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
|
|
|
Movimentacao.objects.create(
|
|
solicitacao=solicitacao,
|
|
**dados_movimentacao,
|
|
)
|
|
|
|
logger.info(
|
|
f"Solicitação de Movimentação {solicitacao.id} criada para "
|
|
f"{funcionario.nome} por {solicitante.nome}."
|
|
)
|
|
|
|
return solicitacao
|
|
|
|
@transaction.atomic
|
|
def enviar_solicitacao(solicitacao: Solicitacao, usuario: UsuarioSistema) -> Solicitacao:
|
|
"""
|
|
Muda o status de uma solicitação de 'Rascunho' para 'Aguardando Head'.
|
|
|
|
O Head deve aprovar ou reprovar; só após aprovação do Head a solicitação
|
|
passa para ENVIADA (e enviada_em é preenchido). Reprovação do Head → REPROVADA.
|
|
|
|
Validações:
|
|
- Apenas o solicitante pode enviar.
|
|
- A solicitação deve estar no estado 'Rascunho'.
|
|
"""
|
|
if solicitacao.solicitante != usuario:
|
|
raise PermissaoError("Apenas o solicitante pode enviar a solicitação.")
|
|
|
|
# Já disparada (ex.: Diretoria cria direto como ENVIADA; cliente pode chamar enviar de novo)
|
|
if solicitacao.status == StatusSolicitacao.ENVIADA:
|
|
return solicitacao
|
|
|
|
if not solicitacao.pode_enviar():
|
|
raise ValidacaoError(f"A solicitação não pode ser enviada no status atual ({solicitacao.get_status_display()}).")
|
|
|
|
solicitacao.status = StatusSolicitacao.AGUARDANDO_HEAD
|
|
solicitacao.save()
|
|
# enviada_em será preenchido quando o Head aprovar (em aprovar_reprovar_por_head)
|
|
|
|
logger.info(f"Solicitação {solicitacao.id} enviada para aprovação do Head por {usuario.nome}.")
|
|
return solicitacao
|
|
|
|
@transaction.atomic
|
|
def registrar_parecer(
|
|
solicitacao: Solicitacao,
|
|
usuario: UsuarioSistema,
|
|
texto: str,
|
|
anexo=None
|
|
) -> Parecer:
|
|
"""
|
|
Registra um parecer de GG ou CONTROLADORIA sobre uma solicitação.
|
|
Não altera o status da solicitação diretamente.
|
|
|
|
Quando ambos os pareceres (GG e CONTROLADORIA) são dados,
|
|
a solicitação muda automaticamente para AGUARDANDO_DIRETORIA.
|
|
|
|
Validações:
|
|
- Verifica se o usuário pode dar parecer (GG ou CONTROLADORIA)
|
|
- Verifica se a solicitação está no status ENVIADA
|
|
- Impede pareceres duplicados para a mesma etapa
|
|
"""
|
|
if not solicitacao.pode_dar_parecer(usuario):
|
|
raise PermissaoError("Usuário não pode dar parecer nesta solicitação.")
|
|
|
|
etapas_aptas = []
|
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG):
|
|
etapas_aptas.append(EtapaAprovacao.GG)
|
|
if usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA):
|
|
etapas_aptas.append(EtapaAprovacao.CONTROLADORIA)
|
|
|
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
|
# ADMIN atua como super-perfil: assume a primeira etapa pendente.
|
|
if not Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists():
|
|
etapa = EtapaAprovacao.GG
|
|
elif not Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA).exists():
|
|
etapa = EtapaAprovacao.CONTROLADORIA
|
|
else:
|
|
raise ValidacaoError("Esta solicitação já possui todos os pareceres técnicos.")
|
|
else:
|
|
etapa = next(
|
|
(e for e in etapas_aptas if not Parecer.objects.filter(solicitacao=solicitacao, etapa=e).exists()),
|
|
None,
|
|
)
|
|
|
|
if not etapa:
|
|
raise PermissaoError("Apenas GG, Controladoria ou Admin podem dar parecer.")
|
|
|
|
# Verifica se já existe parecer para esta etapa
|
|
if Parecer.objects.filter(solicitacao=solicitacao, etapa=etapa).exists():
|
|
raise ValidacaoError(f"Já existe um parecer da {etapa.label} para esta solicitação.")
|
|
|
|
# Cria o parecer
|
|
parecer = Parecer.objects.create(
|
|
solicitacao=solicitacao,
|
|
etapa=etapa,
|
|
usuario=usuario,
|
|
texto=texto,
|
|
anexo=anexo
|
|
)
|
|
|
|
# Verifica se ambos os pareceres foram dados
|
|
parecer_gg = Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists()
|
|
parecer_controladoria = Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA).exists()
|
|
|
|
if parecer_gg and parecer_controladoria:
|
|
# Ambos os pareceres foram dados, muda status para AGUARDANDO_DIRETORIA
|
|
solicitacao.status = StatusSolicitacao.AGUARDANDO_DIRETORIA
|
|
solicitacao.save()
|
|
logger.info(f"Solicitação {solicitacao.id} mudou para AGUARDANDO_DIRETORIA após ambos os pareceres serem dados.")
|
|
|
|
logger.info(f"Parecer da {etapa.label} registrado para a solicitação {solicitacao.id} por {usuario.nome}.")
|
|
return parecer
|
|
|
|
@transaction.atomic
|
|
def aprovar_reprovar_solicitacao(
|
|
solicitacao: Solicitacao,
|
|
aprovador: UsuarioSistema,
|
|
decisao: str,
|
|
justificativa: str = ""
|
|
) -> Solicitacao:
|
|
"""
|
|
Registra uma decisão (Aprovação/Reprovação) da DIRETORIA.
|
|
|
|
Apenas a DIRETORIA pode aprovar/reprovar solicitações.
|
|
A solicitação deve estar no status AGUARDANDO_DIRETORIA.
|
|
|
|
Validações:
|
|
- Verifica se o aprovador é da DIRETORIA
|
|
- Verifica se a solicitação está aguardando aprovação da Diretoria
|
|
- Justificativa é obrigatória apenas para reprovação
|
|
"""
|
|
if not (
|
|
aprovador.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
|
or aprovador.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
|
):
|
|
raise PermissaoError("Apenas a Diretoria ou Admin pode aprovar/reprovar solicitações.")
|
|
|
|
if solicitacao.status != StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
|
raise ValidacaoError("A solicitação não está aguardando aprovação da Diretoria.")
|
|
|
|
etapa_atual = solicitacao.etapa_atual()
|
|
if etapa_atual != EtapaAprovacao.DIRETORIA:
|
|
raise ValidacaoError("A solicitação não está em uma etapa de aprovação válida.")
|
|
|
|
# Valida justificativa apenas para reprovação
|
|
if decisao == DecisaoAprovacao.REPROVADO and not justificativa.strip():
|
|
raise ValidacaoError("Justificativa é obrigatória para reprovação.")
|
|
|
|
# Cria o registro de decisão
|
|
Aprovacao.objects.create(
|
|
solicitacao=solicitacao,
|
|
etapa=etapa_atual,
|
|
decisao=decisao,
|
|
usuario=aprovador,
|
|
justificativa=justificativa or "Aprovado" # Valor padrão para aprovação
|
|
)
|
|
|
|
if decisao == DecisaoAprovacao.REPROVADO:
|
|
solicitacao.status = StatusSolicitacao.REPROVADA
|
|
solicitacao.finalizada_em = timezone.now()
|
|
else:
|
|
solicitacao.status = STATUS_POR_ETAPA[etapa_atual]
|
|
if solicitacao.status == StatusSolicitacao.FINALIZADA:
|
|
solicitacao.finalizada_em = timezone.now()
|
|
|
|
solicitacao.save()
|
|
logger.info(f"Decisão '{decisao}' registrada pela Diretoria para a solicitação {solicitacao.id} por {aprovador.nome}.")
|
|
return solicitacao
|
|
|
|
|
|
@transaction.atomic
|
|
def aprovar_reprovar_por_head(
|
|
solicitacao: Solicitacao,
|
|
aprovador: UsuarioSistema,
|
|
decisao: str,
|
|
justificativa: str = "",
|
|
) -> Solicitacao:
|
|
"""
|
|
Registra a decisão (Aprovação/Reprovação) do HEAD sobre o rascunho enviado pelo gestor.
|
|
|
|
Apenas o perfil HEAD pode usar esta função.
|
|
A solicitação deve estar no status AGUARDANDO_HEAD.
|
|
Se aprovado: status → ENVIADA e enviada_em é preenchido.
|
|
Se reprovado: status → REPROVADA e finalizada_em é preenchido.
|
|
"""
|
|
if not (
|
|
aprovador.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
|
or aprovador.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
|
):
|
|
raise PermissaoError("Apenas o Head ou Admin pode aprovar/reprovar solicitações nesta etapa.")
|
|
|
|
if solicitacao.status != StatusSolicitacao.AGUARDANDO_HEAD:
|
|
raise ValidacaoError("A solicitação não está aguardando aprovação do Head.")
|
|
|
|
etapa_atual = solicitacao.etapa_atual()
|
|
if etapa_atual != EtapaAprovacao.HEAD:
|
|
raise ValidacaoError("A solicitação não está em etapa de aprovação do Head.")
|
|
|
|
if decisao == DecisaoAprovacao.REPROVADO and not justificativa.strip():
|
|
raise ValidacaoError("Justificativa é obrigatória para reprovação.")
|
|
|
|
Aprovacao.objects.create(
|
|
solicitacao=solicitacao,
|
|
etapa=EtapaAprovacao.HEAD,
|
|
decisao=decisao,
|
|
usuario=aprovador,
|
|
justificativa=justificativa or "Aprovado",
|
|
)
|
|
|
|
if decisao == DecisaoAprovacao.REPROVADO:
|
|
solicitacao.status = StatusSolicitacao.REPROVADA
|
|
solicitacao.finalizada_em = timezone.now()
|
|
else:
|
|
solicitacao.status = StatusSolicitacao.ENVIADA
|
|
solicitacao.enviada_em = timezone.now()
|
|
|
|
solicitacao.save()
|
|
logger.info(
|
|
f"Decisão '{decisao}' registrada pelo Head para a solicitação {solicitacao.id} por {aprovador.nome}."
|
|
)
|
|
return solicitacao
|
|
|
|
|
|
def _status_apos_remover_pareceres(solicitacao: Solicitacao) -> str:
|
|
"""Define status após remoção de um parecer (GG/Ctrl): volta para ENVIADA até ambos existirem de novo."""
|
|
tem_gg = Parecer.objects.filter(
|
|
solicitacao=solicitacao, etapa=EtapaAprovacao.GG
|
|
).exists()
|
|
tem_ctrl = Parecer.objects.filter(
|
|
solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA
|
|
).exists()
|
|
if tem_gg and tem_ctrl:
|
|
return StatusSolicitacao.AGUARDANDO_DIRETORIA
|
|
return StatusSolicitacao.ENVIADA
|
|
|
|
|
|
@transaction.atomic
|
|
def reverter_ultima_acao(solicitacao: Solicitacao, dry_run: bool = False) -> dict:
|
|
"""
|
|
Desfaz um passo do fluxo, da situação atual para trás (última ação gravada).
|
|
|
|
Ordem de reversão:
|
|
1. FINALIZADA com Aprovacao DIRETORIA → remove decisão da Diretoria.
|
|
2. AGUARDANDO_DIRETORIA → remove o parecer mais recente (GG ou Controladoria).
|
|
3. ENVIADA → remove o parecer mais recente, se houver; senão remove Aprovacao HEAD (volta AGUARDANDO_HEAD).
|
|
4. AGUARDANDO_HEAD (sem aprovação Head) → volta para RASCUNHO (desfaz envio ao Head).
|
|
|
|
Não trata REPROVADA nem estados legados (APROVADA_*); use o Admin ou script dedicado.
|
|
|
|
Retorna dict com chaves: acao (str), detalhe (str).
|
|
Se dry_run=True, não grava alterações.
|
|
"""
|
|
pk = solicitacao.pk
|
|
s = Solicitacao.objects.select_for_update().get(pk=pk) if not dry_run else Solicitacao.objects.get(pk=pk)
|
|
|
|
st = s.status
|
|
acao = ""
|
|
detalhe = ""
|
|
|
|
# 1) Decisão da Diretoria
|
|
if st == StatusSolicitacao.FINALIZADA:
|
|
qs_dir = Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.DIRETORIA)
|
|
if not qs_dir.exists():
|
|
raise ValidacaoError(
|
|
"FINALIZADA sem registro de Aprovacao na DIRETORIA — estado inconsistente; corrija manualmente."
|
|
)
|
|
novo = _status_apos_remover_pareceres(s)
|
|
acao = "remover_aprovacao_diretoria"
|
|
detalhe = f"Remover decisão DIRETORIA; status → {novo}"
|
|
if not dry_run:
|
|
qs_dir.delete()
|
|
s.finalizada_em = None
|
|
s.status = novo
|
|
s.save()
|
|
logger.warning(
|
|
"reverter_ultima_acao: removida Aprovacao DIRETORIA da solicitação %s → %s",
|
|
s.id,
|
|
novo,
|
|
)
|
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
|
|
|
# 2) Estava aguardando Diretoria: desfaz último parecer técnico
|
|
if st == StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
|
ultimo = (
|
|
Parecer.objects.filter(solicitacao=s)
|
|
.order_by("-criado_em", "-id")
|
|
.first()
|
|
)
|
|
if not ultimo:
|
|
raise ValidacaoError(
|
|
"AGUARDANDO_DIRETORIA sem pareceres — inconsistente; corrija manualmente."
|
|
)
|
|
et = ultimo.etapa
|
|
acao = "remover_parecer"
|
|
detalhe = f"Remover parecer {et} (mais recente); status → ENVIADA"
|
|
novo = StatusSolicitacao.ENVIADA
|
|
if not dry_run:
|
|
ultimo.delete()
|
|
s.status = novo
|
|
s.save()
|
|
logger.warning(
|
|
"reverter_ultima_acao: removido Parecer %s da solicitação %s → ENVIADA",
|
|
et,
|
|
s.id,
|
|
)
|
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
|
|
|
# 3) ENVIADA: parecer mais recente OU aprovação Head
|
|
if st == StatusSolicitacao.ENVIADA:
|
|
ultimo_p = (
|
|
Parecer.objects.filter(solicitacao=s)
|
|
.order_by("-criado_em", "-id")
|
|
.first()
|
|
)
|
|
if ultimo_p:
|
|
et = ultimo_p.etapa
|
|
acao = "remover_parecer"
|
|
if dry_run:
|
|
c = Counter(
|
|
Parecer.objects.filter(solicitacao=s).values_list("etapa", flat=True)
|
|
)
|
|
c[et] -= 1
|
|
tem_gg = c[EtapaAprovacao.GG] > 0
|
|
tem_ctrl = c[EtapaAprovacao.CONTROLADORIA] > 0
|
|
novo = (
|
|
StatusSolicitacao.AGUARDANDO_DIRETORIA
|
|
if (tem_gg and tem_ctrl)
|
|
else StatusSolicitacao.ENVIADA
|
|
)
|
|
detalhe = f"Remover parecer {et} (mais recente); status → {novo}"
|
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
|
ultimo_p.delete()
|
|
s.refresh_from_db()
|
|
s.status = _status_apos_remover_pareceres(s)
|
|
s.save()
|
|
logger.warning(
|
|
"reverter_ultima_acao: removido Parecer %s da solicitação %s → %s",
|
|
et,
|
|
s.id,
|
|
s.status,
|
|
)
|
|
return {
|
|
"acao": acao,
|
|
"detalhe": f"Remover parecer {et}; status → {s.status}",
|
|
"novo_status": s.status,
|
|
}
|
|
|
|
ap_head = Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.HEAD).first()
|
|
if ap_head:
|
|
acao = "remover_aprovacao_head"
|
|
detalhe = "Remover aprovação HEAD; status → AGUARDANDO_HEAD; limpar enviada_em"
|
|
novo = StatusSolicitacao.AGUARDANDO_HEAD
|
|
if not dry_run:
|
|
ap_head.delete()
|
|
s.status = novo
|
|
s.enviada_em = None
|
|
s.save()
|
|
logger.warning(
|
|
"reverter_ultima_acao: removida Aprovacao HEAD da solicitação %s → AGUARDANDO_HEAD",
|
|
s.id,
|
|
)
|
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
|
|
|
raise ValidacaoError(
|
|
"ENVIADA sem pareceres e sem Aprovacao HEAD — nada a reverter (ex.: criada pela Diretoria já neste status)."
|
|
)
|
|
|
|
# 4) Aguardando Head: desfaz o envio (volta rascunho)
|
|
if st == StatusSolicitacao.AGUARDANDO_HEAD:
|
|
if Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.HEAD).exists():
|
|
raise ValidacaoError(
|
|
"AGUARDANDO_HEAD com Aprovacao HEAD já registrada — estado inconsistente; use reversão após corrigir status."
|
|
)
|
|
acao = "desfazer_envio_head"
|
|
detalhe = "Voltar de AGUARDANDO_HEAD para RASCUNHO (desfaz envio ao Head)"
|
|
novo = StatusSolicitacao.RASCUNHO
|
|
if not dry_run:
|
|
s.status = novo
|
|
s.save()
|
|
logger.warning(
|
|
"reverter_ultima_acao: solicitação %s voltou de AGUARDANDO_HEAD → RASCUNHO",
|
|
s.id,
|
|
)
|
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
|
|
|
raise ValidacaoError(
|
|
f"Reversão não suportada para o status atual ({st}). "
|
|
"Estados como REPROVADA ou APROVADA_* legados exigem correção manual."
|
|
)
|
|
|
|
|