Inclusão de registro de alterações realizadas na auditoria - Plano de Saúde
This commit is contained in:
parent
4d75e8f8e2
commit
50772d864b
@ -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 ' *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
CLAUDE.md
25
CLAUDE.md
@ -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)
|
||||||
|
|
||||||
|
|||||||
10
plano.md
10
plano.md
@ -1099,6 +1099,16 @@ Cliente real (Fallkner Ribeiro Borges) em que a Unimed manda **dois PDFs separad
|
|||||||
- **Coparticipação analítica**: `benef`+`nome`+`grau` vêm **colados sem espaço nenhum** (ex.: "0975.0167003824292ANDREIA STORMTITULAR") — corrigido ajustando a regex pra não exigir espaço entre eles. Mas surgiu um problema mais sério: o **nome do beneficiário sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM"), o que faria casamento por nome falhar sistematicamente pra qualquer nome mais longo que a coluna — não é um bug de regex, é informação perdida de verdade no relatório. Como esse mesmo relatório traz CPF completo e confiável, a solução foi trocar a estratégia de casamento: `OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)` (default: mesmo valor de sempre, backward-compatible pra todo outro parser) e `UnimedSaude` a sobrescreve pra devolver `"cpf"` só quando `tipo_lancamento="coparticipacao"` **e** a origem foi o PDF (rastreado numa flag de instância setada em `extrai()` — mensalidade, sem CPF em nenhum formato, continua em `"nome"`). Também corrigido: linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento de um item já somado no valor principal) estavam sendo contadas como itens de serviço de verdade (colidindo com a mesma regex de tipo de serviço), duplicando o valor — agora ignoradas explicitamente.
|
- **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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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.
|
||||||
@ -245,6 +286,17 @@ def _casa_por_nome(
|
|||||||
nome_titular_norm = normaliza_nome(nome_titular)
|
nome_titular_norm = normaliza_nome(nome_titular)
|
||||||
linha_titular = titulares.get(nome_titular_norm)
|
linha_titular = titulares.get(nome_titular_norm)
|
||||||
|
|
||||||
|
if linha_titular is None:
|
||||||
|
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:
|
if linha_titular is None:
|
||||||
for m in membros:
|
for m in membros:
|
||||||
auditoria.append(_item_nao_cadastrado(
|
auditoria.append(_item_nao_cadastrado(
|
||||||
@ -252,7 +304,12 @@ def _casa_por_nome(
|
|||||||
))
|
))
|
||||||
continue
|
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}")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user