sgmp/solicitacoes/services.py

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."
)