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.core.validators import RegexValidator 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 AJUDA_APLICACAO_TEXTO_MAX_CHARS = 2_000_000 CONTABIL_RESUMO_FECHAMENTO_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} # Nome do perfil autorizado a editar o texto de "Mais informações" das # aplicações (`AjudaAplicacao`, ver abaixo) — match de nome fixo, mesmo # padrão já usado pro selo "Restrito" de Relatórios Gerenciais (nome === # "Diretoria"), não uma flag na árvore de permissões. Sobrevive a uma # renomeação de perfil só se este valor for atualizado junto. PERFIL_INOVACAO_NOME = "Inovação" 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_tamanho_texto_ajuda_aplicacao(valor: str) -> None: if len(valor) > AJUDA_APLICACAO_TEXTO_MAX_CHARS: raise ValidationError("O texto (com imagens embutidas) ficou grande demais.") def validar_tamanho_resumo_fechamento_contabil(valor: str) -> None: if len(valor) > CONTABIL_RESUMO_FECHAMENTO_MAX_CHARS: raise ValidationError("O resumo do fechamento (com imagens embutidas) ficou grande 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()) def eh_perfil_inovacao(self) -> bool: """Só quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado pode editar o texto de "Mais informações" de uma aplicação (`AjudaAplicacao`).""" return self.perfis.filter(nome=PERFIL_INOVACAO_NOME).exists() 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 AjudaAplicacao(models.Model): """Texto de "Mais informações" mostrado no botão de interrogação ao lado do nome de uma aplicação — chave natural (`app_key`, mesma ideia de `app_id`/`notif_id` acima), sem FK pra `Usuario`: é um texto compartilhado, igual pra quem quer que abra o modal. Visualizar é liberado a qualquer autenticado; editar é restrito a quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado (`Usuario.eh_perfil_inovacao()`), uma regra fixa que não aparece na árvore de permissões de Perfis de Acesso. `texto` aceita imagens embutidas (mesmo mecanismo de `AcessoGeral. observacoes` — `
` no cliente, sanitizado no servidor via `nh3` antes de salvar, ver `RICHTEXT_ALLOWED_TAGS` em serializers.py).""" app_key = models.CharField("Chave da aplicação", max_length=100, unique=True) texto = models.TextField("Texto", blank=True, validators=[validar_tamanho_texto_ajuda_aplicacao]) atualizado_em = models.DateTimeField("Atualizado em", auto_now=True) atualizado_por = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+" ) class Meta: verbose_name = "Ajuda de aplicação" verbose_name_plural = "Ajudas de aplicação" def __str__(self) -> str: return self.app_key @classmethod def para_app(cls, app_key: str) -> "AjudaAplicacao": obj, _ = cls.objects.get_or_create(app_key=app_key) return obj 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 + um ou mais relatórios de faturamento da operadora (`ImportacaoPlanoSaudeArquivoOperadora`, ver abaixo — a maioria das operadoras manda só um arquivo, mas algumas mandam mensalidade e coparticipação em arquivos separados), 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, blank=True, validators=[validar_tamanho_arquivo_plano_saude], ) # Preenchida só quando a planilha padrão veio de uma busca direta no # Questor (via SQL) em vez de upload manual — ver # portal_api.planos_saude.questor_planilha. Nesse caso, planilha_padrao # ainda é salva (um CSV gerado no mesmo formato do upload), só que essa # data indica a origem alternativa. Fica em branco pra toda importação # que veio por upload. competencia = models.DateField("Competência (Questor)", null=True, blank=True) 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 ImportacaoPlanoSaudeArquivoOperadora(models.Model): """Um dos relatórios da operadora anexados a uma importação — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde, quando manda PDF em vez do CSV único) mandam mensalidade e coparticipação em arquivos separados. O parser da operadora (`OperadoraParser.extrai()`) é chamado uma vez por arquivo, sem precisar que o usuário diga qual é qual — cada parser detecta o tipo de relatório pelo próprio conteúdo (ver `operadoras/unimed/saude.py`). `ordem` só reflete a ordem de upload, sem efeito no processamento (a soma dos indivíduos de todos os arquivos é o que importa, não a ordem entre eles).""" importacao = models.ForeignKey( ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="arquivos_operadora" ) arquivo = models.FileField( "Arquivo", upload_to="planos_saude/operadora/", max_length=255, validators=[validar_tamanho_arquivo_plano_saude], ) ordem = models.PositiveIntegerField("Ordem", default=0) criado_em = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "Arquivo da operadora (importação de plano de saúde)" verbose_name_plural = "Arquivos da operadora (importação de plano de saúde)" ordering = ["ordem", "id"] def __str__(self) -> str: return f"{self.importacao} — {self.arquivo.name}" 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 aceitam PATCH no backend (sem trava aqui), mas a tela de revisão só expõe `valor_empresa`/`valor` como editáveis numa linha que já veio do processamento — os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (decisão revista do usuário; ver `linhasIncluidasManualmente()` em `importacao-plano-saude.js`, que decide isso no cliente a partir de `ImportacaoPlanoSaudeAlteracao` — não há trava correspondente aqui no model/serializer, é só a UI que restringe).""" 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 VinculoNomeOperadora(models.Model): """"DE/PARA" persistente de um nome divergente do arquivo da operadora, resolvido manualmente uma vez via "Vincular pessoa" (ver ImportacaoPlanoSaudeAuditoriaViewSet.resolver, que cria/atualiza este registro depois de aplicar a resolução) — reaplicado automaticamente em importações FUTURAS da mesma operadora+empresa, sem precisar vincular de novo todo mês (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md). Continua não sendo aproximação: só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez — `portal_api.planos_saude.matcher._casa_por_nome` só aplica um vínculo já salvo, nunca inventa um por semelhança. Cada aplicação automática numa importação nova vira um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=este registro`), com um botão "Apagar vínculo" na aba Alterações que desfaz o valor lançado nessa linha E apaga este registro (pra não ser reaplicado numa importação seguinte) — ver ImportacaoPlanoSaudeAlteracaoViewSet.reverter. `codigo_empresa` fica cru aqui, sem normalizar via `empresas_questor.normalizar_codigo_empresa` — importar essa função dentro de models.py criaria um import circular (empresas_questor.py já importa `EmpresaQuestor` de models.py). A normalização acontece em views.py, no mesmo lugar que já normaliza pra outros usos (RegraCusteioPlanoSaude.codigo_empresa).""" operadora = models.CharField("Operadora", max_length=50) codigo_empresa = models.CharField("Código empresa", max_length=20) nome_arquivo_operadora = models.CharField( "Nome divergente (arquivo da operadora)", max_length=150, help_text="Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.", ) nome_func_destino = models.CharField("Nome do titular vinculado (planilha padrão)", max_length=150, blank=True) nome_dependente_destino = models.CharField( "Nome do dependente vinculado (planilha padrão)", max_length=150, blank=True ) criado_em = models.DateTimeField("Criado em", auto_now_add=True) criado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, related_name="vinculos_nome_plano_saude" ) class Meta: verbose_name = "Vínculo de nome (plano de saúde)" verbose_name_plural = "Vínculos de nome (plano de saúde)" ordering = ["-criado_em"] constraints = [ models.UniqueConstraint( fields=["operadora", "codigo_empresa", "nome_arquivo_operadora"], name="vinculo_nome_operadora_unico", ) ] def __str__(self) -> str: destino = self.nome_func_destino or self.nome_dependente_destino return f"{self.nome_arquivo_operadora} → {destino}" 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"), exclusão de linha (ver ImportacaoPlanoSaudeLinhaViewSet em views.py, que grava um registro aqui a cada uma dessas três operações) e vínculo de nome aplicado automaticamente a partir de um `VinculoNomeOperadora` já salvo (ver ImportacaoPlanoSaudeViewSet.create(), que grava um registro por `VinculoAplicado` devolvido pelo pipeline) 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 próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); só a reaplicação automática desse vínculo numa importação FUTURA vira um registro do tipo `TIPO_VINCULO_AUTOMATICO`.""" TIPO_EDICAO = "edicao" TIPO_INCLUSAO = "inclusao" TIPO_EXCLUSAO = "exclusao" TIPO_VINCULO_AUTOMATICO = "vinculo_automatico" TIPO_CHOICES = [ (TIPO_EDICAO, "Edição de valor"), (TIPO_INCLUSAO, "Inclusão de linha"), (TIPO_EXCLUSAO, "Exclusão de linha"), (TIPO_VINCULO_AUTOMATICO, "Vínculo automático de nome"), ] importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="alteracoes") tipo = models.CharField("Tipo", max_length=20, 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) # Só preenchido em TIPO_VINCULO_AUTOMATICO — o VinculoNomeOperadora que foi # aplicado automaticamente pra gerar esta linha; apagar o vínculo (botão # "Apagar vínculo" na aba Alterações, mesmo endpoint de reverter()) some # com este FK (SET_NULL) mas o registro de alteração em si continua. vinculo_nome = models.ForeignKey( VinculoNomeOperadora, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes" ) 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 " - " (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 ImportacaoPlanoSaudeDePaula(models.Model): """Mesma ferramenta de `ImportacaoPlanoSaude` acima, mas pro plano de saúde dos próprios colaboradores do escritório De Paula, não de clientes — ver "Importação de Plano de Saúde - De Paula" no CLAUDE.md do pacote `portal_api/planos_saude/`. Tabela própria, sem nenhuma FK cruzando com `ImportacaoPlanoSaude`: o pipeline de extração (`portal_api.planos_saude.*`) é o mesmo, só o histórico/permissão são independentes.""" 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) regra_empresa = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True) regra_custeio_salva = models.ForeignKey( "RegraCusteioPlanoSaudeDePaula", 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_de_paula/planilha_padrao/", max_length=255, blank=True, validators=[validar_tamanho_arquivo_plano_saude], ) competencia = models.DateField("Competência (Questor)", null=True, blank=True) 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_de_paula" ) 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 - De Paula" verbose_name_plural = "Importações de plano de saúde - De Paula" ordering = ["-criado_em"] def __str__(self) -> str: return f"{self.nome_operadora} ({self.criado_em:%d/%m/%Y})" class ImportacaoPlanoSaudeDePaulaArquivoOperadora(models.Model): """Ver `ImportacaoPlanoSaudeArquivoOperadora` — mesmo papel, escopo De Paula.""" importacao = models.ForeignKey( ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="arquivos_operadora" ) arquivo = models.FileField( "Arquivo", upload_to="planos_saude_de_paula/operadora/", max_length=255, validators=[validar_tamanho_arquivo_plano_saude], ) ordem = models.PositiveIntegerField("Ordem", default=0) criado_em = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "Arquivo da operadora (importação de plano de saúde - De Paula)" verbose_name_plural = "Arquivos da operadora (importação de plano de saúde - De Paula)" ordering = ["ordem", "id"] def __str__(self) -> str: return f"{self.importacao} — {self.arquivo.name}" class ImportacaoPlanoSaudeDePaulaLinha(models.Model): """Ver `ImportacaoPlanoSaudeLinha` — mesmo papel, escopo De Paula.""" importacao = models.ForeignKey( ImportacaoPlanoSaudeDePaula, 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 - De Paula" verbose_name_plural = "Linhas de importação de plano de saúde - De Paula" ordering = ["tipo_lancamento", "ordem", "id"] def __str__(self) -> str: return f"{self.importacao} → {self.nome_func}" class ImportacaoPlanoSaudeDePaulaAuditoria(models.Model): """Ver `ImportacaoPlanoSaudeAuditoria` — mesmo papel, escopo De Paula.""" MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO") importacao = models.ForeignKey( ImportacaoPlanoSaudeDePaula, 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( ImportacaoPlanoSaudeDePaulaLinha, 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 - De Paula" verbose_name_plural = "Itens de auditoria de importação de plano de saúde - De Paula" ordering = ["id"] class VinculoNomeOperadoraDePaula(models.Model): """Ver `VinculoNomeOperadora` — mesmo "DE/PARA" persistente, escopo De Paula. Tabela própria pra um vínculo resolvido no escopo cliente nunca vazar pro escopo De Paula (ou vice-versa) mesmo que operadora+código de empresa coincidam por acaso.""" operadora = models.CharField("Operadora", max_length=50) codigo_empresa = models.CharField("Código empresa", max_length=20) nome_arquivo_operadora = models.CharField( "Nome divergente (arquivo da operadora)", max_length=150, help_text="Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.", ) nome_func_destino = models.CharField("Nome do titular vinculado (planilha padrão)", max_length=150, blank=True) nome_dependente_destino = models.CharField( "Nome do dependente vinculado (planilha padrão)", max_length=150, blank=True ) criado_em = models.DateTimeField("Criado em", auto_now_add=True) criado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, related_name="vinculos_nome_plano_saude_de_paula" ) class Meta: verbose_name = "Vínculo de nome (plano de saúde - De Paula)" verbose_name_plural = "Vínculos de nome (plano de saúde - De Paula)" ordering = ["-criado_em"] constraints = [ models.UniqueConstraint( fields=["operadora", "codigo_empresa", "nome_arquivo_operadora"], name="vinculo_nome_operadora_de_paula_unico", ) ] def __str__(self) -> str: destino = self.nome_func_destino or self.nome_dependente_destino return f"{self.nome_arquivo_operadora} → {destino}" class ImportacaoPlanoSaudeDePaulaAlteracao(models.Model): """Ver `ImportacaoPlanoSaudeAlteracao` — mesmo log de edição/inclusão/ exclusão/vínculo automático, escopo De Paula.""" TIPO_EDICAO = "edicao" TIPO_INCLUSAO = "inclusao" TIPO_EXCLUSAO = "exclusao" TIPO_VINCULO_AUTOMATICO = "vinculo_automatico" TIPO_CHOICES = [ (TIPO_EDICAO, "Edição de valor"), (TIPO_INCLUSAO, "Inclusão de linha"), (TIPO_EXCLUSAO, "Exclusão de linha"), (TIPO_VINCULO_AUTOMATICO, "Vínculo automático de nome"), ] importacao = models.ForeignKey( ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="alteracoes" ) tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES) linha = models.ForeignKey( ImportacaoPlanoSaudeDePaulaLinha, 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) vinculo_nome = models.ForeignKey( VinculoNomeOperadoraDePaula, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes" ) usuario = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude_de_paula" ) 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 - De Paula" verbose_name_plural = "Alterações de importação de plano de saúde - De Paula" ordering = ["-criado_em"] def __str__(self) -> str: return f"{self.importacao} — {self.get_tipo_display()}" class RegraCusteioPlanoSaudeDePaula(models.Model): """Ver `RegraCusteioPlanoSaude` — mesmo cadastro de regra de custeio por empresa+operadora, escopo De Paula. Permissão própria (`PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")`).""" 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) 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_de_paula" ) 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 - De Paula" verbose_name_plural = "Regras de custeio de plano de saúde - De Paula" 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})" NAO_CONFORMIDADE_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024 def validar_tamanho_arquivo_nao_conformidade(arquivo: File) -> None: if arquivo.size > NAO_CONFORMIDADE_ARQUIVO_MAX_BYTES: raise ValidationError("O arquivo deve ter no máximo 15MB.") class NaoConformidadeImportacao(models.Model): """Uma execução da ferramenta "Não Conformidades" (Relatórios > Qualidade): o responsável pela Qualidade anexa os dois arquivos exportados do Sigsistem (ocorrências .xlsx + ações .xls) e o pipeline (`portal_api.nao_conformidades. pipeline.processa_importacao`) faz o upsert de `NCOcorrencia`/`NCAcao`/ `NCAcompanhamento` a partir daqui — ver `NaoConformidadeImportacaoViewSet. create()` em views.py. Este registro é só um LOG de auditoria de quando cada importação rodou: ao contrário de `ImportacaoPlanoSaude`, ele não é "dono" das ocorrências/ações (que são entidades contínuas, upsertadas por código) — apagar uma importação remove só o log e os 2 arquivos, nunca as ocorrências/ações já persistidas.""" arquivo_ocorrencias = models.FileField( "Arquivo de ocorrências (.xlsx)", upload_to="nao_conformidades/ocorrencias/", max_length=255, validators=[validar_tamanho_arquivo_nao_conformidade], ) arquivo_acoes = models.FileField( "Arquivo de ações (.xls / HTML)", upload_to="nao_conformidades/acoes/", max_length=255, validators=[validar_tamanho_arquivo_nao_conformidade], ) criado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_nao_conformidade" ) criado_em = models.DateTimeField(auto_now_add=True) # Contadores de novas/atualizadas/reabertas, formato livre — ver # `_aplica_upsert_nao_conformidades` em views.py. resumo = models.JSONField("Resumo do processamento", default=dict, blank=True) avisos = models.JSONField("Avisos do processamento", default=list, blank=True) class Meta: verbose_name = "Importação de Não Conformidades" verbose_name_plural = "Importações de Não Conformidades" ordering = ["-criado_em"] def __str__(self) -> str: return f"Importação de {self.criado_em:%d/%m/%Y %H:%M}" class NCOcorrencia(models.Model): """Uma ocorrência do Sigsistem (Não Conformidade, Reclamação de Cliente, Oportunidade de Melhoria etc.) — upsertada por `codigo` a cada nova importação (ver `NaoConformidadeImportacaoViewSet.create()`). Os campos espelham as colunas do .xlsx de ocorrências (ver `portal_api.nao_conformidades.leiaute_ocorrencias.COLUNAS_ESPERADAS`). `status_tratativa`/`snapshot_tratativa` são o controle interno de tratativa da Qualidade — não existe no Sigsistem. Quando marcada como tratada, `snapshot_tratativa` congela o estado relevante (via `nao_conformidades.diff.snapshot_ocorrencia`); numa importação futura, se o estado atual divergir do congelado (nova análise, análise alterada, ação nova), o item reabre sozinho (`reaberto_em`/`reaberto_motivo` preenchidos, `status_tratativa` volta a pendente) — a tratativa de fato (decidir a análise, abrir uma ação) acontece no Sigsistem, sistema externo sem integração; o Portal só monitora e reflete.""" STATUS_PENDENTE = "pendente" STATUS_TRATADO = "tratado" STATUS_CHOICES = [(STATUS_PENDENTE, "Pendente"), (STATUS_TRATADO, "Tratado")] codigo = models.PositiveIntegerField("Código da Ocorrência", unique=True) data_emissao = models.DateField("Data de Emissão", null=True, blank=True) # TextField, não CharField: exports reais já trouxeram "Assunto" com mais # de 390 caracteres (texto livre digitado no Sigsistem, sem limite # aparente) — ver amostra `projects/Controladoria - Analise NCs/`. assunto = models.TextField("Assunto", blank=True) tipo_ocorrencia = models.CharField("Tipo de Ocorrência", max_length=100, blank=True) pessoas_relacionadas = models.TextField("Pessoa(s) Relacionado(s)", blank=True) origem = models.CharField("Origem da Ocorrência", max_length=100, blank=True) fornecedores_relacionados = models.TextField("Fornecedor(es) Relacionado(s)", blank=True) clientes_relacionados = models.TextField("Cliente(s) Relacionado(s)", blank=True) area = models.CharField("Área", max_length=100, blank=True) setor = models.CharField("Setor", max_length=100, blank=True) riscos_relacionados = models.TextField("Risco(s) Relacionado(s)", blank=True) data_relato = models.DateField("Data do Relato", null=True, blank=True) relato = models.TextField("Relato", blank=True) emissor_relato = models.CharField("Emissor Relato", max_length=150, blank=True) representante_gerente = models.CharField("Representante / Gerente", max_length=150, blank=True) prazo_finalizar = models.DateField("Prazo para Finalizar a Ocorrência", null=True, blank=True) indicado_analise = models.CharField( "Indicado para Descrever Análise da Ocorrência", max_length=150, blank=True ) tipos_causa = models.TextField("Tipo(s) de Causa(s) da Ocorrência", blank=True) descricao_analise = models.TextField("Descrição da Análise da Ocorrência", blank=True) responsavel_analise = models.CharField( "Responsável pela Descrição da Análise", max_length=150, blank=True ) data_analise = models.DateField("Data da Análise da Ocorrência", null=True, blank=True) # True quando alguma linha do export trouxe análise preenchida numa linha # SEM código de ação (regra "Análise sem Ação" da skill original) — vem de # `OcorrenciaExtraida.tem_linha_analise_sem_acao`, checado por linha # durante o parsing (ver nao_conformidades/leiaute_ocorrencias.py), não # re-derivável de forma confiável só a partir de `descricao_analise` + # `acoes.count()` porque uma ocorrência pode ter linhas mistas. analise_sem_acao_detectada = models.BooleanField(default=False) # Só relevantes/preenchidos quando a ocorrência não tem nenhuma ação # (linha do export sem "Código da Ação") e o Sigsistem fecha a própria # ocorrência direto — ex.: tipo "Elogios de Clientes", que não exige # análise/ação nenhuma. Vêm das MESMAS colunas "Data de Finalização"/ # "Fase" que, numa linha COM ação, descrevem a ação (ver # NCAcao.data_finalizacao/fase) — o Sigsistem reaproveita as colunas # conforme o que a linha representa; ver leiaute_ocorrencias.py. data_finalizacao = models.DateField(null=True, blank=True) fase = models.CharField("Fase (Sigsistem)", max_length=100, blank=True) status_tratativa = models.CharField( "Status de tratativa", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE ) tratado_em = models.DateTimeField(null=True, blank=True) tratado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="ocorrencias_nc_tratadas" ) snapshot_tratativa = models.JSONField(null=True, blank=True) reaberto_em = models.DateTimeField(null=True, blank=True) reaberto_motivo = models.TextField(blank=True) ultima_importacao_em = models.DateTimeField(null=True, blank=True) class Meta: verbose_name = "Ocorrência (Não Conformidade)" verbose_name_plural = "Ocorrências (Não Conformidade)" ordering = ["-data_emissao", "-codigo"] def __str__(self) -> str: return f"Ocorrência {self.codigo} — {self.assunto}" class NCAcao(models.Model): """Uma ação (Correção ou Ação Corretiva) vinculada a uma `NCOcorrencia` — upsertada por `(ocorrencia, codigo)` a cada importação. `vencimento_efetivo` (Prazo Prorrogado, senão Data da Conclusão da Ação) e `ultimo_acompanhamento_em`/`ultimo_acompanhamento_eh_prorrogacao` só mudam com uma importação nova, por isso são seguros de cachear aqui — o status relativo a "hoje" (vencida/vence em breve) NUNCA é persistido, é sempre calculado em tempo de leitura via `portal_api.nao_conformidades.classificacao.status_prazo()` (ver NCAcaoSerializer em serializers.py).""" STATUS_PENDENTE = "pendente" STATUS_TRATADO = "tratado" STATUS_CHOICES = [(STATUS_PENDENTE, "Pendente"), (STATUS_TRATADO, "Tratado")] ocorrencia = models.ForeignKey(NCOcorrencia, on_delete=models.CASCADE, related_name="acoes") codigo = models.PositiveIntegerField("Código da Ação") data_emissao = models.DateField(null=True, blank=True) tipo_acao = models.CharField("Tipo de Ação", max_length=100, blank=True) acao_texto = models.TextField("Ação", blank=True) data_conclusao = models.DateField("Data da Conclusão da Ação", null=True, blank=True) prazo_prorrogado = models.DateField("Prazo Prorrogado", null=True, blank=True) justificativa_prorrogacao = models.TextField(blank=True) executor = models.CharField("Executor da Ação", max_length=150, blank=True) emissor_acao = models.CharField(max_length=150, blank=True) indicado_autorizar = models.CharField("Indicado para Autorizar/Aprovar", max_length=150, blank=True) responsavel_autorizacao = models.CharField(max_length=150, blank=True) data_finalizacao = models.DateField(null=True, blank=True) dias_finalizacao = models.IntegerField(null=True, blank=True) situacao = models.CharField("Situação (Sigsistem)", max_length=100, blank=True) fase = models.CharField("Fase (Sigsistem)", max_length=100, blank=True) vencimento_efetivo = models.DateField(null=True, blank=True) ultimo_acompanhamento_em = models.DateField(null=True, blank=True) ultimo_acompanhamento_eh_prorrogacao = models.BooleanField(default=False) status_tratativa = models.CharField( "Status de tratativa", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE ) tratado_em = models.DateTimeField(null=True, blank=True) tratado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="acoes_nc_tratadas" ) snapshot_tratativa = models.JSONField(null=True, blank=True) reaberto_em = models.DateTimeField(null=True, blank=True) reaberto_motivo = models.TextField(blank=True) ultima_importacao_em = models.DateTimeField(null=True, blank=True) class Meta: verbose_name = "Ação (Não Conformidade)" verbose_name_plural = "Ações (Não Conformidade)" ordering = ["vencimento_efetivo"] constraints = [ models.UniqueConstraint(fields=["ocorrencia", "codigo"], name="nc_acao_unica_por_ocorrencia") ] def __str__(self) -> str: return f"{self.ocorrencia.codigo}/{self.codigo}" class NCAcompanhamento(models.Model): """Uma entrada de "Relato(s) do Acompanhamento" de uma `NCAcao`, extraída do .xls (HTML) de ações — histórico completo (não só a última entrada, diferente da skill original que gerou o relatório estático). Append-only: `hash_entrada` (sha256 de data+autor+texto normalizado) deduplica entre importações repetidas, nunca apaga uma entrada já persistida.""" acao = models.ForeignKey(NCAcao, on_delete=models.CASCADE, related_name="acompanhamentos") data = models.DateField() autor = models.CharField(max_length=150, blank=True) texto = models.TextField() eh_prorrogacao = models.BooleanField(default=False) hash_entrada = models.CharField(max_length=64) criado_em = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "Acompanhamento (Não Conformidade)" verbose_name_plural = "Acompanhamentos (Não Conformidade)" ordering = ["data", "id"] constraints = [ models.UniqueConstraint(fields=["acao", "hash_entrada"], name="nc_acompanhamento_unico") ] def __str__(self) -> str: return f"{self.acao} — {self.data:%d/%m/%Y}" CONTABIL_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024 def validar_tamanho_arquivo_contabil(arquivo: File) -> None: if arquivo.size > CONTABIL_ARQUIVO_MAX_BYTES: raise ValidationError("O arquivo deve ter no máximo 15MB.") class ContabilApuracao(models.Model): """Uma análise do Relatório Contábil (Relatórios > Contabilidade): o contador anexa o PDF de Balancete + DRE (leiaute Questor, mesmo relatório hoje enviado ao cliente) e o pipeline (`portal_api.dashboard_contabil. pipeline.processa_apuracao`) extrai as contas/linhas da DRE e roda o motor de regras de auditoria (`dashboard_contabil.regras`) contra o histórico já processado desta mesma empresa — ver `ContabilApuracaoViewSet.create()` em views.py. `unique_together` evita duplicar sem querer a mesma competência de uma empresa (reprocessar exige excluir a apuração antiga primeiro).""" STATUS_REVISAO = "revisao" STATUS_CONCLUIDA = "concluida" STATUS_CHOICES = [ (STATUS_REVISAO, "Em revisão"), (STATUS_CONCLUIDA, "Concluída"), ] codigo_empresa = models.CharField("Código da empresa", max_length=20) nome_empresa = models.CharField("Nome da empresa", max_length=255) cnpj = models.CharField("CNPJ", max_length=20, blank=True) competencia = models.DateField("Competência") periodo_inicio = models.DateField("Início do período") periodo_fim = models.DateField("Fim do período") arquivo = models.FileField( "Arquivo do balancete (PDF)", upload_to="contabil/apuracoes/", max_length=255, validators=[validar_tamanho_arquivo_contabil], ) 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="apuracoes_contabeis") criado_em = models.DateTimeField(auto_now_add=True) concluida_em = models.DateTimeField("Concluída em", null=True, blank=True) # Chaves de IndicadoresFinanceiros (dashboard_contabil/indicadores.py, # ex. "roa"/"kanitz") que o contador optou por não incluir no relatório # "Gerar Dashboard" — ver aba "Dashboard" da tela de revisão. indicadores_ocultos = models.JSONField("Indicadores ocultos no relatório", default=list, blank=True) # Chaves de IndicadorContabilDefinicao com padrao=False que o contador # ativou especificamente pra esta apuração — um indicador não padrão # não aparece em nenhuma apuração até ser selecionado assim numa delas # (ver "Gerenciar Indicadores" na aba "Dashboard"). Indicador padrao=True # não precisa (nem deveria) aparecer aqui, já entra sempre. indicadores_selecionados = models.JSONField("Indicadores não padrão selecionados", default=list, blank=True) # Texto rico (HTML sanitizado por nh3, mesmo allowlist de # AcessoGeral.observacoes/AjudaAplicacao.texto — ver # RICHTEXT_ALLOWED_TAGS/_ATTRS/_SCHEMES em serializers.py) que o contador # escreve na aba "Dashboard" da revisão — considerações/análises livres # sobre o fechamento, aparece no relatório "Gerar Dashboard" antes dos # cards de indicador. Sanitizado em ContabilResumoFechamentoSerializer, # nunca aceito cru do request. resumo_fechamento = models.TextField( "Resumo do Fechamento", blank=True, validators=[validar_tamanho_resumo_fechamento_contabil] ) # Rótulos dos meses da seção "Demonstração Mensal (Análise Vertical)" do # PDF (ex.: ["mai/2026", "jun/2026", "jul/2026"]), na mesma ordem/posição # dos valores de cada ContabilLinhaAnaliseVertical.valores — compartilhado # aqui (não por linha) porque é sempre o mesmo conjunto de meses pra toda # a apuração. Lista vazia quando o PDF não tinha essa seção (relatório # mais antigo, ou empresa sem essa seção habilitada no Questor). analise_vertical_meses = models.JSONField("Meses da Análise Vertical", default=list, blank=True) # Detectado por `parser.extrai_balancete_dre()` (`fonte_pdf_atipica` no # `ResultadoExtracao`) e persistido por `create()`/`reprocessar()` — # marca quando o título de seção (DRE/Análise Vertical) só bateu depois # de remover acento (`_normaliza_titulo`), sinal de que este PDF usa uma # fonte diferente da do relatório de referência. Confirmado contra `1751 # - Balancete 07.2026.pdf`: nesse arquivo, o mesmo tipo de variação de # fonte também gruda palavras em algumas descrições de conta (ex. # "BANCÁRIOSA VISTA"), sem que exista um jeito confiável de corrigir # automaticamente (ver plano.md/CLAUDE.md do pacote) — o frontend usa # este campo só pra mostrar um aviso ao lado do nome da empresa, pedindo # atenção a esse risco, não pra bloquear nada. fonte_pdf_atipica = models.BooleanField("PDF de fonte atípica (risco de nomenclatura)", default=False) class Meta: verbose_name = "Apuração do Relatório Contábil" verbose_name_plural = "Apurações do Relatório Contábil" ordering = ["-competencia", "-criado_em"] constraints = [ models.UniqueConstraint(fields=["codigo_empresa", "competencia"], name="contabil_apuracao_unica") ] def __str__(self) -> str: return f"{self.codigo_empresa} — {self.competencia:%m/%Y}" class ContabilConta(models.Model): """Uma linha do Balancete extraída do PDF (grupo Ativo/Passivo, sintética ou analítica). `observacao` é livre e editável pelo contador via PATCH em qualquer conta, tenha ela gerado um `ContabilAchado` ou não — é o espaço de "análise" pedido pelo usuário, independente do que a auditoria automática encontrou.""" apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="contas") # `conta_numero` (a numeração interna do Questor, coluna "Conta" do PDF) # NÃO corresponde à ordem real de impressão do balancete — o Questor # reaproveita numeração de contas antigas/canceladas, então o mesmo # intervalo de números pode ter contas de Ativo e Passivo intercaladas. # `ordem` é a posição de leitura real do PDF (atribuída pela view na # criação, mesmo papel de ContabilLinhaDre.ordem) e é o que garante que # a árvore do Balancete (ver dashboard-contabil.js) não misture grupos. ordem = models.IntegerField("Ordem", default=0) conta_numero = models.IntegerField("Número da conta") codigo = models.CharField("Classificação", max_length=60) descricao = models.CharField("Descrição", max_length=255) tipo = models.CharField("Tipo", max_length=1) # "S" sintética | "A" analítica saldo_anterior = models.DecimalField(max_digits=16, decimal_places=2) debito = models.DecimalField(max_digits=16, decimal_places=2) credito = models.DecimalField(max_digits=16, decimal_places=2) saldo_atual = models.DecimalField(max_digits=16, decimal_places=2) # A observação do contador NÃO mora mais aqui — virou `ContabilObservacao` # (histórico por empresa+conta que atravessa competências, ver o model # abaixo). Os campos `observacao`/`oculta_no_relatorio` que existiam aqui # foram migrados e removidos na migração `0071`. # Checkbox de "já revisei esta conta" — puramente informativo (não afeta # achados/status/relatório), pedido explícito do usuário como um segundo # botão ao lado do de observação, pra marcar contas já conferidas durante # a revisão. validado = models.BooleanField("Validado pelo contador", default=False) # Marcado por `ContabilApuracaoViewSet.reprocessar()` quando o valor desta # conta mudou em relação à versão anterior (novo arquivo anexado pro # mesmo período/empresa) — reprocessar também força `validado=False` de # volta nesse caso (a conta precisa ser revisada de novo), e o frontend # mostra um alerta ao lado do ícone de observação enquanto este campo for # `True`. Marcar a conta como validada de novo NÃO limpa este campo # (pedido explícito do usuário) — o alerta continua visível, só muda de # cor, pra dar pra identificar depois quais itens já foram reprocessados # E revalidados; só um próximo reprocessamento sem mudança nesta conta # específica limpa de vez. Não afeta contas sem mudança nenhuma (essas # mantêm `validado` como estava, ver "Reprocessar" no CLAUDE.md do # pacote; observação nunca é afetada, mora em `ContabilObservacao`). alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False) # Cópia de `saldo_atual` de ANTES do reprocessamento que ligou # `alterada_reprocessamento` — só existe pra alimentar o tooltip do # badge de alerta no frontend ("valor antes do reprocessamento"), pedido # explícito do usuário. `None` enquanto `alterada_reprocessamento` for # `False` (nunca mudou, ou um reprocessamento seguinte não mudou de # novo — ver `_contabil_sincroniza_contas()` em views.py, que também é # quem grava este campo). valor_anterior_reprocessamento = models.DecimalField( "Saldo atual antes do último reprocessamento", max_digits=16, decimal_places=2, null=True, blank=True ) class Meta: verbose_name = "Conta do Balancete (Relatório Contábil)" verbose_name_plural = "Contas do Balancete (Relatório Contábil)" ordering = ["ordem"] def __str__(self) -> str: return f"{self.codigo} {self.descricao}" class ContabilLinhaDre(models.Model): """Uma linha da Demonstração do Resultado do Exercício extraída do PDF — sem código de classificação (o relatório da DRE não traz, diferente do Balancete), só descrição/nível/valor na ordem em que aparecem.""" apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="linhas_dre") ordem = models.IntegerField("Ordem") descricao = models.CharField("Descrição", max_length=255) nivel = models.IntegerField("Nível de indentação", default=0) valor = models.DecimalField(max_digits=16, decimal_places=2) totalizador = models.BooleanField("Linha totalizadora", default=False) # Observação virou `ContabilObservacao` — ver a nota em `ContabilConta`. # Mesmo espírito de ContabilConta.validado. validado = models.BooleanField("Validado pelo contador", default=False) # Mesmo espírito de ContabilConta.alterada_reprocessamento. alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False) # Mesmo espírito de ContabilConta.valor_anterior_reprocessamento, só que # cópia de `valor` (não tem saldo_atual/saldo_anterior separados aqui). valor_anterior_reprocessamento = models.DecimalField( "Valor antes do último reprocessamento", max_digits=16, decimal_places=2, null=True, blank=True ) class Meta: verbose_name = "Linha da DRE (Relatório Contábil)" verbose_name_plural = "Linhas da DRE (Relatório Contábil)" ordering = ["ordem"] def __str__(self) -> str: return f"{self.descricao}" class ContabilLinhaAnaliseVertical(models.Model): """Uma linha da seção "Demonstração Mensal (Análise Vertical)" do mesmo PDF — mesma árvore/descrição/nível da DRE (`ContabilLinhaDre`), só que com um valor+percentual por mês em vez de um valor único, por isso `valores` é uma lista (não um `DecimalField` só). Cada posição da lista corresponde à mesma posição em `ContabilApuracao.analise_vertical_meses` (ex.: `valores[0]` é o mês `analise_vertical_meses[0]`). Gravado como texto (não float), pra não perder precisão decimal em nenhuma conversão — `[{"valor": "1234.56", "percentual": "12.34"}, ...]`.""" apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="linhas_analise_vertical") ordem = models.IntegerField("Ordem") descricao = models.CharField("Descrição", max_length=255) nivel = models.IntegerField("Nível de indentação", default=0) totalizador = models.BooleanField("Linha totalizadora", default=False) valores = models.JSONField("Valores mensais", default=list) # Observação virou `ContabilObservacao` — ver a nota em `ContabilConta`. # Mesmo espírito de ContabilLinhaDre.validado. validado = models.BooleanField("Validado pelo contador", default=False) # Mesmo espírito de ContabilConta.alterada_reprocessamento. alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False) # Mesmo espírito de ContabilConta.valor_anterior_reprocessamento — aqui é # uma cópia da lista `valores` inteira (mesmo formato, um item por mês), # já que não existe um valor único nesta linha. valores_anterior_reprocessamento = models.JSONField("Valores mensais antes do último reprocessamento", default=list, blank=True) class Meta: verbose_name = "Linha da Análise Vertical (Relatório Contábil)" verbose_name_plural = "Linhas da Análise Vertical (Relatório Contábil)" ordering = ["ordem"] def __str__(self) -> str: return f"{self.descricao}" class ContabilAchado(models.Model): """Um achado gerado automaticamente pelo motor de regras (`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só muda de `status` conforme o contador revisa (mesmo espírito de `ImportacaoPlanoSaudeAuditoria`: histórico completo preservado). `conta` é opcional porque alguns achados são gerais (ex.: desbalanceamento Ativo x Passivo), sem uma única conta associada.""" SEVERIDADE_ALTA = "alta" SEVERIDADE_MEDIA = "media" SEVERIDADE_BAIXA = "baixa" SEVERIDADE_CHOICES = [ (SEVERIDADE_ALTA, "Alta"), (SEVERIDADE_MEDIA, "Média"), (SEVERIDADE_BAIXA, "Baixa"), ] STATUS_PENDENTE = "pendente" STATUS_TRATADO = "tratado" STATUS_IGNORADO = "ignorado" STATUS_CHOICES = [ (STATUS_PENDENTE, "Pendente"), (STATUS_TRATADO, "Tratado"), (STATUS_IGNORADO, "Ignorado"), ] apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="achados") conta = models.ForeignKey( ContabilConta, on_delete=models.SET_NULL, null=True, blank=True, related_name="achados" ) regra = models.CharField("Regra", max_length=60) severidade = models.CharField("Severidade", max_length=10, choices=SEVERIDADE_CHOICES) titulo = models.CharField("Título", max_length=255) mensagem = models.TextField("Mensagem") valor_referencia = models.DecimalField(max_digits=16, decimal_places=2, null=True, blank=True) status = models.CharField("Status", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE) observacao_contador = models.TextField("Observação do contador", blank=True) tratado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="achados_contabeis_tratados" ) tratado_em = models.DateTimeField("Tratado em", null=True, blank=True) # Mesmo espírito de ContabilConta.oculta_no_relatorio — só afeta a # seção "Observações" do relatório "Gerar Dashboard" (relevante só pra # achados com observacao_contador preenchida, que são os que aparecem # lá); não afeta o status/tratativa do achado em si. oculto_no_relatorio = models.BooleanField("Observação oculta no relatório", default=False) class Meta: verbose_name = "Achado do Relatório Contábil" verbose_name_plural = "Achados do Relatório Contábil" ordering = ["-severidade", "id"] def __str__(self) -> str: return f"{self.titulo} ({self.get_status_display()})" class ContabilObservacao(models.Model): """Uma observação escrita pelo contador sobre uma conta do Balancete, uma linha da DRE ou uma linha da Análise Vertical — **não** pertence à apuração, e sim à empresa + conta, atravessando competências (pedido explícito do usuário: "se eu fizer uma observação sobre o estoque do cliente... no mês seguinte este histórico deve aparecer, informando que fui eu quem fiz, no dia e data tal"). Substituiu os campos `observacao`/`oculta_no_relatorio` que viviam em `ContabilConta`/ `ContabilLinhaDre`/`ContabilLinhaAnaliseVertical` (migração `0071`), onde morriam junto com a competência. O alvo é guardado por **chave natural**, nunca por FK a uma linha de uma apuração específica (uma linha é recriada/ressincronizada a cada apuração/reprocessamento): `codigo` de classificação no Balancete e `"descricao|nivel"` na DRE/Análise Vertical — as mesmas chaves já usadas por `_contabil_sincroniza_*()` em views.py e pelo histórico de variação em `regras.py`. Efeito colateral bem-vindo: reprocessar uma apuração não toca em observação nenhuma, já que elas não moram mais na linha que é resincronizada. **Vigência** (ver `vigentes_para()`): aparece em toda apuração da mesma empresa com competência entre `competencia_origem` e `encerrada_em_competencia` (inclusive nas duas pontas; `None` = vigente pra sempre). "Manter o histórico" é o padrão (nada a fazer), "ocultar das próximas execuções" grava `encerrada_em_competencia` com a competência em que o contador encerrou, e "incluir uma nova observação" cria outro registro — nunca reescreve um já existente. **Imutabilidade**: `texto` só pode ser editado enquanto a apuração de origem estiver "Em revisão" (ver `ContabilObservacaoViewSet`); numa competência posterior a observação é histórica, somente leitura, com autor/data à vista. `mostrar_ao_cliente` é a exceção deliberada, sempre alternável (decisão confirmada com o usuário): o bloqueio protege texto, autor e data, mas mostrar ou não ao cliente é uma decisão editorial de cada relatório.""" ALVO_CONTA = "conta" ALVO_DRE = "dre" ALVO_ANALISE_VERTICAL = "analise_vertical" ALVO_CHOICES = [ (ALVO_CONTA, "Conta do Balancete"), (ALVO_DRE, "Linha da DRE"), (ALVO_ANALISE_VERTICAL, "Linha da Análise Vertical"), ] codigo_empresa = models.CharField("Código da empresa", max_length=20) alvo_tipo = models.CharField("Tipo do alvo", max_length=20, choices=ALVO_CHOICES) alvo_chave = models.CharField("Chave natural do alvo", max_length=320) # Descrição da conta/linha no momento em que a observação foi escrita — # só pra exibir o histórico quando aquela conta não existir mais na # apuração aberta (plano de contas mudou, linha saiu do relatório). alvo_rotulo = models.CharField("Rótulo do alvo", max_length=320, blank=True) # `SET_NULL` (não CASCADE) de propósito: o histórico é do cliente, não da # apuração — excluir a análise de um mês não pode apagar o que o contador # registrou sobre aquela conta. `competencia_origem` é uma cópia # justamente pra vigência continuar resolvendo sem a apuração original. apuracao_origem = models.ForeignKey( ContabilApuracao, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes" ) competencia_origem = models.DateField("Competência de origem") texto = models.TextField("Observação") # Substitui o antigo `oculta_no_relatorio` das linhas, com o sinal # invertido pra ficar igual ao rótulo que o contador vê no editor # ("Mostrar esta observação ao cliente no relatório"). Nasce desmarcado, # mesma decisão de sempre: uma observação nova só vai pro relatório do # cliente quando o contador confirmar explicitamente. mostrar_ao_cliente = models.BooleanField("Mostrar ao cliente no relatório", default=False) criado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes_contabeis" ) criado_em = models.DateTimeField(auto_now_add=True) atualizado_em = models.DateTimeField(auto_now=True) # "Ocultar das próximas execuções": a observação continua visível na # competência em que foi encerrada (e em todas as anteriores, o histórico # nunca é reescrito) e some a partir da seguinte. encerrada_em_competencia = models.DateField("Encerrada na competência", null=True, blank=True) encerrada_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes_contabeis_encerradas" ) encerrada_em = models.DateTimeField("Encerrada em", null=True, blank=True) class Meta: verbose_name = "Observação do Relatório Contábil" verbose_name_plural = "Observações do Relatório Contábil" ordering = ["competencia_origem", "criado_em", "id"] indexes = [ models.Index(fields=["codigo_empresa", "alvo_tipo", "alvo_chave"], name="contabil_obs_alvo_idx"), ] def __str__(self) -> str: return f"{self.codigo_empresa} {self.alvo_chave} ({self.competencia_origem:%m/%Y})" @staticmethod def chave_conta(codigo: str) -> str: """Chave natural de uma conta do Balancete — mesmo critério de `_contabil_sincroniza_contas()` (casa por `codigo` de classificação).""" return codigo @staticmethod def chave_linha(descricao: str, nivel: int) -> str: """Chave natural de uma linha da DRE/Análise Vertical — o par `(descricao, nivel)` de `_contabil_sincroniza_linhas_dre()`, que desambigua descrições repetidas em ramos diferentes da árvore.""" return f"{descricao}|{nivel}" @classmethod def vigentes_para(cls, codigo_empresa: str, competencia: date) -> models.QuerySet["ContabilObservacao"]: return cls.objects.filter( codigo_empresa=codigo_empresa, competencia_origem__lte=competencia ).filter(models.Q(encerrada_em_competencia__isnull=True) | models.Q(encerrada_em_competencia__gte=competencia)) def vigente_em(self, competencia: date) -> bool: if self.competencia_origem > competencia: return False return self.encerrada_em_competencia is None or self.encerrada_em_competencia >= competencia def eh_historica_em(self, competencia: date) -> bool: """Observação de uma competência anterior à que está aberta — texto, autor e data ficam travados (só dá pra encerrar ou responder com uma observação nova).""" return self.competencia_origem < competencia class ContabilObservacaoEdicao(models.Model): """Log de cada edição de texto de uma `ContabilObservacao` (pedido explícito do usuário, depois que o botão de excluir observação foi removido da tela — "evitar a ocultação de observações importantes" exige que uma edição de texto deixe rastro visível, não só sobrescreva). Um registro por `PATCH` que muda `texto` (nunca por `mostrar_ao_cliente`/ `encerrar`/`reativar`, que não mexem no conteúdo). `editada` (usada pro selo "Editada" na tela) é só `observacao.edicoes.exists()`, sem campo dedicado. Só visível dentro do Portal (`ContabilObservacaoSerializer`) — o relatório HTML gerado pro cliente (`dashboard()`) não usa esse serializer, então o selo/histórico nunca vaza pra lá.""" observacao = models.ForeignKey(ContabilObservacao, on_delete=models.CASCADE, related_name="edicoes") texto_anterior = models.TextField("Texto anterior") texto_novo = models.TextField("Texto novo") editado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="edicoes_observacoes_contabeis" ) editado_em = models.DateTimeField("Editado em", auto_now_add=True) class Meta: verbose_name = "Edição de observação do Relatório Contábil" verbose_name_plural = "Edições de observação do Relatório Contábil" ordering = ["-editado_em", "-id"] def __str__(self) -> str: return f"Edição de {self.observacao_id} em {self.editado_em:%d/%m/%Y %H:%M}" class IndicadorContabilDefinicao(models.Model): """Um indicador financeiro do Relatório Contábil — todo indicador é um registro aqui agora, inclusive os antigos 11 "de sistema" (ROA/ROE/ Kanitz/EBIT/EBITDA/Liquidez.../Composição.../Grau.../IPL), migrados pra cá numa rodada posterior à criação deste modelo (a pedido explícito do usuário, depois de conferir cada um contra uma apuração real — ver CLAUDE.md do pacote, "Migração dos 11 indicadores de sistema"). O cálculo Python fixo que existia antes (`dashboard_contabil.indicadores. calcula_indicadores`) foi mantido no código só como referência/ auditoria, não é mais chamado pela aplicação. `formula` é uma expressão aritmética (+ - * / e parênteses) sobre as `chave`s dos `componentes` abaixo e/ou de outro indicador (por chave) — avaliada por `dashboard_contabil.formula.avalia_formula` (interpretador restrito via `ast`, nunca `eval()` puro). Se qualquer componente referenciado ficar indisponível (ex.: variação sem apuração anterior) ou a fórmula dividir por zero, o resultado é `None` ("indisponível"), nunca 0.""" FORMATO_MOEDA = "moeda" FORMATO_PERCENTUAL = "percentual" FORMATO_INDICE = "indice" FORMATO_CHOICES = [ (FORMATO_MOEDA, "Moeda (R$)"), (FORMATO_PERCENTUAL, "Percentual (%)"), (FORMATO_INDICE, "Índice"), ] # Ícone do card no relatório "Gerar Dashboard" — só uma chave curta aqui; # o desenho (SVG) de cada opção fica em duas cópias mantidas em sincronia # "à mão", nunca geradas uma a partir da outra: `_CONTABIL_ICONES_SVG` # (portal_api/views.py, monta o card do relatório) e # `PID_DC_INDICADOR_ICONES` (static/js/dashboard-contabil.js, desenha o # seletor de ícone no modal de cadastro). Essa duplicação é proposital, # mesmo espírito de outras duplicações entre o relatório autocontido e o # resto do Portal (ver "Origem do arquivo" em CLAUDE.md sobre a intro do # login) — o relatório é HTML puro servido pelo Django, sem acesso ao JS # do app, e a tela de cadastro é só JS/HTML estático (sem contexto de # servidor), então não dá pra ter uma fonte única sem inventar mais uma # ida ao backend só pra isso. ICONE_BARRAS = "barras" ICONE_TENDENCIA_ALTA = "tendencia-alta" ICONE_TENDENCIA_BAIXA = "tendencia-baixa" ICONE_PERCENTUAL = "percentual" ICONE_PIZZA = "pizza" ICONE_ATIVIDADE = "atividade" ICONE_MOEDA = "moeda" ICONE_ALVO = "alvo" ICONE_CAMADAS = "camadas" ICONE_CARTAO = "cartao" ICONE_SELO = "selo" ICONE_CHOICES = [ (ICONE_BARRAS, "Barras"), (ICONE_TENDENCIA_ALTA, "Tendência de alta"), (ICONE_TENDENCIA_BAIXA, "Tendência de baixa"), (ICONE_PERCENTUAL, "Percentual"), (ICONE_PIZZA, "Gráfico de pizza"), (ICONE_ATIVIDADE, "Atividade"), (ICONE_MOEDA, "Cifrão"), (ICONE_ALVO, "Alvo/meta"), (ICONE_CAMADAS, "Camadas"), (ICONE_CARTAO, "Cartão"), (ICONE_SELO, "Selo/prêmio"), ] chave = models.SlugField("Chave", max_length=60, unique=True) nome = models.CharField("Nome", max_length=120) descricao = models.TextField("Descrição", blank=True) formula = models.CharField("Fórmula (cálculo)", max_length=500) # Texto livre, independente de `formula` — pedido explícito do usuário # depois de ver a fórmula técnica (`resultado_liquido - despesas_financeiras`, # variáveis internas em snake_case) aparecendo pro cliente no relatório # "Gerar Dashboard". Em branco (padrão pra todo indicador já cadastrado # antes deste campo existir) cai pra `formula` mesmo, ver # `_contabil_monta_cards_indicadores()` em views.py — nunca fica # sem nenhuma fórmula exibida, só sem a versão "bonita" até alguém # preencher pela tela. formula_exibicao = models.CharField("Fórmula (como aparece ao cliente)", max_length=300, blank=True) formato = models.CharField("Formato de exibição", max_length=12, choices=FORMATO_CHOICES, default=FORMATO_INDICE) icone = models.CharField("Ícone", max_length=20, choices=ICONE_CHOICES, default=ICONE_BARRAS) # Livre, só pra agrupar visualmente no relatório/aba "Dashboard" (ver # `indicadores_personalizados`/`renderDashboardIndicadores()`) — não # exposto no formulário de criar/editar (sempre "Indicadores # Personalizados" pra quem é criado pela tela); só os indicadores # migrados dos antigos "de sistema" (ROA/ROE/Kanitz/...) têm um grupo # diferente, setado uma vez na migração de dados, nunca reeditável pela # tela. grupo = models.CharField("Grupo de exibição", max_length=120, default="Indicadores Personalizados") # True (default) = aparece automaticamente em toda apuração. False = # fica salvo/editável normalmente, mas só entra numa apuração # específica se o contador selecionar ali (ver # ContabilApuracao.indicadores_selecionados) — pra indicador que só faz # sentido pra um cliente/situação específica, não pra todo mundo. padrao = models.BooleanField("Indicador padrão (aparece em todas as apurações)", default=True) criado_por = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="indicadores_contabeis_criados" ) criado_em = models.DateTimeField(auto_now_add=True) atualizado_em = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Indicador do Relatório Contábil (personalizado)" verbose_name_plural = "Indicadores do Relatório Contábil (personalizados)" ordering = ["nome"] def __str__(self) -> str: return self.nome class IndicadorContabilComponente(models.Model): """Uma peça usada na fórmula de um `IndicadorContabilDefinicao` — seu `chave` é o nome usado dentro da expressão (`formula`). Contas/linhas da DRE são guardadas por **código de classificação**/**descrição**, não por FK a uma linha de uma apuração específica — a definição é genérica, reaplicada a cada apuração/empresa que o relatório for gerado, e apurações diferentes têm suas próprias linhas com os mesmos códigos/ descrições (quando a empresa usa o mesmo plano de contas).""" TIPO_CONTAS = "contas" TIPO_LINHA_DRE = "linha_dre" TIPO_VARIACAO_CONTA = "variacao_conta" TIPO_INDICADOR = "indicador" TIPO_RESULTADO_LIQUIDO = "resultado_liquido" TIPO_CHOICES = [ (TIPO_CONTAS, "Soma de contas do Balancete"), (TIPO_LINHA_DRE, "Soma de linhas da DRE"), (TIPO_VARIACAO_CONTA, "Variação de contas entre apurações (atual − anterior)"), (TIPO_INDICADOR, "Referência a outro indicador"), (TIPO_RESULTADO_LIQUIDO, "Resultado Líquido da DRE (última linha)"), ] definicao = models.ForeignKey(IndicadorContabilDefinicao, on_delete=models.CASCADE, related_name="componentes") # Precisa ser um identificador Python válido (letras minúsculas, dígitos # e "_", nunca começando por dígito) — é usado como nome de variável na # árvore `ast` de `formula.avalia_formula()`, não um slug qualquer (que # aceitaria hífen, inválido como nome de variável). chave = models.CharField( "Chave", max_length=40, validators=[ RegexValidator( r"^[a-z][a-z0-9_]*$", "Use só letras minúsculas, números e '_', começando por uma letra (é usada na fórmula).", ) ], ) tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES) # Usados só quando tipo é "contas"/"variacao_conta": lista de # ContabilConta.codigo (ex. ["1.01.08", "1.01.08.001"]) — somados em # valor absoluto, mesma convenção de `indicadores._saldo()` (Passivo/PL # vêm negativos no relatório). contas_codigos = models.JSONField("Códigos de conta", default=list, blank=True) # Usado só quando tipo é "linha_dre": lista de ContabilLinhaDre.descricao # (texto exato) — somadas com o sinal já impresso no relatório (DRE não # segue a convenção de valor absoluto do Balancete). linhas_dre_descricoes = models.JSONField("Descrições de linha da DRE", default=list, blank=True) # Usado só quando tipo é "indicador": chave de outro # IndicadorContabilDefinicao já cadastrado. indicador_referenciado = models.CharField("Indicador referenciado", max_length=60, blank=True) # tipo="resultado_liquido" não usa nenhum dos 3 campos acima — resolve # sempre pra `linhas_dre[-1].valor` (posição, não texto). Necessário # porque a última linha da DRE muda de rótulo conforme o resultado é # positivo ou negativo ("(=) LUCRO LÍQUIDO DO EXERCÍCIO" vs "(=) # PREJUÍZO LÍQUIDO DO EXERCÍCIO", confirmado contra balancete real) — # um componente tipo="linha_dre" (que casa por texto exato) quebraria # assim que o resultado da empresa virasse de sinal entre uma apuração # e outra. class Meta: verbose_name = "Componente de indicador (Relatório Contábil)" verbose_name_plural = "Componentes de indicador (Relatório Contábil)" ordering = ["id"] constraints = [ models.UniqueConstraint(fields=["definicao", "chave"], name="contabil_componente_chave_unica") ] def __str__(self) -> str: return f"{self.chave} ({self.get_tipo_display()})"