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: # { "": { "enabled": bool, "apps": { "": 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})"