Inclusão dos parametros da operadora Unimed Foz.
This commit is contained in:
parent
8d2650812d
commit
4d75e8f8e2
@ -6,7 +6,13 @@
|
|||||||
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --dry-run --verbosity 2)",
|
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --dry-run --verbosity 2)",
|
||||||
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api)",
|
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api)",
|
||||||
"Bash(node --check static/js/importacao-plano-saude.js)",
|
"Bash(node --check static/js/importacao-plano-saude.js)",
|
||||||
"Bash(grep -n \"Histórico \\(\\\\`#ips-list-table\\\\`\\)\" CLAUDE.md)"
|
"Bash(grep -n \"Histórico \\(\\\\`#ips-list-table\\\\`\\)\" CLAUDE.md)",
|
||||||
|
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --name arquivo_operadora_multiplo_passo1)",
|
||||||
|
"Bash(.venv/Scripts/python.exe manage.py makemigrations portal_api --empty --name arquivo_operadora_multiplo_passo2_backfill)",
|
||||||
|
"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/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@ -557,7 +557,7 @@ portal_api/planos_saude/
|
|||||||
└── operadoras/
|
└── operadoras/
|
||||||
├── base.py OperadoraParser (interface)
|
├── base.py OperadoraParser (interface)
|
||||||
├── amil/odonto_mensalidade.py Amil Odonto (PDF via pdfplumber, só mensalidade, casamento por CPF)
|
├── amil/odonto_mensalidade.py Amil Odonto (PDF via pdfplumber, só mensalidade, casamento por CPF)
|
||||||
├── unimed/saude.py Unimed (CSV, mensalidade+coparticipação, casamento por nome)
|
├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo)
|
||||||
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
|
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
|
||||||
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
|
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
|
||||||
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
|
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
|
||||||
@ -575,7 +575,8 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
|
|||||||
|
|
||||||
### Modelos (`portal_api/models.py`)
|
### Modelos (`portal_api/models.py`)
|
||||||
|
|
||||||
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), os dois arquivos anexados (`planilha_padrao`/`arquivo_operadora`, `FileField` com o mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — são documentos reais, não ícones; `planilha_padrao` é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável.
|
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), `planilha_padrao` (`FileField`, mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável. O arquivo (ou arquivos) da operadora vive num model relacionado separado, ver `ImportacaoPlanoSaudeArquivoOperadora` a seguir e "Múltiplos arquivos de operadora" abaixo.
|
||||||
|
- `ImportacaoPlanoSaudeArquivoOperadora`: um dos relatórios da operadora anexados a uma importação (FK `importacao`, `arquivo` FileField, `ordem`) — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde em PDF) mandam mensalidade e coparticipação em arquivos separados. Substituiu, numa rodada posterior, o antigo `FileField` único `ImportacaoPlanoSaude.arquivo_operadora` (migração em 3 passos — `0048` cria o model novo + torna o campo legado `blank=True`; `0049`, `RunPython`, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo em `MEDIA_ROOT`, sem copiar bytes; `0050` remove o campo legado — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`).
|
||||||
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
|
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
|
||||||
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
|
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
|
||||||
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
|
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
|
||||||
@ -584,7 +585,7 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
|
|||||||
### Fluxo e endpoints
|
### Fluxo e endpoints
|
||||||
|
|
||||||
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
|
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
|
||||||
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model (isso já grava os 2 arquivos em `MEDIA_ROOT`) e roda `pipeline.processa_importacao()` **de forma síncrona** usando `instance.arquivo_operadora.path` + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos + o registro órfão e devolve 400.
|
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo em `arquivo_operadora` (lista, ver "Múltiplos arquivos de operadora" abaixo) e roda `pipeline.processa_importacao()` **de forma síncrona** usando os caminhos de todos os arquivos da operadora + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos (planilha + todos os da operadora) + o registro órfão e devolve 400.
|
||||||
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `"<código> - <Nome>"` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend.
|
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `"<código> - <Nome>"` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend.
|
||||||
- `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão").
|
- `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão").
|
||||||
- `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`.
|
- `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`.
|
||||||
@ -603,6 +604,25 @@ No frontend (`importacao-plano-saude.js`), a tela de Revisão espelha essa trava
|
|||||||
|
|
||||||
Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela.
|
Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela.
|
||||||
|
|
||||||
|
### Múltiplos arquivos de operadora
|
||||||
|
|
||||||
|
Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação") era um único upload obrigatório — trocado por **1 ou mais arquivos** (pedido explícito do usuário): algumas operadoras mandam mensalidade e coparticipação em arquivos separados (a primeira real: Unimed Saúde, quando manda PDF em vez do CSV único — ver "Parser da Unimed Saúde" abaixo), em vez de um único arquivo com os dois tipos juntos.
|
||||||
|
|
||||||
|
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.arquivo_operadora` é um `ListField(child=FileField(), allow_empty=False)` — o DRF já lê múltiplos arquivos do mesmo campo em `multipart/form-data` via `request.data.getlist(...)` (mesma semântica do `QueryDict`), sem tratamento manual extra na view. `create()` cria um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo (`ordem=índice`); `pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)` chama `OperadoraParser.extrai()` **uma vez por caminho** (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só o `pipeline.py`) e concatena os indivíduos/itens de auditoria de todos os arquivos antes de seguir com o casamento normal.
|
||||||
|
- **`_agrega_individuos_entre_arquivos()` (`pipeline.py`)** — bug real encontrado e corrigido ao testar esta funcionalidade de ponta a ponta: se dois arquivos contribuem indivíduos da MESMA pessoa e do MESMO `tipo_lancamento` (ex.: duas coparticipações do mesmo mês, separadas por período), só concatenar as duas listas não bastava — `casa_individuos_com_planilha`/`_aplica_regra_custeio` (matcher.py) **grava** o valor final na `LinhaSistema` por pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (`valor_total` e `rubricas`) os indivíduos de mesma chave (`numero_beneficiario`, `tipo_lancamento`) **entre arquivos**, logo depois de concatenar as listas — mesmo padrão que cada parser já faz **dentro** de um único arquivo (`_agrega_por_individuo_e_tipo`), só que agora entre arquivos também.
|
||||||
|
- `perform_destroy()`/os `except` de `create()` (arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam **todos** os arquivos de `importacao.arquivos_operadora.all()` de `MEDIA_ROOT`, não só um.
|
||||||
|
- **Frontend**: `<input type="file" multiple>` + uma lista dinâmica (`#ips-form-arquivo-list`/`.ips-arquivo-list`, `importacao-plano-saude.js`) no lugar do campo único de sempre — cada arquivo anexado é validado individualmente (mesmo endpoint `POST /.../validar-arquivo/` de sempre, chamado uma vez por arquivo, sem mudança nenhuma nele) e listado com seu próprio status + botão de remover; trocar a operadora revalida todos os arquivos já anexados. No submit, `formData.append("arquivo_operadora", file)` uma vez por arquivo.
|
||||||
|
|
||||||
|
#### Parser da Unimed Saúde (PDF): dois relatórios separados, tipo detectado automaticamente
|
||||||
|
|
||||||
|
`operadoras/unimed/saude.py` (`unimed_saude`, código 5060) ganhou um segundo formato de entrada, além do CSV único já existente: **dois PDFs** de um cliente real (mensalidade + coparticipação analítico), detectados automaticamente pelo **conteúdo** de cada arquivo — nunca pelo usuário escolhendo um "tipo de documento" (pedido explícito). `UnimedSaude.extrai()` abre o PDF com `pdfplumber` e olha a primeira página: `"BENEFICIARIOS COM FATURAMENTO NO MES"` → relatório de mensalidade (`_extrai_pdf_mensalidade`, uma linha por beneficiário, `extract_text()` simples já basta); `"SERVIÇOS PRESTADOS"`/`"ANALITICO"` → coparticipação analítica (`_extrai_pdf_coparticipacao`, várias linhas de serviço por beneficiário, somadas por pessoa).
|
||||||
|
|
||||||
|
- Confirmado com o usuário: a coparticipação devida por beneficiário é a **soma do "Vl Total" de cada linha de serviço** daquele beneficiário — a coluna "Tt Copar" (valor fixo, repetido em toda linha do documento) **não é usada**. Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um item, cuja soma já está no valor do item principal) são ignoradas — senão duplicariam o valor.
|
||||||
|
- `nome`/`Grau Dep.` (TITULAR/CONJUGE/FILHO(A)/...) só aparecem na primeira linha de cada bloco de atendimento — parsing com estado (mesmo padrão do `ItamedSaude`).
|
||||||
|
- Valores nos dois PDFs vêm em **formato americano** (ponto decimal, vírgula de milhar — ex. "6,061.74"), ao contrário do formato BR do resto do pipeline — `_valor_pdf_para_float`, função própria, separada de `_valor_para_float` (BR, só pro CSV).
|
||||||
|
- **`OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)`** (default: devolve `chave_casamento`, mesmo valor de sempre — método novo, backward-compatible pra todo outro parser) porque a coparticipação analítica da Unimed em PDF precisou de uma estratégia de casamento **diferente da mensalidade dentro da mesma operadora**: o "Beneficiario" desse relatório vem colado sem espaço com o nome e o grau de dependência (ex.: "0975.0167003824292ANDREIA STORMTITULAR") e o **nome sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM") — inviabilizando casamento por nome. Como esse relatório traz CPF completo e confiável, `UnimedSaude` usa `"cpf"` só pra `tipo_lancamento="coparticipacao"` quando a origem foi esse PDF (rastreado numa flag de instância, `self._veio_de_pdf_coparticipacao`, setada em `extrai()`); mensalidade (sem CPF em nenhum dos dois formatos) continua em `"nome"`. `pipeline.processa_importacao` chama `chave_casamento_para_tipo(tipo_lancamento)` em vez do atributo fixo.
|
||||||
|
- **Validado contra os dois arquivos reais** (não só texto colado numa conversa — o texto que sai de um PDF colado no chat **não é** o que `pdfplumber.extract_text()` de fato produz, então não serve pra desenhar regex com confiança; só o arquivo real confirma). Bate exatamente com "Total da Familia"/"Total da Sequencia" impresso no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e com o casamento por CPF contra a planilha padrão real da empresa — toda família presente na planilha bateu centavo a centavo; a família ausente da planilha de teste foi corretamente pra auditoria, não ignorada silenciosamente.
|
||||||
|
|
||||||
### Planilha padrão via Questor (SQL)
|
### Planilha padrão via Questor (SQL)
|
||||||
|
|
||||||
Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total.
|
Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total.
|
||||||
|
|||||||
1649
media/planos_saude/operadora/Mensalidade.pdf
Normal file
1649
media/planos_saude/operadora/Mensalidade.pdf
Normal file
File diff suppressed because it is too large
Load Diff
5261
media/planos_saude/operadora/cooparticipação.pdf
Normal file
5261
media/planos_saude/operadora/cooparticipação.pdf
Normal file
File diff suppressed because it is too large
Load Diff
24
media/planos_saude/planilha_padrao/questor_1123_2026-08.csv
Normal file
24
media/planos_saude/planilha_padrao/questor_1123_2026-08.csv
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
|
||||||
|
1123;ANDREIA STORMOSKI LARA;031.535.759-24;5060;01/04/2026;GABRIELE LARAVILLELA;135.897.549-31;0;0;
|
||||||
|
1123;ANDREIA STORMOSKI LARA;031.535.759-24;5060;01/04/2026;;;0;0;
|
||||||
|
1123;ANDREIA STORMOSKI LARA;031.535.759-24;5060;01/04/2026;MANUELA LARA VILLELA;135.897.739-95;0;0;
|
||||||
|
1123;CHRISTIAN GASTON RIZZI;703.895.279-04;5060;01/04/2026;;;0;0;
|
||||||
|
1123;FALLKNER RIBEIRO BORGES;040.942.249-55;5060;01/04/2026;;;0;0;
|
||||||
|
1123;FALLKNER RIBEIRO BORGES;040.942.249-55;5060;01/04/2026;CATHERINE NUNES BORGES;800.830.749-88;0;0;
|
||||||
|
1123;FALLKNER RIBEIRO BORGES;040.942.249-55;5060;01/04/2026;AMANDA RODRIGUES BORGES;154.745.769-43;0;0;
|
||||||
|
1123;FALLKNER RIBEIRO BORGES;040.942.249-55;5060;01/04/2026;ADRIANA RODRIGUES;047.011.989-60;0;0;
|
||||||
|
1123;FRANCIELLI ZANDONA;070.345.739-05;5060;01/04/2026;;;0;0;
|
||||||
|
1123;GABRIEL HENRIQUE ALCANTARA DE OLIVEIRA;081.249.969-70;5060;01/04/2026;;;0;0;
|
||||||
|
1123;GABRIEL HENRIQUE ALCANTARA DE OLIVEIRA;081.249.969-70;5060;01/04/2026;ANA JULIA RAZZOLINI ALCANTARA DE OLIVEIRA;137.047.219-64;0;0;
|
||||||
|
1123;JEFFERSON DAMBORIANA HWANG;083.124.969-20;5060;01/04/2026;;;0;0;
|
||||||
|
1123;JESSICA RIBEIRO DE SOUZA;109.445.609-89;5060;01/04/2026;;;0;0;
|
||||||
|
1123;MARINA MASSAO PEREIRA;071.602.179-00;5060;01/04/2026;;;0;0;
|
||||||
|
1123;PAULO HENRIQUE APARECIDO DA SILVA;096.531.429-41;5060;01/04/2026;;;0;0;
|
||||||
|
1123;PAULO HENRIQUE APARECIDO DA SILVA;096.531.429-41;5060;01/04/2026;TALYTA CARNEIRO NERIS;033.441.441-54;0;0;
|
||||||
|
1123;PAULO HENRIQUE APARECIDO DA SILVA;096.531.429-41;5060;01/04/2026;VICENTE NERIS DA SILVA ROZENDO;160.485.389-10;0;0;
|
||||||
|
1123;PAULO ROMEO DOMINGUES JUNIOR;045.568.259-38;5060;01/04/2026;;;0;0;
|
||||||
|
1123;PAULO ROMEO DOMINGUES JUNIOR;045.568.259-38;5060;01/04/2026;LOHAINE AGUIAR DOMINGUES;129.814.229-60;0;0;
|
||||||
|
1123;RODRIGO GOTTLIEB MONZON;027.514.939-04;5060;01/04/2026;;;0;0;
|
||||||
|
1123;RODRIGO GOTTLIEB MONZON;027.514.939-04;5060;01/04/2026;SOL GRACIOLA MONZON;152.963.359-10;0;0;
|
||||||
|
1123;THAIS BRUNO CUNHA DOS SANTOS;415.927.758-61;5060;01/04/2026;;;0;0;
|
||||||
|
1123;WALLACE ZENARO;094.583.269-90;5060;01/04/2026;;;0;0;
|
||||||
|
32
plano.md
32
plano.md
@ -1067,6 +1067,38 @@ Até aqui, "Nova Importação" exigia exportar manualmente do Questor a "planilh
|
|||||||
|
|
||||||
**Sétimo ajuste, mesmo dia — filtro revisado pra "estilo Excel"**: usuário pediu pra trocar os campos de texto por um filtro de planilha de verdade — clicar num ícone de funil na coluna e marcar/desmarcar os valores que aparecem numa lista, como no AutoFilter do Excel. Reescrito (`criarFiltroColuna()`) como um popup por coluna com busca + checklist dos valores distintos daquela coluna (reaproveita `.checklist-box` de Perfis de Acesso, sem CSS/componente novo pra isso) e botões "Aplicar"/"Limpar" — os filtros das 4 colunas continuam combinando entre si (AND). Precisou de um ajuste de CSS colateral: `.pa-table-wrap` corta com `overflow:hidden` pra arredondar os cantos da tabela, o que cortaria o popup também — resolvido com uma classe extra só nesta tabela (`.ips-list-table-wrap`) sobrescrevendo pra `overflow:visible`.
|
**Sétimo ajuste, mesmo dia — filtro revisado pra "estilo Excel"**: usuário pediu pra trocar os campos de texto por um filtro de planilha de verdade — clicar num ícone de funil na coluna e marcar/desmarcar os valores que aparecem numa lista, como no AutoFilter do Excel. Reescrito (`criarFiltroColuna()`) como um popup por coluna com busca + checklist dos valores distintos daquela coluna (reaproveita `.checklist-box` de Perfis de Acesso, sem CSS/componente novo pra isso) e botões "Aplicar"/"Limpar" — os filtros das 4 colunas continuam combinando entre si (AND). Precisou de um ajuste de CSS colateral: `.pa-table-wrap` corta com `overflow:hidden` pra arredondar os cantos da tabela, o que cortaria o popup também — resolvido com uma classe extra só nesta tabela (`.ips-list-table-wrap`) sobrescrevendo pra `overflow:visible`.
|
||||||
|
|
||||||
|
### 81. "Mais informações" por aplicação (botão "?") — e correção do `seed_portal.py` resetando nomes de perfil
|
||||||
|
|
||||||
|
Pedido explícito do usuário: um botão "?" ao lado do nome de qualquer aplicação, mostrando "Mais informações" no hover e abrindo, ao clicar, um modal com um texto de ajuda (objetivo/processo/cuidados/resultado esperado). Visualizar é livre a qualquer autenticado; editar é restrito a quem tem o perfil **"Inovação"** vinculado.
|
||||||
|
|
||||||
|
- Novo model `AjudaAplicacao` (chave natural `app_key`, `texto`, `atualizado_em`/`atualizado_por`) + endpoint `GET`/`PATCH /api/ajuda-aplicacoes/<app_key>/`. A checagem de quem pode editar é por **nome fixo** do perfil (`Usuario.eh_perfil_inovacao()`/`models.PERFIL_INOVACAO_NOME`), mesmo padrão já usado pro selo "Restrito" de Relatórios Gerenciais (nome === "Diretoria") — decisão explícita do usuário pra não precisar aparecer na árvore de Perfis de Acesso. `GET /api/me/` ganhou `eh_perfil_inovacao`.
|
||||||
|
- Ligado por ora só em Importação de Plano de Saúde (`static/js/ajuda-aplicacao.js`, `pidCriarBotaoAjuda()`), com um texto inicial já estruturado e salvo no banco — mecanismo genérico o bastante pra outra aplicação só precisar do botão+tooltip no HTML.
|
||||||
|
- **Ganhou imagens embutidas** (pedido explícito, mesmo mecanismo de "Observações" de Acessos Gerais): editor `<div contenteditable>` com colar/arrastar imagem, sanitizado no servidor via `nh3` antes de salvar. As constantes de allowlist do nh3 foram generalizadas (`ACESSO_GERAL_OBSERVACOES_ALLOWED_*` → `RICHTEXT_ALLOWED_*`, `serializers.py`) pra serem compartilhadas pelos dois campos.
|
||||||
|
- **Bug real corrigido, achado ao investigar a preocupação do usuário com nomenclatura**: o usuário tinha renomeado o perfil "Integração e Inovação" (código 8) pra "Inovação" e criado um perfil novo "Integração" (código 9) — mas `seed_portal.py` fazia `update_or_create(codigo=..., defaults={"nome": ...})`, que reescrevia `nome` de volta pro valor original a cada execução do seed. Corrigido pra `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação); `ativo`/`gerencia_permissoes`/`permissoes` continuam realinhados a cada execução, de propósito. Todas as 8 telas/textos do frontend que ainda diziam "Integração e Inovação" foram atualizadas pra "Inovação".
|
||||||
|
|
||||||
|
### 82. Modal de confirmação/aviso genérico — fim do `window.confirm`/`window.alert` nativo
|
||||||
|
|
||||||
|
Usuário viu o popup nativo do Chrome ("192.168.x.x:8000 diz...") na confirmação de "Sair sem salvar" do modal de "Mais informações" (rodada 81) e pediu, de forma geral: nenhum popup deve usar o diálogo nativo do browser — sempre um modal dentro do próprio Portal, no padrão visual dele.
|
||||||
|
|
||||||
|
- `static/js/confirm-modal.js` (novo, incluído logo depois de `api.js` em **todo** shell, inclusive `index.html`): `pidConfirm(mensagem, opcoes)` (Promise<boolean>) e `pidAlert(mensagem, opcoes)` (Promise<void>) compartilham o mesmo modal (`#pid-confirm-modal`), que empilha por cima de qualquer modal já aberto (`.modal-overlay--top`, z-index maior) sem fechá-lo. `opcoes.perigoso` troca o botão de ação pra `.btn-danger-outline`.
|
||||||
|
- **Migração completa** (pedido explícito — "migre as demais"): todo `window.confirm()`/`window.alert()` do app (31 ocorrências em 8 arquivos — `acessos-gerais.js`, `calendar-individual.js`, `links-ferramentas.js`, `importacao-plano-saude.js`, `ramais.js`, `profiles.js`, `indicador-desempenho.js`, `users-admin.js`) foi trocado por `pidConfirm`/`pidAlert`. Um modal bespoke que já existia em `importacao-plano-saude.js` só pra esse mesmo motivo (`#ips-regracad-confirm-fechar-modal`, no "Cadastro de Regras") foi removido e consolidado no componente genérico. **Não migrado**: um `window.prompt()` em `users-admin.js` (renomear departamento) — tipo de popup diferente (pede texto), sem componente equivalente ainda.
|
||||||
|
|
||||||
|
### 83. Múltiplos arquivos de operadora + Unimed Saúde em PDF (Importação de Plano de Saúde)
|
||||||
|
|
||||||
|
Cliente real (Fallkner Ribeiro Borges) em que a Unimed manda **dois PDFs separados** (mensalidade + coparticipação analítico) em vez do CSV único já suportado — diferente de toda operadora até então, que sempre mandava um único arquivo. Duas mudanças, decididas em `EnterPlanMode`/`ExitPlanMode` antes de implementar dado o tamanho:
|
||||||
|
|
||||||
|
- **"Arquivo da operadora" passou a aceitar 1+ arquivos** — mudança de arquitetura que afeta todas as operadoras, não só a Unimed. `ImportacaoPlanoSaude.arquivo_operadora` (FileField único) foi substituído por `ImportacaoPlanoSaudeArquivoOperadora` (FK + `arquivo` + `ordem`), migração em 3 passos (`0048` cria o model novo + torna o campo legado `blank=True`; `0049` faz o backfill via `RunPython`, reapontando pro mesmo caminho já salvo sem copiar bytes; `0050` remove o campo legado) — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`. `pipeline.processa_importacao` passou a receber uma lista de caminhos, chamando `OperadoraParser.extrai()` uma vez por arquivo. Frontend: `<input type="file" multiple>` + lista dinâmica com validação e remoção individuais.
|
||||||
|
- **Bug real encontrado testando de ponta a ponta** (2 arquivos idênticos da mesma operadora/tipo, via `APIRequestFactory` real): simplesmente concatenar os indivíduos de cada arquivo não bastava — se dois arquivos contribuem pra mesma pessoa e mesmo `tipo_lancamento`, `_aplica_regra_custeio` (matcher.py) **grava** o valor por linha, não acumula, então o segundo arquivo sobrescrevia o valor do primeiro. Corrigido com `_agrega_individuos_entre_arquivos()` (`pipeline.py`), somando por (`numero_beneficiario`, `tipo_lancamento`) antes do casamento — mesmo padrão que cada parser já fazia **dentro** de um arquivo, agora replicado entre arquivos.
|
||||||
|
- **Parser da Unimed Saúde (`operadoras/unimed/saude.py`) ganhou o segundo formato**: `extrai()` detecta automaticamente, pelo conteúdo da primeira página, se o PDF é o relatório de mensalidade ("BENEFICIARIOS COM FATURAMENTO NO MES") ou o analítico de coparticipação ("SERVIÇOS PRESTADOS"/"ANALITICO") — nunca pede pro usuário escolher. Os dois usam `pdfplumber` com `layout=True` (mesma técnica já validada em `ItamedSaude`). Confirmado com o usuário: a coparticipação por beneficiário é a soma do "Vl Total" de cada serviço (a coluna "Tt Copar", valor fixo repetido em todo o documento, não é usada).
|
||||||
|
- **3 bugs reais encontrados e corrigidos testando com PDFs gerados via `reportlab`** (reproduzindo linha a linha o texto dos exemplos, através do fluxo completo de `create()`, não mocks): (1) os valores nos PDFs vêm em formato americano (ponto decimal, vírgula de milhar), diferente do formato BR do resto do pipeline — `_valor_pdf_para_float` separada; (2) o grau "FILHO(A)" nunca casava porque a regex usava `\b` logo depois de `)`, que não é caractere de palavra (`\b` nunca bate entre dois não-palavra) — trocado por `(?=\s|$)`; (3) `numero_titular` de um dependente estava sendo setado como o código da família, mas o resto do pipeline (`nomes_titular_por_numero`/`_casa_por_nome` em matcher.py) espera o `numero_beneficiario` do próprio titular ali — sem isso, a família nunca era resolvida e tudo caía em auditoria "sem titular identificado".
|
||||||
|
- **Ressalva que permanecia**: o arquivo real da Unimed nunca tinha sido processado (só o texto/imagem colados na conversa) — confirmada como necessária no mesmo dia: o usuário testou pela tela e a coparticipação deu "Nenhum beneficiário foi encontrado neste arquivo."
|
||||||
|
|
||||||
|
**Ajuste no mesmo dia, depois do teste real — usuário forneceu o caminho dos dois arquivos no disco**: em vez de tentar adivinhar a estrutura de novo a partir de texto colado (já teria sido a terceira vez), pedi e recebi o caminho local dos PDFs e rodei o `pdfplumber` de verdade contra eles (`.venv/Scripts/python.exe` + scripts descartáveis). Duas descobertas reais, só possíveis com o arquivo de verdade — **o texto de um PDF colado numa conversa não é o que `pdfplumber.extract_text()` de fato produz**, então as duas primeiras tentativas (rodada 83 acima) estavam desenhadas sobre uma estrutura que nunca existiu:
|
||||||
|
|
||||||
|
- **Mensalidade**: `extract_text()` simples (sem `layout=True`) já devolve linhas bem formadas — a regex da rodada anterior funcionou de primeira contra o arquivo real, bateu exatamente com "Total por Contratante: 6.061,74".
|
||||||
|
- **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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from .models import (
|
|||||||
FuncaoTelefonia,
|
FuncaoTelefonia,
|
||||||
ImportacaoPlanoSaude,
|
ImportacaoPlanoSaude,
|
||||||
ImportacaoPlanoSaudeAlteracao,
|
ImportacaoPlanoSaudeAlteracao,
|
||||||
|
ImportacaoPlanoSaudeArquivoOperadora,
|
||||||
ImportacaoPlanoSaudeAuditoria,
|
ImportacaoPlanoSaudeAuditoria,
|
||||||
ImportacaoPlanoSaudeLinha,
|
ImportacaoPlanoSaudeLinha,
|
||||||
IndicadorApuracao,
|
IndicadorApuracao,
|
||||||
@ -131,6 +132,11 @@ class ImportacaoPlanoSaudeAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("nome_operadora",)
|
search_fields = ("nome_operadora",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ImportacaoPlanoSaudeArquivoOperadora)
|
||||||
|
class ImportacaoPlanoSaudeArquivoOperadoraAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("importacao", "arquivo", "ordem")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ImportacaoPlanoSaudeLinha)
|
@admin.register(ImportacaoPlanoSaudeLinha)
|
||||||
class ImportacaoPlanoSaudeLinhaAdmin(admin.ModelAdmin):
|
class ImportacaoPlanoSaudeLinhaAdmin(admin.ModelAdmin):
|
||||||
list_display = ("importacao", "tipo_lancamento", "nome_func", "valor_empresa", "valor")
|
list_display = ("importacao", "tipo_lancamento", "nome_func", "valor_empresa", "valor")
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-08-24 14:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import portal_api.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('portal_api', '0047_alter_ajudaaplicacao_texto'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='importacaoplanosaude',
|
||||||
|
name='arquivo_operadora',
|
||||||
|
field=models.FileField(blank=True, max_length=255, upload_to='planos_saude/operadora/', validators=[portal_api.models.validar_tamanho_arquivo_plano_saude], verbose_name='Arquivo da operadora (legado)'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImportacaoPlanoSaudeArquivoOperadora',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('arquivo', models.FileField(max_length=255, upload_to='planos_saude/operadora/', validators=[portal_api.models.validar_tamanho_arquivo_plano_saude], verbose_name='Arquivo')),
|
||||||
|
('ordem', models.PositiveIntegerField(default=0, verbose_name='Ordem')),
|
||||||
|
('criado_em', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arquivos_operadora', to='portal_api.importacaoplanosaude')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Arquivo da operadora (importação de plano de saúde)',
|
||||||
|
'verbose_name_plural': 'Arquivos da operadora (importação de plano de saúde)',
|
||||||
|
'ordering': ['ordem', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-08-24 14:45
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_arquivos_operadora(apps, schema_editor):
|
||||||
|
"""Antes desta rodada, cada ImportacaoPlanoSaude tinha um único arquivo de
|
||||||
|
operadora (`arquivo_operadora`). Cria uma ImportacaoPlanoSaudeArquivoOperadora
|
||||||
|
por importação já existente, apontando pro mesmo caminho já salvo em
|
||||||
|
MEDIA_ROOT (`arquivo_operadora.name`) — não copia bytes, só reaponta o
|
||||||
|
FileField novo pro arquivo que já existe no disco."""
|
||||||
|
ImportacaoPlanoSaude = apps.get_model("portal_api", "ImportacaoPlanoSaude")
|
||||||
|
ImportacaoPlanoSaudeArquivoOperadora = apps.get_model("portal_api", "ImportacaoPlanoSaudeArquivoOperadora")
|
||||||
|
for importacao in ImportacaoPlanoSaude.objects.exclude(arquivo_operadora=""):
|
||||||
|
ImportacaoPlanoSaudeArquivoOperadora.objects.create(
|
||||||
|
importacao=importacao, arquivo=importacao.arquivo_operadora.name, ordem=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('portal_api', '0048_arquivo_operadora_multiplo_passo1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_arquivos_operadora, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.7 on 2026-08-24 14:46
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('portal_api', '0049_arquivo_operadora_multiplo_passo2_backfill'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='importacaoplanosaude',
|
||||||
|
name='arquivo_operadora',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -570,12 +570,15 @@ class WidgetUsuario(models.Model):
|
|||||||
|
|
||||||
class ImportacaoPlanoSaude(models.Model):
|
class ImportacaoPlanoSaude(models.Model):
|
||||||
"""Uma execução da ferramenta "Importação de Plano de Saúde" (Utilitários):
|
"""Uma execução da ferramenta "Importação de Plano de Saúde" (Utilitários):
|
||||||
o colaborador anexa a planilha padrão do Questor + o relatório de faturamento
|
o colaborador anexa a planilha padrão do Questor + um ou mais relatórios de
|
||||||
de uma operadora, escolhe os tipos de lançamento (mensalidade/coparticipação)
|
faturamento da operadora (`ImportacaoPlanoSaudeArquivoOperadora`, ver abaixo
|
||||||
e, para cada um, se é custeado pela empresa ou descontado do empregado — com
|
— a maioria das operadoras manda só um arquivo, mas algumas mandam
|
||||||
uma regra própria para titular e outra para dependente (`custeio_por_tipo`,
|
mensalidade e coparticipação em arquivos separados), escolhe os tipos de
|
||||||
ver abaixo). O processamento em si (extração + casamento com a planilha) roda
|
lançamento (mensalidade/coparticipação) e, para cada um, se é custeado pela
|
||||||
uma única vez, na criação (ver ImportacaoPlanoSaudeViewSet.create em views.py, que usa
|
empresa ou descontado do empregado — com uma regra própria para titular e
|
||||||
|
outra para dependente (`custeio_por_tipo`, ver abaixo). O processamento em
|
||||||
|
si (extração + casamento com a planilha) roda uma única vez, na criação (ver
|
||||||
|
ImportacaoPlanoSaudeViewSet.create em views.py, que usa
|
||||||
portal_api.planos_saude.pipeline) — as `linhas` resultantes ficam salvas aqui
|
portal_api.planos_saude.pipeline) — as `linhas` resultantes ficam salvas aqui
|
||||||
pra serem revisadas/editadas antes de gerar o CSV final (ver `gerar()`, que
|
pra serem revisadas/editadas antes de gerar o CSV final (ver `gerar()`, que
|
||||||
não reprocessa nada, só formata o que já está no banco)."""
|
não reprocessa nada, só formata o que já está no banco)."""
|
||||||
@ -623,12 +626,6 @@ class ImportacaoPlanoSaude(models.Model):
|
|||||||
# data indica a origem alternativa. Fica em branco pra toda importação
|
# data indica a origem alternativa. Fica em branco pra toda importação
|
||||||
# que veio por upload.
|
# que veio por upload.
|
||||||
competencia = models.DateField("Competência (Questor)", null=True, blank=True)
|
competencia = models.DateField("Competência (Questor)", null=True, blank=True)
|
||||||
arquivo_operadora = models.FileField(
|
|
||||||
"Arquivo da operadora",
|
|
||||||
upload_to="planos_saude/operadora/",
|
|
||||||
max_length=255,
|
|
||||||
validators=[validar_tamanho_arquivo_plano_saude],
|
|
||||||
)
|
|
||||||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||||||
criado_por = models.ForeignKey(
|
criado_por = models.ForeignKey(
|
||||||
Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_plano_saude"
|
Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_plano_saude"
|
||||||
@ -645,6 +642,38 @@ class ImportacaoPlanoSaude(models.Model):
|
|||||||
return f"{self.nome_operadora} ({self.criado_em:%d/%m/%Y})"
|
return f"{self.nome_operadora} ({self.criado_em:%d/%m/%Y})"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportacaoPlanoSaudeArquivoOperadora(models.Model):
|
||||||
|
"""Um dos relatórios da operadora anexados a uma importação — a maioria
|
||||||
|
das operadoras manda só um, mas algumas (ex.: Unimed Saúde, quando manda
|
||||||
|
PDF em vez do CSV único) mandam mensalidade e coparticipação em arquivos
|
||||||
|
separados. O parser da operadora (`OperadoraParser.extrai()`) é chamado
|
||||||
|
uma vez por arquivo, sem precisar que o usuário diga qual é qual — cada
|
||||||
|
parser detecta o tipo de relatório pelo próprio conteúdo (ver
|
||||||
|
`operadoras/unimed/saude.py`). `ordem` só reflete a ordem de upload, sem
|
||||||
|
efeito no processamento (a soma dos indivíduos de todos os arquivos é o
|
||||||
|
que importa, não a ordem entre eles)."""
|
||||||
|
|
||||||
|
importacao = models.ForeignKey(
|
||||||
|
ImportacaoPlanoSaude, on_delete=models.CASCADE, related_name="arquivos_operadora"
|
||||||
|
)
|
||||||
|
arquivo = models.FileField(
|
||||||
|
"Arquivo",
|
||||||
|
upload_to="planos_saude/operadora/",
|
||||||
|
max_length=255,
|
||||||
|
validators=[validar_tamanho_arquivo_plano_saude],
|
||||||
|
)
|
||||||
|
ordem = models.PositiveIntegerField("Ordem", default=0)
|
||||||
|
criado_em = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Arquivo da operadora (importação de plano de saúde)"
|
||||||
|
verbose_name_plural = "Arquivos da operadora (importação de plano de saúde)"
|
||||||
|
ordering = ["ordem", "id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.importacao} — {self.arquivo.name}"
|
||||||
|
|
||||||
|
|
||||||
class ImportacaoPlanoSaudeLinha(models.Model):
|
class ImportacaoPlanoSaudeLinha(models.Model):
|
||||||
"""Uma linha da planilha padrão (leiaute do sistema) dentro de uma
|
"""Uma linha da planilha padrão (leiaute do sistema) dentro de uma
|
||||||
ImportacaoPlanoSaude, já com o valor do mês casado pelo pipeline —
|
ImportacaoPlanoSaude, já com o valor do mês casado pelo pipeline —
|
||||||
|
|||||||
@ -25,11 +25,23 @@ class OperadoraParser(ABC):
|
|||||||
operadora não informa CPF nenhum (ex: Unimed). Nesse
|
operadora não informa CPF nenhum (ex: Unimed). Nesse
|
||||||
modo, nome que não bate EXATO vai para auditoria —
|
modo, nome que não bate EXATO vai para auditoria —
|
||||||
nunca é resolvido por aproximação/fuzzy automática.
|
nunca é resolvido por aproximação/fuzzy automática.
|
||||||
|
|
||||||
|
`chave_casamento_para_tipo(tipo_lancamento)` existe pro caso raro de uma
|
||||||
|
MESMA operadora precisar de estratégias diferentes por tipo de
|
||||||
|
lançamento dependendo do FORMATO do arquivo recebido (ex.: Unimed Saúde
|
||||||
|
em PDF — a mensalidade nunca traz CPF, mas o analítico de coparticipação
|
||||||
|
traz, e o nome do beneficiário nesse relatório vem truncado por largura
|
||||||
|
de coluna, inviabilizando casamento por nome ali). Default: devolve
|
||||||
|
`chave_casamento` (mesmo valor pra todo tipo) — só precisa ser
|
||||||
|
sobrescrito por quem tiver essa necessidade.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
nome_operadora: str
|
nome_operadora: str
|
||||||
chave_casamento: str = "cpf"
|
chave_casamento: str = "cpf"
|
||||||
|
|
||||||
|
def chave_casamento_para_tipo(self, tipo_lancamento: str) -> str:
|
||||||
|
return self.chave_casamento
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
|
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -2,24 +2,73 @@
|
|||||||
Unimed (do Estado do Paraná) - Saúde. Mensalidade + Coparticipação.
|
Unimed (do Estado do Paraná) - Saúde. Mensalidade + Coparticipação.
|
||||||
Porta de projects/project/operadoras/unimed/saude.py.
|
Porta de projects/project/operadoras/unimed/saude.py.
|
||||||
|
|
||||||
Diferente da Amil, esta operadora manda os dois tipos de lançamento no
|
Diferente da Amil, esta operadora manda os dois tipos de lançamento em
|
||||||
MESMO arquivo — cada linha tem uma coluna "Tipo" dizendo se é
|
arquivos independentes um do outro — seja no CSV único de exemplo (uma
|
||||||
"Mensalidade" ou "Serviços" (= coparticipação). Por isso este parser
|
coluna "Tipo" dizendo se é "Mensalidade" ou "Serviços" = coparticipação
|
||||||
devolve indivíduos já separados por tipo.
|
dentro do mesmo arquivo) seja em PDF (dois relatórios separados, um de
|
||||||
|
mensalidade e outro de coparticipação analítico — ver abaixo). Por isso
|
||||||
|
este parser sempre devolve indivíduos já separados por tipo, e o pipeline
|
||||||
|
(`processa_importacao`) já sabe somar os indivíduos de vários arquivos da
|
||||||
|
mesma importação (ver "Importação de Plano de Saúde" no CLAUDE.md, seção
|
||||||
|
sobre múltiplos arquivos de operadora).
|
||||||
|
|
||||||
Formato de entrada: o cliente normalmente manda PDF, mas até hoje só
|
Formato de entrada — dois casos, detectados automaticamente pelo conteúdo
|
||||||
validamos com o CSV de exemplo (mesmas colunas). A extração de PDF
|
do próprio arquivo (nunca pelo usuário escolhendo um "tipo de documento"):
|
||||||
(`_extrai_pdf`) fica como TODO explícito — não dá pra confiar em um
|
|
||||||
layout de colunas que a gente nunca viu; precisa de um PDF real da
|
|
||||||
Unimed pra validar o parsing, do mesmo jeito que foi feito com a Amil.
|
|
||||||
|
|
||||||
Particularidades identificadas no CSV de exemplo:
|
1. CSV de exemplo já validado (colunas Id. Titular, Nome Beneficiário,
|
||||||
|
Tipo Benef., Valor, Tipo, ...) — comportamento original, inalterado.
|
||||||
|
|
||||||
|
2. Dois relatórios em PDF, um por tipo de lançamento:
|
||||||
|
- "BENEFICIARIOS COM FATURAMENTO NO MES" = relatório de mensalidade,
|
||||||
|
uma linha por beneficiário (`_extrai_pdf_mensalidade`).
|
||||||
|
- Um relatório "... EMITE SERVIÇOS PRESTADOS ... ANALITICO" (ex.:
|
||||||
|
"Relação de Serviços Prestados em Custo Emitido") = coparticipação
|
||||||
|
analítica, várias linhas de serviço por beneficiário, que precisam
|
||||||
|
ser somadas (`_extrai_pdf_coparticipacao`).
|
||||||
|
|
||||||
|
**Validado contra os dois arquivos reais** (cliente Fallkner Ribeiro
|
||||||
|
Borges, competência 08/2026 — não só texto colado numa conversa, o
|
||||||
|
`pdfplumber` rodou de fato contra os PDFs de verdade). A primeira versão
|
||||||
|
escrita só a partir de texto/imagem colados errou a estrutura real em
|
||||||
|
dois pontos, só descobertos ao processar o arquivo de verdade (o texto
|
||||||
|
copiado de um PDF numa conversa **não é** o que `pdfplumber.extract_text()`
|
||||||
|
produz — não dá pra confiar nisso pra desenhar regex, só o arquivo real
|
||||||
|
confirma):
|
||||||
|
- **Mensalidade**: `extract_text()` simples (sem `layout=True`) já
|
||||||
|
devolve linhas bem formadas, espaço-separadas — nenhuma técnica
|
||||||
|
especial necessária, ao contrário do que se imaginava.
|
||||||
|
- **Coparticipação analítica**: `benef`+`nome`+`grau` vêm **colados sem
|
||||||
|
nenhum espaço** (ex.: "0975.0167003824292ANDREIA STORMTITULAR") — e o
|
||||||
|
nome do beneficiário sai **truncado em ~13 caracteres** por largura de
|
||||||
|
coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM"), inviabilizando
|
||||||
|
casamento por nome pra esse relatório. Por isso a coparticipação em
|
||||||
|
PDF usa **CPF** (que vem completo e confiável) em vez de nome —
|
||||||
|
`chave_casamento_para_tipo()` (ver `OperadoraParser`) devolve "cpf" só
|
||||||
|
pra `tipo_lancamento="coparticipacao"` quando a origem foi este PDF;
|
||||||
|
mensalidade (sem CPF, em nenhum dos dois formatos) continua em "nome".
|
||||||
|
Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um
|
||||||
|
item, cuja soma já está no valor do item principal) são ignoradas —
|
||||||
|
senão duplicariam o valor, já que contêm um código de tipo (MED/HOS)
|
||||||
|
como palavra isolada, colidindo com a mesma regex que identifica um
|
||||||
|
item de serviço de verdade.
|
||||||
|
Validado batendo exatamente com o "Total da Familia"/"Total da
|
||||||
|
Sequencia" impresso no próprio relatório (1.372,08 de coparticipação,
|
||||||
|
6.061,74 de mensalidade) e com o casamento contra a planilha padrão real
|
||||||
|
da empresa 1123 — cada família presente na planilha bateu centavo a
|
||||||
|
centavo; a única divergência observada (uma família ausente da planilha
|
||||||
|
de teste) foi corretamente pra auditoria, não silenciosamente ignorada.
|
||||||
|
|
||||||
|
Particularidades identificadas no CSV de exemplo (caso 1, inalteradas):
|
||||||
|
|
||||||
1. NÃO HÁ CPF NESTE ARQUIVO. O casamento com a planilha padrão do
|
1. NÃO HÁ CPF NESTE ARQUIVO. O casamento com a planilha padrão do
|
||||||
sistema é feito por NOME (chave_casamento = "nome"), usando o
|
sistema é feito por NOME (chave_casamento = "nome"), usando o
|
||||||
"Id. Titular" do próprio arquivo para saber quem é titular de quem
|
"Id. Titular" do próprio arquivo para saber quem é titular de quem
|
||||||
(evita ambiguidade quando o mesmo nome de dependente aparece em mais
|
(evita ambiguidade quando o mesmo nome de dependente aparece em mais
|
||||||
de uma família na planilha do sistema).
|
de uma família na planilha do sistema). O PDF de mensalidade também não
|
||||||
|
traz CPF nenhum, então casamento por nome vale pros dois — **mas** o
|
||||||
|
PDF de coparticipação analítica traz CPF completo e usa "cpf" em vez de
|
||||||
|
"nome" (ver `chave_casamento_para_tipo()` mais abaixo e a nota no bloco
|
||||||
|
"Validado contra os dois arquivos reais" acima).
|
||||||
|
|
||||||
2. "Tipo Benef." = '1' é Titular; QUALQUER outro valor (normalmente
|
2. "Tipo Benef." = '1' é Titular; QUALQUER outro valor (normalmente
|
||||||
'2') é tratado como Dependente — conforme especificado.
|
'2') é tratado como Dependente — conforme especificado.
|
||||||
@ -32,14 +81,72 @@ Particularidades identificadas no CSV de exemplo:
|
|||||||
4. Uma mesma pessoa pode ter várias linhas de "Serviços" (uma por
|
4. Uma mesma pessoa pode ter várias linhas de "Serviços" (uma por
|
||||||
atendimento/exame) no mês — todas são somadas para chegar no total
|
atendimento/exame) no mês — todas são somadas para chegar no total
|
||||||
de coparticipação do mês daquela pessoa.
|
de coparticipação do mês daquela pessoa.
|
||||||
|
|
||||||
|
Particularidades identificadas no PDF de coparticipação analítico (caso 2):
|
||||||
|
|
||||||
|
5. O relatório tem uma coluna "Tt Copar" que mostra o MESMO valor fixo
|
||||||
|
repetido em toda linha de todo o documento — confirmado com o usuário
|
||||||
|
que essa coluna **não** é usada; a coparticipação devida por
|
||||||
|
beneficiário é a soma do "Vl Total" de cada linha de serviço daquele
|
||||||
|
beneficiário (mesma soma que já gera "Total da Família" no próprio
|
||||||
|
relatório, só por pessoa em vez de por família).
|
||||||
|
|
||||||
|
6. O nome/código do beneficiário e a coluna "Grau Dep." (TITULAR,
|
||||||
|
CONJUGE, FILHO(A), ...) só aparecem na primeira linha de cada bloco de
|
||||||
|
atendimento ("Cartao") daquela pessoa — as linhas de serviço seguintes,
|
||||||
|
até o próximo bloco ou "Total da Familia:", pertencem à mesma pessoa
|
||||||
|
(parsing com estado, mesmo padrão já usado em `ItamedSaude`).
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
|
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
|
||||||
from portal_api.planos_saude.operadoras.base import OperadoraParser
|
from portal_api.planos_saude.operadoras.base import OperadoraParser
|
||||||
|
|
||||||
|
_MARCADOR_MENSALIDADE = "BENEFICIARIOS COM FATURAMENTO NO MES"
|
||||||
|
_MARCADORES_COPARTICIPACAO = ("SERVIÇOS PRESTADOS", "SERVICOS PRESTADOS", "ANALITICO")
|
||||||
|
|
||||||
|
_GRAUS_DEPENDENCIA = ("TITULAR", "CONJUGE", "CÔNJUGE", r"FILHO\(A\)", r"PAI/M[ÃA]E", "AGREGADO")
|
||||||
|
|
||||||
|
_LINHA_MENSALIDADE_RE = re.compile(
|
||||||
|
r"^\s*(?P<familia>\d{5,8})\s+(?P<tipo>[TD])\s+(?P<benef>\d+)\s+"
|
||||||
|
r"(?P<nome>\D+?)\s+(?:Sem|\d+)\s+\d{2}/\d{2}/\d{2,4}\s+[\d.,]+\s+\d+\s+"
|
||||||
|
r"(?P<valor_inscricao>[\d.,]+)\s+(?P<valor_mensalidade>[\d.,]+)\s+\S+\s+\S+"
|
||||||
|
)
|
||||||
|
|
||||||
|
_PESSOA_COPARTICIPACAO_RE = re.compile(
|
||||||
|
r"^\s*(?P<benef>\d+\.\d+)(?P<nome>.+?)"
|
||||||
|
r"(?P<grau>" + "|".join(_GRAUS_DEPENDENCIA) + r")"
|
||||||
|
r"\s+(?P<cpf>\d{3}\.\d{3}\.\d{3}-\d{2})"
|
||||||
|
# Confirmado contra o arquivo real: `benef`+`nome`+`grau` vêm colados,
|
||||||
|
# SEM espaço nenhum entre eles (ex.: "0975.0167003824292ANDREIA
|
||||||
|
# STORMTITULAR") — só depois do grau é que vem um espaço, antes do CPF.
|
||||||
|
# O nome sai truncado por largura de coluna (13 caracteres) — por isso
|
||||||
|
# o casamento deste relatório usa CPF, não nome (ver
|
||||||
|
# `chave_casamento_para_tipo`).
|
||||||
|
)
|
||||||
|
_ITEM_COPARTICIPACAO_RE = re.compile(r"\b(?:EXA|CON|HOS|CLI|ODO|MED)\b")
|
||||||
|
# Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento do valor de um item HOS
|
||||||
|
# em Medicamento/Material/Soma de Outras Taxas, cuja soma já está no valor
|
||||||
|
# do item principal — confirmado contra o arquivo real batendo com "Total
|
||||||
|
# da Familia") também contêm um desses códigos como palavra isolada (ex.:
|
||||||
|
# "Pct:MED"), o que faria `_ITEM_COPARTICIPACAO_RE` casar por engano e somar
|
||||||
|
# esse valor de novo, duplicando-o — por isso são explicitamente ignoradas.
|
||||||
|
_PCT_RE = re.compile(r"^\s*Pct:")
|
||||||
|
# Diferente do resto do pipeline (BR: vírgula decimal, ponto de milhar), os
|
||||||
|
# dois relatórios em PDF da Unimed usam formato americano — ponto decimal,
|
||||||
|
# vírgula de milhar (ex.: "Total por Contratante: 0.00 6,061.74", no
|
||||||
|
# relatório de mensalidade) — daí este regex e `_valor_pdf_para_float`
|
||||||
|
# separados de `_VALOR_MONETARIO_RE`/`_valor_para_float` (BR, usados só
|
||||||
|
# pelo CSV).
|
||||||
|
_VALOR_MONETARIO_RE = re.compile(r"(\d{1,3}(?:,\d{3})*\.\d{2})")
|
||||||
|
_FAMILIA_RE = re.compile(r"^\s*Familia:\s*(?P<familia>\d+)")
|
||||||
|
_FIM_FAMILIA_RE = re.compile(r"^\s*Total (?:da Familia|da Sequencia|do Titulo)")
|
||||||
|
|
||||||
|
|
||||||
def _normaliza_tipo_despesa(tipo_bruto: str) -> str:
|
def _normaliza_tipo_despesa(tipo_bruto: str) -> str:
|
||||||
"""'Mensalidade' -> 'mensalidade' 'Serviços ' -> 'coparticipacao' outro -> ''"""
|
"""'Mensalidade' -> 'mensalidade' 'Serviços ' -> 'coparticipacao' outro -> ''"""
|
||||||
@ -53,14 +160,43 @@ def _normaliza_tipo_despesa(tipo_bruto: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _valor_para_float(texto: str) -> float:
|
def _valor_para_float(texto: str) -> float:
|
||||||
"""'257,09' -> 257.09 '1.234,56' -> 1234.56"""
|
"""'257,09' -> 257.09 '1.234,56' -> 1234.56 (formato BR, usado só pelo CSV)"""
|
||||||
texto = texto.strip().replace(".", "").replace(",", ".")
|
texto = texto.strip().replace(".", "").replace(",", ".")
|
||||||
return float(texto) if texto else 0.0
|
return float(texto) if texto else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _valor_pdf_para_float(texto: str) -> float:
|
||||||
|
"""'257.09' -> 257.09 '1,234.56' -> 1234.56 (formato americano dos
|
||||||
|
relatórios em PDF da Unimed — ver nota em _VALOR_MONETARIO_RE acima)."""
|
||||||
|
texto = texto.strip().replace(",", "")
|
||||||
|
return float(texto) if texto else 0.0
|
||||||
|
|
||||||
|
|
||||||
class UnimedSaude(OperadoraParser):
|
class UnimedSaude(OperadoraParser):
|
||||||
nome_operadora = "UNIMED"
|
nome_operadora = "UNIMED"
|
||||||
chave_casamento = "nome" # sem CPF no arquivo da operadora
|
chave_casamento = "nome" # sem CPF confiável no CSV nem no PDF de mensalidade
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Rastreia, por tipo_lancamento, se o(s) arquivo(s) já processados
|
||||||
|
# nesta importação vieram do PDF de coparticipação — usado por
|
||||||
|
# `chave_casamento_para_tipo()` abaixo. Uma mesma instância é
|
||||||
|
# reaproveitada por `pipeline.processa_importacao` pra todos os
|
||||||
|
# arquivos de uma importação (ver `extrai()` chamado uma vez por
|
||||||
|
# arquivo), então essa marcação persiste até o momento em que o
|
||||||
|
# pipeline lê `chave_casamento_para_tipo()`, depois de todo `extrai()`
|
||||||
|
# já ter rodado.
|
||||||
|
self._veio_de_pdf_coparticipacao = False
|
||||||
|
|
||||||
|
def chave_casamento_para_tipo(self, tipo_lancamento: str) -> str:
|
||||||
|
"""O CSV e o PDF de mensalidade nunca trazem CPF — só o nome serve.
|
||||||
|
Já o PDF de coparticipação analítico traz CPF completo e confiável,
|
||||||
|
e é indispensável usá-lo ali: o nome do beneficiário nesse relatório
|
||||||
|
vem truncado por largura de coluna (ex.: "ANDREIA STORMOSKI LARA"
|
||||||
|
sai como "ANDREIA STORM"), o que faria o casamento por nome falhar
|
||||||
|
sistematicamente para qualquer nome mais longo que a coluna."""
|
||||||
|
if tipo_lancamento == "coparticipacao" and self._veio_de_pdf_coparticipacao:
|
||||||
|
return "cpf"
|
||||||
|
return self.chave_casamento
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Extração
|
# Extração
|
||||||
@ -68,30 +204,131 @@ class UnimedSaude(OperadoraParser):
|
|||||||
|
|
||||||
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
|
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
|
||||||
if caminho_arquivo.lower().endswith(".pdf"):
|
if caminho_arquivo.lower().endswith(".pdf"):
|
||||||
linhas_dict = self._extrai_pdf(caminho_arquivo)
|
lancamentos, auditoria = self._extrai_pdf(caminho_arquivo)
|
||||||
else:
|
else:
|
||||||
linhas_dict = self._extrai_csv(caminho_arquivo)
|
linhas_dict = self._extrai_csv(caminho_arquivo)
|
||||||
|
lancamentos, auditoria = self._parseia_lancamentos(linhas_dict)
|
||||||
lancamentos, auditoria_extracao = self._parseia_lancamentos(linhas_dict)
|
|
||||||
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
|
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
|
||||||
return individuos, auditoria_extracao
|
return individuos, auditoria
|
||||||
|
|
||||||
def _extrai_csv(self, caminho_csv: str) -> List[Dict[str, str]]:
|
def _extrai_csv(self, caminho_csv: str) -> List[Dict[str, str]]:
|
||||||
# O export da Unimed vem em Latin-1 (ISO-8859-1), não UTF-8.
|
# O export da Unimed vem em Latin-1 (ISO-8859-1), não UTF-8.
|
||||||
with open(caminho_csv, encoding="latin-1", newline="") as f:
|
with open(caminho_csv, encoding="latin-1", newline="") as f:
|
||||||
return list(csv.DictReader(f, delimiter=";"))
|
return list(csv.DictReader(f, delimiter=";"))
|
||||||
|
|
||||||
def _extrai_pdf(self, caminho_pdf: str) -> List[Dict[str, str]]:
|
def _extrai_pdf(self, caminho_pdf: str) -> Tuple[List[Lancamento], List[ItemAuditoria]]:
|
||||||
raise NotImplementedError(
|
with pdfplumber.open(caminho_pdf) as pdf:
|
||||||
"Extração de PDF da Unimed ainda não implementada: preciso de um "
|
primeira_pagina = (pdf.pages[0].extract_text() or "").upper()
|
||||||
"PDF real da Unimed (com o layout de colunas de verdade) para "
|
if _MARCADOR_MENSALIDADE in primeira_pagina:
|
||||||
"validar o parsing, como foi feito com a Amil. Por ora, use o "
|
return self._extrai_pdf_mensalidade(pdf), []
|
||||||
"export em CSV com as mesmas colunas (Id. Titular, Nome "
|
if any(marcador in primeira_pagina for marcador in _MARCADORES_COPARTICIPACAO):
|
||||||
"Beneficiário, Tipo Benef., Valor, Tipo, ...)."
|
self._veio_de_pdf_coparticipacao = True
|
||||||
|
return self._extrai_pdf_coparticipacao(pdf), []
|
||||||
|
raise ValueError(
|
||||||
|
"Layout de PDF da Unimed não reconhecido — não é nem o relatório de "
|
||||||
|
"mensalidade ('BENEFICIARIOS COM FATURAMENTO NO MES') nem o analítico "
|
||||||
|
"de coparticipação ('... SERVIÇOS PRESTADOS ... ANALITICO')."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _linhas_pdf(self, pdf: "pdfplumber.PDF") -> List[str]:
|
||||||
|
linhas: List[str] = []
|
||||||
|
for page in pdf.pages:
|
||||||
|
texto = page.extract_text(layout=True, x_density=6, y_density=10) or ""
|
||||||
|
linhas.extend(texto.splitlines())
|
||||||
|
return linhas
|
||||||
|
|
||||||
|
def _extrai_pdf_mensalidade(self, pdf: "pdfplumber.PDF") -> List[Lancamento]:
|
||||||
|
# `numero_titular` de um dependente precisa ser o próprio
|
||||||
|
# `numero_beneficiario` do titular (não o código da família) — é
|
||||||
|
# essa a chave que `nomes_titular_por_numero`/`_casa_por_nome`
|
||||||
|
# (pipeline.py/matcher.py) usam pra resolver quem é titular de quem.
|
||||||
|
# Mapeia código de família -> numero_beneficiario do titular visto
|
||||||
|
# mais recentemente naquela família (as linhas de uma família são
|
||||||
|
# sempre contíguas no relatório: titular primeiro, dependentes
|
||||||
|
# depois).
|
||||||
|
lancamentos: List[Lancamento] = []
|
||||||
|
titular_por_familia: Dict[str, str] = {}
|
||||||
|
for linha in self._linhas_pdf(pdf):
|
||||||
|
m = _LINHA_MENSALIDADE_RE.match(linha)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
tipo = m.group("tipo")
|
||||||
|
familia = m.group("familia")
|
||||||
|
benef = m.group("benef")
|
||||||
|
if tipo == "T":
|
||||||
|
titular_por_familia[familia] = benef
|
||||||
|
lancamentos.append(Lancamento(
|
||||||
|
numero_beneficiario=benef,
|
||||||
|
nome=m.group("nome").strip(),
|
||||||
|
cpf="",
|
||||||
|
tipo=tipo,
|
||||||
|
rubrica="Mensalidade",
|
||||||
|
valor=_valor_pdf_para_float(m.group("valor_mensalidade")),
|
||||||
|
tipo_lancamento="mensalidade",
|
||||||
|
numero_titular=None if tipo == "T" else titular_por_familia.get(familia),
|
||||||
|
))
|
||||||
|
return lancamentos
|
||||||
|
|
||||||
|
def _extrai_pdf_coparticipacao(self, pdf: "pdfplumber.PDF") -> List[Lancamento]:
|
||||||
|
# Mesmo cuidado do `_extrai_pdf_mensalidade`: `numero_titular` de um
|
||||||
|
# dependente precisa ser o `numero_beneficiario` do titular, não o
|
||||||
|
# código da família.
|
||||||
|
lancamentos: List[Lancamento] = []
|
||||||
|
titular_benef_familia: Optional[str] = None
|
||||||
|
pessoa_atual: Optional[dict] = None
|
||||||
|
|
||||||
|
for linha in self._linhas_pdf(pdf):
|
||||||
|
m_familia = _FAMILIA_RE.match(linha)
|
||||||
|
if m_familia:
|
||||||
|
titular_benef_familia = None
|
||||||
|
pessoa_atual = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _FIM_FAMILIA_RE.match(linha):
|
||||||
|
pessoa_atual = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _PCT_RE.match(linha):
|
||||||
|
continue
|
||||||
|
|
||||||
|
m_pessoa = _PESSOA_COPARTICIPACAO_RE.match(linha)
|
||||||
|
if m_pessoa:
|
||||||
|
tipo = "T" if m_pessoa.group("grau").upper() == "TITULAR" else "D"
|
||||||
|
benef = m_pessoa.group("benef")
|
||||||
|
if tipo == "T":
|
||||||
|
titular_benef_familia = benef
|
||||||
|
pessoa_atual = {
|
||||||
|
"numero_beneficiario": benef,
|
||||||
|
"nome": m_pessoa.group("nome").strip(),
|
||||||
|
"cpf": m_pessoa.group("cpf"),
|
||||||
|
"tipo": tipo,
|
||||||
|
"numero_titular": None if tipo == "T" else titular_benef_familia,
|
||||||
|
}
|
||||||
|
# a própria linha da pessoa já traz o primeiro item de
|
||||||
|
# serviço — não usa "continue", cai no bloco de item abaixo.
|
||||||
|
|
||||||
|
if pessoa_atual is None or not _ITEM_COPARTICIPACAO_RE.search(linha):
|
||||||
|
continue
|
||||||
|
|
||||||
|
valores = _VALOR_MONETARIO_RE.findall(linha)
|
||||||
|
if not valores:
|
||||||
|
continue
|
||||||
|
valor_total = _valor_pdf_para_float(valores[-1]) # "Vl Total" é a última coluna monetária da linha
|
||||||
|
|
||||||
|
lancamentos.append(Lancamento(
|
||||||
|
numero_beneficiario=pessoa_atual["numero_beneficiario"],
|
||||||
|
nome=pessoa_atual["nome"],
|
||||||
|
cpf=pessoa_atual["cpf"],
|
||||||
|
tipo=pessoa_atual["tipo"],
|
||||||
|
rubrica="Serviço",
|
||||||
|
valor=valor_total,
|
||||||
|
tipo_lancamento="coparticipacao",
|
||||||
|
numero_titular=pessoa_atual["numero_titular"],
|
||||||
|
))
|
||||||
|
return lancamentos
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Normalização
|
# Normalização (CSV)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _parseia_lancamentos(
|
def _parseia_lancamentos(
|
||||||
@ -143,6 +380,10 @@ class UnimedSaude(OperadoraParser):
|
|||||||
))
|
))
|
||||||
return lancamentos, auditoria
|
return lancamentos, auditoria
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Agregação (comum aos dois formatos)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
|
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
|
||||||
"""
|
"""
|
||||||
Agrupa por (indivíduo, tipo_lancamento): uma pessoa pode aparecer
|
Agrupa por (indivíduo, tipo_lancamento): uma pessoa pode aparecer
|
||||||
|
|||||||
@ -9,10 +9,10 @@ implementando OperadoraParser (ver operadoras/base.py) e registre-a em
|
|||||||
OPERADORAS abaixo. Nada mais precisa mudar.
|
OPERADORAS abaixo. Nada mais precisa mudar.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional
|
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 ItemAuditoria, LinhaSistema
|
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, LinhaSistema
|
||||||
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
|
||||||
@ -87,18 +87,51 @@ class ResultadoProcessamento:
|
|||||||
auditoria: List[ItemAuditoria] = field(default_factory=list)
|
auditoria: List[ItemAuditoria] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _agrega_individuos_entre_arquivos(individuos: List[Individuo]) -> List[Individuo]:
|
||||||
|
"""Some os `Individuo` da MESMA pessoa e do MESMO `tipo_lancamento` que
|
||||||
|
vieram de arquivos diferentes (ex.: duas coparticipações separadas por
|
||||||
|
período) — sem isso, `casa_individuos_com_planilha`/`_aplica_regra_
|
||||||
|
custeio` (matcher.py) *grava* o valor por linha em vez de acumular, então
|
||||||
|
o segundo arquivo simplesmente sobrescreveria o valor do primeiro. Cada
|
||||||
|
`OperadoraParser.extrai()` já faz esse mesmo tipo de agregação dentro de
|
||||||
|
UM arquivo (ver `_agrega_por_individuo_e_tipo` nos parsers) — esta função
|
||||||
|
é o equivalente entre arquivos, chamada uma vez por importação depois de
|
||||||
|
somar os indivíduos de todos eles."""
|
||||||
|
agregados: Dict[Tuple[str, str], Individuo] = {}
|
||||||
|
ordem = []
|
||||||
|
for ind in individuos:
|
||||||
|
chave = (ind.numero_beneficiario, ind.tipo_lancamento)
|
||||||
|
if chave not in agregados:
|
||||||
|
agregados[chave] = Individuo(
|
||||||
|
numero_beneficiario=ind.numero_beneficiario,
|
||||||
|
nome=ind.nome,
|
||||||
|
cpf=ind.cpf,
|
||||||
|
tipo=ind.tipo,
|
||||||
|
tipo_lancamento=ind.tipo_lancamento,
|
||||||
|
numero_titular=ind.numero_titular,
|
||||||
|
)
|
||||||
|
ordem.append(chave)
|
||||||
|
agregados[chave].valor_total += ind.valor_total
|
||||||
|
agregados[chave].rubricas.extend(ind.rubricas)
|
||||||
|
return [agregados[chave] for chave in ordem]
|
||||||
|
|
||||||
|
|
||||||
def processa_importacao(
|
def processa_importacao(
|
||||||
operadora_key: str,
|
operadora_key: str,
|
||||||
caminho_arquivo_operadora: str,
|
caminhos_arquivo_operadora: List[str],
|
||||||
linhas_sistema_template: List[LinhaSistema],
|
linhas_sistema_template: List[LinhaSistema],
|
||||||
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,
|
||||||
) -> ResultadoProcessamento:
|
) -> ResultadoProcessamento:
|
||||||
"""
|
"""
|
||||||
Extrai o arquivo da operadora, filtra pelos tipos de lançamento que o
|
Extrai um ou mais arquivos da operadora (a maioria manda só um, mas
|
||||||
usuário selecionou na tela (ignora qualquer outro tipo presente no
|
algumas — ex.: Unimed Saúde em PDF — mandam mensalidade e coparticipação
|
||||||
arquivo) e casa cada tipo com uma cópia própria da planilha padrão —
|
em arquivos separados; `OperadoraParser.extrai()` é chamado uma vez por
|
||||||
|
arquivo e os indivíduos/itens de auditoria resultantes são somados antes
|
||||||
|
de seguir), filtra pelos tipos de lançamento que o usuário selecionou na
|
||||||
|
tela (ignora qualquer outro tipo presente no arquivo) e casa cada tipo
|
||||||
|
com uma cópia própria da planilha padrão —
|
||||||
cada tipo divide o valor do mês entre `valor_empresa`/`valor` conforme a
|
cada tipo divide o valor do mês entre `valor_empresa`/`valor` conforme a
|
||||||
regra de custeio escolhida para ele, uma para titular e outra para
|
regra de custeio escolhida para ele, uma para titular e outra para
|
||||||
dependente (ver `matcher._regra_para_pessoa`/`_calcula_valores` pro
|
dependente (ver `matcher._regra_para_pessoa`/`_calcula_valores` pro
|
||||||
@ -123,12 +156,20 @@ def processa_importacao(
|
|||||||
operadora_info = OPERADORAS[operadora_key]
|
operadora_info = OPERADORAS[operadora_key]
|
||||||
parser_operadora = operadora_info["parser"]()
|
parser_operadora = operadora_info["parser"]()
|
||||||
|
|
||||||
individuos, auditoria_extracao = parser_operadora.extrai(caminho_arquivo_operadora)
|
individuos: List[Individuo] = []
|
||||||
|
auditoria_extracao: List[ItemAuditoria] = []
|
||||||
|
for caminho in caminhos_arquivo_operadora:
|
||||||
|
individuos_arquivo, auditoria_arquivo = parser_operadora.extrai(caminho)
|
||||||
|
individuos.extend(individuos_arquivo)
|
||||||
|
auditoria_extracao.extend(auditoria_arquivo)
|
||||||
|
individuos = _agrega_individuos_entre_arquivos(individuos)
|
||||||
|
|
||||||
regra_empresa_fn = None
|
regra_empresa_fn = None
|
||||||
if regra_empresa_key:
|
if regra_empresa_key:
|
||||||
|
# regra_empresa só se aplica a "mensalidade" (ver mais abaixo e em
|
||||||
|
# ImportacaoPlanoSaudeCreateSerializer.validate()).
|
||||||
regra_empresa_fn = valida_regra_empresa(
|
regra_empresa_fn = valida_regra_empresa(
|
||||||
regra_empresa_key, parser_operadora.chave_casamento, linhas_sistema_template
|
regra_empresa_key, parser_operadora.chave_casamento_para_tipo("mensalidade"), linhas_sistema_template
|
||||||
)
|
)
|
||||||
|
|
||||||
individuos_por_tipo: Dict[str, list] = {}
|
individuos_por_tipo: Dict[str, list] = {}
|
||||||
@ -155,7 +196,7 @@ def processa_importacao(
|
|||||||
linhas_atualizadas, itens_auditoria = casa_individuos_com_planilha(
|
linhas_atualizadas, itens_auditoria = casa_individuos_com_planilha(
|
||||||
individuos_do_tipo,
|
individuos_do_tipo,
|
||||||
linhas_copia,
|
linhas_copia,
|
||||||
parser_operadora.chave_casamento,
|
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,
|
||||||
|
|||||||
@ -539,7 +539,13 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
|
|||||||
# frontend converte a máscara "MM/AAAA" pro ISO "AAAA-MM-01" antes de
|
# frontend converte a máscara "MM/AAAA" pro ISO "AAAA-MM-01" antes de
|
||||||
# enviar (ver pidIpsCompetenciaParaIso em importacao-plano-saude.js).
|
# enviar (ver pidIpsCompetenciaParaIso em importacao-plano-saude.js).
|
||||||
competencia = serializers.DateField(required=False, allow_null=True)
|
competencia = serializers.DateField(required=False, allow_null=True)
|
||||||
arquivo_operadora = serializers.FileField()
|
# Lista porque algumas operadoras mandam mensalidade e coparticipação em
|
||||||
|
# arquivos separados (ex.: Unimed Saúde em PDF) — a maioria manda só um,
|
||||||
|
# mas o campo sempre aceita 1 ou mais. `ListField` já lê múltiplos
|
||||||
|
# arquivos do mesmo nome de campo em multipart/form-data (via
|
||||||
|
# `request.data.getlist(...)`, mesma semântica do QueryDict do Django),
|
||||||
|
# sem precisar de nenhum tratamento manual na view.
|
||||||
|
arquivo_operadora = serializers.ListField(child=serializers.FileField(), allow_empty=False)
|
||||||
tipos_lancamento = serializers.CharField()
|
tipos_lancamento = serializers.CharField()
|
||||||
# Chave de REGRAS_EMPRESA (portal_api.planos_saude.regras_empresa) — ver
|
# Chave de REGRAS_EMPRESA (portal_api.planos_saude.regras_empresa) — ver
|
||||||
# validate(): quando preenchida, substitui o custeio manual de
|
# validate(): quando preenchida, substitui o custeio manual de
|
||||||
|
|||||||
@ -39,6 +39,7 @@ from .models import (
|
|||||||
FuncaoTelefonia,
|
FuncaoTelefonia,
|
||||||
ImportacaoPlanoSaude,
|
ImportacaoPlanoSaude,
|
||||||
ImportacaoPlanoSaudeAlteracao,
|
ImportacaoPlanoSaudeAlteracao,
|
||||||
|
ImportacaoPlanoSaudeArquivoOperadora,
|
||||||
ImportacaoPlanoSaudeAuditoria,
|
ImportacaoPlanoSaudeAuditoria,
|
||||||
ImportacaoPlanoSaudeLinha,
|
ImportacaoPlanoSaudeLinha,
|
||||||
IndicadorApuracao,
|
IndicadorApuracao,
|
||||||
@ -795,7 +796,8 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
|
|||||||
linhas/auditoria persistidas; `gerar()` só formata o que já está salvo (isto é,
|
linhas/auditoria persistidas; `gerar()` só formata o que já está salvo (isto é,
|
||||||
já reflete qualquer edição feita na revisão), não reprocessa os arquivos
|
já reflete qualquer edição feita na revisão), não reprocessa os arquivos
|
||||||
originais. `destroy()` remove a importação do histórico (botão de excluir na
|
originais. `destroy()` remove a importação do histórico (botão de excluir na
|
||||||
listagem) — apaga os dois arquivos anexados de MEDIA_ROOT antes de excluir o
|
listagem) — apaga a planilha padrão e todos os arquivos da operadora
|
||||||
|
(`arquivos_operadora`, um ou mais) de MEDIA_ROOT antes de excluir o
|
||||||
registro (as linhas/itens de auditoria somem sozinhos via CASCADE)."""
|
registro (as linhas/itens de auditoria somem sozinhos via CASCADE)."""
|
||||||
|
|
||||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
@ -805,7 +807,8 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
|
|||||||
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
|
return [PermissaoApp("utilitarios", "importacao-plano-saude")]
|
||||||
|
|
||||||
def perform_destroy(self, instance: ImportacaoPlanoSaude) -> None:
|
def perform_destroy(self, instance: ImportacaoPlanoSaude) -> None:
|
||||||
instance.arquivo_operadora.delete(save=False)
|
for arquivo in instance.arquivos_operadora.all():
|
||||||
|
arquivo.arquivo.delete(save=False)
|
||||||
instance.planilha_padrao.delete(save=False)
|
instance.planilha_padrao.delete(save=False)
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
@ -913,15 +916,24 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
|
|||||||
criado_por=request.user,
|
criado_por=request.user,
|
||||||
competencia=competencia,
|
competencia=competencia,
|
||||||
planilha_padrao=planilha_padrao_arquivo or planilha_padrao_gerada,
|
planilha_padrao=planilha_padrao_arquivo or planilha_padrao_gerada,
|
||||||
arquivo_operadora=dados["arquivo_operadora"],
|
|
||||||
)
|
)
|
||||||
|
arquivos_operadora = [
|
||||||
|
ImportacaoPlanoSaudeArquivoOperadora.objects.create(importacao=importacao, arquivo=arquivo, ordem=ordem)
|
||||||
|
for ordem, arquivo in enumerate(dados["arquivo_operadora"])
|
||||||
|
]
|
||||||
|
|
||||||
|
def _limpa_arquivos_orfaos() -> None:
|
||||||
|
for arquivo in arquivos_operadora:
|
||||||
|
arquivo.arquivo.delete(save=False)
|
||||||
|
importacao.planilha_padrao.delete(save=False)
|
||||||
|
importacao.delete()
|
||||||
|
|
||||||
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)
|
||||||
resultado = planos_saude_pipeline.processa_importacao(
|
resultado = planos_saude_pipeline.processa_importacao(
|
||||||
operadora_key=operadora_key,
|
operadora_key=operadora_key,
|
||||||
caminho_arquivo_operadora=importacao.arquivo_operadora.path,
|
caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
|
||||||
linhas_sistema_template=linhas_sistema_template,
|
linhas_sistema_template=linhas_sistema_template,
|
||||||
tipos_selecionados=tipos,
|
tipos_selecionados=tipos,
|
||||||
custeio_por_tipo=custeio_por_tipo,
|
custeio_por_tipo=custeio_por_tipo,
|
||||||
@ -931,22 +943,18 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
|
|||||||
# 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
|
||||||
# si, é a combinação operadora/planilha × regra empresa escolhida
|
# si, é a combinação operadora/planilha × regra empresa escolhida
|
||||||
# — vale a pena mostrar a mensagem específica pro usuário.
|
# — vale a pena mostrar a mensagem específica pro usuário.
|
||||||
importacao.arquivo_operadora.delete(save=False)
|
_limpa_arquivos_orfaos()
|
||||||
importacao.planilha_padrao.delete(save=False)
|
|
||||||
importacao.delete()
|
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Arquivo ilegível (PDF num layout desconhecido, planilha padrão fora do
|
# Arquivo ilegível (PDF num layout desconhecido, planilha padrão fora do
|
||||||
# leiaute esperado etc.) — não deixa órfão nem a importação nem os
|
# leiaute esperado etc.) — não deixa órfão nem a importação nem os
|
||||||
# arquivos já salvos em MEDIA_ROOT.
|
# arquivos já salvos em MEDIA_ROOT.
|
||||||
importacao.arquivo_operadora.delete(save=False)
|
_limpa_arquivos_orfaos()
|
||||||
importacao.planilha_padrao.delete(save=False)
|
|
||||||
importacao.delete()
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": (
|
"detail": (
|
||||||
"O formato de um dos arquivos não está conforme o esperado. "
|
"O formato de um dos arquivos não está conforme o esperado. "
|
||||||
"Contate a Integração e Inovação."
|
"Contate a Inovação."
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -972,9 +980,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
|
|||||||
# correta só por essa diferença de formatação.
|
# correta só por essa diferença de formatação.
|
||||||
codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
|
codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
|
||||||
if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
|
if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
|
||||||
importacao.arquivo_operadora.delete(save=False)
|
_limpa_arquivos_orfaos()
|
||||||
importacao.planilha_padrao.delete(save=False)
|
|
||||||
importacao.delete()
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": (
|
"detail": (
|
||||||
|
|||||||
@ -307,6 +307,64 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lista de arquivos anexados em "2. Arquivo da operadora" — várias
|
||||||
|
operadoras (ex.: Unimed em PDF) mandam mensalidade e coparticipação em
|
||||||
|
arquivos separados, então o campo aceita 1+ arquivos. Cada item tem seu
|
||||||
|
próprio status de validação (mesmas cores de .ips-file-field__status
|
||||||
|
acima) e um botão de remover individual. */
|
||||||
|
.ips-arquivo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-canvas);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__status {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__status--pendente {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__status--ok {
|
||||||
|
color: #2f9e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__status--erro {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ips-arquivo-list__remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* "Tipo de importação" fica visualmente bem separado do quadro "2. Arquivo
|
/* "Tipo de importação" fica visualmente bem separado do quadro "2. Arquivo
|
||||||
da operadora" logo acima — sem esse respiro extra, os dois blocos ficavam
|
da operadora" logo acima — sem esse respiro extra, os dois blocos ficavam
|
||||||
colados o bastante pra confundir onde termina um e começa o outro. */
|
colados o bastante pra confundir onde termina um e começa o outro. */
|
||||||
|
|||||||
@ -229,9 +229,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
const planilhaUploadField = document.getElementById("ips-planilha-upload-field");
|
const planilhaUploadField = document.getElementById("ips-planilha-upload-field");
|
||||||
const formCompetencia = document.getElementById("ips-form-competencia");
|
const formCompetencia = document.getElementById("ips-form-competencia");
|
||||||
const formArquivo = document.getElementById("ips-form-arquivo");
|
const formArquivo = document.getElementById("ips-form-arquivo");
|
||||||
const formArquivoName = document.getElementById("ips-form-arquivo-name");
|
const formArquivoList = document.getElementById("ips-form-arquivo-list");
|
||||||
const formArquivoRemoveBtn = document.getElementById("ips-form-arquivo-remove");
|
|
||||||
const formArquivoStatus = document.getElementById("ips-form-arquivo-status");
|
|
||||||
const formTipoMensalidade = document.getElementById("ips-form-tipo-mensalidade");
|
const formTipoMensalidade = document.getElementById("ips-form-tipo-mensalidade");
|
||||||
const formTipoCoparticipacao = document.getElementById("ips-form-tipo-coparticipacao");
|
const formTipoCoparticipacao = document.getElementById("ips-form-tipo-coparticipacao");
|
||||||
const formTipoRegraEmpresa = document.getElementById("ips-form-tipo-regra-empresa");
|
const formTipoRegraEmpresa = document.getElementById("ips-form-tipo-regra-empresa");
|
||||||
@ -460,11 +458,97 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
tipo: "planilha",
|
tipo: "planilha",
|
||||||
});
|
});
|
||||||
|
|
||||||
const validadorArquivo = criarValidadorArquivo({
|
/*
|
||||||
|
* "2. Arquivo da operadora" aceita 1+ arquivos (algumas operadoras mandam
|
||||||
|
* mensalidade e coparticipação em arquivos separados) — cada arquivo
|
||||||
|
* anexado é validado individualmente (mesmo endpoint de sempre,
|
||||||
|
* pidValidarArquivoPlanoSaude, chamado uma vez por arquivo) e listado com
|
||||||
|
* seu próprio status + botão de remover. Diferente de
|
||||||
|
* criarValidadorArquivo() acima (um único arquivo, pensado pra planilha
|
||||||
|
* padrão), este mantém um array de {file, resultado} e re-renderiza a
|
||||||
|
* lista a cada mudança.
|
||||||
|
*/
|
||||||
|
function criarValidadorArquivoMultiplo({ input, listEl, tipo, getOperadoraKey }) {
|
||||||
|
let arquivos = [];
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
arquivos.forEach((item, index) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "ips-arquivo-list__item";
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "ips-arquivo-list__info";
|
||||||
|
const nameEl = document.createElement("span");
|
||||||
|
nameEl.className = "ips-arquivo-list__name";
|
||||||
|
nameEl.textContent = item.file.name;
|
||||||
|
const statusEl = document.createElement("span");
|
||||||
|
if (item.resultado === null) {
|
||||||
|
statusEl.className = "ips-arquivo-list__status ips-arquivo-list__status--pendente";
|
||||||
|
statusEl.textContent = "Validando arquivo...";
|
||||||
|
} else {
|
||||||
|
statusEl.className = `ips-arquivo-list__status ${item.resultado.valido ? "ips-arquivo-list__status--ok" : "ips-arquivo-list__status--erro"}`;
|
||||||
|
statusEl.textContent = `${item.resultado.valido ? "✓" : "✗"} ${item.resultado.mensagem}`;
|
||||||
|
}
|
||||||
|
info.appendChild(nameEl);
|
||||||
|
info.appendChild(statusEl);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "icon-btn ips-arquivo-list__remove";
|
||||||
|
removeBtn.setAttribute("aria-label", "Remover arquivo");
|
||||||
|
removeBtn.title = "Remover arquivo";
|
||||||
|
removeBtn.innerHTML =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>';
|
||||||
|
removeBtn.addEventListener("click", () => {
|
||||||
|
arquivos.splice(index, 1);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(info);
|
||||||
|
li.appendChild(removeBtn);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validarItem(item) {
|
||||||
|
item.resultado = null;
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
const operadoraKey = getOperadoraKey ? getOperadoraKey() : null;
|
||||||
|
item.resultado = await pidValidarArquivoPlanoSaude(tipo, item.file, operadoraKey);
|
||||||
|
} catch (e) {
|
||||||
|
item.resultado = { valido: false, mensagem: e.message };
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function limpar() {
|
||||||
|
input.value = "";
|
||||||
|
arquivos = [];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const novosArquivos = Array.from(input.files || []).map((file) => ({ file, resultado: null }));
|
||||||
|
input.value = "";
|
||||||
|
arquivos = arquivos.concat(novosArquivos);
|
||||||
|
render();
|
||||||
|
novosArquivos.forEach(validarItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
limpar,
|
||||||
|
revalidarSeAnexado: () => arquivos.forEach(validarItem),
|
||||||
|
ehVazio: () => arquivos.length === 0,
|
||||||
|
ehInvalido: () => arquivos.some((item) => item.resultado !== null && item.resultado.valido === false),
|
||||||
|
arquivosSelecionados: () => arquivos.map((item) => item.file),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validadorArquivo = criarValidadorArquivoMultiplo({
|
||||||
input: formArquivo,
|
input: formArquivo,
|
||||||
nameEl: formArquivoName,
|
listEl: formArquivoList,
|
||||||
removeBtn: formArquivoRemoveBtn,
|
|
||||||
statusEl: formArquivoStatus,
|
|
||||||
tipo: "operadora",
|
tipo: "operadora",
|
||||||
getOperadoraKey: () => formOperadora.value,
|
getOperadoraKey: () => formOperadora.value,
|
||||||
});
|
});
|
||||||
@ -2502,12 +2586,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!formArquivo.files[0]) {
|
if (validadorArquivo.ehVazio()) {
|
||||||
formError.textContent = "Anexe o arquivo da operadora.";
|
formError.textContent = "Anexe ao menos um arquivo da operadora.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (validadorArquivo.ehInvalido()) {
|
if (validadorArquivo.ehInvalido()) {
|
||||||
formError.textContent = "Corrija o arquivo da operadora (veja a mensagem abaixo do campo) antes de processar.";
|
formError.textContent = "Corrija o(s) arquivo(s) da operadora (veja a mensagem abaixo de cada um) antes de processar.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
formError.textContent = "";
|
formError.textContent = "";
|
||||||
@ -2518,7 +2602,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
} else {
|
} else {
|
||||||
formData.append("planilha_padrao", formPlanilha.files[0]);
|
formData.append("planilha_padrao", formPlanilha.files[0]);
|
||||||
}
|
}
|
||||||
formData.append("arquivo_operadora", formArquivo.files[0]);
|
validadorArquivo.arquivosSelecionados().forEach((file) => formData.append("arquivo_operadora", file));
|
||||||
montarFormDataDeRegra(formData, regraResolvidaAtual);
|
montarFormDataDeRegra(formData, regraResolvidaAtual);
|
||||||
|
|
||||||
formSubmitBtn.disabled = true;
|
formSubmitBtn.disabled = true;
|
||||||
|
|||||||
@ -427,19 +427,15 @@
|
|||||||
</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. 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.</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>
|
||||||
<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>
|
||||||
Selecionar arquivo
|
Selecionar arquivo(s)
|
||||||
</label>
|
</label>
|
||||||
<input type="file" id="ips-form-arquivo" accept=".csv,.pdf" class="ips-file-field__input" />
|
<input type="file" id="ips-form-arquivo" accept=".csv,.pdf" class="ips-file-field__input" multiple />
|
||||||
<span class="ips-file-field__name" id="ips-form-arquivo-name">Nenhum arquivo selecionado</span>
|
|
||||||
<button type="button" class="icon-btn ips-file-field__remove" id="ips-form-arquivo-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="ips-file-field__status" id="ips-form-arquivo-status" hidden></p>
|
<ul class="ips-arquivo-list" id="ips-form-arquivo-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user