1300 lines
54 KiB
Python
1300 lines
54 KiB
Python
from typing import Any
|
||
|
||
import nh3
|
||
from django.db.models import Q
|
||
from rest_framework import serializers
|
||
|
||
from . import catalogo
|
||
from .indicadores import calculo as indicadores_calculo
|
||
from .models import (
|
||
AcessoGeral,
|
||
AcessoGeralSecao,
|
||
CategoriaEvento,
|
||
CompromissoAgenda,
|
||
Departamento,
|
||
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
|
||
|
||
# Observações de Acesso Geral aceitam imagens embutidas (ver "Acessos Gerais" no
|
||
# CLAUDE.md) — 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.
|
||
ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS = {"p", "br", "div", "b", "strong", "i", "em", "u", "ul", "ol", "li", "img"}
|
||
ACESSO_GERAL_OBSERVACOES_ALLOWED_ATTRS = {"img": {"src", "alt"}}
|
||
ACESSO_GERAL_OBSERVACOES_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"]
|
||
|
||
|
||
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)
|
||
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)
|
||
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 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=ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS,
|
||
attributes=ACESSO_GERAL_OBSERVACOES_ALLOWED_ATTRS,
|
||
url_schemes=ACESSO_GERAL_OBSERVACOES_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()))
|
||
planilha_padrao = serializers.FileField()
|
||
arquivo_operadora = serializers.FileField()
|
||
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()
|
||
if regra_empresa_key:
|
||
if regra_empresa_key not in REGRAS_EMPRESA:
|
||
raise serializers.ValidationError({"regra_empresa": f"Regra empresa desconhecida: {regra_empresa_key}"})
|
||
if "mensalidade" not in tipos:
|
||
raise serializers.ValidationError(
|
||
{"regra_empresa": "'Regra empresa' exige o tipo de importação 'Mensalidade' selecionado."}
|
||
)
|
||
parser_classe = OPERADORAS[attrs["operadora"]]["parser"]
|
||
if parser_classe.chave_casamento != "nome":
|
||
raise serializers.ValidationError(
|
||
{"regra_empresa": "Esta operadora não é compatível com 'Regra empresa' (requer casamento por nome)."}
|
||
)
|
||
|
||
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 de mensalidade —
|
||
# sem titular/dependente pra configurar aqui (ver models.py
|
||
# ImportacaoPlanoSaude.regra_empresa).
|
||
if tipo == "mensalidade" and regra_empresa_key:
|
||
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
|
||
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()
|
||
|
||
class Meta:
|
||
model = ImportacaoPlanoSaudeAlteracao
|
||
fields = [
|
||
"id",
|
||
"tipo",
|
||
"linha",
|
||
"linha_nome",
|
||
"tipo_lancamento",
|
||
"campo",
|
||
"valor_anterior",
|
||
"valor_novo",
|
||
"dados_linha",
|
||
"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
|
||
|
||
|
||
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",
|
||
"tipos_lancamento",
|
||
"custeio_por_tipo",
|
||
"status",
|
||
"criado_por_nome",
|
||
"criado_em",
|
||
"concluida_em",
|
||
]
|
||
|
||
def get_codigo_empresa(self, 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)."""
|
||
linha = obj.linhas.exclude(codigo_empresa="").first()
|
||
return linha.codigo_empresa if linha else ""
|
||
|
||
|
||
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()
|
||
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",
|
||
"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_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
|
||
|
||
|
||
class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
|
||
"""Banco de regras de custeio salvas (ex.: "092 - Unimed") — substitui o
|
||
antigo fluxo de exportar/importar um arquivo `.json`. `custeio_por_tipo`
|
||
chega do frontend no mesmo "formato de formulário" já usado por
|
||
coletarRegraCusteioAtual() (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."""
|
||
|
||
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||
|
||
class Meta:
|
||
model = RegraCusteioPlanoSaude
|
||
fields = [
|
||
"id",
|
||
"nome",
|
||
"operadora",
|
||
"tipos_lancamento",
|
||
"custeio_por_tipo",
|
||
"observacoes",
|
||
"criado_por_nome",
|
||
"criado_em",
|
||
"atualizado_em",
|
||
]
|
||
read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"]
|
||
|
||
def validate_nome(self, value: str) -> str:
|
||
value = value.strip()
|
||
if not value:
|
||
raise serializers.ValidationError("Informe um nome para a regra.")
|
||
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 []
|
||
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."})
|
||
|
||
custeio_validado: dict[str, Any] = {}
|
||
for tipo in tipos:
|
||
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
|
||
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")
|