From 50772d864bc0e0df12a3d98c31660d06d6bb4291 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 24 Aug 2026 15:10:59 -0300 Subject: [PATCH] =?UTF-8?q?Inclus=C3=A3o=20de=20registro=20de=20altera?= =?UTF-8?q?=C3=A7=C3=B5es=20realizadas=20na=20auditoria=20-=20Plano=20de?= =?UTF-8?q?=20Sa=C3=BAde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 8 +- CLAUDE.md | 25 +++- plano.md | 10 ++ portal_api/admin.py | 11 ++ ...rtacaoplanosaudealteracao_tipo_and_more.py | 47 +++++++ portal_api/models.py | 90 +++++++++++-- portal_api/planos_saude/matcher.py | 92 +++++++++++-- portal_api/planos_saude/modelos.py | 27 ++++ portal_api/planos_saude/pipeline.py | 18 ++- portal_api/serializers.py | 15 +++ portal_api/views.py | 126 ++++++++++++++++-- static/css/importacao-plano-saude.css | 9 ++ static/js/importacao-plano-saude.js | 16 ++- templates/importacao-plano-saude.html | 6 +- 14 files changed, 455 insertions(+), 45 deletions(-) create mode 100644 portal_api/migrations/0051_alter_importacaoplanosaudealteracao_tipo_and_more.py diff --git a/.claude/settings.json b/.claude/settings.json index 4955277..645378c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,7 +12,13 @@ "Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --name arquivo_operadora_multiplo_passo3)", "Bash(.venv/Scripts/python.exe -c ' *)", "Bash(ls -1 'C:\\\\Users\\\\Depaula\\\\.claude\\\\projects\\\\c--Users-Depaula-Documents-Portal\\\\memory')", - "Read(//c/Users/Depaula/.claude/projects/c--Users-Depaula-Documents-Portal/memory/**)" + "Read(//c/Users/Depaula/.claude/projects/c--Users-Depaula-Documents-Portal/memory/**)", + "Bash(.venv\\\\Scripts\\\\python.exe manage.py makemigrations portal_api)", + "Bash(\"./.venv/Scripts/python.exe\" manage.py makemigrations portal_api)", + "Bash(\"./.venv/Scripts/python.exe\" manage.py migrate portal_api)", + "Bash(\"./.venv/Scripts/python.exe\" manage.py check)", + "Bash(PYTHONIOENCODING=utf-8 ./.venv/Scripts/python.exe -c ' *)", + "Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index c87e75c..8f702dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Um único app, `portal_api/`: | Arquivo | Conteúdo | |---|---| -| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo). | +| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo, inclusive "Vínculos de nome salvos (DE/PARA)"). | | `catalogo.py` | Fonte única da verdade do catálogo de módulos/aplicações do menu (`MODULES`, `MODULE_APPS`) — exposto só leitura via `GET /api/catalogo/`. Ao adicionar uma seção/aplicação nova ao menu, editar **aqui**, não em `static/js/profiles.js` (que só cacheia o payload recebido). | | `serializers.py` | `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita, aceita `senha`+`perfis`+`departamentos`+`liderados`)/`UsuarioListSerializer` (leitura, `perfis`/`departamentos`/`liderados` aninhados), `CompromissoAgendaSerializer` (`sou_dono`, `dono_nome`, `dono_username`), `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `LinkFerramentaSerializer`, `LinkFerramentaFavoritoSerializer`, `AcessoGeralSecaoSerializer`, `AcessoGeralSerializer`, `RamalSerializer` (só das linhas avulsas — ver seção "Ramais"), `RamalAusenciaSerializer`, `TelefoneExternoSerializer`, `FuncaoTelefoniaSerializer`, `ImportacaoPlanoSaudeCreateSerializer`/`ImportacaoPlanoSaudeListSerializer`/`ImportacaoPlanoSaudeDetailSerializer`/`ImportacaoPlanoSaudeLinhaSerializer`/`ImportacaoPlanoSaudeAuditoriaSerializer` (ver seção "Importação de Plano de Saúde"). | | `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). | @@ -643,18 +643,31 @@ 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 — " (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 `` — 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. +### Vínculos de nome salvos (DE/PARA) + +Depois de "Vincular pessoa" resolver manualmente uma divergência de nome, o usuário perguntou se ela precisava ser refeita em toda execução futura ou se podia ficar guardada, "como se fosse um DE/PARA" — decisão explícita do usuário: sim, guardar e reaplicar automaticamente, mostrando cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação/fuzzy matching (ver `matcher.py`) — o DE/PARA só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez; sem vínculo salvo, o comportamento é idêntico a antes (cai em auditoria). + +- **Model** (`VinculoNomeOperadora`, migração `0051`): `operadora` (chave de `pipeline.OPERADORAS`), `codigo_empresa` (cru, sem normalizar — ver abaixo por quê), `nome_arquivo_operadora` (o nome divergente do arquivo da operadora, já normalizado via `matcher.normaliza_nome` — é a chave de busca), `nome_func_destino`/`nome_dependente_destino` (o nome real na planilha padrão — só um dos dois preenchido, conforme o vínculo seja de titular ou de dependente), `criado_em`/`criado_por`. `unique_together` em `(operadora, codigo_empresa, nome_arquivo_operadora)`. +- **`codigo_empresa` fica cru no model, normalizado só em `views.py`**: importar `empresas_questor.normalizar_codigo_empresa` dentro de `models.py` criaria um import circular (`empresas_questor.py` já importa `EmpresaQuestor` de `models.py`) — por isso a normalização acontece nos dois pontos de uso em `views.py` (`_carrega_vinculos_por_nome`, `resolver()`), que já importam essa função pra outros fins (ver "Nome da empresa (Questor)" acima). +- **Gravado em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()`** (mesma view de "Vincular pessoa" acima) — depois de aplicar a resolução manual, `update_or_create` um `VinculoNomeOperadora` com `nome_arquivo_operadora=normaliza_nome(item.nome)` e o destino (`linha.nome_func` se `item.tipo == "T"`, senão `linha.nome_dependente`). Só grava se `linha.codigo_empresa` normalizado não for vazio (sempre o caso na prática). +- **Aplicado em `matcher._casa_por_nome`** (não em `_casa_por_cpf` — CPF já é exato por natureza, nunca precisa de DE/PARA): recebe `vinculos_por_nome: Dict[str, VinculoNome]` (`nome normalizado -> VinculoNome`, dataclass "pura" sem ORM em `planos_saude/modelos.py`) e `tipo_lancamento` (só pra rotular o `VinculoAplicado` gerado, o dict em si não é escopado por tipo — o mesmo DE/PARA vale pra mensalidade e coparticipação da mesma operadora+empresa). Quando o titular ou o dependente não bate por nome exato, checa `vinculos_por_nome.get(nome_normalizado)` antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitando `regra_empresa_fn`, já que o vínculo só decide QUAL linha usar — o resto do fluxo de custeio é idêntico ao casamento por nome exato) e registra um `VinculoAplicado` (índice da linha dentro do `tipo_lancamento`, id do vínculo, nome do arquivo da operadora) — devolvido em `ResultadoProcessamento.vinculos_aplicados` (`pipeline.py`) pra `views.py` montar os registros de `ImportacaoPlanoSaudeAlteracao` depois que as linhas estiverem persistidas (no momento do casamento elas ainda não têm `id`). +- **`ImportacaoPlanoSaudeViewSet.create()`**: `_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)` (views.py) busca todo `VinculoNomeOperadora` da operadora cujo `codigo_empresa` normalizado apareça em algum `LinhaSistema` da planilha padrão desta importação, monta o dict e passa em `processa_importacao(vinculos_por_nome=...)`. Depois do `bulk_create` das linhas, correlaciona cada `VinculoAplicado.indice_linha` (índice dentro do `tipo_lancamento`, o mesmo usado como `ordem` na criação da linha) com a `ImportacaoPlanoSaudeLinha` já persistida e cria um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=`, `valor_novo=`) por vínculo aplicado. +- **`POST /.../reverter/` (botão "Apagar vínculo")**: mesmo endpoint de reverter uma alteração normal (ver "Alterações" abaixo) — pra `TIPO_VINCULO_AUTOMATICO`, zera `valor`/`valor_empresa` da linha (reaplicando a regra empresa da família, se houver, mesma lógica de `_recalcula_familia_regra_empresa`) **e** apaga o `VinculoNomeOperadora` (`SET_NULL` em qualquer outra `ImportacaoPlanoSaudeAlteracao` que o referenciasse) — pra essa divergência voltar a cair em auditoria numa importação futura em vez de ser reaplicada sozinha. Como o vínculo é global (não por importação), apagá-lo afeta todas as importações futuras da mesma operadora+empresa, não só a atual. +- **Frontend**: badge próprio (`.ips-alteracao-tipo--vinculo_automatico`, cor `--accent`) na aba Alterações, com o detalhe `"" (arquivo da operadora) → (planilha padrão)` e o botão de ação lendo "Apagar vínculo" em vez de "Reverter" (mesmo endpoint, `pidReverterAlteracaoPlanoSaude`) — a confirmação (`pidConfirm`) também tem um texto próprio avisando que a divergência volta a cair em 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. +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"), exclusão de linha e vínculo automático de nome (ver "Vínculos de nome salvos (DE/PARA)" acima) feitos na própria tela de revisão (ou, no caso do vínculo automático, aplicados por `create()` a partir de um DE/PARA já salvo), com um botão pra reverter/apagar cada um individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação, uma exclusão ou um vínculo automático indesejado, 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`. +- **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`, `vinculo_nome` adicionado na `0051`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`/`vinculo_automatico`), `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`; em `vinculo_automatico`, `valor_novo` guarda o nome do arquivo da operadora), `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), `vinculo_nome` (FK `SET_NULL`, só em `vinculo_automatico`), `usuario`, `criado_em`, `revertida`/`revertida_em`. +- **Fora de escopo de propósito**: o próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) 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. Só a reaplicação automática desse vínculo numa importação **futura** vira um registro do tipo `vinculo_automatico`. +- **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`. `vinculo_automatico` é gravado em `ImportacaoPlanoSaudeViewSet.create()` (ver "Vínculos de nome salvos (DE/PARA)" acima), não no `LinhaViewSet`. - **`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. + - `vinculo_automatico`: zera `valor`/`valor_empresa` da linha vinculada (reaplicando a regra empresa da família, se `tipo_lancamento == "mensalidade"` e a importação tiver `regra_empresa`) e apaga o `VinculoNomeOperadora` associado — ver "Vínculos de nome salvos (DE/PARA)" acima. +- **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/--vinculo_automatico`, cores dourado/teal/vermelho/`--accent`), 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, `"" → ` pra vínculo automático) e o usuário. A coluna "Ação" mostra "Reverter" ou "Apagar vínculo" (conforme o tipo, com `pidConfirm`, 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) diff --git a/plano.md b/plano.md index 885623c..b98b955 100644 --- a/plano.md +++ b/plano.md @@ -1099,6 +1099,16 @@ Cliente real (Fallkner Ribeiro Borges) em que a Unimed manda **dois PDFs separad - **Coparticipação analítica**: `benef`+`nome`+`grau` vêm **colados sem espaço nenhum** (ex.: "0975.0167003824292ANDREIA STORMTITULAR") — corrigido ajustando a regex pra não exigir espaço entre eles. Mas surgiu um problema mais sério: o **nome do beneficiário sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM"), o que faria casamento por nome falhar sistematicamente pra qualquer nome mais longo que a coluna — não é um bug de regex, é informação perdida de verdade no relatório. Como esse mesmo relatório traz CPF completo e confiável, a solução foi trocar a estratégia de casamento: `OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)` (default: mesmo valor de sempre, backward-compatible pra todo outro parser) e `UnimedSaude` a sobrescreve pra devolver `"cpf"` só quando `tipo_lancamento="coparticipacao"` **e** a origem foi o PDF (rastreado numa flag de instância setada em `extrai()` — mensalidade, sem CPF em nenhum formato, continua em `"nome"`). Também corrigido: linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento de um item já somado no valor principal) estavam sendo contadas como itens de serviço de verdade (colidindo com a mesma regex de tipo de serviço), duplicando o valor — agora ignoradas explicitamente. - **Validado de ponta a ponta com os dois arquivos reais** através do fluxo completo de `create()`: bateu exatamente com "Total da Familia"/"Total da Sequencia" impressos no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e, usando a planilha padrão real da empresa 1123, cada família presente na planilha casou centavo a centavo — a única família ausente da planilha de teste foi corretamente pra auditoria "não cadastrado", não ignorada. +### 84. Vínculos de nome salvos (DE/PARA) — reaplicação automática de "Vincular pessoa" + +Usuário perguntou se a resolução manual de nome divergente ("Vincular pessoa") precisava ser refeita em toda execução futura, ou se podia ficar guardada "como se fosse um DE/PARA". Confirmado: sim — guardar, reaplicar automaticamente em importações futuras da mesma operadora+empresa, e mostrar cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação — só existe depois de uma confirmação humana explícita (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md pro detalhamento completo). + +- Novo model `VinculoNomeOperadora` (migração `0051`, junto com `ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO` + FK `vinculo_nome`) — `codigo_empresa` fica cru no model (normalizado só em `views.py`, pra evitar um import circular entre `models.py` e `empresas_questor.py`, que já importa de `models.py`). +- `matcher._casa_por_nome` ganhou `vinculos_por_nome`/`tipo_lancamento`: quando titular ou dependente não bate por nome exato, consulta o DE/PARA antes de cair em auditoria; cada aplicação automática vira um `VinculoAplicado` (dataclass pura, sem ORM), devolvido por `pipeline.processa_importacao` em `ResultadoProcessamento.vinculos_aplicados`. +- `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()` passou a gravar (`update_or_create`) o `VinculoNomeOperadora` depois de aplicar a resolução manual. `ImportacaoPlanoSaudeViewSet.create()` busca os vínculos relevantes (`_carrega_vinculos_por_nome`) antes de processar, e depois do `bulk_create` das linhas correlaciona cada `VinculoAplicado` (por índice dentro do tipo de lançamento) com a linha já persistida, criando um `ImportacaoPlanoSaudeAlteracao` por vínculo aplicado. +- Botão "Apagar vínculo" (aba Alterações, mesmo endpoint `reverter()`) zera o valor lançado na linha (redistribuindo a regra empresa da família, se houver) e apaga o `VinculoNomeOperadora` — a divergência volta a cair em auditoria nas próximas importações. +- Testado via `APIRequestFactory` dentro de uma transação revertida (nada persistido nos dados reais): matcher.py aplicando/não aplicando o DE/PARA corretamente, `resolver()` criando o vínculo, `_carrega_vinculos_por_nome` encontrando-o, e `reverter()` zerando a linha + apagando o vínculo. + ## 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 553a508..6e55712 100644 --- a/portal_api/admin.py +++ b/portal_api/admin.py @@ -31,6 +31,7 @@ from .models import ( RegraCusteioPlanoSaude, TelefoneExterno, Usuario, + VinculoNomeOperadora, WidgetUsuario, ) @@ -158,6 +159,16 @@ class ImportacaoPlanoSaudeAlteracaoAdmin(admin.ModelAdmin): search_fields = ("campo",) +@admin.register(VinculoNomeOperadora) +class VinculoNomeOperadoraAdmin(admin.ModelAdmin): + list_display = ( + "operadora", "codigo_empresa", "nome_arquivo_operadora", "nome_func_destino", + "nome_dependente_destino", "criado_por", "criado_em", + ) + list_filter = ("operadora",) + search_fields = ("codigo_empresa", "nome_arquivo_operadora", "nome_func_destino", "nome_dependente_destino") + + @admin.register(RegraCusteioPlanoSaude) class RegraCusteioPlanoSaudeAdmin(admin.ModelAdmin): list_display = ("nome", "operadora", "tipos_lancamento", "criado_por", "atualizado_em") diff --git a/portal_api/migrations/0051_alter_importacaoplanosaudealteracao_tipo_and_more.py b/portal_api/migrations/0051_alter_importacaoplanosaudealteracao_tipo_and_more.py new file mode 100644 index 0000000..18bfba0 --- /dev/null +++ b/portal_api/migrations/0051_alter_importacaoplanosaudealteracao_tipo_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.7 on 2026-08-24 17:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0050_arquivo_operadora_multiplo_passo3'), + ] + + operations = [ + migrations.AlterField( + model_name='importacaoplanosaudealteracao', + name='tipo', + field=models.CharField(choices=[('edicao', 'Edição de valor'), ('inclusao', 'Inclusão de linha'), ('exclusao', 'Exclusão de linha'), ('vinculo_automatico', 'Vínculo automático de nome')], max_length=20, verbose_name='Tipo'), + ), + migrations.CreateModel( + name='VinculoNomeOperadora', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('operadora', models.CharField(max_length=50, verbose_name='Operadora')), + ('codigo_empresa', models.CharField(max_length=20, verbose_name='Código empresa')), + ('nome_arquivo_operadora', models.CharField(help_text='Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.', max_length=150, verbose_name='Nome divergente (arquivo da operadora)')), + ('nome_func_destino', models.CharField(blank=True, max_length=150, verbose_name='Nome do titular vinculado (planilha padrão)')), + ('nome_dependente_destino', models.CharField(blank=True, max_length=150, verbose_name='Nome do dependente vinculado (planilha padrão)')), + ('criado_em', models.DateTimeField(auto_now_add=True, verbose_name='Criado em')), + ('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vinculos_nome_plano_saude', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Vínculo de nome (plano de saúde)', + 'verbose_name_plural': 'Vínculos de nome (plano de saúde)', + 'ordering': ['-criado_em'], + }, + ), + migrations.AddField( + model_name='importacaoplanosaudealteracao', + name='vinculo_nome', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes', to='portal_api.vinculonomeoperadora'), + ), + migrations.AddConstraint( + model_name='vinculonomeoperadora', + constraint=models.UniqueConstraint(fields=('operadora', 'codigo_empresa', 'nome_arquivo_operadora'), name='vinculo_nome_operadora_unico'), + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index e9e8328..e11ef6e 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -755,32 +755,97 @@ class ImportacaoPlanoSaudeAuditoria(models.Model): ordering = ["id"] +class VinculoNomeOperadora(models.Model): + """"DE/PARA" persistente de um nome divergente do arquivo da operadora, + resolvido manualmente uma vez via "Vincular pessoa" (ver + ImportacaoPlanoSaudeAuditoriaViewSet.resolver, que cria/atualiza este + registro depois de aplicar a resolução) — reaplicado automaticamente em + importações FUTURAS da mesma operadora+empresa, sem precisar vincular de + novo todo mês (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md). + Continua não sendo aproximação: só existe depois que um humano confirmou + explicitamente aquela divergência específica uma vez — + `portal_api.planos_saude.matcher._casa_por_nome` só aplica um vínculo já + salvo, nunca inventa um por semelhança. + + Cada aplicação automática numa importação nova vira um + `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, + `vinculo_nome=este registro`), com um botão "Apagar vínculo" na aba + Alterações que desfaz o valor lançado nessa linha E apaga este registro + (pra não ser reaplicado numa importação seguinte) — ver + ImportacaoPlanoSaudeAlteracaoViewSet.reverter. + + `codigo_empresa` fica cru aqui, sem normalizar via + `empresas_questor.normalizar_codigo_empresa` — importar essa função + dentro de models.py criaria um import circular (empresas_questor.py já + importa `EmpresaQuestor` de models.py). A normalização acontece em + views.py, no mesmo lugar que já normaliza pra outros usos + (RegraCusteioPlanoSaude.codigo_empresa).""" + + operadora = models.CharField("Operadora", max_length=50) + codigo_empresa = models.CharField("Código empresa", max_length=20) + nome_arquivo_operadora = models.CharField( + "Nome divergente (arquivo da operadora)", + max_length=150, + help_text="Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.", + ) + nome_func_destino = models.CharField("Nome do titular vinculado (planilha padrão)", max_length=150, blank=True) + nome_dependente_destino = models.CharField( + "Nome do dependente vinculado (planilha padrão)", max_length=150, blank=True + ) + criado_em = models.DateTimeField("Criado em", auto_now_add=True) + criado_por = models.ForeignKey( + Usuario, on_delete=models.SET_NULL, null=True, related_name="vinculos_nome_plano_saude" + ) + + class Meta: + verbose_name = "Vínculo de nome (plano de saúde)" + verbose_name_plural = "Vínculos de nome (plano de saúde)" + ordering = ["-criado_em"] + constraints = [ + models.UniqueConstraint( + fields=["operadora", "codigo_empresa", "nome_arquivo_operadora"], + name="vinculo_nome_operadora_unico", + ) + ] + + def __str__(self) -> str: + destino = self.nome_func_destino or self.nome_dependente_destino + return f"{self.nome_arquivo_operadora} → {destino}" + + 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 + inclusão manual de linha ("Adicionar linha"), 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 + cada uma dessas três operações) e vínculo de nome aplicado automaticamente + a partir de um `VinculoNomeOperadora` já salvo (ver + ImportacaoPlanoSaudeViewSet.create(), que grava um registro por + `VinculoAplicado` devolvido pelo pipeline) 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).""" + Fora de escopo de propósito: o próprio ato de "Vincular pessoa" (resolução + manual de um item de auditoria) não gera um registro aqui — já tem seu + próprio rastro (o selo "Resolvido" na aba Auditoria); só a reaplicação + automática desse vínculo numa importação FUTURA vira um registro do tipo + `TIPO_VINCULO_AUTOMATICO`.""" TIPO_EDICAO = "edicao" TIPO_INCLUSAO = "inclusao" TIPO_EXCLUSAO = "exclusao" + TIPO_VINCULO_AUTOMATICO = "vinculo_automatico" TIPO_CHOICES = [ (TIPO_EDICAO, "Edição de valor"), (TIPO_INCLUSAO, "Inclusão de linha"), (TIPO_EXCLUSAO, "Exclusão de linha"), + (TIPO_VINCULO_AUTOMATICO, "Vínculo automático de nome"), ] importacao = models.ForeignKey(ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="alteracoes") - tipo = models.CharField("Tipo", max_length=10, choices=TIPO_CHOICES) + tipo = models.CharField("Tipo", max_length=20, 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 @@ -793,6 +858,13 @@ class ImportacaoPlanoSaudeAlteracao(models.Model): 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) + # Só preenchido em TIPO_VINCULO_AUTOMATICO — o VinculoNomeOperadora que foi + # aplicado automaticamente pra gerar esta linha; apagar o vínculo (botão + # "Apagar vínculo" na aba Alterações, mesmo endpoint de reverter()) some + # com este FK (SET_NULL) mas o registro de alteração em si continua. + vinculo_nome = models.ForeignKey( + VinculoNomeOperadora, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes" + ) usuario = models.ForeignKey( Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude" ) diff --git a/portal_api/planos_saude/matcher.py b/portal_api/planos_saude/matcher.py index b767af5..4a5c17b 100644 --- a/portal_api/planos_saude/matcher.py +++ b/portal_api/planos_saude/matcher.py @@ -38,11 +38,32 @@ uma função de custeio calculada por FAMÍLIA inteira em vez de por pessoa, pra regras especiais que não cabem no desenho normal (ver portal_api.planos_saude.regras_empresa) — resolvida e validada em pipeline.processa_importacao antes de chegar aqui. + +Terceiro acréscimo: `vinculos_por_nome` (opcional, só na estratégia +"nome") — um "DE/PARA" persistente (`portal_api.models.VinculoNomeOperadora`, +representado aqui sem ORM como `modelos.VinculoNome`) que reaproveita uma +confirmação humana anterior ("Vincular pessoa", ver +ImportacaoPlanoSaudeAuditoriaViewSet.resolver em views.py) pra resolver +automaticamente a MESMA divergência de nome numa importação futura, sem +precisar vincular de novo todo mês. Continua não sendo aproximação — só +existe depois que alguém confirmou explicitamente aquele nome específico +uma vez; sem vínculo salvo, o comportamento é idêntico a antes (auditoria). +Cada aplicação automática é registrada em `VinculoAplicado` e devolvida +pra quem chamou (pipeline.py/views.py) montar um `ImportacaoPlanoSaudeAlteracao` +("vínculo automático") depois que as linhas forem persistidas — ver +"Vínculos de nome salvos (DE/PARA)" no CLAUDE.md. """ from typing import Callable, Dict, List, Optional, Tuple from portal_api.planos_saude.leiaute_sistema import formata_valor_br -from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, LinhaSistema, normaliza_nome +from portal_api.planos_saude.modelos import ( + Individuo, + ItemAuditoria, + LinhaSistema, + VinculoAplicado, + VinculoNome, + normaliza_nome, +) REGRA_CUSTEIO_PADRAO = {"modo": "empregado"} @@ -169,7 +190,7 @@ def _casa_por_cpf( linhas_sistema: List[LinhaSistema], regra_custeio: Optional[dict] = None, **_ignorado, -) -> Tuple[List[LinhaSistema], List[ItemAuditoria]]: +) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]: titulares, dependentes = _indexa_por_cpf(linhas_sistema) auditoria: List[ItemAuditoria] = [] @@ -188,7 +209,9 @@ def _casa_por_cpf( _aplica_regra_custeio(linha_destino, ind.valor_total, _regra_para_pessoa(regra_custeio, ind.tipo)) - return linhas_sistema, auditoria + # Casamento por CPF nunca precisa do DE/PARA de nomes divergentes (ver + # _casa_por_nome) — o CPF já é exato por natureza. + return linhas_sistema, auditoria, [] # ---------------------------------------------------------------------- @@ -201,8 +224,10 @@ def _casa_por_nome( regra_custeio: Optional[dict] = None, nomes_titular_por_numero: Dict[str, str] = None, regra_empresa_fn: Optional[Callable[[List[Tuple[LinhaSistema, float]]], None]] = None, + vinculos_por_nome: Optional[Dict[str, VinculoNome]] = None, + tipo_lancamento: str = "", **_ignorado, -) -> Tuple[List[LinhaSistema], List[ItemAuditoria]]: +) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]: """ `nomes_titular_por_numero` mapeia numero_titular (id da família no arquivo da operadora) -> nome do titular. É construído a partir de @@ -220,10 +245,26 @@ def _casa_por_nome( `linhas_e_valores` e só then passados de uma vez pra `regra_empresa_fn` no fim do laço da família — ela decide como dividir entre as linhas (normalmente um teto por família, não por pessoa). + + `vinculos_por_nome` (nome normalizado, como veio do arquivo da + operadora, -> VinculoNome) é o "DE/PARA" persistente: quando um titular + ou dependente não bate por nome exato, mas já existe um vínculo salvo + pra esse nome exato — confirmado por um humano numa importação anterior + via "Vincular pessoa" (ImportacaoPlanoSaudeAuditoriaViewSet.resolver) — + ele é aplicado automaticamente, sem ir pra auditoria. Cada aplicação + automática vira um `VinculoAplicado` (devolvido pra quem chamou montar + um ImportacaoPlanoSaudeAlteracao depois, já que as linhas ainda não + têm `id` neste ponto). Continua valendo a regra de nunca resolver por + aproximação: um vínculo só existe depois de uma confirmação humana + explícita da MESMA divergência — isso aqui só reaproveita essa decisão, + não inventa uma nova. """ titulares, dependentes_por_titular = _indexa_por_nome(linhas_sistema) nomes_titular_por_numero = nomes_titular_por_numero or {} + vinculos_por_nome = vinculos_por_nome or {} auditoria: List[ItemAuditoria] = [] + vinculos_aplicados: List[VinculoAplicado] = [] + indice_por_id = {id(l): i for i, l in enumerate(linhas_sistema)} # Agrupa os indivíduos por família (numero_titular do arquivo da # operadora) para resolver o titular uma vez e escopar os dependentes. @@ -246,13 +287,29 @@ def _casa_por_nome( linha_titular = titulares.get(nome_titular_norm) if linha_titular is None: - for m in membros: - auditoria.append(_item_nao_cadastrado( - m, f"Titular '{nome_titular}' não encontrado (por nome) na planilha padrão." - )) - continue + vinculo_titular = vinculos_por_nome.get(nome_titular_norm) + if vinculo_titular is not None: + linha_titular = titulares.get(normaliza_nome(vinculo_titular.nome_func_destino)) + if linha_titular is not None: + vinculos_aplicados.append(VinculoAplicado( + indice_linha=indice_por_id[id(linha_titular)], + tipo_lancamento=tipo_lancamento, + vinculo_id=vinculo_titular.id, + nome_arquivo_operadora=nome_titular, + )) + if linha_titular is None: + for m in membros: + auditoria.append(_item_nao_cadastrado( + m, f"Titular '{nome_titular}' não encontrado (por nome) na planilha padrão." + )) + continue - dependentes_da_familia = dependentes_por_titular.get(nome_titular_norm, {}) + # A partir daqui `linha_titular` está sempre resolvida — por nome + # exato ou por vínculo salvo. `dependentes_por_titular` foi indexado + # a partir da planilha, então a chave certa é o nome REAL do + # titular na planilha (`linha_titular.nome_func`), não o nome que + # veio do arquivo da operadora (que pode ser divergente). + dependentes_da_familia = dependentes_por_titular.get(normaliza_nome(linha_titular.nome_func), {}) linhas_e_valores_familia: List[Tuple[LinhaSistema, float]] = [] for m in membros: @@ -268,6 +325,17 @@ def _casa_por_nome( continue linha_dep = dependentes_da_familia.get(m.nome_normalizado) + if linha_dep is None: + vinculo_dep = vinculos_por_nome.get(m.nome_normalizado) + if vinculo_dep is not None and vinculo_dep.nome_dependente_destino: + linha_dep = dependentes_da_familia.get(normaliza_nome(vinculo_dep.nome_dependente_destino)) + if linha_dep is not None: + vinculos_aplicados.append(VinculoAplicado( + indice_linha=indice_por_id[id(linha_dep)], + tipo_lancamento=tipo_lancamento, + vinculo_id=vinculo_dep.id, + nome_arquivo_operadora=m.nome, + )) if linha_dep is None: auditoria.append(ItemAuditoria( motivo="NOME_DIVERGENTE", @@ -294,7 +362,7 @@ def _casa_por_nome( if regra_empresa_fn is not None and linhas_e_valores_familia: regra_empresa_fn(linhas_e_valores_familia) - return linhas_sistema, auditoria + return linhas_sistema, auditoria, vinculos_aplicados # ---------------------------------------------------------------------- @@ -347,7 +415,7 @@ def casa_individuos_com_planilha( chave_casamento: str = "cpf", regra_custeio: Optional[dict] = None, **kwargs, -) -> Tuple[List[LinhaSistema], List[ItemAuditoria]]: +) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]: estrategia = ESTRATEGIAS.get(chave_casamento) if estrategia is None: raise ValueError(f"chave_casamento desconhecida: {chave_casamento!r}") diff --git a/portal_api/planos_saude/modelos.py b/portal_api/planos_saude/modelos.py index 118d3cd..04d30aa 100644 --- a/portal_api/planos_saude/modelos.py +++ b/portal_api/planos_saude/modelos.py @@ -112,3 +112,30 @@ class ItemAuditoria: valor: float tipo_lancamento: str = "" detalhe: str = "" + + +@dataclass +class VinculoNome: + """Representação "pura" (sem ORM) de um VinculoNomeOperadora salvo + (portal_api.models) — só os campos que matcher.py precisa pra resolver + automaticamente um nome divergente já confirmado antes, sem esse pacote + (deliberadamente sem ORM) depender do Django. Montado por + ImportacaoPlanoSaudeViewSet.create() (views.py) a partir de uma consulta + real ao banco, e passado adiante até `_casa_por_nome`.""" + id: int + nome_func_destino: str + nome_dependente_destino: str + + +@dataclass +class VinculoAplicado: + """Registra que um Individuo foi casado com uma LinhaSistema através de + um VinculoNome já salvo (nome divergente, resolvido automaticamente), + não por nome exato — usado por views.py pra gerar um + ImportacaoPlanoSaudeAlteracao (tipo "vinculo_automatico") depois que as + linhas forem persistidas (ainda não têm `id` no momento em que + matcher.py roda).""" + indice_linha: int # índice de `linha` dentro de linhas_sistema, pra esse tipo_lancamento + tipo_lancamento: str + vinculo_id: int + nome_arquivo_operadora: str diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index 8fb691b..2d45960 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple from portal_api.planos_saude.matcher import casa_individuos_com_planilha -from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, LinhaSistema +from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, LinhaSistema, VinculoAplicado, VinculoNome from portal_api.planos_saude.regras_empresa import valida_regra_empresa from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdontoMensalidade from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade @@ -85,6 +85,11 @@ class ResultadoProcessamento: nome_operadora: str linhas_por_tipo: Dict[str, List[LinhaSistema]] = field(default_factory=dict) auditoria: List[ItemAuditoria] = field(default_factory=list) + # Nomes divergentes resolvidos automaticamente via um VinculoNomeOperadora + # já salvo (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) — views.py + # usa isso pra gerar um ImportacaoPlanoSaudeAlteracao por vínculo aplicado, + # depois que `linhas_por_tipo` já estiver persistido (só aí as linhas têm id). + vinculos_aplicados: List[VinculoAplicado] = field(default_factory=list) def _agrega_individuos_entre_arquivos(individuos: List[Individuo]) -> List[Individuo]: @@ -123,6 +128,7 @@ def processa_importacao( tipos_selecionados: List[str], custeio_por_tipo: Dict[str, dict], regra_empresa_key: Optional[str] = None, + vinculos_por_nome: Optional[Dict[str, VinculoNome]] = None, ) -> ResultadoProcessamento: """ Extrai um ou mais arquivos da operadora (a maioria manda só um, mas @@ -152,6 +158,11 @@ def processa_importacao( (ImportacaoPlanoSaudeCreateSerializer já garante que vem vazio). Levanta RegraEmpresaIncompativelError se a regra não servir pra esta operadora/planilha (propagada pra fora, não é um erro de arquivo). + + `vinculos_por_nome` (opcional, só usado pela estratégia "nome" — ver + matcher._casa_por_nome) é o "DE/PARA" de nomes divergentes já + confirmados numa importação anterior; buscado no banco por views.py + (este pacote não tem ORM) e passado pra todo tipo_lancamento igual. """ operadora_info = OPERADORAS[operadora_key] parser_operadora = operadora_info["parser"]() @@ -193,15 +204,18 @@ def processa_importacao( linhas_copia = [ LinhaSistema(**vars(linha)) for linha in linhas_sistema_template ] - linhas_atualizadas, itens_auditoria = casa_individuos_com_planilha( + linhas_atualizadas, itens_auditoria, vinculos_aplicados = casa_individuos_com_planilha( individuos_do_tipo, linhas_copia, parser_operadora.chave_casamento_para_tipo(tipo_lancamento), regra_custeio=custeio_por_tipo[tipo_lancamento], nomes_titular_por_numero=nomes_titular_por_numero, regra_empresa_fn=regra_empresa_fn if tipo_lancamento == "mensalidade" else None, + vinculos_por_nome=vinculos_por_nome, + tipo_lancamento=tipo_lancamento, ) resultado.linhas_por_tipo[tipo_lancamento] = linhas_atualizadas + resultado.vinculos_aplicados.extend(vinculos_aplicados) resultado.auditoria.extend(itens_auditoria) return resultado diff --git a/portal_api/serializers.py b/portal_api/serializers.py index c9828d1..c43b5a1 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -737,6 +737,7 @@ class ImportacaoPlanoSaudeAuditoriaSerializer(serializers.ModelSerializer): 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 @@ -750,6 +751,8 @@ class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer): "valor_anterior", "valor_novo", "dados_linha", + "vinculo_nome", + "vinculo_nome_destino", "usuario_nome", "criado_em", "revertida", @@ -767,6 +770,18 @@ class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer): 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 "" → " + 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 diff --git a/portal_api/views.py b/portal_api/views.py index 2c37902..9d18828 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -60,6 +60,7 @@ from .models import ( RegraCusteioPlanoSaude, TelefoneExterno, Usuario, + VinculoNomeOperadora, WidgetUsuario, ) from .indicadores import calculo as indicadores_calculo @@ -73,6 +74,9 @@ from .planos_saude import pipeline as planos_saude_pipeline from .planos_saude import regras_empresa as planos_saude_regras_empresa from .planos_saude.leiaute_sistema import CABECALHO as PLANO_SAUDE_CABECALHO from .planos_saude.leiaute_sistema import formata_valor_br, le_planilha_padrao, parse_valor_br +from .planos_saude.modelos import LinhaSistema +from .planos_saude.modelos import VinculoNome as VinculoNomePuro +from .planos_saude.modelos import normaliza_nome from .planos_saude.questor_planilha import busca_linhas_questor, linhas_para_csv_bytes from .planos_saude.regras_empresa import RegraEmpresaIncompativelError from .serializers import ( @@ -787,6 +791,37 @@ def _garante_importacao_em_revisao(importacao: ImportacaoPlanoSaude) -> None: ) +def _carrega_vinculos_por_nome( + operadora_key: str, linhas_sistema_template: list[LinhaSistema] +) -> dict[str, VinculoNomePuro]: + """Monta o "DE/PARA" (ver VinculoNomeOperadora em models.py e + "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) pra passar em + `planos_saude_pipeline.processa_importacao(vinculos_por_nome=...)` — + busca todo VinculoNomeOperadora desta operadora cujo `codigo_empresa` + (normalizado) apareça em algum `LinhaSistema` da planilha padrão desta + importação, e devolve um dict {nome_arquivo_operadora (já normalizado): + VinculoNomePuro} pronto pro matcher.py (pacote sem ORM, por isso o dict + é montado aqui, não lá). Uma planilha normalmente tem um único + `codigo_empresa`, mas o filtro cobre todos os distintos por segurança.""" + codigos = { + normalizar_codigo_empresa(linha.codigo_empresa) + for linha in linhas_sistema_template + if linha.codigo_empresa + } + if not codigos: + return {} + vinculos = VinculoNomeOperadora.objects.filter(operadora=operadora_key) + return { + vinculo.nome_arquivo_operadora: VinculoNomePuro( + id=vinculo.id, + nome_func_destino=vinculo.nome_func_destino, + nome_dependente_destino=vinculo.nome_dependente_destino, + ) + for vinculo in vinculos + if normalizar_codigo_empresa(vinculo.codigo_empresa) in codigos + } + + 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 @@ -931,6 +966,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet): try: if planilha_padrao_arquivo: linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path) + vinculos_por_nome = _carrega_vinculos_por_nome(operadora_key, linhas_sistema_template) resultado = planos_saude_pipeline.processa_importacao( operadora_key=operadora_key, caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora], @@ -938,6 +974,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet): tipos_selecionados=tipos, custeio_por_tipo=custeio_por_tipo, regra_empresa_key=dados["regra_empresa"] or None, + vinculos_por_nome=vinculos_por_nome, ) except RegraEmpresaIncompativelError as exc: # Diferente do genérico abaixo: aqui o problema não é o arquivo em @@ -992,7 +1029,12 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - linhas_bulk = [ + linhas_specs = [ + (tipo, ordem, linha) + for tipo, linhas in resultado.linhas_por_tipo.items() + for ordem, linha in enumerate(linhas) + ] + linhas_bulk = ImportacaoPlanoSaudeLinha.objects.bulk_create([ ImportacaoPlanoSaudeLinha( importacao=importacao, tipo_lancamento=tipo, @@ -1008,10 +1050,35 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet): valor=linha.valor, descricao=linha.descricao, ) - for tipo, linhas in resultado.linhas_por_tipo.items() - for ordem, linha in enumerate(linhas) - ] - ImportacaoPlanoSaudeLinha.objects.bulk_create(linhas_bulk) + for tipo, ordem, linha in linhas_specs + ]) + + # Correlaciona cada VinculoAplicado (matcher.py, sem `id` de banco no + # momento em que resolveu o casamento) com a ImportacaoPlanoSaudeLinha + # já persistida — `indice_linha` é o mesmo índice usado como `ordem` + # acima, dentro do mesmo tipo_lancamento (ver VinculoAplicado em + # planos_saude/modelos.py). Gera um ImportacaoPlanoSaudeAlteracao por + # vínculo aplicado, exibido na aba "Alterações" com um botão "Apagar + # vínculo" (ver ImportacaoPlanoSaudeAlteracaoViewSet.reverter). + linha_por_tipo_ordem = { + (tipo, ordem): linha_obj for (tipo, ordem, _), linha_obj in zip(linhas_specs, linhas_bulk) + } + alteracoes_vinculo_bulk = [] + for vinculo_aplicado in resultado.vinculos_aplicados: + linha_obj = linha_por_tipo_ordem.get((vinculo_aplicado.tipo_lancamento, vinculo_aplicado.indice_linha)) + if linha_obj is None: + continue + alteracoes_vinculo_bulk.append(ImportacaoPlanoSaudeAlteracao( + importacao=importacao, + tipo=ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO, + linha=linha_obj, + tipo_lancamento=vinculo_aplicado.tipo_lancamento, + valor_novo=vinculo_aplicado.nome_arquivo_operadora, + dados_linha=_snapshot_linha_plano_saude(linha_obj), + vinculo_nome_id=vinculo_aplicado.vinculo_id, + usuario=request.user, + )) + ImportacaoPlanoSaudeAlteracao.objects.bulk_create(alteracoes_vinculo_bulk) auditoria_bulk = [ ImportacaoPlanoSaudeAuditoria( @@ -1178,10 +1245,16 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet): 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").""" + partir do snapshot salvo em `dados_linha`; vínculo automático de nome + (botão "Apagar vínculo" na aba Alterações) zera o valor lançado nessa + linha (redistribuindo a regra empresa da família de novo, se + aplicável — mesma lógica de `_recalcula_familia_regra_empresa`) E + apaga o `VinculoNomeOperadora` (ver "Vínculos de nome salvos + (DE/PARA)" no CLAUDE.md), pra essa divergência voltar a cair em + auditoria numa importação futura em vez de ser reaplicada sozinha. + 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() _garante_importacao_em_revisao(alteracao.importacao) if alteracao.revertida: @@ -1204,6 +1277,17 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet): tipo_lancamento=alteracao.tipo_lancamento, **dados, ) + elif alteracao.tipo == ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO: + if not alteracao.linha_id: + raise ValidationError({"detail": "A linha desta alteração não existe mais."}) + alteracao.linha.valor_empresa = "0" + alteracao.linha.valor = "0" + alteracao.linha.save(update_fields=["valor_empresa", "valor"]) + if alteracao.tipo_lancamento == "mensalidade" and alteracao.importacao.regra_empresa: + _recalcula_familia_regra_empresa(alteracao.importacao, alteracao.linha) + if alteracao.vinculo_nome_id: + alteracao.vinculo_nome.delete() + alteracao.vinculo_nome = None alteracao.revertida = True alteracao.revertida_em = timezone.now() @@ -1260,7 +1344,15 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet): lançamento × titular/dependente) e marca o item como resolvido. Só aceita linhas ainda em branco (valor=valor_empresa="0"), pra nunca sobrescrever sem querer um lançamento que já casou automaticamente - com outra pessoa do arquivo da operadora.""" + com outra pessoa do arquivo da operadora. + + Também grava (ou atualiza) um `VinculoNomeOperadora` — o "DE/PARA" + (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md) que faz essa + MESMA divergência ser resolvida automaticamente em importações + futuras da mesma operadora+empresa, sem precisar vincular de novo + (`ImportacaoPlanoSaudeViewSet.create`/`_carrega_vinculos_por_nome`). + Continua não sendo aproximação — só existe porque este humano + confirmou explicitamente esta divergência agora.""" item = self.get_object() _garante_importacao_em_revisao(item.importacao) @@ -1308,6 +1400,20 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet): item.linha_vinculada = linha item.save(update_fields=["resolvida", "linha_vinculada"]) + codigo_empresa_norm = normalizar_codigo_empresa(linha.codigo_empresa) + if codigo_empresa_norm: + destino = {"nome_func_destino": "", "nome_dependente_destino": ""} + if item.tipo == "T": + destino["nome_func_destino"] = linha.nome_func + else: + destino["nome_dependente_destino"] = linha.nome_dependente + VinculoNomeOperadora.objects.update_or_create( + operadora=item.importacao.operadora, + codigo_empresa=codigo_empresa_norm, + nome_arquivo_operadora=normaliza_nome(item.nome), + defaults={**destino, "criado_por": request.user}, + ) + return Response(ImportacaoPlanoSaudeAuditoriaSerializer(item).data) diff --git a/static/css/importacao-plano-saude.css b/static/css/importacao-plano-saude.css index cf0c50a..311a89c 100644 --- a/static/css/importacao-plano-saude.css +++ b/static/css/importacao-plano-saude.css @@ -869,6 +869,15 @@ body.is-resizing-column * { background: rgba(var(--danger-rgb), 0.14); } +/* Vínculo automático de nome (DE/PARA reaplicado — ver "Vínculos de nome + salvos" no CLAUDE.md): usa --accent, mesmo tom já usado em outras marcas + de "ajuste reaplicado automaticamente" (ex.: honorário ajustado + manualmente em Indicador de Desempenho). */ +.ips-alteracao-tipo--vinculo_automatico { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.16); +} + .ips-alteracao-revertida { display: inline-flex; align-items: center; diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index 34dc9aa..51fbc12 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -47,6 +47,7 @@ const PID_IPS_ALTERACAO_TIPO_LABELS = { edicao: "Edição", inclusao: "Inclusão", exclusao: "Exclusão", + vinculo_automatico: "Vínculo automático", }; const PID_IPS_CAMPOS_AUDITORIA = [ @@ -1814,6 +1815,10 @@ document.addEventListener("DOMContentLoaded", async () => { detalheHtml = `${escapeHtml(campoLabel)}: "${escapeHtml(alt.valor_anterior)}" → "${escapeHtml(alt.valor_novo)}"`; } else if (alt.tipo === "inclusao") { detalheHtml = "Linha incluída manualmente"; + } else if (alt.tipo === "vinculo_automatico") { + detalheHtml = alt.vinculo_nome_destino + ? `"${escapeHtml(alt.valor_novo)}" (arquivo da operadora) → ${escapeHtml(alt.vinculo_nome_destino)} (planilha padrão)` + : `"${escapeHtml(alt.valor_novo)}" (vínculo já apagado)`; } else { detalheHtml = "Linha removida"; } @@ -1822,7 +1827,9 @@ document.addEventListener("DOMContentLoaded", async () => { ? `Revertida` : importacaoAtual.status === "concluida" ? `—` - : ``; + : ``; return ` @@ -2280,7 +2287,12 @@ document.addEventListener("DOMContentLoaded", async () => { const reverterBtn = event.target.closest("[data-reverter-alteracao]"); if (reverterBtn) { const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao")); - if (!(await pidConfirm("Reverter esta alteração?"))) return; + const alteracao = (importacaoAtual.alteracoes || []).find((a) => a.id === alteracaoId); + const mensagemConfirmacao = + alteracao && alteracao.tipo === "vinculo_automatico" + ? "Apagar este vínculo? O valor lançado nesta linha será zerado e esta divergência de nome voltará a cair em auditoria nas próximas importações." + : "Reverter esta alteração?"; + if (!(await pidConfirm(mensagemConfirmacao))) return; reverterBtn.disabled = true; try { await pidReverterAlteracaoPlanoSaude(alteracaoId); diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index 184da9c..800a46d 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -396,7 +396,7 @@