Parametrização de novas operadoras de plano de saúde
This commit is contained in:
parent
50772d864b
commit
41512f793f
@ -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`)
|
||||
|
||||
BIN
media/planos_saude/operadora/SULAMERICA_AGOSTO_2026.pdf
Normal file
BIN
media/planos_saude/operadora/SULAMERICA_AGOSTO_2026.pdf
Normal file
Binary file not shown.
BIN
media/planos_saude/operadora/unimed_vitoria.PDF
Normal file
BIN
media/planos_saude/operadora/unimed_vitoria.PDF
Normal file
Binary file not shown.
@ -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;
|
||||
|
@ -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;
|
||||
|
18
plano.md
18
plano.md
@ -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
|
||||
|
||||
@ -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]
|
||||
261
portal_api/planos_saude/operadoras/unimed_vitoria/saude.py
Normal file
261
portal_api/planos_saude/operadoras/unimed_vitoria/saude.py
Normal 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]
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user