sgmp/solicitacoes/models.py

762 lines
25 KiB
Python

from django.db import models
from django.utils.translation import gettext_lazy as _
import uuid
class BaseModel(models.Model):
"""
Modelo base abstrato utilizado por todas as entidades do SGMP.
Centraliza atributos comuns de persistência, garantindo:
- Identificação única global via UUID (facilita integrações externas)
- Rastreabilidade temporal (criação e última atualização)
O uso de UUID como chave primária evita dependência de IDs sequenciais
e facilita sincronização entre sistemas distintos (SGMP, RM, Winthor).
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
criado_em = models.DateTimeField(auto_now_add=True)
atualizado_em = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class TipoSolicitacao(models.TextChoices):
"""
Enumeração que define os tipos de solicitações suportadas pelo SGMP.
Cada tipo representa um processo de RH distinto, podendo possuir:
- regras de negócio específicas
- dados complementares próprios
- fluxos de validação e integração diferentes
Exemplos:
- Desligamento
- Admissão (substituição ou aumento de quadro)
- Movimentação interna
"""
DESLIGAMENTO = "DESLIGAMENTO", _("Desligamento")
ADMISSAO_SUBSTITUICAO = "ADM_SUBSTITUICAO", _("Admissão por Substituição")
ADMISSAO_AUMENTO = "ADM_AUMENTO", _("Admissão por Aumento de Quadro")
MOVIMENTACAO = "MOVIMENTACAO", _("Movimentação")
class StatusSolicitacao(models.TextChoices):
"""
Enumeração que representa o estado atual de uma solicitação
dentro do fluxo de aprovação do SGMP.
O status reflete a posição da solicitação no processo,
controlando permissões de edição, envio, aprovação e finalização.
O fluxo é progressivo e controlado, permitindo auditoria
e rastreabilidade do ciclo de vida da solicitação.
"""
RASCUNHO = "RASCUNHO", _("Rascunho")
AGUARDANDO_HEAD = "AGUARDANDO_HEAD", _("Aguardando Head")
ENVIADA = "ENVIADA", _("Enviada")
APROVADA_GG = "APROVADA_GG", _("Aprovada GG")
APROVADA_CONTROLADORIA = "APROVADA_CONTROLADORIA", _("Aprovada Controladoria")
APROVADA_DIRETORIA = "APROVADA_DIRETORIA", _("Aprovada Diretoria")
AGUARDANDO_DIRETORIA = "AGUARDANDO_DIRETORIA", _("Aguardando Diretoria")
FINALIZADA = "FINALIZADA", _("Finalizada")
REPROVADA = "REPROVADA", _("Reprovada")
class EtapaAprovacao(models.TextChoices):
"""
Enumeração que define as etapas formais de aprovação
dentro do fluxo decisório do SGMP.
Cada etapa corresponde a um papel organizacional específico,
responsável por validar a solicitação sob sua ótica
(pessoas, orçamento, estratégia).
"""
HEAD = "HEAD", _("Head")
GG = "GG", _("Gente e Gestão")
CONTROLADORIA = "CONTROLADORIA", _("Controladoria")
DIRETORIA = "DIRETORIA", _("Diretoria")
class DecisaoAprovacao(models.TextChoices):
"""
Enumeração que representa a decisão tomada em uma etapa
de aprovação de uma solicitação.
A decisão é registrada de forma explícita para garantir:
- clareza
- rastreabilidade
- auditoria do processo decisório
"""
APROVADO = "APROVADO", _("Aprovado")
REPROVADO = "REPROVADO", _("Reprovado")
class UsuarioSistema(BaseModel):
"""
Representa um usuário que possui acesso ao SGMP.
Este modelo não representa necessariamente um funcionário ativo,
mas sim um agente que interage com o sistema, como:
- gestores
- equipe de Gente & Gestão
- controladoria
- diretoria
O perfil do usuário define seu papel no fluxo de solicitações
e suas permissões de visualização, edição e aprovação.
"""
matricula = models.CharField(max_length=20, unique=True)
nome = models.CharField(max_length=255)
ativo = models.BooleanField(default=True)
class Perfil(models.TextChoices):
GESTOR = "GESTOR", _("Gestor")
HEAD = "HEAD", _("Head")
GG = "GG", _("Gente e Gestão")
CONTROLADORIA = "CONTROLADORIA", _("Controladoria")
DIRETORIA = "DIRETORIA", _("Diretoria")
perfil = models.CharField(
max_length=20,
choices=Perfil.choices,
default=Perfil.GESTOR
)
def tem_perfil(self, perfil: str) -> bool:
"""
Verifica se o usuário possui o perfil informado, considerando
o perfil principal e perfis extras.
"""
if self.perfil == perfil:
return True
# Evita import cíclico: FK de UsuarioPerfilExtra usa related_name="perfis_extras"
return self.perfis_extras.filter(perfil=perfil).exists()
def perfis_ativos(self):
"""
Retorna lista de códigos de perfis ativos para o usuário,
começando pelo perfil principal e incluindo extras (sem repetir).
"""
extras = list(self.perfis_extras.values_list("perfil", flat=True))
# Garante que o principal venha primeiro e não seja duplicado
return [self.perfil] + [p for p in extras if p != self.perfil]
def __str__(self):
return f"{self.nome} ({self.matricula})"
class UsuarioPerfilExtra(BaseModel):
"""
Representa perfis adicionais atribuídos a um usuário do sistema.
Exemplo: perfil principal GESTOR + perfil extra HEAD para permitir
atuar como Head sem perder o comportamento padrão de gestor.
"""
usuario = models.ForeignKey(
UsuarioSistema,
on_delete=models.CASCADE,
related_name="perfis_extras",
)
perfil = models.CharField(
max_length=20,
choices=UsuarioSistema.Perfil.choices,
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["usuario", "perfil"],
name="unique_usuario_perfil_extra",
),
]
verbose_name = "Perfil extra de usuário"
verbose_name_plural = "Perfis extras de usuário"
def __str__(self):
return f"{self.usuario} -> {self.perfil}"
class HeadGestor(BaseModel):
"""
Vínculo Head → Gestores: define quais gestores um usuário com perfil HEAD
aprova. Quando o Head está cadastrado, o dashboard mostra apenas
solicitações AGUARDANDO_HEAD cujo solicitante está nesta lista.
"""
head = models.ForeignKey(
UsuarioSistema,
on_delete=models.CASCADE,
related_name="gestores_vinculados",
)
gestor = models.ForeignKey(
UsuarioSistema,
on_delete=models.CASCADE,
related_name="heads_que_me_aprovam",
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["head", "gestor"], name="unique_head_gestor"),
]
verbose_name = "Head → Gestor"
verbose_name_plural = "Head → Gestores"
def __str__(self):
return f"{self.head} aprova {self.gestor}"
def matriculas_gestores_do_head(usuario: "UsuarioSistema") -> list:
"""
Retorna a lista de matrículas dos gestores que o usuário (HEAD) aprova.
Se o usuário não for HEAD ou não tiver gestores vinculados, retorna lista vazia.
"""
if not usuario or not usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
return []
return list(
HeadGestor.objects.filter(head=usuario).values_list("gestor__matricula", flat=True)
)
class PessoaRM(BaseModel):
"""
Representa um funcionário sincronizado a partir do TOTVS RM.
Este modelo armazena um *snapshot* dos dados cadastrais e organizacionais
do colaborador, utilizados pelo SGMP para iniciar e acompanhar processos
internos de RH (ex: desligamento, admissão, movimentação).
⚠️ Importante:
- O TOTVS RM é a origem da verdade.
- Os dados aqui persistidos representam o estado do colaborador
no momento da sincronização.
- Alterações posteriores no RM NÃO atualizam este registro
automaticamente, exceto via rotina de sincronização.
-------------------------
Query de origem (consolidada)
-------------------------
A sincronização parte da tabela PFUNC (cadastro de pessoas),
utilizando LEFT JOIN para dados auxiliares, garantindo que
TODOS os colaboradores ativos sejam retornados, mesmo que
não possuam banco de horas.
WITH SALDO_ATUAL AS (
SELECT
SB.CODCOLIGADA,
SB.CHAPA,
SB.INICIOPER,
SB.FIMPER,
((SB.EXTRAANT - SB.ATRASOANT - SB.FALTAANT) +
(SB.EXTRAATU - SB.ATRASOATU - SB.FALTAATU)) AS SALDO_MINUTOS,
ROW_NUMBER() OVER (
PARTITION BY SB.CODCOLIGADA, SB.CHAPA
ORDER BY SB.FIMPER DESC
) AS RN
FROM ASALDOBANCOHOR SB
)
SELECT
PF.CODCOLIGADA,
PF.CHAPA,
PF.NOME,
P.CPF,
PS.DESCRICAO AS SECAO,
PU.NOME AS FUNCAO,
PF.DATAADMISSAO,
PF.CODSITUACAO,
PF.CODSECAO,
PF.CODFUNCAO,
PF.SALARIO,
PF.CODSINDICATO,
SA.INICIOPER,
SA.FIMPER,
SA.SALDO_MINUTOS
FROM PFUNC PF
JOIN PPESSOA P ON P.CODIGO = PF.CODPESSOA
JOIN PSECAO PS ON PS.CODCOLIGADA = PF.CODCOLIGADA AND PS.CODIGO = PF.CODSECAO
JOIN PFUNCAO PU ON PU.CODCOLIGADA = PF.CODCOLIGADA AND PU.CODIGO = PF.CODFUNCAO
LEFT JOIN SALDO_ATUAL SA
ON SA.CODCOLIGADA = PF.CODCOLIGADA
AND SA.CHAPA = PF.CHAPA
AND SA.RN = 1
WHERE PF.CODSITUACAO <> 'D';
-------------------------
Origem dos dados no RM
-------------------------
- PFUNC → dados funcionais e vínculo
- PPESSOA → dados pessoais básicos
- PSECAO → estrutura organizacional
- PFUNCAO → função/cargo
- ASALDOBANCOHOR → banco de horas (dado auxiliar e opcional)
-------------------------
Observações de domínio
-------------------------
- Banco de horas NÃO é obrigatório para todos os colaboradores.
- Dados de banco de horas são usados apenas para validações
e apoio a decisões (ex: desligamento).
- O SGMP nunca utiliza ASALDOBANCOHOR como base de cadastro.
"""
# Identificação no RM
id_rm = models.CharField(
max_length=20,
unique=True,
help_text="Identificador único do colaborador no RM (CODCOLIGADA + CHAPA)"
)
matricula = models.CharField(
max_length=20,
unique=True,
help_text="Matrícula do colaborador no RM (PFUNC.CHAPA)"
)
# Dados cadastrais básicos
nome = models.CharField(max_length=255)
cargo = models.CharField(
max_length=255,
help_text="Nome da função/cargo atual do colaborador"
)
setor = models.CharField(
max_length=255,
help_text="Descrição da seção/departamento atual"
)
centro_custo = models.CharField(
max_length=50,
help_text="Código da seção no RM (PFUNC.CODSECAO)"
)
cpf = models.CharField(
max_length=14,
null=True,
blank=True,
help_text="CPF do colaborador (PPESSOA.CPF)"
)
data_admissao = models.DateField(
null=True,
blank=True,
help_text="Data de admissão do colaborador no RM"
)
situacao = models.CharField(
max_length=5,
null=True,
blank=True,
help_text="Situação do colaborador no RM (PFUNC.CODSITUACAO)"
)
cod_funcao = models.CharField(
max_length=50,
null=True,
blank=True,
help_text="Código da função/cargo no RM (PFUNC.CODFUNCAO)"
)
salario = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
help_text="Salário atual do colaborador no RM"
)
cod_sindicato = models.CharField(
max_length=10,
null=True,
blank=True,
help_text="Código do sindicato no RM"
)
saldo_banco_horas_minutos = models.IntegerField(
null=True,
blank=True,
help_text="Saldo atual de banco de horas em minutos (se aplicável)"
)
inicio_periodo_banco_horas = models.DateField(
null=True,
blank=True,
help_text="Início do período do último banco de horas considerado"
)
fim_periodo_banco_horas = models.DateField(
null=True,
blank=True,
help_text="Fim do período do último banco de horas considerado"
)
# Integração externa (opcional)
matricula_winthor = models.CharField(
max_length=20,
null=True,
blank=True,
db_index=True,
help_text="Matrícula/código do usuário correspondente no Winthor"
)
# Controle de sincronização
sincronizado_em = models.DateTimeField(
null=True,
blank=True,
help_text="Data/hora da última sincronização com o RM"
)
class Meta:
verbose_name = "Pessoa (RM)"
verbose_name_plural = "Pessoas (RM)"
def __str__(self):
return f"{self.nome} ({self.matricula})"
#
class Solicitacao(BaseModel):
"""
Entidade central do SGMP e agregador raiz do processo de RH.
Uma Solicitação representa um pedido formal que percorre
um fluxo controlado de aprovação, desde sua criação até
a finalização ou reprovação.
Responsabilidades:
- Identificar o tipo de processo (ex: desligamento,movimentação, admissão por substuição ou por aumento de quadro)
- Relacionar solicitante e funcionário
- Controlar o status atual do fluxo
- Servir de ponto de ligação para aprovações e dados específicos
"""
tipo = models.CharField(
max_length=30,
choices=TipoSolicitacao.choices
)
solicitante = models.ForeignKey(
UsuarioSistema,
on_delete=models.PROTECT,
related_name="solicitacoes_criadas"
)
funcionario = models.ForeignKey(
PessoaRM,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="solicitacoes"
)
status = models.CharField(
max_length=30,
choices=StatusSolicitacao.choices,
default=StatusSolicitacao.RASCUNHO
)
enviada_em = models.DateTimeField(null=True, blank=True)
finalizada_em = models.DateTimeField(null=True, blank=True)
def pode_editar(self):
return self.status == StatusSolicitacao.RASCUNHO
def pode_enviar(self):
return self.status == StatusSolicitacao.RASCUNHO
def etapa_atual(self):
"""
Retorna a etapa atual baseada no status.
AGUARDANDO_HEAD: HEAD (apenas Head pode aprovar/reprovar)
ENVIADA: None (GG e CONTROLADORIA podem dar parecer)
AGUARDANDO_DIRETORIA: DIRETORIA (apenas Diretoria pode aprovar/reprovar)
"""
mapa = {
StatusSolicitacao.AGUARDANDO_HEAD: EtapaAprovacao.HEAD,
StatusSolicitacao.ENVIADA: None, # GG e CONTROLADORIA podem dar parecer
StatusSolicitacao.AGUARDANDO_DIRETORIA: EtapaAprovacao.DIRETORIA,
}
return mapa.get(self.status)
def pode_aprovar(self, usuario=None):
"""
Verifica se a solicitação pode ser aprovada.
- HEAD pode aprovar/reprovar quando status é AGUARDANDO_HEAD.
Considera tanto perfil principal quanto perfis extras.
- DIRETORIA pode aprovar/reprovar quando status é AGUARDANDO_DIRETORIA.
Considera tanto perfil principal quanto perfis extras.
"""
etapa_atual = self.etapa_atual()
if etapa_atual is None:
return False
if etapa_atual == EtapaAprovacao.HEAD:
if usuario and not usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
return False
return True
if etapa_atual == EtapaAprovacao.DIRETORIA:
if usuario and not usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
return False
return True
return False
def pode_dar_parecer(self, usuario):
"""
Verifica se o usuário pode dar parecer na solicitação.
GG e CONTROLADORIA podem dar parecer quando status é ENVIADA.
"""
if self.status != StatusSolicitacao.ENVIADA:
return False
# GG e CONTROLADORIA podem dar parecer
if usuario.perfil not in [UsuarioSistema.Perfil.GG, UsuarioSistema.Perfil.CONTROLADORIA]:
return False
# Verifica se já deu parecer
etapa_esperada = None
if usuario.perfil == UsuarioSistema.Perfil.GG:
etapa_esperada = EtapaAprovacao.GG
elif usuario.perfil == UsuarioSistema.Perfil.CONTROLADORIA:
etapa_esperada = EtapaAprovacao.CONTROLADORIA
if etapa_esperada:
parecer_existente = self.pareceres.filter(etapa=etapa_esperada).exists()
if parecer_existente:
return False # Já deu parecer
return True
def __str__(self):
"""
Representação textual da solicitação.
Em alguns tipos (ex.: Admissão por aumento de quadro) não há
funcionário vinculado, então protegemos contra None.
"""
tipo_label = self.get_tipo_display()
if self.funcionario:
return f"{tipo_label} - {self.funcionario.nome}"
return f"{tipo_label} - (sem funcionário)"
class TipoDesligamento(models.TextChoices):
"""Tipos de desligamento suportados pelo sistema."""
PEDIDO_DEMISSAO = "PEDIDO_DEMISSAO", _("Pedido de Demissão")
SEM_JUSTA_CAUSA = "SEM_JUSTA_CAUSA", _("Demissão sem Justa Causa")
COM_JUSTA_CAUSA = "COM_JUSTA_CAUSA", _("Demissão por Justa Causa")
TERMINO_CONTRATO = "TERMINO_CONTRATO", _("Término de Contrato de Experiência")
OUTROS = "OUTROS", _("Outros")
class TipoAvisoPrevio(models.TextChoices):
"""Tipos de aviso prévio."""
TRABALHADO = "TRABALHADO", _("Trabalhado")
INDENIZADO = "INDENIZADO", _("Indenizado (Dispensa imediata)")
DISPENSADO = "DISPENSADO", _("Dispensado (Pedido de demissão)")
class Desligamento(BaseModel):
"""
Modelo que representa os dados específicos de uma solicitação
de desligamento de funcionário.
Está vinculado obrigatoriamente a uma única Solicitação,
permitindo que o SGMP trate desligamentos como um subtipo
de processo, sem poluir o modelo genérico de Solicitação.
Contém apenas informações relevantes ao processo de desligamento,
mantendo a separação clara de responsabilidades no domínio.
"""
solicitacao = models.OneToOneField(
Solicitacao,
on_delete=models.CASCADE,
related_name="desligamento"
)
tipo_desligamento = models.CharField(
max_length=30,
choices=TipoDesligamento.choices,
help_text="Tipo de desligamento (pedido de demissão, sem justa causa, etc.)",
null=True,
blank=True
)
aviso_previo = models.CharField(
max_length=20,
choices=TipoAvisoPrevio.choices,
help_text="Tipo de aviso prévio (trabalhado, indenizado, dispensado)",
null=True,
blank=True
)
motivo = models.TextField()
data_prevista_desligamento = models.DateField()
observacoes = models.TextField(blank=True)
arquivo_pedido = models.FileField(
upload_to='desligamentos/cartas/%Y/%m/%d/',
null=True,
blank=True,
help_text="Carta de pedido de demissão (obrigatório apenas para pedido de demissão)"
)
def __str__(self):
return f"Desligamento - {self.solicitacao.funcionario.nome}"
class AdmissaoSubstituicao(BaseModel):
"""
Representa a contratação de um novo colaborador para ocupar a vaga
de alguém que saiu (desligamento) ou mudou de área (movimentação).
Ponto de Atenção: O 'substituído' é a PessoaRM vinculada na Solicitacao pai.
"""
solicitacao = models.OneToOneField(
Solicitacao,
on_delete=models.CASCADE,
related_name="admissao_substituicao"
)
data_previsao_contratacao = models.DateField()
# Dados do Destino (Onde o novo colaborador irá trabalhar)
cod_coligada_destino = models.IntegerField(help_text="Cód. Coligada RM")
cod_filial_destino = models.IntegerField(help_text="Cód. Filial RM")
cod_secao_destino = models.CharField(max_length=50, help_text="Cód. Seção/Centro de Custo")
cod_funcao_destino = models.CharField(max_length=50, help_text="Cód. Função/Cargo")
justificativa = models.TextField()
def __str__(self):
return f"Substituição de {self.solicitacao.funcionario.nome}"
class AdmissaoAumentoQuadro(BaseModel):
"""
Representa a abertura de uma nova vaga no orçamento (Headcount),
sem um colaborador predecessor.
"""
solicitacao = models.OneToOneField(
Solicitacao,
on_delete=models.CASCADE,
related_name="admissao_aumento"
)
data_previsao_contratacao = models.DateField()
justificativa_estrategica = models.TextField()
# Estrutura de Destino
cod_coligada_destino = models.IntegerField()
cod_filial_destino = models.IntegerField()
cod_secao_destino = models.CharField(max_length=50)
cod_funcao_destino = models.CharField(max_length=50)
# No BPMN menciona 'Avaliar Suplementação' - campo para controle orçamentário
requer_suplementacao_orcamentaria = models.BooleanField(default=False)
def __str__(self):
return f"Aumento de Quadro - {self.cod_secao_destino}"
class Movimentacao(BaseModel):
"""
Representa alteração de cargo, setor ou centro de custo de um
colaborador ativo.
"""
solicitacao = models.OneToOneField(
Solicitacao,
on_delete=models.CASCADE,
related_name="movimentacao"
)
# Flags de Mudança (conforme decisão no BPMN: "Altera Função?", "Transfere CC?")
altera_funcao = models.BooleanField(default=False)
altera_centro_custo = models.BooleanField(default=False)
# Novos Dados (Snapshot do Destino)
novo_cod_secao = models.CharField(max_length=50, null=True, blank=True)
novo_cod_funcao = models.CharField(max_length=50, null=True, blank=True)
novo_salario = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
data_efetivacao = models.DateField(help_text="Data que a mudança passa a valer no RM")
justificativa = models.TextField()
def __str__(self):
return f"Movimentação - {self.solicitacao.funcionario.nome}"
class Aprovacao(BaseModel):
"""
Representa uma decisão formal tomada em uma etapa específica
do fluxo de aprovação de uma solicitação.
Cada aprovação registra:
- a etapa do processo
- a decisão tomada
- o usuário responsável
- a justificativa
- o momento da decisão
O modelo garante que exista apenas uma aprovação
por etapa para cada solicitação, assegurando consistência
e integridade do processo decisório.
"""
solicitacao = models.ForeignKey(
Solicitacao,
on_delete=models.CASCADE,
related_name="aprovacoes"
)
etapa = models.CharField(
max_length=20,
choices=EtapaAprovacao.choices
)
decisao = models.CharField(
max_length=10,
choices=DecisaoAprovacao.choices
)
usuario = models.ForeignKey(
UsuarioSistema,
on_delete=models.PROTECT,
related_name="aprovacoes_realizadas"
)
justificativa = models.TextField()
decidido_em = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("solicitacao", "etapa")
def __str__(self):
return f"{self.solicitacao.id} - {self.etapa} - {self.decisao}"
class Parecer(BaseModel):
"""
Representa um parecer técnico emitido por GG ou CONTROLADORIA
sobre uma solicitação. Diferente de Aprovacao, um Parecer não
altera o status da solicitação diretamente, apenas fornece análise e dados.
Quando ambos os pareceres (GG e CONTROLADORIA) são dados,
a solicitação muda para AGUARDANDO_DIRETORIA.
"""
solicitacao = models.ForeignKey(
Solicitacao,
on_delete=models.CASCADE,
related_name="pareceres"
)
etapa = models.CharField(
max_length=20,
choices=EtapaAprovacao.choices,
help_text="Etapa do parecer (GG ou CONTROLADORIA)"
)
usuario = models.ForeignKey(
UsuarioSistema,
on_delete=models.PROTECT,
related_name="pareceres_emitidos"
)
texto = models.TextField(
help_text="Análise, dados e considerações sobre a solicitação"
)
anexo = models.FileField(
upload_to='pareceres/anexos/%Y/%m/%d/',
null=True,
blank=True,
help_text="Anexo opcional ao parecer (documentos, planilhas, etc.)"
)
criado_em = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("solicitacao", "etapa")
verbose_name = "Parecer"
verbose_name_plural = "Pareceres"
def __str__(self):
return f"Parecer {self.etapa} - {self.solicitacao.id}"