# Importação de Plano de Saúde (Utilitários) > Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/planos_saude/`. Ver também as duas skills de contexto de negócio (`.claude/skills/`), divididas porque a empresa trabalha com mais de um sistema contábil e as etapas finais divergem entre eles: `importacao-plano-saude` (leitura/extração dos arquivos de operadora e regras de negócio, independente do destino — quais operadoras/empresas já estão validadas, o que falta) e `importacao-questor-plano-saude` (a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final). Uma terceira skill pro Contabit ainda não existe. Primeira aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo; a segunda é a variante "- De Paula", ver a seção logo abaixo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar). **A lógica de negócio em si não nasceu neste projeto** — veio de um pipeline Python já testado e documentado em `projects/importacao-planos-saude.skill` (arquivo `.skill`, é um zip — `SKILL.md` + `scripts/`), com um protótipo funcional em `projects/project/` (CLI `main.py`, nunca tocado pelo Portal, fica só como referência/histórico). Esse pipeline foi portado quase 1:1 para dentro do Django em **`portal_api/planos_saude/`** (pacote Python puro, sem depender do ORM): ``` portal_api/planos_saude/ ├── modelos.py Lancamento, Individuo, LinhaSistema, ItemAuditoria (dataclasses) ├── matcher.py casa_individuos_com_planilha() — casamento por CPF ou por nome ├── leiaute_sistema.py CABECALHO, le_planilha_padrao(), formata_valor_br() ├── pipeline.py OPERADORAS (registro), processa_importacao() — orquestração, chamada pela view └── operadoras/ ├── base.py OperadoraParser (interface) ├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo) ├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome) ├── 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) ├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber **ou** Excel via openpyxl — detectado pela extensão do arquivo, ver nota abaixo —, só mensalidade, casamento por CPF) ├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo) ├── humana/saude.py Humana Saúde — 5064 (PDF via pdfplumber, mensalidade+coparticipação no mesmo arquivo, casamento por nome — coparticipação nunca casada automaticamente, ver nota abaixo) ├── unimed_cascavel/saude.py Unimed Cascavel — 158 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome — coparticipação pode vir de duas fontes possíveis, nunca somadas juntas, ver nota abaixo e `OperadoraParser.finaliza()`) └── metlife/odonto_mensalidade.py MetLife Odonto — 3761 (PDF via pdfplumber, só mensalidade, casamento por CPF, extração por posição de coluna em vez de regex de linha, ver nota abaixo) ``` Pra adicionar uma operadora nova: criar `operadoras//.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. **`OperadoraParser.finaliza()`** (`operadoras/base.py`) — hook opcional chamado pelo `pipeline.processa_importacao` uma vez, depois que `extrai()` já rodou pra **todos** os arquivos da importação (default: não acrescenta nada, a maioria das operadoras nunca precisa sobrescrever). Existe pra operadora que só consegue decidir algo depois de ver o conjunto completo de arquivos anexados — hoje o único caso real é a Unimed Cascavel (ver abaixo), que usa isso pra escolher entre duas fontes possíveis de coparticipação sem correr risco de somar as duas. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. **As células reconstruídas pelo modelo de tabela (TableFormer, `document.tables`) não são confiáveis neste PDF**: em 08/2026 o valor de uma linha "vazava" pra célula da vizinha; em 09/2026 as 7 linhas vieram fundidas numa só, e um mesmo beneficiário passou a ter vários lançamentos (inclusão retroativa, uma linha por Mês/Ano, só a primeira com Certif./Nome). Por isso o parser lê as **palavras OCR com posição** que o Docling guarda por trás de cada tabela (`pages[i].predictions.tablestructure.table_map[...].cluster.cells`), reagrupa em linhas físicas pela posição vertical e reconhece cada campo pelo formato; uma linha física é um lançamento, linha sem Certif. soma no beneficiário anterior. A extração é conferida contra a linha "(TS)TOTAIS DA SUBFATURA" do quadro "Resumo" da própria fatura (valor, Part. Seg., número de lançamentos) e falha se não bater. Ver o docstring de `operadoras/bradesco/saude.py` (particularidade 6) e a rodada 136 em `CHANGELOG.md` desta pasta. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`/00` = titular, `/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. **Amil Odonto (3758)** — PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", só mensalidade, casamento por CPF. **Bug real corrigido (rodada em que este parser foi validado pela primeira vez contra um arquivo real, contrato 2831804000)**: o regex de parsing de linha exigia espaço (`\s+`) entre a coluna do plano (ex.: "DENTAL BRONZE DOC R PADRÃO") e a coluna "Tp." logo em seguida, mas nesse relatório real as duas colunas vêm **coladas sem nenhum espaço** ("PADRÃOT", "PADRÃOD", "PADRÃOA") — não é um problema de `x_density` do `pdfplumber` (testado de 6 até 20, sem efeito), o espaço realmente não existe no PDF de origem. Toda linha falhava o match silenciosamente, resultando em "Nenhum beneficiário foi encontrado neste arquivo" pra qualquer arquivo AMIL. Corrigido trocando esse `\s+` por `\s*` em `_AFTER_CPF_RE` (`operadoras/amil/odonto_mensalidade.py`). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 161 beneficiários (124 titulares/35 dependentes/2 agregados), R$ 1.630,93 no total, batendo exatamente com os totais impressos no próprio relatório. **Nota**: até esta rodada, este parser aparecia documentado (aqui e na skill `importacao-plano-saude`) com o código "898" — divergência de documentação desde a primeira versão do arquivo, nunca refletida em `pipeline.OPERADORAS` (sempre foi `3758`); corrigido nesta rodada, ver item abaixo. **Amil Odonto tem um segundo cadastro no Questor, código 898** — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`), porque empresas distintas usam um ou outro cadastro. `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, mesma `nome="Amil Odonto"`), reaproveitando a **mesma classe** `AmilOdontoMensalidade` — regras de extração e de custeio são idênticas às de `amil_odonto_mensalidade`/3758, só muda qual código filtra a planilha padrão buscada no Questor (`busca_linhas_questor`, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de `parser`. **Amil Odonto (898) também lê Excel, não só PDF** — o cliente Tecnomyl (empresa 1778, cadastro 898) manda o mesmo relatório "Demonstrativo Analítico Faturamento" em `.xlsx`, não em PDF. `AmilOdontoMensalidade.extrai()` detecta a extensão do arquivo (`.xlsx`/`.xlsm` → `_extrai_xlsx`, via `openpyxl`; qualquer outra → o caminho PDF já existente, inalterado) — mesmo padrão de detecção por formato já usado pela Unimed Saúde (CSV/PDF). `_localiza_cabecalho_xlsx` acha a linha de cabeçalho de verdade (`Código`/`Beneficiário`/`CPF`/`Tipo`/`Mensalidade`) procurando pelo NOME das colunas dentro das primeiras 20 linhas, não por um número de linha fixo — o arquivo real tem um número variável de linhas de título/contrato/fatura acima da tabela. "Total Família" é só um subtotal por família (mesmo espírito do "Valor Total" do PDF, item 1 acima — ignorado); o valor de cada linha vem de "Mensalidade", somado por `Código` (um mesmo beneficiário pode se repetir em várias linhas — rubricas normais + devoluções/exclusões retroativas de competências anteriores cobradas na mesma fatura, mesma regra geral de "somar por indivíduo"). A coluna "Tipo" já vem como 'T'/'D'/'A' direto do relatório, sem precisar de nenhuma inferência a partir da "Dependência" (livre, só informativa). CPF normalizado + `.zfill(11)` por segurança (mesmo cuidado da MetLife). Validado rodando `extrai()` de ponta a ponta contra o arquivo real da competência 08/2026 (contrato Tecnomyl): 475 beneficiários (217 titulares/220 dependentes/38 agregados), cada um pagando um valor fixo por plano (R$ 20,81 no plano "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — bate exatamente com a régua "Tipo" T/D/A do relatório real batendo 1:1 com os exemplos de "agregado" (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) repassados pelo cliente pra `amil_898_tecnomyl` (ver "Regra empresa" abaixo). **2 beneficiários com valor final negativo** no arquivo real (exclusão retroativa de competência anterior cobrada nesta fatura) corretamente caem em auditoria (`VALOR_NEGATIVO`), sem relação com o parser em si — comportamento já existente do `matcher.py`, não precisou de nenhum tratamento novo. **Nota operacional (não é bug, mas vale saber)**: alguns dependentes do arquivo real vêm sem CPF (campo vazio) — normalizado para `"00000000000"`. Como `chave_casamento="cpf"` pra esta operadora, esse tipo de linha só casa automaticamente se a planilha padrão do Questor também tiver um CPF preenchido pra essa pessoa (o caso normal); se o Questor tiver o CPF real da pessoa (diferente de zeros), a linha cai em auditoria "CPF não encontrado" e precisa de "Vincular pessoa" manual. Diferente da estratégia "nome" (`_casa_por_nome`), a estratégia "cpf" (`_casa_por_cpf`) **não** aceita `vinculos_por_nome` — um vínculo salvo ao resolver manualmente esse caso não é reaplicado automaticamente numa competência futura (a mesma pessoa sem CPF precisaria ser vinculada de novo todo mês). Isso não é uma limitação nova desta rodada, é um comportamento pré-existente de qualquer operadora com `chave_casamento="cpf"` (Amil, SulAmérica, MetLife) — só ficou mais visível aqui porque o arquivo real da Tecnomyl tem vários dependentes menores de idade sem CPF cadastrado. Estender o DE/PARA por nome pra também cobrir a estratégia "cpf" seria uma mudança maior, fora do escopo desta rodada — avaliar se vale a pena caso isso vire um incômodo recorrente. **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). **O mesmo relatório pode vir com fonte de símbolo** (visto em 09/2026, empresa 792): cada caractere deslocado em 0xF000 (U+F020 a U+F0FF), invisível na tela mas sem nenhum dígito "de verdade" pro regex. `_normaliza_fonte_simbolo()` desfaz isso em cada linha antes do regex (ver rodada 135 em `CHANGELOG.md` desta pasta). **SulAmérica Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - ") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha. **Humana Saúde (5064)** — único parser deste pacote em que a coparticipação **nunca** vira um `Individuo` tentando casamento automático. O PDF "boletim" traz mensalidade e coparticipação juntas no mesmo arquivo (uma tabela de beneficiários, depois "TOTALIZAÇÃO POR PLANO" — resumo agregado sem valor por pessoa, sempre ignorado — e depois "DESPESAS COBRADAS"), mas as duas tabelas usam identificadores diferentes: a de mensalidade tem uma "Matrícula" por beneficiário (`..`, com "Tipo do usuário" já como texto explícito "Titular"/"Dependente"/"Agregado" — não precisa inferir por indentação), enquanto a de "DESPESAS COBRADAS" só tem a matrícula do CONTRATO (não bate com a do beneficiário) e o nome vem truncado por largura de coluna (colado sem espaço no número da conta seguinte quando ultrapassa a largura — ex.: "CINTHIA ADRIANA DE SOUZA SANTOS" vira "CINTHIA ADRIANA DE SOUZ" colado em "...SOUZ39324387"). Sem CPF nem matrícula confiável pra casar, cada evento de coparticipação (já somado por pessoa antes, mesma regra geral de "somar por indivíduo") vira direto um `ItemAuditoria` (`motivo="NAO_CADASTRADO"`) na extração — decisão explícita do usuário, pra sempre exigir "Vincular pessoa" manual em vez de arriscar casar a pessoa errada por causa do corte de nome. Validado contra o arquivo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI, competência 08/2026): 4 beneficiários de mensalidade somando R$ 1.154,73 e 2 itens de auditoria de coparticipação somando R$ 145,20 (uma pessoa com duas despesas no mês corretamente somada em um único item, não dois) — batendo exatamente com os totais impressos no próprio boletim. **Só uma família no arquivo-modelo**: as posições fixas usadas pra extrair "Titular"/"Usuário" da tabela de despesas (colunas 16 e 34 do texto extraído) não puderam ser confirmadas com um nome bem mais curto que a largura da coluna — reconferir se aparecer uma competência real com mais de uma família. **Unimed Cascavel (158)** — outra Unimed regional, layout de PDF sem nenhuma sobreposição com a "Unimed Saúde" (5060, Unimed do Estado do Paraná) já cadastrada; nome comercial genérico repetido entre operadoras diferentes, cada uma com seu próprio parser/código no Questor, mesmo padrão de `unimed_oeste_pr`/`unimed_vitoria`. Sem CPF em nenhum dos formatos de arquivo (`chave_casamento="nome"`). - **Espaçamento das colunas só sai correto com `extract_words(x_tolerance=1)`**: nem `extract_text()` simples nem `extract_text(layout=True, x_density=6)` bastam aqui — os dois fundem palavras adjacentes sem nenhum espaço (ex.: "UNIMED DE CASCAVEL..." vira "UNIMEDDECASCAVEL...", nomes de beneficiário perdem os espaços internos), confirmado inspecionando `page.chars` diretamente: o espaçamento real entre palavras neste PDF é mais estreito que a tolerância padrão do pdfplumber (a densidade de `x_density=6` do `layout=True` não ajudou; a solução foi baixar o `x_tolerance` de `extract_words()` pra 1). Com isso, as linhas são reconstruídas agrupando palavras por posição vertical (`top`) e concatenando com um único espaço — mesma técnica de "reconstruir a linha a partir de `extract_words()`" já usada pela Unimed Vitória, só que ali por causa de quebra de linha física, aqui por causa da fusão de palavras. - **Duas fontes possíveis de coparticipação, nunca somadas**: o relatório de mensalidade (`"Matrícula Usuário Plano Tipo do usuário..."`, com `"TOTALIZAÇÃO POR PLANO"` no rodapé) pode opcionalmente trazer, na mesma página, uma tabela `"DESPESAS COBRADAS"` já resumida por beneficiário — **ou** a coparticipação pode vir num arquivo separado `"EXTRATO DE ATENDIMENTOS COBRADOS"` (um bloco por beneficiário, `"Total do usuário:"` já com o valor somado). Confirmado no arquivo-modelo que as duas fontes representam a **mesma competência** quando as duas aparecem juntas (os totais por beneficiário batem centavo a centavo entre as duas). Como "o modelo de arquivo é gerado pela operadora" (decisão explícita do usuário — não dá pra saber de antemão qual formato vai chegar num mês qualquer, e às vezes os dois vêm juntos), o parser nunca soma as duas: `extrai()` só coleta candidatos por matrícula em dois dicionários separados (`_despesas_embutidas`/`_coparticipacao_extrato`, nunca devolvidos direto) e `finaliza()` (chamado pelo pipeline só depois que todos os arquivos da importação já foram processados) decide — prefere o extrato separado quando presente (fonte mais granular), senão cai pra tabela embutida, senão não há coparticipação naquele mês (decisão explícita do usuário: ausência das duas fontes não gera nenhum `ItemAuditoria`, é tratada como "não houve despesa"). - **Toda resolução de família (titular/dependente) é feita por matrícula, nunca por nome** — a coluna "Usuário" do relatório de mensalidade é estreita e trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana, confirmado inspecionando os limites reais de x0/x1 do PDF: o nome pára exatamente na borda da coluna seguinte), então comparar o nome truncado da mensalidade com o nome completo do extrato de coparticipação para resolver `numero_titular`/`tipo` não seria confiável. Em vez disso, `_pessoa_por_matricula` (matrícula -> nome/tipo/numero_titular) é populado só ao processar a tabela de **mensalidade** (onde a família já vem corretamente resolvida por ordem de bloco: titular sempre antes dos próprios dependentes) e reaproveitado em `finaliza()` pra resolver os dois candidatos de coparticipação — que só carregam matrícula + valor, nada de nome. Um beneficiário com coparticipação mas ausente de toda tabela de mensalidade desta importação (arquivo daquele contrato não anexado) vira um `ItemAuditoria` explícito (`NAO_CADASTRADO`), nunca é descartado silenciosamente. Nomes truncados na mensalidade em si seguem o fluxo normal (`NOME_DIVERGENTE` em auditoria, resolvido manualmente uma vez via "Vincular pessoa" — nunca por aproximação). - Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, 2 contratos — 183237 e 183210/"Estadual"): mensalidade batendo exatamente com os totais impressos (R$ 4.076,41 + R$ 808,98 = R$ 4.885,39, 12 beneficiários) e coparticipação batendo com R$ 1.067,17 (3 beneficiários), confirmando que a tabela embutida (também extraída, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada. **MetLife Odonto (3761)** — relatório "Detalhamento de Mensalidade" (NF-e da própria MetLife), só mensalidade, casamento por CPF. Confirmado com o usuário: segue a regra padrão de custeio (mensalidade separada por titular/dependente), sem "Regra empresa". Diferente dos demais parsers deste pacote, a extração é feita **bucketizando `extract_words()` por posição de coluna** (x0), não por regex de linha inteira — necessário porque o Nome não tem largura fixa nem separador (ex.: "MICHELE REGINA DA SILVA EUGENIO"), então um regex de `.+?` até a próxima coluna arriscaria errar a fronteira; a mesma técnica generaliza de graça pra colunas opcionais (Parentesco/Nº Funcional, vazias em boa parte das linhas) sem precisar de grupos regex opcionais complicados. - **Titular/dependente é decidido pela coluna Parentesco estar vazia ou não** (nunca pelo sufixo do código de beneficiário, ex. `.00`/`.01`) — decisão tomada depois de um caso real já no próprio arquivo-modelo: um dependente (mãe de um titular) veio com o código malformado **no próprio PDF de origem** (`50696900.000100.01`, 8 dígitos na primeira parte em vez dos 6 esperados — confirmado inspecionando `page.extract_words()` que a string malformada já existe no PDF, não é artefato de extração; a "Adesão" desse dependente era bem mais recente que o resto da família, sugerindo inclusão tardia com erro do sistema da própria operadora). A coluna Parentesco continuou confiável nesse caso, então a classificação não foi afetada; `numero_beneficiario` guarda o código bruto tal como veio (mesmo malformado), só como identificador de exibição, nunca como chave de casamento. - **CPF sai sem zero(s) à esquerda quando o valor real da pessoa começa com 0** — confirmado comparando com o formato real de `CPFFUNC`/`CPFDEPENDENTE` na planilha padrão do Questor (sempre 11 dígitos, `XXX.XXX.XXX-XX`): os CPFs deste relatório MetLife saem com 9 a 11 dígitos (ex.: `"795136978"`, 9 dígitos), típico de um campo numérico que perdeu o(s) zero(s) à esquerda ao ser gerado a partir de planilha. Sem corrigir, o casamento por CPF falharia silenciosamente ("CPF não encontrado") pra toda pessoa cujo CPF real começa com zero — corrigido com `.zfill(11)` na extração (`operadoras/metlife/odonto_mensalidade.py`), seguro porque CPF brasileiro sempre tem 11 dígitos. - Validado rodando `extrai()` de ponta a ponta contra o arquivo real (competência 09/2026, empresa "OESTEFOZ NEW CORRETORA DE SEGUROS LTDA", 3 famílias): 8 beneficiários (3 titulares + 5 dependentes), R$ 120,00 no total — bate exatamente com "TOTAL DE MENSALIDADES: 8 itens 120,00" impresso no próprio relatório. Ainda não rodado contra a planilha padrão real de nenhuma empresa (nenhuma `RegraCusteioPlanoSaude` cadastrada pra esta operadora até o momento). - **Não confirmado ainda**: o arquivo-modelo só tinha nomes/planos curtos, cabendo numa única linha física — nome ou plano que ultrapasse a largura da coluna e quebre em duas linhas físicas ainda não foi visto; reconferir se aparecer numa competência real. **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None, "limite_desconto_empregado": float|None}`). Dentro da regra específica, dois grupos de critério, **mutuamente exclusivos** entre si (validado em `_monta_regra_custeio`, `serializers.py`, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — `atualizarExclusividadeRegraEspecifica()`, `importacao-plano-saude.js`): - **`limite_valor`/`percentual`** protegem o gasto da **empresa**: `limite_valor` é um teto de quanto ela cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada por ela (o resto vira desconto). Podem vir 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. - **`limite_desconto_empregado`** protege o gasto do **empregado** (adicionado a pedido do usuário, ex.: mensalidade de R$150/R$200 com desconto sempre limitado a R$10, a empresa absorve o restante — R$140/R$190): teto de quanto é descontado dele, sem limite algum pro que sobra pra empresa. Direção oposta da anterior — combinar os dois grupos não teria uma resolução determinística única quando entrassem em conflito (ex.: um teto de empresa que por si só implicaria um desconto maior que o teto de empregado permitido), por isso o formulário nunca deixa preencher os dois grupos ao mesmo tempo pra uma mesma combinação tipo×pessoa. 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 o valor "protegido" (empresa ou empregado, conforme o grupo de critério usado) é arredondado primeiro e o outro é derivado como o complemento exato (`valor_total` menos o protegido, 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`. ## "Importação de Plano de Saúde - De Paula" (segunda instância da mesma ferramenta) Existe uma **segunda aplicação** no menu Utilitários, `importacao-plano-saude-de-paula` ("Importação de Plano de Saúde - De Paula"), que é a mesma ferramenta apontada para o plano de saúde dos **próprios colaboradores do escritório**, não dos clientes. Os docstrings de `ImportacaoPlanoSaudeDePaula` (`models.py`) e o bloco de comentário das views (`views.py`, logo antes de `_garante_importacao_de_paula_em_revisao`) apontam para esta seção. Por que uma cópia em vez de um campo/filtro no mesmo histórico: o dado é de natureza diferente (dado pessoal de colaborador do escritório, não dado de cliente), e a separação garante que **nada cruza** entre as duas, nem por engano, nem por um filtro esquecido numa query. Em troca, tudo que for regra de negócio é compartilhado sem duplicação. **O que é duplicado** (tabelas e permissão próprias, sem nenhuma FK cruzando com as originais): | Original | Variante De Paula | |---|---| | `ImportacaoPlanoSaude` | `ImportacaoPlanoSaudeDePaula` | | `ImportacaoPlanoSaudeArquivoOperadora` | `ImportacaoPlanoSaudeDePaulaArquivoOperadora` | | `ImportacaoPlanoSaudeLinha` | `ImportacaoPlanoSaudeDePaulaLinha` | | `ImportacaoPlanoSaudeAuditoria` | `ImportacaoPlanoSaudeDePaulaAuditoria` | | `ImportacaoPlanoSaudeAlteracao` | `ImportacaoPlanoSaudeDePaulaAlteracao` | | `RegraCusteioPlanoSaude` | `RegraCusteioPlanoSaudeDePaula` | | `VinculoNomeOperadora` | `VinculoNomeOperadoraDePaula` | | `/api/importacoes-plano-saude/` (+ `-linhas`, `-auditoria`, `-alteracoes`) | os mesmos com sufixo `-de-paula` | | `/api/regras-custeio-plano-saude/` | `/api/regras-custeio-plano-saude-de-paula/` | | `templates/importacao-plano-saude.html` | `templates/importacao-plano-saude-de-paula.html` | | upload em `media/planos_saude/` | upload em `media/planos_saude_de_paula/` | O DE/PARA de nomes (`VinculoNomeOperadoraDePaula`) é tabela separada de propósito: um vínculo criado na aplicação de clientes **nunca** vale para a dos colaboradores, e vice-versa. **O que é compartilhado** (nada disso foi copiado): todo o pacote `portal_api/planos_saude/` (parsers de operadora, `matcher`, `pipeline`, `regras_empresa`, leiaute do Questor) e os helpers puros de `views.py` (`_salva_arquivo_temporario`, `_valida_planilha_padrao`, `_valida_arquivo_operadora`, `_monta_csv_linhas_plano_saude`, `_snapshot_linha_plano_saude`). **Consequência prática: adicionar uma operadora nova, corrigir um parser ou mexer numa regra de custeio vale automaticamente para as duas aplicações** — não existe (nem deve existir) uma versão "De Paula" de nenhum arquivo do pacote. **Frontend: um arquivo só, parametrizado.** `static/js/importacao-plano-saude.js` e `static/css/importacao-plano-saude.css` servem as duas páginas. O template da variante define `window.PID_IPS_CONFIG` num `