diff --git a/.claude/settings.json b/.claude/settings.json index 645378c..cdbff6f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -18,7 +18,8 @@ "Bash(\"./.venv/Scripts/python.exe\" manage.py migrate portal_api)", "Bash(\"./.venv/Scripts/python.exe\" manage.py check)", "Bash(PYTHONIOENCODING=utf-8 ./.venv/Scripts/python.exe -c ' *)", - "Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)" + "Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)", + "Edit(/.claude/skills/importacao-plano-saude/**)" ] } } diff --git a/.claude/skills/importacao-plano-saude/SKILL.md b/.claude/skills/importacao-plano-saude/SKILL.md index ef2a64e..e51c9d0 100644 --- a/.claude/skills/importacao-plano-saude/SKILL.md +++ b/.claude/skills/importacao-plano-saude/SKILL.md @@ -15,7 +15,7 @@ Este SKILL.md documenta a etapa **comum a qualquer sistema contábil de destino* 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: uso real até agora +### 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? | |---|---|---|---| @@ -27,13 +27,14 @@ A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamen | `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 | +| `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 (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. +**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). diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index cfbd7c8..b45eb60 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -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. - 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 @@ -312,3 +312,11 @@ Usuário pediu uma terceira modalidade dentro de "Regra específica" (Cadastro d - **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__` (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. diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index 0a0e064..8b072fc 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -20,7 +20,7 @@ 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) ├── 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) - ├── 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) ├── 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) @@ -36,7 +36,9 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme **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." (`/00` = titular, `/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 "..-" = 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. diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index 9ebb7d6..061b48e 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -37,6 +37,11 @@ OPERADORAS = { "nome": "Amil Odonto", "parser": AmilOdontoMensalidade, }, + "amil_odonto_mensalidade_898": { + "codigo_operadora": "898", + "nome": "Amil Odonto", + "parser": AmilOdontoMensalidade, + }, "unimed_saude": { "codigo_operadora": "5060", "nome": "Unimed Saúde",