diff --git a/CLAUDE.md b/CLAUDE.md index 8f702dc..cabd2c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//.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//.py` impleme **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`/00` = titular, `/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. +**Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "..-" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado. + +**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`) diff --git a/media/planos_saude/operadora/SULAMERICA_AGOSTO_2026.pdf b/media/planos_saude/operadora/SULAMERICA_AGOSTO_2026.pdf new file mode 100644 index 0000000..7f18243 Binary files /dev/null and b/media/planos_saude/operadora/SULAMERICA_AGOSTO_2026.pdf differ diff --git a/media/planos_saude/operadora/unimed_vitoria.PDF b/media/planos_saude/operadora/unimed_vitoria.PDF new file mode 100644 index 0000000..9e90bed Binary files /dev/null and b/media/planos_saude/operadora/unimed_vitoria.PDF differ diff --git a/media/planos_saude/planilha_padrao/questor_792_2026-08.csv b/media/planos_saude/planilha_padrao/questor_792_2026-08.csv new file mode 100644 index 0000000..d5d8224 --- /dev/null +++ b/media/planos_saude/planilha_padrao/questor_792_2026-08.csv @@ -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; diff --git a/media/planos_saude/planilha_padrao/questor_792_2026-08_KQCYKPt.csv b/media/planos_saude/planilha_padrao/questor_792_2026-08_KQCYKPt.csv new file mode 100644 index 0000000..72b09f5 --- /dev/null +++ b/media/planos_saude/planilha_padrao/questor_792_2026-08_KQCYKPt.csv @@ -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; diff --git a/plano.md b/plano.md index b98b955..c154ed4 100644 --- a/plano.md +++ b/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 diff --git a/portal_api/planos_saude/operadoras/sulamerica/__init__.py b/portal_api/planos_saude/operadoras/sulamerica/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal_api/planos_saude/operadoras/sulamerica/odonto_mensalidade.py b/portal_api/planos_saude/operadoras/sulamerica/odonto_mensalidade.py new file mode 100644 index 0000000..ad09aba --- /dev/null +++ b/portal_api/planos_saude/operadoras/sulamerica/odonto_mensalidade.py @@ -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 (" 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: " + + R$ R$ " — 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\d{9})\s+(?P.+?)\s+(?P\d{11})\s+\d{2}/\d{2}/\d{4}\s+" + r"(?P[A-ZÀ-Ú]+(?:\s+[A-ZÀ-Ú]+)?)\s+\d{2}/\d{2}/\d{4}\s+.*?R\$\s*[\d.,]+" + r"\s+R\$\s*(?P[\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] diff --git a/portal_api/planos_saude/operadoras/unimed_vitoria/__init__.py b/portal_api/planos_saude/operadoras/unimed_vitoria/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal_api/planos_saude/operadoras/unimed_vitoria/saude.py b/portal_api/planos_saude/operadoras/unimed_vitoria/saude.py new file mode 100644 index 0000000..cf16e13 --- /dev/null +++ b/portal_api/planos_saude/operadoras/unimed_vitoria/saude.py @@ -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 " - " + 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 +("..-", ex.: +"0080.9957009467.00-8"): sequência "00" = titular, qualquer outra = +dependente; família = "." (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��o") — +# este trecho, sem acento, sai intacto nos dois relatórios reais. +_MARCADOR_COPARTICIPACAO = "CO-PARTICIPA" + +_CARTEIRINHA_RE = re.compile(r"(?P\d{4})\.(?P\d{10})\.(?P\d{2})-(?P\d)") +_VALOR_RE = re.compile(r"R\$\s*(?P[\d.,]+)") +_LINHA_SERVICO_RE = re.compile( + r"^\d{2}/\d{2}/\d{4}\s+\S+\s+\d+\s+.+?\s+R\$\s*(?P[\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] diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index 2d45960..bb6341f 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -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, + }, }