Inclusão dos parametros da operadora Unimed Foz.

This commit is contained in:
Gabriel 2026-08-24 14:29:44 -03:00
parent 8d2650812d
commit 4d75e8f8e2
19 changed files with 7636 additions and 85 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 1123 ANDREIA STORMOSKI LARA 031.535.759-24 5060 01/04/2026 GABRIELE LARAVILLELA 135.897.549-31 0 0
3 1123 ANDREIA STORMOSKI LARA 031.535.759-24 5060 01/04/2026 0 0
4 1123 ANDREIA STORMOSKI LARA 031.535.759-24 5060 01/04/2026 MANUELA LARA VILLELA 135.897.739-95 0 0
5 1123 CHRISTIAN GASTON RIZZI 703.895.279-04 5060 01/04/2026 0 0
6 1123 FALLKNER RIBEIRO BORGES 040.942.249-55 5060 01/04/2026 0 0
7 1123 FALLKNER RIBEIRO BORGES 040.942.249-55 5060 01/04/2026 CATHERINE NUNES BORGES 800.830.749-88 0 0
8 1123 FALLKNER RIBEIRO BORGES 040.942.249-55 5060 01/04/2026 AMANDA RODRIGUES BORGES 154.745.769-43 0 0
9 1123 FALLKNER RIBEIRO BORGES 040.942.249-55 5060 01/04/2026 ADRIANA RODRIGUES 047.011.989-60 0 0
10 1123 FRANCIELLI ZANDONA 070.345.739-05 5060 01/04/2026 0 0
11 1123 GABRIEL HENRIQUE ALCANTARA DE OLIVEIRA 081.249.969-70 5060 01/04/2026 0 0
12 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
13 1123 JEFFERSON DAMBORIANA HWANG 083.124.969-20 5060 01/04/2026 0 0
14 1123 JESSICA RIBEIRO DE SOUZA 109.445.609-89 5060 01/04/2026 0 0
15 1123 MARINA MASSAO PEREIRA 071.602.179-00 5060 01/04/2026 0 0
16 1123 PAULO HENRIQUE APARECIDO DA SILVA 096.531.429-41 5060 01/04/2026 0 0
17 1123 PAULO HENRIQUE APARECIDO DA SILVA 096.531.429-41 5060 01/04/2026 TALYTA CARNEIRO NERIS 033.441.441-54 0 0
18 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
19 1123 PAULO ROMEO DOMINGUES JUNIOR 045.568.259-38 5060 01/04/2026 0 0
20 1123 PAULO ROMEO DOMINGUES JUNIOR 045.568.259-38 5060 01/04/2026 LOHAINE AGUIAR DOMINGUES 129.814.229-60 0 0
21 1123 RODRIGO GOTTLIEB MONZON 027.514.939-04 5060 01/04/2026 0 0
22 1123 RODRIGO GOTTLIEB MONZON 027.514.939-04 5060 01/04/2026 SOL GRACIOLA MONZON 152.963.359-10 0 0
23 1123 THAIS BRUNO CUNHA DOS SANTOS 415.927.758-61 5060 01/04/2026 0 0
24 1123 WALLACE ZENARO 094.583.269-90 5060 01/04/2026 0 0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]]:
""" """

View File

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

View File

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

View File

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

View File

@ -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": (

View File

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

View File

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

View File

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