portal_publico/portal_api/models.py

2639 lines
130 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import re
from datetime import date, datetime, time, timedelta
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.core.files.base import File
from django.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:
# { "<moduleKey>": { "enabled": bool, "apps": { "<appKey>": bool } } }
permissoes = models.JSONField("Permissões", default=dict, blank=True)
criado_em = models.DateTimeField(auto_now_add=True)
atualizado_em = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Perfil de acesso"
verbose_name_plural = "Perfis de acesso"
ordering = ["codigo"]
def __str__(self) -> str:
return self.nome
class Departamento(models.Model):
nome = models.CharField("Nome", max_length=100, unique=True)
criado_em = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Departamento"
verbose_name_plural = "Departamentos"
ordering = ["nome"]
def __str__(self) -> str:
return self.nome
class CategoriaEvento(models.Model):
"""Cadastro de tipos de evento do Calendário Individual (Reunião, Treinamento
etc.) — não é uma lista fixa no código porque quem tem a permissão de criar
evento de departamento/todos também cadastra categorias novas pela própria
tela (ver `calendario-individual-criar-evento` em catalogo.py)."""
nome = models.CharField("Nome", max_length=60, unique=True)
cor = models.CharField("Cor", max_length=7, validators=[validar_cor_categoria_evento])
class Meta:
verbose_name = "Categoria de evento"
verbose_name_plural = "Categorias de evento"
ordering = ["nome"]
def __str__(self) -> str:
return self.nome
class Usuario(AbstractUser):
nome = models.CharField("Nome", max_length=150, blank=True)
perfis = models.ManyToManyField(
PerfilAcesso, related_name="usuarios", blank=True, verbose_name="Perfis de acesso"
)
departamentos = models.ManyToManyField(
Departamento, related_name="usuarios", blank=True, verbose_name="Departamentos"
)
codigo_folha = models.CharField("Código da Folha", max_length=50, blank=True)
codigo_questor = models.CharField("Código do Questor", max_length=50, blank=True)
codigo_tareffa = models.CharField("Código do Tareffa", max_length=50, blank=True)
codigo_contabit = models.CharField("Código do Contabit", max_length=50, blank=True)
ramal = models.CharField("Ramal", max_length=20, blank=True)
data_aniversario = models.DateField("Data de aniversário", null=True, blank=True)
lideranca = models.BooleanField("É gerente ou coordenador", default=False)
liderados = models.ManyToManyField(
"self", symmetrical=False, related_name="lideres", blank=True, verbose_name="Liderados"
)
def __str__(self) -> str:
return self.nome or self.username
def gerencia_permissoes(self) -> bool:
return self.perfis.filter(gerencia_permissoes=True).exists()
def permissao_app(self, module_key: str, app_key: str) -> bool:
"""União (`any`) do flag `apps[app_key]` de `module_key` entre todos os perfis
vinculados — mesma lógica de `permissoes_efetivas()` (views.py), mas pra checar
uma única chave sem montar o payload inteiro. Usado por permissões de escrita
que precisam de um nível de acesso específico (ex.: "editar" em Links &
Ferramentas), não só "o módulo está habilitado"."""
return any(p.permissoes.get(module_key, {}).get("apps", {}).get(app_key) for p in self.perfis.all())
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` — `<div contenteditable>` 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)
tipo_pessoa = models.CharField(
"Tipo de pessoa (T/D/A)",
max_length=1,
blank=True,
help_text=(
"Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da "
"operadora — usado só por regras de empresa que precisam distinguir "
"dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema."
),
)
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
"<codigo_empresa> - <nome da operadora>" (cuidado: o "código" dentro do
label de `pipeline.OPERADORAS` é o código de cadastro da OPERADORA no
Questor, não o `codigo_empresa` do cliente — são códigos diferentes,
não confundir ao compor o nome)."""
nome = models.CharField("Nome", max_length=100)
codigo_empresa = models.CharField("Código da empresa", max_length=20)
operadora = models.CharField("Operadora", max_length=50)
# Quando preenchida, indica que o tipo de lançamento "mensalidade" desta
# regra usa o algoritmo especial por família de
# `planos_saude.regras_empresa.REGRAS_EMPRESA[chave]` em vez do custeio
# manual titular/dependente — unifica o antigo checkbox "Regra empresa"
# (que era um caminho paralelo na tela de Nova Importação) dentro do
# cadastro por empresa+operadora.
regra_empresa_chave = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True)
tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
observacoes = models.TextField("Observações", blank=True)
criado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, related_name="regras_custeio_plano_saude"
)
criado_em = models.DateTimeField(auto_now_add=True)
atualizado_em = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Regra de custeio de plano de saúde"
verbose_name_plural = "Regras de custeio de plano de saúde"
unique_together = [["codigo_empresa", "operadora"]]
ordering = [Length("codigo_empresa"), "codigo_empresa", "operadora"]
def __str__(self) -> str:
return self.nome
class 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)
tipo_pessoa = models.CharField(
"Tipo de pessoa (T/D/A)",
max_length=1,
blank=True,
help_text=(
"Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da "
"operadora — usado só por regras de empresa que precisam distinguir "
"dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema."
),
)
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 ContabilApuracaoReprocessamento(models.Model):
"""Log de cada reprocessamento de uma `ContabilApuracao` (pedido explícito
do usuário — "identificar quem reprocessou e quantos reprocessamentos já
ocorreu"), mesmo espírito de `ContabilObservacaoEdicao`. Um registro por
chamada bem-sucedida de `ContabilApuracaoViewSet.reprocessar()` — criado
dentro da mesma transação da resincronização, então uma tentativa que
falhar no meio do caminho não deixa um registro órfão aqui."""
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="reprocessamentos")
reprocessado_por = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="reprocessamentos_contabeis"
)
reprocessado_em = models.DateTimeField("Reprocessado em", auto_now_add=True)
class Meta:
verbose_name = "Reprocessamento do Relatório Contábil"
verbose_name_plural = "Reprocessamentos do Relatório Contábil"
ordering = ["-reprocessado_em", "-id"]
def __str__(self) -> str:
return f"Reprocessamento de {self.apuracao_id} em {self.reprocessado_em:%d/%m/%Y %H:%M}"
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, só muda de `status`
conforme o contador revisa (mesmo espírito de `ImportacaoPlanoSaudeAuditoria`).
`conta`/`linha_analise_vertical` são opcionais porque alguns achados são
gerais (ex.: desbalanceamento Ativo x Passivo), sem uma única conta/linha
associada; nunca os dois preenchidos ao mesmo tempo no achado (cada regra
referencia só um dos dois, ver `regras.py`).
**Exceção a "nunca é apagado"**: um reprocessamento (`ContabilApuracaoViewSet.
reprocessar()`/`_contabil_recria_achados()` em views.py) apaga **todos**
os achados da apuração e recria do zero a partir das regras rodadas sobre
o PDF novo — decisão explícita do usuário, pra um apontamento que não
dispara mais sumir da aba Observações em vez de ficar pendurado "tratado"
(ver CHANGELOG.md do pacote `dashboard_contabil`). Fora desse fluxo (ex.:
`ContabilAchadoViewSet.update()`/`alternar_oculto()`), o registro nunca é
apagado, só muda de campo."""
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"
)
# Preenchido só por achados sem uma conta do Balancete por trás (hoje, só
# `regra_variacao_atipica_dre`, que lê a Análise Vertical) — mesmo
# espírito de `conta` acima, alimenta o botão "Ver na tabela" da tela de
# revisão (navega até a Análise Vertical e destaca esta linha em vez do
# Balancete). Casado por `ordem` na view, ver `AchadoDetectado.
# ordem_linha_analise_vertical`/`_contabil_recria_achados()`.
linha_analise_vertical = models.ForeignKey(
ContabilLinhaAnaliseVertical, 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|descricao"` no Balancete (ver
`chave_conta()` — `codigo` de classificação sozinho não é único, ver
abaixo) 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, descricao: str) -> str:
"""Chave natural de uma conta do Balancete — o par `(codigo, descricao)`,
mesmo critério de `_contabil_sincroniza_contas()`. `codigo` sozinho
**não** é único: o Questor reaproveita a mesma classificação pra
várias contas analíticas de mesma natureza (ex. bancos diferentes,
todos sob o código de "Depósitos Bancários à Vista" — confirmado
contra um balancete real com 6 bancos distintos sob o mesmo código),
então usar só `codigo` fazia uma observação escrita numa conta
"vazar" pra todas as outras que compartilham a classificação.
`descricao` desambigua, mesmo espírito de `chave_linha()` pra
DRE/Análise Vertical."""
return f"{codigo}|{descricao}"
@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()})"