portal_publico/portal_api/serializers.py

1538 lines
66 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 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 .models import (
AcessoGeral,
AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento,
CompromissoAgenda,
Departamento,
EmpresaQuestor,
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
IndicadorApuracaoResposta,
IndicadorCriterio,
IndicadorDepartamento,
IndicadorDepartamentoGerente,
IndicadorPercentualTipo,
LinkFerramenta,
LinkFerramentaFavorito,
NotificacaoDispensada,
ParametroFiscalCustoContratacao,
PerfilAcesso,
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
TelefoneExterno,
Usuario,
WidgetUsuario,
)
from .custo_contratacao.calculo import EntradaSimulacaoEmpregado
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) -> 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)."""
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()
if not limite_bruto and not percentual_bruto:
raise serializers.ValidationError(
{f"custeio_{chave}": "Informe o limite de valor e/ou o percentual de custeio da empresa."}
)
regra: dict[str, Any] = {"modo": "especifica", "limite_valor": None, "percentual": 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
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_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_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_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_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}", ""),
)
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."
)
}
)
if regra_re["codigo_empresa"] != codigo_empresa:
raise serializers.ValidationError(
{
"regra_empresa_chave": (
f"Esta regra especial foi cadastrada para a empresa código "
f"{regra_re['codigo_empresa']}, 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")),
)
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")