From 69f35d47b7238a63e40909e65e4d029e18ae6817 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 18 Aug 2026 17:11:12 -0300 Subject: [PATCH] =?UTF-8?q?Reestrutura=C3=A7=C3=A3o=20da=20tela=20de=20rev?= =?UTF-8?q?is=C3=A3o=20da=20Importa=C3=A7=C3=A3o=20do=20Plano=20de=20Sa?= =?UTF-8?q?=C3=BAde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 17 ++- plano.md | 8 ++ portal_api/admin.py | 8 ++ .../0038_importacaoplanosaudealteracao.py | 38 ++++++ portal_api/models.py | 54 ++++++++ portal_api/serializers.py | 37 ++++++ portal_api/urls.py | 5 + portal_api/views.py | 122 +++++++++++++++++- static/css/importacao-plano-saude.css | 42 ++++++ static/js/importacao-plano-saude.js | 100 +++++++++++++- 10 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 portal_api/migrations/0038_importacaoplanosaudealteracao.py diff --git a/CLAUDE.md b/CLAUDE.md index 35c0475..45df9c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` — 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 ``, 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 (`": "" → """` 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). diff --git a/plano.md b/plano.md index 24cf6c0..ce8b0ae 100644 --- a/plano.md +++ b/plano.md @@ -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 diff --git a/portal_api/admin.py b/portal_api/admin.py index aedf7a7..a4031fc 100644 --- a/portal_api/admin.py +++ b/portal_api/admin.py @@ -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") diff --git a/portal_api/migrations/0038_importacaoplanosaudealteracao.py b/portal_api/migrations/0038_importacaoplanosaudealteracao.py new file mode 100644 index 0000000..26e2e55 --- /dev/null +++ b/portal_api/migrations/0038_importacaoplanosaudealteracao.py @@ -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'], + }, + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index dd44c55..ed7c095 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -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 diff --git a/portal_api/serializers.py b/portal_api/serializers.py index 53f7c51..2fe8653 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -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", ] diff --git a/portal_api/urls.py b/portal_api/urls.py index 2820804..4280d44 100644 --- a/portal_api/urls.py +++ b/portal_api/urls.py @@ -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" ) diff --git a/portal_api/views.py b/portal_api/views.py index 5d4e165..732e963 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -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): diff --git a/static/css/importacao-plano-saude.css b/static/css/importacao-plano-saude.css index 2659915..638b7e4 100644 --- a/static/css/importacao-plano-saude.css +++ b/static/css/importacao-plano-saude.css @@ -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); diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index cc349f0..66426c9 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -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 ``; }) .join(""); @@ -973,11 +989,70 @@ document.addEventListener("DOMContentLoaded", async () => { `; } + // 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 `

Nenhuma alteração registrada nesta importação ainda.

`; + } + + const linhasHtml = alteracoes + .map((alt) => { + let detalheHtml; + if (alt.tipo === "edicao") { + const campoLabel = PID_IPS_CAMPO_LABELS[alt.campo] || alt.campo; + detalheHtml = `${escapeHtml(campoLabel)}: "${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 + ? `Revertida` + : ``; + + return ` + + ${acaoHtml} + ${new Date(alt.criado_em).toLocaleString("pt-BR")} + ${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo} + ${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"} + ${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"} + ${detalheHtml} + ${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"} + `; + }) + .join(""); + + return ` +
+ + + + + + + + + + + + + ${linhasHtml} +
AçãoQuandoTipoLançamentoLinhaAlteraçãoUsuário
+
`; + } + 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 `
${html}
`; }) .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; + } } }); }