portal_publico/portal_api/models.py

1299 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
# Só registro informativo de qual "Regra de custeio salva" (se alguma) foi
# usada pra preencher este formulário — não influencia o processamento
# (custeio_por_tipo já é o que vale), só permite mostrar a observação da
# regra na tela de Revisão. SET_NULL pra não impedir excluir a regra
# salva depois; nunca obrigatório, a maioria das importações não usa uma.
regra_custeio_salva = models.ForeignKey(
"RegraCusteioPlanoSaude",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="importacoes_plano_saude",
)
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 EmpresaQuestor(models.Model):
"""Cache local do nome de uma empresa cadastrada no Questor (banco
externo, fora do Django, acessado só-leitura via
`database.connection.DatabaseConnection` + `sqls.questor.QuestorSQL` —
ver `portal_api.empresas_questor.resolve_nome_empresa`), indexado por
`codigo_empresa`. Resolvido uma única vez (na primeira vez que um
`codigo_empresa` aparece em "Cadastro de Regras" — seja digitado em "+
Nova regra" ou já existente numa regra antiga sem cache ainda) e
reaproveitado depois — a consulta ao Questor não precisa se repetir a
cada exibição, só na primeira. Se o nome oficial mudar no Questor, o
cache não atualiza sozinho (sem esse mecanismo hoje); não é um problema
esperado com frequência pra justificar essa complexidade agora."""
codigo_empresa = models.CharField("Código da empresa", max_length=20, unique=True)
nome_empresa = models.CharField("Nome da empresa", max_length=255)
criado_em = models.DateTimeField("Criado em", auto_now_add=True)
atualizado_em = models.DateTimeField("Atualizado em", auto_now=True)
class Meta:
verbose_name = "Empresa (Questor)"
verbose_name_plural = "Empresas (Questor)"
ordering = [Length("codigo_empresa"), "codigo_empresa"]
def __str__(self) -> str:
return f"{self.codigo_empresa} - {self.nome_empresa}"
class RegraCusteioPlanoSaude(models.Model):
"""Regra de custeio cadastrada pra uma empresa+operadora (ex.: empresa
"092" + Unimed), reaplicada nas importações futuras de Plano de Saúde
dessa combinação — cadastro e edição vivem só na tela "Cadastro de
Regras" (`importacao-plano-saude.js`), separada da tela de execução
("Nova Importação"), que só resolve e aplica a regra já existente, sem
editá-la. 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")`).
`codigo_empresa`+`operadora` são únicos juntos (`Meta.unique_together`)
— uma única regra por empresa+operadora, decisão validada contra os
dados reais existentes antes de impor a restrição. `nome` não é mais
digitado pelo usuário: é sempre derivado em
`RegraCusteioPlanoSaudeSerializer.validate()` como
"<codigo_empresa> - <nome da operadora>" (cuidado: o "código" dentro do
label de `pipeline.OPERADORAS` é o código de cadastro da OPERADORA no
Questor, não o `codigo_empresa` do cliente — são códigos diferentes,
não confundir ao compor o nome)."""
nome = models.CharField("Nome", max_length=100)
codigo_empresa = models.CharField("Código da empresa", max_length=20)
operadora = models.CharField("Operadora", max_length=50)
# Quando preenchida, indica que o tipo de lançamento "mensalidade" desta
# regra usa o algoritmo especial por família de
# `planos_saude.regras_empresa.REGRAS_EMPRESA[chave]` em vez do custeio
# manual titular/dependente — unifica o antigo checkbox "Regra empresa"
# (que era um caminho paralelo na tela de Nova Importação) dentro do
# cadastro por empresa+operadora.
regra_empresa_chave = models.CharField("Regra empresa (mensalidade)", 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"
unique_together = [["codigo_empresa", "operadora"]]
ordering = [Length("codigo_empresa"), "codigo_empresa", "operadora"]
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})"