diff --git a/.claude/skills/importacao-questor-plano-saude/SKILL.md b/.claude/skills/importacao-questor-plano-saude/SKILL.md index c310448..7717483 100644 --- a/.claude/skills/importacao-questor-plano-saude/SKILL.md +++ b/.claude/skills/importacao-questor-plano-saude/SKILL.md @@ -1,41 +1,19 @@ --- 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 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) — atende várias empresas/operadoras reais, com 10 parsers de operadora já implementados. Documenta quais operadoras já estão validadas com dado real (e quais gaps conhecidos existem por operadora), como consultar ao vivo quais empresas têm regra de custeio cadastrada, 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. --- -# Importação de Plano de Saúde: origem, migração e estado atual +# Importação de Plano de Saúde: manutenção e estado atual ## 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 **o processo de negócio** por trás da ferramenta. É 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 pra lista de gaps ainda não confirmados como equivalentes ao processo manual que a ferramenta substitui. -## 1. Origem: processo manual mensal da TECNOMYL +## 1. Operadoras e empresas já parametrizadas hoje (fotografia do banco em 25/08/2026) -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: +A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamento especial no código — toda empresa passa pelo mesmo pipeline genérico, configurada via "Cadastro de Regras" (seção 1.2). 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). -- Extração AMIL (PDF ou XLSX) por CPF, tipos T/D/A (agregado sempre 100% descontado do empregado, nunca custeado pela empresa). -- 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). - -## 2. O que foi migrado para o Portal (`portal_api/planos_saude/`) - -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. - -Confirmado hoje (rodada em que este documento foi atualizado) que já refletem regras específicas validadas com a TECNOMYL: - -- **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`): 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) - -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.1 Os 9 parsers de operadora existentes: uso real até agora +### 1.1 Os 10 parsers de operadora existentes: uso real até agora | Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? | |---|---|---|---| @@ -48,53 +26,81 @@ A ferramenta deixou de ser "a automação da TECNOMYL", hoje atende várias empr | `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 | +| `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` | **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) +### 1.2 Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui) -| 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 | +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: -**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). +```python +RegraCusteioPlanoSaude.objects.order_by("codigo_empresa").values_list("codigo_empresa", "nome") +``` -## 4. Pontos NÃO confirmados como equivalentes (verificar antes de confiar na tela pra TECNOMYL) +**Ú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. -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): +## 2. O que a ferramenta cobre -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. +A ferramenta é genérica multi-empresa/multi-operadora — qualquer empresa cadastrada em "Cadastro de Regras" (seção 1.2) passa pelo mesmo pipeline. 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. -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. +Mecanismos que substituem uma decisão manual repetida por uma configuração reaproveitável: -## 5. Como adicionar/ajustar um parser de operadora +- **"Cadastro de Regras" por empresa+operadora** (`RegraCusteioPlanoSaude`): decidir "quem paga o quê" uma vez, reaproveitar todo mês, em vez de decidir de novo a cada competência. +- **"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. +- **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. -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: +## 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 6) — são gaps de **operadora/leiaute**, 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 no modelo do Questor:** 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 CSV até alguém resolver), mas pode não ser o resultado esperado pelo cliente. +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 5 pra reproduzir esse método manualmente, se for pedido. +4. **Sistema Contabit** (formato alternativo de rubricas 338/200/201, usado por processos manuais antigos): 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 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 Questor (`codigo_operadora`) — sem isso não dá pra registrar em `pipeline.OPERADORAS` nem montar o label de exibição. 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 (Questor) 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. +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. +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//.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. + +### 4.2 Regras de negócio que nunca devem ser reinterpretadas + +Válidas pra qualquer operadora nova, sem exceção: - 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) +## 5. 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: @@ -105,6 +111,11 @@ Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no 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. -## 7. Onde está o histórico real de competências já processadas +## 6. Histórico (contexto, não necessário no dia a dia) -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. +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" (seção 1.2), 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. diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index 06bbea7..e0f6d7a 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -259,6 +259,15 @@ Corrigido acrescentando um segundo regex de linha (`_LINHA_SEM_COLCHETE_RE`, ten 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). +### 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) 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. diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index 2ab279f..afd3ba7 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -23,7 +23,8 @@ portal_api/planos_saude/ ├── amil/odonto_mensalidade.py Amil Odonto — 898 (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) + ├── 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) ``` Pra adicionar uma operadora nova: criar `operadoras//.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. @@ -40,6 +41,8 @@ Pra adicionar uma operadora nova: criar `operadoras//.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 - ") 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. +**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 (`..`, 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. + **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`. ## Modelos (`portal_api/models.py`) diff --git a/portal_api/planos_saude/operadoras/humana/__init__.py b/portal_api/planos_saude/operadoras/humana/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal_api/planos_saude/operadoras/humana/saude.py b/portal_api/planos_saude/operadoras/humana/saude.py new file mode 100644 index 0000000..621da55 --- /dev/null +++ b/portal_api/planos_saude/operadoras/humana/saude.py @@ -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\d+\.\d+\.\d+)\s+(?P.+?)\s+\d+\s+' + r'(?PTitular|Dependente|Agregado)\s+\d{2}/\d{2}/\d{4}\s+' + r'\d+\s+\d{2}/\d{2}/\d{4}\s+(?P[\d.,]+)\s*$' +) +# "Usuário" (truncado, sem separador confiável) + "Conta" (6+ dígitos) + "Atendimento" (data). +_DESPESA_RESTO_RE = re.compile(r'^(?P.+?)\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 diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index e3cfa6d..f9007fc 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -18,6 +18,7 @@ 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.saude import BradescoSaude 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.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude @@ -50,6 +51,11 @@ OPERADORAS = { "nome": "Dental Uni Odonto", "parser": DentalUniOdontoMensalidade, }, + "humana_saude": { + "codigo_operadora": "5064", + "nome": "Humana Saúde", + "parser": HumanaSaude, + }, "unimed_oeste_pr_saude": { "codigo_operadora": "4709", "nome": "Unimed Oeste do Paraná", @@ -95,7 +101,8 @@ def label_operadora(operadora_key: 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