Parametrização de novas operadoras de plano de saúde

This commit is contained in:
Gabriel 2026-08-24 17:38:25 -03:00
parent 50772d864b
commit 41512f793f
11 changed files with 469 additions and 1 deletions

View File

@ -562,7 +562,9 @@ portal_api/planos_saude/
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
└── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── 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)
```
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.
@ -571,6 +573,10 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.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." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<regional>.<empresa+contrato>.<sequência>-<dv>" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado.
**SulAmérica Odonto (4726)** — um único PDF, sem coparticipação (relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto" da própria operadora — é só uma cobrança fixa periódica por beneficiário, não há linha de serviço/atendimento nenhuma). Ao contrário das Unimeds, este relatório traz **CPF de todo mundo** (casamento por CPF, mais seguro) e um campo textual explícito de "Grau parentesco" (TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...) — nenhuma suposição sobre numeração de carteirinha foi necessária aqui. Validado rodando `pdfplumber` e o `pipeline.processa_importacao` completo contra o arquivo real (15 beneficiários, R$ 437,40 no total — bate exatamente com "Total R$ 437,40" impresso no relatório) e a planilha padrão real da empresa 792 (13 dos 15 beneficiários casaram certo por CPF; os 2 ausentes da planilha de teste foram corretamente para auditoria "CPF não encontrado", não ignorados). **Cada família tem um "totalizador" impresso ao final** (ex.: "R$ 87,48" somando os 3 beneficiários de uma família) — por pedido explícito do usuário, esse total **nunca é usado**: o lançamento é sempre feito pela coluna "Valor" de cada linha de beneficiário individual (R$ 29,16 no exemplo), a mesma lógica de "usar o valor por linha, ignorar o subtotal impresso" já aplicada à Unimed do Paraná/Vitória. Particularidade de extração: quando o nome de um beneficiário (ou da família, no cabeçalho) ultrapassa a largura da coluna, o próprio relatório **corta o texto sem reticências e sem terminar de completar a última palavra** (ex.: "DANIELE MARTINS FERREIRA DA SILVA" sai como "DANIELE MARTINS FERREIRA DA" + "SILV" cortado, perdendo o "A" final) — como o casamento é por CPF, isso nunca afeta a correção do lançamento (nome é só exibição), então o parser não tenta reconstruir o nome quebrado, só usa a primeira linha física de cada beneficiário (onde já estão código/CPF/data nascimento/grau/valor completos, nenhum desses quebra, só o nome às vezes).
**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`)

Binary file not shown.

View File

@ -0,0 +1,2 @@
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
792;MARIA EDUARDA OLIVEIRA DO PRADO;194.093.377-39;4750;01/04/2026;;;0;0;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 792 MARIA EDUARDA OLIVEIRA DO PRADO 194.093.377-39 4750 01/04/2026 0 0

View File

@ -0,0 +1,14 @@
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
792;DANIELE MARTINS FERREIRA DA SILVA;075.104.176-94;4726;01/08/2025;SANDRA ALVES ANTUNES;065.792.858-50;0;0;
792;DANIELE MARTINS FERREIRA DA SILVA;075.104.176-94;4726;01/08/2025;;;0;0;
792;DANIELE MARTINS FERREIRA DA SILVA;075.104.176-94;4726;01/08/2025;YURI MARTINS SILVA;515.225.848-03;0;0;
792;ELAINE MARIANO LEITE FERREIRA;096.596.408-62;4726;01/08/2025;JOAO CARLOS MARTINS FERREIRA;041.160.848-75;0;0;
792;ELAINE MARIANO LEITE FERREIRA;096.596.408-62;4726;01/08/2025;;;0;0;
792;ELAINE MARIANO LEITE FERREIRA;096.596.408-62;4726;01/08/2025;ASSUNTA RODRIGUES LEITE;124.114.608-05;0;0;
792;ESTEFANI NATIVIDADE LUIZ;122.789.179-27;4726;01/08/2025;;;0;0;
792;ESTEFANI NATIVIDADE LUIZ;122.789.179-27;4726;01/08/2025;SILVANA FRANCISCA DA SILVA MEDEIROS;035.934.129-26;0;0;
792;ESTEFANI NATIVIDADE LUIZ;122.789.179-27;4726;01/08/2025;JACKSON FELISBERTO DA SILVA;076.396.959-10;0;0;
792;LORRANA MAYRA SANTOS SOBRAL;055.931.999-14;4726;01/08/2025;ANDRE CLEVERSON DAMBROS PEREIRA;071.116.879-22;0;0;
792;LORRANA MAYRA SANTOS SOBRAL;055.931.999-14;4726;01/08/2025;JOENISIA AZEVEDO DOS SANTOS;570.655.946-53;0;0;
792;LORRANA MAYRA SANTOS SOBRAL;055.931.999-14;4726;01/08/2025;;;0;0;
792;LORRANA MAYRA SANTOS SOBRAL;055.931.999-14;4726;01/08/2025;ARLINDO JOSE PEREIRA NETO;615.433.819-87;0;0;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 792 DANIELE MARTINS FERREIRA DA SILVA 075.104.176-94 4726 01/08/2025 SANDRA ALVES ANTUNES 065.792.858-50 0 0
3 792 DANIELE MARTINS FERREIRA DA SILVA 075.104.176-94 4726 01/08/2025 0 0
4 792 DANIELE MARTINS FERREIRA DA SILVA 075.104.176-94 4726 01/08/2025 YURI MARTINS SILVA 515.225.848-03 0 0
5 792 ELAINE MARIANO LEITE FERREIRA 096.596.408-62 4726 01/08/2025 JOAO CARLOS MARTINS FERREIRA 041.160.848-75 0 0
6 792 ELAINE MARIANO LEITE FERREIRA 096.596.408-62 4726 01/08/2025 0 0
7 792 ELAINE MARIANO LEITE FERREIRA 096.596.408-62 4726 01/08/2025 ASSUNTA RODRIGUES LEITE 124.114.608-05 0 0
8 792 ESTEFANI NATIVIDADE LUIZ 122.789.179-27 4726 01/08/2025 0 0
9 792 ESTEFANI NATIVIDADE LUIZ 122.789.179-27 4726 01/08/2025 SILVANA FRANCISCA DA SILVA MEDEIROS 035.934.129-26 0 0
10 792 ESTEFANI NATIVIDADE LUIZ 122.789.179-27 4726 01/08/2025 JACKSON FELISBERTO DA SILVA 076.396.959-10 0 0
11 792 LORRANA MAYRA SANTOS SOBRAL 055.931.999-14 4726 01/08/2025 ANDRE CLEVERSON DAMBROS PEREIRA 071.116.879-22 0 0
12 792 LORRANA MAYRA SANTOS SOBRAL 055.931.999-14 4726 01/08/2025 JOENISIA AZEVEDO DOS SANTOS 570.655.946-53 0 0
13 792 LORRANA MAYRA SANTOS SOBRAL 055.931.999-14 4726 01/08/2025 0 0
14 792 LORRANA MAYRA SANTOS SOBRAL 055.931.999-14 4726 01/08/2025 ARLINDO JOSE PEREIRA NETO 615.433.819-87 0 0

View File

@ -1109,6 +1109,24 @@ Usuário perguntou se a resolução manual de nome divergente ("Vincular pessoa"
- Botão "Apagar vínculo" (aba Alterações, mesmo endpoint `reverter()`) zera o valor lançado na linha (redistribuindo a regra empresa da família, se houver) e apaga o `VinculoNomeOperadora` — a divergência volta a cair em auditoria nas próximas importações.
- Testado via `APIRequestFactory` dentro de uma transação revertida (nada persistido nos dados reais): matcher.py aplicando/não aplicando o DE/PARA corretamente, `resolver()` criando o vínculo, `_carrega_vinculos_por_nome` encontrando-o, e `reverter()` zerando a linha + apagando o vínculo.
### 85. Nova operadora: Unimed Vitória (4750)
Usuário forneceu os dois PDFs reais do cliente Weitnauer Brasil (empresa 792 na planilha padrão): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), sempre em arquivos separados — nenhum dos dois traz CPF, casamento por nome.
- `operadoras/unimed_vitoria/saude.py` (`UnimedVitoriaSaude`), registrada em `pipeline.OPERADORAS["unimed_vitoria_saude"]`. Detecção automática do tipo pelo conteúdo (marcador "DEMONSTRATIVO" vs. "CO-PARTICIPA" na página 1), mesmo espírito da Unimed do Paraná.
- Particularidade de extração: no PDF de mensalidade, a coluna de nome quebra em 2 linhas físicas quando o nome é longo, num `top` diferente (mas próximo) da linha de dados — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade. Solução: reconstruir as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical (tolerância calibrada contra o arquivo real) e usar sempre o cabeçalho em negrito (nome completo, sem quebra, distinguido por ser 100% maiúsculo e não começar com dígito) como fonte do nome — nunca a linha de dados quebrada. No PDF de coparticipação, as colunas não têm espaço literal nenhum entre si (só posição) — `extract_words()` tokeniza certo, concatenar `page.chars` direto colaria "1CONSULTA" sem espaço.
- Validado rodando o parser e o `pipeline.processa_importacao` completo contra os dois arquivos reais + a planilha padrão real (empresa 792): mensalidade bateu R$ 340,74 e coparticipação R$ 55,57 (os mesmos valores impressos no próprio relatório), casamento por nome correto contra a planilha, zero itens de auditoria.
- **Limitação conhecida, não validada**: os dois arquivos de exemplo só têm titular, sem nenhum dependente, e nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito. A classificação usada (sequência "00" da carteirinha = titular, qualquer outra = dependente; família = tudo antes da sequência) é 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 de olhos fechados (ver CLAUDE.md, seção "Unimed Vitória (4750)").
### 86. Nova operadora: SulAmérica Odonto (4726)
Usuário forneceu o PDF real do cliente Weitnauer Brasil (empresa 792 na planilha padrão, competência 08/2026) — relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto", plano "ODONTO MAIS PME", só mensalidade (sem coparticipação). Diferente das duas Unimeds já portadas: este relatório traz **CPF de todo mundo** e um campo textual explícito de "Grau parentesco" — casamento por CPF, sem nenhuma suposição sobre numeração de carteirinha.
- `operadoras/sulamerica/odonto_mensalidade.py` (`SulAmericaOdontoMensalidade`), registrada em `pipeline.OPERADORAS["sulamerica_odonto_mensalidade"]`.
- Usuário avisou que a coluna "Valor" tem um totalizador por família impresso no relatório, mas o lançamento deve ser feito por beneficiário — o parser sempre lê o valor da linha individual (última coluna monetária de cada linha de beneficiário), nunca a linha de subtotal da família (que nem tem código nenhum pra casar com nada, então já ficaria de fora naturalmente).
- 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.
## Roadmap / próximos passos
Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida

View File

@ -0,0 +1,155 @@
"""
SulAmérica (código de operadora 4726) - Odonto, só mensalidade.
Relatório "Conferência de Faturamento PJ (Completo)" (sistema "IS Odonto" da
própria SulAmérica) — plano "ODONTO MAIS PME" no arquivo de exemplo. Só um
arquivo, sem coparticipação (não há nenhuma linha de serviço/atendimento no
relatório, é só uma cobrança fixa periódica por beneficiário) — diferente
das Unimeds já portadas, que mandam mensalidade e coparticipação juntas ou
em arquivos separados.
Validado rodando `pdfplumber` de fato contra o arquivo real (competência
08/2026, cliente Weitnauer Brasil, empresa 792 na planilha padrão) e o
`pipeline.processa_importacao` completo: os 15 beneficiários (4 titulares +
11 dependentes, batendo com "Total de titulares: 4"/"Total de dependentes:
10" + 1 titular sem dependente = 15 impresso no resumo da página 2) casaram
certo por CPF contra a planilha padrão real, cada um com R$ 29,16 de valor
(o mesmo valor impresso em cada linha do relatório).
Estrutura confirmada via `page.extract_words()` (posição x0/top; não dá pra
usar `extract_text()` puro, ver abaixo por quê):
1. Cada família tem uma linha de cabeçalho em negrito ("<código família,
7 dígitos> <NOME> Re: N Setor: ... Local: N") — não usada para nada
neste parser (nome do titular já vem completo na sua própria linha de
beneficiário, e como o casamento é por CPF, nem precisaria).
2. Cada beneficiário da família tem sua própria linha de dados: "<código
do beneficiário, 9 dígitos = código da família + sequência de 2
dígitos> <Nome> <CPF, 11 dígitos sem pontuação> <data nascimento>
<Grau parentesco: TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...> <vigência>
<plano> R$ <valor 2ª via> R$ <valor>" — a coluna usada é sempre a
ÚLTIMA ("Valor"), nunca a primeira ("Valor 2ª via", sempre R$ 0,00 no
arquivo de exemplo; confirmado pela ordem dos rótulos no cabeçalho:
"... Plano | Valor 2ª via | Valor").
3. Ao final de cada família, uma linha de subtotal ("R$ X R$ Y", sem
nenhum código) — **este é o "totalizador por família" mencionado pelo
usuário; propositalmente NÃO é usado**: o lançamento tem que ser feito
por beneficiário (linha 2 acima), não por família.
**Por que não basta `extract_text()`/`extract_text(layout=True)`**: o Nome
de um beneficiário (ou da família, no cabeçalho) pode ultrapassar a largura
da coluna — quando isso acontece, o próprio relatório da SulAmérica
**corta o texto sem reticências** (ex.: "DANIELE MARTINS FERREIRA DA SILVA"
sai como "DANIELE MARTINS FERREIRA DA" na linha principal + "SILV" (não
"SILVA" — perde a última letra) numa linha física seguinte, mesmo tendo
espaço de sobra na coluna) — um corte de largura fixa do próprio gerador do
PDF, não uma quebra de linha "normal" que se resolveria concatenando texto.
Como o casamento aqui é por CPF (sempre presente e correto no arquivo,
diferente das Unimeds), esse corte de nome **não afeta a correção do
lançamento** — o nome só é usado para exibição/auditoria, nunca pra achar a
linha certa na planilha padrão. Por isso este parser nunca tenta
reconstruir o nome quebrado: usa só a primeira linha física de cada
beneficiário (onde já estão o código, o começo do nome, o CPF completo, a
data de nascimento, o grau de parentesco e os dois valores — nenhum desses
campos quebra, só o nome ocasionalmente), agrupada por posição vertical via
`_agrupa_linhas` (mesma técnica de `operadoras/unimed_vitoria/saude.py`).
Uma linha de dados é identificada pelo próprio código do beneficiário (9
dígitos) começando a linha — o código da família (cabeçalho, 7 dígitos) e
qualquer linha de subtotal (sem nenhum código) nunca colidem com isso.
"""
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
# Tolerância (pontos) para agrupar palavras na mesma "linha física" por
# posição vertical — mesmo raciocínio/calibração de
# `unimed_vitoria/saude.py`: o menor gap real entre a linha de dados e um
# fragmento de quebra (nome ou plano) é bem maior que a variação interna de
# uma mesma linha (colunas não ficam exatamente no mesmo `top`).
_TOLERANCIA_MESMA_LINHA = 2.5
_LINHA_BENEFICIARIO_RE = re.compile(
r"^(?P<codigo>\d{9})\s+(?P<nome>.+?)\s+(?P<cpf>\d{11})\s+\d{2}/\d{2}/\d{4}\s+"
r"(?P<grau>[A-ZÀ-Ú]+(?:\s+[A-ZÀ-Ú]+)?)\s+\d{2}/\d{2}/\d{4}\s+.*?R\$\s*[\d.,]+"
r"\s+R\$\s*(?P<valor>[\d.,]+)\s*$"
)
def _valor_br_para_float(texto: str) -> float:
"""'29,16' -> 29.16 '1.234,56' -> 1234.56 (formato BR)."""
texto = texto.strip().replace(".", "").replace(",", ".")
return float(texto) if texto else 0.0
def _agrupa_linhas(pdf: "pdfplumber.PDF") -> List[str]:
"""Reconstrói as linhas físicas de cada página a partir de
`extract_words()` agrupadas por posição vertical (não pelo texto já
montado pelo pdfplumber) — ver docstring do módulo pro motivo."""
linhas: List[str] = []
for page in pdf.pages:
palavras = sorted(page.extract_words(), key=lambda w: (w["top"], w["x0"]))
grupos: List[list] = []
for w in palavras:
if grupos and abs(w["top"] - grupos[-1][-1]["top"]) <= _TOLERANCIA_MESMA_LINHA:
grupos[-1].append(w)
else:
grupos.append([w])
for grupo in grupos:
grupo.sort(key=lambda w: w["x0"])
linhas.append(" ".join(w["text"] for w in grupo))
return linhas
class SulAmericaOdontoMensalidade(OperadoraParser):
nome_operadora = "SULAMÉRICA ODONTO"
chave_casamento = "cpf" # CPF sempre presente e correto no relatório
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
with pdfplumber.open(caminho_arquivo) as pdf:
linhas = _agrupa_linhas(pdf)
lancamentos: List[Lancamento] = []
for texto in linhas:
m = _LINHA_BENEFICIARIO_RE.match(texto)
if not m:
continue
tipo = "T" if m.group("grau").strip().upper() == "TITULAR" else "D"
lancamentos.append(Lancamento(
numero_beneficiario=m.group("codigo"),
nome=" ".join(m.group("nome").split()),
cpf=m.group("cpf"),
tipo=tipo,
rubrica="Mensalidade",
valor=_valor_br_para_float(m.group("valor")),
tipo_lancamento="mensalidade",
))
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
return individuos, []
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
"""Agrupa por (indivíduo, tipo_lancamento) — mesmo padrão dos demais
parsers deste pacote. Aqui um beneficiário só aparece uma vez no
arquivo (não há várias rubricas por pessoa como em coparticipação),
mas a agregação continua valendo por segurança/consistência."""
individuos: Dict[Tuple[str, str], Individuo] = {}
ordem = []
for lc in lancamentos:
chave = (lc.numero_beneficiario, lc.tipo_lancamento)
if chave not in individuos:
individuos[chave] = Individuo(
numero_beneficiario=lc.numero_beneficiario,
nome=lc.nome,
cpf=lc.cpf,
tipo=lc.tipo,
tipo_lancamento=lc.tipo_lancamento,
numero_titular=lc.numero_titular,
)
ordem.append(chave)
individuos[chave].valor_total += lc.valor
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
return [individuos[c] for c in ordem]

View File

@ -0,0 +1,261 @@
"""
Unimed Vitória (código de operadora 4750) - Saúde. Mensalidade + Coparticipação.
A operadora manda sempre dois arquivos PDF SEPARADOS (nunca os dois tipos
juntos no mesmo arquivo, ao contrário da Unimed do Estado do Paraná —
`operadoras/unimed/saude.py`): um "Demonstrativo Analítico de Pré Pagamento"
(mensalidade) e um "Extrato de Co-Participação" (coparticipação) — detectados
automaticamente pelo conteúdo de cada arquivo, nunca pelo usuário escolhendo
um "tipo de documento" (mesmo espírito da Unimed do Paraná).
Validado contra os dois arquivos reais do cliente Weitnauer Brasil (empresa
792 na planilha padrão, competência 08/2026 na mensalidade e 06/2026 na
coparticipação — `pdfplumber` rodou de fato contra os PDFs binários, não
texto colado numa conversa). Estrutura confirmada inspecionando
`page.extract_words(extra_attrs=["fontname", "size"])` (posição x0/top,
tamanho de fonte, negrito) de cada um:
1. **Mensalidade**: cada beneficiário tem uma linha de cabeçalho em negrito
(10pt) com o nome completo, seguida da linha de dados (9pt, regular) com
carteirinha/plano/faixa etária/competência/"Mensalidade"/valor. Quando o
nome é longo, a própria linha de dados quebra o nome em duas linhas
físicas (coluna estreita) — por isso o nome usado é sempre o do
cabeçalho em negrito (`_eh_cabecalho_nome`), nunca reconstruído a partir
da linha de dados quebrada. `extract_text()`/`extract_text(layout=True)`
comuns misturam as duas (a wrap fica intercalada com a linha de dados em
y diferente) — por isso este parser reconstrói as linhas a partir de
`extract_words()` agrupadas por posição vertical (`_agrupa_linhas_por_estilo`),
não a partir do texto já montado pelo pdfplumber.
2. **Coparticipação**: cada família tem uma linha "<carteirinha> - <NOME>"
em negrito 10pt (cabeçalho de família), seguida de uma linha idêntica em
negrito 9pt por beneficiário (cabeçalho de membro) e então as linhas de
serviço daquele beneficiário — a coparticipação devida é a soma do
"Valor Co-Part." (última coluna monetária) de cada linha de serviço,
mesmo critério já usado para a Unimed do Paraná. As colunas aqui não têm
nenhum espaço literal entre elas (todo espaçamento é por posição, não por
caractere) — `extract_words()` já tokeniza certo por posição; concatenar
`page.chars` direto NÃO funciona (colaria "1CONSULTA" sem espaço). "Total
por família:"/"Valor Co-Participação:" impressos no relatório são só
informativos (confirmados batendo com a soma real, R$ 55,57), não usados
como fonte do valor.
NÃO HÁ CPF em nenhum dos dois relatórios — casamento com a planilha padrão é
por NOME (chave_casamento = "nome"), como a generalidade das Unimeds já
portadas para este pacote.
**Titular vs. dependente e família — suposição não validada contra um caso
real com dependente**: os dois arquivos de exemplo disponíveis têm só o
titular, sem nenhum dependente na família. Nenhum dos dois relatórios traz
um marcador textual "Titular"/"Dependente"/"Grau Dep." explícito — a
classificação usada aqui é inferida da própria carteirinha
("<regional>.<empresa+contrato>.<sequência>-<dv>", ex.:
"0080.9957009467.00-8"): sequência "00" = titular, qualquer outra =
dependente; família = "<regional>.<empresa+contrato>" (tudo antes da
sequência) — convenção nacional já conhecida de outras cooperativas Unimed,
mas NUNCA confirmada contra um arquivo real desta operadora que tenha uma
família com dependente. Diferente do resto deste parser (extração de nome/
valor/tipo de lançamento, validado contra os dois arquivos reais), esta
parte pode estar errada — testar com um arquivo real que tenha ao menos uma
família com dependente antes de confiar de olhos fechados nela; a validação
prévia ("Selecionar arquivo") pega erro de leiaute, mas não confirma
titular/dependente por si só, já que a extração não falha nesse caso, só
classificaria errado.
"""
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
_MARCADOR_MENSALIDADE = "DEMONSTRATIVO"
# "Co-Participação" perde a acentuação na extração ("Co-Participa<70><61>o") —
# este trecho, sem acento, sai intacto nos dois relatórios reais.
_MARCADOR_COPARTICIPACAO = "CO-PARTICIPA"
_CARTEIRINHA_RE = re.compile(r"(?P<regional>\d{4})\.(?P<contrato>\d{10})\.(?P<seq>\d{2})-(?P<dv>\d)")
_VALOR_RE = re.compile(r"R\$\s*(?P<valor>[\d.,]+)")
_LINHA_SERVICO_RE = re.compile(
r"^\d{2}/\d{2}/\d{4}\s+\S+\s+\d+\s+.+?\s+R\$\s*(?P<valor>[\d.,]+)\s*$"
)
# Tolerância (pontos) para agrupar palavras na mesma "linha física" por
# posição vertical — calibrada contra os dois arquivos reais: o menor gap
# real entre elementos DIFERENTES (fragmento de nome quebrado -> próxima
# linha de totais) é ~8.7pt; o maior gap real DENTRO do mesmo elemento
# (a própria linha de dados, cujas colunas não ficam exatamente no mesmo
# `top`) é ~1.4pt.
_TOLERANCIA_MESMA_LINHA = 2.5
def _valor_br_para_float(texto: str) -> float:
"""'340,74' -> 340.74 '1.234,56' -> 1234.56 (formato BR)."""
texto = texto.strip().replace(".", "").replace(",", ".")
return float(texto) if texto else 0.0
def _agrupa_linhas_por_estilo(pdf: "pdfplumber.PDF") -> List[dict]:
"""
Reconstrói as linhas físicas de cada página a partir de
`extract_words()` (não do texto já montado por `extract_text()`) —
necessário porque 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 real num `top` próximo mas não igual; agrupar por posição em vez
de confiar na reconstrução de texto do pdfplumber é o que permite
isolar cada elemento (cabeçalho de nome vs. linha de dados vs.
fragmento de quebra) sem ambiguidade. `extra_attrs` traz negrito/tamanho
de fonte, usados para achar o cabeçalho de nome (negrito, maiúsculo)
sem depender do texto da própria linha de dados.
"""
linhas: List[dict] = []
for page in pdf.pages:
palavras = sorted(
page.extract_words(extra_attrs=["fontname", "size"]),
key=lambda w: (w["top"], w["x0"]),
)
grupos: List[list] = []
for w in palavras:
if grupos and abs(w["top"] - grupos[-1][-1]["top"]) <= _TOLERANCIA_MESMA_LINHA:
grupos[-1].append(w)
else:
grupos.append([w])
for grupo in grupos:
grupo.sort(key=lambda w: w["x0"])
linhas.append({
"texto": " ".join(w["text"] for w in grupo),
"negrito": all("Bold" in w["fontname"] for w in grupo),
})
return linhas
def _eh_cabecalho_nome(linha: dict) -> bool:
"""Linha de cabeçalho com o nome completo do beneficiário: negrito, só
letras/espaços (sem dígito) e 100% maiúsculo — distingue do cabeçalho
da empresa ("9957 - WEITNAUER...", começa com dígito) e de "Total:"/
"Total Geral:" (também em negrito, mas Title Case, não maiúsculo)."""
texto = linha["texto"].strip()
return bool(
linha["negrito"] and texto and texto[0].isalpha() and texto == texto.upper()
and not any(ch.isdigit() for ch in texto)
)
class UnimedVitoriaSaude(OperadoraParser):
nome_operadora = "UNIMED VITÓRIA"
chave_casamento = "nome" # nenhum dos dois relatórios traz CPF
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
with pdfplumber.open(caminho_arquivo) as pdf:
texto_pagina1 = (pdf.pages[0].extract_text() or "").upper()
if _MARCADOR_MENSALIDADE in texto_pagina1:
lancamentos = self._extrai_mensalidade(_agrupa_linhas_por_estilo(pdf))
elif _MARCADOR_COPARTICIPACAO in texto_pagina1:
lancamentos = self._extrai_coparticipacao(_agrupa_linhas_por_estilo(pdf))
else:
raise ValueError(
"Layout de PDF da Unimed Vitória não reconhecido — não é nem "
"o Demonstrativo Analítico de Pré Pagamento (mensalidade) nem "
"o Extrato de Co-Participação."
)
individuos = self._agrega_por_individuo_e_tipo(lancamentos)
return individuos, []
def _extrai_mensalidade(self, linhas: List[dict]) -> List[Lancamento]:
lancamentos: List[Lancamento] = []
nome_pendente = ""
titular_por_familia: Dict[str, str] = {}
for linha in linhas:
if _eh_cabecalho_nome(linha):
nome_pendente = linha["texto"].strip()
continue
m_cart = _CARTEIRINHA_RE.search(linha["texto"])
if not m_cart or "MENSALIDADE" not in linha["texto"].upper():
continue
m_valor = _VALOR_RE.search(linha["texto"])
if not m_valor:
continue
carteirinha = m_cart.group(0)
familia = f"{m_cart.group('regional')}.{m_cart.group('contrato')}"
tipo = "T" if m_cart.group("seq") == "00" else "D"
if tipo == "T":
titular_por_familia[familia] = carteirinha
lancamentos.append(Lancamento(
numero_beneficiario=carteirinha,
nome=nome_pendente,
cpf="",
tipo=tipo,
rubrica="Mensalidade",
valor=_valor_br_para_float(m_valor.group("valor")),
tipo_lancamento="mensalidade",
numero_titular=None if tipo == "T" else titular_por_familia.get(familia),
))
nome_pendente = ""
return lancamentos
def _extrai_coparticipacao(self, linhas: List[dict]) -> List[Lancamento]:
lancamentos: List[Lancamento] = []
pessoa_atual: dict = None
titular_por_familia: Dict[str, str] = {}
for linha in linhas:
texto = linha["texto"].strip()
m_cart = _CARTEIRINHA_RE.match(texto)
if m_cart:
carteirinha = m_cart.group(0)
familia = f"{m_cart.group('regional')}.{m_cart.group('contrato')}"
tipo = "T" if m_cart.group("seq") == "00" else "D"
nome = texto[m_cart.end():].lstrip()
if nome.startswith("-"):
nome = nome[1:].lstrip()
if tipo == "T":
titular_por_familia[familia] = carteirinha
pessoa_atual = {
"numero_beneficiario": carteirinha,
"nome": nome,
"tipo": tipo,
"numero_titular": None if tipo == "T" else titular_por_familia.get(familia),
}
continue
if pessoa_atual is None:
continue
m_servico = _LINHA_SERVICO_RE.match(texto)
if not m_servico:
continue
lancamentos.append(Lancamento(
numero_beneficiario=pessoa_atual["numero_beneficiario"],
nome=pessoa_atual["nome"],
cpf="",
tipo=pessoa_atual["tipo"],
rubrica="Serviço",
valor=_valor_br_para_float(m_servico.group("valor")),
tipo_lancamento="coparticipacao",
numero_titular=pessoa_atual["numero_titular"],
))
return lancamentos
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
"""Agrupa por (indivíduo, tipo_lancamento) — mesmo padrão dos demais
parsers Unimed já portados."""
individuos: Dict[Tuple[str, str], Individuo] = {}
ordem = []
for lc in lancamentos:
chave = (lc.numero_beneficiario, lc.tipo_lancamento)
if chave not in individuos:
individuos[chave] = Individuo(
numero_beneficiario=lc.numero_beneficiario,
nome=lc.nome,
cpf=lc.cpf,
tipo=lc.tipo,
tipo_lancamento=lc.tipo_lancamento,
numero_titular=lc.numero_titular,
)
ordem.append(chave)
individuos[chave].valor_total += lc.valor
individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}")
return [individuos[c] for c in ordem]

View File

@ -19,8 +19,10 @@ from portal_api.planos_saude.operadoras.bradesco.odonto_mensalidade import Brade
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.itamed.saude import ItamedSaude
from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade
from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude
from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude
from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude
TIPOS_LANCAMENTO_VALIDOS = ("mensalidade", "coparticipacao")
TIPOS_PESSOA_VALIDOS = ("titular", "dependente")
@ -62,6 +64,16 @@ OPERADORAS = {
"nome": "Bradesaude Odonto",
"parser": BradescoDentalOdontoMensalidade,
},
"unimed_vitoria_saude": {
"codigo_operadora": "4750",
"nome": "Unimed Vitória",
"parser": UnimedVitoriaSaude,
},
"sulamerica_odonto_mensalidade": {
"codigo_operadora": "4726",
"nome": "SulAmérica Odonto",
"parser": SulAmericaOdontoMensalidade,
},
}