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