Inclusão de registro de alterações realizadas na auditoria - Plano de Saúde

This commit is contained in:
Gabriel 2026-08-24 15:10:59 -03:00
parent 4d75e8f8e2
commit 50772d864b
14 changed files with 455 additions and 45 deletions

View File

@ -12,7 +12,13 @@
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --name arquivo_operadora_multiplo_passo3)", "Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --name arquivo_operadora_multiplo_passo3)",
"Bash(.venv/Scripts/python.exe -c ' *)", "Bash(.venv/Scripts/python.exe -c ' *)",
"Bash(ls -1 'C:\\\\Users\\\\Depaula\\\\.claude\\\\projects\\\\c--Users-Depaula-Documents-Portal\\\\memory')", "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 ' *)"
] ]
} }
} }

View File

@ -97,7 +97,7 @@ Um único app, `portal_api/`:
| Arquivo | Conteúdo | | 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). | | `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"). | | `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). | | `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 — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado. - O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado.
- **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria. - **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria.
### 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=<vínculo>`, `valor_novo=<nome do arquivo da operadora>`) 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 `"<nome do arquivo>" (arquivo da operadora) → <nome vinculado> (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) ### 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`. - **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 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. - **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 `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. - **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. `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"): - **`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. - `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). - `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. - `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao`, cores dourado/teal/vermelho), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão) e o usuário. A coluna "Ação" mostra "Reverter" (com `window.confirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas. - `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 (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão, `"<nome do arquivo>" → <nome vinculado>` 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) ### Pré-validação de arquivo ao anexar (tela de Nova Importação)

View File

@ -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. - **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. - **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 ## Roadmap / próximos passos
Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida

View File

@ -31,6 +31,7 @@ from .models import (
RegraCusteioPlanoSaude, RegraCusteioPlanoSaude,
TelefoneExterno, TelefoneExterno,
Usuario, Usuario,
VinculoNomeOperadora,
WidgetUsuario, WidgetUsuario,
) )
@ -158,6 +159,16 @@ class ImportacaoPlanoSaudeAlteracaoAdmin(admin.ModelAdmin):
search_fields = ("campo",) 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) @admin.register(RegraCusteioPlanoSaude)
class RegraCusteioPlanoSaudeAdmin(admin.ModelAdmin): class RegraCusteioPlanoSaudeAdmin(admin.ModelAdmin):
list_display = ("nome", "operadora", "tipos_lancamento", "criado_por", "atualizado_em") list_display = ("nome", "operadora", "tipos_lancamento", "criado_por", "atualizado_em")

View File

@ -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'),
),
]

View File

@ -755,32 +755,97 @@ class ImportacaoPlanoSaudeAuditoria(models.Model):
ordering = ["id"] 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): class ImportacaoPlanoSaudeAlteracao(models.Model):
"""Log de alterações feitas na tela de revisão de uma ImportacaoPlanoSaude """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, 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 ImportacaoPlanoSaudeLinhaViewSet em views.py, que grava um registro aqui a
cada uma dessas três operações) vira um registro aqui, exibido na aba cada uma dessas três operações) e vínculo de nome aplicado automaticamente
"Alterações" da revisão (ao lado de Mensalidade/Coparticipação/Auditoria). a partir de um `VinculoNomeOperadora` já salvo (ver
Nunca é apagado — `revertida` marca quando o usuário desfez aquela ImportacaoPlanoSaudeViewSet.create(), que grava um registro por
alteração específica (mesmo espírito de `resolvida` em `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). ImportacaoPlanoSaudeAuditoria: histórico completo, nada some da lista).
Fora de escopo de propósito: o valor lançado por "Vincular pessoa" (ver Fora de escopo de propósito: o próprio ato de "Vincular pessoa" (resolução
ImportacaoPlanoSaudeAuditoriaViewSet.resolver) não gera um registro aqui — manual de um item de auditoria) não gera um registro aqui — já tem seu
já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria).""" 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_EDICAO = "edicao"
TIPO_INCLUSAO = "inclusao" TIPO_INCLUSAO = "inclusao"
TIPO_EXCLUSAO = "exclusao" TIPO_EXCLUSAO = "exclusao"
TIPO_VINCULO_AUTOMATICO = "vinculo_automatico"
TIPO_CHOICES = [ TIPO_CHOICES = [
(TIPO_EDICAO, "Edição de valor"), (TIPO_EDICAO, "Edição de valor"),
(TIPO_INCLUSAO, "Inclusão de linha"), (TIPO_INCLUSAO, "Inclusão de linha"),
(TIPO_EXCLUSAO, "Exclusã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") 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á # 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 # 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 # 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_anterior = models.TextField("Valor anterior", blank=True)
valor_novo = models.TextField("Valor novo", blank=True) valor_novo = models.TextField("Valor novo", blank=True)
dados_linha = models.JSONField("Dados da linha", default=dict, 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 = models.ForeignKey(
Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude" Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude"
) )

View File

@ -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 pra regras especiais que não cabem no desenho normal (ver
portal_api.planos_saude.regras_empresa) — resolvida e validada em portal_api.planos_saude.regras_empresa) — resolvida e validada em
pipeline.processa_importacao antes de chegar aqui. 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 typing import Callable, Dict, List, Optional, Tuple
from portal_api.planos_saude.leiaute_sistema import formata_valor_br 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"} REGRA_CUSTEIO_PADRAO = {"modo": "empregado"}
@ -169,7 +190,7 @@ def _casa_por_cpf(
linhas_sistema: List[LinhaSistema], linhas_sistema: List[LinhaSistema],
regra_custeio: Optional[dict] = None, regra_custeio: Optional[dict] = None,
**_ignorado, **_ignorado,
) -> Tuple[List[LinhaSistema], List[ItemAuditoria]]: ) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]:
titulares, dependentes = _indexa_por_cpf(linhas_sistema) titulares, dependentes = _indexa_por_cpf(linhas_sistema)
auditoria: List[ItemAuditoria] = [] 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)) _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, regra_custeio: Optional[dict] = None,
nomes_titular_por_numero: Dict[str, str] = None, nomes_titular_por_numero: Dict[str, str] = None,
regra_empresa_fn: Optional[Callable[[List[Tuple[LinhaSistema, float]]], None]] = None, regra_empresa_fn: Optional[Callable[[List[Tuple[LinhaSistema, float]]], None]] = None,
vinculos_por_nome: Optional[Dict[str, VinculoNome]] = None,
tipo_lancamento: str = "",
**_ignorado, **_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 `nomes_titular_por_numero` mapeia numero_titular (id da família no
arquivo da operadora) -> nome do titular. É construído a partir de 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` `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 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). (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) titulares, dependentes_por_titular = _indexa_por_nome(linhas_sistema)
nomes_titular_por_numero = nomes_titular_por_numero or {} nomes_titular_por_numero = nomes_titular_por_numero or {}
vinculos_por_nome = vinculos_por_nome or {}
auditoria: List[ItemAuditoria] = [] 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 # Agrupa os indivíduos por família (numero_titular do arquivo da
# operadora) para resolver o titular uma vez e escopar os dependentes. # 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) linha_titular = titulares.get(nome_titular_norm)
if linha_titular is None: if linha_titular is None:
for m in membros: vinculo_titular = vinculos_por_nome.get(nome_titular_norm)
auditoria.append(_item_nao_cadastrado( if vinculo_titular is not None:
m, f"Titular '{nome_titular}' não encontrado (por nome) na planilha padrão." linha_titular = titulares.get(normaliza_nome(vinculo_titular.nome_func_destino))
)) if linha_titular is not None:
continue 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]] = [] linhas_e_valores_familia: List[Tuple[LinhaSistema, float]] = []
for m in membros: for m in membros:
@ -268,6 +325,17 @@ def _casa_por_nome(
continue continue
linha_dep = dependentes_da_familia.get(m.nome_normalizado) 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: if linha_dep is None:
auditoria.append(ItemAuditoria( auditoria.append(ItemAuditoria(
motivo="NOME_DIVERGENTE", motivo="NOME_DIVERGENTE",
@ -294,7 +362,7 @@ def _casa_por_nome(
if regra_empresa_fn is not None and linhas_e_valores_familia: if regra_empresa_fn is not None and linhas_e_valores_familia:
regra_empresa_fn(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", chave_casamento: str = "cpf",
regra_custeio: Optional[dict] = None, regra_custeio: Optional[dict] = None,
**kwargs, **kwargs,
) -> Tuple[List[LinhaSistema], List[ItemAuditoria]]: ) -> Tuple[List[LinhaSistema], List[ItemAuditoria], List[VinculoAplicado]]:
estrategia = ESTRATEGIAS.get(chave_casamento) estrategia = ESTRATEGIAS.get(chave_casamento)
if estrategia is None: if estrategia is None:
raise ValueError(f"chave_casamento desconhecida: {chave_casamento!r}") raise ValueError(f"chave_casamento desconhecida: {chave_casamento!r}")

View File

@ -112,3 +112,30 @@ class ItemAuditoria:
valor: float valor: float
tipo_lancamento: str = "" tipo_lancamento: str = ""
detalhe: 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

View File

@ -12,7 +12,7 @@ from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from portal_api.planos_saude.matcher import casa_individuos_com_planilha 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.regras_empresa import valida_regra_empresa
from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdontoMensalidade from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdontoMensalidade
from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade
@ -85,6 +85,11 @@ class ResultadoProcessamento:
nome_operadora: str nome_operadora: str
linhas_por_tipo: Dict[str, List[LinhaSistema]] = field(default_factory=dict) linhas_por_tipo: Dict[str, List[LinhaSistema]] = field(default_factory=dict)
auditoria: List[ItemAuditoria] = field(default_factory=list) 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]: def _agrega_individuos_entre_arquivos(individuos: List[Individuo]) -> List[Individuo]:
@ -123,6 +128,7 @@ def processa_importacao(
tipos_selecionados: List[str], tipos_selecionados: List[str],
custeio_por_tipo: Dict[str, dict], custeio_por_tipo: Dict[str, dict],
regra_empresa_key: Optional[str] = None, regra_empresa_key: Optional[str] = None,
vinculos_por_nome: Optional[Dict[str, VinculoNome]] = None,
) -> ResultadoProcessamento: ) -> ResultadoProcessamento:
""" """
Extrai um ou mais arquivos da operadora (a maioria manda só um, mas 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). (ImportacaoPlanoSaudeCreateSerializer já garante que vem vazio).
Levanta RegraEmpresaIncompativelError se a regra não servir pra esta Levanta RegraEmpresaIncompativelError se a regra não servir pra esta
operadora/planilha (propagada pra fora, não é um erro de arquivo). 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] operadora_info = OPERADORAS[operadora_key]
parser_operadora = operadora_info["parser"]() parser_operadora = operadora_info["parser"]()
@ -193,15 +204,18 @@ def processa_importacao(
linhas_copia = [ linhas_copia = [
LinhaSistema(**vars(linha)) for linha in linhas_sistema_template 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, individuos_do_tipo,
linhas_copia, linhas_copia,
parser_operadora.chave_casamento_para_tipo(tipo_lancamento), parser_operadora.chave_casamento_para_tipo(tipo_lancamento),
regra_custeio=custeio_por_tipo[tipo_lancamento], regra_custeio=custeio_por_tipo[tipo_lancamento],
nomes_titular_por_numero=nomes_titular_por_numero, nomes_titular_por_numero=nomes_titular_por_numero,
regra_empresa_fn=regra_empresa_fn if tipo_lancamento == "mensalidade" else None, 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.linhas_por_tipo[tipo_lancamento] = linhas_atualizadas
resultado.vinculos_aplicados.extend(vinculos_aplicados)
resultado.auditoria.extend(itens_auditoria) resultado.auditoria.extend(itens_auditoria)
return resultado return resultado

View File

@ -737,6 +737,7 @@ class ImportacaoPlanoSaudeAuditoriaSerializer(serializers.ModelSerializer):
class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer): class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None) usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None)
linha_nome = serializers.SerializerMethodField() linha_nome = serializers.SerializerMethodField()
vinculo_nome_destino = serializers.SerializerMethodField()
class Meta: class Meta:
model = ImportacaoPlanoSaudeAlteracao model = ImportacaoPlanoSaudeAlteracao
@ -750,6 +751,8 @@ class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
"valor_anterior", "valor_anterior",
"valor_novo", "valor_novo",
"dados_linha", "dados_linha",
"vinculo_nome",
"vinculo_nome_destino",
"usuario_nome", "usuario_nome",
"criado_em", "criado_em",
"revertida", "revertida",
@ -767,6 +770,18 @@ class ImportacaoPlanoSaudeAlteracaoSerializer(serializers.ModelSerializer):
return obj.linha.nome_dependente or obj.linha.nome_func or None return obj.linha.nome_dependente or obj.linha.nome_func or None
return dados.get("nome_dependente") or dados.get("nome_func") or None return dados.get("nome_dependente") or dados.get("nome_func") or None
def get_vinculo_nome_destino(self, obj: ImportacaoPlanoSaudeAlteracao) -> str | None:
"""Só em TIPO_VINCULO_AUTOMATICO — o nome (planilha padrão) pro qual
`valor_novo` (nome divergente do arquivo da operadora) foi vinculado
automaticamente, pra montar "<nome do arquivo>" → <nome vinculado>"
na aba Alterações. `None` quando o VinculoNomeOperadora já foi
apagado (botão "Apagar vínculo", ver ImportacaoPlanoSaudeAlteracaoViewSet.
reverter) — a linha continua identificável pelos outros campos."""
if not obj.vinculo_nome_id:
return None
vinculo = obj.vinculo_nome
return vinculo.nome_func_destino or vinculo.nome_dependente_destino or None
def _codigo_empresa_da_importacao(obj: ImportacaoPlanoSaude) -> str: def _codigo_empresa_da_importacao(obj: ImportacaoPlanoSaude) -> str:
"""Todas as linhas de uma importação vêm da mesma planilha padrão, então """Todas as linhas de uma importação vêm da mesma planilha padrão, então

View File

@ -60,6 +60,7 @@ from .models import (
RegraCusteioPlanoSaude, RegraCusteioPlanoSaude,
TelefoneExterno, TelefoneExterno,
Usuario, Usuario,
VinculoNomeOperadora,
WidgetUsuario, WidgetUsuario,
) )
from .indicadores import calculo as indicadores_calculo 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 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 CABECALHO as PLANO_SAUDE_CABECALHO
from .planos_saude.leiaute_sistema import formata_valor_br, le_planilha_padrao, parse_valor_br 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.questor_planilha import busca_linhas_questor, linhas_para_csv_bytes
from .planos_saude.regras_empresa import RegraEmpresaIncompativelError from .planos_saude.regras_empresa import RegraEmpresaIncompativelError
from .serializers import ( 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): class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
"""Ferramenta "Importação de Plano de Saúde" (Utilitários) — permissão de """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 toggle único (sem par visualizar/editar, ver catalogo.py "utilitarios"), então
@ -931,6 +966,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
try: try:
if planilha_padrao_arquivo: if planilha_padrao_arquivo:
linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path) 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( resultado = planos_saude_pipeline.processa_importacao(
operadora_key=operadora_key, operadora_key=operadora_key,
caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora], caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
@ -938,6 +974,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
tipos_selecionados=tipos, tipos_selecionados=tipos,
custeio_por_tipo=custeio_por_tipo, custeio_por_tipo=custeio_por_tipo,
regra_empresa_key=dados["regra_empresa"] or None, regra_empresa_key=dados["regra_empresa"] or None,
vinculos_por_nome=vinculos_por_nome,
) )
except RegraEmpresaIncompativelError as exc: except RegraEmpresaIncompativelError as exc:
# Diferente do genérico abaixo: aqui o problema não é o arquivo em # 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, 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( ImportacaoPlanoSaudeLinha(
importacao=importacao, importacao=importacao,
tipo_lancamento=tipo, tipo_lancamento=tipo,
@ -1008,10 +1050,35 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
valor=linha.valor, valor=linha.valor,
descricao=linha.descricao, descricao=linha.descricao,
) )
for tipo, linhas in resultado.linhas_por_tipo.items() for tipo, ordem, linha in linhas_specs
for ordem, linha in enumerate(linhas) ])
]
ImportacaoPlanoSaudeLinha.objects.bulk_create(linhas_bulk) # 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 = [ auditoria_bulk = [
ImportacaoPlanoSaudeAuditoria( ImportacaoPlanoSaudeAuditoria(
@ -1178,10 +1245,16 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet):
def reverter(self, request: Request, pk: str | None = None) -> Response: def reverter(self, request: Request, pk: str | None = None) -> Response:
"""Desfaz uma alteração específica: edição volta o campo pro valor """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 anterior; inclusão remove a linha incluída; exclusão recria a linha a
partir do snapshot salvo em `dados_linha`. Idempotente — recusa partir do snapshot salvo em `dados_linha`; vínculo automático de nome
reverter de novo uma alteração já revertida (`revertida=True`), e a (botão "Apagar vínculo" na aba Alterações) zera o valor lançado nessa
própria reversão não gera um novo registro de alteração (evita um linha (redistribuindo a regra empresa da família de novo, se
loop de "reverter a reversão").""" 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() alteracao = self.get_object()
_garante_importacao_em_revisao(alteracao.importacao) _garante_importacao_em_revisao(alteracao.importacao)
if alteracao.revertida: if alteracao.revertida:
@ -1204,6 +1277,17 @@ class ImportacaoPlanoSaudeAlteracaoViewSet(viewsets.GenericViewSet):
tipo_lancamento=alteracao.tipo_lancamento, tipo_lancamento=alteracao.tipo_lancamento,
**dados, **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 = True
alteracao.revertida_em = timezone.now() alteracao.revertida_em = timezone.now()
@ -1260,7 +1344,15 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet):
lançamento × titular/dependente) e marca o item como resolvido. Só lançamento × titular/dependente) e marca o item como resolvido. Só
aceita linhas ainda em branco (valor=valor_empresa="0"), pra nunca aceita linhas ainda em branco (valor=valor_empresa="0"), pra nunca
sobrescrever sem querer um lançamento que já casou automaticamente 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() item = self.get_object()
_garante_importacao_em_revisao(item.importacao) _garante_importacao_em_revisao(item.importacao)
@ -1308,6 +1400,20 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet):
item.linha_vinculada = linha item.linha_vinculada = linha
item.save(update_fields=["resolvida", "linha_vinculada"]) 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) return Response(ImportacaoPlanoSaudeAuditoriaSerializer(item).data)

View File

@ -869,6 +869,15 @@ body.is-resizing-column * {
background: rgba(var(--danger-rgb), 0.14); 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 { .ips-alteracao-revertida {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -47,6 +47,7 @@ const PID_IPS_ALTERACAO_TIPO_LABELS = {
edicao: "Edição", edicao: "Edição",
inclusao: "Inclusão", inclusao: "Inclusão",
exclusao: "Exclusão", exclusao: "Exclusão",
vinculo_automatico: "Vínculo automático",
}; };
const PID_IPS_CAMPOS_AUDITORIA = [ const PID_IPS_CAMPOS_AUDITORIA = [
@ -1814,6 +1815,10 @@ document.addEventListener("DOMContentLoaded", async () => {
detalheHtml = `<strong>${escapeHtml(campoLabel)}:</strong> "${escapeHtml(alt.valor_anterior)}" → "${escapeHtml(alt.valor_novo)}"`; detalheHtml = `<strong>${escapeHtml(campoLabel)}:</strong> "${escapeHtml(alt.valor_anterior)}" → "${escapeHtml(alt.valor_novo)}"`;
} else if (alt.tipo === "inclusao") { } else if (alt.tipo === "inclusao") {
detalheHtml = "Linha incluída manualmente"; detalheHtml = "Linha incluída manualmente";
} else if (alt.tipo === "vinculo_automatico") {
detalheHtml = alt.vinculo_nome_destino
? `"${escapeHtml(alt.valor_novo)}" (arquivo da operadora) → <strong>${escapeHtml(alt.vinculo_nome_destino)}</strong> (planilha padrão)`
: `"${escapeHtml(alt.valor_novo)}" (vínculo já apagado)`;
} else { } else {
detalheHtml = "Linha removida"; detalheHtml = "Linha removida";
} }
@ -1822,7 +1827,9 @@ document.addEventListener("DOMContentLoaded", async () => {
? `<span class="ips-alteracao-revertida">Revertida</span>` ? `<span class="ips-alteracao-revertida">Revertida</span>`
: importacaoAtual.status === "concluida" : importacaoAtual.status === "concluida"
? `<span class="ips-empty-cell">—</span>` ? `<span class="ips-empty-cell">—</span>`
: `<button type="button" class="btn-outline ips-reverter-btn" data-reverter-alteracao="${alt.id}">Reverter</button>`; : `<button type="button" class="btn-outline ips-reverter-btn" data-reverter-alteracao="${alt.id}">${
alt.tipo === "vinculo_automatico" ? "Apagar vínculo" : "Reverter"
}</button>`;
return ` return `
<tr> <tr>
@ -2280,7 +2287,12 @@ document.addEventListener("DOMContentLoaded", async () => {
const reverterBtn = event.target.closest("[data-reverter-alteracao]"); const reverterBtn = event.target.closest("[data-reverter-alteracao]");
if (reverterBtn) { if (reverterBtn) {
const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao")); 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; reverterBtn.disabled = true;
try { try {
await pidReverterAlteracaoPlanoSaude(alteracaoId); await pidReverterAlteracaoPlanoSaude(alteracaoId);

View File

@ -396,7 +396,7 @@
<div class="modal-field"> <div class="modal-field">
<label>Arquivos</label> <label>Arquivos</label>
<div class="ips-upload-box"> <div class="ips-upload-box">
<p class="ips-upload-box__title">1. Planilha padrão (Questor)</p> <p class="ips-upload-box__title">1. Planilha padrão</p>
<p class="ips-upload-box__hint">Cadastro dos beneficiários (sem valores) — buscado direto do Questor, ou anexado manualmente como alternativa.</p> <p class="ips-upload-box__hint">Cadastro dos beneficiários (sem valores) — buscado direto do Questor, ou anexado manualmente como alternativa.</p>
<div class="ips-planilha-origem-opcoes"> <div class="ips-planilha-origem-opcoes">
<label class="modal-checkbox"> <label class="modal-checkbox">
@ -426,8 +426,8 @@
<p class="ips-file-field__status" id="ips-form-planilha-status" hidden></p> <p class="ips-file-field__status" id="ips-form-planilha-status" hidden></p>
</div> </div>
<div class="ips-upload-box"> <div class="ips-upload-box">
<p class="ips-upload-box__title">2. Arquivo da operadora</p> <p class="ips-upload-box__title">2. Arquivos da operadora</p>
<p class="ips-upload-box__hint">Relatório de faturamento enviado pela operadora do plano (PDF ou CSV), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados — anexe quantos precisar, o tipo de cada um é identificado automaticamente.</p> <p class="ips-upload-box__hint">Relatório de faturamento enviado pela operadora do plano (PDF ou CSV), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados, o tipo de cada um é identificado automaticamente.</p>
<div class="ips-file-field"> <div class="ips-file-field">
<label class="btn-outline ips-file-field__btn" for="ips-form-arquivo"> <label class="btn-outline ips-file-field__btn" for="ips-form-arquivo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>