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 -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 ' *)"
]
}
}

View File

@ -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 — <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.
### 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)
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 `<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`.
- **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 `<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"):
- `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo.
- `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão).
- `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao`, cores dourado/teal/vermelho), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão) e o usuário. A coluna "Ação" mostra "Reverter" (com `window.confirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.
- `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)

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.
- **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

View File

@ -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")

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"]
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"
)

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
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}")

View File

@ -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

View File

@ -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

View File

@ -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 "<nome do arquivo>" → <nome vinculado>"
na aba Alterações. `None` quando o VinculoNomeOperadora já foi
apagado (botão "Apagar vínculo", ver ImportacaoPlanoSaudeAlteracaoViewSet.
reverter) — a linha continua identificável pelos outros campos."""
if not obj.vinculo_nome_id:
return None
vinculo = obj.vinculo_nome
return vinculo.nome_func_destino or vinculo.nome_dependente_destino or None
def _codigo_empresa_da_importacao(obj: ImportacaoPlanoSaude) -> str:
"""Todas as linhas de uma importação vêm da mesma planilha padrão, então

View File

@ -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)

View File

@ -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;

View File

@ -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 = `<strong>${escapeHtml(campoLabel)}:</strong> "${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) → <strong>${escapeHtml(alt.vinculo_nome_destino)}</strong> (planilha padrão)`
: `"${escapeHtml(alt.valor_novo)}" (vínculo já apagado)`;
} else {
detalheHtml = "Linha removida";
}
@ -1822,7 +1827,9 @@ document.addEventListener("DOMContentLoaded", async () => {
? `<span class="ips-alteracao-revertida">Revertida</span>`
: importacaoAtual.status === "concluida"
? `<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 `
<tr>
@ -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);

View File

@ -396,7 +396,7 @@
<div class="modal-field">
<label>Arquivos</label>
<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>
<div class="ips-planilha-origem-opcoes">
<label class="modal-checkbox">
@ -426,8 +426,8 @@
<p class="ips-file-field__status" id="ips-form-planilha-status" hidden></p>
</div>
<div class="ips-upload-box">
<p class="ips-upload-box__title">2. Arquivo 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__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, o tipo de cada um é identificado automaticamente.</p>
<div class="ips-file-field">
<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>