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}"