From 04172a9945b8b4b7a01f1566dcdaf4b838c24377 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 08:55:01 -0300 Subject: [PATCH 1/8] =?UTF-8?q?Corre=C3=A7=C3=A3o=20operadora=20Odonto=20U?= =?UTF-8?q?ni?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../importacao-questor-plano-saude/SKILL.md | 2 +- .gitignore | 10 ++ portal_api/planos_saude/CHANGELOG.md | 8 + .../dental_uni/odonto_mensalidade.py | 142 +++++++++++++----- 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/.claude/skills/importacao-questor-plano-saude/SKILL.md b/.claude/skills/importacao-questor-plano-saude/SKILL.md index f343545..c310448 100644 --- a/.claude/skills/importacao-questor-plano-saude/SKILL.md +++ b/.claude/skills/importacao-questor-plano-saude/SKILL.md @@ -41,7 +41,7 @@ A ferramenta deixou de ser "a automação da TECNOMYL", hoje atende várias empr |---|---|---|---| | `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" | +| `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** | diff --git a/.gitignore b/.gitignore index 4d793fd..e85e880 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,16 @@ db.sqlite3-journal *.log 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 build/ dist/ diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index f04a12f..06bbea7 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -251,6 +251,14 @@ 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. +### 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. + +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). + ### 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/operadoras/dental_uni/odonto_mensalidade.py b/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py index 282bc1e..ce8e6a3 100644 --- a/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py +++ b/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py @@ -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 menos de algum beneficiário vizinho antes de assumir que é uma divergência 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: " [Exclusão] +[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 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.operadoras.base import OperadoraParser -_LINHA_RE = re.compile( +_LINHA_COLCHETE_RE = re.compile( r'^(?P\s*)(?P[^\[\]]*?)\s*\[(?P\d+)\]\s*(?P.*)$' ) +# Segundo layout (sem colchete no Nº Cartão, ver docstring do módulo) — +# " ". +_LINHA_SEM_COLCHETE_RE = re.compile( + r"^(?P\s*)\d+\s+(?P[A-ZÀ-Ý][A-ZÀ-Ý '.-]*?)\s+(?P\d{10,})\s+(?P.*)$" +) # 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 # outro texto do PDF (cabeçalho/rodapé/totais), que nunca vem 100% maiúsculo. @@ -98,49 +127,82 @@ class DentalUniOdontoMensalidade(OperadoraParser): titular_cartao_atual: Optional[str] = None for linha in linhas: - m = _LINHA_RE.match(linha) - if not 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}" + m = _LINHA_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 not valores: + # Linha com colchetes mas sem nome+valor de beneficiário + # de verdade (ex: "Cliente: (...) CNPJ: [19210328000160]"). + continue + + indent = len(m.group("indent")) + if indent_base is None: + # A 1ª linha de beneficiário do arquivo é sempre um titular. + indent_base = indent + + if indent <= indent_base: + 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[0]), + tipo_lancamento="mensalidade", + numero_titular=numero_titular, + )) continue - nome = m.group("nome_parcial").strip() - cartao = m.group("cartao") - valores = _VALOR_RE.findall(m.group("resto")) - if not nome or not valores: - # Linha com colchetes mas sem nome+valor de beneficiário de - # verdade (ex: "Cliente: (...) CNPJ: [19210328000160]"). + 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 - indent = len(m.group("indent")) - if indent_base is None: - # A 1ª linha de beneficiário do arquivo é sempre um titular. - indent_base = indent - - if indent <= indent_base: - 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[0]), - tipo_lancamento="mensalidade", - numero_titular=numero_titular, - )) + # 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 def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]: From f4629052b70494a504af4901db68e36f1644b3d0 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 09:54:24 -0300 Subject: [PATCH 2/8] =?UTF-8?q?Reestrutura=C3=A7=C3=A3o=20das=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../importacao-questor-plano-saude/SKILL.md | 129 ++++++----- portal_api/planos_saude/CHANGELOG.md | 9 + portal_api/planos_saude/CLAUDE.md | 5 +- .../operadoras/humana/__init__.py | 0 .../planos_saude/operadoras/humana/saude.py | 209 ++++++++++++++++++ portal_api/planos_saude/pipeline.py | 9 +- 6 files changed, 300 insertions(+), 61 deletions(-) create mode 100644 portal_api/planos_saude/operadoras/humana/__init__.py create mode 100644 portal_api/planos_saude/operadoras/humana/saude.py 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 From b1bfd90521446cd7b8067e41af21d5418f5a3fea Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 10:39:27 -0300 Subject: [PATCH 3/8] =?UTF-8?q?Inclus=C3=A3o=20das=20operadoras=20Unimed?= =?UTF-8?q?=20Cascavel=20e=20Humana=20Saude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/importacao-plano-saude/SKILL.md | 104 ++++++ .../importacao-questor-plano-saude/SKILL.md | 100 +----- portal_api/planos_saude/CHANGELOG.md | 29 +- portal_api/planos_saude/CLAUDE.md | 16 +- portal_api/planos_saude/README.md | 4 +- portal_api/planos_saude/operadoras/base.py | 15 + .../operadoras/unimed_cascavel/__init__.py | 0 .../operadoras/unimed_cascavel/saude.py | 314 ++++++++++++++++++ portal_api/planos_saude/pipeline.py | 12 + portal_api/views.py | 20 +- templates/importacao-plano-saude.html | 2 +- 11 files changed, 516 insertions(+), 100 deletions(-) create mode 100644 .claude/skills/importacao-plano-saude/SKILL.md create mode 100644 portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py create mode 100644 portal_api/planos_saude/operadoras/unimed_cascavel/saude.py diff --git a/.claude/skills/importacao-plano-saude/SKILL.md b/.claude/skills/importacao-plano-saude/SKILL.md new file mode 100644 index 0000000..b24e1dd --- /dev/null +++ b/.claude/skills/importacao-plano-saude/SKILL.md @@ -0,0 +1,104 @@ +--- +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. + +**Qual sistema contábil é o destino desta importação?** A ferramenta hoje só gera saída pro **Questor** — se for esse o caso (ou se não tiver sido dito o contrário), ler também a skill `importacao-questor-plano-saude` antes de tocar em Cadastro de Regras, planilha padrão, leiaute ou geração do CSV/ZIP final — isso é escopo dela, não deste arquivo. 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 4 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: 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` (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` | +| `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. + +**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//.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. diff --git a/.claude/skills/importacao-questor-plano-saude/SKILL.md b/.claude/skills/importacao-questor-plano-saude/SKILL.md index 7717483..04b0b74 100644 --- a/.claude/skills/importacao-questor-plano-saude/SKILL.md +++ b/.claude/skills/importacao-questor-plano-saude/SKILL.md @@ -1,38 +1,17 @@ --- 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) — 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. +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: manutençã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 é) -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. +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. Operadoras e empresas já parametrizadas hoje (fotografia do banco em 25/08/2026) +**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). -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). - -### 1.1 Os 10 parsers de operadora existentes: 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` (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. - -### 1.2 Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui) +## 1. Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui) 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: @@ -42,65 +21,17 @@ RegraCusteioPlanoSaude.objects.order_by("codigo_empresa").values_list("codigo_em **Ú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. -## 2. O que a ferramenta cobre +## 2. Cadastro de Regras — por que vive aqui, não na skill geral -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. +**"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. -Mecanismos que substituem uma decisão manual repetida por uma configuração reaproveitável: +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`). -- **"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. +## 3. Gap conhecido: sem auditoria automática pós-importação -## 3. Gaps conhecidos do pipeline genérico (por operadora) +**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. -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. - -## 5. Auditoria pós-importação contra o relatório do Questor (ainda manual) +## 4. 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: @@ -110,12 +41,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. 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. 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" (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 e0f6d7a..b92054c 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -257,8 +257,6 @@ Usuário reportou (empresa 503, "TAROBA CONSTRUCOES LTDA", dois arquivos "503"/" 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. -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). @@ -271,3 +269,30 @@ Usuário forneceu o modelo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI - EPP) ### 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. + +### 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). diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index afd3ba7..852d681 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -1,6 +1,6 @@ # 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). @@ -24,11 +24,14 @@ portal_api/planos_saude/ ├── 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) - └── 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) + ├── 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//.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. **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. @@ -43,6 +46,13 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme **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. +**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}`). 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`) @@ -148,7 +158,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). -- **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 `` 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 ``, 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 `` 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. diff --git a/portal_api/planos_saude/README.md b/portal_api/planos_saude/README.md index d6f3b33..f2d6026 100644 --- a/portal_api/planos_saude/README.md +++ b/portal_api/planos_saude/README.md @@ -1,8 +1,8 @@ # 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 diff --git a/portal_api/planos_saude/operadoras/base.py b/portal_api/planos_saude/operadoras/base.py index 86f2614..a478adc 100644 --- a/portal_api/planos_saude/operadoras/base.py +++ b/portal_api/planos_saude/operadoras/base.py @@ -54,3 +54,18 @@ class OperadoraParser(ABC): a operadora nunca usou antes e não sabemos classificar). """ 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 [], [] diff --git a/portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py b/portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py b/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py new file mode 100644 index 0000000..990f97e --- /dev/null +++ b/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py @@ -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: - ..." seguido de um ou mais "Conta"/"Item" e fechado por "Total + do usuário: "). 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\d+)\s+(?P.+?)\s+(?P\d{3,6})\s+" + r"(?PTitular|Dependente|Agregado)\s+\d{2}/\d{2}/\d{4}\s+\d+\s+" + r"\d{2}/\d{2}/\d{4}\s+(?P[\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\d+)\s+.+?\s+\d{9}\s+\d{2}/\d{2}/\d{4}\s+\S+\s+.+?\s+" + r"(?P[\d.,]+)\s*$" +) +_BENEFICIARIO_RE = re.compile( + r"^Benefici\S*rio:\s*(?P\d+)\s*-\s*.+?\s+Idade:", re.IGNORECASE +) +_TOTAL_USUARIO_RE = re.compile(r"^Total do usu\S*rio:\s*(?P[\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] diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index f9007fc..9ebb7d6 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -23,6 +23,7 @@ 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 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_vitoria.saude import UnimedVitoriaSaude @@ -86,6 +87,11 @@ OPERADORAS = { "nome": "SulAmérica", "parser": SulAmericaSaude, }, + "unimed_cascavel_saude": { + "codigo_operadora": "158", + "nome": "Unimed Cascavel", + "parser": UnimedCascavelSaude, + }, } @@ -202,6 +208,12 @@ def processa_importacao( individuos_arquivo, auditoria_arquivo = parser_operadora.extrai(caminho) individuos.extend(individuos_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) regra_empresa_fn = None diff --git a/portal_api/views.py b/portal_api/views.py index 2daa222..449e900 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -731,7 +731,20 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict caminho = _salva_arquivo_temporario(arquivo, sufixo) operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key] 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: return { "valido": False, @@ -744,9 +757,10 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict finally: 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": 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: diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index c9e36f5..9e16b26 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -431,7 +431,7 @@

2. Arquivos da operadora

-

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.

+

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.

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.

+
+ +

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

+
@@ -617,6 +624,13 @@

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.

+
+ +

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

+
@@ -642,6 +656,13 @@

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.

+
+ +

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

+
@@ -659,6 +680,13 @@

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.

+
+ +

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

+
From a14d3251f166a93be8fe16264b344188be238906 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 28 Aug 2026 09:43:07 -0300 Subject: [PATCH 8/8] =?UTF-8?q?Inclus=C3=A3o=20do=20cadastro=20alternativo?= =?UTF-8?q?=20da=20operadora=20Amil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 3 ++- .claude/skills/importacao-plano-saude/SKILL.md | 7 ++++--- portal_api/planos_saude/CHANGELOG.md | 10 +++++++++- portal_api/planos_saude/CLAUDE.md | 6 ++++-- portal_api/planos_saude/pipeline.py | 5 +++++ 5 files changed, 24 insertions(+), 7 deletions(-) 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",