This commit is contained in:
Gabriel 2026-08-28 10:23:55 -03:00
commit 15d1bdc631
21 changed files with 1256 additions and 168 deletions

View File

@ -18,7 +18,8 @@
"Bash(\"./.venv/Scripts/python.exe\" manage.py migrate portal_api)", "Bash(\"./.venv/Scripts/python.exe\" manage.py migrate portal_api)",
"Bash(\"./.venv/Scripts/python.exe\" manage.py check)", "Bash(\"./.venv/Scripts/python.exe\" manage.py check)",
"Bash(PYTHONIOENCODING=utf-8 ./.venv/Scripts/python.exe -c ' *)", "Bash(PYTHONIOENCODING=utf-8 ./.venv/Scripts/python.exe -c ' *)",
"Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)" "Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)",
"Edit(/.claude/skills/importacao-plano-saude/**)"
] ]
} }
} }

View File

@ -0,0 +1,107 @@
---
name: importacao-plano-saude
description: Guia de manutenção/extensão da etapa de leitura e validação da ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/, tela importacao-plano-saude.html) — extração dos relatórios de operadora (11 parsers já implementados), casamento contra a planilha padrão e regras de negócio (regra de custeio, "Regra empresa", vínculos de nome), independente do sistema contábil de destino. Documenta quais operadoras já estão validadas com dado real, os gaps conhecidos por operadora, e como adicionar uma operadora nova. Direciona pra uma skill específica de sistema contábil (Questor hoje; Contabit no futuro) pra tudo que for leiaute/geração de arquivo final. Usar ao dar manutenção nos parsers, ao investigar uma divergência de valores, ou ao decidir se uma operadora nova pode ser cadastrada com segurança.
---
# Importação de Plano de Saúde: leitura, extração e regras de negócio
## 0. O que este documento é (e o que não é)
Este SKILL.md documenta a etapa **comum a qualquer sistema contábil de destino**: ler o relatório de faturamento de uma operadora de plano de saúde/odontológico, extrair beneficiários e valores, e aplicar as regras de negócio (custeio, "Regra empresa", nome divergente) que decidem quanto cada lançamento deve valer — tudo isso antes de qualquer formatação específica de sistema. É o complemento "por quê"/"cuidado com X" da documentação técnica, já exaustivamente descrita em `CLAUDE.md` (seção "Importação de Plano de Saúde (Utilitários)"). Antes de tocar em qualquer parser ou regra de custeio, ler os dois: `CLAUDE.md` pra arquitetura (models, endpoints, formato de `custeio_por_tipo`, "Regra empresa", "Vínculos de nome salvos"), este arquivo pra contexto de negócio e pra lista de gaps ainda não confirmados como equivalentes ao processo manual que a ferramenta substitui.
**Se a tarefa é sobre Cadastro de Regras, planilha padrão, leiaute ou geração do CSV/ZIP final: pare aqui.** Esta skill não cobre isso — é escopo da skill específica do sistema contábil de destino. A ferramenta hoje só gera saída pro **Questor**: se for esse o caso (ou se não tiver sido dito o contrário), carregar `importacao-questor-plano-saude` antes de prosseguir. Se o destino for o **Contabit** (formato alternativo de rubricas 338/200/201), avisar o usuário que essa etapa ainda não foi implementada, nem a skill correspondente existe ainda — não inventar um leiaute Contabit sem confirmação explícita (ver gap 3 na seção 3).
## 1. Operadoras já parametrizadas hoje (fotografia em 25/08/2026)
A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamento especial no código de extração — toda operadora passa pelo mesmo `OperadoraParser`/`pipeline.processa_importacao` genérico. **É uma fotografia, não um fato permanente**: cresce todo mês, reconsultar `ImportacaoPlanoSaude` (`python manage.py shell`) antes de confiar nela pra uma decisão importante.
### 1.1 Os 11 parsers de operadora existentes (12 cadastros — Amil Odonto tem dois): uso real até agora
| Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? |
|---|---|---|---|
| `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) |
| `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) |
| `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão". **Tem 2 layouts de relatório**: um com `[Nº Cartão]` entre colchetes e indentação distinguindo titular/dependente (validado empresa 1084), outro sem colchete (Nº Cartão solto) e mesma indentação para titular/dependente, distinguido pela presença de "Total Fam" (validado empresa 503, "TAROBA CONSTRUCOES LTDA", 27/08/2026) — ver `operadoras/dental_uni/odonto_mensalidade.py` |
| `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) |
| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) |
| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** |
| `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) |
| `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) |
| `amil_odonto_mensalidade` (3758) | CPF | 0 até 26/08/2026 | Nunca foi rodada dentro da ferramenta até então — ver nota abaixo, primeiro teste real achou e corrigiu um bug de parsing |
| `amil_odonto_mensalidade_898` (898) | CPF | 0 até 28/08/2026 | Nunca foi rodada — segundo cadastro da mesma operadora/mesmo parser no Questor (empresas diferentes usam um código ou outro), adicionado a pedido do usuário; regras e parâmetros de extração são idênticos aos de `amil_odonto_mensalidade` (3758) |
| `humana_saude` (5064) | nome | 0 até 27/08/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, construído via o checklist da seção 4.1 direto no primeiro teste com o arquivo-modelo (empresa 1972). **Coparticipação nunca casa automaticamente** (decisão do usuário): sem CPF nem matrícula confiável na tabela "DESPESAS COBRADAS" (nome truncado por largura de coluna), cada evento vira direto um item de auditoria — ver `operadoras/humana/saude.py` |
| `unimed_cascavel_saude` (158) | nome | 0 até 27/08/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, mesma empresa-modelo da Humana (1972). Outra Unimed regional, layout de PDF sem nenhuma sobreposição com `unimed_saude` (5060). **Coparticipação pode vir de duas fontes possíveis pro mesmo mês** (tabela embutida no relatório de mensalidade OU extrato separado — "o modelo de arquivo é gerado pela operadora"), nunca somadas: `OperadoraParser.finaliza()` (hook novo, chamado só depois de ver todos os arquivos da importação) resolve qual usar, preferindo o extrato separado — ver `operadoras/unimed_cascavel/saude.py` |
**Ressalva sobre a Bradesco Dental (3759):** o `CLAUDE.md` registra que este parser foi escrito só a partir de texto colado numa conversa, nunca confirmado contra o arquivo real. O banco, porém, já tem uma importação **concluída** pra esse operador (empresa 1684), ou seja, alguém rodou um arquivo real depois daquela ressalva ser escrita. "Concluída" só significa que o pipeline processou sem erro e o CSV foi gerado, **não** que os valores foram de fato conferidos linha a linha contra a fatura. Antes de remover a ressalva do `CLAUDE.md`, confirmar com o usuário se essa conferência manual aconteceu.
**AMIL: primeiro teste real (26/08/2026) achou um bug de parsing, já corrigido.** O parser nunca tinha sido rodado contra um arquivo de verdade — no primeiro teste em produção (empresa 1751, contrato 2831804000), todo arquivo AMIL dava "Nenhum beneficiário foi encontrado" porque o regex exigia espaço entre a coluna do plano e a coluna "Tp.", mas nesse relatório real as duas vêm coladas sem espaço nenhum. Corrigido (ver `portal_api/planos_saude/CLAUDE.md`, seção "Amil Odonto (3758)") e validado rodando `extrai()` de ponta a ponta: 161 beneficiários, R$ 1.630,93, batendo com os totais do próprio relatório. Continua valendo o cuidado geral: essa foi a primeira empresa/arquivo real confirmado, então tratar qualquer resultado da AMIL como "conferir contra a fatura" até mais empresas passarem pela ferramenta. (Este parser aparecia documentado aqui e no `CLAUDE.md` com o código "898" desde a primeira versão de cada arquivo — nunca foi esse o valor em `pipeline.OPERADORAS`, sempre `3758`; corrigido em 28/08/2026, quando a Amil ganhou um segundo cadastro de verdade sob o código 898, ver linha da tabela acima.)
**Unimed Saúde (5060): novo arquivo real (empresa 1970, 27/08/2026) achou um bug de parsing na coparticipação em PDF, já corrigido.** Mesmo padrão da AMIL acima — layout com uma coluna colada sem espaço que o regex não previa (aqui, o código de "Tipo Serviço" colado ao final do nome do Prestador, ex. "...FABRICCON", "...GUSTAVEXA"), dando "Nenhum beneficiário foi encontrado" pra qualquer arquivo de coparticipação com esse estilo de coluna. Corrigido junto com dois valores novos descobertos no mesmo arquivo ("OUTROS DEP" como grau de dependência, "CIR" como tipo de serviço) — ver `portal_api/planos_saude/CLAUDE.md`, seção "Parser da Unimed Saúde", item 8. Validado batendo exatamente com "Total da Familia" impresso no relatório (R$431,02).
**Nota sobre o `codigo_operadora`**: hoje esse código vem sempre do cadastro de operadora do **Questor** (única integração existente — usado tanto pro label de exibição quanto pra filtrar a consulta SQL da planilha padrão, ver skill `importacao-questor-plano-saude`). Se o Contabit vier a ter um cadastro de operadora próprio e divergente, isso pode precisar de um campo adicional por sistema — a confirmar quando essa frente for aberta, não assumir que o mesmo código serve pros dois.
## 2. Regras de negócio que decidem o valor de cada lançamento
A ferramenta é genérica multi-empresa/multi-operadora. O pipeline, os parsers por operadora e as regras gerais (nunca aproximar nome automaticamente, valor negativo nunca vai pro lançamento final, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) estão descritos em detalhe no `CLAUDE.md`, não repetir aqui.
Mecanismos que substituem uma decisão manual repetida por uma configuração reaproveitável:
- **Regra de custeio configurável por empresa+operadora**: quem paga o quê (empresa/empregado/regra específica) é decidido uma vez e reaproveitado todo mês, em vez de decidir de novo a cada competência. Hoje isso é feito via "Cadastro de Regras" (`RegraCusteioPlanoSaude`) — documentado na skill `importacao-questor-plano-saude`, porque nasce ligado ao cadastro de empresa/operadora do Questor (`codigo_empresa`/`codigo_operadora` resolvidos contra o banco de lá).
- **"Regra empresa"** (`portal_api/planos_saude/regras_empresa.py`): cobre custeios negociados que não cabem no desenho padrão "por tipo de lançamento × titular/dependente" — calculados por família inteira ou por critério fixo. Dois exemplos já cadastrados: um teto de R$ 661,61/família na Unimed (`unimed_1778_tecnomyl`, prioridade dependente primeiro e titular absorve o residual) e um critério fixo na Ottimizza/SulAmérica (`sulamerica_5775_ottimizza`). Ver `CLAUDE.md` pra lista completa e como cadastrar uma regra nova. Independente do sistema de destino — a regra decide o valor, não o formato de saída.
- **Vínculos de nome salvos (DE/PARA)** (`VinculoNomeOperadora`): confirmar manualmente uma vez ("Vincular pessoa") que dois nomes divergentes são a mesma pessoa, e o sistema reaplica sozinho nas competências seguintes — nunca aproximação automática, sempre confirmação humana explícita uma vez por divergência.
## 3. Gaps conhecidos do pipeline genérico (por operadora)
As regras abaixo foram identificadas comparando o pipeline genérico com o processo manual mais detalhado já visto até hoje (ver seção 5) — são gaps de **operadora/leiaute de custeio**, não peculiaridade de uma empresa só: qualquer empresa que negocie condição parecida com AMIL ou Bradesco pode ser afetada.
1. **AMIL, tipo "A" (agregado) vs. "D" (dependente direto):** o parser genérico (`operadoras/amil/odonto_mensalidade.py`) extrai o tipo (`T`/`D`/`A`), mas o resto do pipeline trata `D` e `A` como o mesmo "dependente" pra efeito de custeio (`matcher._regra_para_pessoa`, ver `CLAUDE.md`). Em pelo menos um contrato real já visto, a regra negociada exige que **todo tipo A seja 100% descontado do empregado, independente da regra configurada pra dependente**. Cadastrar uma empresa com beneficiários tipo A na AMIL sem confirmar essa condição arrisca custear um agregado pela empresa por engano.
2. **Bradesco, dependente sem linha na planilha padrão:** já apareceu um caso em que a regra negociada era acumular o valor desse dependente na linha do **titular** (não é regra geral do leiaute, é específica daquele contrato). O `matcher.py` genérico, na ausência dessa regra, trata esse caso como "sem cadastro", indo pra auditoria — uma saída **segura** (não lança valor errado, só some do lançamento final até alguém resolver), mas pode não ser o resultado esperado pelo cliente.
3. **Sistema Contabit** (formato alternativo de rubricas 338/200/201, usado por processos manuais antigos): fora do escopo da ferramenta hoje, que só sabe estruturar pro Questor (skill `importacao-questor-plano-saude`). Quando essa frente for aberta, provavelmente vai exigir um `LinhaSistema`/casamento próprios também — o formato de "planilha padrão" contra o qual tudo casa hoje já é moldado no leiaute do Questor (`NOMEFUNC`/`CPFFUNC`/`CODIGOOUTEMP`...), não é só a geração do arquivo final que muda entre sistemas.
Nenhum desses é motivo pra não usar a ferramenta — são pontos que quem for cadastrar uma empresa com essas operadoras/condições precisa confirmar antes (ou verificar que não se aplicam, ex.: se a empresa não tiver nenhum beneficiário tipo A). Até confirmar, mais seguro conferir manualmente o resultado da tela pra essas condições antes de considerar definitivo.
## 4. Como adicionar/ajustar um parser de operadora
Ver `CLAUDE.md` (tabela de operadoras registradas, `OperadoraParser.extrai()`, `pipeline.OPERADORAS`) para o mecanismo técnico.
### 4.1 Checklist — se a pessoa só anexar o arquivo-modelo
Objetivo: perguntar tudo que falta **antes** de escrever código, pra não precisar ajustar o parser várias vezes por falta de informação de negócio (que não tem como vir do arquivo sozinho). Nem toda pergunta se beneficia do mesmo momento — perguntar o que o arquivo nunca vai responder **antes** de abrir qualquer coisa; deixar a inspeção responder sozinha o que é auto-determinável; e só formular a pergunta de negócio mais específica **depois** de ver a estrutura real (evita uma pergunta genérica demais, tipo "existe regra especial?" em vez de "como tratar a categoria X que apareceu na tabela?").
**1. Perguntar antes de abrir o arquivo** (nunca vem do conteúdo, então inspecionar primeiro só atrasa):
1. Nome comercial da operadora + o código de cadastro dela no sistema de destino (`codigo_operadora`) — sem isso não dá pra registrar em `pipeline.OPERADORAS` nem montar o label de exibição. Hoje o destino é sempre o Questor (ver nota na seção 1.1); confirmado em duas operadoras diferentes (Dental Uni, Humana) que esse código nunca aparece no arquivo em si, então não há razão pra esperar a inspeção pra perguntar.
2. Qual empresa/código de cliente esse arquivo representa — usado na trava de conferência que já existe (`ImportacaoPlanoSaudeViewSet.create()`). Mesmo quando o nome da pasta/arquivo já sugere um código (ex.: "503 - Dental Uni", "1972 - Humana"), **confirmar em vez de assumir** — é um palpite vindo do nome do arquivo, não do conteúdo.
**2. Extrair e inspecionar** (sempre com o arquivo real — nunca a partir de texto colado numa conversa, ver [[feedback_pdf_parser_precisa_arquivo_real]]) — isto é autodeterminado, não precisa perguntar:
3. Abrir com a lib certa pro formato — `pdfplumber` pra PDF com texto selecionável; se `page.chars`/`extract_text()` vier vazio, é OCR (`docling`), não `pdfplumber` (ver nota em `operadoras/bradesco/saude.py`); `openpyxl` pra `.xlsx`; `csv` com fallback de encoding pra `.csv` acentuado. Rodar de fato contra o arquivo, não confiar em inspeção visual. **Cuidado com espaçamento entre palavras**: nem sempre `extract_text()`/`extract_text(layout=True)` preservam os espaços reais do PDF (já visto na Unimed Cascavel, onde só `extract_words(x_tolerance=1)` resolveu) — se as linhas saírem com palavras coladas, inspecionar `page.chars` direto pra confirmar o espaçamento real antes de desenhar qualquer regex.
4. Olhar o texto/linhas extraídas com `repr()`, não a versão "bonita" — indentação, colchetes, colunas coladas sem espaço e quebra de nome em duas linhas só aparecem assim. A partir do texto real, identificar: **se o arquivo traz CPF de cada beneficiário ou só nome** (decide `chave_casamento` — CPF é sempre preferível quando existe; isso se responde lendo o arquivo, nunca perguntando à pessoa); como titular e dependente se distinguem (rótulo? indentação? um campo "total família" só preenchido num dos dois? uma coluna de tipo já em texto explícito?); formato do valor monetário (vírgula BR ou ponto americano); se algum nome quebra em mais de uma linha física ou é truncado por largura de coluna.
**3. Confirmar depois de ver a estrutura real** (a inspeção já deu contexto suficiente pra fazer a pergunta certa, não uma genérica):
5. Quais tipos de lançamento vêm nesse arquivo — a inspeção já mostra quais tabelas existem (só mensalidade? só coparticipação? os dois juntos no mesmo arquivo, como a Humana? ou em arquivos separados por tipo, ver "Múltiplos arquivos de operadora" no `CLAUDE.md`) — a pergunta que falta responder é se essa composição **é sempre assim** todo mês, ou se pode variar (ex.: Unimed Cascavel, onde a coparticipação pode vir embutida OU separada dependendo do mês).
6. Existe alguma regra de custeio negociada com o cliente além do padrão "empresa/empregado por tipo × titular/dependente" (teto por família, percentual fixo, um tipo sempre 100% descontado)? Usar o que a inspeção revelou pra perguntar de forma específica — ex.: se apareceu uma categoria "Agregado" na totalização (como no arquivo da Humana), perguntar diretamente como ela deve ser tratada, em vez de só "existe regra especial?" genérico. Se houver regra negociada, pedir **um exemplo numérico já calculado à mão** (ex.: "família com mensalidade R$X, empresa cobre R$Y, empregado paga R$Z") — é contra esse exemplo que a implementação é validada no passo 8 abaixo, não só "parece certo".
**Escrever e validar:**
7. Implementar `OperadoraParser.extrai()` em `operadoras/<nome>/<arquivo>.py` e registrar em `pipeline.OPERADORAS` com o `codigo_operadora` do passo 1.
8. Rodar `extrai()` de ponta a ponta contra o arquivo real e comparar total em R$ + contagem de beneficiários com os totais que o próprio relatório já imprime (nunca só "não deu erro" ou "contagem de linhas parece certa").
9. Testar pela tela (`validar-arquivo`) e, se possível, uma importação completa contra a planilha padrão real da empresa antes de considerar pronto (planilha padrão hoje é sempre a do Questor — ver skill `importacao-questor-plano-saude`).
### 4.2 Regras de negócio que nunca devem ser reinterpretadas
Válidas pra qualquer operadora nova, sem exceção — e independentes do sistema contábil de destino:
- Nome divergente nunca é resolvido por aproximação automática (fuzzy match). Sempre confirmação humana explícita, mesmo que pareça óbvio.
- Nenhum valor negativo entra no lançamento final, vira auditoria, nunca é zerado/truncado silenciosamente.
- Um mesmo beneficiário pode gerar várias linhas/rubricas no arquivo da operadora (mensalidade mais retroativo, por exemplo). Sempre somar por indivíduo antes de decidir o valor final, nunca tratar cada linha isoladamente.
## 5. Histórico (contexto, não necessário no dia a dia)
A ferramenta nasceu documentando o processo manual mensal de uma empresa específica — TECNOMYL (código Questor `1778`, operadoras AMIL/Unimed/Bradesco) —, feito à mão com scripts Python ad-hoc (protótipo em `projects/project/`) seguindo um runbook vivo por competência. Hoje TECNOMYL é só mais uma empresa entre as cadastradas em "Cadastro de Regras" (skill `importacao-questor-plano-saude`), sem tratamento especial no código — esse histórico só importa pra quem for:
- **Entender a origem das regras genéricas do pipeline** (nome nunca resolvido por aproximação, valor negativo sempre vira auditoria, somar por indivíduo): vieram de decisões validadas com esse cliente no processo manual, documentadas em detalhe no runbook dele.
- **Cadastrar TECNOMYL de vez na ferramenta**: ela ainda não tem `RegraCusteioPlanoSaude` cadastrada hoje. Existe um único registro histórico de importação (Unimed, `regra_empresa=unimed_1778_tecnomyl` já setado) parado em "revisão", de antes do "Cadastro de Regras" existir como tela separada. Antes de cadastrar, ver os gaps da seção 3 (a maioria foi identificada a partir do processo manual dela) e o aviso abaixo sobre vínculos de nome.
- **Vínculos de nome**: as cerca de 30 equivalências de nome divergente já conhecidas do runbook da TECNOMYL (nome na operadora ≠ nome no Questor) não foram pré-carregadas como `VinculoNomeOperadora` — a primeira competência dela rodada pela tela vai gerar auditoria pra cada uma de novo, até serem confirmadas uma vez cada (ver seção 2).
- **Localizar o runbook**: fora deste repositório, só na máquina de quem processa hoje — `Plano de Saúde - TM\MM-AAAA\` (por competência: `Memoria_Importacao_Questor.md`, CSVs gerados, `Faltantes_Questor_MM_AAAA.xlsx`, `Auditoria_Importacao_Questor_MM_AAAA.xlsx`). **Limitação real**: nada neste SKILL.md substitui esse runbook, só resume o que ele documenta de mais estável. Se for necessário que outra pessoa continue sem acesso a essa máquina, os arquivos precisam ser trazidos pra algum lugar compartilhado (git ou outro) — decisão que envolve dados reais de funcionários, não fazer sem o usuário confirmar onde/como.

View File

@ -1,100 +1,39 @@
--- ---
name: importacao-questor-plano-saude name: importacao-questor-plano-saude
description: Guia de manutenção/extensão da ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/, tela importacao-plano-saude.html). Nasceu como processo manual mensal só da TECNOMYL e hoje atende várias empresas/operadoras reais (9 parsers, 13 empresas com regra de custeio cadastrada). Documenta quais operadoras/empresas já estão parametrizadas e validadas com dado real, o que ainda falta pra TECNOMYL rodar 100% pela tela, e como adicionar uma operadora nova. Usar ao dar manutenção nos parsers, ao investigar uma divergência de valores numa importação, ou ao decidir se uma empresa/operadora nova pode ser cadastrada com segurança. description: Guia da etapa final específica do Questor na ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/) — como os dados já extraídos/validados (ver skill geral `importacao-plano-saude` primeiro) viram um Cadastro de Regras de custeio por empresa+operadora, a planilha padrão via SQL do Questor, e o CSV/ZIP final no leiaute do Questor. Documenta também como auditar manualmente uma importação já concluída contra o relatório de lançamentos do próprio Questor. Usar depois de já saber que o sistema contábil de destino é o Questor — pra extração do arquivo da operadora e regras de negócio genéricas (custeio, nome divergente), ver a skill geral primeiro.
--- ---
# Importação de Plano de Saúde: origem, migração e estado atual # Importação de Plano de Saúde → Questor: estruturação e leiaute final
## 0. O que este documento é (e o que não é) ## 0. O que este documento é (e o que não é)
Este SKILL.md documenta **o processo de negócio e sua migração** para dentro do Portal. É o complemento "por quê"/"cuidado com X" da documentação técnica, que já está exaustivamente descrita em `CLAUDE.md` (seção "Importação de Plano de Saúde (Utilitários)"). Antes de tocar em qualquer parser ou regra de custeio, ler os dois: `CLAUDE.md` pra arquitetura (models, endpoints, formato de `custeio_por_tipo`, "Regra empresa", "Vínculos de nome salvos"), este arquivo pra contexto de negócio e para a lista de pontos ainda não confirmados como equivalentes ao processo manual original. Este SKILL.md documenta a etapa **específica do Questor**: como a informação já extraída do arquivo da operadora e já com as regras de negócio de custeio aplicadas (ver skill `importacao-plano-saude` — leitura e regras de negócio genéricas, sempre o ponto de partida) vira, de fato, um lançamento no leiaute de importação do Questor. Complementa `CLAUDE.md` (models, endpoints, formato de `custeio_por_tipo`, seção "Planilha padrão via Questor (SQL)") com o "por quê"/"cuidado com X" desta etapa.
## 1. Origem: processo manual mensal da TECNOMYL **Se a tarefa é adicionar/ajustar um parser de operadora (extração de um arquivo novo, casamento, regra de custeio negociada): pare aqui, esta skill não cobre isso.** Carregar `importacao-plano-saude` (seção 4 — checklist e regras de negócio) antes de escrever qualquer código ou fazer qualquer pergunta ao usuário sobre o arquivo-modelo. Esta skill só entra depois que o parser já existe e a extração/casamento já estão certos — ela é sobre Cadastro de Regras, planilha padrão e leiaute final, não sobre ler o arquivo da operadora.
Até a ferramenta existir, a importação de plano de saúde/odontológico da TECNOMYL (código Questor `1778`, operadoras AMIL/Unimed/Bradesco) era feita **à mão, todo mês**, com scripts Python ad-hoc (nunca versionados como projeto, só o protótipo em `Portal/projects/project/` ficou como referência) seguindo um runbook vivo por competência: `Plano de Saúde - TM\MM-AAAA\Memoria_Importacao_Questor.md` (fora deste repositório, só na máquina de quem processava). Esse runbook documenta, com exemplos numéricos reais validados com o cliente: **Hoje o Questor é o único sistema contábil de destino implementado** — se em algum momento surgir uma importação com destino Contabit, não usar este guia pra estruturar a saída; ele ainda não foi implementado (ver gap 3 na skill geral).
- Extração AMIL (PDF ou XLSX) por CPF, tipos T/D/A (agregado sempre 100% descontado do empregado, nunca custeado pela empresa). ## 1. Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui)
- Extração Unimed por nome mais teto de R$ 661,61/família (titular e dependentes).
- Extração Bradesco por nome, valores já individualizados por beneficiário.
- Cerca de 30 pares de nome divergente entre operadora e Questor, descobertos e confirmados um a um ao longo de várias competências.
- Checklist de validação e um método de auditoria pós-importação comparando com o relatório `Plano de Saúde - Lançamentos` exportado do próprio Questor.
**Esse runbook por competência continua existindo e sendo o mais atualizado sobre a TECNOMYL especificamente.** Quem for decidir se pode aposentar o processo manual pra essa empresa deve ler a competência mais recente dele antes de qualquer coisa, não só este SKILL.md (que é sobre o processo em geral, não uma cópia congelada dos números de uma competência). Essa lista é dado puro do banco (`codigo_empresa`/`razao social`/`operadora` de `RegraCusteioPlanoSaude`), sem nenhuma análise em cima — mantê-la fixa aqui só garante que fique desatualizada a cada empresa nova cadastrada pela tela "Cadastro de Regras". Consultar direto quando precisar:
## 2. O que foi migrado para o Portal (`portal_api/planos_saude/`) ```python
RegraCusteioPlanoSaude.objects.order_by("codigo_empresa").values_list("codigo_empresa", "nome")
```
A ferramenta hoje é **genérica multi-empresa/multi-operadora** (não é "o robô da TECNOMYL"). TECNOMYL é só mais um `codigo_empresa` (`1778`) entre várias empresas que passam pela mesma tela. O pipeline, os parsers por operadora, o formato de custeio configurável e as regras gerais (nunca aproximar nome automaticamente, valor negativo nunca vai pro CSV final, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) estão descritos em detalhe no `CLAUDE.md`, não repetir aqui. **Único ponto que não é só dado de banco**: a empresa **221** (ROSSONI, PIOTTO & CIA LTDA) tem as 3 operadoras (Bradesco Saúde, Unimed e Itamed) com importação concluída — é a melhor empresa de referência hoje pra testar qualquer mudança no pipeline, exatamente por cobrir três parsers diferentes já validados.
Confirmado hoje (rodada em que este documento foi atualizado) que já refletem regras específicas validadas com a TECNOMYL: ## 2. Cadastro de Regras — por que vive aqui, não na skill geral
- **Teto Unimed de R$ 661,61/família**: migrado como `regra_empresa: unimed_1778_tecnomyl` (`portal_api/planos_saude/regras_empresa.py`), com prioridade dependente primeiro e titular absorve o residual, validado contra o mesmo exemplo do runbook original (Antonio Eduardo Petroni: 361,01 de mensalidade, 224,09 de empresa, 136,92 de desconto). **"Cadastro de Regras" por empresa+operadora** (`RegraCusteioPlanoSaude`): decide "quem paga o quê" (empresa/empregado/regra específica) uma vez, reaproveitado todo mês em vez de decidido de novo a cada competência — mecanismo descrito em detalhe no `CLAUDE.md`. A decisão de custeio em si (quem paga quanto) é uma regra de negócio genérica, mas o **cadastro** dela nasce amarrado ao Questor de fato: `codigo_empresa` e `codigo_operadora` são resolvidos contra o banco de lá (`portal_api/empresas_questor.py`, `resolve_nome_empresa()`, ver "Nome da empresa (Questor)" no `CLAUDE.md`), e é essa combinação que restringe quais operadoras aparecem disponíveis pra uma empresa em "Nova Importação". Por isso o cadastro em si — ao contrário de "Regra empresa"/"Vínculos de nome" (skill geral), que não dependem de nenhum cadastro externo — fica documentado aqui.
- **Cadastro de Regras por empresa+operadora** (`RegraCusteioPlanoSaude`): substitui a decisão manual "quem paga o quê" por combinação, configurar uma vez e reaproveitar todo mês.
- **Vínculos de nome salvos (DE/PARA)** (`VinculoNomeOperadora`): substitui a tabela de equivalência estática do runbook por um mecanismo que aprende. Confirmar manualmente uma vez ("Vincular pessoa") e o sistema reaplica sozinho nas competências seguintes. As cerca de 30 equivalências já conhecidas da TECNOMYL (seção 5 do runbook) **não foram pré-carregadas no banco**. Na prática, a primeira competência da TECNOMYL rodada pela tela vai gerar auditoria pra cada uma delas de novo, até serem confirmadas uma vez cada.
## 3. Operadoras e empresas já parametrizadas hoje (fotografia do banco em 25/08/2026) Nenhuma empresa aparece no combobox de "Nova Importação" sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras" (tela separada da execução, ver `CLAUDE.md`).
A ferramenta deixou de ser "a automação da TECNOMYL", hoje atende várias empresas/operadoras reais. Os números abaixo vieram de consultar o banco de produção direto (`RegraCusteioPlanoSaude`/`ImportacaoPlanoSaude`) na rodada em que este documento foi escrito. **É uma fotografia, não um fato permanente**: cresce todo mês, reconsultar antes de confiar nela pra uma decisão importante (`python manage.py shell`, os dois models citados). ## 3. Gap conhecido: sem auditoria automática pós-importação
### 3.1 Os 9 parsers de operadora existentes: uso real até agora **Auditoria pós-importação contra o relatório do Questor** (`Plano de Saúde - Lançamentos - Competência MM/AAAA`): não existe essa feature na ferramenta. Ver seção 4 pra reproduzir esse método manualmente, se for pedido.
| Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? | ## 4. Auditoria pós-importação contra o relatório do Questor (ainda manual)
|---|---|---|---|
| `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) |
| `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) |
| `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão" |
| `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) |
| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) |
| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** |
| `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) |
| `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) |
| `amil_odonto_mensalidade` (898) | CPF | 0 até 26/08/2026 | Nunca foi rodada dentro da ferramenta até então — ver nota abaixo, primeiro teste real achou e corrigiu um bug de parsing |
**Ressalva sobre a Bradesco Dental (3759):** o `CLAUDE.md` registra que este parser foi escrito só a partir de texto colado numa conversa, nunca confirmado contra o arquivo real. O banco, porém, já tem uma importação **concluída** pra esse operador (empresa 1684), ou seja, alguém rodou um arquivo real depois daquela ressalva ser escrita. "Concluída" só significa que o pipeline processou sem erro e o CSV foi gerado, **não** que os valores foram de fato conferidos linha a linha contra a fatura. Antes de remover a ressalva do `CLAUDE.md`, confirmar com o usuário se essa conferência manual aconteceu.
**AMIL: primeiro teste real (26/08/2026) achou um bug de parsing, já corrigido.** O parser nunca tinha sido rodado contra um arquivo de verdade — no primeiro teste em produção (empresa 1751, contrato 2831804000), todo arquivo AMIL dava "Nenhum beneficiário foi encontrado" porque o regex exigia espaço entre a coluna do plano e a coluna "Tp.", mas nesse relatório real as duas vêm coladas sem espaço nenhum. Corrigido (ver `portal_api/planos_saude/CLAUDE.md`, seção "Amil Odonto (898)") e validado rodando `extrai()` de ponta a ponta: 161 beneficiários, R$ 1.630,93, batendo com os totais do próprio relatório. Continua valendo o cuidado geral: essa foi a primeira empresa/arquivo real confirmado, então tratar qualquer resultado da AMIL como "conferir contra a fatura" até mais empresas passarem pela ferramenta.
### 3.2 Empresas com "Cadastro de Regras" salvo (13 empresas, 17 combinações empresa+operadora)
| Cód. empresa | Razão social | Operadora(s) cadastrada(s) |
|---|---|---|
| 92 | PRESCINOTTI & CIA LTDA. | Unimed |
| 129 | SOCIEDADE CIVIL NOSSA SENHORA APARECIDA | Unimed |
| 197 | ENTREGA COMÉRCIO DE MÓVEIS LTDA - EPP | Itamed |
| **221** | ROSSONI, PIOTTO & CIA LTDA | Bradesco Saúde, Unimed e Itamed (**3 operadoras, todas com importação concluída**, melhor empresa de referência hoje pra testar qualquer mudança no pipeline) |
| 626 | MTI SERVIÇOS E COMÉRCIO EXTERIOR LTDA | Itamed |
| 792 | WEITNAUER BRASIL IMPORTADORA E EXPORTADORA DE PERFUMES E COSMÉTICOS LTDA | SulAmérica Odonto e Unimed Vitória |
| 1006 | COPYVIC LOCAÇÃO DE EQUIPAMENTOS LTDA | Itamed |
| 1084 | LUSIA DALA ROSA VOLPATO LTDA | Dental Uni |
| 1123 | VÍDEO UP COMUNICAÇÃO LTDA | Unimed |
| 1601 | LABORATÓRIO DE ANÁLISES CLÍNICAS OSWALDO CRUZ DE MEDIANEIRA LTDA | Unimed Oeste do Paraná |
| 1604 | T & F JOALHEIROS E ACESSÓRIOS LTDA - ME | Itamed |
| 1684 | FRT CONSOLIDADORA LTDA | Itamed e Bradesco Dental |
| 2028 | LAS WINE BAR LTDA | Dental Uni |
**TECNOMYL (1778) não está nesta lista, não tem nenhuma `RegraCusteioPlanoSaude` cadastrada hoje**, nem pra Unimed, nem AMIL, nem Bradesco. Existe um único registro de importação histórico pra ela na tabela `ImportacaoPlanoSaude` (Unimed, com `regra_empresa=unimed_1778_tecnomyl` já setado), mas ficou em status **"revisão"**, nunca chegou a gerar o CSV, e é de antes da separação do "Cadastro de Regras" (não tem `regra_custeio_salva` vinculada). Não apareceria hoje no fluxo atual de "Nova Importação" sem primeiro cadastrar a regra pela tela "Cadastro de Regras". Isto confirma, com dado real, o que a seção 4 abaixo já levanta como suspeita: **a migração da TECNOMYL nunca foi finalizada de ponta a ponta dentro da ferramenta**, nem para a Unimed (que já tem o algoritmo de teto pronto no código).
## 4. Pontos NÃO confirmados como equivalentes (verificar antes de confiar na tela pra TECNOMYL)
Isto é o motivo mais provável pelo qual a TECNOMYL específica ainda pode estar rodando pelo processo manual, mesmo com a ferramenta existindo: as regras abaixo são regras de negócio reais, validadas com o cliente no processo manual, e **não têm evidência de estarem implementadas na ferramenta genérica** (verificado lendo o código dos parsers na rodada em que este documento foi escrito):
1. **AMIL, tipo "A" (agregado) vs. "D" (dependente direto):** o parser genérico (`operadoras/amil/odonto_mensalidade.py`) extrai o tipo (`T`/`D`/`A`), mas o resto do pipeline trata `D` e `A` como o mesmo "dependente" pra efeito de custeio (`matcher._regra_para_pessoa`, ver `CLAUDE.md`). A regra da TECNOMYL exige que **todo tipo A seja 100% descontado do empregado, independente da regra configurada para dependente** (que pra TECNOMYL costuma custear D pela empresa). Rodar a TECNOMYL pela tela sem resolver isso faria um agregado ser custeado pela empresa por engano.
2. **Bradesco, dependente sem linha no modelo do Questor:** regra manual, acumular o valor desse dependente na linha do **titular** (não é regra geral do leiaute, é específica). O `matcher.py` genérico, na ausência dessa regra, deve estar tratando esse caso como "sem cadastro", indo pra auditoria (comportamento padrão do resto do sistema), o que é uma saída **segura** (não lança valor errado, só some do CSV até alguém resolver), mas não é o mesmo resultado do processo manual.
3. **Auditoria pós-importação contra o relatório do Questor** (`Plano de Saúde - Lançamentos - Competência MM/AAAA`): não existe essa feature na ferramenta. Ver seção 6 abaixo pra reproduzir esse método manualmente, se for pedido.
4. **Sistema Contabit** (seção 10 do runbook, formato alternativo de rubricas 338/200/201): fora do escopo da ferramenta, que só gera o leiaute do Questor.
Nenhum desses é motivo pra não usar a ferramenta, são pontos que **quem for migrar a TECNOMYL de vez pra tela precisa resolver primeiro** (ou confirmar que já deixaram de ser relevantes, ex.: se a TECNOMYL não tiver mais nenhum beneficiário tipo A). Até resolver, mais seguro tratar qualquer resultado da tela pra TECNOMYL como "conferir manualmente contra o runbook" antes de subir ao Questor, em vez de confiar de olhos fechados.
## 5. Como adicionar/ajustar um parser de operadora
Ver `CLAUDE.md` (tabela de operadoras registradas, `OperadoraParser.extrai()`, `pipeline.OPERADORAS`) para o mecanismo técnico. Regras de negócio que **nunca devem ser reinterpretadas** sem confirmar de novo com o usuário, válidas pra qualquer operadora nova:
- Nome divergente nunca é resolvido por aproximação automática (fuzzy match). Sempre confirmação humana explícita, mesmo que pareça óbvio.
- Nenhum valor negativo entra no CSV final, vira auditoria, nunca é zerado/truncado silenciosamente.
- Um mesmo beneficiário pode gerar várias linhas/rubricas no arquivo da operadora (mensalidade mais retroativo, por exemplo). Sempre somar por indivíduo antes de decidir o valor final, nunca tratar cada linha isoladamente.
- PDF sem texto selecionável precisa de OCR (`docling`), não `pdfplumber`. Testar `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual usar (ver nota em `operadoras/bradesco/saude.py`).
- Todo parser novo em PDF só deve ser considerado confiável depois de rodado contra o arquivo real (nunca só a partir de texto colado numa conversa). Ver [[feedback_pdf_parser_precisa_arquivo_real]].
## 6. Auditoria pós-importação contra o relatório do Questor (ainda manual)
Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no Questor, e a pessoa tiver em mãos o PDF `Plano de Saúde - Lançamentos - Competência MM/AAAA` exportado do próprio sistema: Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no Questor, e a pessoa tiver em mãos o PDF `Plano de Saúde - Lançamentos - Competência MM/AAAA` exportado do próprio sistema:
@ -104,7 +43,3 @@ Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no
4. Pessoa com `VALOREMPRESA=0` e `VALOR=0` pode não gerar lançamento nenhum no sistema, não é divergência. 4. Pessoa com `VALOREMPRESA=0` e `VALOR=0` pode não gerar lançamento nenhum no sistema, não é divergência.
5. Confirmar que ninguém da lista de pendências/auditoria aparece lançado no sistema. 5. Confirmar que ninguém da lista de pendências/auditoria aparece lançado no sistema.
6. A linha `Total Empresa` no fim do PDF é o total geral de todas as operadoras, serve de conferência rápida contra a soma dos CSVs antes de entrar no detalhe pessoa a pessoa. 6. A linha `Total Empresa` no fim do PDF é o total geral de todas as operadoras, serve de conferência rápida contra a soma dos CSVs antes de entrar no detalhe pessoa a pessoa.
## 7. Onde está o histórico real de competências já processadas
Fora deste repositório, só na máquina de quem processa hoje: `Plano de Saúde - TM\MM-AAAA\` (por competência: `Memoria_Importacao_Questor.md`, os CSVs gerados, `Faltantes_Questor_MM_AAAA.xlsx`, `Auditoria_Importacao_Questor_MM_AAAA.xlsx`). **Isso é uma limitação real pro objetivo de "qualquer contribuidor consegue dar andamento sem precisar desta máquina"**, nada neste SKILL.md substitui esse runbook, só resume o que ele documenta de mais estável. Se for necessário que outra pessoa continue esse processo (manual ou via tela) sem acesso a esta máquina, os arquivos dessa pasta precisam ser trazidos para algum lugar compartilhado (git ou outro), decisão que envolve dados de funcionários reais (nomes, valores), então não fazer isso sem o usuário confirmar onde/como.

10
.gitignore vendored
View File

@ -22,6 +22,16 @@ db.sqlite3-journal
*.log *.log
local_settings.py local_settings.py
# Uploads de teste (base local) — planilhas anexadas só pra processar uma
# importação/apuração, não são conteúdo permanente do app (diferente de
# media/links_ferramentas/, que são ícones de verdade usados na UI).
# Importações/apurações reais são feitas direto na base de produção, nunca
# sincronizadas via git.
media/planos_saude/operadora/
media/planos_saude/planilha_padrao/
media/indicadores/honorarios/
media/indicadores/tareffa/
# Distribuição / empacotamento # Distribuição / empacotamento
build/ build/
dist/ dist/

View File

@ -239,7 +239,7 @@ Usuário forneceu o PDF real do cliente Weitnauer Brasil (empresa 792 na planilh
- Mesma técnica de reconstrução de linha por posição (`extract_words()` agrupadas por `top`) já usada na Unimed Vitória, porque o nome de um beneficiário longo quebra pro relatório — só que aqui o corte é mais agressivo (o próprio relatório trunca a última letra da palavra, ex. "SILV" em vez de "SILVA"), sem prejuízo nenhum já que o casamento é por CPF, não por nome. - Mesma técnica de reconstrução de linha por posição (`extract_words()` agrupadas por `top`) já usada na Unimed Vitória, porque o nome de um beneficiário longo quebra pro relatório — só que aqui o corte é mais agressivo (o próprio relatório trunca a última letra da palavra, ex. "SILV" em vez de "SILVA"), sem prejuízo nenhum já que o casamento é por CPF, não por nome.
- Validado rodando o parser e o `pipeline.processa_importacao` completo contra o arquivo real + a planilha padrão real: 15 beneficiários extraídos, R$ 437,40 no total (bate com "Total R$ 437,40" impresso no relatório); 13 casaram certo por CPF contra a planilha padrão de teste, os outros 2 (ausentes dessa planilha) foram corretamente para auditoria "CPF não encontrado" em vez de ignorados/silenciosos. - Validado rodando o parser e o `pipeline.processa_importacao` completo contra o arquivo real + a planilha padrão real: 15 beneficiários extraídos, R$ 437,40 no total (bate com "Total R$ 437,40" impresso no relatório); 13 casaram certo por CPF contra a planilha padrão de teste, os outros 2 (ausentes dessa planilha) foram corretamente para auditoria "CPF não encontrado" em vez de ignorados/silenciosos.
> Operadoras adicionadas depois desta rodada (Bradesco Dental/3759, Amil Odonto/898, SulAmérica Saúde/5775 via Ottimizza) não têm uma rodada numerada correspondente registrada em `plano.md` — o estado atual de cada uma está documentado em `CLAUDE.md` desta pasta. > Operadoras adicionadas depois desta rodada (Bradesco Dental/3759, Amil Odonto/3758, SulAmérica Saúde/5775 via Ottimizza) não têm uma rodada numerada correspondente registrada em `plano.md` — o estado atual de cada uma está documentado em `CLAUDE.md` desta pasta. (O código de cadastro da Amil Odonto aparecia aqui e em outros pontos da documentação como "898" desde a primeira versão do arquivo — nunca foi esse o valor em `pipeline.OPERADORAS`, sempre `3758`; corrigido numa rodada posterior, ver "Amil Odonto ganha um segundo cadastro no Questor (898)" abaixo.)
### Bug real — Unimed Saúde (5060, PDF de coparticipação): grau "COMPANHEIRO" truncado não reconhecido ### Bug real — Unimed Saúde (5060, PDF de coparticipação): grau "COMPANHEIRO" truncado não reconhecido
@ -251,6 +251,72 @@ Usuário reportou, testando a correção acima pela tela: depois de editar Valor
Aproveitando a mesma conversa, usuário pediu que as células de Valor/Valor Empresa aceitem uma expressão de soma/subtração digitada direto (ex.: `"15,30-15"` pra abater R$15 do valor, sem precisar calcular fora e digitar o resultado pronto). Implementado client-side: `pidAvaliaExpressaoValorMonetario()` reconhece números em formato BR (vírgula decimal, ponto de milhar opcional) separados por `+`/`-` e, se o texto digitado for uma expressão válida (sobra zero caractere não reconhecido), substitui o campo pelo resultado já calculado antes do PATCH — um valor negativo isolado (ex.: `"-15,30"`) continua sendo só um número, não uma expressão (só conta como expressão se houver operador depois do primeiro caractere). O backend não teve nenhuma mudança — `valor`/`valor_empresa` continuam sendo `CharField` sem validação de formato, então já aceitava (e continua aceitando) qualquer string; a expressão nunca chega até lá, só o resultado. Aproveitando a mesma conversa, usuário pediu que as células de Valor/Valor Empresa aceitem uma expressão de soma/subtração digitada direto (ex.: `"15,30-15"` pra abater R$15 do valor, sem precisar calcular fora e digitar o resultado pronto). Implementado client-side: `pidAvaliaExpressaoValorMonetario()` reconhece números em formato BR (vírgula decimal, ponto de milhar opcional) separados por `+`/`-` e, se o texto digitado for uma expressão válida (sobra zero caractere não reconhecido), substitui o campo pelo resultado já calculado antes do PATCH — um valor negativo isolado (ex.: `"-15,30"`) continua sendo só um número, não uma expressão (só conta como expressão se houver operador depois do primeiro caractere). O backend não teve nenhuma mudança — `valor`/`valor_empresa` continuam sendo `CharField` sem validação de formato, então já aceitava (e continua aceitando) qualquer string; a expressão nunca chega até lá, só o resultado.
### Bug real — Dental Uni Odonto: segundo layout de relatório (sem colchete no Nº Cartão) travava a extração inteira
Usuário reportou (empresa 503, "TAROBA CONSTRUCOES LTDA", dois arquivos "503"/"503-2" — um por contrato/filial, 774977 e 785666) que os dois arquivos davam "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação. Investigado rodando `pdfplumber` contra os dois PDFs reais: o relatório "Relatório de Beneficiários" da Dental Uni tem uma segunda variante de renderização, sem o `[Nº Cartão]` entre colchetes que o parser exigia desde a Rodada 49 — o número vem solto, colado direto depois do nome ("774977 ALEX PATRICIO VISOLI 00202577667800001301 25/04/1991 09/11/2023 0,00 16,57 33,14") — e, nessa variante, titular e dependente têm a MESMA indentação (a heurística de indentação da Rodada 49 não se aplica).
Corrigido acrescentando um segundo regex de linha (`_LINHA_SEM_COLCHETE_RE`, tentado só quando o formato com colchete não bate) em `operadoras/dental_uni/odonto_mensalidade.py`: como a indentação não ajuda nesse layout, titular/dependente passou a ser decidido pela presença da coluna "Total Fam" (só preenchida na linha do titular, mesma regra de negócio já documentada desde a Rodada 49, só que agora usada como sinal em vez de só ser ignorada) — 3 valores monetários na linha = titular, 2 = dependente. O valor do beneficiário é sempre o 2º valor monetário (a coluna "Tx Inc." vem sempre impressa, mesmo "0,00", antes de "Valor Unit"). Validado rodando `extrai()` de ponta a ponta contra os dois arquivos reais: 24 beneficiários/R$397,68 e 10 beneficiários/R$165,70, batendo exatamente com os totais impressos em cada relatório, incluindo nomes quebrados em duas linhas reconstituídos certos (mesma lógica da Rodada 50, sem mudança). O formato original com colchete (empresa 1084) foi reconfirmado sem regressão com um teste dedicado.
### Décima operadora: Humana Saúde (5064)
Usuário forneceu o modelo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI - EPP), competência 08/2026 — primeiro teste guiado pelo checklist novo da skill `importacao-questor-plano-saude` (seção 4.1): antes de escrever qualquer código, o arquivo foi inspecionado (`pdfplumber`, `repr()` linha a linha) e as perguntas que não davam pra responder só com o arquivo foram feitas ao usuário (código da operadora no Questor, confirmação do código da empresa, regra de custeio negociada, e se a tabela "TOTALIZAÇÃO POR PLANO" devia ser ignorada).
- **Novo parser** `operadoras/humana/saude.py` (`HumanaSaude`), registrado em `pipeline.OPERADORAS["humana_saude"]` (código `5064`). Único arquivo com mensalidade e coparticipação juntas, mas em duas tabelas com identificadores diferentes: mensalidade tem uma "Matrícula" por beneficiário (com "Tipo do usuário" já em texto explícito — não precisa inferir titular/dependente por indentação, ao contrário de Dental Uni/Itamed); "DESPESAS COBRADAS" (coparticipação) só tem a matrícula do CONTRATO, e o nome vem truncado por largura de coluna, colado sem espaço na conta seguinte quando ultrapassa a largura.
- **Decisão explícita do usuário, descoberta durante a inspeção**: sem CPF nem matrícula confiável nessa segunda tabela, coparticipação **nunca** tenta casamento automático — cada evento (já somado por pessoa) vira direto um `ItemAuditoria` (`NAO_CADASTRADO`) na extração, exigindo sempre "Vincular pessoa" manual. Primeiro parser do pacote a gerar itens de auditoria já na extração por essa razão (os demais só devolvem auditoria via `matcher.py`, depois de tentar e falhar o casamento).
- Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 4 beneficiários de mensalidade somando R$ 1.154,73 e 2 itens de auditoria de coparticipação somando R$ 145,20 (uma pessoa com duas despesas no mês corretamente somada num único item, não dois — evita que o segundo item seja recusado ao tentar vincular a mesma linha já preenchida pelo primeiro), batendo exatamente com os totais impressos no próprio boletim.
- **Só uma família no arquivo-modelo**: as posições fixas usadas pra extrair "Titular"/"Usuário" da tabela de despesas (colunas 16 e 34 do texto extraído) não puderam ser confirmadas com um nome bem mais curto que a largura da coluna — reconferir se aparecer uma competência real com mais de uma família.
### Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723) ### Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723)
Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (`1723`); o código correto é `4723` — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem `OPER_4723_DENTAL_UNI...`). Corrigido `OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"]` em `pipeline.py`; o label de exibição (`"4723 - Dental Uni Odonto"`) é derivado desse campo em todo lugar que usa `label_operadora()`/`lista_operadoras()` (combobox de operadora, mensagens de erro, `nome_operadora` de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (`ImportacaoPlanoSaude.nome_operadora`, texto congelado no momento da criação) não são retroativamente corrigidos. Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (`1723`); o código correto é `4723` — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem `OPER_4723_DENTAL_UNI...`). Corrigido `OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"]` em `pipeline.py`; o label de exibição (`"4723 - Dental Uni Odonto"`) é derivado desse campo em todo lugar que usa `label_operadora()`/`lista_operadoras()` (combobox de operadora, mensagens de erro, `nome_operadora` de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (`ImportacaoPlanoSaude.nome_operadora`, texto congelado no momento da criação) não são retroativamente corrigidos.
### Combobox de Operadora ordenado por código
Pedido do usuário: o combobox "Operadora" de Nova Importação (e os demais que reaproveitam `lista_operadoras()`) mostrava as operadoras na ordem de inserção em `pipeline.OPERADORAS` (ordem de dict, sem critério nenhum pro usuário). `lista_operadoras()` (`pipeline.py`) passou a ordenar pelo `codigo_operadora` (numérico, menor pro maior) antes de montar a lista `{key, label}` — o frontend (`criarComboboxTexto()`, `importacao-plano-saude.js`) já renderiza os itens na ordem em que chegam da API, sem reordenar sozinho, então bastou ordenar na origem.
### Décima primeira operadora: Unimed Cascavel (158)
Usuário forneceu os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, mesma empresa-modelo da Humana): dois relatórios de mensalidade (um por contrato — 183237 e 183210/"Estadual") e um extrato de coparticipação separado. Nome comercial genérico ("Unimed"), mas layout de PDF completamente diferente da "Unimed Saúde" (5060) já cadastrada — outra Unimed regional, código de operadora próprio no Questor (158).
- **Novo parser** `operadoras/unimed_cascavel/saude.py` (`UnimedCascavelSaude`), registrado em `pipeline.OPERADORAS["unimed_cascavel_saude"]`. `chave_casamento="nome"` — nenhum dos formatos de arquivo traz CPF.
- **Bug de extração descoberto na inspeção (antes de escrever qualquer regex)**: nem `extract_text()` simples nem `extract_text(layout=True, x_density=6)` (a técnica já usada por outros parsers deste pacote) preservam o espaçamento entre palavras deste PDF — as duas fundem tudo sem espaço nenhum ("UNIMEDDECASCAVEL...", nomes de beneficiário colados). Confirmado inspecionando `page.chars` diretamente que o espaçamento real é mais estreito que a tolerância padrão do pdfplumber; corrigido usando `extract_words(x_tolerance=1)` (em vez de `extract_text()`) e reconstruindo cada linha por posição vertical (`top`) — mesma técnica de reconstrução já usada pela Unimed Vitória, por um motivo diferente (lá era quebra de linha física, aqui é fusão de palavra).
- **Coparticipação pode vir de duas fontes possíveis pro mesmo mês, e a operadora não avisa qual vai mandar**: um relatório de mensalidade pode opcionalmente trazer, na mesma página, uma tabela "DESPESAS COBRADAS" já com a coparticipação resumida por beneficiário — ou ela pode vir num arquivo separado "EXTRATO DE ATENDIMENTOS COBRADOS". Confirmado no arquivo-modelo que as duas, quando aparecem juntas, são a MESMA competência (totais batendo centavo a centavo). Resposta do usuário à pergunta "dá pra implementar as duas? o modelo de arquivo é gerado pela operadora" foi sim — implementadas as duas, mas nunca somadas: `OperadoraParser` ganhou um hook novo, `finaliza()` (`operadoras/base.py`, chamado pelo pipeline uma vez depois que TODOS os arquivos da importação já foram processados, default não faz nada — só a Unimed Cascavel sobrescreve por enquanto), usado aqui pra escolher o extrato separado quando presente (mais granular) e só cair pra tabela embutida na ausência dele — nunca os dois juntos.
- **Resolução de família (titular/dependente) só por matrícula, nunca por nome**: a coluna "Usuário" da mensalidade trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana — confirmado que o texto pára exatamente na borda da coluna seguinte), então cruzar o nome truncado da mensalidade com o nome completo do extrato de coparticipação não seria confiável. Em vez disso, a matrícula (idêntica nos dois formatos) é a chave de tudo: `_pessoa_por_matricula` é populado só a partir da tabela de mensalidade (onde a família já vem certa por ordem de bloco) e reaproveitado pra resolver os dois candidatos de coparticipação, que só carregam matrícula + valor. Beneficiário com coparticipação sem nenhuma linha de mensalidade correspondente nesta importação vira `ItemAuditoria` explícito (arquivo de mensalidade daquele contrato não anexado), nunca é descartado em silêncio.
- Perguntado e confirmado com o usuário: se nem a mensalidade nem a coparticipação trouxerem valor pra alguém numa competência, assume-se que não houve despesa (nenhum `ItemAuditoria` gerado só por ausência).
- Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais: mensalidade batendo R$ 4.076,41 + R$ 808,98 = R$ 4.885,39 (12 beneficiários, 2 contratos) e coparticipação batendo R$ 1.067,17 (3 beneficiários) — confirmado também que a tabela embutida (extraída em paralelo, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada.
- **Bug real corrigido no mesmo dia, testando pela tela**: anexar o extrato de coparticipação junto com a mensalidade dava "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação (`POST /.../validar-arquivo/`), mesmo o arquivo estando correto. Causa: `_valida_arquivo_operadora()` (`views.py`) valida cada arquivo **isoladamente**, com uma instância nova do parser, e só olhava o retorno direto de `extrai()` — mas o extrato de coparticipação da Unimed Cascavel devolve `([], [])` de propósito (os dados ficam retidos até `finaliza()`, ver acima), então a pré-validação nunca via nada. Corrigido chamando `parser.finaliza()` também dentro de `_valida_arquivo_operadora()`, somando `individuos` + `individuos_finais` + `auditoria_final` pra decidir se "algo foi encontrado" — sem exigir que a resolução completa (que depende de ver a família inteira, só disponível na importação real com todos os arquivos juntos) já esteja pronta nesse momento. Não afeta nenhuma outra operadora (`finaliza()` default devolve sempre vazio, soma zero). Validado rodando a mesma checagem isolada contra os 3 arquivos reais: 13/2/3 lançamentos encontrados respectivamente (o arquivo de mensalidade com "DESPESAS COBRADAS" embutida agora conta certo os 10 de mensalidade + 3 de coparticipação, em vez de só 10).
O fato de a operadora ter mandado um arquivo por contrato/filial não teve nenhuma relação com o erro — múltiplos arquivos de operadora por importação já são suportados desde a Rodada 83 (mesclados automaticamente).
### Skill de negócio dividida em duas (Questor x geral)
Pedido do usuário: o escritório trabalha com mais de um sistema contábil (Questor hoje, Contabit no futuro), e a etapa final da ferramenta (estruturar/gerar o arquivo de lançamento) diverge entre eles, mesmo com a extração/regras de negócio sendo as mesmas. A skill única `importacao-questor-plano-saude` (que, apesar do nome, cobria a ferramenta inteira) foi dividida:
- **`importacao-plano-saude`** (nova, skill geral): tabela de operadoras/parsers, checklist de "como adicionar operadora nova", regras de negócio que nunca mudam (nome nunca por aproximação, valor negativo sempre auditoria, somar por indivíduo), "Regra empresa" e "Vínculos de nome salvos", e os gaps de custeio por operadora (AMIL tipo A, Bradesco dependente sem linha) — tudo independente de sistema contábil de destino. Ganhou uma instrução de despacho: confirmar qual é o sistema de destino e carregar a skill específica antes de tocar em leiaute/Cadastro de Regras/geração de arquivo.
- **`importacao-questor-plano-saude`** (existente, esvaziada e focada): ficou só com o que é Questor de fato — "Cadastro de Regras" (por que o cadastro em si, ao contrário da decisão de custeio, nasce amarrado ao `codigo_empresa`/`codigo_operadora` resolvidos contra o banco do Questor) e a auditoria manual pós-importação contra o relatório de lançamentos do Questor.
- Uma terceira skill pro Contabit ainda não existe — a skill geral só documenta o gap e avisa pra não inventar leiaute Contabit sem confirmação, e já registra uma ressalva: o "casamento" hoje (`matcher.py`/`LinhaSistema`) usa um formato de registro moldado no leiaute do Questor, então o Contabit provavelmente vai exigir mais que só uma geração de arquivo diferente — um `LinhaSistema`/matcher próprios também, quando essa frente for aberta.
- Referências cruzadas atualizadas em `portal_api/planos_saude/CLAUDE.md` e `README.md` (skill única → as duas); aproveitado pra corrigir a contagem de parsers no `README.md`, que ainda dizia "9" (desatualizada desde as rodadas da Humana/Unimed Cascavel).
- **Redirecionamento reforçado, mesmo dia**: usuário perguntou o que aconteceria se a skill `importacao-questor-plano-saude` fosse chamada direto pra cadastrar uma operadora nova — o aviso original (uma frase solta na seção 0, tipo "ver skill X, sempre o ponto de partida") dependia de inferência, não era uma trava amarrada ao cenário específico. Trocado pelos dois lados por um "pare aqui" explícito logo no topo: a skill geral (`importacao-plano-saude`) avisa pra quem for mexer em Cadastro de Regras/planilha padrão/leiaute/geração de arquivo ir pra skill do Questor; a skill do Questor avisa pra quem for adicionar/ajustar parser de operadora ir pra skill geral (seção 4) **antes** de escrever qualquer código ou fazer qualquer pergunta ao usuário. Aproveitado pra corrigir uma referência cruzada errada (`importacao-plano-saude` apontava "gap 4" quando o gap do Contabit é o item 3 da seção 3).
### Bug real — Unimed Saúde (5060, PDF de coparticipação): código de "Tipo Serviço" colado ao Prestador não reconhecido
Usuário reportou (empresa Questor 1970, "Rede Brasil de Mídia OOH LTDA", competência 08/2026) "Nenhum beneficiário foi encontrado neste arquivo" ao anexar o arquivo de coparticipação, enquanto a mensalidade da mesma competência processou normalmente (3 lançamentos). Investigado rodando `pdfplumber`/`extrai()` de ponta a ponta contra o arquivo real: a detecção de layout e o casamento da linha de pessoa (`_PESSOA_COPARTICIPACAO_RE`) funcionavam normalmente, mas nenhuma linha de item de serviço casava com `_ITEM_COPARTICIPACAO_RE` — o resultado final era sempre zero indivíduos.
Causa: `_ITEM_COPARTICIPACAO_RE` exigia fronteira de palavra (`\b`) dos dois lados do código de "Tipo Serviço" (CON/EXA/HOS/CLI/ODO/MED). Neste arquivo, esse código vem colado sem espaço nenhum ao final do nome do Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO GUSTAVEXA 40316572..." — mesmo estilo de coluna colada já visto no "Beneficiario" desde a rodada 83, só que numa coluna diferente), então a fronteira à esquerda nunca era satisfeita. O mesmo arquivo revelou, de quebra, mais dois valores não previstos: o grau de dependência "OUTROS DEP" (ausente de `_GRAUS_DEPENDENCIA`) e o código de tipo de serviço "CIR" (cirurgia — "Implante de dispositivo").
Corrigido em `operadoras/unimed/saude.py`: `_ITEM_COPARTICIPACAO_RE` perdeu a fronteira de palavra à esquerda (mantida só à direita, pra não casar um código no meio de outra palavra) e ganhou "CIR"; "OUTROS DEP" foi acrescentado a `_GRAUS_DEPENDENCIA` — sem esse segundo ajuste, mesmo com o regex do item corrigido, a coparticipação dessa dependente cairia por engano na pessoa anterior do bloco (mesmo bug do "COMPANHEIRO" truncado, ver acima). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 2 beneficiários (KARLA VANESSA R$247,74 + RAPHAELA SOUZ R$183,28), somando R$431,02 — bate exatamente com "Total da Familia: 431,02" impresso no relatório; reconfirmado, sem regressão, que a mensalidade da mesma competência continua extraindo os mesmos 3 beneficiários de antes.
### "Regra específica": novo critério "Limite de desconto do empregado"
Usuário pediu uma terceira modalidade dentro de "Regra específica" (Cadastro de Regras): até então só existiam critérios que protegem o gasto da EMPRESA (`limite_valor`: teto de quanto ela cobre; `percentual`: fração do valor custeada por ela) — faltava a direção oposta, um teto de quanto é **descontado do empregado**, com a empresa absorvendo o restante sem limite algum (exemplo dado: mensalidade de R$150/R$200, desconto sempre limitado a R$10, empresa cobre R$140/R$190).
- **Backend**: `_monta_regra_custeio()` (`serializers.py`) ganhou um quarto parâmetro (`limite_desconto_empregado_bruto`) e passou a montar `regra["limite_desconto_empregado"]`; `matcher._calcula_valores()` ganhou um branch novo que, quando esse campo vem preenchido, calcula `valor_empregado = min(valor_total, limite_desconto_empregado)` e deriva `valor_empresa` como o complemento — **mutuamente exclusivo** com `limite_valor`/`percentual` (validado explicitamente em `_monta_regra_custeio`, erro claro se os dois grupos vierem preenchidos juntos), porque os dois protegem lados opostos do valor (teto da empresa vs. teto do empregado) e misturá-los não tem uma resolução determinística única quando entram em conflito. `ImportacaoPlanoSaudeCreateSerializer` ganhou os 4 campos `limite_desconto_empregado_<tipo>_<pessoa>` (mesmo padrão de `limite_valor_.../percentual_...` já existentes); `RegraCusteioPlanoSaudeSerializer.validate()` passou o novo campo adiante também. Nenhuma migração — continua dentro do mesmo `JSONField` (`custeio_por_tipo`), só um campo novo dentro do dict de cada combinação tipo×pessoa quando `modo="especifica"`.
- **Frontend** (`importacao-plano-saude.html`/`.js`): terceiro campo "Limite de desconto do empregado" acrescentado às 4 caixas de "Regra específica" (mensalidade/coparticipação × titular/dependente), num agrupamento visual separado (`.ips-regra-especifica__alt`, linha divisória) dos dois campos existentes, com hint próprio explicando a exclusividade. `atualizarExclusividadeRegraEspecifica()` (nova) desabilita ao vivo um grupo de campos assim que o outro é preenchido (não deixa o usuário sequer tentar preencher os dois) — chamada a cada tecla digitada nos três campos e sempre que o formulário é limpo (`limparCusteioForm()`) ou repopulado a partir de uma regra salva (`aplicarCusteio()`). `coletarCusteioAtual()`, `mensagemErroCusteio()`, `resumoModoPessoa()` (resumo só-leitura de "Nova Importação") e `montarFormDataDeRegra()` atualizados pra ler/validar/exibir/enviar o campo novo.
### Amil Odonto ganha um segundo cadastro no Questor (898)
Pedido do usuário: a Amil Odonto está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`) — o cadastro já existente na ferramenta é o `3758`, e agora entrou também o `898`, usado por outra(s) empresa(s). Regras e parâmetros de extração são idênticos aos do cadastro já existente (mesmo layout de PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", mesma extração por CPF) — o que muda entre os dois é só qual `codigo_operadora` filtra a planilha padrão via Questor e qual código/label aparece no combobox de Operadora.
- `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, `nome="Amil Odonto"`), apontando pra **mesma classe** `AmilOdontoMensalidade` já usada por `amil_odonto_mensalidade` (3758) — nenhum parser novo, nenhuma mudança em `operadoras/amil/odonto_mensalidade.py`. `lista_operadoras()`/`label_operadora()` já cobrem a entrada nova sem alteração (ordenação por `codigo_operadora` numérico já existente coloca "898 - Amil Odonto" na posição certa da lista).
- **Corrigida uma divergência de documentação de longa data, descoberta ao investigar este pedido**: `CLAUDE.md` desta pasta e a skill `importacao-plano-saude` documentavam o cadastro já existente da Amil como "898" desde a primeira versão de cada arquivo — nunca foi esse o valor real em `pipeline.OPERADORAS` (sempre `3758`, confirmado no histórico do git desde o commit que introduziu o campo). Corrigido nos dois lugares; a entrada nova (898) é a única ocorrência legítima desse código no pacote.
- Nenhuma migração, nenhuma mudança em `models.py`/`views.py`/frontend — o mecanismo de "operadora com múltiplos cadastros compartilhando o mesmo parser" já era suportado de fato pelo desenho existente (`OPERADORAS` é só um dict de registro), só nunca tinha sido usado.

View File

@ -1,6 +1,6 @@
# Importação de Plano de Saúde (Utilitários) # Importação de Plano de Saúde (Utilitários)
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/planos_saude/`. Ver também a skill `importacao-questor-plano-saude` (`.claude/skills/`) para contexto de negócio (quais operadoras/empresas já estão validadas, o que falta). > Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/planos_saude/`. Ver também as duas skills de contexto de negócio (`.claude/skills/`), divididas porque a empresa trabalha com mais de um sistema contábil e as etapas finais divergem entre eles: `importacao-plano-saude` (leitura/extração dos arquivos de operadora e regras de negócio, independente do destino — quais operadoras/empresas já estão validadas, o que falta) e `importacao-questor-plano-saude` (a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final). Uma terceira skill pro Contabit ainda não existe.
Primeira e única aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar). Primeira e única aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar).
@ -20,19 +20,25 @@ portal_api/planos_saude/
├── 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)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── amil/odonto_mensalidade.py Amil Odonto — 898 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
└── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo) ├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo)
├── humana/saude.py Humana Saúde — 5064 (PDF via pdfplumber, mensalidade+coparticipação no mesmo arquivo, casamento por nome — coparticipação nunca casada automaticamente, ver nota abaixo)
└── unimed_cascavel/saude.py Unimed Cascavel — 158 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome — coparticipação pode vir de duas fontes possíveis, nunca somadas juntas, ver nota abaixo e `OperadoraParser.finaliza()`)
``` ```
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
**`OperadoraParser.finaliza()`** (`operadoras/base.py`) — hook opcional chamado pelo `pipeline.processa_importacao` uma vez, depois que `extrai()` já rodou pra **todos** os arquivos da importação (default: não acrescenta nada, a maioria das operadoras nunca precisa sobrescrever). Existe pra operadora que só consegue decidir algo depois de ver o conjunto completo de arquivos anexados — hoje o único caso real é a Unimed Cascavel (ver abaixo), que usa isso pra escolher entre duas fontes possíveis de coparticipação sem correr risco de somar as duas.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**Amil Odonto (898)** — PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", só mensalidade, casamento por CPF. **Bug real corrigido (rodada em que este parser foi validado pela primeira vez contra um arquivo real, contrato 2831804000)**: o regex de parsing de linha exigia espaço (`\s+`) entre a coluna do plano (ex.: "DENTAL BRONZE DOC R PADRÃO") e a coluna "Tp." logo em seguida, mas nesse relatório real as duas colunas vêm **coladas sem nenhum espaço** ("PADRÃOT", "PADRÃOD", "PADRÃOA") — não é um problema de `x_density` do `pdfplumber` (testado de 6 até 20, sem efeito), o espaço realmente não existe no PDF de origem. Toda linha falhava o match silenciosamente, resultando em "Nenhum beneficiário foi encontrado neste arquivo" pra qualquer arquivo AMIL. Corrigido trocando esse `\s+` por `\s*` em `_AFTER_CPF_RE` (`operadoras/amil/odonto_mensalidade.py`). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 161 beneficiários (124 titulares/35 dependentes/2 agregados), R$ 1.630,93 no total, batendo exatamente com os totais impressos no próprio relatório. **Amil Odonto (3758)** — PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", só mensalidade, casamento por CPF. **Bug real corrigido (rodada em que este parser foi validado pela primeira vez contra um arquivo real, contrato 2831804000)**: o regex de parsing de linha exigia espaço (`\s+`) entre a coluna do plano (ex.: "DENTAL BRONZE DOC R PADRÃO") e a coluna "Tp." logo em seguida, mas nesse relatório real as duas colunas vêm **coladas sem nenhum espaço** ("PADRÃOT", "PADRÃOD", "PADRÃOA") — não é um problema de `x_density` do `pdfplumber` (testado de 6 até 20, sem efeito), o espaço realmente não existe no PDF de origem. Toda linha falhava o match silenciosamente, resultando em "Nenhum beneficiário foi encontrado neste arquivo" pra qualquer arquivo AMIL. Corrigido trocando esse `\s+` por `\s*` em `_AFTER_CPF_RE` (`operadoras/amil/odonto_mensalidade.py`). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 161 beneficiários (124 titulares/35 dependentes/2 agregados), R$ 1.630,93 no total, batendo exatamente com os totais impressos no próprio relatório. **Nota**: até esta rodada, este parser aparecia documentado (aqui e na skill `importacao-plano-saude`) com o código "898" — divergência de documentação desde a primeira versão do arquivo, nunca refletida em `pipeline.OPERADORAS` (sempre foi `3758`); corrigido nesta rodada, ver item abaixo.
**Amil Odonto tem um segundo cadastro no Questor, código 898** — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`), porque empresas distintas usam um ou outro cadastro. `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, mesma `nome="Amil Odonto"`), reaproveitando a **mesma classe** `AmilOdontoMensalidade` — regras de extração e de custeio são idênticas às de `amil_odonto_mensalidade`/3758, só muda qual código filtra a planilha padrão buscada no Questor (`busca_linhas_questor`, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de `parser`.
**Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<regional>.<empresa+contrato>.<sequência>-<dv>" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado. **Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<regional>.<empresa+contrato>.<sequência>-<dv>" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado.
@ -40,7 +46,23 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
**SulAmérica Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - <competência>") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha. **SulAmérica Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - <competência>") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`. **Humana Saúde (5064)** — único parser deste pacote em que a coparticipação **nunca** vira um `Individuo` tentando casamento automático. O PDF "boletim" traz mensalidade e coparticipação juntas no mesmo arquivo (uma tabela de beneficiários, depois "TOTALIZAÇÃO POR PLANO" — resumo agregado sem valor por pessoa, sempre ignorado — e depois "DESPESAS COBRADAS"), mas as duas tabelas usam identificadores diferentes: a de mensalidade tem uma "Matrícula" por beneficiário (`<plano>.<família>.<pessoa>`, com "Tipo do usuário" já como texto explícito "Titular"/"Dependente"/"Agregado" — não precisa inferir por indentação), enquanto a de "DESPESAS COBRADAS" só tem a matrícula do CONTRATO (não bate com a do beneficiário) e o nome vem truncado por largura de coluna (colado sem espaço no número da conta seguinte quando ultrapassa a largura — ex.: "CINTHIA ADRIANA DE SOUZA SANTOS" vira "CINTHIA ADRIANA DE SOUZ" colado em "...SOUZ39324387"). Sem CPF nem matrícula confiável pra casar, cada evento de coparticipação (já somado por pessoa antes, mesma regra geral de "somar por indivíduo") vira direto um `ItemAuditoria` (`motivo="NAO_CADASTRADO"`) na extração — decisão explícita do usuário, pra sempre exigir "Vincular pessoa" manual em vez de arriscar casar a pessoa errada por causa do corte de nome. Validado contra o arquivo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI, competência 08/2026): 4 beneficiários de mensalidade somando R$ 1.154,73 e 2 itens de auditoria de coparticipação somando R$ 145,20 (uma pessoa com duas despesas no mês corretamente somada em um único item, não dois) — batendo exatamente com os totais impressos no próprio boletim. **Só uma família no arquivo-modelo**: as posições fixas usadas pra extrair "Titular"/"Usuário" da tabela de despesas (colunas 16 e 34 do texto extraído) não puderam ser confirmadas com um nome bem mais curto que a largura da coluna — reconferir se aparecer uma competência real com mais de uma família.
**Unimed Cascavel (158)** — outra Unimed regional, layout de PDF sem nenhuma sobreposição com a "Unimed Saúde" (5060, Unimed do Estado do Paraná) já cadastrada; nome comercial genérico repetido entre operadoras diferentes, cada uma com seu próprio parser/código no Questor, mesmo padrão de `unimed_oeste_pr`/`unimed_vitoria`. Sem CPF em nenhum dos formatos de arquivo (`chave_casamento="nome"`).
- **Espaçamento das colunas só sai correto com `extract_words(x_tolerance=1)`**: nem `extract_text()` simples nem `extract_text(layout=True, x_density=6)` bastam aqui — os dois fundem palavras adjacentes sem nenhum espaço (ex.: "UNIMED DE CASCAVEL..." vira "UNIMEDDECASCAVEL...", nomes de beneficiário perdem os espaços internos), confirmado inspecionando `page.chars` diretamente: o espaçamento real entre palavras neste PDF é mais estreito que a tolerância padrão do pdfplumber (a densidade de `x_density=6` do `layout=True` não ajudou; a solução foi baixar o `x_tolerance` de `extract_words()` pra 1). Com isso, as linhas são reconstruídas agrupando palavras por posição vertical (`top`) e concatenando com um único espaço — mesma técnica de "reconstruir a linha a partir de `extract_words()`" já usada pela Unimed Vitória, só que ali por causa de quebra de linha física, aqui por causa da fusão de palavras.
- **Duas fontes possíveis de coparticipação, nunca somadas**: o relatório de mensalidade (`"Matrícula Usuário Plano Tipo do usuário..."`, com `"TOTALIZAÇÃO POR PLANO"` no rodapé) pode opcionalmente trazer, na mesma página, uma tabela `"DESPESAS COBRADAS"` já resumida por beneficiário — **ou** a coparticipação pode vir num arquivo separado `"EXTRATO DE ATENDIMENTOS COBRADOS"` (um bloco por beneficiário, `"Total do usuário:"` já com o valor somado). Confirmado no arquivo-modelo que as duas fontes representam a **mesma competência** quando as duas aparecem juntas (os totais por beneficiário batem centavo a centavo entre as duas). Como "o modelo de arquivo é gerado pela operadora" (decisão explícita do usuário — não dá pra saber de antemão qual formato vai chegar num mês qualquer, e às vezes os dois vêm juntos), o parser nunca soma as duas: `extrai()` só coleta candidatos por matrícula em dois dicionários separados (`_despesas_embutidas`/`_coparticipacao_extrato`, nunca devolvidos direto) e `finaliza()` (chamado pelo pipeline só depois que todos os arquivos da importação já foram processados) decide — prefere o extrato separado quando presente (fonte mais granular), senão cai pra tabela embutida, senão não há coparticipação naquele mês (decisão explícita do usuário: ausência das duas fontes não gera nenhum `ItemAuditoria`, é tratada como "não houve despesa").
- **Toda resolução de família (titular/dependente) é feita por matrícula, nunca por nome** — a coluna "Usuário" do relatório de mensalidade é estreita e trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana, confirmado inspecionando os limites reais de x0/x1 do PDF: o nome pára exatamente na borda da coluna seguinte), então comparar o nome truncado da mensalidade com o nome completo do extrato de coparticipação para resolver `numero_titular`/`tipo` não seria confiável. Em vez disso, `_pessoa_por_matricula` (matrícula -> nome/tipo/numero_titular) é populado só ao processar a tabela de **mensalidade** (onde a família já vem corretamente resolvida por ordem de bloco: titular sempre antes dos próprios dependentes) e reaproveitado em `finaliza()` pra resolver os dois candidatos de coparticipação — que só carregam matrícula + valor, nada de nome. Um beneficiário com coparticipação mas ausente de toda tabela de mensalidade desta importação (arquivo daquele contrato não anexado) vira um `ItemAuditoria` explícito (`NAO_CADASTRADO`), nunca é descartado silenciosamente. Nomes truncados na mensalidade em si seguem o fluxo normal (`NOME_DIVERGENTE` em auditoria, resolvido manualmente uma vez via "Vincular pessoa" — nunca por aproximação).
- Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, 2 contratos — 183237 e 183210/"Estadual"): mensalidade batendo exatamente com os totais impressos (R$ 4.076,41 + R$ 808,98 = R$ 4.885,39, 12 beneficiários) e coparticipação batendo com R$ 1.067,17 (3 beneficiários), confirmando que a tabela embutida (também extraída, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None, "limite_desconto_empregado": float|None}`).
Dentro da regra específica, dois grupos de critério, **mutuamente exclusivos** entre si (validado em `_monta_regra_custeio`, `serializers.py`, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — `atualizarExclusividadeRegraEspecifica()`, `importacao-plano-saude.js`):
- **`limite_valor`/`percentual`** protegem o gasto da **empresa**: `limite_valor` é um teto de quanto ela cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada por ela (o resto vira desconto). Podem vir só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário.
- **`limite_desconto_empregado`** protege o gasto do **empregado** (adicionado a pedido do usuário, ex.: mensalidade de R$150/R$200 com desconto sempre limitado a R$10, a empresa absorve o restante — R$140/R$190): teto de quanto é descontado dele, sem limite algum pro que sobra pra empresa. Direção oposta da anterior — combinar os dois grupos não teria uma resolução determinística única quando entrassem em conflito (ex.: um teto de empresa que por si só implicaria um desconto maior que o teto de empregado permitido), por isso o formulário nunca deixa preencher os dois grupos ao mesmo tempo pra uma mesma combinação tipo×pessoa.
Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que o valor "protegido" (empresa ou empregado, conforme o grupo de critério usado) é arredondado primeiro e o outro é derivado como o complemento exato (`valor_total` menos o protegido, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`.
## Modelos (`portal_api/models.py`) ## Modelos (`portal_api/models.py`)
@ -49,6 +71,10 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
- `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.
- **Expressão de soma/subtração na célula** (pedido explícito do usuário): ao sair de uma célula de Valor/Valor Empresa (`change`), `pidAvaliaExpressaoValorMonetario()` (`importacao-plano-saude.js`) reconhece se o que foi digitado é uma expressão com `+`/`-` entre números em formato BR (ex.: `"15,30-15"` → `"0,30"`) e substitui o campo pelo resultado antes de mandar o PATCH — puramente client-side, o backend nunca recebe a expressão, só o valor já calculado (continua sem nenhuma validação de formato numérico nesse `CharField`, como já era). Um valor negativo digitado direto (ex.: `"-15,30"`, sem operador depois do primeiro caractere) não é tratado como expressão, continua sendo só um número negativo literal. - **Expressão de soma/subtração na célula** (pedido explícito do usuário): ao sair de uma célula de Valor/Valor Empresa (`change`), `pidAvaliaExpressaoValorMonetario()` (`importacao-plano-saude.js`) reconhece se o que foi digitado é uma expressão com `+`/`-` entre números em formato BR (ex.: `"15,30-15"` → `"0,30"`) e substitui o campo pelo resultado antes de mandar o PATCH — puramente client-side, o backend nunca recebe a expressão, só o valor já calculado (continua sem nenhuma validação de formato numérico nesse `CharField`, como já era). Um valor negativo digitado direto (ex.: `"-15,30"`, sem operador depois do primeiro caractere) não é tratado como expressão, continua sendo só um número negativo literal.
- **Edição de célula (Valor/Valor Empresa) refaz o fetch da importação inteira e re-renderiza** (`renderTabs()`) depois do PATCH — bug real corrigido (2026-08-26): antes disso, `importacaoAtual` só era atualizado por outras ações da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), então editar uma célula deixava o resumo por tipo (contadores "linhas no total"/"com valor lançado"/"em auditoria") e a aba Alterações com o estado de antes da edição até o usuário sair e reabrir a importação do zero. - **Edição de célula (Valor/Valor Empresa) refaz o fetch da importação inteira e re-renderiza** (`renderTabs()`) depois do PATCH — bug real corrigido (2026-08-26): antes disso, `importacaoAtual` só era atualizado por outras ações da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), então editar uma célula deixava o resumo por tipo (contadores "linhas no total"/"com valor lançado"/"em auditoria") e a aba Alterações com o estado de antes da edição até o usuário sair e reabrir a importação do zero.
- **Linha de total no rodapé da tabela** (pedido explícito do usuário): as abas de Mensalidade/Coparticipação (`panelHtmlParaTipo()`, `importacao-plano-saude.js`) ganharam uma última linha (`.ips-grid-row--totais`, fundo/negrito iguais ao cabeçalho) somando Valor Empresa e Valor de **todas** as linhas do tipo (não só as visíveis num scroll, e independente da ordenação da coluna) — rótulo "Total" fica na coluna "Nome Funcionário", as demais colunas ficam em branco. `pidValorRevisaoParaNumero()` (extraída de `comparaValorRevisao()`, mesma função reaproveitada) converte cada `"1.234,56"` BR pra número antes de somar; linhas em auditoria (valor "0") entram na soma sem alterá-la. Some sozinha quando a aba está vazia (mesmo `linhas.length` que já decide a linha "Nenhuma linha."), não é uma linha nova editável nem gera `ImportacaoPlanoSaudeAlteracao`.
- **Fixa no fim da área visível** (`importacao-plano-saude.css`, pedido explícito do usuário): `.ips-grid-row--totais .ips-grid-cell` usa `position:sticky; bottom:0` — gruda no rodapé de `.ips-table-scroll` (o ancestral com `overflow-y`) independente de até onde o usuário rolou a tabela ou de quanto ela foi expandida (`wireAutoExpandScroll()`, ver abaixo — sticky é recalculado contra o tamanho atual do container, não um valor fixo em px). `z-index:2` (maior que o `1` da coluna de ações comum) garante que a linha continue por cima das linhas de dado que passam por trás dela ao rolar.
- **Alça de redimensionar só de altura, não mais de largura** (`.ips-table-scroll`, `importacao-plano-saude.css`): caixa de cada aba (Mensalidade/Coparticipação/Auditoria/Alterações) nasceu com `resize:both` (420px de altura inicial, até 80vh), chegou a virar altura totalmente livre (sem caixa/scroll interno, uma rodada revertida) e voltou pra caixa de 420px — só que agora com `resize:vertical` em vez de `resize:both`: o usuário só arrasta a alça pra aumentar a altura (ver mais linhas de uma vez), a largura já rola sozinha via `overflow-x:auto`, sem precisar de alça própria pra isso. O redimensionar de **coluna** (`wireColumnResize()`, alça em cada cabeçalho) é outro mecanismo, independente, e não foi afetado por nenhuma dessas mudanças.
- **Duplo clique na alça alterna expandir/voltar** (`wireAutoExpandScroll()`, `importacao-plano-saude.js`, chamada junto de `wireColumnResize()` em `renderPanels()`): a alça nativa do `resize` não é um elemento do DOM, então o duplo clique é reconhecido pela posição do clique dentro dos últimos ~20px do canto inferior direito de `.ips-table-scroll` (`PID_IPS_RESIZE_HANDLE_HIT_PX`), não por um alvo específico. Primeiro duplo clique: `wrap.style.maxHeight="none"` (senão o teto de 80vh continuaria cortando) e `wrap.style.height = wrap.scrollHeight + "px"` — mesma ideia de "autofit" de largura de coluna de uma planilha (Excel/Sheets), só que de altura; `wrap.dataset.autoExpandido` marca o estado, guardando o `height`/`max-height` inline de antes (`dataset.alturaAnterior`/`maxAlturaAnterior`, string vazia = "sem inline style", volta a valer o CSS padrão). Segundo duplo clique: restaura exatamente esses dois valores guardados (pedido explícito do usuário — "reverter para como estava antes", não necessariamente os 420px padrão, caso o usuário já tivesse arrastado a alça manualmente antes de expandir) e limpa o estado. Mesma limitação do redimensionamento manual por arrasto: é um ajuste inline, `renderPanels()` recria os elementos do zero (qualquer edição de célula, adicionar/remover linha etc.), então sempre nasce não-expandido de novo.
- `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.
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário. - `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.
@ -94,6 +120,7 @@ Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação"
- **`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. - **`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. - **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.
- **Bug real corrigido (competência 09/2026, empresa Questor 604)**: `_GRAUS_DEPENDENCIA` não previa "COMPANHEIRO"/"COMPANHEIRA" — a coluna "Grau Dep." tem largura fixa de 10 caracteres, então esse grau (11 caracteres) sai truncado no relatório real como "COMPANHEIR". Sem essa entrada, a linha desse dependente não casava com `_PESSOA_COPARTICIPACAO_RE`, e o item de serviço dele (que ainda batia em `_ITEM_COPARTICIPACAO_RE`) era somado por engano no `pessoa_atual` anterior — na prática, no titular da mesma família (primeiro caso real de família com coparticipação em titular **e** dependente ao mesmo tempo; até então só se via titular sozinho). Corrigido acrescentando "COMPANHEIRO"/"COMPANHEIRA"/"COMPANHEIR" (a forma truncada, a que de fato aparece) a `_GRAUS_DEPENDENCIA`; validado rodando `extrai()` de ponta a ponta contra o arquivo real (família R$144,35 → titular R$134,33 + dependente R$10,02, batendo com "Total da Familia" impresso, e total geral do arquivo R$606,61 batendo com a soma dos "Total da Familia" das 3 famílias do documento). A importação já existente no banco (id 88, competência 09/2026) tinha sido corrigida manualmente na tela de Revisão antes deste fix (`ImportacaoPlanoSaudeAlteracao` ids 29/30) — não precisou de correção retroativa, só as importações futuras dependiam deste ajuste no parser. - **Bug real corrigido (competência 09/2026, empresa Questor 604)**: `_GRAUS_DEPENDENCIA` não previa "COMPANHEIRO"/"COMPANHEIRA" — a coluna "Grau Dep." tem largura fixa de 10 caracteres, então esse grau (11 caracteres) sai truncado no relatório real como "COMPANHEIR". Sem essa entrada, a linha desse dependente não casava com `_PESSOA_COPARTICIPACAO_RE`, e o item de serviço dele (que ainda batia em `_ITEM_COPARTICIPACAO_RE`) era somado por engano no `pessoa_atual` anterior — na prática, no titular da mesma família (primeiro caso real de família com coparticipação em titular **e** dependente ao mesmo tempo; até então só se via titular sozinho). Corrigido acrescentando "COMPANHEIRO"/"COMPANHEIRA"/"COMPANHEIR" (a forma truncada, a que de fato aparece) a `_GRAUS_DEPENDENCIA`; validado rodando `extrai()` de ponta a ponta contra o arquivo real (família R$144,35 → titular R$134,33 + dependente R$10,02, batendo com "Total da Familia" impresso, e total geral do arquivo R$606,61 batendo com a soma dos "Total da Familia" das 3 famílias do documento). A importação já existente no banco (id 88, competência 09/2026) tinha sido corrigida manualmente na tela de Revisão antes deste fix (`ImportacaoPlanoSaudeAlteracao` ids 29/30) — não precisou de correção retroativa, só as importações futuras dependiam deste ajuste no parser.
- **Bug real corrigido (empresa Questor 1970, "Rede Brasil de Mídia OOH", competência 08/2026)**: `_ITEM_COPARTICIPACAO_RE` exigia fronteira de palavra (`\b`) dos dois lados do código de "Tipo Serviço" (CON/EXA/HOS/CLI/ODO/MED). Neste arquivo real, esse código vem **colado sem espaço** ao final do nome do Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO GUSTAVEXA 40316572..." — mesmo estilo de coluna colada já visto no "Beneficiario" da nota acima, só que aqui na coluna de tipo de serviço), então nenhuma linha de item deste arquivo casava — resultado final era **zero beneficiários** ("Nenhum beneficiário foi encontrado neste arquivo"), mesmo com a detecção do layout e o casamento de linha de pessoa funcionando normalmente. O mesmo arquivo trouxe de quebra um grau de dependência ("OUTROS DEP") e um código de tipo de serviço ("CIR", cirurgia) não previstos. Corrigido: `_ITEM_COPARTICIPACAO_RE` perdeu a fronteira de palavra à esquerda (mantida só à direita) e ganhou "CIR"; "OUTROS DEP" foi acrescentado a `_GRAUS_DEPENDENCIA` (sem isso, mesmo corrigindo o regex do item, a coparticipação dessa dependente cairia por engano na pessoa anterior do bloco, mesmo bug da nota acima). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 2 beneficiários (KARLA VANESSA R$247,74 + RAPHAELA SOUZ R$183,28), somando R$431,02, batendo exatamente com "Total da Familia" impresso; o arquivo de mensalidade da mesma competência (regex própria, não afetada) continuou extraindo os mesmos 3 beneficiários de sempre.
## Planilha padrão via Questor (SQL) ## Planilha padrão via Questor (SQL)
@ -145,7 +172,7 @@ Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — m
Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma). Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).
- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também vira `valido=False`. - **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` **e também** `.finaliza()` (ver `OperadoraParser.finaliza()` acima) na mesma instância, somando `individuos` + `individuos_finais` + `auditoria_final` pra decidir se algo foi encontrado — necessário porque uma operadora que devolve dado retido em `finaliza()` (hoje só a Unimed Cascavel) processa cada arquivo **sozinho** aqui, sem ver os demais arquivos da importação real; sem essa chamada extra, um arquivo cujo conteúdo só é liberado em `finaliza()` sempre pareceria vazio (bug real, ver `CHANGELOG.md`). Os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos/itens de auditoria extraídos (arquivo no formato certo mas vazio) também vira `valido=False`.
- **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `<input type="file">` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão. - **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `<input type="file">` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão.
- **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `<input>` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva. - **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `<input>` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva.

View File

@ -1,8 +1,8 @@
# Importação de Plano de Saúde (Utilitários) # Importação de Plano de Saúde (Utilitários)
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (pacote `planos_saude/`, cada operadora parametrizada). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também a skill `importacao-questor-plano-saude` (`.claude/skills/`) para contexto de negócio (quais operadoras/empresas já estão validadas, o que falta). > Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (pacote `planos_saude/`, cada operadora parametrizada). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também as skills de contexto de negócio (`.claude/skills/`): `importacao-plano-saude` (leitura/extração dos arquivos de operadora e regras de negócio, independente do sistema contábil de destino — quais operadoras/empresas já estão validadas, o que falta) e `importacao-questor-plano-saude` (a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final).
Única aplicação dentro de "Utilitários" — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão. Hoje suporta 9 parsers de operadora (Amil, Itamed, Unimed em 3 variantes, Dental Uni, Bradesco em 2 variantes, SulAmérica em 2 variantes) e um banco de regras de custeio por empresa+operadora, com histórico completo de cada importação e reversão de qualquer alteração feita na revisão. Única aplicação dentro de "Utilitários" — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão. Hoje suporta 11 parsers de operadora (Amil, Itamed, Unimed em 4 variantes, Dental Uni, Bradesco em 2 variantes, SulAmérica em 2 variantes, Humana) e um banco de regras de custeio por empresa+operadora, com histórico completo de cada importação e reversão de qualquer alteração feita na revisão.
## Onde mexer ## Onde mexer

View File

@ -76,17 +76,26 @@ def _calcula_valores(valor_total: float, regra: Optional[dict]) -> Tuple[float,
""" """
Divide `valor_total` entre (valor_empresa, valor_empregado) conforme Divide `valor_total` entre (valor_empresa, valor_empregado) conforme
`regra` (`{"modo": "empresa"|"empregado"|"especifica", "limite_valor": `regra` (`{"modo": "empresa"|"empregado"|"especifica", "limite_valor":
float|None, "percentual": float|None}`): float|None, "percentual": float|None, "limite_desconto_empregado":
float|None}`):
- "empresa" -> tudo em valor_empresa. - "empresa" -> tudo em valor_empresa.
- "empregado" -> tudo em valor (desconto do empregado) — comportamento - "empregado" -> tudo em valor (desconto do empregado) — comportamento
padrão de antes desta funcionalidade existir. padrão de antes desta funcionalidade existir.
- "especifica" -> `limite_valor` é o teto de quanto a empresa cobre - "especifica" -> duas famílias de critério, mutuamente exclusivas
(excedente vira desconto do empregado); `percentual` é a fração do (garantido por `_monta_regra_custeio`/`serializers.py`, nunca as duas
valor do mês que a empresa cobre (o resto vira desconto). Pode vir só preenchidas ao mesmo tempo):
um dos dois ou os dois — quando os dois vêm juntos, prevalece o mais - `limite_valor`/`percentual` protegem o gasto da EMPRESA: `limite_valor`
restritivo (o menor valor entre os dois critérios), decisão explícita é o teto de quanto ela cobre (excedente vira desconto do empregado);
do usuário ("pode ser aplicado apenas uma destas regras ou as duas"). `percentual` é a fração do valor do mês que ela cobre (o resto vira
desconto). Pode vir só um dos dois ou os dois — quando os dois vêm
juntos, prevalece o mais restritivo (o menor valor entre os dois
critérios), decisão explícita do usuário ("pode ser aplicado apenas
uma destas regras ou as duas").
- `limite_desconto_empregado` protege o gasto do EMPREGADO: teto de
quanto é descontado dele (o restante, sem limite, fica com a
empresa) — direção oposta da anterior, por isso não se combina com
ela.
""" """
regra = regra or REGRA_CUSTEIO_PADRAO regra = regra or REGRA_CUSTEIO_PADRAO
modo = regra.get("modo", "empregado") modo = regra.get("modo", "empregado")
@ -96,6 +105,12 @@ def _calcula_valores(valor_total: float, regra: Optional[dict]) -> Tuple[float,
if modo != "especifica": if modo != "especifica":
return 0.0, valor_total return 0.0, valor_total
limite_desconto_empregado = regra.get("limite_desconto_empregado")
if limite_desconto_empregado is not None:
valor_empregado = round(max(0.0, min(valor_total, limite_desconto_empregado)), 2)
valor_empresa = round(valor_total - valor_empregado, 2)
return valor_empresa, valor_empregado
candidatos = [valor_total] candidatos = [valor_total]
percentual = regra.get("percentual") percentual = regra.get("percentual")
if percentual is not None: if percentual is not None:

View File

@ -54,3 +54,18 @@ class OperadoraParser(ABC):
a operadora nunca usou antes e não sabemos classificar). a operadora nunca usou antes e não sabemos classificar).
""" """
raise NotImplementedError raise NotImplementedError
def finaliza(self) -> Tuple[List[Individuo], List[ItemAuditoria]]:
"""
Chamado uma vez pelo pipeline (`processa_importacao`), depois que
`extrai()` já rodou para TODOS os arquivos desta importação — para
uma operadora que só consegue decidir algo depois de ver o conjunto
completo de arquivos anexados (ex.: a mesma coparticipação pode vir
embutida num arquivo OU num arquivo separado, dependendo de como a
operadora gerou o pacote daquele mês — só dá pra saber qual fonte
usar, sem contar valor em dobro, depois de ver todos os arquivos;
ver `operadoras/unimed_cascavel/saude.py`). Default: não acrescenta
nada — a maioria das operadoras resolve tudo dentro de `extrai()` e
não precisa sobrescrever isto.
"""
return [], []

View File

@ -55,6 +55,30 @@ outro caso de nome desencontrado numa importação real (aba Auditoria —
"Vincular pessoa"), veja se o nome extraído está com um pedaço a mais/a "Vincular pessoa"), veja se o nome extraído está com um pedaço a mais/a
menos de algum beneficiário vizinho antes de assumir que é uma divergência menos de algum beneficiário vizinho antes de assumir que é uma divergência
de cadastro de verdade. de cadastro de verdade.
Segundo layout descoberto (empresa 503, contratos Dental Uni 774977/785666,
"TAROBA CONSTRUCOES LTDA"): o mesmo relatório "Relatório de Beneficiários"
pode vir SEM colchete nenhum no Nº Cartão — o número fica solto, colado
direto depois do nome ("774977 ALEX PATRICIO VISOLI 00202577667800001301
25/04/1991 09/11/2023 0,00 16,57 33,14"), e titular/dependente têm
exatamente a MESMA indentação (a heurística de indentação da particularidade
1 não funciona aqui). O parser tenta primeiro o formato com colchete
(`_LINHA_COLCHETE_RE`, layout original) e, se não bater, tenta este segundo
formato (`_LINHA_SEM_COLCHETE_RE`) — os dois convivem no mesmo parser porque
são a mesma operadora/relatório, só uma variação de renderização.
Nesse segundo formato, a linha tem: "<Contrato> <Nome> <Nº Cartão sem
colchete, 10+ dígitos> <Nasc.> <Inclusão> [Exclusão] <Tx Inc.> <Valor Unit>
[Total Fam]". Como "Tx Inc." aparece sempre impresso (mesmo "0,00") antes de
"Valor Unit", o valor do beneficiário é sempre o SEGUNDO número monetário
encontrado depois do cartão, não o primeiro (diferença deliberada em
relação ao formato com colchete, que não tem essa coluna "Tx Inc." antes do
valor). Como no formato original, "Total Fam" só vem preenchido na linha do
titular (soma da família) — só que aqui essa é a única forma confiável de
saber se a linha é titular ou dependente (3 valores monetários = titular,
2 = dependente), já que a indentação não ajuda. "Tx Inc." é ignorada, mesmo
espírito de "Total Fam" ser ignorada — nenhuma das duas é o valor a
custear/descontar do beneficiário.
""" """
import re import re
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@ -64,9 +88,14 @@ 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
_LINHA_RE = re.compile( _LINHA_COLCHETE_RE = re.compile(
r'^(?P<indent>\s*)(?P<nome_parcial>[^\[\]]*?)\s*\[(?P<cartao>\d+)\]\s*(?P<resto>.*)$' r'^(?P<indent>\s*)(?P<nome_parcial>[^\[\]]*?)\s*\[(?P<cartao>\d+)\]\s*(?P<resto>.*)$'
) )
# Segundo layout (sem colchete no Nº Cartão, ver docstring do módulo) —
# "<Contrato> <Nome> <Cartão solto> <resto: datas + Tx Inc./Valor Unit/Total Fam>".
_LINHA_SEM_COLCHETE_RE = re.compile(
r"^(?P<indent>\s*)\d+\s+(?P<nome_parcial>[A-ZÀ-Ý][A-ZÀ-Ý '.-]*?)\s+(?P<cartao>\d{10,})\s+(?P<resto>.*)$"
)
# Nomes no relatório vêm em CAIXA ALTA — usado pra distinguir uma linha de # Nomes no relatório vêm em CAIXA ALTA — usado pra distinguir uma linha de
# nome "órfã" (continuação de um nome quebrado em duas linhas) de qualquer # nome "órfã" (continuação de um nome quebrado em duas linhas) de qualquer
# outro texto do PDF (cabeçalho/rodapé/totais), que nunca vem 100% maiúsculo. # outro texto do PDF (cabeçalho/rodapé/totais), que nunca vem 100% maiúsculo.
@ -98,24 +127,14 @@ class DentalUniOdontoMensalidade(OperadoraParser):
titular_cartao_atual: Optional[str] = None titular_cartao_atual: Optional[str] = None
for linha in linhas: for linha in linhas:
m = _LINHA_RE.match(linha) m = _LINHA_COLCHETE_RE.match(linha)
if not m: if m:
# Sem "[Nº Cartão]" nesta linha — ou é ruído (cabeçalho,
# totais) ou é o excedente de um nome que quebrou em duas
# linhas (ver particularidade 2 no docstring do módulo):
# nesse caso, pertence ao ÚLTIMO lançamento já adicionado,
# nunca ao próximo.
fragmento = linha.strip()
if fragmento and _NOME_FRAGMENTO_RE.match(fragmento) and lancamentos:
lancamentos[-1].nome = f"{lancamentos[-1].nome} {fragmento}"
continue
nome = m.group("nome_parcial").strip() nome = m.group("nome_parcial").strip()
cartao = m.group("cartao") cartao = m.group("cartao")
valores = _VALOR_RE.findall(m.group("resto")) valores = _VALOR_RE.findall(m.group("resto"))
if not nome or not valores: if not nome or not valores:
# Linha com colchetes mas sem nome+valor de beneficiário de # Linha com colchetes mas sem nome+valor de beneficiário
# verdade (ex: "Cliente: (...) CNPJ: [19210328000160]"). # de verdade (ex: "Cliente: (...) CNPJ: [19210328000160]").
continue continue
indent = len(m.group("indent")) indent = len(m.group("indent"))
@ -141,6 +160,49 @@ class DentalUniOdontoMensalidade(OperadoraParser):
tipo_lancamento="mensalidade", tipo_lancamento="mensalidade",
numero_titular=numero_titular, numero_titular=numero_titular,
)) ))
continue
m = _LINHA_SEM_COLCHETE_RE.match(linha)
if m:
nome = m.group("nome_parcial").strip()
cartao = m.group("cartao")
valores = _VALOR_RE.findall(m.group("resto"))
if not nome or len(valores) < 2:
continue
# Sem colchete, a indentação não distingue titular de
# dependente (ver docstring do módulo) — usamos a presença
# de "Total Fam" (3º valor, só preenchido no titular) em vez
# disso. O valor do beneficiário é sempre o 2º valor
# ("Valor Unit"), já que "Tx Inc." vem sempre impresso antes.
if len(valores) >= 3:
tipo = "T"
titular_cartao_atual = cartao
numero_titular = None
else:
tipo = "D"
numero_titular = titular_cartao_atual
lancamentos.append(Lancamento(
numero_beneficiario=cartao,
nome=nome,
cpf="",
tipo=tipo,
rubrica="Mensalidade",
valor=_valor_para_float(valores[1]),
tipo_lancamento="mensalidade",
numero_titular=numero_titular,
))
continue
# Nenhum dos dois formatos bateu — ou é ruído (cabeçalho,
# totais) ou é o excedente de um nome que quebrou em duas
# linhas (ver particularidade 2 no docstring do módulo): nesse
# caso, pertence ao ÚLTIMO lançamento já adicionado, nunca ao
# próximo.
fragmento = linha.strip()
if fragmento and _NOME_FRAGMENTO_RE.match(fragmento) and lancamentos:
lancamentos[-1].nome = f"{lancamentos[-1].nome} {fragmento}"
return lancamentos return lancamentos
def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]: def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]:

View File

@ -0,0 +1,209 @@
"""
Humana Saúde Sul - Saúde. Mensalidade + Coparticipação (mesmo arquivo).
Formato recebido: PDF "boletim" mensal — cabeçalho com operadora/empresa/
período, depois uma tabela de beneficiários com valor de MENSALIDADE (uma
linha por pessoa: Matrícula, Usuário, Plano, Tipo do usuário, Nascimento,
Idade, Inclusão, Valor), depois "TOTALIZAÇÃO POR PLANO" (resumo agregado
por plano, SEM valor por pessoa — sempre ignorada, confirmado com o
usuário) e por fim "DESPESAS COBRADAS" (uma linha por evento de
coparticipação: Matrícula do CONTRATO — não do beneficiário —, Titular,
Usuário, Conta, Atendimento, Regime, Prestador, Valor).
Validado contra o arquivo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI -
EPP), competência 08/2026 — só uma família aparece no arquivo-modelo
("Página 1/1"), então o comportamento com mais de uma família na mesma
competência (vários blocos "DESPESAS COBRADAS") ainda não foi confirmado.
1. NÃO HÁ CPF NESTE ARQUIVO. Casamento por NOME (chave_casamento="nome").
O tipo (Titular/Dependente/Agregado) já vem como texto explícito na
coluna "Tipo do usuário" da tabela de mensalidade — não precisa
inferir por indentação nem por "total família" como em outras
operadoras deste pacote.
2. Tabela de MENSALIDADE: colunas separadas por espaço, sem ambiguidade
(`_MENSALIDADE_RE` usa a linha inteira, âncora em `$`). `numero_titular`
é rastreado como estado corrente (mesmo padrão de Itamed/Dental Uni):
a linha do titular sempre vem antes das dele mesmo na família, no
único exemplo visto.
3. Tabela "DESPESAS COBRADAS" (coparticipação): layout mais apertado — as
colunas "Titular" (posições 16 a 34 do texto extraído com
`layout=True`) e "Usuário" (a partir de 34) não têm separador
confiável quando o texto é longo: um nome que ultrapassa a largura da
coluna é truncado SEM espaço antes do próximo campo (ex.: "CINTHIA
ADRIANA DE SOUZA SANTOS" vira "CINTHIA ADRIANA DE SOUZ" colado direto
no número da conta seguinte, "...SOUZ39324387"). Por isso "Usuário" é
extraído com regex não-guloso até o primeiro run de 6+ dígitos seguido
de uma data (a coluna "Conta"+"Atendimento"), não por um recorte de
largura fixa. **Só uma família no arquivo-modelo** (nome sempre no
limite da coluna) — não foi possível confirmar se a posição 16/34
continua estável quando "Titular"/"Usuário" são bem mais curtos que a
largura da coluna; reconferir se aparecer um caso estranho numa
competência real. O Valor (sempre a última coluna) não depende disso
— é o único número em formato monetário na linha.
4. DECISÃO EXPLÍCITA DO USUÁRIO: linhas de "DESPESAS COBRADAS" NUNCA são
casadas automaticamente com um beneficiário — o nome vem truncado
(item 3) e a "Matrícula" desta tabela é a do CONTRATO/família, não a
do beneficiário (não bate com a Matrícula da tabela de mensalidade),
então não haveria como confirmar a pessoa certa sem risco de casar
errado. Por isso a extração NUNCA gera um `Individuo` de coparticipação
tentando casamento automático — cada evento (já somado por pessoa,
mesma regra geral de "somar por indivíduo antes de decidir o valor
final") vira direto um `ItemAuditoria` (motivo `NAO_CADASTRADO`), pra
confirmação manual via "Vincular pessoa".
5. "Tipo" (titular/dependente) de uma linha de coparticipação é decidido
comparando o texto de "Usuário" com o de "Titular" NA MESMA LINHA (se
forem iguais, é o próprio titular gerando a despesa; senão, é
dependente) — não cruza com a tabela de mensalidade pra isso, evita a
mesma ambiguidade de nomes truncados.
"""
from typing import Dict, List, Tuple
import re
import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
from portal_api.planos_saude.operadoras.base import OperadoraParser
_MENSALIDADE_RE = re.compile(
r'^\s*(?P<matricula>\d+\.\d+\.\d+)\s+(?P<nome>.+?)\s+\d+\s+'
r'(?P<tipo_label>Titular|Dependente|Agregado)\s+\d{2}/\d{2}/\d{4}\s+'
r'\d+\s+\d{2}/\d{2}/\d{4}\s+(?P<valor>[\d.,]+)\s*$'
)
# "Usuário" (truncado, sem separador confiável) + "Conta" (6+ dígitos) + "Atendimento" (data).
_DESPESA_RESTO_RE = re.compile(r'^(?P<usuario>.+?)\s*\d{6,}\s+\d{2}/\d{2}/\d{4}')
_VALOR_RE = re.compile(r'\d{1,3}(?:\.\d{3})*,\d{2}')
_TIPO_LABEL_PARA_CODIGO = {"Titular": "T", "Dependente": "D", "Agregado": "A"}
_DESPESA_COL_TITULAR = slice(16, 34)
_DESPESA_COL_USUARIO_INICIO = 34
def _valor_para_float(texto: str) -> float:
"""'22,23' -> 22.23 '1.234,56' -> 1234.56"""
texto = texto.strip().replace('.', '').replace(',', '.')
return float(texto) if texto else 0.0
class HumanaSaude(OperadoraParser):
nome_operadora = "HUMANA"
chave_casamento = "nome" # PDF não traz CPF, só Matrícula
def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]:
linhas: List[str] = []
with pdfplumber.open(caminho_pdf) as pdf:
for page in pdf.pages:
texto = page.extract_text(layout=True) or ""
linhas.extend(texto.splitlines())
return linhas
def _parseia_mensalidade(self, linhas: List[str]) -> List[Lancamento]:
lancamentos: List[Lancamento] = []
titular_matricula_atual = None
for linha in linhas:
if linha.strip().startswith("DESPESAS COBRADAS"):
break # tabela de mensalidade termina aqui
m = _MENSALIDADE_RE.match(linha)
if not m:
continue
tipo = _TIPO_LABEL_PARA_CODIGO[m.group("tipo_label")]
matricula = m.group("matricula")
if tipo == "T":
titular_matricula_atual = matricula
numero_titular = None
else:
numero_titular = titular_matricula_atual
lancamentos.append(Lancamento(
numero_beneficiario=matricula,
nome=m.group("nome").strip(),
cpf="",
tipo=tipo,
rubrica="Mensalidade",
valor=_valor_para_float(m.group("valor")),
tipo_lancamento="mensalidade",
numero_titular=numero_titular,
))
return lancamentos
def _parseia_despesas(self, linhas: List[str]) -> List[ItemAuditoria]:
# Agrega por (titular, usuário) truncados antes de gerar o item de
# auditoria — mesma regra geral de "somar por indivíduo": sem isso,
# duas despesas da mesma pessoa virariam dois itens tentando
# vincular a mesma linha (o segundo seria recusado por "linha já
# tem valor lançado").
agregados: Dict[Tuple[str, str], dict] = {}
ordem: List[Tuple[str, str]] = []
dentro_despesas = False
for linha in linhas:
if linha.strip().startswith("DESPESAS COBRADAS"):
dentro_despesas = True
continue
if not dentro_despesas:
continue
titular = linha[_DESPESA_COL_TITULAR].strip()
m = _DESPESA_RESTO_RE.match(linha[_DESPESA_COL_USUARIO_INICIO:])
valores = _VALOR_RE.findall(linha)
if not m or not titular or not valores:
continue
usuario = m.group("usuario").strip()
if not usuario:
continue
chave = (titular, usuario)
if chave not in agregados:
agregados[chave] = {
"titular": titular,
"usuario": usuario,
"tipo": "T" if usuario == titular else "D",
"valor": 0.0,
}
ordem.append(chave)
agregados[chave]["valor"] += _valor_para_float(valores[-1])
return [
ItemAuditoria(
motivo="NAO_CADASTRADO",
numero_beneficiario="",
nome=agregados[chave]["usuario"],
cpf="",
tipo=agregados[chave]["tipo"],
valor=agregados[chave]["valor"],
tipo_lancamento="coparticipacao",
detalhe=(
f"Coparticipação de \"{agregados[chave]['usuario']}\" (titular do "
f"contrato: \"{agregados[chave]['titular']}\") não é casada "
f"automaticamente — o nome vem truncado por largura de coluna "
f"neste relatório e a \"Matrícula\" desta tabela é do contrato, "
f"não do beneficiário. Confirmar manualmente a quem pertence."
),
)
for chave in ordem
]
def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
individuos: Dict[str, Individuo] = {}
ordem = []
for lc in lancamentos:
chave = lc.numero_beneficiario
if chave not in individuos:
individuos[chave] = Individuo(
numero_beneficiario=lc.numero_beneficiario,
nome=lc.nome,
cpf=lc.cpf,
tipo=lc.tipo,
tipo_lancamento=lc.tipo_lancamento,
numero_titular=lc.numero_titular,
)
ordem.append(chave)
individuos[chave].valor_total += lc.valor
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
return [individuos[c] for c in ordem]
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
linhas = self._pdf_para_linhas(caminho_arquivo)
individuos = self._agrega_por_individuo(self._parseia_mensalidade(linhas))
auditoria = self._parseia_despesas(linhas)
return individuos, auditoria

View File

@ -114,6 +114,33 @@ Particularidades identificadas no PDF de coparticipação analítico (caso 2):
separados (R$ 134,33 e R$ 10,02, somando os R$ 144,35 de "Total da separados (R$ 134,33 e R$ 10,02, somando os R$ 144,35 de "Total da
Familia" impresso no relatório) e o total geral do arquivo (R$ 606,61) Familia" impresso no relatório) e o total geral do arquivo (R$ 606,61)
bateu com a soma dos 3 "Total da Familia" do documento. bateu com a soma dos 3 "Total da Familia" do documento.
8. **Bug real corrigido (cliente Rede Brasil de Mídia OOH, competência
08/2026)**: neste relatório, a coluna "Tipo Serviço" (código de 3 letras
— CON, EXA, HOS...) vem **colada sem espaço nenhum** ao final do nome do
Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO
GUSTAVEXA 40316572...") — mesmo estilo de coluna colada já visto em
`_PESSOA_COPARTICIPACAO_RE`, só que aqui na coluna de tipo de serviço,
que não tinha esse tratamento. Como `_ITEM_COPARTICIPACAO_RE` exigia
fronteira de palavra (`\b`) dos dois lados do código, nenhuma linha de
item deste arquivo casava — o resultado final era **zero beneficiários**
("Nenhum beneficiário foi encontrado neste arquivo"), mesmo com a
detecção de layout e o casamento das linhas de pessoa funcionando
normalmente. O mesmo arquivo também trouxe um grau de dependência não
previsto ("OUTROS DEP") e um código de tipo de serviço não previsto
("CIR", cirurgia — ex. "Implante de dispositivo"). Corrigido: (a)
`_ITEM_COPARTICIPACAO_RE` perdeu a fronteira de palavra à esquerda
(mantida só à direita, pra não casar um código no meio de outra
palavra), acrescentando "CIR" à lista; (b) "OUTROS DEP" acrescentado a
`_GRAUS_DEPENDENCIA`. Sem o ajuste em (b), mesmo corrigindo (a) os itens
desse dependente seriam somados por engano na pessoa anterior do bloco
(mesmo bug do item 7 acima). Validado rodando `extrai()` de ponta a
ponta contra o arquivo real da empresa 1970: 2 beneficiários (KARLA
VANESSA R$ 247,74 + RAPHAELA SOUZ R$ 183,28), somando R$ 431,02 — bate
exatamente com "Total da Familia: 431,02" impresso no relatório. O
arquivo de mensalidade da mesma competência não foi afetado por este bug
(usa `_LINHA_MENSALIDADE_RE`, uma regex própria) e continuou extraindo
os mesmos 3 beneficiários de antes.
""" """
import csv import csv
import re import re
@ -137,7 +164,7 @@ _MARCADORES_COPARTICIPACAO = ("SERVIÇOS PRESTADOS", "SERVICOS PRESTADOS", "ANAL
# caso outro relatório real não trunque. # caso outro relatório real não trunque.
_GRAUS_DEPENDENCIA = ( _GRAUS_DEPENDENCIA = (
"TITULAR", "CONJUGE", "CÔNJUGE", r"FILHO\(A\)", r"PAI/M[ÃA]E", "AGREGADO", "TITULAR", "CONJUGE", "CÔNJUGE", r"FILHO\(A\)", r"PAI/M[ÃA]E", "AGREGADO",
"COMPANHEIRO", "COMPANHEIRA", "COMPANHEIR", "COMPANHEIRO", "COMPANHEIRA", "COMPANHEIR", "OUTROS DEP",
) )
_LINHA_MENSALIDADE_RE = re.compile( _LINHA_MENSALIDADE_RE = re.compile(
@ -157,7 +184,17 @@ _PESSOA_COPARTICIPACAO_RE = re.compile(
# o casamento deste relatório usa CPF, não nome (ver # o casamento deste relatório usa CPF, não nome (ver
# `chave_casamento_para_tipo`). # `chave_casamento_para_tipo`).
) )
_ITEM_COPARTICIPACAO_RE = re.compile(r"\b(?:EXA|CON|HOS|CLI|ODO|MED)\b") _ITEM_COPARTICIPACAO_RE = re.compile(r"(?:EXA|CON|HOS|CLI|ODO|MED|CIR)\b")
# Sem fronteira de palavra à esquerda de propósito: confirmado contra um
# segundo arquivo real (cliente Rede Brasil de Mídia OOH, competência
# 08/2026) que esse código de "Tipo Serviço" pode vir colado sem nenhum
# espaço ao final do nome do Prestador (mesmo estilo de coluna colada já
# visto em `_PESSOA_COPARTICIPACAO_RE` — ex.: "...MARCELO FABRICCON
# 10101012...", "...LUCIANO GUSTAVEXA 40316572...") — com `\b` dos dois
# lados, nenhuma linha desse arquivo casava, e o resultado final era zero
# beneficiários ("Nenhum beneficiário foi encontrado"). "CIR" (cirurgia,
# ex. "Implante de dispositivo") também foi acrescentado à lista, código
# visto pela primeira vez neste mesmo arquivo.
# Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento do valor de um item HOS # 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 # em Medicamento/Material/Soma de Outras Taxas, cuja soma já está no valor
# do item principal — confirmado contra o arquivo real batendo com "Total # do item principal — confirmado contra o arquivo real batendo com "Total

View File

@ -0,0 +1,314 @@
"""
Unimed de Cascavel Cooperativa de Trabalho Médico (código 158) - Saúde.
Mensalidade + Coparticipação. Diferente da "Unimed Saúde" (5060, Unimed do
Estado do Paraná) já cadastrada em pipeline.OPERADORAS — mesmo nome
comercial genérico ("Unimed"), mas layout de relatório completamente
diferente (marcadores próprios, sem nenhuma sobreposição com
`operadoras/unimed/saude.py`).
Validado contra os 3 arquivos reais do cliente Fronteira Outdoor Ltda
(empresa Questor 1972, competência 08/2026): "Mensalidade Unimed.pdf"
(contrato 183237), "Mensalidade Unimed Estadual.pdf" (contrato 183210) e
"Cooparticipação Unimed.pdf" (extrato analítico).
Formato de entrada — dois tipos de arquivo, detectados automaticamente pelo
conteúdo (nunca pelo usuário escolhendo um "tipo de documento"):
1. Relatório de MENSALIDADE ("Matrícula Usuário Plano Tipo do usuário
Nascimento Idade Inclusão Valor", com "TOTALIZAÇÃO POR PLANO" no
rodapé) — uma linha por beneficiário, sem CPF nenhum (chave_casamento
= "nome"). Cada arquivo cobre UM contrato (Matrícula/Usuário se repetem
entre contratos diferentes da mesma empresa, ex.: 183237 x 183210 — mas
nunca dentro do mesmo contrato).
Esse relatório pode OPCIONALMENTE trazer, na mesma página, uma segunda
tabela "DESPESAS COBRADAS" logo depois de "TOTALIZAÇÃO POR PLANO" — é a
coparticipação daquele mesmo contrato, já resumida por conta/atendimento
(sem CPF, com a matrícula do próprio beneficiário — não a do titular —
na primeira coluna, confirmado comparando contra a tabela de mensalidade
da mesma página).
2. EXTRATO DE ATENDIMENTOS COBRADOS — coparticipação analítica num arquivo
à parte, um bloco por beneficiário ("Beneficiário: <matrícula> - <nome
completo> ..." seguido de um ou mais "Conta"/"Item" e fechado por "Total
do usuário: <valor>"). Só usa o valor já somado em "Total do usuário:"
(mesmo espírito de "usar o valor por linha/já agregado, nunca recalcular
a partir do detalhe" já usado noutras operadoras deste pacote).
**As duas fontes de coparticipação podem representar a MESMA competência**
(confirmado nos 3 arquivos-modelo: os totais por beneficiário da tabela
"DESPESAS COBRADAS" embutida em "Mensalidade Unimed.pdf" batem centavo a
centavo com os totais do extrato separado "Cooparticipação Unimed.pdf" —
ex.: Claudinei da Silva Tristão R$ 964,89 nos dois). Como o modelo de
arquivo é gerado pela própria operadora (decisão explícita do usuário: "é
possível implementar as duas validações? Pois o modelo de arquivo é gerado
pela operadora"), não dá pra saber de antemão se um mês vai trazer só o
extrato separado, só a tabela embutida, ou os dois juntos (redundantes) —
por isso este parser NUNCA soma as duas fontes: usa `extrai()` só para
COLETAR os candidatos de cada arquivo (em `self._despesas_embutidas`/
`self._coparticipacao_extrato`, chaveados por matrícula, nunca devolvidos
direto) e só decide em `finaliza()` (`OperadoraParser.finaliza()`, chamado
pelo pipeline uma vez depois que TODOS os arquivos da importação já foram
processados) — prefere o extrato separado quando presente (fonte mais
granular/detalhada), senão cai para a tabela embutida, senão não há
coparticipação nesse mês (decisão explícita do usuário: "se não há valores
no arquivo de mensalidade nem de coparticipação, supomos que não houve
despesas nesta competência" — nenhum ItemAuditoria é gerado só por
ausência de coparticipação).
**Truncamento de nome na tabela de mensalidade** (mesmo padrão já visto em
Amil/Bradesco/Humana): a coluna "Usuário" é estreita (confirmado pelos
limites de x0/x1 reais do PDF — o nome pára exatamente na borda da coluna
"Plano" seguinte) e corta nomes longos sem reticências nem terminar a
última palavra (ex.: "AUGUSTO CESAR GOBETI DOS SA" em vez de "...DOS
SANTOS"). Isso é esperado cair em auditoria "NOME_DIVERGENTE" pra quem
tiver nome mais longo que a coluna — resolvido manualmente uma vez via
"Vincular pessoa" (nunca por aproximação automática, ver CLAUDE.md). Por
isso a coparticipação NUNCA tenta resolver `numero_titular`/`tipo`/`nome`
a partir de nome (que estaria truncado de um jeito e completo de outro,
inviabilizando comparação exata) — em vez disso, tudo é resolvido via
matrícula: `self._pessoa_por_matricula` é populado ao processar a tabela
de MENSALIDADE (matrícula -> nome/tipo/numero_titular, já com a família
corretamente resolvida ali), e tanto a tabela embutida quanto o extrato
separado só carregam matrícula + valor — `finaliza()` cruza os dois. Um
beneficiário com coparticipação mas ausente de toda tabela de mensalidade
desta importação (arquivo de mensalidade daquele contrato não anexado)
vira um ItemAuditoria explícito, nunca é descartado silenciosamente.
**Espaçamento das colunas só é preservado com `extract_words(x_tolerance=
1)`**: o `extract_text()` padrão (e mesmo `layout=True` com o `x_density`
default) funde palavras adjacentes sem nenhum espaço (ex.: "UNIMED DE
CASCAVEL..." vira "UNIMEDDECASCAVEL...", nomes de beneficiário perdem os
espaços internos) — confirmado inspecionando `page.chars`: o espaçamento
real entre palavras neste PDF é mais estreito que a tolerância padrão do
pdfplumber. Com `x_tolerance=1`, as palavras separam corretamente. Por
isso `_linhas_pdf()` usa `extract_words(x_tolerance=1)` agrupado por
posição vertical (`top`), não `extract_text()`.
"""
import re
import unicodedata
from typing import Dict, List, Optional, Tuple
import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
from portal_api.planos_saude.operadoras.base import OperadoraParser
_MARCADOR_MENSALIDADE = "TOTALIZACAO POR PLANO"
_MARCADOR_DESPESAS_COBRADAS = "DESPESAS COBRADAS"
_MARCADOR_EXTRATO = "EXTRATO DE ATENDIMENTOS COBRADOS"
_TIPO_POR_ROTULO = {"Titular": "T", "Dependente": "D", "Agregado": "A"}
_LINHA_MENSALIDADE_RE = re.compile(
r"^(?P<matricula>\d+)\s+(?P<nome>.+?)\s+(?P<plano>\d{3,6})\s+"
r"(?P<tipo>Titular|Dependente|Agregado)\s+\d{2}/\d{2}/\d{4}\s+\d+\s+"
r"\d{2}/\d{2}/\d{4}\s+(?P<valor>[\d.,]+)\s*$"
)
# Só precisa de matrícula (já é a chave usada em self._pessoa_por_matricula,
# resolvida a partir da tabela de mensalidade) e do valor — o resto da
# linha (titular/usuário truncados, conta, data, regime, prestador) não é
# usado, ver docstring do módulo.
_LINHA_DESPESA_RE = re.compile(
r"^(?P<matricula>\d+)\s+.+?\s+\d{9}\s+\d{2}/\d{2}/\d{4}\s+\S+\s+.+?\s+"
r"(?P<valor>[\d.,]+)\s*$"
)
_BENEFICIARIO_RE = re.compile(
r"^Benefici\S*rio:\s*(?P<matricula>\d+)\s*-\s*.+?\s+Idade:", re.IGNORECASE
)
_TOTAL_USUARIO_RE = re.compile(r"^Total do usu\S*rio:\s*(?P<valor>[\d.,]+)\s*$", re.IGNORECASE)
def _normaliza_marcador(texto: str) -> str:
sem_acento = unicodedata.normalize("NFKD", texto or "")
sem_acento = "".join(c for c in sem_acento if not unicodedata.combining(c))
return sem_acento.upper()
def _valor_para_float(texto: str) -> float:
"""'257,09' -> 257.09 '1.234,56' -> 1234.56 (formato BR)"""
texto = texto.strip().replace(".", "").replace(",", ".")
return float(texto) if texto else 0.0
class UnimedCascavelSaude(OperadoraParser):
nome_operadora = "UNIMED CASCAVEL"
chave_casamento = "nome" # nenhum dos dois formatos traz CPF
def __init__(self) -> None:
# matrícula -> {"nome", "tipo", "numero_titular"}, populado ao
# processar toda tabela de MENSALIDADE vista nesta importação
# (pode ser mais de um arquivo/contrato) — única fonte de verdade
# pra resolver quem é quem na coparticipação (ver docstring).
self._pessoa_por_matricula: Dict[str, dict] = {}
# candidatos de coparticipação por matrícula, um dict por fonte —
# nunca somados entre si, `finaliza()` escolhe um dos dois.
self._despesas_embutidas: Dict[str, float] = {}
self._coparticipacao_extrato: Dict[str, float] = {}
self._extrato_matricula_atual: Optional[str] = None
# ------------------------------------------------------------------
# Leitura do PDF (espaçamento só correto com x_tolerance=1, ver docstring)
# ------------------------------------------------------------------
def _linhas_pdf(self, caminho_arquivo: str) -> List[str]:
linhas: List[str] = []
with pdfplumber.open(caminho_arquivo) as pdf:
for page in pdf.pages:
palavras = page.extract_words(x_tolerance=1)
por_linha: Dict[float, List[dict]] = {}
for palavra in palavras:
chave = round(palavra["top"], 1)
por_linha.setdefault(chave, []).append(palavra)
for chave in sorted(por_linha):
palavras_linha = sorted(por_linha[chave], key=lambda p: p["x0"])
linhas.append(" ".join(p["text"] for p in palavras_linha))
return linhas
# ------------------------------------------------------------------
# Extração
# ------------------------------------------------------------------
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
linhas = self._linhas_pdf(caminho_arquivo)
texto_normalizado = _normaliza_marcador(" ".join(linhas))
if _MARCADOR_EXTRATO in texto_normalizado:
self._processa_extrato_coparticipacao(linhas)
return [], []
if _MARCADOR_MENSALIDADE in texto_normalizado:
return self._processa_mensalidade(linhas), []
raise ValueError(
"Layout de PDF da Unimed Cascavel não reconhecido — não é nem o "
"relatório de mensalidade ('TOTALIZAÇÃO POR PLANO') nem o "
"extrato de coparticipação ('EXTRATO DE ATENDIMENTOS COBRADOS')."
)
def _processa_mensalidade(self, linhas: List[str]) -> List[Individuo]:
titular_atual: Optional[str] = None
modo_despesas = False
lancamentos: List[Lancamento] = []
for linha in linhas:
if _MARCADOR_DESPESAS_COBRADAS in _normaliza_marcador(linha):
modo_despesas = True
continue
if not modo_despesas:
m = _LINHA_MENSALIDADE_RE.match(linha)
if not m:
continue
matricula = m.group("matricula")
tipo = _TIPO_POR_ROTULO[m.group("tipo")]
nome = m.group("nome").strip()
valor = _valor_para_float(m.group("valor"))
if tipo == "T":
titular_atual = matricula
numero_titular = None
else:
numero_titular = titular_atual
self._pessoa_por_matricula[matricula] = {
"nome": nome, "tipo": tipo, "numero_titular": numero_titular,
}
lancamentos.append(Lancamento(
numero_beneficiario=matricula,
nome=nome,
cpf="",
tipo=tipo,
rubrica="Mensalidade",
valor=valor,
tipo_lancamento="mensalidade",
numero_titular=numero_titular,
))
else:
m = _LINHA_DESPESA_RE.match(linha)
if not m:
continue
matricula = m.group("matricula")
valor = _valor_para_float(m.group("valor"))
self._despesas_embutidas[matricula] = (
self._despesas_embutidas.get(matricula, 0.0) + valor
)
return self._agrega_por_individuo_e_tipo(lancamentos)
def _processa_extrato_coparticipacao(self, linhas: List[str]) -> None:
for linha in linhas:
m_benef = _BENEFICIARIO_RE.match(linha)
if m_benef:
self._extrato_matricula_atual = m_benef.group("matricula")
continue
m_total = _TOTAL_USUARIO_RE.match(linha)
if m_total and self._extrato_matricula_atual:
valor = _valor_para_float(m_total.group("valor"))
matricula = self._extrato_matricula_atual
self._coparticipacao_extrato[matricula] = (
self._coparticipacao_extrato.get(matricula, 0.0) + valor
)
self._extrato_matricula_atual = None
# ------------------------------------------------------------------
# Finalização — resolve a coparticipação só depois de ver todos os
# arquivos desta importação (ver docstring do módulo).
# ------------------------------------------------------------------
def finaliza(self) -> Tuple[List[Individuo], List[ItemAuditoria]]:
fonte = self._coparticipacao_extrato or self._despesas_embutidas
individuos: List[Individuo] = []
auditoria: List[ItemAuditoria] = []
for matricula, valor_total in fonte.items():
pessoa = self._pessoa_por_matricula.get(matricula)
if pessoa is None:
auditoria.append(ItemAuditoria(
motivo="NAO_CADASTRADO",
numero_beneficiario=matricula,
nome="",
cpf="",
tipo="D",
valor=valor_total,
tipo_lancamento="coparticipacao",
detalhe=(
f"Coparticipação de R$ {valor_total:.2f} para a matrícula "
f"{matricula}, sem correspondência na tabela de Mensalidade "
"desta importação — anexe também o arquivo de Mensalidade "
"que contém esse beneficiário."
),
))
continue
individuos.append(Individuo(
numero_beneficiario=matricula,
nome=pessoa["nome"],
cpf="",
tipo=pessoa["tipo"],
tipo_lancamento="coparticipacao",
valor_total=valor_total,
rubricas=[f"Coparticipação: +{valor_total:.2f}"],
numero_titular=pessoa["numero_titular"],
))
return individuos, auditoria
# ------------------------------------------------------------------
# Agregação (mesma técnica de todo outro parser deste pacote)
# ------------------------------------------------------------------
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
individuos: Dict[Tuple[str, str], Individuo] = {}
ordem = []
for lc in lancamentos:
chave = (lc.numero_beneficiario, lc.tipo_lancamento)
if chave not in individuos:
individuos[chave] = Individuo(
numero_beneficiario=lc.numero_beneficiario,
nome=lc.nome,
cpf=lc.cpf,
tipo=lc.tipo,
tipo_lancamento=lc.tipo_lancamento,
numero_titular=lc.numero_titular,
)
ordem.append(chave)
individuos[chave].valor_total += lc.valor
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
return [individuos[c] for c in ordem]

View File

@ -18,10 +18,12 @@ from portal_api.planos_saude.operadoras.amil.odonto_mensalidade import AmilOdont
from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import BradescoDentalOdontoMensalidade
from portal_api.planos_saude.operadoras.bradesco.saude import BradescoSaude from portal_api.planos_saude.operadoras.bradesco.saude import BradescoSaude
from portal_api.planos_saude.operadoras.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade from portal_api.planos_saude.operadoras.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade
from portal_api.planos_saude.operadoras.humana.saude import HumanaSaude
from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude
from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade
from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude
from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude
from portal_api.planos_saude.operadoras.unimed_cascavel.saude import UnimedCascavelSaude
from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude
from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude
@ -35,6 +37,11 @@ OPERADORAS = {
"nome": "Amil Odonto", "nome": "Amil Odonto",
"parser": AmilOdontoMensalidade, "parser": AmilOdontoMensalidade,
}, },
"amil_odonto_mensalidade_898": {
"codigo_operadora": "898",
"nome": "Amil Odonto",
"parser": AmilOdontoMensalidade,
},
"unimed_saude": { "unimed_saude": {
"codigo_operadora": "5060", "codigo_operadora": "5060",
"nome": "Unimed Saúde", "nome": "Unimed Saúde",
@ -50,6 +57,11 @@ OPERADORAS = {
"nome": "Dental Uni Odonto", "nome": "Dental Uni Odonto",
"parser": DentalUniOdontoMensalidade, "parser": DentalUniOdontoMensalidade,
}, },
"humana_saude": {
"codigo_operadora": "5064",
"nome": "Humana Saúde",
"parser": HumanaSaude,
},
"unimed_oeste_pr_saude": { "unimed_oeste_pr_saude": {
"codigo_operadora": "4709", "codigo_operadora": "4709",
"nome": "Unimed Oeste do Paraná", "nome": "Unimed Oeste do Paraná",
@ -80,6 +92,11 @@ OPERADORAS = {
"nome": "SulAmérica", "nome": "SulAmérica",
"parser": SulAmericaSaude, "parser": SulAmericaSaude,
}, },
"unimed_cascavel_saude": {
"codigo_operadora": "158",
"nome": "Unimed Cascavel",
"parser": UnimedCascavelSaude,
},
} }
@ -95,7 +112,8 @@ def label_operadora(operadora_key: str) -> str:
def lista_operadoras() -> List[Dict[str, str]]: def lista_operadoras() -> List[Dict[str, str]]:
return [{"key": chave, "label": label_operadora(chave)} for chave in OPERADORAS] chaves_ordenadas = sorted(OPERADORAS, key=lambda chave: int(OPERADORAS[chave]["codigo_operadora"]))
return [{"key": chave, "label": label_operadora(chave)} for chave in chaves_ordenadas]
@dataclass @dataclass
@ -195,6 +213,12 @@ def processa_importacao(
individuos_arquivo, auditoria_arquivo = parser_operadora.extrai(caminho) individuos_arquivo, auditoria_arquivo = parser_operadora.extrai(caminho)
individuos.extend(individuos_arquivo) individuos.extend(individuos_arquivo)
auditoria_extracao.extend(auditoria_arquivo) auditoria_extracao.extend(auditoria_arquivo)
# Chamado só depois que extrai() já rodou pra todos os arquivos —
# operadoras que precisam ver o conjunto completo antes de decidir algo
# sobrescrevem isto (ver OperadoraParser.finaliza()); a maioria não usa.
individuos_finais, auditoria_final = parser_operadora.finaliza()
individuos.extend(individuos_finais)
auditoria_extracao.extend(auditoria_final)
individuos = _agrega_individuos_entre_arquivos(individuos) individuos = _agrega_individuos_entre_arquivos(individuos)
regra_empresa_fn = None regra_empresa_fn = None

View File

@ -475,13 +475,28 @@ class FuncaoTelefoniaSerializer(serializers.ModelSerializer):
return value return value
def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percentual_bruto: str) -> dict[str, Any]: def _monta_regra_custeio(
chave: str,
modo: str | None,
limite_bruto: str,
percentual_bruto: str,
limite_desconto_empregado_bruto: str = "",
) -> dict[str, Any]:
"""Valida e monta uma única regra de custeio (uma combinação tipo de """Valida e monta uma única regra de custeio (uma combinação tipo de
lançamento × tipo de beneficiário) — reaproveitado por lançamento × tipo de beneficiário) — reaproveitado por
ImportacaoPlanoSaudeCreateSerializer (form da nova importação) e ImportacaoPlanoSaudeCreateSerializer (form da nova importação) e
RegraCusteioPlanoSaudeSerializer (banco de regras salvas), já que as duas RegraCusteioPlanoSaudeSerializer (banco de regras salvas), já que as duas
telas usam exatamente a mesma regra de negócio de custeio (ver "Regra de telas usam exatamente a mesma regra de negócio de custeio (ver "Regra de
custeio" no formulário de nova importação).""" custeio" no formulário de nova importação).
`limite_valor`/`percentual` protegem o gasto da EMPRESA (tetos de quanto
ela cobre, o excedente vira desconto do empregado) e são combináveis
entre si (vale o mais restritivo). `limite_desconto_empregado` protege o
gasto do EMPREGADO (teto de quanto é descontado dele, o restante fica
com a empresa) — direção oposta, por isso é mutuamente exclusivo com os
outros dois: misturar um teto do lado da empresa com um teto do lado do
empregado não tem uma resolução determinística única quando os dois
conflitam (ver `_calcula_valores` em matcher.py)."""
if not modo: if not modo:
raise serializers.ValidationError( raise serializers.ValidationError(
{f"custeio_{chave}": "Informe como esse tipo é custeado para titular e dependente."} {f"custeio_{chave}": "Informe como esse tipo é custeado para titular e dependente."}
@ -493,12 +508,33 @@ def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percen
limite_bruto = (limite_bruto or "").strip() limite_bruto = (limite_bruto or "").strip()
percentual_bruto = (percentual_bruto or "").strip() percentual_bruto = (percentual_bruto or "").strip()
if not limite_bruto and not percentual_bruto: limite_desconto_empregado_bruto = (limite_desconto_empregado_bruto or "").strip()
if limite_desconto_empregado_bruto and (limite_bruto or percentual_bruto):
raise serializers.ValidationError( raise serializers.ValidationError(
{f"custeio_{chave}": "Informe o limite de valor e/ou o percentual de custeio da empresa."} {
f"custeio_{chave}": (
"O limite de desconto do empregado não pode ser combinado com o limite de "
"valor/percentual custeado pela empresa."
)
}
)
if not limite_bruto and not percentual_bruto and not limite_desconto_empregado_bruto:
raise serializers.ValidationError(
{
f"custeio_{chave}": (
"Informe o limite de valor e/ou o percentual de custeio da empresa, ou o limite "
"de desconto do empregado."
)
}
) )
regra: dict[str, Any] = {"modo": "especifica", "limite_valor": None, "percentual": None} regra: dict[str, Any] = {
"modo": "especifica",
"limite_valor": None,
"percentual": None,
"limite_desconto_empregado": None,
}
if limite_bruto: if limite_bruto:
limite = parse_valor_br(limite_bruto) limite = parse_valor_br(limite_bruto)
if limite < 0: if limite < 0:
@ -509,6 +545,11 @@ def _monta_regra_custeio(chave: str, modo: str | None, limite_bruto: str, percen
if not (0 <= percentual <= 100): if not (0 <= percentual <= 100):
raise serializers.ValidationError({f"percentual_{chave}": "Informe um percentual entre 0 e 100."}) raise serializers.ValidationError({f"percentual_{chave}": "Informe um percentual entre 0 e 100."})
regra["percentual"] = percentual regra["percentual"] = percentual
if limite_desconto_empregado_bruto:
limite_desconto = parse_valor_br(limite_desconto_empregado_bruto)
if limite_desconto < 0:
raise serializers.ValidationError({f"limite_desconto_empregado_{chave}": "Informe um valor válido."})
regra["limite_desconto_empregado"] = limite_desconto
return regra return regra
@ -573,12 +614,16 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
# do formulário, igual aos outros valores monetários do pipeline. # do formulário, igual aos outros valores monetários do pipeline.
limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True) limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True) percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True) limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True) percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True) limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True) percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True) limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True) percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
limite_desconto_empregado_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()] tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()]
@ -634,6 +679,7 @@ class ImportacaoPlanoSaudeCreateSerializer(serializers.Serializer):
attrs.get(f"custeio_{chave}"), attrs.get(f"custeio_{chave}"),
attrs.get(f"limite_valor_{chave}", ""), attrs.get(f"limite_valor_{chave}", ""),
attrs.get(f"percentual_{chave}", ""), attrs.get(f"percentual_{chave}", ""),
attrs.get(f"limite_desconto_empregado_{chave}", ""),
) )
custeio_por_tipo[tipo] = custeio_por_pessoa custeio_por_tipo[tipo] = custeio_por_pessoa
@ -1097,6 +1143,7 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
entrada.get("modo"), entrada.get("modo"),
_valor_custeio_para_texto_br(entrada.get("limite_valor")), _valor_custeio_para_texto_br(entrada.get("limite_valor")),
_valor_custeio_para_texto_br(entrada.get("percentual")), _valor_custeio_para_texto_br(entrada.get("percentual")),
_valor_custeio_para_texto_br(entrada.get("limite_desconto_empregado")),
) )
custeio_validado[tipo] = custeio_por_pessoa custeio_validado[tipo] = custeio_por_pessoa

View File

@ -748,7 +748,20 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict
caminho = _salva_arquivo_temporario(arquivo, sufixo) caminho = _salva_arquivo_temporario(arquivo, sufixo)
operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key] operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
try: try:
individuos, _ = operadora_info["parser"]().extrai(caminho) parser = operadora_info["parser"]()
individuos, _ = parser.extrai(caminho)
# Algumas operadoras (ex.: Unimed Cascavel) não devolvem os dados de
# um arquivo direto em extrai() — retêm num estado interno e só
# resolvem em finaliza() (OperadoraParser.finaliza(), chamado pelo
# pipeline depois de ver TODOS os arquivos da importação real, pra
# decidir entre fontes de dado que se sobrepõem sem contar em
# dobro). Chamado aqui também, com o arquivo sozinho, só pra essa
# pré-validação enxergar que algo FOI encontrado nele — mesmo que a
# resolução completa (casamento por família) só aconteça de verdade
# quando os demais arquivos da importação também estiverem
# presentes. Não tem efeito nenhum pra quem não sobrescreve
# finaliza() (devolve sempre vazio).
individuos_finais, auditoria_final = parser.finaliza()
except Exception: except Exception:
return { return {
"valido": False, "valido": False,
@ -761,9 +774,10 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict
finally: finally:
os.remove(caminho) os.remove(caminho)
if not individuos: total_encontrado = len(individuos) + len(individuos_finais) + len(auditoria_final)
if not total_encontrado:
return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."} return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."}
return {"valido": True, "mensagem": f"{len(individuos)} lançamento(s) encontrado(s) no arquivo."} return {"valido": True, "mensagem": f"{total_encontrado} lançamento(s) encontrado(s) no arquivo."}
def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -> bytes: def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -> bytes:

View File

@ -655,6 +655,21 @@
margin-top: var(--space-2); margin-top: var(--space-2);
} }
/* Agrupa "Limite de desconto do empregado" visualmente separado dos dois
campos do lado da empresa acima (linha divisória) — são mutuamente
exclusivos (ver atualizarExclusividadeRegraEspecifica(), JS), então o
divisor reforça que são dois "modos" alternativos, não um conjunto só. */
.ips-regra-especifica__alt {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.ips-regra-especifica input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Resumo por tipo, acima de cada tabela da revisão */ /* Resumo por tipo, acima de cada tabela da revisão */
.ips-resumo { .ips-resumo {
display: flex; display: flex;
@ -717,12 +732,12 @@
min-height: 160px; min-height: 160px;
max-height: 80vh; max-height: 80vh;
max-width: 100%; max-width: 100%;
/* `resize` exige overflow != visible pra mostrar a alça de redimensionar no /* `resize:vertical` (só altura, não largura — pedido explícito do usuário)
canto inferior direito — o usuário arrasta pra ver mais linhas (altura) e exige overflow != visible pra mostrar a alça de redimensionar no canto
mais colunas de uma vez (largura), até 100% da largura de .page-content inferior direito — o usuário arrasta só pra baixo, pra ver mais linhas de
(que aqui é .page-content--wide, 1600px em vez do padrão 1200px, ver uma vez, até 80vh. Largura já rola sozinha via overflow-x (abaixo), não
layout.css). */ precisa de alça própria. */
resize: both; resize: vertical;
overflow-y: auto; overflow-y: auto;
/* .pa-table-wrap (perfis-acesso.css) tem overflow:hidden, que corta colunas /* .pa-table-wrap (perfis-acesso.css) tem overflow:hidden, que corta colunas
em vez de rolar quando a tabela é mais larga que o container — sobrescreve em vez de rolar quando a tabela é mais larga que o container — sobrescreve
@ -821,6 +836,30 @@
padding: var(--space-6); padding: var(--space-6);
} }
/* Linha de total (Valor Empresa/Valor) no rodapé de Mensalidade/Coparticipação
— mesmo fundo do cabeçalho (.ips-grid-cell--head), pra ler como um resumo
fixo, não mais uma linha de dado igual às demais. `position:sticky;
bottom:0` (pedido explícito do usuário) gruda a linha no fim da área
visível de `.ips-table-scroll` (o ancestral com overflow-y:auto/scroll) —
fica sempre à vista rolando a tabela pra cima/baixo, e continua funcionando
igual com a tabela expandida (`wireAutoExpandScroll()`, JS), já que sticky
é recalculado contra o tamanho atual do container, não um valor fixo.
z-index maior que o da coluna de ações comum (1) garante que a linha de
total fique por cima das linhas de dado ao rolar por trás dela. */
.ips-grid-row--totais .ips-grid-cell {
position: sticky;
bottom: 0;
z-index: 2;
font-weight: 700;
color: var(--text-primary);
background: var(--bg-surface-raised);
border-top: 1px solid var(--border-subtle);
}
.ips-grid-row--totais .ips-grid-cell.ips-table-actions {
background: var(--bg-surface-raised);
}
/* Coluna de ações (ícone fixo) não recebe alça de redimensionar, então fica /* Coluna de ações (ícone fixo) não recebe alça de redimensionar, então fica
de fora do `position: relative` — evita conflitar com o `position: sticky` de fora do `position: relative` — evita conflitar com o `position: sticky`
dela (ver regra mais abaixo). */ dela (ver regra mais abaixo). */

View File

@ -412,19 +412,24 @@ document.addEventListener("DOMContentLoaded", async () => {
let regracadModoNovo = false; let regracadModoNovo = false;
let regracadNovoOperadoraKey = null; let regracadNovoOperadoraKey = null;
// "1.234,56" (BR) -> 1234.56; qualquer coisa que não seja só dígitos/./,
// (nome, CPF com hífen, data dd/mm/aaaa) -> NaN. Reaproveitada por
// comparaValorRevisao() (ordenação) e por totaisLinhaHtml() (soma da linha
// de total no rodapé da tabela).
function pidValorRevisaoParaNumero(valor) {
if (valor === null || valor === undefined || valor === "") return NaN;
const texto = String(valor).trim();
if (!/^-?[\d.,]+$/.test(texto)) return NaN;
return Number(texto.replace(/\./g, "").replace(",", "."));
}
// Compara valores de uma célula da tabela de revisão pra ordenação por // Compara valores de uma célula da tabela de revisão pra ordenação por
// coluna — números no formato BR (milhar com ponto, decimal com vírgula, // coluna — números no formato BR (milhar com ponto, decimal com vírgula,
// ex.: "1.234,56") são comparados numericamente; qualquer outra coisa // ex.: "1.234,56") são comparados numericamente; qualquer outra coisa
// (nome, CPF com hífen, data dd/mm/aaaa) cai pra comparação de texto. // (nome, CPF com hífen, data dd/mm/aaaa) cai pra comparação de texto.
function comparaValorRevisao(a, b) { function comparaValorRevisao(a, b) {
const paraNumero = (valor) => { const an = pidValorRevisaoParaNumero(a);
if (valor === null || valor === undefined || valor === "") return NaN; const bn = pidValorRevisaoParaNumero(b);
const texto = String(valor).trim();
if (!/^-?[\d.,]+$/.test(texto)) return NaN;
return Number(texto.replace(/\./g, "").replace(",", "."));
};
const an = paraNumero(a);
const bn = paraNumero(b);
if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn; if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn;
return String(a || "").localeCompare(String(b || ""), "pt-BR", { sensitivity: "base" }); return String(a || "").localeCompare(String(b || ""), "pt-BR", { sensitivity: "base" });
} }
@ -877,13 +882,38 @@ document.addEventListener("DOMContentLoaded", async () => {
const regraBox = document.getElementById(`ips-regra-especifica-${chave}`); const regraBox = document.getElementById(`ips-regra-especifica-${chave}`);
const limiteInput = document.getElementById(`ips-limite-${chave}`); const limiteInput = document.getElementById(`ips-limite-${chave}`);
const percentualInput = document.getElementById(`ips-percentual-${chave}`); const percentualInput = document.getElementById(`ips-percentual-${chave}`);
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
if (regraBox) regraBox.hidden = true; if (regraBox) regraBox.hidden = true;
if (limiteInput) limiteInput.value = ""; if (limiteInput) limiteInput.value = "";
if (percentualInput) percentualInput.value = ""; if (percentualInput) percentualInput.value = "";
if (limiteDescontoInput) limiteDescontoInput.value = "";
atualizarExclusividadeRegraEspecifica(chave);
}); });
}); });
} }
// "Limite de desconto do empregado" é mutuamente exclusivo com "Limite de
// valor custeado pela empresa"/"% de custeio da empresa" dentro da mesma
// combinação tipo×pessoa (pedido explícito do usuário) — os dois lados
// protegem partes opostas do valor (teto de quanto a empresa paga vs. teto
// de quanto o empregado paga), então misturar os dois não tem um resultado
// determinístico único (ver `_calcula_valores`/`_monta_regra_custeio` no
// backend, que valida a mesma exclusividade). Preencher um lado desabilita
// visualmente o outro, refletindo no formulário a mesma regra que o
// backend aplica — não limpa o valor do lado desabilitado, só impede
// digitar mais nele enquanto o outro lado estiver preenchido.
function atualizarExclusividadeRegraEspecifica(chave) {
const limiteInput = document.getElementById(`ips-limite-${chave}`);
const percentualInput = document.getElementById(`ips-percentual-${chave}`);
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
if (!limiteInput || !percentualInput || !limiteDescontoInput) return;
const descontoPreenchido = limiteDescontoInput.value.trim() !== "";
const empresaPreenchido = limiteInput.value.trim() !== "" || percentualInput.value.trim() !== "";
limiteInput.disabled = descontoPreenchido;
percentualInput.disabled = descontoPreenchido;
limiteDescontoInput.disabled = empresaPreenchido;
}
// "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os // "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os
// inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo // inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo
// custeio_por_tipo já vem validado/parseado como float pelo backend. // custeio_por_tipo já vem validado/parseado como float pelo backend.
@ -921,6 +951,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (escolhido.value === "especifica") { if (escolhido.value === "especifica") {
entrada.limite_valor = document.getElementById(`ips-limite-${chave}`).value.trim(); entrada.limite_valor = document.getElementById(`ips-limite-${chave}`).value.trim();
entrada.percentual = document.getElementById(`ips-percentual-${chave}`).value.trim(); entrada.percentual = document.getElementById(`ips-percentual-${chave}`).value.trim();
entrada.limite_desconto_empregado = document.getElementById(`ips-limite-desconto-${chave}`).value.trim();
} }
custeio_por_tipo[tipo][pessoa] = entrada; custeio_por_tipo[tipo][pessoa] = entrada;
}); });
@ -949,7 +980,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (escolhido.value === "especifica") { if (escolhido.value === "especifica") {
const limite = document.getElementById(`ips-limite-${chave}`).value.trim(); const limite = document.getElementById(`ips-limite-${chave}`).value.trim();
const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim(); const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim();
if (!limite && !percentual) return `Informe o limite de valor e/ou o percentual de custeio para ${rotulo}.`; const limiteDesconto = document.getElementById(`ips-limite-desconto-${chave}`).value.trim();
if (!limite && !percentual && !limiteDesconto) {
return `Informe o limite de valor, o percentual de custeio ou o limite de desconto do empregado para ${rotulo}.`;
}
if (limiteDesconto && (limite || percentual)) {
return `${rotulo}: o limite de desconto do empregado não pode ser combinado com o limite de valor/percentual da empresa.`;
}
} }
} }
} }
@ -995,21 +1032,28 @@ document.addEventListener("DOMContentLoaded", async () => {
const regraBox = document.getElementById(`ips-regra-especifica-${chave}`); const regraBox = document.getElementById(`ips-regra-especifica-${chave}`);
const limiteInput = document.getElementById(`ips-limite-${chave}`); const limiteInput = document.getElementById(`ips-limite-${chave}`);
const percentualInput = document.getElementById(`ips-percentual-${chave}`); const percentualInput = document.getElementById(`ips-percentual-${chave}`);
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
document.querySelectorAll(`input[name="ips-custeio-${chave}"]`).forEach((r) => (r.checked = false)); document.querySelectorAll(`input[name="ips-custeio-${chave}"]`).forEach((r) => (r.checked = false));
if (limiteInput) limiteInput.value = ""; if (limiteInput) limiteInput.value = "";
if (percentualInput) percentualInput.value = ""; if (percentualInput) percentualInput.value = "";
if (limiteDescontoInput) limiteDescontoInput.value = "";
if (regraBox) regraBox.hidden = true; if (regraBox) regraBox.hidden = true;
const entrada = custeioPorTipo[tipo] && custeioPorTipo[tipo][pessoa]; const entrada = custeioPorTipo[tipo] && custeioPorTipo[tipo][pessoa];
if (!entrada || !entrada.modo) return; if (!entrada || !entrada.modo) {
atualizarExclusividadeRegraEspecifica(chave);
return;
}
const radio = document.querySelector(`input[name="ips-custeio-${chave}"][value="${entrada.modo}"]`); const radio = document.querySelector(`input[name="ips-custeio-${chave}"][value="${entrada.modo}"]`);
if (radio) radio.checked = true; if (radio) radio.checked = true;
if (entrada.modo === "especifica") { if (entrada.modo === "especifica") {
if (limiteInput) limiteInput.value = formatarNumeroBr(entrada.limite_valor); if (limiteInput) limiteInput.value = formatarNumeroBr(entrada.limite_valor);
if (percentualInput) percentualInput.value = formatarNumeroBr(entrada.percentual); if (percentualInput) percentualInput.value = formatarNumeroBr(entrada.percentual);
if (limiteDescontoInput) limiteDescontoInput.value = formatarNumeroBr(entrada.limite_desconto_empregado);
if (regraBox) regraBox.hidden = false; if (regraBox) regraBox.hidden = false;
} }
atualizarExclusividadeRegraEspecifica(chave);
}); });
}); });
} }
@ -1205,6 +1249,9 @@ document.addEventListener("DOMContentLoaded", async () => {
if (entrada.percentual !== null && entrada.percentual !== undefined) { if (entrada.percentual !== null && entrada.percentual !== undefined) {
partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`); partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`);
} }
if (entrada.limite_desconto_empregado !== null && entrada.limite_desconto_empregado !== undefined) {
partes.push(`desconto do empregado limitado a R$ ${formatarNumeroBr(entrada.limite_desconto_empregado)}`);
}
return `regra específica (${partes.join(", ") || "sem detalhe"})`; return `regra específica (${partes.join(", ") || "sem detalhe"})`;
} }
return entrada.modo; return entrada.modo;
@ -1294,6 +1341,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (entrada.modo === "especifica") { if (entrada.modo === "especifica") {
formData.append(`limite_valor_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_valor)); formData.append(`limite_valor_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_valor));
formData.append(`percentual_${tipo}_${pessoa}`, formatarNumeroBr(entrada.percentual)); formData.append(`percentual_${tipo}_${pessoa}`, formatarNumeroBr(entrada.percentual));
formData.append(`limite_desconto_empregado_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_desconto_empregado));
} }
}); });
}); });
@ -1806,6 +1854,57 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
// Tamanho aproximado (px) da área que o navegador reserva no canto
// inferior direito de um elemento com `resize` pra desenhar a própria alça
// de arrastar — não é um elemento do DOM (não dá pra selecionar/escutar
// direto nela), então um duplo clique é reconhecido só pela posição do
// clique dentro do container, não por um `target` específico.
const PID_IPS_RESIZE_HANDLE_HIT_PX = 20;
// Duplo clique na alça de redimensionar (canto inferior direito de
// `.ips-table-scroll`, `resize:vertical`) alterna entre expandir a altura
// pra caber TODAS as linhas de uma vez (pedido explícito do usuário) e
// voltar exatamente pra como estava antes de expandir — um segundo duplo
// clique não é "voltar ao padrão de 420px", é desfazer a expansão em cima
// do que quer que já estivesse (padrão, ou uma altura já arrastada à mão
// antes do duplo clique). `wrap.dataset.autoExpandido` marca o estado
// atual; `alturaAnterior`/`maxAlturaAnterior` guardam o inline style de
// antes de expandir (string vazia = "sem inline style", volta a valer o
// CSS de `.ips-table-scroll`), restaurado ao alternar de volta.
// `scrollHeight` já inclui o conteúdo que hoje transborda pro scroll
// interno; `max-height` (80vh, `importacao-plano-saude.css`) precisa ser
// removido enquanto expandido, senão continuaria cortando a tabela numa
// importação com muitas linhas. Como o redimensionamento manual por
// arrasto, todo esse estado é só inline/local: `renderPanels()` recria os
// elementos do zero (qualquer edição de célula, adicionar/remover linha
// etc.), então volta a nascer não-expandido.
function wireAutoExpandScroll(root) {
root.querySelectorAll(".ips-table-scroll").forEach((wrap) => {
wrap.addEventListener("dblclick", (event) => {
const rect = wrap.getBoundingClientRect();
const noCantoDaAlca =
event.clientX >= rect.right - PID_IPS_RESIZE_HANDLE_HIT_PX &&
event.clientY >= rect.bottom - PID_IPS_RESIZE_HANDLE_HIT_PX;
if (!noCantoDaAlca) return;
if (wrap.dataset.autoExpandido === "true") {
wrap.style.height = wrap.dataset.alturaAnterior || "";
wrap.style.maxHeight = wrap.dataset.maxAlturaAnterior || "";
delete wrap.dataset.autoExpandido;
delete wrap.dataset.alturaAnterior;
delete wrap.dataset.maxAlturaAnterior;
return;
}
wrap.dataset.alturaAnterior = wrap.style.height;
wrap.dataset.maxAlturaAnterior = wrap.style.maxHeight;
wrap.style.maxHeight = "none";
wrap.style.height = `${wrap.scrollHeight}px`;
wrap.dataset.autoExpandido = "true";
});
});
}
// Colunas sempre editáveis, em qualquer linha — o resto do cadastro // Colunas sempre editáveis, em qualquer linha — o resto do cadastro
// (nome, CPF, código, data...) já vem certo do casamento automático (ou // (nome, CPF, código, data...) já vem certo do casamento automático (ou
// da busca no Questor) e só devia ser tocado numa linha incluída à mão // da busca no Questor) e só devia ser tocado numa linha incluída à mão
@ -1846,6 +1945,29 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>` </div>`
: ""; : "";
// Linha de total no rodapé da tabela (pedido explícito do usuário) — soma
// Valor Empresa/Valor de TODAS as linhas do tipo (`linhas`, já filtrada
// por tipo_lancamento acima), inclusive as ainda em auditoria (valor "0",
// não altera a soma). Ordenação da tabela não afeta o total, é sempre a
// soma do conjunto inteiro, não só das linhas visíveis num scroll.
const totalValorEmpresa = linhas.reduce((soma, l) => soma + (pidValorRevisaoParaNumero(l.valor_empresa) || 0), 0);
const totalValor = linhas.reduce((soma, l) => soma + (pidValorRevisaoParaNumero(l.valor) || 0), 0);
const totaisHtml = linhas.length
? `<div class="ips-grid-row ips-grid-row--totais" role="row">
<div class="ips-grid-cell ips-table-actions" role="cell"></div>
${campos
.map(([campo]) => {
if (campo === "nome_func") return `<div class="ips-grid-cell" role="cell">Total</div>`;
if (campo === "valor_empresa" || campo === "valor") {
const total = campo === "valor_empresa" ? totalValorEmpresa : totalValor;
return `<div class="ips-grid-cell" role="cell">${formatarNumeroBr(total)}</div>`;
}
return `<div class="ips-grid-cell" role="cell"></div>`;
})
.join("")}
</div>`
: "";
const linhasHtml = linhas const linhasHtml = linhas
.map((linha) => { .map((linha) => {
const editavelLivre = !concluida && incluidasManualmente.has(linha.id); const editavelLivre = !concluida && incluidasManualmente.has(linha.id);
@ -1884,6 +2006,7 @@ document.addEventListener("DOMContentLoaded", async () => {
linhasHtml || linhasHtml ||
`<div class="ips-grid-row pa-empty-row" role="row"><div class="ips-grid-cell" role="cell" style="grid-column:1 / -1">Nenhuma linha.</div></div>` `<div class="ips-grid-row pa-empty-row" role="row"><div class="ips-grid-cell" role="cell" style="grid-column:1 / -1">Nenhuma linha.</div></div>`
} }
${totaisHtml}
</div> </div>
</div> </div>
${concluida ? "" : ` ${concluida ? "" : `
@ -2032,6 +2155,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}) })
.join(""); .join("");
wireColumnResize(panelsEl); wireColumnResize(panelsEl);
wireAutoExpandScroll(panelsEl);
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -2347,6 +2471,20 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
}); });
// Duplo custeio da "Regra específica" (limite/percentual do lado da
// empresa x limite de desconto do lado do empregado) é mutuamente
// exclusivo — ver atualizarExclusividadeRegraEspecifica(). Reavalia a cada
// tecla digitada nos três campos de cada combinação tipo×pessoa.
PID_IPS_TIPOS.forEach((tipo) => {
PID_IPS_PESSOAS.forEach((pessoa) => {
const chave = `${tipo}-${pessoa}`;
[`ips-limite-${chave}`, `ips-percentual-${chave}`, `ips-limite-desconto-${chave}`].forEach((id) => {
const input = document.getElementById(id);
if (input) input.addEventListener("input", () => atualizarExclusividadeRegraEspecifica(chave));
});
});
});
if (tabsEl) { if (tabsEl) {
tabsEl.addEventListener("click", (event) => { tabsEl.addEventListener("click", (event) => {
const btn = event.target.closest("[data-ips-tab]"); const btn = event.target.closest("[data-ips-tab]");

View File

@ -445,7 +445,7 @@
</div> </div>
<div class="ips-upload-box"> <div class="ips-upload-box">
<p class="ips-upload-box__title">2. Arquivos da operadora</p> <p class="ips-upload-box__title">2. Arquivos da operadora</p>
<p class="ips-upload-box__hint">Relatório de faturamento enviado pela operadora do plano (PDF ou CSV), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados, o tipo de cada um é identificado automaticamente.</p> <p class="ips-upload-box__hint">Relatório de faturamento enviado pela operadora do plano (PDF, CSV ou XLSX), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados, o tipo de cada um é identificado automaticamente.</p>
<div class="ips-file-field"> <div class="ips-file-field">
<label class="btn-outline ips-file-field__btn" for="ips-form-arquivo"> <label class="btn-outline ips-file-field__btn" for="ips-form-arquivo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
@ -513,7 +513,7 @@
<div class="modal-overlay" id="ips-regra-empresa-modal" hidden> <div class="modal-overlay" id="ips-regra-empresa-modal" hidden>
<div class="modal-card"> <div class="modal-card">
<h2 class="modal-card__title">Selecionar regra empresa</h2> <h2 class="modal-card__title">Selecionar regra empresa</h2>
<p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Inovação, por família — usadas aqui como o modo "mensalidade" de uma regra de custeio.</p> <p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Inovação — cada uma substitui o custeio manual só do(s) tipo(s) de lançamento que ela cobre (mensalidade e/ou coparticipação, varia por regra); os demais tipos selecionados continuam usando o custeio configurado manualmente abaixo.</p>
<div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div> <div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div>
<p class="modal-error" id="ips-regra-empresa-error"></p> <p class="modal-error" id="ips-regra-empresa-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@ -614,6 +614,13 @@
<input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div>
</div> </div>
</div> </div>
<div class="ips-custeio-pessoa"> <div class="ips-custeio-pessoa">
@ -631,6 +638,13 @@
<input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -656,6 +670,13 @@
<input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div>
</div> </div>
</div> </div>
<div class="ips-custeio-pessoa"> <div class="ips-custeio-pessoa">
@ -673,6 +694,13 @@
<input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt">
<div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" autocomplete="off" />
</div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div>
</div> </div>
</div> </div>
</div> </div>