2862 lines
142 KiB
Python
2862 lines
142 KiB
Python
import re
|
||
from datetime import date, datetime, time, timedelta
|
||
|
||
from django.conf import settings
|
||
from django.contrib.auth.models import AbstractUser
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.files.base import File
|
||
from django.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
|
||
from .dashboard_contabil import chaves as chaves_contabil
|
||
|
||
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)
|
||
# Sistema que gerou o PDF, detectado pelo parser (nunca informado no
|
||
# upload) — ver `dashboard_contabil.modelos.LEIAUTE_*`. Muda os códigos
|
||
# fixos das regras, a convenção de sinal e se a apuração mostra
|
||
# indicadores (`tem_indicadores`).
|
||
LEIAUTE_QUESTOR = "questor"
|
||
LEIAUTE_CONTABIT = "contabit"
|
||
LEIAUTE_CHOICES = [
|
||
(LEIAUTE_QUESTOR, "Questor"),
|
||
(LEIAUTE_CONTABIT, "Contabit"),
|
||
]
|
||
# `db_default` (não só `default`): o banco é compartilhado com a produção,
|
||
# e um processo ainda rodando o código anterior a este campo não o envia
|
||
# no INSERT — o próprio Postgres preenche.
|
||
leiaute = models.CharField("Modelo do arquivo", max_length=20, choices=LEIAUTE_CHOICES, db_default=LEIAUTE_QUESTOR)
|
||
|
||
@property
|
||
def tem_indicadores(self) -> bool:
|
||
"""Empresas do Contabit não usam indicadores por enquanto (decisão
|
||
explícita do usuário): os indicadores cadastrados são calibrados no
|
||
plano de contas do Questor e sairiam errados. A seção some da aba
|
||
"Dashboard", do relatório e do PDF do Resumo."""
|
||
return self.leiaute != self.LEIAUTE_CONTABIT
|
||
|
||
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)
|
||
# Nulo só no leiaute Contabit, onde a conta sintética não tem número.
|
||
conta_numero = models.IntegerField("Número da conta", null=True, blank=True)
|
||
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)"
|
||
# `id` desempata: `ordem` sozinha deixa a ordenação indefinida quando
|
||
# duas linhas empatam, e a árvore (nível de cada linha em relação à
|
||
# anterior) e a chave natural da DRE/Análise Vertical dependem da
|
||
# ordem de leitura. Empate não deveria acontecer, mas quando acontece
|
||
# o resultado precisa ser o mesmo em toda consulta.
|
||
ordering = ["ordem", "id"]
|
||
|
||
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)"
|
||
# Ver a nota em `ContabilConta.Meta`.
|
||
ordering = ["ordem", "id"]
|
||
|
||
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)"
|
||
# Ver a nota em `ContabilConta.Meta`.
|
||
ordering = ["ordem", "id"]
|
||
|
||
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 `"caminho na árvore|nivel"` na DRE/Análise Vertical (ver
|
||
`chave_linha()`) — 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)
|
||
# 320 bastava quando a chave da DRE era `descricao|nivel`; com o caminho
|
||
# na árvore inteiro (ver `chave_linha()`) uma linha funda soma a descrição
|
||
# de todos os grupos acima dela, então o limite subiu com folga. Não pode
|
||
# truncar: chave truncada volta a confundir duas linhas diferentes, que é
|
||
# exatamente o defeito que o caminho veio corrigir.
|
||
alvo_chave = models.CharField("Chave natural do alvo", max_length=1000)
|
||
# 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 chaves_contabil.chave_conta(codigo, descricao)
|
||
|
||
@staticmethod
|
||
def chave_linha(caminho: str, nivel: int) -> str:
|
||
"""Chave natural de uma linha da DRE/Análise Vertical, a partir do
|
||
**caminho na árvore** (`chaves_contabil.caminhos_linhas()`), não da
|
||
descrição isolada: o mesmo rótulo aparece em ramos diferentes no
|
||
mesmo nível (caso real: "DESPESAS COM PESSOAL" sob "DESPESAS DE
|
||
VENDAS" e sob "DESPESAS ADMINISTRATIVAS"). Como a chave nunca é
|
||
calculável a partir de uma linha isolada, o chamador normalmente usa
|
||
`chaves_contabil.chaves_linhas(linhas)` sobre a lista inteira."""
|
||
return chaves_contabil.chave_linha(caminho, 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()})"
|
||
|
||
|
||
# --- Conciliação de Fornecedores (Utilitários) -----------------------------
|
||
# Ver portal_api/conciliacao_fornecedores/CLAUDE.md. Cabeçalho → contas →
|
||
# lançamentos, com os vínculos (automáticos ou manuais) como registro
|
||
# próprio. Status/alertas de cada conta não são gravados: são recalculados a
|
||
# cada exibição (`conciliacao_fornecedores.alertas`), já que dependem dos
|
||
# vínculos atuais, que o contador pode desfazer ou criar.
|
||
|
||
CONCILIACAO_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||
|
||
|
||
def validar_tamanho_arquivo_conciliacao(arquivo: File) -> None:
|
||
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
|
||
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||
|
||
|
||
class ConciliacaoFornecedor(models.Model):
|
||
"""Uma conciliação do razão de fornecedores de uma empresa: o contador
|
||
informa o código da empresa (nome resolvido no Questor) e anexa a
|
||
exportação do razão (.xlsx ou .csv). Sem `unique_together`: a mesma
|
||
empresa pode ser conciliada várias vezes (meses diferentes, ou de novo
|
||
depois de corrigir lançamentos no Questor)."""
|
||
|
||
codigo_empresa = models.CharField("Código da empresa", max_length=20)
|
||
nome_empresa = models.CharField("Nome da empresa", max_length=255, blank=True)
|
||
data_base = models.DateField("Data-base da análise")
|
||
periodo_inicio = models.DateField("Início do período", null=True, blank=True)
|
||
periodo_fim = models.DateField("Fim do período", null=True, blank=True)
|
||
arquivo = models.FileField(
|
||
"Arquivo do razão",
|
||
upload_to="conciliacao_fornecedores/",
|
||
max_length=255,
|
||
validators=[validar_tamanho_arquivo_conciliacao],
|
||
)
|
||
nome_arquivo = models.CharField("Nome do arquivo original", max_length=255, blank=True)
|
||
tolerancia_valor = models.DecimalField("Tolerância (R$)", max_digits=12, decimal_places=2)
|
||
tolerancia_percentual = models.DecimalField("Tolerância (%)", max_digits=5, decimal_places=2)
|
||
# Totais por status/valores, recalculados a cada criação e a cada ajuste
|
||
# manual (`_conciliacao_atualiza_resumo()` em views.py). Só existe para a
|
||
# listagem do histórico não precisar reanalisar todas as contas de
|
||
# todas as conciliações; a tela de detalhe usa a análise fresca.
|
||
resumo = models.JSONField("Resumo", default=dict, blank=True)
|
||
criado_por = models.ForeignKey(
|
||
Usuario, on_delete=models.SET_NULL, null=True, related_name="conciliacoes_fornecedores"
|
||
)
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Conciliação de fornecedores"
|
||
verbose_name_plural = "Conciliações de fornecedores"
|
||
ordering = ["-criado_em", "-id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.codigo_empresa} - {self.nome_empresa} ({self.data_base:%d/%m/%Y})"
|
||
|
||
|
||
class ConciliacaoFornecedorConta(models.Model):
|
||
"""Uma conta de fornecedor do razão. `saldo_inicial`/`saldo_final`
|
||
seguem a convenção credor positivo (valor devido ao fornecedor)."""
|
||
|
||
conciliacao = models.ForeignKey(ConciliacaoFornecedor, on_delete=models.CASCADE, related_name="contas")
|
||
ordem = models.PositiveIntegerField("Ordem no arquivo")
|
||
conta_numero = models.CharField("Conta (reduzido)", max_length=20)
|
||
classificacao = models.CharField("Classificação", max_length=40)
|
||
fornecedor = models.CharField("Fornecedor", max_length=255)
|
||
saldo_inicial = models.DecimalField("Saldo inicial", max_digits=16, decimal_places=2)
|
||
saldo_final = models.DecimalField("Saldo final (recalculado)", max_digits=16, decimal_places=2)
|
||
saldo_final_arquivo = models.DecimalField("Saldo final (arquivo)", max_digits=16, decimal_places=2)
|
||
validada = models.BooleanField("Validada pelo contador", default=False)
|
||
validada_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||
validada_em = models.DateTimeField("Validada em", null=True, blank=True)
|
||
observacao = models.TextField("Observação do contador", blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Conta (Conciliação de fornecedores)"
|
||
verbose_name_plural = "Contas (Conciliação de fornecedores)"
|
||
ordering = ["ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.conta_numero} - {self.fornecedor}"
|
||
|
||
|
||
class ConciliacaoFornecedorVinculo(models.Model):
|
||
"""Um conjunto de lançamentos da mesma conta que se compensam.
|
||
`diferenca` = soma dos créditos - soma dos débitos (zero nos vínculos
|
||
exatos). Excluir um vínculo devolve os lançamentos para pendente
|
||
(`SET_NULL` em `ConciliacaoFornecedorLancamento.vinculo`)."""
|
||
|
||
TIPO_CHOICES = [
|
||
("exato", "Valor exato"),
|
||
("combinacao", "Combinação de valores"),
|
||
("sequencia", "Quitação dos títulos mais antigos"),
|
||
("saldo_zerado", "Compensação até saldo zerado"),
|
||
("tolerancia", "Com diferença (juros/desconto)"),
|
||
("manual", "Manual"),
|
||
("parcial", "Pagamento parcial"),
|
||
]
|
||
|
||
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="vinculos")
|
||
tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES)
|
||
diferenca = models.DecimalField("Diferença", max_digits=16, decimal_places=2, default=0)
|
||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Vínculo (Conciliação de fornecedores)"
|
||
verbose_name_plural = "Vínculos (Conciliação de fornecedores)"
|
||
ordering = ["id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.get_tipo_display()} #{self.pk}"
|
||
|
||
|
||
class ConciliacaoFornecedorLancamento(models.Model):
|
||
"""Um lançamento do razão, ou o saldo anterior da conta
|
||
(`eh_saldo_anterior`, só quando diferente de zero — entra na
|
||
conciliação como um título/pagamento a mais, sem detalhe de
|
||
composição). `valor` sempre positivo; `natureza` diz o lado."""
|
||
|
||
NATUREZA_CHOICES = [("D", "Débito"), ("C", "Crédito")]
|
||
|
||
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="lancamentos")
|
||
ordem = models.PositiveIntegerField("Ordem na conta")
|
||
eh_saldo_anterior = models.BooleanField("Saldo anterior", default=False)
|
||
data = models.DateField("Data")
|
||
sequencia = models.CharField("Seqüência", max_length=20, blank=True)
|
||
historico = models.CharField("Histórico", max_length=500, blank=True)
|
||
contrapartida_codigo = models.CharField("Contrapartida", max_length=20, blank=True)
|
||
contrapartida_descricao = models.CharField("Descrição da contrapartida", max_length=255, blank=True)
|
||
natureza = models.CharField("Natureza", max_length=1, choices=NATUREZA_CHOICES)
|
||
valor = models.DecimalField("Valor", max_digits=16, decimal_places=2)
|
||
saldo_apos = models.DecimalField("Saldo após o lançamento", max_digits=16, decimal_places=2, null=True, blank=True)
|
||
usuario = models.CharField("Usuário (Questor)", max_length=100, blank=True)
|
||
origem = models.CharField("Origem", max_length=10, blank=True)
|
||
vinculo = models.ForeignKey(
|
||
ConciliacaoFornecedorVinculo, on_delete=models.SET_NULL, null=True, blank=True, related_name="lancamentos"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Lançamento (Conciliação de fornecedores)"
|
||
verbose_name_plural = "Lançamentos (Conciliação de fornecedores)"
|
||
ordering = ["ordem", "id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}"
|
||
|
||
|
||
class ConciliacaoFornecedorVinculoHistorico(models.Model):
|
||
"""Registro de cada ação manual sobre vínculos de uma conta (vincular,
|
||
acrescentar lançamentos, desfazer — inclusive o desfazer de um vínculo
|
||
automático), para o contador saber quem vinculou/desvinculou o quê
|
||
(pedido do usuário). `lancamentos` é uma cópia dos lançamentos
|
||
envolvidos no momento da ação (seq., data, natureza, valor), porque o
|
||
vínculo desfeito é apagado e `vinculo_id` deixa de existir."""
|
||
|
||
ACAO_VINCULOU = "vinculou"
|
||
ACAO_ACRESCENTOU = "acrescentou"
|
||
ACAO_DESFEZ = "desfez"
|
||
ACAO_CHOICES = [
|
||
(ACAO_VINCULOU, "Vinculou"),
|
||
(ACAO_ACRESCENTOU, "Acrescentou ao vínculo"),
|
||
(ACAO_DESFEZ, "Desfez o vínculo"),
|
||
]
|
||
|
||
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="historico_vinculos")
|
||
acao = models.CharField("Ação", max_length=20, choices=ACAO_CHOICES)
|
||
vinculo_id_original = models.IntegerField("Vínculo (id)", null=True, blank=True)
|
||
tipo_rotulo = models.CharField("Tipo do vínculo", max_length=60, blank=True)
|
||
vinculo_automatico = models.BooleanField("Vínculo automático", default=False)
|
||
# [{"id", "sequencia", "data", "natureza", "valor", "eh_saldo_anterior", "acrescentado"}]
|
||
lancamentos = models.JSONField("Lançamentos envolvidos", default=list)
|
||
usuario = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||
criado_em = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Histórico de vínculo (Conciliação de fornecedores)"
|
||
verbose_name_plural = "Histórico de vínculos (Conciliação de fornecedores)"
|
||
ordering = ["-criado_em", "-id"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.get_acao_display()} #{self.vinculo_id_original} ({self.criado_em:%d/%m/%Y %H:%M})"
|