Inclusão do Plano de Saude - MetLife

This commit is contained in:
Gabriel 2026-09-02 15:24:55 -03:00
parent 429a64d4a5
commit 4c622f7013
8 changed files with 123664 additions and 3 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: importacao-plano-saude 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. 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 (12 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 # Importação de Plano de Saúde: leitura, extração e regras de negócio
@ -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. A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamento especial no código de extração — toda operadora passa pelo mesmo `OperadoraParser`/`pipeline.processa_importacao` genérico. **É uma fotografia, não um fato permanente**: cresce todo mês, reconsultar `ImportacaoPlanoSaude` (`python manage.py shell`) antes de confiar nela pra uma decisão importante.
### 1.1 Os 11 parsers de operadora existentes (12 cadastros — Amil Odonto tem dois): uso real até agora ### 1.1 Os 12 parsers de operadora existentes (13 cadastros — Amil Odonto tem dois): uso real até agora
| Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? | | Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? |
|---|---|---|---| |---|---|---|---|
@ -31,6 +31,7 @@ A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamen
| `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) | | `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` | | `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` | | `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` |
| `metlife_odonto_mensalidade` (3761) | CPF | 0 até 02/09/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, construído via o checklist da seção 4.1. Só mensalidade, regra de custeio padrão (sem "Regra empresa"), confirmado com o usuário. Extração por posição de coluna (`extract_words()` bucketizado por x0), não regex de linha — nome sem largura fixa. **CPF sai sem zero(s) à esquerda quando o valor real começa com 0** (corrigido com `.zfill(11)` na extração — conferir se aparecer de novo numa operadora futura com CPF aparentemente "curto"). Validado rodando `extrai()` contra o arquivo real: 8 beneficiários, R$ 120,00, batendo com o total impresso — ainda não rodado contra uma planilha padrão real (nenhuma empresa cadastrada nesta operadora até agora) — ver `operadoras/metlife/odonto_mensalidade.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. **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.

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,8 @@ portal_api/planos_saude/
├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo) ├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo)
├── humana/saude.py Humana Saúde — 5064 (PDF via pdfplumber, mensalidade+coparticipação no mesmo arquivo, casamento por nome — coparticipação nunca casada automaticamente, ver nota abaixo) ├── 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()`) ├── 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()`)
└── metlife/odonto_mensalidade.py MetLife Odonto — 3761 (PDF via pdfplumber, só mensalidade, casamento por CPF, extração por posição de coluna em vez de regex de linha, ver nota abaixo)
``` ```
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
@ -55,6 +56,13 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
- **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). - **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. - 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.
**MetLife Odonto (3761)** — relatório "Detalhamento de Mensalidade" (NF-e da própria MetLife), só mensalidade, casamento por CPF. Confirmado com o usuário: segue a regra padrão de custeio (mensalidade separada por titular/dependente), sem "Regra empresa". Diferente dos demais parsers deste pacote, a extração é feita **bucketizando `extract_words()` por posição de coluna** (x0), não por regex de linha inteira — necessário porque o Nome não tem largura fixa nem separador (ex.: "MICHELE REGINA DA SILVA EUGENIO"), então um regex de `.+?` até a próxima coluna arriscaria errar a fronteira; a mesma técnica generaliza de graça pra colunas opcionais (Parentesco/Nº Funcional, vazias em boa parte das linhas) sem precisar de grupos regex opcionais complicados.
- **Titular/dependente é decidido pela coluna Parentesco estar vazia ou não** (nunca pelo sufixo do código de beneficiário, ex. `.00`/`.01`) — decisão tomada depois de um caso real já no próprio arquivo-modelo: um dependente (mãe de um titular) veio com o código malformado **no próprio PDF de origem** (`50696900.000100.01`, 8 dígitos na primeira parte em vez dos 6 esperados — confirmado inspecionando `page.extract_words()` que a string malformada já existe no PDF, não é artefato de extração; a "Adesão" desse dependente era bem mais recente que o resto da família, sugerindo inclusão tardia com erro do sistema da própria operadora). A coluna Parentesco continuou confiável nesse caso, então a classificação não foi afetada; `numero_beneficiario` guarda o código bruto tal como veio (mesmo malformado), só como identificador de exibição, nunca como chave de casamento.
- **CPF sai sem zero(s) à esquerda quando o valor real da pessoa começa com 0** — confirmado comparando com o formato real de `CPFFUNC`/`CPFDEPENDENTE` na planilha padrão do Questor (sempre 11 dígitos, `XXX.XXX.XXX-XX`): os CPFs deste relatório MetLife saem com 9 a 11 dígitos (ex.: `"795136978"`, 9 dígitos), típico de um campo numérico que perdeu o(s) zero(s) à esquerda ao ser gerado a partir de planilha. Sem corrigir, o casamento por CPF falharia silenciosamente ("CPF não encontrado") pra toda pessoa cujo CPF real começa com zero — corrigido com `.zfill(11)` na extração (`operadoras/metlife/odonto_mensalidade.py`), seguro porque CPF brasileiro sempre tem 11 dígitos.
- Validado rodando `extrai()` de ponta a ponta contra o arquivo real (competência 09/2026, empresa "OESTEFOZ NEW CORRETORA DE SEGUROS LTDA", 3 famílias): 8 beneficiários (3 titulares + 5 dependentes), R$ 120,00 no total — bate exatamente com "TOTAL DE MENSALIDADES: 8 itens 120,00" impresso no próprio relatório. Ainda não rodado contra a planilha padrão real de nenhuma empresa (nenhuma `RegraCusteioPlanoSaude` cadastrada pra esta operadora até o momento).
- **Não confirmado ainda**: o arquivo-modelo só tinha nomes/planos curtos, cabendo numa única linha física — nome ou plano que ultrapasse a largura da coluna e quebre em duas linhas físicas ainda não foi visto; reconferir se aparecer numa competência real.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None, "limite_desconto_empregado": float|None}`). **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None, "limite_desconto_empregado": float|None}`).
Dentro da regra específica, dois grupos de critério, **mutuamente exclusivos** entre si (validado em `_monta_regra_custeio`, `serializers.py`, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — `atualizarExclusividadeRegraEspecifica()`, `importacao-plano-saude.js`): Dentro da regra específica, dois grupos de critério, **mutuamente exclusivos** entre si (validado em `_monta_regra_custeio`, `serializers.py`, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — `atualizarExclusividadeRegraEspecifica()`, `importacao-plano-saude.js`):

View File

@ -0,0 +1,175 @@
"""
MetLife Planos Odontológicos (código de operadora 3761) - Odonto, só
mensalidade. Casamento por CPF (confirmado com o usuário: regra padrão de
custeio, sem "Regra empresa").
Relatório "Detalhamento de Mensalidade" (NF-e da própria MetLife, uma linha
por beneficiário dentro de cada grupo familiar) — sem coparticipação
(nenhuma tabela de serviço/atendimento no arquivo, só a cobrança fixa
periódica por pessoa).
Estrutura confirmada via `page.extract_words()` (posição x0/top) contra o
arquivo real (competência 09/2026, empresa "OESTEFOZ NEW CORRETORA DE
SEGUROS LTDA", 3 famílias, 8 beneficiários, R$ 120,00):
1. Colunas do relatório (x0 aproximado, usado pra bucketizar cada palavra
na coluna certa em vez de tentar um regex de linha inteira): Código
(~31), Nº Funcional (~113, **sempre vazio no arquivo-modelo** — nenhum
beneficiário testado até hoje tinha algo nessa coluna; se aparecer
preenchida numa empresa futura, o bucket `funcional` já existe e
simplesmente não é usado, então não quebra nada, só fica sem efeito),
Nome (~169), Data de nasc. (~329), Parentesco (~381, **só preenchido
pro dependente** — é o campo usado pra decidir titular/dependente, não
o sufixo do código, ver item 3), CPF (~485), Adesão (~545), Plano
(~597), Valor (~750+). Abordagem por posição (`_coluna_para_x0`), não
regex de linha inteira, porque o Nome pode ter qualquer número de
palavras (ex.: "MICHELE REGINA DA SILVA EUGENIO") sem nenhum separador
que distinga onde ele termina — só a posição x0 resolve isso de forma
confiável, mesma técnica de `operadoras/sulamerica/odonto_mensalidade.py`
e `operadoras/unimed_vitoria/saude.py`.
2. Ao final de cada família, uma linha "Total do grupo familiar: R$X" (sem
nenhum código de beneficiário) — como as outras operadoras deste
pacote, **nunca usada**: o lançamento é sempre por linha de
beneficiário, nunca pelo subtotal impresso.
3. **Titular vs. dependente é decidido pela coluna Parentesco estar vazia
ou não** (não pelo sufixo do código, ex. ".00"/".01") — decisão
deliberada depois de um caso real no próprio arquivo-modelo: uma
dependente ("SONIA REGINA ROCHA", MÃE) veio com um código malformado no
PRÓPRIO PDF de origem (`50696900.000100.01`, 8 dígitos na primeira parte
em vez dos 6 esperados — confirmado com `page.extract_words()` que essa
é a string real no PDF, não um artefato de extração; provável erro de
sistema da operadora ao incluir esse dependente depois, "Adesão"
01/08/2026, bem mais recente que o resto da família). Se a classificação
dependesse do sufixo do código, essa pessoa teria sido malinterpretada;
usando a coluna Parentesco (sempre confiável nos 8 casos do
arquivo-modelo), o problema não afeta o resultado. `numero_beneficiario`
guarda o código bruto tal como veio (mesmo malformado) — só usado como
identificador de exibição/agregação, nunca como chave de casamento.
4. **CPF vem sem zero à esquerda quando o valor real da pessoa começa com
0** — confirmado comparando com a planilha padrão real do Questor
(`CPFFUNC`/`CPFDEPENDENTE` sempre no formato `XXX.XXX.XXX-XX`, 11
dígitos): os CPFs deste relatório MetLife saem com 9 a 11 dígitos
(ex.: `"795136978"`, 9 dígitos — típico de um campo numérico que perdeu
o(s) zero(s) à esquerda ao ser exportado, comum em relatórios gerados a
partir de planilha). Sem corrigir isso, o casamento por CPF falharia
silenciamente (viraria "CPF não encontrado") pra toda pessoa cujo CPF
real começa com zero. Corrigido com `.zfill(11)` na extração — CPF
brasileiro sempre tem 11 dígitos, então completar com zeros à esquerda
é seguro e não ambíguo.
Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 8
beneficiários (3 titulares + 5 dependentes), R$ 120,00 no total — bate
exatamente com "TOTAL DE MENSALIDADES: 8 itens 120,00" impresso no próprio
relatório.
**Não confirmado ainda** (arquivo-modelo só tinha nomes/planos curtos,
cabendo numa única linha física): nome ou plano que ultrapasse a largura da
própria coluna e quebre em duas linhas físicas — reconferir se aparecer
numa competência real com um nome bem mais longo.
"""
import re
from typing import Dict, List, Tuple
import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
from portal_api.planos_saude.operadoras.base import OperadoraParser
# (nome da coluna, x0 mínimo) — ordenado por x0 crescente; uma palavra cai
# na última coluna cujo x0 mínimo ela ainda satisfaz.
_COLUNAS = [
("codigo", 0),
("funcional", 113),
("nome", 169),
("nasc", 329),
("parentesco", 381),
("cpf", 485),
("adesao", 545),
("plano", 597),
("valor", 750),
]
_CODIGO_BENEFICIARIO_RE = re.compile(r"^\d+\.\d+\.\d+$")
def _coluna_para_x0(x0: float) -> str:
coluna = _COLUNAS[0][0]
for nome, minimo in _COLUNAS:
if x0 >= minimo:
coluna = nome
else:
break
return coluna
def _valor_br_para_float(texto: str) -> float:
"""'15,00' -> 15.0 '1.234,56' -> 1234.56 (formato BR)."""
texto = texto.strip().replace(".", "").replace(",", ".")
return float(texto) if texto else 0.0
def _agrupa_linhas_por_coluna(pdf: "pdfplumber.PDF") -> List[Dict[str, str]]:
"""Reconstrói cada linha física do relatório como um dict coluna->texto,
bucketizando `extract_words()` por posição vertical (linha) e depois
horizontal (coluna) — ver docstring do módulo pro motivo de não usar
regex de linha inteira."""
linhas: List[Dict[str, str]] = []
for page in pdf.pages:
palavras = sorted(page.extract_words(), key=lambda w: (w["top"], w["x0"]))
grupos: Dict[float, list] = {}
for w in palavras:
grupos.setdefault(round(w["top"], 1), []).append(w)
for top in sorted(grupos):
buckets: Dict[str, List[str]] = {}
for w in grupos[top]:
buckets.setdefault(_coluna_para_x0(w["x0"]), []).append(w["text"])
linhas.append({coluna: " ".join(textos) for coluna, textos in buckets.items()})
return linhas
class MetlifeOdontoMensalidade(OperadoraParser):
nome_operadora = "MetLife Odonto"
chave_casamento = "cpf" # CPF sempre presente no relatório (ver nota do zfill acima)
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
with pdfplumber.open(caminho_arquivo) as pdf:
linhas = _agrupa_linhas_por_coluna(pdf)
lancamentos: List[Lancamento] = []
for linha in linhas:
codigo = linha.get("codigo", "").strip()
if not _CODIGO_BENEFICIARIO_RE.match(codigo):
continue # cabeçalho, "Total do grupo familiar:", rodapé etc.
parentesco = linha.get("parentesco", "").strip()
cpf = "".join(ch for ch in linha.get("cpf", "") if ch.isdigit()).zfill(11)
lancamentos.append(Lancamento(
numero_beneficiario=codigo,
nome=" ".join(linha.get("nome", "").split()),
cpf=cpf,
tipo="D" if parentesco else "T",
rubrica=f"Mensalidade ({linha.get('plano', '').strip()})",
valor=_valor_br_para_float(linha.get("valor", "")),
tipo_lancamento="mensalidade",
))
individuos = self._agrega_por_individuo(lancamentos)
return individuos, []
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,
)
ordem.append(chave)
individuos[chave].valor_total += lc.valor
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
return [individuos[c] for c in ordem]

View File

@ -20,6 +20,7 @@ from portal_api.planos_saude.operadoras.bradesco.saude import BradescoSaude
from portal_api.planos_saude.operadoras.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade from portal_api.planos_saude.operadoras.dental_uni.odonto_mensalidade import DentalUniOdontoMensalidade
from portal_api.planos_saude.operadoras.humana.saude import HumanaSaude from portal_api.planos_saude.operadoras.humana.saude import HumanaSaude
from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude
from portal_api.planos_saude.operadoras.metlife.odonto_mensalidade import MetlifeOdontoMensalidade
from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade
from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude
from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude
@ -97,6 +98,11 @@ OPERADORAS = {
"nome": "Unimed Cascavel", "nome": "Unimed Cascavel",
"parser": UnimedCascavelSaude, "parser": UnimedCascavelSaude,
}, },
"metlife_odonto_mensalidade": {
"codigo_operadora": "3761",
"nome": "MetLife Odonto",
"parser": MetlifeOdontoMensalidade,
},
} }