1239 lines
58 KiB
Python
1239 lines
58 KiB
Python
import re
|
||
from datetime import date, datetime, time, timedelta
|
||
|
||
from django.conf import settings
|
||
from django.contrib.auth.models import AbstractUser
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.files.base import File
|
||
from django.db import models
|
||
from django.db.models.functions import Length
|
||
from django.utils import timezone
|
||
|
||
from .custo_contratacao import tabelas as tabelas_custo_contratacao
|
||
from .custo_contratacao.calculo import ParametrosFiscais
|
||
|
||
LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024
|
||
ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000
|
||
PLANO_SAUDE_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||
|
||
COMPROMISSO_HORARIO_COMERCIAL_INICIO = time(8, 0)
|
||
COMPROMISSO_HORARIO_COMERCIAL_FIM = time(18, 0)
|
||
COMPROMISSO_LEMBRETE_HORAS = {"1h": 1, "2h": 2, "4h": 4, "24h": 24}
|
||
|
||
|
||
def _dia_util(dia: date) -> bool:
|
||
return dia.weekday() < 5
|
||
|
||
|
||
def _janela_comercial(dia: date) -> tuple[datetime, datetime] | None:
|
||
"""Janela [8h,18h] daquele dia, ou None se não for dia útil (sáb/dom)."""
|
||
if not _dia_util(dia):
|
||
return None
|
||
inicio = timezone.make_aware(datetime.combine(dia, COMPROMISSO_HORARIO_COMERCIAL_INICIO))
|
||
fim = timezone.make_aware(datetime.combine(dia, COMPROMISSO_HORARIO_COMERCIAL_FIM))
|
||
return inicio, fim
|
||
|
||
|
||
def _dia_util_anterior(dia: date) -> date:
|
||
anterior = dia - timedelta(days=1)
|
||
while not _dia_util(anterior):
|
||
anterior -= timedelta(days=1)
|
||
return anterior
|
||
|
||
|
||
def validar_tamanho_icone_link(arquivo: File) -> None:
|
||
if arquivo.size > LINK_FERRAMENTA_ICONE_MAX_BYTES:
|
||
raise ValidationError("O ícone deve ter no máximo 2MB.")
|
||
|
||
|
||
def validar_tamanho_observacoes_acesso(valor: str) -> None:
|
||
if len(valor) > ACESSO_GERAL_OBSERVACOES_MAX_CHARS:
|
||
raise ValidationError("As observações (com imagens embutidas) ficaram grandes demais.")
|
||
|
||
|
||
def validar_cor_categoria_evento(valor: str) -> None:
|
||
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", valor):
|
||
raise ValidationError("A cor deve estar no formato hexadecimal, ex.: #7c4dff.")
|
||
|
||
|
||
def validar_tamanho_arquivo_plano_saude(arquivo: File) -> None:
|
||
if arquivo.size > PLANO_SAUDE_ARQUIVO_MAX_BYTES:
|
||
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||
|
||
|
||
def validar_valor_monetario_br(valor: str) -> None:
|
||
if not re.fullmatch(r"\d+(,\d{2})?", valor or ""):
|
||
raise ValidationError("Informe um valor no formato \"1234,56\" (ou \"0\").")
|
||
|
||
|
||
INDICADOR_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||
|
||
TIPO_COLABORADOR_INDICADOR_CHOICES = [
|
||
("1", "Contábil + Fiscal"),
|
||
("2", "Contador (sem conciliador)"),
|
||
("3", "Contador (com conciliador)"),
|
||
("4", "Fiscal"),
|
||
("5", "Conciliador"),
|
||
]
|
||
|
||
RESPOSTA_CRITERIO_INDICADOR_CHOICES = [
|
||
("SIM", "Sim"),
|
||
("NAO", "Não"),
|
||
("NAO_FAZ", "Não faz"),
|
||
("NAO_SE_APLICA", "Não se aplica"),
|
||
]
|
||
|
||
|
||
def validar_tamanho_arquivo_indicador(arquivo: File) -> None:
|
||
if arquivo.size > INDICADOR_ARQUIVO_MAX_BYTES:
|
||
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||
|
||
|
||
class PerfilAcesso(models.Model):
|
||
codigo = models.AutoField(primary_key=True)
|
||
nome = models.CharField("Nome do perfil", max_length=100, unique=True)
|
||
ativo = models.BooleanField("Ativo", default=True)
|
||
gerencia_permissoes = models.BooleanField("Gerencia permissões", default=False)
|
||
# Mesmo formato aninhado usado no frontend antes da migração:
|
||
# { "<moduleKey>": { "enabled": bool, "apps": { "<appKey>": bool } } }
|
||
permissoes = models.JSONField("Permissões", default=dict, blank=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
atualizado_em = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Perfil de acesso"
|
||
verbose_name_plural = "Perfis de acesso"
|
||
ordering = ["codigo"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class Departamento(models.Model):
|
||
nome = models.CharField("Nome", max_length=100, unique=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Departamento"
|
||
verbose_name_plural = "Departamentos"
|
||
ordering = ["nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class CategoriaEvento(models.Model):
|
||
"""Cadastro de tipos de evento do Calendário Individual (Reunião, Treinamento
|
||
etc.) — não é uma lista fixa no código porque quem tem a permissão de criar
|
||
evento de departamento/todos também cadastra categorias novas pela própria
|
||
tela (ver `calendario-individual-criar-evento` em catalogo.py)."""
|
||
|
||
nome = models.CharField("Nome", max_length=60, unique=True)
|
||
cor = models.CharField("Cor", max_length=7, validators=[validar_cor_categoria_evento])
|
||
|
||
class Meta:
|
||
verbose_name = "Categoria de evento"
|
||
verbose_name_plural = "Categorias de evento"
|
||
ordering = ["nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class Usuario(AbstractUser):
|
||
nome = models.CharField("Nome", max_length=150, blank=True)
|
||
perfis = models.ManyToManyField(
|
||
PerfilAcesso, related_name="usuarios", blank=True, verbose_name="Perfis de acesso"
|
||
)
|
||
departamentos = models.ManyToManyField(
|
||
Departamento, related_name="usuarios", blank=True, verbose_name="Departamentos"
|
||
)
|
||
codigo_folha = models.CharField("Código da Folha", max_length=50, blank=True)
|
||
codigo_questor = models.CharField("Código do Questor", max_length=50, blank=True)
|
||
codigo_tareffa = models.CharField("Código do Tareffa", max_length=50, blank=True)
|
||
codigo_contabit = models.CharField("Código do Contabit", max_length=50, blank=True)
|
||
ramal = models.CharField("Ramal", max_length=20, blank=True)
|
||
data_aniversario = models.DateField("Data de aniversário", null=True, blank=True)
|
||
lideranca = models.BooleanField("É gerente ou coordenador", default=False)
|
||
liderados = models.ManyToManyField(
|
||
"self", symmetrical=False, related_name="lideres", blank=True, verbose_name="Liderados"
|
||
)
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome or self.username
|
||
|
||
def gerencia_permissoes(self) -> bool:
|
||
return self.perfis.filter(gerencia_permissoes=True).exists()
|
||
|
||
def permissao_app(self, module_key: str, app_key: str) -> bool:
|
||
"""União (`any`) do flag `apps[app_key]` de `module_key` entre todos os perfis
|
||
vinculados — mesma lógica de `permissoes_efetivas()` (views.py), mas pra checar
|
||
uma única chave sem montar o payload inteiro. Usado por permissões de escrita
|
||
que precisam de um nível de acesso específico (ex.: "editar" em Links &
|
||
Ferramentas), não só "o módulo está habilitado"."""
|
||
return any(p.permissoes.get(module_key, {}).get("apps", {}).get(app_key) for p in self.perfis.all())
|
||
|
||
|
||
class CompromissoAgenda(models.Model):
|
||
VISIBILIDADE_SOMENTE_EU = "somente_eu"
|
||
VISIBILIDADE_DEPARTAMENTO = "departamento"
|
||
VISIBILIDADE_TODOS = "todos"
|
||
VISIBILIDADE_CHOICES = [
|
||
(VISIBILIDADE_SOMENTE_EU, "Somente eu"),
|
||
(VISIBILIDADE_DEPARTAMENTO, "Meu departamento"),
|
||
(VISIBILIDADE_TODOS, "Todos"),
|
||
]
|
||
LEMBRETE_CHOICES = [
|
||
("", "Sem lembrete"),
|
||
("1h", "1 hora antes"),
|
||
("2h", "2 horas antes"),
|
||
("4h", "4 horas antes"),
|
||
("24h", "24 horas antes"),
|
||
]
|
||
MODALIDADE_PRESENCIAL = "presencial"
|
||
MODALIDADE_REMOTO = "remoto"
|
||
MODALIDADE_HIBRIDO = "hibrido"
|
||
MODALIDADE_CHOICES = [
|
||
(MODALIDADE_PRESENCIAL, "Presencial"),
|
||
(MODALIDADE_REMOTO, "Remoto"),
|
||
(MODALIDADE_HIBRIDO, "Híbrido"),
|
||
]
|
||
|
||
dono = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="compromissos")
|
||
titulo = models.CharField("Título", max_length=200)
|
||
data = models.DateField("Data")
|
||
horario = models.TimeField("Horário", null=True, blank=True)
|
||
visibilidade = models.CharField(
|
||
"Visibilidade", max_length=20, choices=VISIBILIDADE_CHOICES, default=VISIBILIDADE_SOMENTE_EU
|
||
)
|
||
departamento_compartilhado = models.ForeignKey(
|
||
Departamento,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="compromissos_compartilhados",
|
||
verbose_name="Departamento compartilhado",
|
||
)
|
||
lembrete_antecedencia = models.CharField(
|
||
"Lembrete", max_length=10, blank=True, default="", choices=LEMBRETE_CHOICES
|
||
)
|
||
categoria = models.ForeignKey(
|
||
CategoriaEvento,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="compromissos",
|
||
verbose_name="Categoria",
|
||
)
|
||
eh_evento = models.BooleanField("É um evento", default=False)
|
||
local = models.CharField("Local", max_length=120, blank=True, default="")
|
||
modalidade = models.CharField(
|
||
"Modalidade", max_length=10, blank=True, default="", choices=MODALIDADE_CHOICES
|
||
)
|
||
descricao = models.TextField("Descrição", blank=True, default="")
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Compromisso da agenda"
|
||
verbose_name_plural = "Compromissos da agenda"
|
||
ordering = ["data", "horario"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.titulo} ({self.data})"
|
||
|
||
def calcular_notificar_em(self) -> datetime | None:
|
||
"""Horário em que o lembrete deve passar a aparecer no sino de notificações,
|
||
contando `lembrete_antecedencia` horas de expediente (seg-sex, 8h-18h) pra trás
|
||
a partir do compromisso — janelas fora do expediente são puladas de graça (não
|
||
consomem antecedência), então o lembrete só "gasta" horas dentro do expediente.
|
||
Sem horário definido ou sem lembrete escolhido, não há o que calcular."""
|
||
horas = COMPROMISSO_LEMBRETE_HORAS.get(self.lembrete_antecedencia)
|
||
if not horas or not self.horario:
|
||
return None
|
||
|
||
restante = timedelta(hours=horas)
|
||
ponteiro = timezone.make_aware(datetime.combine(self.data, self.horario))
|
||
|
||
while restante > timedelta(0):
|
||
janela = _janela_comercial(ponteiro.date())
|
||
if janela is not None:
|
||
inicio, fim = janela
|
||
topo = min(ponteiro, fim)
|
||
if topo > inicio:
|
||
disponivel = topo - inicio
|
||
consumido = min(disponivel, restante)
|
||
ponteiro = topo - consumido
|
||
restante -= consumido
|
||
if restante <= timedelta(0):
|
||
break
|
||
_, fim_dia_anterior = _janela_comercial(_dia_util_anterior(ponteiro.date()))
|
||
ponteiro = fim_dia_anterior
|
||
|
||
return ponteiro
|
||
|
||
|
||
class Favorito(models.Model):
|
||
usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favoritos")
|
||
app_id = models.CharField("ID da aplicação", max_length=150)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Favorito"
|
||
verbose_name_plural = "Favoritos"
|
||
ordering = ["ordem", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["usuario", "app_id"], name="favorito_unico_por_usuario"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.usuario} → {self.app_id}"
|
||
|
||
|
||
class NotificacaoDispensada(models.Model):
|
||
usuario = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notificacoes_dispensadas"
|
||
)
|
||
notif_id = models.CharField("ID da notificação", max_length=150)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Notificação dispensada"
|
||
verbose_name_plural = "Notificações dispensadas"
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["usuario", "notif_id"], name="notificacao_dispensada_unica_por_usuario"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.usuario} → {self.notif_id}"
|
||
|
||
|
||
class LinkFerramenta(models.Model):
|
||
"""Cartões da tela Links & Ferramentas — lista compartilhada (não por usuário).
|
||
Ordenação editável só por quem tem `apps.editar=True` em `links-ferramentas` num
|
||
perfil vinculado (ver `catalogo.MODULE_APPS["links-ferramentas"]` e `PermissaoApp`
|
||
em `permissions.py`)."""
|
||
|
||
nome = models.CharField("Nome", max_length=100)
|
||
url = models.URLField("URL")
|
||
icone = models.ImageField(
|
||
"Ícone",
|
||
upload_to="links_ferramentas/",
|
||
blank=True,
|
||
null=True,
|
||
validators=[validar_tamanho_icone_link],
|
||
)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Link/Ferramenta"
|
||
verbose_name_plural = "Links/Ferramentas"
|
||
ordering = ["ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class LinkFerramentaFavorito(models.Model):
|
||
"""Favorito de um cartão de Links & Ferramentas, por usuário — não confundir com
|
||
`Favorito` (que marca aplicações inteiras do menu). Só afeta a ordenação de
|
||
exibição dentro da própria tela de Links & Ferramentas, nunca o `ordem`
|
||
compartilhado do `LinkFerramenta`."""
|
||
|
||
usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="links_favoritos")
|
||
link = models.ForeignKey(LinkFerramenta, on_delete=models.CASCADE, related_name="favoritos")
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Favorito de Link/Ferramenta"
|
||
verbose_name_plural = "Favoritos de Links/Ferramentas"
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["usuario", "link"], name="link_favorito_unico_por_usuario"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.usuario} → {self.link}"
|
||
|
||
|
||
class AcessoGeralSecao(models.Model):
|
||
"""Seção do cadastro "Acessos Gerais" (aplicação dentro da seção Links &
|
||
Ferramentas) — agrupa linhas de acesso/login compartilhado (ex.: "Banco de
|
||
Dados/API"). Lista compartilhada, sem FK pra Usuario, mesmo padrão de
|
||
LinkFerramenta."""
|
||
|
||
nome = models.CharField("Nome", max_length=150)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
perfis_restritos = models.ManyToManyField(
|
||
PerfilAcesso,
|
||
related_name="secoes_acessos_gerais",
|
||
blank=True,
|
||
verbose_name="Perfis com acesso (vazio = todos com acesso a Acessos Gerais)",
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Seção de Acessos Gerais"
|
||
verbose_name_plural = "Seções de Acessos Gerais"
|
||
ordering = ["ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class AcessoGeral(models.Model):
|
||
"""Linha dentro de uma AcessoGeralSecao — um acesso/login compartilhado (ex.:
|
||
login geral de um site). `ordem` é escopado por seção (`AcessoGeralViewSet.
|
||
perform_create` calcula `max(ordem)` só entre as linhas da mesma seção), já que
|
||
reordenar acontece dentro de cada seção, não na lista inteira."""
|
||
|
||
secao = models.ForeignKey(AcessoGeralSecao, on_delete=models.CASCADE, related_name="acessos")
|
||
nome = models.CharField("Nome", max_length=150)
|
||
url = models.URLField("URL", blank=True)
|
||
usuario = models.CharField("Usuário", max_length=150, blank=True)
|
||
senha = models.CharField("Senha", max_length=255, blank=True)
|
||
observacoes = models.TextField(
|
||
"Observações", blank=True, validators=[validar_tamanho_observacoes_acesso]
|
||
)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Acesso Geral"
|
||
verbose_name_plural = "Acessos Gerais"
|
||
ordering = ["ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.secao} → {self.nome}"
|
||
|
||
|
||
class Ramal(models.Model):
|
||
"""Linha avulsa da tela de Ramais — sem conta de sistema por trás (ex.: telefone de
|
||
sala, recepção). Colaboradores com Usuario aparecem automaticamente na listagem
|
||
(RamalViewSet.list monta a linha deles a partir do cadastro, com o ramal pendente se
|
||
ainda não tiver sido preenchido) — não precisam de uma linha aqui."""
|
||
|
||
nome = models.CharField("Nome", max_length=150)
|
||
departamento = models.CharField("Departamento", max_length=150, blank=True)
|
||
numero = models.CharField("Ramal", max_length=20, blank=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Ramal"
|
||
verbose_name_plural = "Ramais"
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class RamalAusencia(models.Model):
|
||
"""Período de ausência de um colaborador, criado pela tela de Ramais. "Ausente
|
||
agora" nunca é armazenado — é sempre calculado comparando a hora atual com o
|
||
período (ver esta_ativa()), exceto quando encerrada manualmente antes do previsto."""
|
||
|
||
usuario = models.ForeignKey(Usuario, on_delete=models.CASCADE, related_name="ausencias_ramal")
|
||
data_inicio = models.DateField("Data inicial")
|
||
hora_inicio = models.TimeField("Hora de início", null=True, blank=True)
|
||
data_fim = models.DateField("Data final")
|
||
hora_volta = models.TimeField("Hora da volta", null=True, blank=True)
|
||
tipo = models.CharField("Tipo da ausência", max_length=100, blank=True, default="Ausência")
|
||
observacoes = models.TextField("Observações", blank=True)
|
||
encerrada_manualmente = models.BooleanField("Encerrada manualmente", default=False)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Ausência de ramal"
|
||
verbose_name_plural = "Ausências de ramal"
|
||
ordering = ["-data_inicio", "-id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.usuario} ({self.data_inicio} a {self.data_fim})"
|
||
|
||
def esta_ativa(self, agora: datetime | None = None) -> bool:
|
||
if self.encerrada_manualmente:
|
||
return False
|
||
agora = agora or timezone.localtime()
|
||
inicio = timezone.make_aware(datetime.combine(self.data_inicio, self.hora_inicio or time.min))
|
||
fim = timezone.make_aware(datetime.combine(self.data_fim, self.hora_volta or time.max))
|
||
return inicio <= agora <= fim
|
||
|
||
|
||
class TelefoneExterno(models.Model):
|
||
"""Linha da subtela "Telefones Externos" (dentro de Ramais) — contatos de
|
||
fornecedores/terceiros, sem relação com Usuario. Só `nome` é obrigatório,
|
||
mesmo padrão de `Ramal` avulso."""
|
||
|
||
nome = models.CharField("Nome", max_length=150)
|
||
ramal = models.CharField("Ramal", max_length=20, blank=True)
|
||
telefone = models.CharField("Telefone", max_length=30, blank=True)
|
||
observacoes = models.TextField("Observações", blank=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Telefone externo"
|
||
verbose_name_plural = "Telefones externos"
|
||
ordering = ["nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class FuncaoTelefonia(models.Model):
|
||
"""Linha da subtela "Funções de Telefonia" (dentro de Ramais) — comandos
|
||
padrão da central telefônica (ex.: "*01 + Código de Agente"). `Meta.ordering`
|
||
por `comando` reproduz a ordem de exibição esperada sem precisar de um campo
|
||
de ordem manual, já que os códigos já nascem em ordem lexicográfica."""
|
||
|
||
comando = models.CharField("Comando", max_length=50)
|
||
funcao = models.TextField("Função")
|
||
resumo = models.CharField("Resumo", max_length=150, blank=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Função de telefonia"
|
||
verbose_name_plural = "Funções de telefonia"
|
||
ordering = ["comando"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.comando
|
||
|
||
|
||
class WidgetUsuario(models.Model):
|
||
usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="widgets")
|
||
tipo = models.CharField("Tipo de widget", max_length=50)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
largura = models.PositiveIntegerField("Largura (px)", null=True, blank=True)
|
||
altura = models.PositiveIntegerField("Altura (px)", null=True, blank=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Widget do usuário"
|
||
verbose_name_plural = "Widgets do usuário"
|
||
ordering = ["ordem", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["usuario", "tipo"], name="widget_unico_por_usuario"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.usuario} → {self.tipo}"
|
||
|
||
|
||
class ImportacaoPlanoSaude(models.Model):
|
||
"""Uma execução da ferramenta "Importação de Plano de Saúde" (Utilitários):
|
||
o colaborador anexa a planilha padrão do Questor + o relatório de faturamento
|
||
de uma operadora, escolhe os tipos de lançamento (mensalidade/coparticipação)
|
||
e, para cada um, se é custeado pela empresa ou descontado do empregado — com
|
||
uma regra própria para titular e outra para dependente (`custeio_por_tipo`,
|
||
ver abaixo). O processamento em si (extração + casamento com a planilha) roda
|
||
uma única vez, na criação (ver ImportacaoPlanoSaudeViewSet.create em views.py, que usa
|
||
portal_api.planos_saude.pipeline) — as `linhas` resultantes ficam salvas aqui
|
||
pra serem revisadas/editadas antes de gerar o CSV final (ver `gerar()`, que
|
||
não reprocessa nada, só formata o que já está no banco)."""
|
||
|
||
STATUS_REVISAO = "revisao"
|
||
STATUS_CONCLUIDA = "concluida"
|
||
STATUS_CHOICES = [
|
||
(STATUS_REVISAO, "Em revisão"),
|
||
(STATUS_CONCLUIDA, "Concluída"),
|
||
]
|
||
|
||
operadora = models.CharField("Operadora", max_length=50)
|
||
nome_operadora = models.CharField("Nome da operadora", max_length=50)
|
||
tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
|
||
custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
|
||
# Chave de portal_api.planos_saude.regras_empresa.REGRAS_EMPRESA — quando
|
||
# preenchida, "mensalidade" foi custeada por essa regra especial (por
|
||
# família) em vez do custeio_por_tipo["mensalidade"] normal (que fica
|
||
# vazio nesse caso). Mutuamente exclusivo com o custeio manual de
|
||
# mensalidade na tela — ver ImportacaoPlanoSaudeCreateSerializer.
|
||
regra_empresa = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True)
|
||
planilha_padrao = models.FileField(
|
||
"Planilha padrão (Questor)",
|
||
upload_to="planos_saude/planilha_padrao/",
|
||
max_length=255,
|
||
validators=[validar_tamanho_arquivo_plano_saude],
|
||
)
|
||
arquivo_operadora = models.FileField(
|
||
"Arquivo da operadora",
|
||
upload_to="planos_saude/operadora/",
|
||
max_length=255,
|
||
validators=[validar_tamanho_arquivo_plano_saude],
|
||
)
|
||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||
criado_por = models.ForeignKey(
|
||
Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_plano_saude"
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
concluida_em = models.DateTimeField("Concluída em", null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Importação de plano de saúde"
|
||
verbose_name_plural = "Importações de plano de saúde"
|
||
ordering = ["-criado_em"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.nome_operadora} ({self.criado_em:%d/%m/%Y})"
|
||
|
||
|
||
class ImportacaoPlanoSaudeLinha(models.Model):
|
||
"""Uma linha da planilha padrão (leiaute do sistema) dentro de uma
|
||
ImportacaoPlanoSaude, já com o valor do mês casado pelo pipeline —
|
||
espelha `portal_api.planos_saude.modelos.LinhaSistema` campo a campo.
|
||
Todos os campos (não só os valores) são editáveis na tela de revisão
|
||
antes de gerar o CSV final (decisão explícita do usuário)."""
|
||
|
||
importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="linhas")
|
||
tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20)
|
||
codigo_empresa = models.CharField("Código empresa", max_length=20, blank=True)
|
||
nome_func = models.CharField("Nome do funcionário", max_length=150, blank=True)
|
||
cpf_func = models.CharField("CPF do funcionário", max_length=20, blank=True)
|
||
codigo_out_emp = models.CharField("Código out. emp.", max_length=20, blank=True)
|
||
data_inicial = models.CharField("Data inicial", max_length=10, blank=True)
|
||
nome_dependente = models.CharField("Nome do dependente", max_length=150, blank=True)
|
||
cpf_dependente = models.CharField("CPF do dependente", max_length=20, blank=True)
|
||
valor_empresa = models.CharField(
|
||
"Valor empresa", max_length=20, blank=True, default="0", validators=[validar_valor_monetario_br]
|
||
)
|
||
valor = models.CharField(
|
||
"Valor", max_length=20, blank=True, default="0", validators=[validar_valor_monetario_br]
|
||
)
|
||
descricao = models.CharField("Descrição", max_length=255, blank=True)
|
||
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||
|
||
class Meta:
|
||
verbose_name = "Linha de importação de plano de saúde"
|
||
verbose_name_plural = "Linhas de importação de plano de saúde"
|
||
ordering = ["tipo_lancamento", "ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.importacao} → {self.nome_func}"
|
||
|
||
|
||
class ImportacaoPlanoSaudeAuditoria(models.Model):
|
||
"""Item que o pipeline não conseguiu lançar automaticamente numa
|
||
ImportacaoPlanoSaude — espelha `portal_api.planos_saude.modelos.ItemAuditoria`.
|
||
Read-only quanto aos dados extraídos do arquivo da operadora (motivo,
|
||
nome, valor, detalhe...), mas `resolvida`/`linha_vinculada` são graváveis
|
||
pela tela: quando o motivo é de leitura/grafia de nome (`NOME_DIVERGENTE`
|
||
ou `NAO_CADASTRADO`), o colaborador pode confirmar manualmente qual linha
|
||
da planilha padrão é essa pessoa — ver `ImportacaoPlanoSaudeAuditoriaViewSet.resolver`
|
||
em views.py, que aplica `valor` na `linha_vinculada` (dividido conforme a
|
||
regra de custeio já salva em `ImportacaoPlanoSaude.custeio_por_tipo` para
|
||
aquele tipo de lançamento × tipo de pessoa) e marca `resolvida=True`. O
|
||
item nunca é apagado nem some da lista — fica com um selo "Resolvido" na
|
||
tela, preservando o rastro de que aquele valor entrou por confirmação
|
||
manual, não pelo casamento automático."""
|
||
|
||
MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")
|
||
|
||
importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="itens_auditoria")
|
||
motivo = models.CharField("Motivo", max_length=30)
|
||
tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20, blank=True)
|
||
numero_beneficiario = models.CharField("Número do beneficiário", max_length=30, blank=True)
|
||
nome = models.CharField("Nome", max_length=150, blank=True)
|
||
cpf = models.CharField("CPF", max_length=20, blank=True)
|
||
tipo = models.CharField("Tipo", max_length=5, blank=True)
|
||
valor = models.DecimalField("Valor", max_digits=12, decimal_places=2, default=0)
|
||
detalhe = models.TextField("Detalhe/justificativa", blank=True)
|
||
resolvida = models.BooleanField("Resolvida manualmente", default=False)
|
||
linha_vinculada = models.ForeignKey(
|
||
ImportacaoPlanoSaudeLinha,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="itens_auditoria_resolvidos",
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Item de auditoria de importação de plano de saúde"
|
||
verbose_name_plural = "Itens de auditoria de importação de plano de saúde"
|
||
ordering = ["id"]
|
||
|
||
|
||
class ImportacaoPlanoSaudeAlteracao(models.Model):
|
||
"""Log de alterações feitas na tela de revisão de uma ImportacaoPlanoSaude
|
||
depois que o pipeline já processou os arquivos — cada edição de campo,
|
||
inclusão manual de linha ("Adicionar linha") e exclusão de linha (ver
|
||
ImportacaoPlanoSaudeLinhaViewSet em views.py, que grava um registro aqui a
|
||
cada uma dessas três operações) vira um registro aqui, exibido na aba
|
||
"Alterações" da revisão (ao lado de Mensalidade/Coparticipação/Auditoria).
|
||
Nunca é apagado — `revertida` marca quando o usuário desfez aquela
|
||
alteração específica (mesmo espírito de `resolvida` em
|
||
ImportacaoPlanoSaudeAuditoria: histórico completo, nada some da lista).
|
||
|
||
Fora de escopo de propósito: o valor lançado por "Vincular pessoa" (ver
|
||
ImportacaoPlanoSaudeAuditoriaViewSet.resolver) não gera um registro aqui —
|
||
já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria)."""
|
||
|
||
TIPO_EDICAO = "edicao"
|
||
TIPO_INCLUSAO = "inclusao"
|
||
TIPO_EXCLUSAO = "exclusao"
|
||
TIPO_CHOICES = [
|
||
(TIPO_EDICAO, "Edição de valor"),
|
||
(TIPO_INCLUSAO, "Inclusão de linha"),
|
||
(TIPO_EXCLUSAO, "Exclusão de linha"),
|
||
]
|
||
|
||
importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="alteracoes")
|
||
tipo = models.CharField("Tipo", max_length=10, choices=TIPO_CHOICES)
|
||
# Null quando a linha em si já não existe mais (excluída, ou uma inclusão já
|
||
# revertida) — o snapshot em `dados_linha` é o que sobra pra identificar a
|
||
# linha na tela mesmo nesse caso, e também o que permite recriá-la ao
|
||
# reverter uma exclusão (ver ImportacaoPlanoSaudeAlteracaoViewSet.reverter).
|
||
linha = models.ForeignKey(
|
||
ImportacaoPlanoSaudeLinha, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes"
|
||
)
|
||
tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20, blank=True)
|
||
campo = models.CharField("Campo alterado", max_length=30, blank=True)
|
||
valor_anterior = models.TextField("Valor anterior", blank=True)
|
||
valor_novo = models.TextField("Valor novo", blank=True)
|
||
dados_linha = models.JSONField("Dados da linha", default=dict, blank=True)
|
||
usuario = models.ForeignKey(
|
||
Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude"
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
revertida = models.BooleanField("Revertida", default=False)
|
||
revertida_em = models.DateTimeField("Revertida em", null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Alteração de importação de plano de saúde"
|
||
verbose_name_plural = "Alterações de importação de plano de saúde"
|
||
ordering = ["-criado_em"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.importacao} — {self.get_tipo_display()}"
|
||
|
||
|
||
class RegraCusteioPlanoSaude(models.Model):
|
||
"""Regra de custeio salva (ex.: "092 - Unimed") pra reaplicar em
|
||
importações futuras de Plano de Saúde, sem redigitar o custeio de cada
|
||
combinação tipo de lançamento × tipo de beneficiário — substitui o antigo
|
||
fluxo de exportar/importar um arquivo `.json` manualmente. Guarda o mesmo
|
||
par `tipos_lancamento`/`custeio_por_tipo` de `ImportacaoPlanoSaude`, no
|
||
mesmo formato (ver `RegraCusteioPlanoSaudeSerializer` em serializers.py).
|
||
Lista compartilhada, sem "dono" — mesma permissão de toggle único da
|
||
própria ferramenta (`PermissaoApp("utilitarios", "importacao-plano-saude")`)."""
|
||
|
||
nome = models.CharField("Nome", max_length=100)
|
||
operadora = models.CharField("Operadora", max_length=50, blank=True)
|
||
tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
|
||
custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
|
||
observacoes = models.TextField("Observações", blank=True)
|
||
criado_por = models.ForeignKey(
|
||
Usuario, on_delete=models.SET_NULL, null=True, related_name="regras_custeio_plano_saude"
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
atualizado_em = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Regra de custeio de plano de saúde"
|
||
verbose_name_plural = "Regras de custeio de plano de saúde"
|
||
ordering = ["nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class IndicadorDepartamento(models.Model):
|
||
"""Departamento organizacional usado pelo Indicador de Desempenho — cada
|
||
um tem seu próprio cadastro de critérios (`IndicadorCriterio`) e
|
||
percentuais por tipo (`IndicadorPercentualTipo`), e sua própria meta de
|
||
Departamento na apuração (`IndicadorApuracaoColaborador.pct_departamento`,
|
||
agrupada por `IndicadorApuracaoColaborador.departamento`) — decisão
|
||
explícita do usuário: a regra do Fisco/Contábil pode ser diferente da
|
||
regra do Condomínio, por exemplo.
|
||
|
||
Substituiu o mecanismo de "setor" de uma rodada anterior (coluna bruta
|
||
"departamento" da planilha Tareffa + fusão automática Contabilidade/
|
||
Fiscal→Fisco-Contábil + `IndicadorSetorApelido`) — o cadastro agora é
|
||
mantido pela própria aplicação (Configurações → Departamentos), não mais
|
||
inferido da planilha. Ver `IndicadorDepartamentoGerente` pra como um
|
||
colaborador é associado a um departamento (pelo `gerente`, não mais pelo
|
||
setor bruto)."""
|
||
|
||
nome = models.CharField("Nome", max_length=150, unique=True)
|
||
ativo = models.BooleanField("Ativo", default=True)
|
||
criado_em = models.DateTimeField("Criado em", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Departamento (Indicador de Desempenho)"
|
||
verbose_name_plural = "Departamentos (Indicador de Desempenho)"
|
||
ordering = ["nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class IndicadorDepartamentoGerente(models.Model):
|
||
"""Relaciona um gerente (por nome, como vem da coluna "gerente" da
|
||
planilha Serviços Tareffa) a um `IndicadorDepartamento` — um departamento
|
||
pode ter mais de um gerente (ex.: Fisco/Contábil tem "João Candido
|
||
Rodrigues" e "Lhais Vergilio Delavy"), mas cada gerente pertence a só um
|
||
departamento (`nome_gerente` é único). Mantido manualmente pela própria
|
||
aplicação por ora (Configurações → Departamentos → "Gerenciar Gerentes")
|
||
— decisão explícita do usuário; alimentar isso automaticamente a partir
|
||
da planilha fica pra uma rodada futura.
|
||
|
||
`portal_api.indicadores.departamentos.carrega_mapa_gerentes()` usa essa
|
||
tabela pra decidir, na criação de uma apuração, de qual departamento é
|
||
cada colaborador (via `IndicadorApuracaoColaborador.gerente`) — sem
|
||
entrada aqui pro gerente de alguém, o colaborador fica com
|
||
`departamento=None` (sinalizado como aviso na apuração)."""
|
||
|
||
departamento = models.ForeignKey(IndicadorDepartamento, on_delete=models.CASCADE, related_name="gerentes")
|
||
nome_gerente = models.CharField("Nome do gerente", max_length=150, unique=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Gerente do departamento (Indicador de Desempenho)"
|
||
verbose_name_plural = "Gerentes do departamento (Indicador de Desempenho)"
|
||
ordering = ["nome_gerente"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.nome_gerente} → {self.departamento}"
|
||
|
||
|
||
class IndicadorPercentualTipo(models.Model):
|
||
"""Percentuais (individual/grupo/departamento) aplicados por tipo de
|
||
colaborador (Contábil+Fiscal/Contador SC/Contador CC/Fiscal/Conciliador,
|
||
ver `TIPO_COLABORADOR_INDICADOR_CHOICES`), por departamento
|
||
(`IndicadorDepartamento` — cada departamento tem seu próprio histórico)
|
||
no cálculo do Indicador de Desempenho (Geradoc). Nunca é editado in-place
|
||
— uma mudança de política insere uma linha nova com `vigente_desde` mais
|
||
recente, preservando o histórico (decisão explícita do usuário). O valor
|
||
vigente para uma competência é a linha daquele `tipo`+`departamento` com
|
||
o maior `vigente_desde` que seja `<=` a competência (ver
|
||
`portal_api.indicadores.calculo.percentual_vigente`)."""
|
||
|
||
departamento = models.ForeignKey(IndicadorDepartamento, on_delete=models.CASCADE, related_name="percentuais_tipo")
|
||
tipo = models.CharField("Tipo", max_length=1, choices=TIPO_COLABORADOR_INDICADOR_CHOICES)
|
||
percentual_individual = models.DecimalField(
|
||
"% Individual", max_digits=7, decimal_places=4, help_text="Em escala de 0 a 100 (ex.: 1,42 = 1,42%)."
|
||
)
|
||
percentual_grupo = models.DecimalField(
|
||
"% Grupo", max_digits=7, decimal_places=4, help_text="Em escala de 0 a 100 (ex.: 20 = 20%)."
|
||
)
|
||
percentual_departamento = models.DecimalField(
|
||
"% Departamento", max_digits=7, decimal_places=4, help_text="Em escala de 0 a 100 (ex.: 20 = 20%)."
|
||
)
|
||
vigente_desde = models.DateField("Vigente desde")
|
||
criado_por = models.ForeignKey(
|
||
Usuario, on_delete=models.SET_NULL, null=True, related_name="percentuais_indicador_criados"
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Percentual por tipo (Indicador de Desempenho)"
|
||
verbose_name_plural = "Percentuais por tipo (Indicador de Desempenho)"
|
||
ordering = ["departamento", "tipo", "-vigente_desde"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"Tipo {self.tipo} — vigente desde {self.vigente_desde:%d/%m/%Y}"
|
||
|
||
|
||
class IndicadorCriterio(models.Model):
|
||
"""Cadastro genérico de critérios do Indicador de Desempenho — em vez de
|
||
fixar no código os pesos/critérios da planilha antiga (que está com
|
||
fórmulas quebradas por edições manuais acumuladas), o RH mantém essa lista
|
||
pela própria tela, com nome/peso/período/papel livres. `calculo_automatico`
|
||
marca os 3 critérios que o pipeline consegue calcular sozinho a partir da
|
||
planilha Serviços Tareffa (entrega de balancetes/liberações
|
||
fiscais/conciliações no prazo); os demais são sempre marcação manual do RH
|
||
por colaborador (ver `IndicadorApuracaoResposta`).
|
||
|
||
Cada critério pertence a um único `IndicadorDepartamento` — a regra do
|
||
Fisco/Contábil pode ser diferente da regra do Condomínio, por exemplo
|
||
(decisão explícita do usuário); um mesmo critério "nome"/"peso" que valha
|
||
pra dois departamentos precisa de duas linhas, uma por departamento."""
|
||
|
||
GRUPO_INDIVIDUAL = "individual"
|
||
GRUPO_GRUPO = "grupo"
|
||
GRUPO_DEPARTAMENTO = "departamento"
|
||
GRUPO_CHOICES = [
|
||
(GRUPO_INDIVIDUAL, "Individual"),
|
||
(GRUPO_GRUPO, "Grupo"),
|
||
(GRUPO_DEPARTAMENTO, "Departamento"),
|
||
]
|
||
|
||
PERIODO_TODOS = "todos"
|
||
PERIODO_MAR_A_NOV = "mar_a_nov"
|
||
PERIODO_DEZ_A_JAN = "dez_a_jan"
|
||
PERIODO_CHOICES = [
|
||
(PERIODO_TODOS, "Todos os meses"),
|
||
(PERIODO_MAR_A_NOV, "Março a Novembro"),
|
||
(PERIODO_DEZ_A_JAN, "Dezembro a Janeiro"),
|
||
]
|
||
|
||
CALCULO_BALANCETE = "balancete"
|
||
CALCULO_LIBERACAO_FISCAL = "liberacao_fiscal"
|
||
CALCULO_CONCILIACAO = "conciliacao"
|
||
CALCULO_AUTOMATICO_CHOICES = [
|
||
("", "Manual"),
|
||
(CALCULO_BALANCETE, "Entrega de balancetes no prazo (automático)"),
|
||
(CALCULO_LIBERACAO_FISCAL, "Entrega de liberações fiscais no prazo (automático)"),
|
||
(CALCULO_CONCILIACAO, "Entrega de conciliações no prazo (automático)"),
|
||
]
|
||
|
||
departamento = models.ForeignKey(IndicadorDepartamento, on_delete=models.CASCADE, related_name="criterios")
|
||
nome = models.CharField("Nome", max_length=200)
|
||
grupo = models.CharField("Grupo", max_length=15, choices=GRUPO_CHOICES)
|
||
peso = models.DecimalField("Peso", max_digits=7, decimal_places=4, help_text="Em escala de 0 a 100 (ex.: 60 = 60%).")
|
||
periodo = models.CharField("Período", max_length=10, choices=PERIODO_CHOICES, default=PERIODO_TODOS)
|
||
papel_aplicavel = models.CharField(
|
||
"Papel aplicável",
|
||
max_length=1,
|
||
choices=TIPO_COLABORADOR_INDICADOR_CHOICES,
|
||
blank=True,
|
||
help_text="Só usado quando grupo=Individual — em branco significa que se aplica a qualquer papel.",
|
||
)
|
||
calculo_automatico = models.CharField(
|
||
"Cálculo automático", max_length=20, choices=CALCULO_AUTOMATICO_CHOICES, blank=True, default=""
|
||
)
|
||
limiar_percentual = models.DecimalField(
|
||
"Limiar percentual para SIM (cálculo automático)", max_digits=5, decimal_places=2, default=90
|
||
)
|
||
ativo = models.BooleanField("Ativo", default=True)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Critério do Indicador de Desempenho"
|
||
verbose_name_plural = "Critérios do Indicador de Desempenho"
|
||
ordering = ["departamento", "grupo", "periodo", "nome"]
|
||
|
||
def __str__(self) -> str:
|
||
return self.nome
|
||
|
||
|
||
class IndicadorApuracao(models.Model):
|
||
"""Uma apuração mensal do Indicador de Desempenho (Fiscontábil, Geradoc): o
|
||
RH anexa a planilha Serviços Tareffa + Honorários Por Cliente daquele mês,
|
||
o pipeline (`portal_api.indicadores.pipeline`) deriva colaboradores/
|
||
empresas/tipos e pré-calcula os 3 critérios automáticos — o resultado fica
|
||
em `colaboradores` pra revisão/ajuste manual antes de gerar os recibos em
|
||
PDF (ver `gerar()` na view, que não reprocessa nada, só formata o que já
|
||
está salvo)."""
|
||
|
||
STATUS_REVISAO = "revisao"
|
||
STATUS_CONCLUIDA = "concluida"
|
||
STATUS_CHOICES = [
|
||
(STATUS_REVISAO, "Em revisão"),
|
||
(STATUS_CONCLUIDA, "Concluída"),
|
||
]
|
||
|
||
competencia = models.DateField("Competência")
|
||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||
planilha_tareffa = models.FileField(
|
||
"Planilha de Serviços Tareffa",
|
||
upload_to="indicadores/tareffa/",
|
||
validators=[validar_tamanho_arquivo_indicador],
|
||
)
|
||
planilha_honorarios = models.FileField(
|
||
"Planilha de Honorários Por Cliente",
|
||
upload_to="indicadores/honorarios/",
|
||
validators=[validar_tamanho_arquivo_indicador],
|
||
)
|
||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, related_name="apuracoes_indicador")
|
||
avisos = models.JSONField(
|
||
"Avisos do processamento",
|
||
default=list,
|
||
blank=True,
|
||
help_text="Casos que o pipeline não conseguiu decidir sozinho (ex.: empate Fiscal/Conciliador) — só leitura.",
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
concluida_em = models.DateTimeField("Concluída em", null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Apuração do Indicador de Desempenho"
|
||
verbose_name_plural = "Apurações do Indicador de Desempenho"
|
||
ordering = ["-competencia", "-criado_em"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"Indicador de Desempenho — {self.competencia:%m/%Y}"
|
||
|
||
def periodo_criterio(self) -> str:
|
||
"""'Março a Novembro' cobre fev-nov (a planilha original só define
|
||
explicitamente Mar-Nov e Dez-Jan, sem mencionar fevereiro — assumimos
|
||
que fevereiro segue a regra "do meio do ano", não a de fechamento)."""
|
||
return IndicadorCriterio.PERIODO_DEZ_A_JAN if self.competencia.month in (12, 1) else IndicadorCriterio.PERIODO_MAR_A_NOV
|
||
|
||
|
||
class IndicadorApuracaoColaborador(models.Model):
|
||
"""Um colaborador dentro de uma IndicadorApuracao — nome/gerente vêm como
|
||
texto direto da planilha Tareffa (sem FK pra Usuario: o recibo é um
|
||
documento interno do RH, não precisa casar com uma conta do Portal).
|
||
`pct_individual/grupo/departamento` são o percentual efetivo (o que de
|
||
fato entra no cálculo do valor) — recalculados automaticamente a cada
|
||
mudança de uma `IndicadorApuracaoResposta`, exceto quando o RH ajusta um
|
||
deles manualmente (`pct_*_ajustado_manualmente=True`), caso em que o
|
||
recálculo automático passa a respeitar o valor ajustado até ele ser
|
||
revertido (ver `portal_api.indicadores.calculo.recalcula_colaborador` e
|
||
`IndicadorApuracaoColaboradorViewSet.recalcular` em views.py, só pra
|
||
`pct_individual`).
|
||
|
||
**`pct_individual` não é só a média dos critérios de nível Individual** —
|
||
é o percentual final do Indicador Individual do colaborador, composto
|
||
pelos 3 níveis (Individual/Grupo/Departamento), cada um pesando conforme
|
||
o peso médio dos seus próprios critérios aplicáveis na competência
|
||
(decisão explícita do usuário; ver `calculo._combina_niveis`/
|
||
`_peso_medio_nivel`). Ex.: Individual (critérios peso 60) atingiu 57,14%,
|
||
Grupo (peso 10) atingiu 100%, Departamento (peso 30) atingiu 100% →
|
||
(57,14×60 + 100×10 + 100×30) / (60+10+30) = 74,28%. `pct_grupo`/
|
||
`pct_departamento` continuam sendo só a média dos próprios critérios
|
||
(sem composição) — só `pct_individual` agrega os 3.
|
||
|
||
O campo continua existindo por colaborador (sem tabela nova pra "grupo"),
|
||
mas `pct_grupo`/`pct_departamento` são conceitualmente compartilhados —
|
||
"cada gerente representa um grupo" (decisão do usuário): o percentual de
|
||
Grupo é o mesmo pra todo colaborador com o mesmo `gerente` dentro da
|
||
apuração, e o de Departamento é o mesmo pra todos os colaboradores do
|
||
mesmo `departamento` (ver campo abaixo) — não mais um valor único pra
|
||
toda a apuração; cada departamento (Fisco/Contábil, Rocket, Gerentes,
|
||
...) tem sua própria meta de Departamento, seu próprio cadastro de
|
||
critérios e seus próprios percentuais por tipo. Por isso eles nunca são
|
||
ajustados/revertidos por `IndicadorApuracaoColaboradorViewSet` (que só
|
||
cobre `pct_individual`) — `IndicadorApuracaoViewSet.ajustar_grupo`/
|
||
`recalcular_grupo`/`ajustar_departamento`/`recalcular_departamento`
|
||
aplicam a mudança de uma vez a todos os colaboradores do mesmo
|
||
gerente/departamento, mantendo os valores em sincronia entre si. A tela
|
||
de revisão (`indicador-desempenho.js`) reflete isso com uma tabela de
|
||
"Metas de Grupo e Departamento" no início da página (uma linha de
|
||
Departamento por `departamento` + uma linha de Grupo por gerente, cada
|
||
uma com seu próprio input) — a lista de colaboradores abaixo fica de fora
|
||
dessas metas, e só serve pra revisão individual (percentual Individual,
|
||
respostas de critério, recibo); botões de filtro por departamento
|
||
(`#ind-filtro-departamento`) permitem ao RH ver todos de uma vez ou só um
|
||
departamento específico (e, dentro dele, só os gerentes daquele
|
||
departamento), sem afetar as metas preenchidas acima.
|
||
|
||
`validado` é só um checklist de revisão do RH (checkbox no início do card,
|
||
`IndicadorApuracaoColaboradorViewSet.marcar_validado`) — não participa de
|
||
nenhum cálculo, nem bloqueia edição; existe só pra o RH controlar quem já
|
||
conferiu e quem ainda falta, numa apuração com muitos colaboradores."""
|
||
|
||
apuracao = models.ForeignKey(IndicadorApuracao, on_delete=models.CASCADE, related_name="colaboradores")
|
||
nome = models.CharField("Nome", max_length=150)
|
||
gerente = models.CharField("Gerente", max_length=150, blank=True)
|
||
departamento = models.ForeignKey(
|
||
IndicadorDepartamento,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="colaboradores",
|
||
help_text=(
|
||
"Resolvido uma vez, na criação da apuração, a partir de "
|
||
"IndicadorDepartamentoGerente (nome do gerente → departamento) — é um "
|
||
"retrato daquele momento, não recalculado sozinho se a relação "
|
||
"gerente→departamento mudar depois (mesmo espírito de `gerente`, que "
|
||
"também vem congelado da planilha). Fica em branco quando o gerente do "
|
||
"colaborador ainda não está mapeado a nenhum departamento (ver os "
|
||
"`avisos` da apuração)."
|
||
),
|
||
)
|
||
pct_individual = models.DecimalField(
|
||
"% Individual atingido", max_digits=7, decimal_places=4, default=0,
|
||
help_text="Em escala de 0 a 100 (ex.: 90 = 90%). Recalculado automaticamente, a menos que ajustado manualmente.",
|
||
)
|
||
pct_individual_ajustado_manualmente = models.BooleanField("% Individual ajustado manualmente", default=False)
|
||
pct_grupo = models.DecimalField(
|
||
"% Grupo atingido", max_digits=7, decimal_places=4, default=0,
|
||
help_text="Em escala de 0 a 100. Recalculado automaticamente, a menos que ajustado manualmente.",
|
||
)
|
||
pct_grupo_ajustado_manualmente = models.BooleanField("% Grupo ajustado manualmente", default=False)
|
||
pct_departamento = models.DecimalField(
|
||
"% Departamento atingido", max_digits=7, decimal_places=4, default=0,
|
||
help_text="Em escala de 0 a 100. Recalculado automaticamente, a menos que ajustado manualmente.",
|
||
)
|
||
pct_departamento_ajustado_manualmente = models.BooleanField(
|
||
"% Departamento ajustado manualmente", default=False
|
||
)
|
||
valor_total = models.DecimalField("Valor total", max_digits=12, decimal_places=2, default=0)
|
||
validado = models.BooleanField(
|
||
"Validado pelo RH",
|
||
default=False,
|
||
help_text="Controle manual de revisão — não afeta nenhum cálculo, só marca que o RH já conferiu este colaborador.",
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Colaborador da apuração (Indicador de Desempenho)"
|
||
verbose_name_plural = "Colaboradores da apuração (Indicador de Desempenho)"
|
||
ordering = ["nome"]
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["apuracao", "nome"], name="indicador_colaborador_unico_por_apuracao"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.apuracao} → {self.nome}"
|
||
|
||
|
||
class IndicadorApuracaoEmpresa(models.Model):
|
||
"""Uma linha do recibo de um colaborador: uma empresa em que ele atuou
|
||
naquela competência, o honorário dela (casado por código com a planilha
|
||
de Honorários Por Cliente) e o tipo derivado (ver
|
||
`portal_api.indicadores.tipos.deriva_tipos_por_empresa`).
|
||
|
||
`honorario_nao_encontrado=True` significa que o código da empresa não
|
||
bateu com nenhuma linha da planilha de Honorários Por Cliente — `honorario`
|
||
fica `0` até o RH preencher manualmente (ver
|
||
`IndicadorApuracaoEmpresaViewSet`/`IndicadorApuracaoEmpresaAjusteSerializer`
|
||
em views.py/serializers.py, tela de revisão em `indicador-desempenho.js`, e
|
||
o modal "Empresas sem Honorário" — `IndicadorApuracaoViewSet.ajustar_honorario_empresa`
|
||
— que preenche de uma vez todas as linhas com o mesmo `codigo_empresa`).
|
||
Preencher zera `honorario_nao_encontrado` (o valor passou a ser
|
||
"encontrado", só que informado à mão) e recalcula o colaborador (ver
|
||
`calculo.recalcula_colaborador`), já que `honorario_ajustado`/`valor_*`
|
||
dependem desse campo. `honorario_ajustado_manualmente=True` fica marcado
|
||
pra sempre nessa linha (mesmo que o RH edite o valor de novo depois) —
|
||
é só um registro de que o honorário não veio da planilha, exibido como
|
||
nota na tela de revisão (ver `indicador-desempenho.js`), sem UI própria
|
||
de reverter (ao contrário de `pct_individual_ajustado_manualmente` etc.,
|
||
que têm um automático pra voltar a — aqui não existe "automático" pra
|
||
voltar, já que o código nunca casou com a planilha)."""
|
||
|
||
colaborador = models.ForeignKey(IndicadorApuracaoColaborador, on_delete=models.CASCADE, related_name="empresas")
|
||
codigo_empresa = models.CharField("Código da empresa", max_length=20)
|
||
nome_empresa = models.CharField("Nome da empresa", max_length=255, blank=True)
|
||
honorario = models.DecimalField("Honorário", max_digits=12, decimal_places=2, default=0)
|
||
honorario_ajustado = models.DecimalField(
|
||
"Honorário ajustado",
|
||
max_digits=12,
|
||
decimal_places=2,
|
||
default=0,
|
||
help_text="Honorário × % Individual do colaborador — é sobre esse valor que os percentuais por tipo são aplicados.",
|
||
)
|
||
honorario_nao_encontrado = models.BooleanField("Honorário não encontrado", default=False)
|
||
honorario_ajustado_manualmente = models.BooleanField("Honorário ajustado manualmente", default=False)
|
||
tipo = models.CharField("Tipo", max_length=1, choices=TIPO_COLABORADOR_INDICADOR_CHOICES)
|
||
valor_individual = models.DecimalField("Valor individual", max_digits=12, decimal_places=2, default=0)
|
||
valor_grupo = models.DecimalField("Valor grupo", max_digits=12, decimal_places=2, default=0)
|
||
valor_departamento = models.DecimalField("Valor departamento", max_digits=12, decimal_places=2, default=0)
|
||
valor_total = models.DecimalField("Valor total", max_digits=12, decimal_places=2, default=0)
|
||
|
||
class Meta:
|
||
verbose_name = "Empresa da apuração (Indicador de Desempenho)"
|
||
verbose_name_plural = "Empresas da apuração (Indicador de Desempenho)"
|
||
# `codigo_empresa` é CharField — ordenar só por ele é ordem alfabética
|
||
# ("80"/"503" foram pro fim da lista, depois de "2134", porque '8' e
|
||
# '5' são "maiores" que '1'/'2' como caractere, mesmo sendo menores
|
||
# como número). Ordenar por tamanho da string primeiro reproduz a
|
||
# ordem numérica correta pra códigos sem zero à esquerda (string mais
|
||
# curta = número menor, sempre) sem precisar converter pra inteiro —
|
||
# evita um erro de banco se algum código um dia não for só dígitos.
|
||
ordering = [Length("codigo_empresa"), "codigo_empresa", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.colaborador} → {self.nome_empresa}"
|
||
|
||
|
||
class IndicadorApuracaoResposta(models.Model):
|
||
"""SIM/NÃO/NÃO FAZ/NÃO SE APLICA de um colaborador para um IndicadorCriterio,
|
||
dentro de uma apuração. `valor_automatico`/`percentual_calculado` guardam a
|
||
sugestão do pipeline (só preenchidos nos 3 critérios com
|
||
`calculo_automatico`) mesmo depois de um ajuste manual — `valor` é o que de
|
||
fato entra no cálculo, editável individualmente ou em lote (ver
|
||
`IndicadorApuracaoRespostaViewSet.aplicar_em_lote` em views.py)."""
|
||
|
||
colaborador = models.ForeignKey(IndicadorApuracaoColaborador, on_delete=models.CASCADE, related_name="respostas")
|
||
criterio = models.ForeignKey(IndicadorCriterio, on_delete=models.PROTECT, related_name="respostas")
|
||
valor = models.CharField("Valor", max_length=15, choices=RESPOSTA_CRITERIO_INDICADOR_CHOICES)
|
||
valor_automatico = models.CharField(
|
||
"Valor sugerido automaticamente", max_length=15, choices=RESPOSTA_CRITERIO_INDICADOR_CHOICES, blank=True
|
||
)
|
||
percentual_calculado = models.DecimalField(
|
||
"Percentual calculado (entrega no prazo)", max_digits=7, decimal_places=4, null=True, blank=True
|
||
)
|
||
ajustado_manualmente = models.BooleanField("Ajustado manualmente", default=False)
|
||
|
||
class Meta:
|
||
verbose_name = "Resposta de critério (Indicador de Desempenho)"
|
||
verbose_name_plural = "Respostas de critério (Indicador de Desempenho)"
|
||
constraints = [
|
||
models.UniqueConstraint(fields=["colaborador", "criterio"], name="indicador_resposta_unica_por_criterio"),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.colaborador} → {self.criterio}: {self.valor}"
|
||
|
||
|
||
def _faixas_para_json(faixas: list[tuple[float, float, float]]) -> list[dict]:
|
||
return [{"limite_superior": limite, "aliquota": aliquota, "deduzir": deduzir} for limite, aliquota, deduzir in faixas]
|
||
|
||
|
||
def _faixas_inss_padrao() -> list[dict]:
|
||
return _faixas_para_json(tabelas_custo_contratacao.FAIXAS_INSS)
|
||
|
||
|
||
def _faixas_irrf_padrao() -> list[dict]:
|
||
return _faixas_para_json(tabelas_custo_contratacao.FAIXAS_IRRF)
|
||
|
||
|
||
class ParametroFiscalCustoContratacao(models.Model):
|
||
"""Parâmetros fiscais (INSS/IRRF) usados pela Simulação de Custo de
|
||
Contratação (Geradoc) — singleton (uma única linha, pk=1 via `atual()`),
|
||
editável pela própria tela porque essas tabelas mudam todo ano (mesma
|
||
permissão de quem usa a simulação — `apps["simulacao-custo-contratacao"]`
|
||
em `permissoes["geradoc"]`, sem par visualizar/editar dedicado).
|
||
`custo_contratacao.tabelas` continua existindo só como o seed/default
|
||
usado na primeira criação desta linha, não é mais lido direto pelo
|
||
cálculo (ver `para_calculo()` abaixo, que monta o
|
||
`custo_contratacao.calculo.ParametrosFiscais` a partir do que estiver
|
||
salvo aqui)."""
|
||
|
||
faixas_inss = models.JSONField("Faixas de INSS", default=_faixas_inss_padrao)
|
||
teto_desconto_inss = models.FloatField(
|
||
"Teto do desconto de INSS", default=tabelas_custo_contratacao.TETO_DESCONTO_INSS
|
||
)
|
||
faixas_irrf = models.JSONField("Faixas de IRRF", default=_faixas_irrf_padrao)
|
||
aliquota_irrf_topo = models.FloatField(
|
||
"Alíquota IRRF acima da última faixa", default=tabelas_custo_contratacao.ALIQUOTA_IRRF_TOPO
|
||
)
|
||
deduzir_irrf_topo = models.FloatField(
|
||
"Valor a deduzir do IRRF acima da última faixa", default=tabelas_custo_contratacao.DEDUZIR_IRRF_TOPO
|
||
)
|
||
desconto_simplificado_irrf = models.FloatField(
|
||
"Desconto simplificado do IRRF", default=tabelas_custo_contratacao.DESCONTO_SIMPLIFICADO_IRRF
|
||
)
|
||
deducao_por_dependente = models.FloatField(
|
||
"Dedução por dependente", default=tabelas_custo_contratacao.DEDUCAO_POR_DEPENDENTE
|
||
)
|
||
reducao_lei_15270_coeficiente_a = models.FloatField(
|
||
"Coeficiente A da redução (Lei 15.270/2025)",
|
||
default=tabelas_custo_contratacao.REDUCAO_LEI_15270_COEFICIENTE_A,
|
||
)
|
||
reducao_lei_15270_coeficiente_b = models.FloatField(
|
||
"Coeficiente B da redução (Lei 15.270/2025)",
|
||
default=tabelas_custo_contratacao.REDUCAO_LEI_15270_COEFICIENTE_B,
|
||
)
|
||
reducao_lei_15270_limite = models.FloatField(
|
||
"Limite de rendimento bruto para a redução (Lei 15.270/2025)",
|
||
default=tabelas_custo_contratacao.REDUCAO_LEI_15270_LIMITE,
|
||
)
|
||
atualizado_em = models.DateTimeField("Atualizado em", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Parâmetro fiscal de custo de contratação"
|
||
verbose_name_plural = "Parâmetros fiscais de custo de contratação"
|
||
|
||
def __str__(self) -> str:
|
||
return "Parâmetros fiscais — Simulação de Custo de Contratação"
|
||
|
||
@classmethod
|
||
def atual(cls) -> "ParametroFiscalCustoContratacao":
|
||
obj, _ = cls.objects.get_or_create(pk=1)
|
||
return obj
|
||
|
||
def para_calculo(self) -> ParametrosFiscais:
|
||
return ParametrosFiscais(
|
||
faixas_inss=[(f["limite_superior"], f["aliquota"], f["deduzir"]) for f in self.faixas_inss],
|
||
teto_desconto_inss=self.teto_desconto_inss,
|
||
faixas_irrf=[(f["limite_superior"], f["aliquota"], f["deduzir"]) for f in self.faixas_irrf],
|
||
aliquota_irrf_topo=self.aliquota_irrf_topo,
|
||
deduzir_irrf_topo=self.deduzir_irrf_topo,
|
||
desconto_simplificado_irrf=self.desconto_simplificado_irrf,
|
||
deducao_por_dependente=self.deducao_por_dependente,
|
||
reducao_lei_15270_coeficiente_a=self.reducao_lei_15270_coeficiente_a,
|
||
reducao_lei_15270_coeficiente_b=self.reducao_lei_15270_coeficiente_b,
|
||
reducao_lei_15270_limite=self.reducao_lei_15270_limite,
|
||
)
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.importacao} → {self.nome} ({self.motivo})"
|