portal_publico/portal_api/serializers.py

2846 lines
120 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from typing import Any
import nh3
from django.db.models import Q
from django.utils import timezone
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from . import catalogo
from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
from .indicadores import calculo as indicadores_calculo
from .nao_conformidades import classificacao as nc_classificacao
from .models import (
CONCILIACAO_ARQUIVO_MAX_BYTES,
AcessoGeral,
AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento,
CompromissoAgenda,
ConciliacaoFornecedor,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
ContabilConta,
ContabilLinhaAnaliseVertical,
ContabilLinhaDre,
ContabilObservacao,
ContabilObservacaoEdicao,
Departamento,
EmpresaQuestor,
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
ImportacaoPlanoSaudeDePaula,
ImportacaoPlanoSaudeDePaulaAlteracao,
ImportacaoPlanoSaudeDePaulaAuditoria,
ImportacaoPlanoSaudeDePaulaLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
IndicadorApuracaoResposta,
IndicadorContabilComponente,
IndicadorContabilDefinicao,
IndicadorCriterio,
IndicadorDepartamento,
IndicadorDepartamentoGerente,
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NaoConformidadeImportacao,
NCAcao,
NCAcompanhamento,
NCOcorrencia,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
RegraCusteioPlanoSaudeDePaula,
TelefoneExterno,
Usuario,
WidgetUsuario,
)
from .custo_contratacao.calculo import EntradaSimulacaoEmpregado
from .dashboard_contabil import formula as dashboard_contabil_formula
from .planos_saude.leiaute_sistema import formata_valor_br, parse_valor_br
from .planos_saude.pipeline import CUSTEIOS_VALIDOS, OPERADORAS, TIPOS_LANCAMENTO_VALIDOS, TIPOS_PESSOA_VALIDOS
from .planos_saude.regras_empresa import REGRAS_EMPRESA
# Allowlist compartilhada por todo campo de texto rico com imagens embutidas
# (Observações de Acesso Geral, ver "Acessos Gerais" no CLAUDE.md; texto de
# "Mais informações" de uma aplicação, ver "Ajuda de aplicação") — o conteúdo
# vem de um <div contenteditable> no cliente e é renderizado via innerHTML pra
# qualquer usuário com acesso de visualização, então passa por um allowlist
# estrito (nh3 — binding Python da lib Rust "ammonia"; usado no lugar do
# bleach, que está sem manutenção desde 2023 e parou de receber releases,
# inclusive de segurança) antes de salvar: só marcação de texto básica +
# <img>, nada de <script>/<a>/atributos de evento. `data` entra nos esquemas
# de URL aceitos porque as imagens inseridas viajam como data URI (sem upload
# de arquivo separado), não como URL externa.
RICHTEXT_ALLOWED_TAGS = {"p", "br", "div", "b", "strong", "i", "em", "u", "ul", "ol", "li", "img"}
RICHTEXT_ALLOWED_ATTRS = {"img": {"src", "alt"}}
RICHTEXT_ALLOWED_SCHEMES = {"http", "https", "data"}
class DepartamentoSerializer(serializers.ModelSerializer):
class Meta:
model = Departamento
fields = ["id", "nome"]
class PerfilAcessoSerializer(serializers.ModelSerializer):
class Meta:
model = PerfilAcesso
fields = ["codigo", "nome", "ativo", "gerencia_permissoes", "permissoes"]
read_only_fields = ["codigo"]
def validate_permissoes(self, value: dict[str, Any]) -> dict[str, Any]:
if not isinstance(value, dict):
raise serializers.ValidationError("Permissões devem ser um objeto.")
modulos_validos = {m["key"] for m in catalogo.MODULES}
for chave in value:
if chave not in modulos_validos:
raise serializers.ValidationError(f"Módulo desconhecido: {chave}")
return value
class PerfilResumoSerializer(serializers.ModelSerializer):
class Meta:
model = PerfilAcesso
fields = ["codigo", "nome", "gerencia_permissoes"]
class UsuarioResumoSerializer(serializers.ModelSerializer):
"""Representação enxuta (id/nome/departamentos) de um usuário — usada nos dois
sentidos da relação de liderança (`liderados` expandido em
`UsuarioListSerializer`/`/api/me/`) e por `/api/usuarios-resumo/`, que alimenta o
seletor de liderados (com busca e coluna por departamento) sem exigir
`gerencia_permissoes` (mesmo padrão de `RamalViewSet.usuarios_disponiveis`)."""
departamentos = DepartamentoSerializer(many=True, read_only=True)
class Meta:
model = Usuario
fields = ["id", "nome", "departamentos"]
def _revogar_acesso_se_inativo(usuario: Usuario) -> None:
"""Decisão explícita do usuário: uma conta inativa (`is_active=False`) não
deve continuar vinculada a nenhum perfil de acesso, nem aparecer sob a
liderança de nenhum gerente — sem isso, o vínculo ficaria "pendurado" em
alguém que já não pode mais logar. Chamado sempre **depois** dos
`.set()` de `perfis`/`liderados` em `create()`/`update()`, pra não ser
imediatamente desfeito se o mesmo payload também enviar `perfis` (ex.: o
formulário de edição de `usuarios.html` sempre reenvia o checklist
inteiro, mesmo quando só o checkbox "Usuário ativo" mudou). `lideres` é a
relação reversa de `Usuario.liderados` — limpar aqui remove `usuario` do
`liderados` de todo gerente que o tivesse, sem afetar quem `usuario`
eventualmente lidera."""
if usuario.is_active:
return
usuario.perfis.clear()
usuario.lideres.clear()
class UsuarioSerializer(serializers.ModelSerializer):
"""Usado para criar/editar (aceita `senha` e `perfis` graváveis)."""
perfis = serializers.PrimaryKeyRelatedField(queryset=PerfilAcesso.objects.all(), many=True, required=False)
departamentos = serializers.PrimaryKeyRelatedField(
queryset=Departamento.objects.all(), many=True, required=False
)
liderados = serializers.PrimaryKeyRelatedField(queryset=Usuario.objects.all(), many=True, required=False)
senha = serializers.CharField(write_only=True, required=False, allow_blank=True)
class Meta:
model = Usuario
fields = [
"id",
"username",
"nome",
"email",
"departamentos",
"codigo_folha",
"codigo_questor",
"codigo_tareffa",
"codigo_contabit",
"ramal",
"data_aniversario",
"perfis",
"lideranca",
"liderados",
"senha",
"is_active",
]
def create(self, validated_data: dict[str, Any]) -> Usuario:
senha = validated_data.pop("senha", None)
perfis = validated_data.pop("perfis", [])
departamentos = validated_data.pop("departamentos", [])
liderados = validated_data.pop("liderados", [])
if not senha:
raise serializers.ValidationError({"senha": "Informe uma senha para o novo usuário."})
usuario = Usuario(**validated_data)
usuario.set_password(senha)
usuario.save()
usuario.perfis.set(perfis)
usuario.departamentos.set(departamentos)
usuario.liderados.set(liderados)
_revogar_acesso_se_inativo(usuario)
return usuario
def update(self, instance: Usuario, validated_data: dict[str, Any]) -> Usuario:
senha = validated_data.pop("senha", None)
perfis = validated_data.pop("perfis", None)
departamentos = validated_data.pop("departamentos", None)
liderados = validated_data.pop("liderados", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if senha:
instance.set_password(senha)
instance.save()
if perfis is not None:
instance.perfis.set(perfis)
if departamentos is not None:
instance.departamentos.set(departamentos)
if liderados is not None:
instance.liderados.set(liderados)
_revogar_acesso_se_inativo(instance)
return instance
class UsuarioListSerializer(serializers.ModelSerializer):
"""Usado para listar/detalhar (perfis e departamentos expandidos, somente leitura)."""
perfis = PerfilResumoSerializer(many=True, read_only=True)
departamentos = DepartamentoSerializer(many=True, read_only=True)
liderados = UsuarioResumoSerializer(many=True, read_only=True)
class Meta:
model = Usuario
fields = [
"id",
"username",
"nome",
"email",
"departamentos",
"codigo_folha",
"codigo_questor",
"codigo_tareffa",
"codigo_contabit",
"ramal",
"data_aniversario",
"perfis",
"lideranca",
"liderados",
"is_active",
]
class CategoriaEventoSerializer(serializers.ModelSerializer):
class Meta:
model = CategoriaEvento
fields = ["id", "nome", "cor"]
class CompromissoAgendaSerializer(serializers.ModelSerializer):
dono_nome = serializers.CharField(source="dono.nome", read_only=True)
dono_username = serializers.CharField(source="dono.username", read_only=True)
departamento_compartilhado_nome = serializers.CharField(
source="departamento_compartilhado.nome", read_only=True, default=None
)
categoria_nome = serializers.CharField(source="categoria.nome", read_only=True, default=None)
categoria_cor = serializers.CharField(source="categoria.cor", read_only=True, default=None)
sou_dono = serializers.SerializerMethodField()
notificar_em = serializers.SerializerMethodField()
class Meta:
model = CompromissoAgenda
fields = [
"id",
"titulo",
"data",
"horario",
"visibilidade",
"departamento_compartilhado",
"departamento_compartilhado_nome",
"lembrete_antecedencia",
"categoria",
"categoria_nome",
"categoria_cor",
"eh_evento",
"local",
"modalidade",
"descricao",
"notificar_em",
"dono_nome",
"dono_username",
"sou_dono",
]
def get_sou_dono(self, obj: CompromissoAgenda) -> bool:
request = self.context.get("request")
return bool(request and request.user.is_authenticated and obj.dono_id == request.user.id)
def get_notificar_em(self, obj: CompromissoAgenda) -> str | None:
notificar_em = obj.calcular_notificar_em()
return notificar_em.isoformat() if notificar_em else None
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
instance = self.instance
# Evento (`eh_evento`) é sempre visível pra todo mundo, por definição —
# força a visibilidade antes de qualquer outra checagem, então o gate de
# permissão abaixo (que já cobre "departamento"/"todos") também cobre
# a criação de eventos sem precisar de uma checagem duplicada.
eh_evento = attrs.get("eh_evento", getattr(instance, "eh_evento", False))
if eh_evento:
attrs["visibilidade"] = CompromissoAgenda.VISIBILIDADE_TODOS
visibilidade = attrs.get(
"visibilidade", getattr(instance, "visibilidade", CompromissoAgenda.VISIBILIDADE_SOMENTE_EU)
)
if visibilidade == CompromissoAgenda.VISIBILIDADE_DEPARTAMENTO:
if "departamento_compartilhado" in attrs:
departamento = attrs["departamento_compartilhado"]
else:
departamento = getattr(instance, "departamento_compartilhado", None)
request = self.context.get("request")
if departamento is None:
raise serializers.ValidationError(
{"departamento_compartilhado": "Escolha um departamento para compartilhar o compromisso."}
)
if request and not request.user.departamentos.filter(pk=departamento.pk).exists():
raise serializers.ValidationError(
{"departamento_compartilhado": "Escolha um dos seus próprios departamentos."}
)
attrs["departamento_compartilhado"] = departamento
else:
attrs["departamento_compartilhado"] = None
if visibilidade in (CompromissoAgenda.VISIBILIDADE_DEPARTAMENTO, CompromissoAgenda.VISIBILIDADE_TODOS):
request = self.context.get("request")
if not (
request
and request.user.permissao_app("calendario-individual", "calendario-individual-criar-evento")
):
raise serializers.ValidationError(
{"visibilidade": "Você não tem permissão para criar eventos de departamento ou para todos."}
)
return attrs
class FavoritoSerializer(serializers.ModelSerializer):
class Meta:
model = Favorito
fields = ["id", "app_id", "ordem"]
class NotificacaoDispensadaSerializer(serializers.ModelSerializer):
class Meta:
model = NotificacaoDispensada
fields = ["id", "notif_id"]
class AjudaAplicacaoSerializer(serializers.ModelSerializer):
atualizado_por_nome = serializers.SerializerMethodField()
class Meta:
model = AjudaAplicacao
fields = ["app_key", "texto", "atualizado_em", "atualizado_por_nome"]
read_only_fields = ["app_key", "atualizado_em", "atualizado_por_nome"]
def get_atualizado_por_nome(self, obj: AjudaAplicacao) -> str | None:
return obj.atualizado_por.nome if obj.atualizado_por else None
def validate_texto(self, value: str) -> str:
return nh3.clean(
value,
tags=RICHTEXT_ALLOWED_TAGS,
attributes=RICHTEXT_ALLOWED_ATTRS,
url_schemes=RICHTEXT_ALLOWED_SCHEMES,
)
class WidgetUsuarioSerializer(serializers.ModelSerializer):
class Meta:
model = WidgetUsuario
fields = ["id", "tipo", "ordem", "largura", "altura"]
class LinkFerramentaSerializer(serializers.ModelSerializer):
class Meta:
model = LinkFerramenta
fields = ["id", "nome", "url", "icone", "ordem"]
class LinkFerramentaFavoritoSerializer(serializers.ModelSerializer):
link = serializers.PrimaryKeyRelatedField(queryset=LinkFerramenta.objects.all())
class Meta:
model = LinkFerramentaFavorito
fields = ["id", "link"]
class AcessoGeralSecaoSerializer(serializers.ModelSerializer):
"""`perfis_restritos` vazio = seção visível a qualquer um com
`acessos-gerais-visualizar`; não vazio = só quem tem um desses perfis também
(ver `AcessoGeralSecaoViewSet.get_queryset`)."""
perfis_restritos = serializers.PrimaryKeyRelatedField(
queryset=PerfilAcesso.objects.all(), many=True, required=False
)
class Meta:
model = AcessoGeralSecao
fields = ["id", "nome", "ordem", "perfis_restritos"]
def validate_nome(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Informe o nome da seção.")
return value
class AcessoGeralSerializer(serializers.ModelSerializer):
class Meta:
model = AcessoGeral
fields = ["id", "secao", "nome", "url", "usuario", "senha", "observacoes", "ordem"]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request is not None and request.user.is_authenticated:
# Restringe o <select> de seção às que o usuário pode ver (mesmo filtro
# de AcessoGeralSecaoViewSet.get_queryset), senão daria pra criar/mover
# uma linha pra dentro de uma seção restrita a outro perfil só sabendo o id.
perfis_ids = list(request.user.perfis.values_list("codigo", flat=True))
self.fields["secao"].queryset = AcessoGeralSecao.objects.filter(
Q(perfis_restritos__isnull=True) | Q(perfis_restritos__codigo__in=perfis_ids)
).distinct()
def validate_nome(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Informe o nome do acesso.")
return value
def validate_observacoes(self, value: str) -> str:
return nh3.clean(
value,
tags=RICHTEXT_ALLOWED_TAGS,
attributes=RICHTEXT_ALLOWED_ATTRS,
url_schemes=RICHTEXT_ALLOWED_SCHEMES,
)
class RamalAusenciaSerializer(serializers.ModelSerializer):
usuario_nome = serializers.CharField(source="usuario.nome", read_only=True)
class Meta:
model = RamalAusencia
fields = [
"id",
"usuario",
"usuario_nome",
"data_inicio",
"hora_inicio",
"data_fim",
"hora_volta",
"tipo",
"observacoes",
"encerrada_manualmente",
]
class RamalSerializer(serializers.ModelSerializer):
"""Só para as linhas avulsas (sem `Usuario` por trás) — colaboradores com conta
aparecem automaticamente na listagem via `RamalViewSet.list`, que monta a linha
deles direto do cadastro e não passa por este serializer."""
class Meta:
model = Ramal
fields = ["id", "nome", "departamento", "numero", "criado_em"]
def validate_nome(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Informe o nome.")
return value
class TelefoneExternoSerializer(serializers.ModelSerializer):
class Meta:
model = TelefoneExterno
fields = ["id", "nome", "ramal", "telefone", "observacoes", "criado_em"]
def validate_nome(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Informe o nome.")
return value
class FuncaoTelefoniaSerializer(serializers.ModelSerializer):
class Meta:
model = FuncaoTelefonia
fields = ["id", "comando", "funcao", "resumo", "criado_em"]
def validate_comando(self, value: str) -> str:
if not value.strip():
raise serializers.ValidationError("Informe o comando.")
return value
def _monta_regra_custeio(
chave: str,
modo: str | None,
limite_bruto: str,
percentual_bruto: str,
limite_desconto_empregado_bruto: str = "",
) -> dict[str, Any]:
"""Valida e monta uma única regra de custeio (uma combinação tipo de
lançamento × tipo de beneficiário) — reaproveitado por
ImportacaoPlanoSaudeCreateSerializer (form da nova importação) e
RegraCusteioPlanoSaudeSerializer (banco de regras salvas), já que as duas
telas usam exatamente a mesma regra de negócio de custeio (ver "Regra de
custeio" no formulário de nova importação).
`limite_valor`/`percentual` protegem o gasto da EMPRESA (tetos de quanto
ela cobre, o excedente vira desconto do empregado) e são combináveis
entre si (vale o mais restritivo). `limite_desconto_empregado` protege o
gasto do EMPREGADO (teto de quanto é descontado dele, o restante fica
com a empresa) — direção oposta, por isso é mutuamente exclusivo com os
outros dois: misturar um teto do lado da empresa com um teto do lado do
empregado não tem uma resolução determinística única quando os dois
conflitam (ver `_calcula_valores` em matcher.py)."""
if not modo:
raise serializers.ValidationError(
{f"custeio_{chave}": "Informe como esse tipo é custeado para titular e dependente."}
)
if modo not in CUSTEIOS_VALIDOS:
raise serializers.ValidationError({f"custeio_{chave}": f"Modo de custeio inválido: {modo}"})
if modo != "especifica":
return {"modo": modo}
limite_bruto = (limite_bruto or "").strip()
percentual_bruto = (percentual_bruto or "").strip()
limite_desconto_empregado_bruto = (limite_desconto_empregado_bruto or "").strip()
if limite_desconto_empregado_bruto and (limite_bruto or percentual_bruto):
raise serializers.ValidationError(
{
f"custeio_{chave}": (
"O limite de desconto do empregado não pode ser combinado com o limite de "
"valor/percentual custeado pela empresa."
)
}
)
if not limite_bruto and not percentual_bruto and not limite_desconto_empregado_bruto:
raise serializers.ValidationError(
{
f"custeio_{chave}": (
"Informe o limite de valor e/ou o percentual de custeio da empresa, ou o limite "
"de desconto do empregado."
)
}
)
regra: dict[str, Any] = {
"modo": "especifica",
"limite_valor": None,
"percentual": None,
"limite_desconto_empregado": None,
}
if limite_bruto:
limite = parse_valor_br(limite_bruto)
if limite < 0:
raise serializers.ValidationError({f"limite_valor_{chave}": "Informe um valor válido."})
regra["limite_valor"] = limite
if percentual_bruto:
percentual = parse_valor_br(percentual_bruto)
if not (0 <= percentual <= 100):
raise serializers.ValidationError({f"percentual_{chave}": "Informe um percentual entre 0 e 100."})
regra["percentual"] = percentual
if limite_desconto_empregado_bruto:
limite_desconto = parse_valor_br(limite_desconto_empregado_bruto)
if limite_desconto < 0:
raise serializers.ValidationError({f"limite_desconto_empregado_{chave}": "Informe um valor válido."})
regra["limite_desconto_empregado"] = limite_desconto
return regra
def _valor_custeio_para_texto_br(valor: Any) -> str:
"""`RegraCusteioPlanoSaudeSerializer` recebe `limite_valor`/`percentual`
tanto como texto BR cru (regra recém-preenchida no formulário) quanto já
como float (regra salva sendo reaplicada sem edição, vinda de volta do
próprio `custeio_por_tipo` já persistido) — normaliza os dois formatos
antes de `_monta_regra_custeio` chamar `parse_valor_br`, que só entende
texto BR (`"150.0"` seria lido errado como 15000, não 150)."""
if valor is None or valor == "":
return ""
if isinstance(valor, (int, float)):
return formata_valor_br(float(valor))
return str(valor)
class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
"""Valida o multipart de criação de uma nova importação — não é um
ModelSerializer porque o formato de entrada (`tipos_lancamento` como string
separada por vírgula + um `custeio_<tipo>_<pessoa>` por tipo de lançamento
× tipo de beneficiário) não bate 1:1 com os campos do model
(`ImportacaoPlanoSaude.custeio_por_tipo` é um único JSONField, no formato
`{tipo: {"titular": {...regra...}, "dependente": {...regra...}}}`). Ver
`ImportacaoPlanoSaudeViewSet.create` em views.py."""
operadora = serializers.ChoiceField(choices=list(OPERADORAS.keys()))
# Exatamente uma das duas origens da planilha padrão precisa vir
# preenchida — ver validate(): upload manual (comportamento original)
# ou busca automática no Questor por competência (mês/ano digitado na
# tela, formato "AAAA-MM").
planilha_padrao = serializers.FileField(required=False)
# Mesmo formato de entrada de IndicadorApuracaoCreateSerializer — o
# frontend converte a máscara "MM/AAAA" pro ISO "AAAA-MM-01" antes de
# enviar (ver pidIpsCompetenciaParaIso em importacao-plano-saude.js).
competencia = serializers.DateField(required=False, allow_null=True)
# Lista porque algumas operadoras mandam mensalidade e coparticipação em
# arquivos separados (ex.: Unimed Saúde em PDF) — a maioria manda só um,
# mas o campo sempre aceita 1 ou mais. `ListField` já lê múltiplos
# arquivos do mesmo nome de campo em multipart/form-data (via
# `request.data.getlist(...)`, mesma semântica do QueryDict do Django),
# sem precisar de nenhum tratamento manual na view.
arquivo_operadora = serializers.ListField(child=serializers.FileField(), allow_empty=False)
tipos_lancamento = serializers.CharField()
# Chave de REGRAS_EMPRESA (portal_api.planos_saude.regras_empresa) — ver
# validate(): quando preenchida, substitui o custeio manual de
# "mensalidade" (custeio_mensalidade_titular/dependente ficam ignorados).
regra_empresa = serializers.CharField(required=False, allow_blank=True)
# Só registro informativo de qual "Regra de custeio salva" (se alguma) foi
# aplicada no formulário — não influencia o processamento, só permite
# mostrar a observação da regra na tela de Revisão (ver
# ImportacaoPlanoSaudeDetailSerializer.regra_custeio_salva_observacoes).
regra_custeio_salva = serializers.PrimaryKeyRelatedField(
queryset=RegraCusteioPlanoSaude.objects.all(), required=False, allow_null=True
)
custeio_mensalidade_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_mensalidade_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_coparticipacao_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_coparticipacao_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
# Só usados quando custeio_<tipo>_<pessoa> == "especifica" (ver
# validate()) — texto livre porque chegam no formato BR ("150,00"/"50")
# do formulário, igual aos outros valores monetários do pipeline.
limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()]
if not tipos:
raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."})
regra_empresa_key = (attrs.get("regra_empresa") or "").strip()
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_key:
regra_re = REGRAS_EMPRESA.get(regra_empresa_key)
if regra_re is None:
raise serializers.ValidationError({"regra_empresa": f"Regra empresa desconhecida: {regra_empresa_key}"})
regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
if not regra_empresa_tipos:
raise serializers.ValidationError(
{
"regra_empresa": (
f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
"mas nenhum desses tipos de importação está selecionado."
)
}
)
parser_instancia = OPERADORAS[attrs["operadora"]]["parser"]()
casamento_exigido = regra_re.get("chave_casamento", "nome")
for tipo in regra_empresa_tipos:
if parser_instancia.chave_casamento_para_tipo(tipo) != casamento_exigido:
raise serializers.ValidationError(
{
"regra_empresa": (
f"Esta operadora não é compatível com \"{regra_re['label']}\" "
f"(exige casamento por {casamento_exigido!r})."
)
}
)
custeio_por_tipo: dict[str, Any] = {}
for tipo in tipos:
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"})
# "Regra empresa" substitui o custeio manual dos tipos que ela
# cobre — sem titular/dependente pra configurar aqui (ver
# models.py ImportacaoPlanoSaude.regra_empresa).
if tipo in regra_empresa_tipos:
custeio_por_tipo[tipo] = {}
continue
custeio_por_pessoa: dict[str, Any] = {}
for pessoa in TIPOS_PESSOA_VALIDOS:
chave = f"{tipo}_{pessoa}"
custeio_por_pessoa[pessoa] = _monta_regra_custeio(
chave,
attrs.get(f"custeio_{chave}"),
attrs.get(f"limite_valor_{chave}", ""),
attrs.get(f"percentual_{chave}", ""),
attrs.get(f"limite_desconto_empregado_{chave}", ""),
)
custeio_por_tipo[tipo] = custeio_por_pessoa
attrs["tipos_lancamento_lista"] = tipos
attrs["custeio_por_tipo"] = custeio_por_tipo
attrs["regra_empresa"] = regra_empresa_key
# Exatamente uma origem pra planilha padrão: upload manual (arquivo)
# ou busca automática no Questor (competência). Ver
# ImportacaoPlanoSaudeViewSet.create, em views.py, pra onde cada uma
# das duas é de fato resolvida em `linhas_sistema_template`.
planilha_padrao = attrs.get("planilha_padrao")
competencia = attrs.get("competencia")
if planilha_padrao and competencia:
raise serializers.ValidationError(
{"competencia": "Anexe a planilha manualmente ou informe a competência para buscar no Questor — não os dois."}
)
if not planilha_padrao and not competencia:
raise serializers.ValidationError(
{"planilha_padrao": "Anexe a planilha padrão ou informe a competência para buscar no Questor."}
)
if competencia:
regra_custeio_salva = attrs.get("regra_custeio_salva")
if not regra_custeio_salva or not regra_custeio_salva.codigo_empresa:
raise serializers.ValidationError(
{"competencia": "Buscar a planilha no Questor exige selecionar a empresa (regra de custeio cadastrada)."}
)
return attrs
class ImportacaoPlanoSaudeLinhaSerializer(serializers.ModelSerializer):
class Meta:
model = ImportacaoPlanoSaudeLinha
fields = [
"id",
"tipo_lancamento",
"codigo_empresa",
"nome_func",
"cpf_func",
"codigo_out_emp",
"data_inicial",
"nome_dependente",
"cpf_dependente",
"valor_empresa",
"valor",
"descricao",
]
read_only_fields = ["id", "tipo_lancamento"]
class ImportacaoPlanoSaudeLinhaCreateSerializer(serializers.ModelSerializer):
"""Só para incluir manualmente uma linha nova numa importação já criada
(botão "Adicionar linha" na revisão) — ao contrário de
ImportacaoPlanoSaudeLinhaSerializer (edição), aqui `importacao`/
`tipo_lancamento` são graváveis, já que precisam ser informados na
criação. Os demais campos nascem em branco/"0" (mesmo default do model)
e são editados depois pelos mesmos inputs das linhas geradas pelo
pipeline."""
class Meta:
model = ImportacaoPlanoSaudeLinha
fields = [
"id",
"importacao",
"tipo_lancamento",
"codigo_empresa",
"nome_func",
"cpf_func",
"codigo_out_emp",
"data_inicial",
"nome_dependente",
"cpf_dependente",
"valor_empresa",
"valor",
"descricao",
]
read_only_fields = ["id"]
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
importacao = attrs.get("importacao")
tipo = attrs.get("tipo_lancamento")
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError({"tipo_lancamento": f"Tipo de importação inválido: {tipo}"})
if importacao is not None and tipo not in (importacao.tipos_lancamento or []):
raise serializers.ValidationError(
{"tipo_lancamento": "Esse tipo de lançamento não faz parte desta importação."}
)
return attrs
class ImportacaoPlanoSaudeAuditoriaSerializer(serializers.ModelSerializer):
linha_vinculada_nome = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeAuditoria
fields = [
"id",
"motivo",
"tipo_lancamento",
"numero_beneficiario",
"nome",
"cpf",
"tipo",
"valor",
"detalhe",
"resolvida",
"linha_vinculada",
"linha_vinculada_nome",
]
read_only_fields = ["resolvida", "linha_vinculada"]
def get_linha_vinculada_nome(self, obj: ImportacaoPlanoSaudeAuditoria) -> str | None:
"""Nome pra exibir junto do selo "Resolvido" — o nome do dependente
se a linha vinculada for uma linha de dependente, senão o do titular."""
if not obj.linha_vinculada:
return None
return obj.linha_vinculada.nome_dependente or obj.linha_vinculada.nome_func
class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None)
linha_nome = serializers.SerializerMethodField()
vinculo_nome_destino = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeAlteracao
fields = [
"id",
"tipo",
"linha",
"linha_nome",
"tipo_lancamento",
"campo",
"valor_anterior",
"valor_novo",
"dados_linha",
"vinculo_nome",
"vinculo_nome_destino",
"usuario_nome",
"criado_em",
"revertida",
"revertida_em",
]
read_only_fields = fields
def get_linha_nome(self, obj: ImportacaoPlanoSaudeAlteracao) -> str | None:
"""Nome pra identificar a linha na aba "Alterações" mesmo quando ela já
não existe mais (excluída, ou uma inclusão já revertida) — usa o
snapshot em `dados_linha` nesse caso, senão o estado atual da própria
linha (que pode ter sido editada depois desta alteração específica)."""
dados = obj.dados_linha or {}
if obj.linha_id:
return obj.linha.nome_dependente or obj.linha.nome_func or None
return dados.get("nome_dependente") or dados.get("nome_func") or None
def get_vinculo_nome_destino(self, obj: ImportacaoPlanoSaudeAlteracao) -> str | None:
"""Só em TIPO_VINCULO_AUTOMATICO — o nome (planilha padrão) pro qual
`valor_novo` (nome divergente do arquivo da operadora) foi vinculado
automaticamente, pra montar "<nome do arquivo>" → <nome vinculado>"
na aba Alterações. `None` quando o VinculoNomeOperadora já foi
apagado (botão "Apagar vínculo", ver ImportacaoPlanoSaudeAlteracaoViewSet.
reverter) — a linha continua identificável pelos outros campos."""
if not obj.vinculo_nome_id:
return None
vinculo = obj.vinculo_nome
return vinculo.nome_func_destino or vinculo.nome_dependente_destino or None
def _codigo_empresa_da_importacao(obj: ImportacaoPlanoSaude) -> str:
"""Todas as linhas de uma importação vêm da mesma planilha padrão, então
compartilham o mesmo código de empresa — pega o primeiro não vazio (uma
linha incluída manualmente pode nascer sem esse campo preenchido).
Compartilhado por `ImportacaoPlanoSaudeListSerializer` e
`ImportacaoPlanoSaudeDetailSerializer`."""
linha = obj.linhas.exclude(codigo_empresa="").first()
return linha.codigo_empresa if linha else ""
def _nome_empresa_cacheado(codigo_empresa: str) -> str | None:
"""Só lê o cache local (`EmpresaQuestor`) — nunca consulta o Questor de
novo aqui. Usado na tela de Revisão, onde o nome já deveria ter sido
resolvido antes (ao cadastrar/editar a regra de custeio aplicada, ver
`RegraCusteioPlanoSaudeSerializer.get_nome_empresa`); diferente desse
outro ponto, não vale a pena pagar o custo de uma consulta ao Questor
aqui, numa tela consultada com muito mais frequência. Normaliza o
código antes de buscar (ver `normalizar_codigo_empresa`) porque este
aqui vem cru da planilha padrão (`ImportacaoPlanoSaudeLinha`, pode ter
zero à esquerda tipo "092"), enquanto a chave em `EmpresaQuestor` já é
sempre canônica ("92") — sem normalizar aqui, a busca erraria por uma
diferença que nem devia importar."""
codigo_empresa = normalizar_codigo_empresa(codigo_empresa)
if not codigo_empresa:
return None
cache = EmpresaQuestor.objects.filter(codigo_empresa=codigo_empresa).first()
return cache.nome_empresa if cache else None
class ImportacaoPlanoSaudeListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
codigo_empresa = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaude
fields = [
"id",
"nome_operadora",
"codigo_empresa",
"competencia",
"tipos_lancamento",
"custeio_por_tipo",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaude) -> str:
return _codigo_empresa_da_importacao(obj)
class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
linhas = ImportacaoPlanoSaudeLinhaSerializer(many=True, read_only=True)
itens_auditoria = ImportacaoPlanoSaudeAuditoriaSerializer(many=True, read_only=True)
alteracoes = ImportacaoPlanoSaudeAlteracaoSerializer(many=True, read_only=True)
resumo_por_tipo = serializers.SerializerMethodField()
codigo_empresa = serializers.SerializerMethodField()
nome_empresa = serializers.SerializerMethodField()
regra_empresa_label = serializers.SerializerMethodField()
regra_empresa_observacoes = serializers.SerializerMethodField()
regra_custeio_salva_nome = serializers.CharField(source="regra_custeio_salva.nome", read_only=True, default=None)
regra_custeio_salva_observacoes = serializers.CharField(
source="regra_custeio_salva.observacoes", read_only=True, default=None
)
class Meta:
model = ImportacaoPlanoSaude
fields = [
"id",
"operadora",
"nome_operadora",
"codigo_empresa",
"nome_empresa",
"competencia",
"tipos_lancamento",
"custeio_por_tipo",
"regra_empresa",
"regra_empresa_label",
"regra_empresa_observacoes",
"regra_custeio_salva",
"regra_custeio_salva_nome",
"regra_custeio_salva_observacoes",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"linhas",
"itens_auditoria",
"alteracoes",
"resumo_por_tipo",
]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaude) -> str:
return _codigo_empresa_da_importacao(obj)
def get_nome_empresa(self, obj: ImportacaoPlanoSaude) -> str | None:
return _nome_empresa_cacheado(self.get_codigo_empresa(obj))
def get_regra_empresa_label(self, obj: ImportacaoPlanoSaude) -> str | None:
"""Rótulo pra exibir na revisão (ex.: "1778 - Unimed (Tecnomyl)") —
REGRAS_EMPRESA é um registro fixo no código, não uma tabela, então
não dá pra expor via FK/serializer aninhado normal."""
regra = REGRAS_EMPRESA.get(obj.regra_empresa)
return regra["label"] if regra else None
def get_regra_empresa_observacoes(self, obj: ImportacaoPlanoSaude) -> str | None:
"""Observação da regra (texto livre cadastrado em REGRAS_EMPRESA) —
exibida só-leitura no topo da tela de revisão, pra o colaborador
conferir a regra aplicada sem precisar abrir o código."""
regra = REGRAS_EMPRESA.get(obj.regra_empresa)
observacoes = (regra or {}).get("observacoes")
return observacoes or None
def get_resumo_por_tipo(self, obj: ImportacaoPlanoSaude) -> list[dict[str, Any]]:
"""Contagem de apoio pra tela de revisão: quantas linhas do tipo têm valor
lançado (VALOR ou VALOREMPRESA diferente de "0") vs. quantas ficaram em
auditoria — mesmos números que o CLI original imprimia no fim (main.py).
`total_auditoria` só conta itens ainda pendentes — um item resolvido
manualmente (ver ImportacaoPlanoSaudeAuditoriaViewSet.resolver) já tem
seu valor refletido em `total_lancadas` (a linha vinculada passa a ter
valor != "0"), então contá-lo de novo aqui inflaria os dois números."""
resumo = []
for tipo in obj.tipos_lancamento:
linhas_do_tipo = [l for l in obj.linhas.all() if l.tipo_lancamento == tipo]
lancadas = sum(1 for l in linhas_do_tipo if l.valor != "0" or l.valor_empresa != "0")
auditoria_do_tipo = sum(
1 for item in obj.itens_auditoria.all() if item.tipo_lancamento == tipo and not item.resolvida
)
resumo.append({
"tipo_lancamento": tipo,
"total_linhas": len(linhas_do_tipo),
"total_lancadas": lancadas,
"total_auditoria": auditoria_do_tipo,
})
return resumo
def _nome_operadora_sem_codigo(operadora_key: str) -> str:
"""Nome da operadora (ex.: "Unimed Saúde"), sem o código de cadastro
dela no Questor — usado só pra compor o `nome` derivado de
RegraCusteioPlanoSaude sem repetir esse código, que não tem relação com
o `codigo_empresa` do cliente que contratou o plano."""
return OPERADORAS[operadora_key]["nome"]
class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
"""Banco de regras de custeio cadastradas por empresa+operadora (ex.:
empresa "092" + Unimed) — cadastro/edição só na tela "Cadastro de
Regras" (`importacao-plano-saude.js`), separada da execução da
importação. `custeio_por_tipo` chega do frontend no mesmo "formato de
formulário" já usado por coletarCusteioAtual() (limite_valor/percentual
como texto BR, ex. "150,00") e é validado/normalizado aqui pro formato
final (float|None) antes de salvar, via `_monta_regra_custeio` — mesma
regra de negócio de ImportacaoPlanoSaudeCreateSerializer, pra nunca
divergir entre os dois pontos de entrada.
`nome` é sempre derivado aqui (nunca aceito do cliente — ver
RegraCusteioPlanoSaude.nome) e `codigo_empresa`+`operadora` são
validados como únicos juntos via `UniqueTogetherValidator` explícito
(em vez de confiar só no validador automático do DRF), pra manter a
mensagem de erro em português consistente com o resto do app."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
nome = serializers.CharField(read_only=True)
nome_empresa = serializers.SerializerMethodField()
class Meta:
model = RegraCusteioPlanoSaude
fields = [
"id",
"nome",
"codigo_empresa",
"nome_empresa",
"operadora",
"regra_empresa_chave",
"tipos_lancamento",
"custeio_por_tipo",
"observacoes",
"criado_por_nome",
"criado_em",
"atualizado_em",
]
read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"]
validators = [
UniqueTogetherValidator(
queryset=RegraCusteioPlanoSaude.objects.all(),
fields=["codigo_empresa", "operadora"],
message="Já existe uma regra de custeio cadastrada para essa empresa com essa operadora.",
)
]
def get_nome_empresa(self, obj: RegraCusteioPlanoSaude) -> str | None:
"""Resolve (e cacheia, se ainda não tiver — ver
`empresas_questor.resolve_nome_empresa`) o nome da empresa desta
regra a partir do Questor. Regras cadastradas antes deste campo
existir ainda não têm nada em `EmpresaQuestor` — resolver aqui, na
leitura, preenche o cache sozinho na primeira vez que a lista é
carregada, sem precisar de nenhum backfill manual."""
return resolve_nome_empresa(obj.codigo_empresa)
def validate_codigo_empresa(self, value: str) -> str:
value = normalizar_codigo_empresa(value)
if not value:
raise serializers.ValidationError("Informe o código da empresa.")
return value
def validate_operadora(self, value: str) -> str:
value = (value or "").strip()
if value not in OPERADORAS:
raise serializers.ValidationError(f"Operadora inválida: {value}")
return value
def validate_regra_empresa_chave(self, value: str) -> str:
value = (value or "").strip()
if value and value not in REGRAS_EMPRESA:
raise serializers.ValidationError(f"Regra empresa desconhecida: {value}")
return value
def validate_tipos_lancamento(self, value: Any) -> list[str]:
if not isinstance(value, list) or not value:
raise serializers.ValidationError("Selecione ao menos um tipo de importação.")
for tipo in value:
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError(f"Tipo de importação inválido: {tipo}")
return value
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
tipos = attrs.get("tipos_lancamento")
if tipos is None:
tipos = self.instance.tipos_lancamento if self.instance else []
codigo_empresa = attrs.get("codigo_empresa", self.instance.codigo_empresa if self.instance else None)
operadora_key = attrs.get("operadora", self.instance.operadora if self.instance else None)
regra_empresa_chave = attrs.get(
"regra_empresa_chave", self.instance.regra_empresa_chave if self.instance else ""
)
custeio_bruto = attrs.get("custeio_por_tipo")
if custeio_bruto is None:
custeio_bruto = self.instance.custeio_por_tipo if self.instance else {}
if not isinstance(custeio_bruto, dict):
raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."})
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_chave:
regra_re = REGRAS_EMPRESA[regra_empresa_chave]
regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
if not regra_empresa_tipos:
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
"mas nenhum desses tipos de importação está selecionado."
)
}
)
# Uma regra pode cobrir mais de uma empresa quando são do mesmo
# grupo e a condição negociada é idêntica (ver `codigos_empresa`
# em planos_saude.regras_empresa).
if codigo_empresa not in regra_re["codigos_empresa"]:
aceitos = " ou ".join(regra_re["codigos_empresa"])
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"Esta regra especial foi cadastrada para a empresa código "
f"{aceitos}, não para {codigo_empresa}."
)
}
)
if regra_re["operadora"] != operadora_key:
raise serializers.ValidationError(
{"regra_empresa_chave": "Esta regra especial foi cadastrada para outra operadora."}
)
custeio_validado: dict[str, Any] = {}
for tipo in tipos:
# "Regra empresa" substitui o custeio manual dos tipos que ela
# cobre — sem titular/dependente pra configurar aqui (mesmo
# padrão de ImportacaoPlanoSaudeCreateSerializer.validate()).
if tipo in regra_empresa_tipos:
custeio_validado[tipo] = {}
continue
por_tipo = custeio_bruto.get(tipo) or {}
if not isinstance(por_tipo, dict):
raise serializers.ValidationError({"custeio_por_tipo": f"Formato inválido para o tipo {tipo}."})
custeio_por_pessoa: dict[str, Any] = {}
for pessoa in TIPOS_PESSOA_VALIDOS:
chave = f"{tipo}_{pessoa}"
entrada = por_tipo.get(pessoa) or {}
if not isinstance(entrada, dict):
entrada = {}
custeio_por_pessoa[pessoa] = _monta_regra_custeio(
chave,
entrada.get("modo"),
_valor_custeio_para_texto_br(entrada.get("limite_valor")),
_valor_custeio_para_texto_br(entrada.get("percentual")),
_valor_custeio_para_texto_br(entrada.get("limite_desconto_empregado")),
)
custeio_validado[tipo] = custeio_por_pessoa
attrs["custeio_por_tipo"] = custeio_validado
attrs["nome"] = f"{codigo_empresa} - {_nome_operadora_sem_codigo(operadora_key)}"
return attrs
# --- "Importação de Plano de Saúde - De Paula" ---------------------------
# Mesmos serializers acima, apontando pros models "DePaula" (tabelas
# próprias, plano de saúde dos colaboradores do escritório, não de
# clientes — ver CLAUDE.md em portal_api/planos_saude/). Os helpers
# module-level (_monta_regra_custeio, _valor_custeio_para_texto_br,
# _nome_operadora_sem_codigo, _nome_empresa_cacheado,
# _codigo_empresa_da_importacao) são genéricos o bastante (operam por
# duck typing sobre o objeto recebido) pra serem reaproveitados sem
# duplicação.
class ImportacaoPlanoSaudeDePaulaCreateSerializer(serializers.Serializer):
"""Ver `ImportacaoPlanoSaudeCreateSerializer` — mesma validação de
entrada, escopo De Paula."""
operadora = serializers.ChoiceField(choices=list(OPERADORAS.keys()))
planilha_padrao = serializers.FileField(required=False)
competencia = serializers.DateField(required=False, allow_null=True)
arquivo_operadora = serializers.ListField(child=serializers.FileField(), allow_empty=False)
tipos_lancamento = serializers.CharField()
regra_empresa = serializers.CharField(required=False, allow_blank=True)
regra_custeio_salva = serializers.PrimaryKeyRelatedField(
queryset=RegraCusteioPlanoSaudeDePaula.objects.all(), required=False, allow_null=True
)
custeio_mensalidade_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_mensalidade_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_coparticipacao_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
custeio_coparticipacao_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()]
if not tipos:
raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."})
regra_empresa_key = (attrs.get("regra_empresa") or "").strip()
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_key:
regra_re = REGRAS_EMPRESA.get(regra_empresa_key)
if regra_re is None:
raise serializers.ValidationError({"regra_empresa": f"Regra empresa desconhecida: {regra_empresa_key}"})
regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
if not regra_empresa_tipos:
raise serializers.ValidationError(
{
"regra_empresa": (
f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
"mas nenhum desses tipos de importação está selecionado."
)
}
)
parser_instancia = OPERADORAS[attrs["operadora"]]["parser"]()
casamento_exigido = regra_re.get("chave_casamento", "nome")
for tipo in regra_empresa_tipos:
if parser_instancia.chave_casamento_para_tipo(tipo) != casamento_exigido:
raise serializers.ValidationError(
{
"regra_empresa": (
f"Esta operadora não é compatível com \"{regra_re['label']}\" "
f"(exige casamento por {casamento_exigido!r})."
)
}
)
custeio_por_tipo: dict[str, Any] = {}
for tipo in tipos:
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"})
if tipo in regra_empresa_tipos:
custeio_por_tipo[tipo] = {}
continue
custeio_por_pessoa: dict[str, Any] = {}
for pessoa in TIPOS_PESSOA_VALIDOS:
chave = f"{tipo}_{pessoa}"
custeio_por_pessoa[pessoa] = _monta_regra_custeio(
chave,
attrs.get(f"custeio_{chave}"),
attrs.get(f"limite_valor_{chave}", ""),
attrs.get(f"percentual_{chave}", ""),
attrs.get(f"limite_desconto_empregado_{chave}", ""),
)
custeio_por_tipo[tipo] = custeio_por_pessoa
attrs["tipos_lancamento_lista"] = tipos
attrs["custeio_por_tipo"] = custeio_por_tipo
attrs["regra_empresa"] = regra_empresa_key
planilha_padrao = attrs.get("planilha_padrao")
competencia = attrs.get("competencia")
if planilha_padrao and competencia:
raise serializers.ValidationError(
{"competencia": "Anexe a planilha manualmente ou informe a competência para buscar no Questor — não os dois."}
)
if not planilha_padrao and not competencia:
raise serializers.ValidationError(
{"planilha_padrao": "Anexe a planilha padrão ou informe a competência para buscar no Questor."}
)
if competencia:
regra_custeio_salva = attrs.get("regra_custeio_salva")
if not regra_custeio_salva or not regra_custeio_salva.codigo_empresa:
raise serializers.ValidationError(
{"competencia": "Buscar a planilha no Questor exige selecionar a empresa (regra de custeio cadastrada)."}
)
return attrs
class ImportacaoPlanoSaudeDePaulaLinhaSerializer(serializers.ModelSerializer):
class Meta:
model = ImportacaoPlanoSaudeDePaulaLinha
fields = [
"id",
"tipo_lancamento",
"codigo_empresa",
"nome_func",
"cpf_func",
"codigo_out_emp",
"data_inicial",
"nome_dependente",
"cpf_dependente",
"valor_empresa",
"valor",
"descricao",
]
read_only_fields = ["id", "tipo_lancamento"]
class ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ImportacaoPlanoSaudeDePaulaLinha
fields = [
"id",
"importacao",
"tipo_lancamento",
"codigo_empresa",
"nome_func",
"cpf_func",
"codigo_out_emp",
"data_inicial",
"nome_dependente",
"cpf_dependente",
"valor_empresa",
"valor",
"descricao",
]
read_only_fields = ["id"]
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
importacao = attrs.get("importacao")
tipo = attrs.get("tipo_lancamento")
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError({"tipo_lancamento": f"Tipo de importação inválido: {tipo}"})
if importacao is not None and tipo not in (importacao.tipos_lancamento or []):
raise serializers.ValidationError(
{"tipo_lancamento": "Esse tipo de lançamento não faz parte desta importação."}
)
return attrs
class ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(serializers.ModelSerializer):
linha_vinculada_nome = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeDePaulaAuditoria
fields = [
"id",
"motivo",
"tipo_lancamento",
"numero_beneficiario",
"nome",
"cpf",
"tipo",
"valor",
"detalhe",
"resolvida",
"linha_vinculada",
"linha_vinculada_nome",
]
read_only_fields = ["resolvida", "linha_vinculada"]
def get_linha_vinculada_nome(self, obj: ImportacaoPlanoSaudeDePaulaAuditoria) -> str | None:
if not obj.linha_vinculada:
return None
return obj.linha_vinculada.nome_dependente or obj.linha_vinculada.nome_func
class ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(serializers.ModelSerializer):
usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None)
linha_nome = serializers.SerializerMethodField()
vinculo_nome_destino = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeDePaulaAlteracao
fields = [
"id",
"tipo",
"linha",
"linha_nome",
"tipo_lancamento",
"campo",
"valor_anterior",
"valor_novo",
"dados_linha",
"vinculo_nome",
"vinculo_nome_destino",
"usuario_nome",
"criado_em",
"revertida",
"revertida_em",
]
read_only_fields = fields
def get_linha_nome(self, obj: ImportacaoPlanoSaudeDePaulaAlteracao) -> str | None:
dados = obj.dados_linha or {}
if obj.linha_id:
return obj.linha.nome_dependente or obj.linha.nome_func or None
return dados.get("nome_dependente") or dados.get("nome_func") or None
def get_vinculo_nome_destino(self, obj: ImportacaoPlanoSaudeDePaulaAlteracao) -> str | None:
if not obj.vinculo_nome_id:
return None
vinculo = obj.vinculo_nome
return vinculo.nome_func_destino or vinculo.nome_dependente_destino or None
class ImportacaoPlanoSaudeDePaulaListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
codigo_empresa = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeDePaula
fields = [
"id",
"nome_operadora",
"codigo_empresa",
"competencia",
"tipos_lancamento",
"custeio_por_tipo",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str:
return _codigo_empresa_da_importacao(obj)
class ImportacaoPlanoSaudeDePaulaDetailSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
linhas = ImportacaoPlanoSaudeDePaulaLinhaSerializer(many=True, read_only=True)
itens_auditoria = ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(many=True, read_only=True)
alteracoes = ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(many=True, read_only=True)
resumo_por_tipo = serializers.SerializerMethodField()
codigo_empresa = serializers.SerializerMethodField()
nome_empresa = serializers.SerializerMethodField()
regra_empresa_label = serializers.SerializerMethodField()
regra_empresa_observacoes = serializers.SerializerMethodField()
regra_custeio_salva_nome = serializers.CharField(source="regra_custeio_salva.nome", read_only=True, default=None)
regra_custeio_salva_observacoes = serializers.CharField(
source="regra_custeio_salva.observacoes", read_only=True, default=None
)
class Meta:
model = ImportacaoPlanoSaudeDePaula
fields = [
"id",
"operadora",
"nome_operadora",
"codigo_empresa",
"nome_empresa",
"competencia",
"tipos_lancamento",
"custeio_por_tipo",
"regra_empresa",
"regra_empresa_label",
"regra_empresa_observacoes",
"regra_custeio_salva",
"regra_custeio_salva_nome",
"regra_custeio_salva_observacoes",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"linhas",
"itens_auditoria",
"alteracoes",
"resumo_por_tipo",
]
def get_codigo_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str:
return _codigo_empresa_da_importacao(obj)
def get_nome_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
return _nome_empresa_cacheado(self.get_codigo_empresa(obj))
def get_regra_empresa_label(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
regra = REGRAS_EMPRESA.get(obj.regra_empresa)
return regra["label"] if regra else None
def get_regra_empresa_observacoes(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
regra = REGRAS_EMPRESA.get(obj.regra_empresa)
observacoes = (regra or {}).get("observacoes")
return observacoes or None
def get_resumo_por_tipo(self, obj: ImportacaoPlanoSaudeDePaula) -> list[dict[str, Any]]:
resumo = []
for tipo in obj.tipos_lancamento:
linhas_do_tipo = [l for l in obj.linhas.all() if l.tipo_lancamento == tipo]
lancadas = sum(1 for l in linhas_do_tipo if l.valor != "0" or l.valor_empresa != "0")
auditoria_do_tipo = sum(
1 for item in obj.itens_auditoria.all() if item.tipo_lancamento == tipo and not item.resolvida
)
resumo.append({
"tipo_lancamento": tipo,
"total_linhas": len(linhas_do_tipo),
"total_lancadas": lancadas,
"total_auditoria": auditoria_do_tipo,
})
return resumo
class RegraCusteioPlanoSaudeDePaulaSerializer(serializers.ModelSerializer):
"""Ver `RegraCusteioPlanoSaudeSerializer` — mesma validação, escopo De
Paula."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
nome = serializers.CharField(read_only=True)
nome_empresa = serializers.SerializerMethodField()
class Meta:
model = RegraCusteioPlanoSaudeDePaula
fields = [
"id",
"nome",
"codigo_empresa",
"nome_empresa",
"operadora",
"regra_empresa_chave",
"tipos_lancamento",
"custeio_por_tipo",
"observacoes",
"criado_por_nome",
"criado_em",
"atualizado_em",
]
read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"]
validators = [
UniqueTogetherValidator(
queryset=RegraCusteioPlanoSaudeDePaula.objects.all(),
fields=["codigo_empresa", "operadora"],
message="Já existe uma regra de custeio cadastrada para essa empresa com essa operadora.",
)
]
def get_nome_empresa(self, obj: RegraCusteioPlanoSaudeDePaula) -> str | None:
return resolve_nome_empresa(obj.codigo_empresa)
def validate_codigo_empresa(self, value: str) -> str:
value = normalizar_codigo_empresa(value)
if not value:
raise serializers.ValidationError("Informe o código da empresa.")
return value
def validate_operadora(self, value: str) -> str:
value = (value or "").strip()
if value not in OPERADORAS:
raise serializers.ValidationError(f"Operadora inválida: {value}")
return value
def validate_regra_empresa_chave(self, value: str) -> str:
value = (value or "").strip()
if value and value not in REGRAS_EMPRESA:
raise serializers.ValidationError(f"Regra empresa desconhecida: {value}")
return value
def validate_tipos_lancamento(self, value: Any) -> list[str]:
if not isinstance(value, list) or not value:
raise serializers.ValidationError("Selecione ao menos um tipo de importação.")
for tipo in value:
if tipo not in TIPOS_LANCAMENTO_VALIDOS:
raise serializers.ValidationError(f"Tipo de importação inválido: {tipo}")
return value
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
tipos = attrs.get("tipos_lancamento")
if tipos is None:
tipos = self.instance.tipos_lancamento if self.instance else []
codigo_empresa = attrs.get("codigo_empresa", self.instance.codigo_empresa if self.instance else None)
operadora_key = attrs.get("operadora", self.instance.operadora if self.instance else None)
regra_empresa_chave = attrs.get(
"regra_empresa_chave", self.instance.regra_empresa_chave if self.instance else ""
)
custeio_bruto = attrs.get("custeio_por_tipo")
if custeio_bruto is None:
custeio_bruto = self.instance.custeio_por_tipo if self.instance else {}
if not isinstance(custeio_bruto, dict):
raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."})
regra_empresa_tipos: tuple[str, ...] = ()
if regra_empresa_chave:
regra_re = REGRAS_EMPRESA[regra_empresa_chave]
regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
if not regra_empresa_tipos:
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
"mas nenhum desses tipos de importação está selecionado."
)
}
)
# Uma regra pode cobrir mais de uma empresa quando são do mesmo
# grupo e a condição negociada é idêntica (ver `codigos_empresa`
# em planos_saude.regras_empresa).
if codigo_empresa not in regra_re["codigos_empresa"]:
aceitos = " ou ".join(regra_re["codigos_empresa"])
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"Esta regra especial foi cadastrada para a empresa código "
f"{aceitos}, não para {codigo_empresa}."
)
}
)
if regra_re["operadora"] != operadora_key:
raise serializers.ValidationError(
{"regra_empresa_chave": "Esta regra especial foi cadastrada para outra operadora."}
)
custeio_validado: dict[str, Any] = {}
for tipo in tipos:
if tipo in regra_empresa_tipos:
custeio_validado[tipo] = {}
continue
por_tipo = custeio_bruto.get(tipo) or {}
if not isinstance(por_tipo, dict):
raise serializers.ValidationError({"custeio_por_tipo": f"Formato inválido para o tipo {tipo}."})
custeio_por_pessoa: dict[str, Any] = {}
for pessoa in TIPOS_PESSOA_VALIDOS:
chave = f"{tipo}_{pessoa}"
entrada = por_tipo.get(pessoa) or {}
if not isinstance(entrada, dict):
entrada = {}
custeio_por_pessoa[pessoa] = _monta_regra_custeio(
chave,
entrada.get("modo"),
_valor_custeio_para_texto_br(entrada.get("limite_valor")),
_valor_custeio_para_texto_br(entrada.get("percentual")),
_valor_custeio_para_texto_br(entrada.get("limite_desconto_empregado")),
)
custeio_validado[tipo] = custeio_por_pessoa
attrs["custeio_por_tipo"] = custeio_validado
attrs["nome"] = f"{codigo_empresa} - {_nome_operadora_sem_codigo(operadora_key)}"
return attrs
class IndicadorPercentualTipoSerializer(serializers.ModelSerializer):
tipo_label = serializers.CharField(source="get_tipo_display", read_only=True)
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
departamento_nome = serializers.CharField(source="departamento.nome", read_only=True)
class Meta:
model = IndicadorPercentualTipo
fields = [
"id",
"departamento",
"departamento_nome",
"tipo",
"tipo_label",
"percentual_individual",
"percentual_grupo",
"percentual_departamento",
"vigente_desde",
"criado_por_nome",
"criado_em",
]
read_only_fields = ["id", "criado_em"]
class IndicadorCriterioSerializer(serializers.ModelSerializer):
departamento_nome = serializers.CharField(source="departamento.nome", read_only=True)
class Meta:
model = IndicadorCriterio
fields = [
"id",
"departamento",
"departamento_nome",
"nome",
"grupo",
"peso",
"periodo",
"papel_aplicavel",
"calculo_automatico",
"limiar_percentual",
"ativo",
"criado_em",
]
read_only_fields = ["id", "criado_em"]
class IndicadorApuracaoCreateSerializer(serializers.Serializer):
"""Entrada de "Nova Apuração" (multipart): mês/ano de competência + as duas
planilhas mensais (Serviços Tareffa e Honorários Por Cliente)."""
competencia = serializers.DateField()
planilha_tareffa = serializers.FileField()
planilha_honorarios = serializers.FileField()
class IndicadorApuracaoEmpresaSerializer(serializers.ModelSerializer):
tipo_label = serializers.CharField(source="get_tipo_display", read_only=True)
class Meta:
model = IndicadorApuracaoEmpresa
fields = [
"id",
"codigo_empresa",
"nome_empresa",
"honorario",
"honorario_ajustado",
"honorario_nao_encontrado",
"honorario_ajustado_manualmente",
"tipo",
"tipo_label",
"valor_individual",
"valor_grupo",
"valor_departamento",
"valor_total",
]
read_only_fields = fields
class IndicadorApuracaoEmpresaAjusteSerializer(serializers.Serializer):
"""Preenchimento manual do honorário de uma empresa com
`honorario_nao_encontrado=True` — ver `IndicadorApuracaoEmpresaViewSet`."""
honorario = serializers.DecimalField(max_digits=12, decimal_places=2)
class IndicadorApuracaoEmpresaTrocarResponsavelSerializer(serializers.Serializer):
"""Reatribui uma linha (empresa+tipo) pra outro colaborador da mesma
apuração — ver `IndicadorApuracaoEmpresaViewSet.trocar_responsavel`."""
colaborador_id = serializers.IntegerField()
class IndicadorApuracaoRespostaSerializer(serializers.ModelSerializer):
criterio_nome = serializers.CharField(source="criterio.nome", read_only=True)
criterio_grupo = serializers.CharField(source="criterio.grupo", read_only=True)
criterio_peso = serializers.DecimalField(source="criterio.peso", read_only=True, max_digits=7, decimal_places=4)
criterio_periodo = serializers.CharField(source="criterio.periodo", read_only=True)
criterio_papel_aplicavel = serializers.CharField(source="criterio.papel_aplicavel", read_only=True)
class Meta:
model = IndicadorApuracaoResposta
fields = [
"id",
"colaborador",
"criterio",
"criterio_nome",
"criterio_grupo",
"criterio_peso",
"criterio_periodo",
"criterio_papel_aplicavel",
"valor",
"valor_automatico",
"percentual_calculado",
"ajustado_manualmente",
]
read_only_fields = ["id", "colaborador", "criterio", "valor_automatico", "percentual_calculado"]
def update(self, instance: IndicadorApuracaoResposta, validated_data: dict[str, Any]) -> IndicadorApuracaoResposta:
validated_data["ajustado_manualmente"] = True
return super().update(instance, validated_data)
class IndicadorApuracaoRespostaLoteSerializer(serializers.Serializer):
resposta_ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False)
valor = serializers.ChoiceField(choices=["SIM", "NAO", "NAO_FAZ", "NAO_SE_APLICA"])
class IndicadorApuracaoColaboradorSerializer(serializers.ModelSerializer):
empresas = IndicadorApuracaoEmpresaSerializer(many=True, read_only=True)
respostas = IndicadorApuracaoRespostaSerializer(many=True, read_only=True)
composicao_individual = serializers.SerializerMethodField()
# `departamento.nome` com fallback None — colaborador cujo gerente ainda
# não está mapeado a nenhum departamento (ver IndicadorDepartamentoGerente)
# fica com `departamento` nulo; mesmo padrão de `criado_por_nome` acima.
departamento_nome = serializers.CharField(source="departamento.nome", read_only=True, default=None)
class Meta:
model = IndicadorApuracaoColaborador
fields = [
"id",
"nome",
"gerente",
"departamento",
"departamento_nome",
"pct_individual",
"pct_individual_ajustado_manualmente",
"pct_grupo",
"pct_grupo_ajustado_manualmente",
"pct_departamento",
"pct_departamento_ajustado_manualmente",
"valor_total",
"validado",
"empresas",
"respostas",
"composicao_individual",
]
read_only_fields = fields
def get_composicao_individual(self, obj: IndicadorApuracaoColaborador) -> dict[str, Any]:
"""Detalhamento de como o "Total Indicador" (`pct_individual`) foi
composto a partir dos 3 níveis — só pra exibição (card de revisão),
ver `portal_api.indicadores.calculo.composicao_individual`."""
composicao = indicadores_calculo.composicao_individual(obj)
return {
"individual": {
"percentual": float(composicao["individual"]["percentual"]),
"peso": float(composicao["individual"]["peso"]),
},
"grupo": {
"percentual": float(composicao["grupo"]["percentual"]),
"peso": float(composicao["grupo"]["peso"]),
},
"departamento": {
"percentual": float(composicao["departamento"]["percentual"]),
"peso": float(composicao["departamento"]["peso"]),
},
"total": float(composicao["total"]),
"ajustado_manualmente": composicao["ajustado_manualmente"],
}
class IndicadorApuracaoColaboradorAjusteSerializer(serializers.Serializer):
"""Ajuste manual do percentual Individual de um colaborador — passa a
valer no cálculo (`pct_individual_ajustado_manualmente=True`) até ser
revertido pela action `recalcular`. Grupo e Departamento não são ajustados
por colaborador (ver IndicadorApuracaoAjusteGrupoSerializer/
IndicadorApuracaoAjusteDepartamentoSerializer, em IndicadorApuracaoViewSet
— cada um deles vale de uma vez pra todo o gerente/apuração, ver CLAUDE.md)."""
pct_individual = serializers.DecimalField(max_digits=7, decimal_places=4)
class IndicadorApuracaoColaboradorValidadoSerializer(serializers.Serializer):
"""Checklist de revisão do RH (`IndicadorApuracaoColaborador.validado`) —
não afeta nenhum cálculo, ver `IndicadorApuracaoColaboradorViewSet.marcar_validado`."""
validado = serializers.BooleanField()
class IndicadorApuracaoAjusteGrupoSerializer(serializers.Serializer):
"""Ajuste manual do percentual Grupo — aplicado de uma vez a todos os
colaboradores do `gerente` informado dentro da apuração (ver
`IndicadorApuracaoViewSet.ajustar_grupo`)."""
gerente = serializers.CharField(allow_blank=True)
pct_grupo = serializers.DecimalField(max_digits=7, decimal_places=4)
class IndicadorApuracaoAjusteDepartamentoSerializer(serializers.Serializer):
"""Ajuste manual do percentual Departamento — aplicado de uma vez a todos
os colaboradores do `departamento` (id de um IndicadorDepartamento)
informado dentro da apuração (cada departamento tem sua própria meta de
Departamento, ver `IndicadorApuracaoViewSet.ajustar_departamento`)."""
departamento = serializers.IntegerField()
pct_departamento = serializers.DecimalField(max_digits=7, decimal_places=4)
class IndicadorApuracaoRecalcularGrupoSerializer(serializers.Serializer):
gerente = serializers.CharField(allow_blank=True)
class IndicadorApuracaoRecalcularDepartamentoSerializer(serializers.Serializer):
departamento = serializers.IntegerField()
class IndicadorDepartamentoGerenteSerializer(serializers.ModelSerializer):
departamento_nome = serializers.CharField(source="departamento.nome", read_only=True)
class Meta:
model = IndicadorDepartamentoGerente
fields = ["id", "departamento", "departamento_nome", "nome_gerente"]
read_only_fields = ["id"]
class IndicadorDepartamentoSerializer(serializers.ModelSerializer):
gerentes = serializers.SlugRelatedField(slug_field="nome_gerente", many=True, read_only=True)
class Meta:
model = IndicadorDepartamento
fields = ["id", "nome", "ativo", "gerentes", "criado_em"]
read_only_fields = ["id", "gerentes", "criado_em"]
class IndicadorApuracaoAjusteHonorarioEmpresaSerializer(serializers.Serializer):
"""Preenchimento manual do honorário de uma empresa **por código**, de uma
vez pra todos os colaboradores da apuração que a têm em
`honorario_nao_encontrado=True` — a mesma empresa pode aparecer em mais de
um colaborador (um por balancete, outro por liberação fiscal etc.), mas o
honorário é sempre o mesmo valor, então informar uma vez já resolve todo
mundo (ver `IndicadorApuracaoViewSet.ajustar_honorario_empresa`)."""
codigo_empresa = serializers.CharField()
honorario = serializers.DecimalField(max_digits=12, decimal_places=2)
class IndicadorApuracaoListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
total_colaboradores = serializers.SerializerMethodField()
class Meta:
model = IndicadorApuracao
fields = [
"id",
"competencia",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"total_colaboradores",
]
def get_total_colaboradores(self, obj: IndicadorApuracao) -> int:
return obj.colaboradores.count()
class IndicadorApuracaoDetailSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
colaboradores = IndicadorApuracaoColaboradorSerializer(many=True, read_only=True)
class Meta:
model = IndicadorApuracao
fields = [
"id",
"competencia",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"avisos",
"colaboradores",
]
class SimulacaoCustoContratacaoEntradaSerializer(serializers.Serializer):
"""Entrada do formulário de "Simulação de Custo de Contratação" (Geradoc) —
sem model por trás, a ferramenta não persiste nada (ver CLAUDE.md). Campos
monetários/percentuais chegam como texto no formato brasileiro (ex.:
"8.500,00") e são convertidos com `parse_valor_br`, mesmo padrão já usado
em ImportacaoPlanoSaudeCreateSerializer."""
nome_referencia = serializers.CharField(required=False, allow_blank=True, max_length=200, default="")
empresa = serializers.CharField(required=False, allow_blank=True, max_length=200, default="")
cargo = serializers.CharField(required=False, allow_blank=True, max_length=200, default="")
salario_contratual = serializers.CharField()
adicional_art62 = serializers.CharField(required=False, allow_blank=True, default="0")
comissao = serializers.CharField(required=False, allow_blank=True, default="0")
dsr = serializers.CharField(required=False, allow_blank=True, default="0")
outros_itens_remuneracao = serializers.ListField(child=serializers.DictField(), required=False, default=list)
percentual_inss_empregador = serializers.CharField()
percentual_fgts = serializers.CharField()
dependentes = serializers.IntegerField(required=False, min_value=0, default=0)
vale_transporte = serializers.CharField(required=False, allow_blank=True, default="0")
seguro_vida = serializers.CharField(required=False, allow_blank=True, default="0")
vale_alimentacao = serializers.CharField(required=False, allow_blank=True, default="0")
assiduidade = serializers.CharField(required=False, allow_blank=True, default="0")
fundo_formacao = serializers.CharField(required=False, allow_blank=True, default="0")
outros_beneficios = serializers.ListField(child=serializers.DictField(), required=False, default=list)
desconto_vale_transporte = serializers.CharField(required=False, allow_blank=True, default="0")
desconto_vale_alimentacao = serializers.CharField(required=False, allow_blank=True, default="0")
desconto_sindicato = serializers.CharField(required=False, allow_blank=True, default="0")
outros_descontos = serializers.ListField(child=serializers.DictField(), required=False, default=list)
def _valida_itens_nome_valor(self, valor: Any, nome_campo: str) -> list[dict[str, str]]:
itens = []
for item in valor:
nome = str(item.get("nome", "")).strip()
if not nome:
raise serializers.ValidationError(f"Informe o nome de cada item em {nome_campo}.")
itens.append({"nome": nome, "valor": str(item.get("valor", "0"))})
return itens
def validate_outros_itens_remuneracao(self, valor: Any) -> list[dict[str, str]]:
return self._valida_itens_nome_valor(valor, "outros itens de remuneração")
def validate_outros_beneficios(self, valor: Any) -> list[dict[str, str]]:
return self._valida_itens_nome_valor(valor, "outros benefícios")
def validate_outros_descontos(self, valor: Any) -> list[dict[str, str]]:
return self._valida_itens_nome_valor(valor, "outros descontos")
def validate_salario_contratual(self, valor: str) -> str:
if parse_valor_br(valor) <= 0:
raise serializers.ValidationError("Informe o salário contratual.")
return valor
def validate_percentual_inss_empregador(self, valor: str) -> str:
if not (0 <= parse_valor_br(valor) <= 100):
raise serializers.ValidationError("Informe um percentual entre 0 e 100.")
return valor
def validate_percentual_fgts(self, valor: str) -> str:
if not (0 <= parse_valor_br(valor) <= 100):
raise serializers.ValidationError("Informe um percentual entre 0 e 100.")
return valor
def to_entrada(self) -> EntradaSimulacaoEmpregado:
dados = self.validated_data
return EntradaSimulacaoEmpregado(
nome_referencia=dados["nome_referencia"],
empresa=dados["empresa"],
cargo=dados["cargo"],
salario_contratual=parse_valor_br(dados["salario_contratual"]),
adicional_art62=parse_valor_br(dados["adicional_art62"]),
comissao=parse_valor_br(dados["comissao"]),
dsr=parse_valor_br(dados["dsr"]),
outros_itens_remuneracao=[
(item["nome"], parse_valor_br(item["valor"])) for item in dados["outros_itens_remuneracao"]
],
percentual_inss_empregador=parse_valor_br(dados["percentual_inss_empregador"]) / 100,
percentual_fgts=parse_valor_br(dados["percentual_fgts"]) / 100,
dependentes=dados["dependentes"],
vale_transporte=parse_valor_br(dados["vale_transporte"]),
seguro_vida=parse_valor_br(dados["seguro_vida"]),
vale_alimentacao=parse_valor_br(dados["vale_alimentacao"]),
assiduidade=parse_valor_br(dados["assiduidade"]),
fundo_formacao=parse_valor_br(dados["fundo_formacao"]),
outros_beneficios=[(item["nome"], parse_valor_br(item["valor"])) for item in dados["outros_beneficios"]],
desconto_vale_transporte=parse_valor_br(dados["desconto_vale_transporte"]),
desconto_vale_alimentacao=parse_valor_br(dados["desconto_vale_alimentacao"]),
desconto_sindicato=parse_valor_br(dados["desconto_sindicato"]),
outros_descontos=[(item["nome"], parse_valor_br(item["valor"])) for item in dados["outros_descontos"]],
)
def _valida_faixas(valor: Any, nome_campo: str) -> list[dict[str, float]]:
"""Valida o formato de `faixas_inss`/`faixas_irrf` (lista de
{limite_superior, aliquota, deduzir}) e devolve já ordenada por
`limite_superior` — a UI não precisa garantir a ordem."""
if not isinstance(valor, list) or not valor:
raise serializers.ValidationError(f"Informe ao menos uma faixa de {nome_campo}.")
faixas = []
for item in valor:
try:
limite = float(item["limite_superior"])
aliquota = float(item["aliquota"])
deduzir = float(item["deduzir"])
except (KeyError, TypeError, ValueError):
raise serializers.ValidationError(
f"Cada faixa de {nome_campo} precisa de limite_superior, aliquota e deduzir numéricos."
)
if limite <= 0:
raise serializers.ValidationError(f"O limite superior de uma faixa de {nome_campo} precisa ser positivo.")
if not (0 <= aliquota <= 1):
raise serializers.ValidationError(f"A alíquota de uma faixa de {nome_campo} precisa estar entre 0 e 1.")
faixas.append({"limite_superior": limite, "aliquota": aliquota, "deduzir": deduzir})
faixas.sort(key=lambda f: f["limite_superior"])
return faixas
class ParametroFiscalCustoContratacaoSerializer(serializers.ModelSerializer):
"""Tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela
Simulação de Custo de Contratação — editável pela própria tela, sem
histórico: é sempre "o valor vigente agora" que vale (ver CLAUDE.md)."""
class Meta:
model = ParametroFiscalCustoContratacao
fields = [
"faixas_inss",
"teto_desconto_inss",
"faixas_irrf",
"aliquota_irrf_topo",
"deduzir_irrf_topo",
"desconto_simplificado_irrf",
"deducao_por_dependente",
"reducao_lei_15270_coeficiente_a",
"reducao_lei_15270_coeficiente_b",
"reducao_lei_15270_limite",
"atualizado_em",
]
read_only_fields = ["atualizado_em"]
def validate_faixas_inss(self, valor: Any) -> list[dict[str, float]]:
return _valida_faixas(valor, "INSS")
def validate_faixas_irrf(self, valor: Any) -> list[dict[str, float]]:
return _valida_faixas(valor, "IRRF")
class NCAcompanhamentoSerializer(serializers.ModelSerializer):
class Meta:
model = NCAcompanhamento
fields = ["id", "data", "autor", "texto", "eh_prorrogacao"]
class NCAcaoSerializer(serializers.ModelSerializer):
"""`status_prazo`/`dias_para_vencer` são sempre calculados em tempo de
leitura (nunca persistidos — ver NCAcao.vencimento_efetivo em models.py e
o motivo em nao_conformidades/classificacao.py)."""
ocorrencia_codigo = serializers.IntegerField(source="ocorrencia.codigo", read_only=True)
ocorrencia_assunto = serializers.CharField(source="ocorrencia.assunto", read_only=True)
# Permite filtrar/buscar Ações por empresa no frontend (busca compartilhada
# da Gestão e drill-down dos rankings do Dashboard) sem duplicar o dado.
ocorrencia_clientes = serializers.CharField(source="ocorrencia.clientes_relacionados", read_only=True)
ocorrencia_area = serializers.CharField(source="ocorrencia.area", read_only=True)
ocorrencia_tipo_ocorrencia = serializers.CharField(source="ocorrencia.tipo_ocorrencia", read_only=True)
status_prazo = serializers.SerializerMethodField()
status_prazo_label = serializers.SerializerMethodField()
dias_para_vencer = serializers.SerializerMethodField()
class Meta:
model = NCAcao
fields = [
"id",
"ocorrencia",
"ocorrencia_codigo",
"ocorrencia_assunto",
"ocorrencia_clientes",
"ocorrencia_area",
"ocorrencia_tipo_ocorrencia",
"codigo",
"data_emissao",
"tipo_acao",
"acao_texto",
"data_conclusao",
"prazo_prorrogado",
"justificativa_prorrogacao",
"executor",
"emissor_acao",
"situacao",
"fase",
"vencimento_efetivo",
"data_finalizacao",
"ultimo_acompanhamento_em",
"ultimo_acompanhamento_eh_prorrogacao",
"status_tratativa",
"tratado_em",
"reaberto_em",
"reaberto_motivo",
"status_prazo",
"status_prazo_label",
"dias_para_vencer",
]
def get_status_prazo(self, obj: NCAcao) -> str:
return nc_classificacao.status_prazo(
obj.vencimento_efetivo, timezone.localdate(), finalizada=bool(obj.data_finalizacao)
)
def get_status_prazo_label(self, obj: NCAcao) -> str:
return nc_classificacao.STATUS_PRAZO_LABELS[self.get_status_prazo(obj)]
def get_dias_para_vencer(self, obj: NCAcao) -> int | None:
if obj.data_finalizacao:
return None
return nc_classificacao.dias_para_vencer(obj.vencimento_efetivo, timezone.localdate())
class NCAcaoDetailSerializer(NCAcaoSerializer):
acompanhamentos = NCAcompanhamentoSerializer(many=True, read_only=True)
class Meta(NCAcaoSerializer.Meta):
fields = NCAcaoSerializer.Meta.fields + ["acompanhamentos"]
class NCOcorrenciaSerializer(serializers.ModelSerializer):
sem_analise = serializers.SerializerMethodField()
class Meta:
model = NCOcorrencia
fields = [
"id",
"codigo",
"data_emissao",
"assunto",
"tipo_ocorrencia",
"clientes_relacionados",
"area",
"setor",
"emissor_relato",
"indicado_analise",
"tipos_causa",
"descricao_analise",
"responsavel_analise",
"data_analise",
"analise_sem_acao_detectada",
"status_tratativa",
"tratado_em",
"reaberto_em",
"reaberto_motivo",
"sem_analise",
]
def get_sem_analise(self, obj: NCOcorrencia) -> bool:
return not obj.descricao_analise.strip()
class NCOcorrenciaDetailSerializer(NCOcorrenciaSerializer):
acoes = NCAcaoSerializer(many=True, read_only=True)
class Meta(NCOcorrenciaSerializer.Meta):
fields = NCOcorrenciaSerializer.Meta.fields + [
"relato",
"pessoas_relacionadas",
"origem",
"fornecedores_relacionados",
"riscos_relacionados",
"data_relato",
"representante_gerente",
"prazo_finalizar",
"acoes",
]
NAO_CONFORMIDADE_EXTENSOES_OCORRENCIAS = (".xlsx",)
NAO_CONFORMIDADE_EXTENSOES_ACOES = (".xls",)
class NaoConformidadeImportacaoCreateSerializer(serializers.Serializer):
arquivo_ocorrencias = serializers.FileField()
arquivo_acoes = serializers.FileField()
def validate_arquivo_ocorrencias(self, arquivo: Any) -> Any:
if not arquivo.name.lower().endswith(NAO_CONFORMIDADE_EXTENSOES_OCORRENCIAS):
raise serializers.ValidationError("O arquivo de ocorrências deve ser um .xlsx.")
return arquivo
def validate_arquivo_acoes(self, arquivo: Any) -> Any:
if not arquivo.name.lower().endswith(NAO_CONFORMIDADE_EXTENSOES_ACOES):
raise serializers.ValidationError("O arquivo de ações deve ser um .xls.")
return arquivo
class NaoConformidadeImportacaoSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
class Meta:
model = NaoConformidadeImportacao
fields = ["id", "criado_por_nome", "criado_em", "resumo", "avisos"]
class ContabilApuracaoCreateSerializer(serializers.Serializer):
"""Entrada de "Nova Análise" (multipart): só o PDF de Balancete + DRE —
empresa/competência/período são extraídos do próprio arquivo pelo
pipeline, não informados pelo usuário."""
arquivo = serializers.FileField()
class ContabilContaSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilConta
fields = [
"id",
"conta_numero",
"codigo",
"descricao",
"tipo",
"saldo_anterior",
"debito",
"credito",
"saldo_atual",
"validado",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
read_only_fields = [
"id",
"conta_numero",
"codigo",
"descricao",
"tipo",
"saldo_anterior",
"debito",
"credito",
"saldo_atual",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
class ContabilLinhaDreSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilLinhaDre
fields = [
"id",
"ordem",
"descricao",
"nivel",
"valor",
"totalizador",
"validado",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
read_only_fields = [
"id",
"ordem",
"descricao",
"nivel",
"valor",
"totalizador",
"alterada_reprocessamento",
"valor_anterior_reprocessamento",
]
class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer):
class Meta:
model = ContabilLinhaAnaliseVertical
fields = [
"id",
"ordem",
"descricao",
"nivel",
"totalizador",
"valores",
"validado",
"alterada_reprocessamento",
"valores_anterior_reprocessamento",
]
read_only_fields = [
"id",
"ordem",
"descricao",
"nivel",
"totalizador",
"valores",
"alterada_reprocessamento",
"valores_anterior_reprocessamento",
]
class ContabilObservacaoEdicaoSerializer(serializers.ModelSerializer):
"""Um item do histórico de edições de texto de uma observação (ver
`ContabilObservacaoEdicao`) — só existe pra alimentar o modal "Histórico
de edições" da tela, nunca é lido separadamente da observação."""
editado_por_nome = serializers.CharField(source="editado_por.nome", read_only=True, default=None)
class Meta:
model = ContabilObservacaoEdicao
fields = ["id", "texto_anterior", "texto_novo", "editado_por_nome", "editado_em"]
read_only_fields = fields
class ContabilObservacaoSerializer(serializers.ModelSerializer):
"""Leitura de uma observação do histórico (ver `ContabilObservacao`).
`historica`/`encerrada` são derivados da competência da apuração que
está sendo exibida, passada em `context["competencia"]` — a mesma
observação é editável na apuração que a criou e somente leitura em
qualquer competência posterior. `editada`/`edicoes` refletem
`ContabilObservacaoEdicao` (nunca vazio depois de qualquer mudança real
de texto) — usados pro selo "Editada" e pro histórico de edições na
tela; o relatório HTML pro cliente não usa este serializer, então esses
dois campos nunca aparecem lá. `criado_por` (o id, não só o nome) é o que
o frontend compara contra `me.id` pra decidir se mostra o botão de
excluir — `ContabilObservacaoViewSet.perform_destroy()` faz a mesma
checagem no servidor, então esconder o botão é só UX, nunca a única
barreira."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
encerrada_por_nome = serializers.CharField(source="encerrada_por.nome", read_only=True, default=None)
historica = serializers.SerializerMethodField()
encerrada = serializers.SerializerMethodField()
editada = serializers.SerializerMethodField()
edicoes = ContabilObservacaoEdicaoSerializer(many=True, read_only=True)
class Meta:
model = ContabilObservacao
fields = [
"id",
"codigo_empresa",
"alvo_tipo",
"alvo_chave",
"alvo_rotulo",
"apuracao_origem",
"competencia_origem",
"texto",
"mostrar_ao_cliente",
"criado_por",
"criado_por_nome",
"criado_em",
"encerrada_em_competencia",
"encerrada_por_nome",
"encerrada_em",
"historica",
"encerrada",
"editada",
"edicoes",
]
read_only_fields = fields
def get_historica(self, obj: ContabilObservacao) -> bool:
competencia = self.context.get("competencia")
return bool(competencia and obj.eh_historica_em(competencia))
def get_encerrada(self, obj: ContabilObservacao) -> bool:
return obj.encerrada_em_competencia is not None
def get_editada(self, obj: ContabilObservacao) -> bool:
return len(obj.edicoes.all()) > 0
class ContabilObservacaoCreateSerializer(serializers.Serializer):
"""Entrada de `ContabilObservacaoViewSet.create()` — o cliente informa a
apuração aberta e **qual linha dela** recebeu a observação (`alvo_id`, o
id da `ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`);
empresa, competência, chave natural e rótulo do alvo são derivados no
servidor a partir desse objeto, nunca aceitos do cliente (mesmo espírito
da `chave` derivada em `IndicadorContabilDefinicao`)."""
apuracao = serializers.PrimaryKeyRelatedField(queryset=ContabilApuracao.objects.all())
alvo_tipo = serializers.ChoiceField(choices=[opcao[0] for opcao in ContabilObservacao.ALVO_CHOICES])
alvo_id = serializers.IntegerField()
texto = serializers.CharField(allow_blank=False)
mostrar_ao_cliente = serializers.BooleanField(required=False, default=False)
class ContabilObservacaoUpdateSerializer(serializers.Serializer):
"""PATCH de uma observação — os dois campos são opcionais e independentes:
`texto` só é aceito enquanto a observação não for histórica (ver
`ContabilObservacaoViewSet.partial_update()`), `mostrar_ao_cliente` é
sempre aceito (decisão editorial de cada relatório, não faz parte do que
o histórico congela)."""
texto = serializers.CharField(allow_blank=False, required=False)
mostrar_ao_cliente = serializers.BooleanField(required=False)
def validate(self, attrs: dict) -> dict:
if not attrs:
raise serializers.ValidationError({"detail": "Informe ao menos um campo para alterar."})
return attrs
class ContabilObservacaoApuracaoSerializer(serializers.Serializer):
"""Corpo de `ContabilObservacaoViewSet.encerrar()`/`reativar()` — a
apuração aberta é quem define a competência em que a observação deixa de
valer ("ocultar das próximas execuções"), então ela precisa vir no corpo
mesmo nessas ações que não mudam o texto."""
apuracao = serializers.PrimaryKeyRelatedField(queryset=ContabilApuracao.objects.all())
class ContabilAchadoSerializer(serializers.ModelSerializer):
conta_codigo = serializers.CharField(source="conta.codigo", read_only=True, default=None)
conta_descricao = serializers.CharField(source="conta.descricao", read_only=True, default=None)
tratado_por_nome = serializers.CharField(source="tratado_por.nome", read_only=True, default=None)
class Meta:
model = ContabilAchado
fields = [
"id",
"conta",
"conta_codigo",
"conta_descricao",
# `id` da `ContabilLinhaAnaliseVertical` referenciada (achados sem
# conta associada, ex. variação atípica na Análise Vertical) — o
# frontend já tem a linha completa cacheada em
# `apuracaoAtual.linhas_analise_vertical`, então só o id basta pro
# botão "Ver na tabela" (ver dashboard-contabil.js, dcIrParaLinha).
"linha_analise_vertical",
"regra",
"severidade",
"titulo",
"mensagem",
"valor_referencia",
"status",
"observacao_contador",
"tratado_por_nome",
"tratado_em",
"oculto_no_relatorio",
]
read_only_fields = [
"id",
"conta",
"conta_codigo",
"conta_descricao",
"linha_analise_vertical",
"regra",
"severidade",
"titulo",
"mensagem",
"valor_referencia",
"tratado_por_nome",
"tratado_em",
"oculto_no_relatorio",
]
class ContabilAchadoAjusteSerializer(serializers.Serializer):
"""PATCH de um achado — mudar status sempre exige uma observação
justificando (tratado ou ignorado), ver `ContabilAchadoViewSet`."""
status = serializers.ChoiceField(choices=[ContabilAchado.STATUS_TRATADO, ContabilAchado.STATUS_IGNORADO])
observacao_contador = serializers.CharField(allow_blank=False)
class ContabilAchadoValidarSerializer(serializers.Serializer):
"""Corpo de `ContabilAchadoViewSet.validar()` — só para variação atípica
da Análise Vertical, que é tratada sem justificativa escrita (o texto
dessas variações mora em `ContabilObservacao` da linha, ver a view)."""
validado = serializers.BooleanField()
class ContabilAchadoOcultoSerializer(serializers.Serializer):
"""Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de
`ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve
exigir status/justificativa (mesmo achado pode continuar "Pendente" e
ainda assim ter sua observação escondida do relatório, ver aba
"Dashboard" da tela de revisão)."""
oculto_no_relatorio = serializers.BooleanField()
class ContabilIndicadoresOcultosSerializer(serializers.Serializer):
"""Corpo de `ContabilApuracaoViewSet.indicadores_ocultos()` — substitui
a lista inteira de cards escondidos do relatório de uma vez (o
frontend já tem a lista atual, só alterna uma chave e reenvia). Aceita
a chave de qualquer `IndicadorContabilDefinicao` já cadastrada
(inclusive os antigos 11 "de sistema", migrados pra cá) — por isso não
dá pra usar um `ChoiceField` com uma lista fixa de opções, a validação
roda contra o banco em `validate_indicadores_ocultos`.
Chave que não existe mais (indicador excluído depois de ter sido
ocultado nesta apuração) é descartada silenciosamente em vez de
rejeitar a lista inteira — é só estado de exibição, não dado
auditado, e o frontend sempre reenvia a lista completa a cada
alternância; rejeitar travaria até alternar qualquer outra chave
(ver bug real, rodada 106: `perform_destroy` de
`IndicadorContabilDefinicaoViewSet` já limpa essa referência na
exclusão, isto aqui é a rede de segurança para o que ficou órfão
antes dessa limpeza existir)."""
indicadores_ocultos = serializers.ListField(child=serializers.CharField(), allow_empty=True)
def validate_indicadores_ocultos(self, chaves: list[str]) -> list[str]:
chaves_validas = set(IndicadorContabilDefinicao.objects.values_list("chave", flat=True))
return [chave for chave in chaves if chave in chaves_validas]
class ContabilIndicadoresSelecionadosSerializer(serializers.Serializer):
"""Corpo de `ContabilApuracaoViewSet.indicadores_selecionados()` —
substitui a lista inteira de indicadores **não padrão** ativados nesta
apuração de uma vez (mesmo espírito de `ContabilIndicadoresOcultosSerializer`).
Só aceita chave de indicador com `padrao=False`: um indicador padrão já
aparece sempre, não faz sentido "selecioná-lo" aqui.
Chave que não existe mais, ou que deixou de ser não padrão, é
descartada silenciosamente em vez de rejeitar a lista inteira — mesmo
raciocínio de `ContabilIndicadoresOcultosSerializer.validate_indicadores_ocultos`
acima."""
indicadores_selecionados = serializers.ListField(child=serializers.CharField(), allow_empty=True)
def validate_indicadores_selecionados(self, chaves: list[str]) -> list[str]:
chaves_nao_padrao = set(
IndicadorContabilDefinicao.objects.filter(padrao=False).values_list("chave", flat=True)
)
return [chave for chave in chaves if chave in chaves_nao_padrao]
class ContabilResumoFechamentoSerializer(serializers.Serializer):
"""Corpo de `ContabilApuracaoViewSet.resumo_fechamento()` — texto rico
(mesmo editor contenteditable + allowlist nh3 de
`AcessoGeral.observacoes`/`AjudaAplicacao.texto`) que o contador escreve
na aba "Dashboard" da revisão, aparece no relatório "Gerar Dashboard"
antes dos cards de indicador. Substitui o campo inteiro de uma vez,
mesmo espírito dos outros dois serializers acima — não há conceito de
edição incremental num texto livre."""
resumo_fechamento = serializers.CharField(allow_blank=True)
def validate_resumo_fechamento(self, value: str) -> str:
return nh3.clean(
value,
tags=RICHTEXT_ALLOWED_TAGS,
attributes=RICHTEXT_ALLOWED_ATTRS,
url_schemes=RICHTEXT_ALLOWED_SCHEMES,
)
class IndicadorContabilComponenteSerializer(serializers.ModelSerializer):
"""Só leitura — usada aninhada em `IndicadorContabilDefinicaoSerializer`
pra mostrar os componentes já salvos de um indicador personalizado.
Escrita passa por `IndicadorContabilComponenteInputSerializer` abaixo,
processada manualmente em `IndicadorContabilDefinicaoViewSet` (troca
todos os componentes de uma vez a cada criação/edição — mais simples e
seguro do que tentar diferenciar quais mudaram)."""
tipo_label = serializers.CharField(source="get_tipo_display", read_only=True)
class Meta:
model = IndicadorContabilComponente
fields = [
"id",
"chave",
"tipo",
"tipo_label",
"contas_codigos",
"linhas_dre_descricoes",
"indicador_referenciado",
]
read_only_fields = fields
class IndicadorContabilDefinicaoSerializer(serializers.ModelSerializer):
"""Só leitura — resposta de GET/POST/PATCH de
`IndicadorContabilDefinicaoViewSet` (a escrita em si é validada por
`IndicadorContabilDefinicaoInputSerializer`, ver abaixo)."""
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
componentes = IndicadorContabilComponenteSerializer(many=True, read_only=True)
class Meta:
model = IndicadorContabilDefinicao
fields = [
"id",
"chave",
"nome",
"descricao",
"formula",
"formula_exibicao",
"formato",
"icone",
"grupo",
"padrao",
"criado_por_nome",
"criado_em",
"atualizado_em",
"componentes",
]
class IndicadorContabilComponenteInputSerializer(serializers.Serializer):
chave = serializers.RegexField(
r"^[a-z][a-z0-9_]*$",
max_length=40,
error_messages={"invalid": "Use só letras minúsculas, números e '_', começando por uma letra."},
)
tipo = serializers.ChoiceField(choices=[t[0] for t in IndicadorContabilComponente.TIPO_CHOICES])
contas_codigos = serializers.ListField(child=serializers.CharField(), required=False, default=list)
linhas_dre_descricoes = serializers.ListField(child=serializers.CharField(), required=False, default=list)
indicador_referenciado = serializers.CharField(required=False, allow_blank=True, default="")
class IndicadorContabilDefinicaoInputSerializer(serializers.Serializer):
"""Corpo de POST/PATCH em `IndicadorContabilDefinicaoViewSet` — sempre o
indicador **inteiro** (nome/descrição/fórmula/formato + a lista completa
de componentes), mesmo em PATCH: um indicador personalizado é pequeno
o bastante (poucos componentes) pra não valer a pena um endpoint de
edição parcial por componente, e evita ter que decidir "o que muda,
o que fica" numa edição incremental. `chave` nunca é aceita aqui — é
sempre derivada do `nome` na criação (ver `IndicadorContabilDefinicaoViewSet`),
imutável depois (mudar a chave quebraria qualquer `indicadores_ocultos`/
referência de outro indicador já salva)."""
nome = serializers.CharField(max_length=120)
descricao = serializers.CharField(required=False, allow_blank=True, default="")
formula = serializers.CharField(max_length=500)
formula_exibicao = serializers.CharField(max_length=300, required=False, allow_blank=True, default="")
formato = serializers.ChoiceField(choices=[f[0] for f in IndicadorContabilDefinicao.FORMATO_CHOICES])
icone = serializers.ChoiceField(
choices=[i[0] for i in IndicadorContabilDefinicao.ICONE_CHOICES],
required=False,
default=IndicadorContabilDefinicao.ICONE_BARRAS,
)
# True (default) = aparece automaticamente em toda apuração, mesmo
# comportamento de sempre; False = fica salvo, só aparece nas apurações
# em que o contador selecionar (ver "Gerenciar Indicadores").
padrao = serializers.BooleanField(required=False, default=True)
componentes = IndicadorContabilComponenteInputSerializer(many=True)
def validate_componentes(self, componentes: list[dict]) -> list[dict]:
if not componentes:
raise serializers.ValidationError("Adicione pelo menos um componente.")
chaves = [c["chave"] for c in componentes]
if len(chaves) != len(set(chaves)):
raise serializers.ValidationError("Cada componente precisa de uma chave única dentro do indicador.")
chaves_indicadores_existentes = set(
IndicadorContabilDefinicao.objects.exclude(
pk=self.instance.pk if self.instance else None
).values_list("chave", flat=True)
)
for componente in componentes:
chave = componente["chave"]
tipo = componente["tipo"]
if tipo in (IndicadorContabilComponente.TIPO_CONTAS, IndicadorContabilComponente.TIPO_VARIACAO_CONTA):
if not componente.get("contas_codigos"):
raise serializers.ValidationError(f'Componente "{chave}": selecione ao menos uma conta.')
elif tipo == IndicadorContabilComponente.TIPO_LINHA_DRE:
if not componente.get("linhas_dre_descricoes"):
raise serializers.ValidationError(f'Componente "{chave}": selecione ao menos uma linha da DRE.')
elif tipo == IndicadorContabilComponente.TIPO_INDICADOR:
referenciado = componente.get("indicador_referenciado")
if not referenciado:
raise serializers.ValidationError(f'Componente "{chave}": selecione o indicador referenciado.')
if referenciado not in chaves_indicadores_existentes:
raise serializers.ValidationError(
f'Componente "{chave}": o indicador "{referenciado}" não existe.'
)
return componentes
def validate(self, attrs: dict) -> dict:
chaves_componentes = {c["chave"] for c in attrs["componentes"]}
try:
dashboard_contabil_formula.valida_formula(attrs["formula"], chaves_componentes)
except dashboard_contabil_formula.FormulaInvalidaError as exc:
raise serializers.ValidationError({"formula": str(exc)}) from exc
return attrs
class ContabilApuracaoReprocessamentoSerializer(serializers.ModelSerializer):
"""Um item do log de reprocessamentos de uma apuração (ver
`ContabilApuracaoReprocessamento`) — alimenta o modal "Reprocessar
análise" da lista, pra o contador ver quem já reprocessou e quantas
vezes, antes de decidir se reprocessa de novo."""
reprocessado_por_nome = serializers.CharField(source="reprocessado_por.nome", read_only=True, default=None)
class Meta:
model = ContabilApuracaoReprocessamento
fields = ["id", "reprocessado_por_nome", "reprocessado_em"]
read_only_fields = fields
class ContabilApuracaoListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
total_achados_pendentes = serializers.SerializerMethodField()
reprocessamentos = ContabilApuracaoReprocessamentoSerializer(many=True, read_only=True)
class Meta:
model = ContabilApuracao
fields = [
"id",
"codigo_empresa",
"nome_empresa",
"cnpj",
"competencia",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"total_achados_pendentes",
"fonte_pdf_atipica",
"leiaute",
"reprocessamentos",
]
def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int:
return sum(1 for achado in obj.achados.all() if achado.status == ContabilAchado.STATUS_PENDENTE)
class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
contas = ContabilContaSerializer(many=True, read_only=True)
linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True)
linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True)
achados = ContabilAchadoSerializer(many=True, read_only=True)
tem_indicadores = serializers.BooleanField(read_only=True)
class Meta:
model = ContabilApuracao
fields = [
"id",
"codigo_empresa",
"nome_empresa",
"cnpj",
"competencia",
"periodo_inicio",
"periodo_fim",
"status",
"criado_por_nome",
"criado_em",
"concluida_em",
"resumo_fechamento",
"fonte_pdf_atipica",
"leiaute",
"tem_indicadores",
"contas",
"linhas_dre",
"analise_vertical_meses",
"linhas_analise_vertical",
"achados",
]
# --- Conciliação de Fornecedores (Utilitários) -----------------------------
# O detalhe (contas + lançamentos + análise recalculada) é montado em
# `_conciliacao_detalhe()` em views.py, porque depende da análise de
# `conciliacao_fornecedores.alertas` (não é campo de model).
class ConciliacaoFornecedorListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
class Meta:
model = ConciliacaoFornecedor
fields = [
"id",
"codigo_empresa",
"nome_empresa",
"data_base",
"periodo_inicio",
"periodo_fim",
"nome_arquivo",
"tolerancia_valor",
"tolerancia_percentual",
"resumo",
"criado_por_nome",
"criado_em",
]
class ConciliacaoFornecedorCreateSerializer(serializers.Serializer):
# Data-base (dia do processamento) e tolerâncias (motor.TOLERANCIA_*)
# são definidas no servidor, nunca aceitas da tela (decisão do usuário).
codigo_empresa = serializers.CharField(max_length=20)
arquivo = serializers.FileField()
def validate_codigo_empresa(self, valor: str) -> str:
codigo = normalizar_codigo_empresa(valor)
if not codigo.isdigit():
raise serializers.ValidationError("Informe o código numérico da empresa.")
return codigo
def validate_arquivo(self, arquivo: Any) -> Any:
nome = (arquivo.name or "").lower()
if not (nome.endswith(".xlsx") or nome.endswith(".csv")):
raise serializers.ValidationError("Anexe a exportação do razão em .xlsx ou .csv.")
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
raise serializers.ValidationError("O arquivo deve ter no máximo 15MB.")
return arquivo
class ConciliacaoFornecedorVincularSerializer(serializers.Serializer):
# 2+ para criar um vínculo; 1 já basta para acrescentar a um existente
# (a exigência de débito + crédito da criação fica na view).
lancamentos = serializers.ListField(child=serializers.IntegerField(), min_length=1)
class ConciliacaoFornecedorValidarSerializer(serializers.Serializer):
validada = serializers.BooleanField()
observacao = serializers.CharField(required=False, allow_blank=True, max_length=5000)