Reestruturação da tela de revisão da Importação do Plano de Saúde

This commit is contained in:
Gabriel 2026-08-18 17:11:12 -03:00
parent 5d5f224dff
commit 69f35d47b7
10 changed files with 424 additions and 7 deletions

View File

@ -133,7 +133,8 @@ Um único app, `portal_api/`:
| `/api/importacoes-plano-saude/`, `/api/importacoes-plano-saude/{id}/` | GET/POST | histórico + criação (ver seção "Importação de Plano de Saúde"); `PermissaoApp("utilitarios", "importacao-plano-saude")` (toggle único) pra todos os métodos; POST é `multipart/form-data` (planilha padrão + arquivo da operadora) e roda o pipeline de forma síncrona antes de responder |
| `/api/importacoes-plano-saude/operadoras/` | GET | `[{key, label}]` das operadoras registradas em `planos_saude.pipeline.OPERADORAS` — alimenta o `<select>` do formulário |
| `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` |
| `/api/importacoes-plano-saude-linhas/{id}/` | GET/PATCH | edição de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono" |
| `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) |
| `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo |
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio salvas (`nome`+`operadora`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`, ver "Regras de custeio salvas" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono" |
| `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo |
| `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado |
@ -512,6 +513,7 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), os dois arquivos anexados (`planilha_padrao`/`arquivo_operadora`, `FileField` com o mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — são documentos reais, não ícones), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável.
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — **todos os campos são editáveis** na tela de revisão antes de gerar o CSV (decisão explícita do usuário, não só os valores). `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada (sem FK pra `ImportacaoPlanoSaude`), mesmo espírito de `LinkFerramenta`/`AcessoGeral`.
### Fluxo e endpoints
@ -534,6 +536,19 @@ Quando o casamento por nome falha (`NOME_DIVERGENTE`/`NAO_CADASTRADO` — ver `m
- O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado.
- **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria.
### Alterações (histórico de edição/inclusão/exclusão de linha, com reversão)
Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha") e exclusão de linha feitas na própria tela de revisão, com um botão pra reverter cada uma individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação ou uma exclusão feita sem querer, sem precisar reprocessar a importação do zero.
- **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`), `linha` (FK `SET_NULL` — fica `null` quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), `campo`/`valor_anterior`/`valor_novo` (só preenchidos em `edicao`), `dados_linha` (JSONField — snapshot de todos os campos editáveis da linha **+** `ordem`, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), `usuario`, `criado_em`, `revertida`/`revertida_em`.
- **Fora de escopo de propósito**: o valor lançado por "Vincular pessoa" (resolução manual de auditoria, ver acima) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade.
- **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`.
- **`POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/`** (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) — idempotente, recusa reverter de novo uma alteração já `revertida`. A própria reversão **não** gera um novo registro de alteração (evitaria um loop de "reverter a reversão"):
- `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo.
- `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão).
- `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao`, cores dourado/teal/vermelho), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão) e o usuário. A coluna "Ação" mostra "Reverter" (com `window.confirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.
### Pré-validação de arquivo ao anexar (tela de Nova Importação)
Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).

View File

@ -840,6 +840,14 @@ Ajuste de usabilidade pedido pelo usuário na seção "Regra de custeio salva" d
Novo botão "Limpar seleção" (`#ips-regra-limpar-btn`, ao lado de Aplicar) resolve o caso de aplicar a regra errada por engano: desfaz tanto o rastreamento (`regraSelecionadaId`/`regraAplicadaId`, texto do combobox, observações) quanto o próprio custeio que a regra preencheu (tipos de lançamento, radios de custeio, limite/percentual de cada combinação tipo×pessoa) — as duas partes de `resetForm()` que faziam isso foram extraídas em `limparCusteioForm()`/`limparRegraSelecionada()` pra serem reaproveitadas aqui. De propósito não mexe em Operadora nem nos arquivos já anexados — só desfaz o que uma regra aplicada de fato preenche em massa.
### 56. Importação de Plano de Saúde: aba "Alterações" com histórico e reversão
Pedido do usuário: na tela de revisão (Mensalidade/Coparticipação/Auditoria), incluir uma quarta aba "Alterações" que registra cada edição de valor, exclusão de linha e inclusão manual de linha feita na revisão, permitindo verificar e reverter cada uma.
Novo model `ImportacaoPlanoSaudeAlteracao` (migração `0038`) — um registro por operação, nunca apagado (`revertida`/`revertida_em` marcam quando o usuário desfez). `ImportacaoPlanoSaudeLinhaViewSet` passou a gravar um registro a cada `perform_create`/`perform_update`/`perform_destroy`, com um snapshot completo da linha (`dados_linha`, JSONField) — necessário porque uma linha excluída deixa de existir, então o snapshot é o único jeito de mostrar/recriar essa linha depois. Novo endpoint `POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/` desfaz uma alteração específica: edição volta o campo, inclusão remove a linha, exclusão recria a linha a partir do snapshot — idempotente, e a própria reversão não gera um novo registro (evita loop).
De propósito, o valor lançado por "Vincular pessoa" (resolução manual de auditoria) não entra nessa aba — já tem seu próprio rastro (selo "Resolvido" na aba Auditoria).
## Roadmap / próximos passos
Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida

View File

@ -9,6 +9,7 @@ from .models import (
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
IndicadorApuracao,
@ -136,6 +137,13 @@ class ImportacaoPlanoSaudeAuditoriaAdmin(admin.ModelAdmin):
search_fields = ("nome",)
@admin.register(ImportacaoPlanoSaudeAlteracao)
class ImportacaoPlanoSaudeAlteracaoAdmin(admin.ModelAdmin):
list_display = ("importacao", "tipo", "campo", "usuario", "criado_em", "revertida")
list_filter = ("tipo", "revertida")
search_fields = ("campo",)
@admin.register(RegraCusteioPlanoSaude)
class RegraCusteioPlanoSaudeAdmin(admin.ModelAdmin):
list_display = ("nome", "operadora", "tipos_lancamento", "criado_por", "atualizado_em")

View File

@ -0,0 +1,38 @@
# Generated by Django 6.0.7 on 2026-08-18 19:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0037_regracusteioplanosaude'),
]
operations = [
migrations.CreateModel(
name='ImportacaoPlanoSaudeAlteracao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tipo', models.CharField(choices=[('edicao', 'Edição de valor'), ('inclusao', 'Inclusão de linha'), ('exclusao', 'Exclusão de linha')], max_length=10, verbose_name='Tipo')),
('tipo_lancamento', models.CharField(blank=True, max_length=20, verbose_name='Tipo de lançamento')),
('campo', models.CharField(blank=True, max_length=30, verbose_name='Campo alterado')),
('valor_anterior', models.TextField(blank=True, verbose_name='Valor anterior')),
('valor_novo', models.TextField(blank=True, verbose_name='Valor novo')),
('dados_linha', models.JSONField(blank=True, default=dict, verbose_name='Dados da linha')),
('criado_em', models.DateTimeField(auto_now_add=True)),
('revertida', models.BooleanField(default=False, verbose_name='Revertida')),
('revertida_em', models.DateTimeField(blank=True, null=True, verbose_name='Revertida em')),
('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alteracoes', to='portal_api.importacaoplanosaude')),
('linha', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes', to='portal_api.importacaoplanosaudelinha')),
('usuario', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes_plano_saude', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Alteração de importação de plano de saúde',
'verbose_name_plural': 'Alterações de importação de plano de saúde',
'ordering': ['-criado_em'],
},
),
]

View File

@ -644,6 +644,60 @@ class ImportacaoPlanoSaudeAuditoria(models.Model):
ordering = ["id"]
class ImportacaoPlanoSaudeAlteracao(models.Model):
"""Log de alterações feitas na tela de revisão de uma ImportacaoPlanoSaude
depois que o pipeline já processou os arquivos — cada edição de campo,
inclusão manual de linha ("Adicionar linha") e exclusão de linha (ver
ImportacaoPlanoSaudeLinhaViewSet em views.py, que grava um registro aqui a
cada uma dessas três operações) vira um registro aqui, exibido na aba
"Alterações" da revisão (ao lado de Mensalidade/Coparticipação/Auditoria).
Nunca é apagado — `revertida` marca quando o usuário desfez aquela
alteração específica (mesmo espírito de `resolvida` em
ImportacaoPlanoSaudeAuditoria: histórico completo, nada some da lista).
Fora de escopo de propósito: o valor lançado por "Vincular pessoa" (ver
ImportacaoPlanoSaudeAuditoriaViewSet.resolver) não gera um registro aqui —
já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria)."""
TIPO_EDICAO = "edicao"
TIPO_INCLUSAO = "inclusao"
TIPO_EXCLUSAO = "exclusao"
TIPO_CHOICES = [
(TIPO_EDICAO, "Edição de valor"),
(TIPO_INCLUSAO, "Inclusão de linha"),
(TIPO_EXCLUSAO, "Exclusão de linha"),
]
importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="alteracoes")
tipo = models.CharField("Tipo", max_length=10, choices=TIPO_CHOICES)
# Null quando a linha em si já não existe mais (excluída, ou uma inclusão já
# revertida) — o snapshot em `dados_linha` é o que sobra pra identificar a
# linha na tela mesmo nesse caso, e também o que permite recriá-la ao
# reverter uma exclusão (ver ImportacaoPlanoSaudeAlteracaoViewSet.reverter).
linha = models.ForeignKey(
ImportacaoPlanoSaudeLinha, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes"
)
tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20, blank=True)
campo = models.CharField("Campo alterado", max_length=30, blank=True)
valor_anterior = models.TextField("Valor anterior", blank=True)
valor_novo = models.TextField("Valor novo", blank=True)
dados_linha = models.JSONField("Dados da linha", default=dict, blank=True)
usuario = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude"
)
criado_em = models.DateTimeField(auto_now_add=True)
revertida = models.BooleanField("Revertida", default=False)
revertida_em = models.DateTimeField("Revertida em", null=True, blank=True)
class Meta:
verbose_name = "Alteração de importação de plano de saúde"
verbose_name_plural = "Alterações de importação de plano de saúde"
ordering = ["-criado_em"]
def __str__(self) -> str:
return f"{self.importacao} — {self.get_tipo_display()}"
class RegraCusteioPlanoSaude(models.Model):
"""Regra de custeio salva (ex.: "092 - Unimed") pra reaplicar em
importações futuras de Plano de Saúde, sem redigitar o custeio de cada

View File

@ -15,6 +15,7 @@ from .models import (
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
IndicadorApuracao,
@ -616,6 +617,40 @@ class ImportacaoPlanoSaudeAuditoriaSerializer(serializers.ModelSerializer):
return obj.linha_vinculada.nome_dependente or obj.linha_vinculada.nome_func
class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None)
linha_nome = serializers.SerializerMethodField()
class Meta:
model = ImportacaoPlanoSaudeAlteracao
fields = [
"id",
"tipo",
"linha",
"linha_nome",
"tipo_lancamento",
"campo",
"valor_anterior",
"valor_novo",
"dados_linha",
"usuario_nome",
"criado_em",
"revertida",
"revertida_em",
]
read_only_fields = fields
def get_linha_nome(self, obj: ImportacaoPlanoSaudeAlteracao) -> str | None:
"""Nome pra identificar a linha na aba "Alterações" mesmo quando ela já
não existe mais (excluída, ou uma inclusão já revertida) — usa o
snapshot em `dados_linha` nesse caso, senão o estado atual da própria
linha (que pode ter sido editada depois desta alteração específica)."""
dados = obj.dados_linha or {}
if obj.linha_id:
return obj.linha.nome_dependente or obj.linha.nome_func or None
return dados.get("nome_dependente") or dados.get("nome_func") or None
class ImportacaoPlanoSaudeListSerializer(serializers.ModelSerializer):
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
codigo_empresa = serializers.SerializerMethodField()
@ -646,6 +681,7 @@ 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()
class Meta:
@ -662,6 +698,7 @@ class ImportacaoPlanoSaudeDetailSerializer(serializers.ModelSerializer):
"concluida_em",
"linhas",
"itens_auditoria",
"alteracoes",
"resumo_por_tipo",
]

View File

@ -31,6 +31,11 @@ router.register(
views.ImportacaoPlanoSaudeAuditoriaViewSet,
basename="importacao-plano-saude-auditoria",
)
router.register(
"importacoes-plano-saude-alteracoes",
views.ImportacaoPlanoSaudeAlteracaoViewSet,
basename="importacao-plano-saude-alteracao",
)
router.register(
"regras-custeio-plano-saude", views.RegraCusteioPlanoSaudeViewSet, basename="regra-custeio-plano-saude"
)

View File

@ -36,6 +36,7 @@ from .models import (
Favorito,
FuncaoTelefonia,
ImportacaoPlanoSaude,
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
IndicadorApuracao,
@ -75,6 +76,7 @@ from .serializers import (
DepartamentoSerializer,
FavoritoSerializer,
FuncaoTelefoniaSerializer,
ImportacaoPlanoSaudeAlteracaoSerializer,
ImportacaoPlanoSaudeAuditoriaSerializer,
ImportacaoPlanoSaudeCreateSerializer,
ImportacaoPlanoSaudeDetailSerializer,
@ -721,6 +723,26 @@ def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -
return buffer.getvalue().encode("utf-8-sig")
# Campos editáveis de ImportacaoPlanoSaudeLinha (sem tipo_lancamento/ordem,
# guardados à parte em ImportacaoPlanoSaudeAlteracao) — usado tanto pra
# detectar qual campo mudou num PATCH (ImportacaoPlanoSaudeLinhaViewSet.perform_update)
# quanto pra montar o snapshot de _snapshot_linha_plano_saude abaixo.
PLANO_SAUDE_CAMPOS_ALTERACAO = [
"codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial",
"nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao",
]
def _snapshot_linha_plano_saude(linha: ImportacaoPlanoSaudeLinha) -> dict[str, Any]:
"""Espelha os campos editáveis de uma ImportacaoPlanoSaudeLinha (+ ordem) pra
guardar em ImportacaoPlanoSaudeAlteracao.dados_linha — usado tanto pra
identificar a linha na aba "Alterações" quanto pra recriá-la ao reverter
uma exclusão (ImportacaoPlanoSaudeAlteracaoViewSet.reverter)."""
dados = {campo: getattr(linha, campo) for campo in PLANO_SAUDE_CAMPOS_ALTERACAO}
dados["ordem"] = linha.ordem
return dados
class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
"""Ferramenta "Importação de Plano de Saúde" (Utilitários) — permissão de
toggle único (sem par visualizar/editar, ver catalogo.py "utilitarios"), então
@ -907,7 +929,11 @@ class ImportacaoPlanoSaudeLinhaViewSet(viewsets.ModelViewSet):
que `gerar()` (na outra view) lê todas as linhas da importação sem distinguir
origem. `destroy()` (botão de remover ao lado de cada linha, qualquer uma —
gerada pelo pipeline ou incluída manualmente) é o CRUD padrão do DRF, sem
override: a linha simplesmente some da importação e do CSV gerado depois."""
override: a linha simplesmente some da importação e do CSV gerado depois.
As três operações (criar/editar/excluir) também gravam um
ImportacaoPlanoSaudeAlteracao — histórico exibido na aba "Alterações" da
revisão, revertível via ImportacaoPlanoSaudeAlteracaoViewSet.reverter."""
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
queryset = ImportacaoPlanoSaudeLinha.objects.all()
@ -926,7 +952,99 @@ class ImportacaoPlanoSaudeLinhaViewSet(viewsets.ModelViewSet):
maior_ordem = ImportacaoPlanoSaudeLinha.objects.filter(
importacao=importacao, tipo_lancamento=tipo
).aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
linha = serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_INCLUSAO,
linha=linha,
tipo_lancamento=tipo,
dados_linha=_snapshot_linha_plano_saude(linha),
usuario=self.request.user,
)
def perform_update(self, serializer: ImportacaoPlanoSaudeLinhaSerializer) -> None:
# serializer.instance ainda reflete os valores ANTES do save() abaixo —
# é o que permite comparar campo a campo o que de fato mudou.
linha_anterior = serializer.instance
valores_anteriores = {
campo: getattr(linha_anterior, campo)
for campo in PLANO_SAUDE_CAMPOS_ALTERACAO
if campo in serializer.validated_data and serializer.validated_data[campo] != getattr(linha_anterior, campo)
}
linha = serializer.save()
for campo, valor_anterior in valores_anteriores.items():
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=linha.importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_EDICAO,
linha=linha,
tipo_lancamento=linha.tipo_lancamento,
campo=campo,
valor_anterior=valor_anterior,
valor_novo=getattr(linha, campo),
dados_linha=_snapshot_linha_plano_saude(linha),
usuario=self.request.user,
)
def perform_destroy(self, instance: ImportacaoPlanoSaudeLinha) -> None:
ImportacaoPlanoSaudeAlteracao.objects.create(
importacao=instance.importacao,
tipo=ImportacaoPlanoSaudeAlteracao.TIPO_EXCLUSAO,
tipo_lancamento=instance.tipo_lancamento,
dados_linha=_snapshot_linha_plano_saude(instance),
usuario=self.request.user,
)
instance.delete()
class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet):
"""Só a reversão de uma alteração já registrada (ver ImportacaoPlanoSaudeAlteracao
em models.py) — não há list/create/update/destroy porque os registros só
nascem via ImportacaoPlanoSaudeLinhaViewSet (edição/inclusão/exclusão de
linha) e já chegam ao frontend aninhados em
ImportacaoPlanoSaudeDetailSerializer.alteracoes. Mesma permissão de
toggle único das outras views de plano de saúde."""
http_method_names = ["post", "head", "options"]
queryset = ImportacaoPlanoSaudeAlteracao.objects.all()
serializer_class = ImportacaoPlanoSaudeAlteracaoSerializer
def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
@action(detail=True, methods=["post"])
def reverter(self, request: Request, pk: str | None = None) -> Response:
"""Desfaz uma alteração específica: edição volta o campo pro valor
anterior; inclusão remove a linha incluída; exclusão recria a linha a
partir do snapshot salvo em `dados_linha`. Idempotente — recusa
reverter de novo uma alteração já revertida (`revertida=True`), e a
própria reversão não gera um novo registro de alteração (evita um
loop de "reverter a reversão")."""
alteracao = self.get_object()
if alteracao.revertida:
raise ValidationError({"detail": "Esta alteração já foi revertida."})
if alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_EDICAO:
if not alteracao.linha_id:
raise ValidationError({"detail": "A linha desta alteração não existe mais."})
setattr(alteracao.linha, alteracao.campo, alteracao.valor_anterior)
alteracao.linha.save(update_fields=[alteracao.campo])
elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_INCLUSAO:
if not alteracao.linha_id:
raise ValidationError({"detail": "Esta linha já não existe mais."})
alteracao.linha.delete()
alteracao.linha = None
elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_EXCLUSAO:
dados = dict(alteracao.dados_linha or {})
alteracao.linha = ImportacaoPlanoSaudeLinha.objects.create(
importacao=alteracao.importacao,
tipo_lancamento=alteracao.tipo_lancamento,
**dados,
)
alteracao.revertida = True
alteracao.revertida_em = timezone.now()
alteracao.save()
return Response(ImportacaoPlanoSaudeAlteracaoSerializer(alteracao).data)
class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet):

View File

@ -607,6 +607,48 @@ body.is-resizing-column * {
color: var(--text-muted);
}
/* Aba "Alterações" — badge de tipo (edição/inclusão/exclusão) e selo de
"Revertida", mesma linguagem visual de .ips-auditoria-motivo/
.ips-auditoria-resolvido acima. Cores por tipo: dourado (edição — uma
mudança de valor, neutra), teal (inclusão — algo foi adicionado), vermelho
(exclusão — algo foi removido, mesmo tom de alerta de .ips-auditoria-motivo). */
.ips-alteracao-tipo {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
}
.ips-alteracao-tipo--edicao {
color: var(--gold);
background: rgba(var(--gold-rgb), 0.16);
}
.ips-alteracao-tipo--inclusao {
color: var(--teal);
background: rgba(var(--teal-rgb), 0.16);
}
.ips-alteracao-tipo--exclusao {
color: var(--danger);
background: rgba(var(--danger-rgb), 0.14);
}
.ips-alteracao-revertida {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.72rem;
font-weight: 700;
color: #2f9e6e;
background: rgba(47, 158, 110, 0.14);
white-space: nowrap;
}
.ips-vincular-resumo {
font-size: 0.85rem;
color: var(--text-secondary);

View File

@ -37,6 +37,18 @@ const PID_IPS_CAMPOS_LINHA = [
["descricao", "Descrição", 240],
];
// Lookup campo -> label pra aba "Alterações" (ex.: "valor" -> "Valor"),
// derivado de PID_IPS_CAMPOS_LINHA pra nunca divergir do rótulo já usado na
// própria coluna da tabela de Mensalidade/Coparticipação.
const PID_IPS_CAMPO_LABELS = Object.fromEntries(PID_IPS_CAMPOS_LINHA.map(([campo, label]) => [campo, label]));
// Espelha ImportacaoPlanoSaudeAlteracao.TIPO_CHOICES (models.py).
const PID_IPS_ALTERACAO_TIPO_LABELS = {
edicao: "Edição",
inclusao: "Inclusão",
exclusao: "Exclusão",
};
const PID_IPS_CAMPOS_AUDITORIA = [
["nome", "Nome", 160],
["cpf", "CPF", 120],
@ -101,6 +113,10 @@ async function pidResolverAuditoriaPlanoSaude(itemId, linhaId) {
});
}
async function pidReverterAlteracaoPlanoSaude(id) {
return pidApiRequest(`/importacoes-plano-saude-alteracoes/${id}/reverter/`, { method: "POST" });
}
async function pidFetchRegrasCusteioPlanoSaude() {
return (await pidApiRequest("/regras-custeio-plano-saude/")) || [];
}
@ -794,12 +810,12 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderTabs() {
const tipos = importacaoAtual.tipos_lancamento || [];
const tabKeys = [...tipos, "auditoria"];
const tabKeys = [...tipos, "auditoria", "alteracoes"];
if (!activeTab || !tabKeys.includes(activeTab)) activeTab = tabKeys[0];
tabsEl.innerHTML = tabKeys
.map((key) => {
const label = key === "auditoria" ? "Auditoria" : PID_IPS_TIPO_LABELS[key] || key;
const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || key;
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${key}">${label}</button>`;
})
.join("");
@ -973,11 +989,70 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>`;
}
// Aba "Alterações" — histórico de edição/inclusão/exclusão de linha feito
// manualmente na revisão (ImportacaoPlanoSaudeAlteracao, ver models.py). Já
// vem do backend ordenado do mais recente pro mais antigo; sem ordenação/
// redimensionamento de coluna (é um log, não uma planilha editável).
function panelHtmlAlteracoes() {
const alteracoes = importacaoAtual.alteracoes || [];
if (!alteracoes.length) {
return `<p class="ips-empty">Nenhuma alteração registrada nesta importação ainda.</p>`;
}
const linhasHtml = alteracoes
.map((alt) => {
let detalheHtml;
if (alt.tipo === "edicao") {
const campoLabel = PID_IPS_CAMPO_LABELS[alt.campo] || alt.campo;
detalheHtml = `<strong>${escapeHtml(campoLabel)}:</strong> "${escapeHtml(alt.valor_anterior)}" → "${escapeHtml(alt.valor_novo)}"`;
} else if (alt.tipo === "inclusao") {
detalheHtml = "Linha incluída manualmente";
} else {
detalheHtml = "Linha removida";
}
const acaoHtml = alt.revertida
? `<span class="ips-alteracao-revertida">Revertida</span>`
: `<button type="button" class="btn-outline ips-reverter-btn" data-reverter-alteracao="${alt.id}">Reverter</button>`;
return `
<tr>
<td class="ips-table-actions">${acaoHtml}</td>
<td>${new Date(alt.criado_em).toLocaleString("pt-BR")}</td>
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${alt.tipo}">${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo}</span></td>
<td>${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"}</td>
<td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td>
<td>${detalheHtml}</td>
<td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td>
</tr>`;
})
.join("");
return `
<div class="pa-table-wrap ips-table-scroll">
<table class="pa-table">
<thead>
<tr>
<th class="ips-table-actions">Ação</th>
<th>Quando</th>
<th>Tipo</th>
<th>Lançamento</th>
<th>Linha</th>
<th>Alteração</th>
<th>Usuário</th>
</tr>
</thead>
<tbody>${linhasHtml}</tbody>
</table>
</div>`;
}
function renderPanels() {
const tipos = importacaoAtual.tipos_lancamento || [];
panelsEl.innerHTML = [...tipos, "auditoria"]
panelsEl.innerHTML = [...tipos, "auditoria", "alteracoes"]
.map((key) => {
const html = key === "auditoria" ? panelHtmlAuditoria() : panelHtmlParaTipo(key);
const html =
key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key);
return `<div class="pa-tab-panel" data-ips-tab-panel="${key}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
})
.join("");
@ -1361,6 +1436,23 @@ document.addEventListener("DOMContentLoaded", async () => {
const itemId = Number(vincularBtn.getAttribute("data-vincular-auditoria"));
const item = (importacaoAtual.itens_auditoria || []).find((i) => i.id === itemId);
if (item) abrirVincularModal(item);
return;
}
const reverterBtn = event.target.closest("[data-reverter-alteracao]");
if (reverterBtn) {
const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao"));
if (!window.confirm("Reverter esta alteração?")) return;
reverterBtn.disabled = true;
try {
await pidReverterAlteracaoPlanoSaude(alteracaoId);
importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id);
reviewError.textContent = "";
renderTabs();
} catch (e) {
reviewError.textContent = e.message;
reverterBtn.disabled = false;
}
}
});
}