# 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 e única 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) — 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 — 898 (PDF via pdfplumber, só mensalidade, casamento por CPF, 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) ├── 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()`) ``` 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`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. 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 (898)** — 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. **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). **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. **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`) - `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), `planilha_padrao` (`FileField`, mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável. O arquivo (ou arquivos) da operadora vive num model relacionado separado, ver `ImportacaoPlanoSaudeArquivoOperadora` a seguir e "Múltiplos arquivos de operadora" abaixo. - `ImportacaoPlanoSaudeArquivoOperadora`: um dos relatórios da operadora anexados a uma importação (FK `importacao`, `arquivo` FileField, `ordem`) — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde em PDF) mandam mensalidade e coparticipação em arquivos separados. Substituiu, numa rodada posterior, o antigo `FileField` único `ImportacaoPlanoSaude.arquivo_operadora` (migração em 3 passos — `0048` cria o model novo + torna o campo legado `blank=True`; `0049`, `RunPython`, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo em `MEDIA_ROOT`, sem copiar bytes; `0050` remove o campo legado — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`). - `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento. - **Expressão de soma/subtração na célula** (pedido explícito do usuário): ao sair de uma célula de Valor/Valor Empresa (`change`), `pidAvaliaExpressaoValorMonetario()` (`importacao-plano-saude.js`) reconhece se o que foi digitado é uma expressão com `+`/`-` entre números em formato BR (ex.: `"15,30-15"` → `"0,30"`) e substitui o campo pelo resultado antes de mandar o PATCH — puramente client-side, o backend nunca recebe a expressão, só o valor já calculado (continua sem nenhuma validação de formato numérico nesse `CharField`, como já era). Um valor negativo digitado direto (ex.: `"-15,30"`, sem operador depois do primeiro caractere) não é tratado como expressão, continua sendo só um número negativo literal. - **Edição de célula (Valor/Valor Empresa) refaz o fetch da importação inteira e re-renderiza** (`renderTabs()`) depois do PATCH — bug real corrigido (2026-08-26): antes disso, `importacaoAtual` só era atualizado por outras ações da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), então editar uma célula deixava o resumo por tipo (contadores "linhas no total"/"com valor lançado"/"em auditoria") e a aba Alterações com o estado de antes da edição até o usuário sair e reabrir a importação do zero. - **Linha de total no rodapé da tabela** (pedido explícito do usuário): as abas de Mensalidade/Coparticipação (`panelHtmlParaTipo()`, `importacao-plano-saude.js`) ganharam uma última linha (`.ips-grid-row--totais`, fundo/negrito iguais ao cabeçalho) somando Valor Empresa e Valor de **todas** as linhas do tipo (não só as visíveis num scroll, e independente da ordenação da coluna) — rótulo "Total" fica na coluna "Nome Funcionário", as demais colunas ficam em branco. `pidValorRevisaoParaNumero()` (extraída de `comparaValorRevisao()`, mesma função reaproveitada) converte cada `"1.234,56"` BR pra número antes de somar; linhas em auditoria (valor "0") entram na soma sem alterá-la. Some sozinha quando a aba está vazia (mesmo `linhas.length` que já decide a linha "Nenhuma linha."), não é uma linha nova editável nem gera `ImportacaoPlanoSaudeAlteracao`. - **Fixa no fim da área visível** (`importacao-plano-saude.css`, pedido explícito do usuário): `.ips-grid-row--totais .ips-grid-cell` usa `position:sticky; bottom:0` — gruda no rodapé de `.ips-table-scroll` (o ancestral com `overflow-y`) independente de até onde o usuário rolou a tabela ou de quanto ela foi expandida (`wireAutoExpandScroll()`, ver abaixo — sticky é recalculado contra o tamanho atual do container, não um valor fixo em px). `z-index:2` (maior que o `1` da coluna de ações comum) garante que a linha continue por cima das linhas de dado que passam por trás dela ao rolar. - **Alça de redimensionar só de altura, não mais de largura** (`.ips-table-scroll`, `importacao-plano-saude.css`): caixa de cada aba (Mensalidade/Coparticipação/Auditoria/Alterações) nasceu com `resize:both` (420px de altura inicial, até 80vh), chegou a virar altura totalmente livre (sem caixa/scroll interno, uma rodada revertida) e voltou pra caixa de 420px — só que agora com `resize:vertical` em vez de `resize:both`: o usuário só arrasta a alça pra aumentar a altura (ver mais linhas de uma vez), a largura já rola sozinha via `overflow-x:auto`, sem precisar de alça própria pra isso. O redimensionar de **coluna** (`wireColumnResize()`, alça em cada cabeçalho) é outro mecanismo, independente, e não foi afetado por nenhuma dessas mudanças. - **Duplo clique na alça alterna expandir/voltar** (`wireAutoExpandScroll()`, `importacao-plano-saude.js`, chamada junto de `wireColumnResize()` em `renderPanels()`): a alça nativa do `resize` não é um elemento do DOM, então o duplo clique é reconhecido pela posição do clique dentro dos últimos ~20px do canto inferior direito de `.ips-table-scroll` (`PID_IPS_RESIZE_HANDLE_HIT_PX`), não por um alvo específico. Primeiro duplo clique: `wrap.style.maxHeight="none"` (senão o teto de 80vh continuaria cortando) e `wrap.style.height = wrap.scrollHeight + "px"` — mesma ideia de "autofit" de largura de coluna de uma planilha (Excel/Sheets), só que de altura; `wrap.dataset.autoExpandido` marca o estado, guardando o `height`/`max-height` inline de antes (`dataset.alturaAnterior`/`maxAlturaAnterior`, string vazia = "sem inline style", volta a valer o CSS padrão). Segundo duplo clique: restaura exatamente esses dois valores guardados (pedido explícito do usuário — "reverter para como estava antes", não necessariamente os 420px padrão, caso o usuário já tivesse arrastado a alça manualmente antes de expandir) e limpa o estado. Mesma limitação do redimensionamento manual por arrasto: é um ajuste inline, `renderPanels()` recria os elementos do zero (qualquer edição de célula, adicionar/remover linha etc.), então sempre nasce não-expandido de novo. - `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa". - `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo. - `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário. ## Fluxo e endpoints `ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos): - `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo em `arquivo_operadora` (lista, ver "Múltiplos arquivos de operadora" abaixo) e roda `pipeline.processa_importacao()` **de forma síncrona** usando os caminhos de todos os arquivos da operadora + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos (planilha + todos os da operadora) + o registro órfão e devolve 400. - `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `" - "` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend. - `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão"). - `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`. `ImportacaoPlanoSaudeLinhaViewSet` (`/api/importacoes-plano-saude-linhas/{id}/`, só GET/PATCH): edição de uma linha por vez, disparada por `blur`/`change` de cada `` na tela de revisão — mesma permissão de toggle único, sem checagem de "dono". `create()`/`partial_update()`/`destroy()` recusam (400) se a importação já estiver `concluida` — ver "Trava de edição pós-conclusão" abaixo. `ImportacaoPlanoSaudeAuditoriaViewSet` (`/api/importacoes-plano-saude-auditoria/{id}/resolver/`, só `POST`) — ver seção própria abaixo; também recusa se a importação estiver `concluida`. **Histórico (`#ips-list-table`): ordenação por coluna + filtro "estilo Excel" por coluna, os dois client-side** sobre o array já carregado (`GET /api/importacoes-plano-saude/`, sem paginação/filtro no servidor) — mesmo padrão de ordenação já usado em `#ua-table`/Ramais (`th[data-sort]`, ícone `↕`). O filtro (`criarFiltroColuna()`, `importacao-plano-saude.js`) nasceu como um segundo campo de texto por coluna, mas foi revisto a pedido do usuário pra imitar o filtro de planilha (Excel/Sheets): um botão de funil dentro do próprio `` de cada coluna filtrável (Cód. Empresa/Operadora/Status/Criado por) abre um popup com busca + checklist dos **valores distintos daquela coluna** (reaproveita `.checklist-box`/`.checklist-search`/`.checklist-select-all`/`.modal-checkbox` de `components.css` — mesmo componente já usado nos checklists de Perfis de Acesso), tudo desmarcável/marcável, com "Aplicar"/"Limpar" no rodapé (só aplica no clique, não a cada checkbox — evita re-renderizar a lista principal a cada toque). Os filtros das 4 colunas combinam entre si (AND). `listFiltros[campo]` vale `null` (sem filtro) ou um `Set` dos valores brutos marcados; marcar **todos** os valores existentes equivale a `null` (sem filtro), pra um valor novo que apareça depois (operadora nova, por exemplo) não nascer excluído até o usuário marcá-lo manualmente. O botão de funil ganha `.is-active` (cor de destaque) enquanto a coluna tiver um filtro aplicado — mesmo sinal visual do funil "azul" do Excel. `.pa-table-wrap` normalmente usa `overflow:hidden` pra arredondar os cantos da tabela; só a tabela do histórico (`.ips-list-table-wrap`) sobrescreve pra `visible`, senão o popup (que precisa aparecer por cima das linhas, não só dentro do cabeçalho) seria cortado ali. Qualquer mudança de filtro (ou de ordenação) volta pra página 1. `listEmpty` mostra uma mensagem diferente conforme o caso: "Nenhuma importação realizada ainda." quando o histórico está mesmo vazio, "Nenhuma importação encontrada com esse filtro." quando o vazio é só resultado do filtro aplicado. ## Trava de edição pós-conclusão Depois que `POST /{id}/gerar/` marca uma importação como `concluida`, editar/incluir/excluir uma linha (`ImportacaoPlanoSaudeLinhaViewSet`), resolver um item de auditoria (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`) ou reverter uma alteração (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) passam a ser recusados (400) — decisão explícita do usuário, depois de ver que a tabela de revisão continuava 100% editável mesmo depois do arquivo já ter sido gerado e entregue. `_garante_importacao_em_revisao(importacao)` (função módulo-level em `views.py`, chamada no início de cada um desses pontos de escrita) é o único lugar que checa isso — levanta `ValidationError` com uma mensagem pedindo pra usar o botão "Editar" (`POST /{id}/reabrir/`) primeiro. `gerar()` em si nunca é bloqueado (pode ser chamado de novo com a importação já `concluida`, só regera o mesmo arquivo a partir do que está salvo). No frontend (`importacao-plano-saude.js`), a tela de Revisão espelha essa trava puramente pra UX (a validação real é sempre a do backend acima): com `importacaoAtual.status === "concluida"`, toda célula da tabela de Mensalidade/Coparticipação vira texto (não ``, nem Valor Empresa/Valor — a regra de "só Valor Empresa/Valor editáveis" descrita no bullet de `ImportacaoPlanoSaudeLinha` acima só se aplica quando a importação ainda está em revisão), o "×" de remover linha e o botão "Adicionar linha" somem, "Vincular pessoa" (Auditoria) e "Reverter" (Alterações) também somem. O botão "Editar" (`#ips-review-editar-btn`, ao lado de "Gerar Arquivo") aparece só nesse estado e chama `POST /{id}/reabrir/`, atualizando `importacaoAtual` e re-renderizando as abas. Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela. ## Múltiplos arquivos de operadora Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação") era um único upload obrigatório — trocado por **1 ou mais arquivos** (pedido explícito do usuário): algumas operadoras mandam mensalidade e coparticipação em arquivos separados (a primeira real: Unimed Saúde, quando manda PDF em vez do CSV único — ver "Parser da Unimed Saúde" abaixo), em vez de um único arquivo com os dois tipos juntos. - **Backend**: `ImportacaoPlanoSaudeCreateSerializer.arquivo_operadora` é um `ListField(child=FileField(), allow_empty=False)` — o DRF já lê múltiplos arquivos do mesmo campo em `multipart/form-data` via `request.data.getlist(...)` (mesma semântica do `QueryDict`), sem tratamento manual extra na view. `create()` cria um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo (`ordem=índice`); `pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)` chama `OperadoraParser.extrai()` **uma vez por caminho** (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só o `pipeline.py`) e concatena os indivíduos/itens de auditoria de todos os arquivos antes de seguir com o casamento normal. - **`_agrega_individuos_entre_arquivos()` (`pipeline.py`)** — bug real encontrado e corrigido ao testar esta funcionalidade de ponta a ponta: se dois arquivos contribuem indivíduos da MESMA pessoa e do MESMO `tipo_lancamento` (ex.: duas coparticipações do mesmo mês, separadas por período), só concatenar as duas listas não bastava — `casa_individuos_com_planilha`/`_aplica_regra_custeio` (matcher.py) **grava** o valor final na `LinhaSistema` por pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (`valor_total` e `rubricas`) os indivíduos de mesma chave (`numero_beneficiario`, `tipo_lancamento`) **entre arquivos**, logo depois de concatenar as listas — mesmo padrão que cada parser já faz **dentro** de um único arquivo (`_agrega_por_individuo_e_tipo`), só que agora entre arquivos também. - `perform_destroy()`/os `except` de `create()` (arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam **todos** os arquivos de `importacao.arquivos_operadora.all()` de `MEDIA_ROOT`, não só um. - **Frontend**: `` + uma lista dinâmica (`#ips-form-arquivo-list`/`.ips-arquivo-list`, `importacao-plano-saude.js`) no lugar do campo único de sempre — cada arquivo anexado é validado individualmente (mesmo endpoint `POST /.../validar-arquivo/` de sempre, chamado uma vez por arquivo, sem mudança nenhuma nele) e listado com seu próprio status + botão de remover; trocar a operadora revalida todos os arquivos já anexados. No submit, `formData.append("arquivo_operadora", file)` uma vez por arquivo. ### Parser da Unimed Saúde (PDF): dois relatórios separados, tipo detectado automaticamente `operadoras/unimed/saude.py` (`unimed_saude`, código 5060) ganhou um segundo formato de entrada, além do CSV único já existente: **dois PDFs** de um cliente real (mensalidade + coparticipação analítico), detectados automaticamente pelo **conteúdo** de cada arquivo — nunca pelo usuário escolhendo um "tipo de documento" (pedido explícito). `UnimedSaude.extrai()` abre o PDF com `pdfplumber` e olha a primeira página: `"BENEFICIARIOS COM FATURAMENTO NO MES"` → relatório de mensalidade (`_extrai_pdf_mensalidade`, uma linha por beneficiário, `extract_text()` simples já basta); `"SERVIÇOS PRESTADOS"`/`"ANALITICO"` → coparticipação analítica (`_extrai_pdf_coparticipacao`, várias linhas de serviço por beneficiário, somadas por pessoa). - Confirmado com o usuário: a coparticipação devida por beneficiário é a **soma do "Vl Total" de cada linha de serviço** daquele beneficiário — a coluna "Tt Copar" (valor fixo, repetido em toda linha do documento) **não é usada**. Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um item, cuja soma já está no valor do item principal) são ignoradas — senão duplicariam o valor. - `nome`/`Grau Dep.` (TITULAR/CONJUGE/FILHO(A)/...) só aparecem na primeira linha de cada bloco de atendimento — parsing com estado (mesmo padrão do `ItamedSaude`). - Valores nos dois PDFs vêm em **formato americano** (ponto decimal, vírgula de milhar — ex. "6,061.74"), ao contrário do formato BR do resto do pipeline — `_valor_pdf_para_float`, função própria, separada de `_valor_para_float` (BR, só pro CSV). - **`OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)`** (default: devolve `chave_casamento`, mesmo valor de sempre — método novo, backward-compatible pra todo outro parser) porque a coparticipação analítica da Unimed em PDF precisou de uma estratégia de casamento **diferente da mensalidade dentro da mesma operadora**: o "Beneficiario" desse relatório vem colado sem espaço com o nome e o grau de dependência (ex.: "0975.0167003824292ANDREIA STORMTITULAR") e o **nome sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM") — inviabilizando casamento por nome. Como esse relatório traz CPF completo e confiável, `UnimedSaude` usa `"cpf"` só pra `tipo_lancamento="coparticipacao"` quando a origem foi esse PDF (rastreado numa flag de instância, `self._veio_de_pdf_coparticipacao`, setada em `extrai()`); mensalidade (sem CPF em nenhum dos dois formatos) continua em `"nome"`. `pipeline.processa_importacao` chama `chave_casamento_para_tipo(tipo_lancamento)` em vez do atributo fixo. - **Validado contra os dois arquivos reais** (não só texto colado numa conversa — o texto que sai de um PDF colado no chat **não é** o que `pdfplumber.extract_text()` de fato produz, então não serve pra desenhar regex com confiança; só o arquivo real confirma). Bate exatamente com "Total da Familia"/"Total da Sequencia" impresso no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e com o casamento por CPF contra a planilha padrão real da empresa — toda família presente na planilha bateu centavo a centavo; a família ausente da planilha de teste foi corretamente pra auditoria, não ignorada silenciosamente. - **Bug real corrigido (competência 09/2026, empresa Questor 604)**: `_GRAUS_DEPENDENCIA` não previa "COMPANHEIRO"/"COMPANHEIRA" — a coluna "Grau Dep." tem largura fixa de 10 caracteres, então esse grau (11 caracteres) sai truncado no relatório real como "COMPANHEIR". Sem essa entrada, a linha desse dependente não casava com `_PESSOA_COPARTICIPACAO_RE`, e o item de serviço dele (que ainda batia em `_ITEM_COPARTICIPACAO_RE`) era somado por engano no `pessoa_atual` anterior — na prática, no titular da mesma família (primeiro caso real de família com coparticipação em titular **e** dependente ao mesmo tempo; até então só se via titular sozinho). Corrigido acrescentando "COMPANHEIRO"/"COMPANHEIRA"/"COMPANHEIR" (a forma truncada, a que de fato aparece) a `_GRAUS_DEPENDENCIA`; validado rodando `extrai()` de ponta a ponta contra o arquivo real (família R$144,35 → titular R$134,33 + dependente R$10,02, batendo com "Total da Familia" impresso, e total geral do arquivo R$606,61 batendo com a soma dos "Total da Familia" das 3 famílias do documento). A importação já existente no banco (id 88, competência 09/2026) tinha sido corrigida manualmente na tela de Revisão antes deste fix (`ImportacaoPlanoSaudeAlteracao` ids 29/30) — não precisou de correção retroativa, só as importações futuras dependiam deste ajuste no parser. - **Bug real corrigido (empresa Questor 1970, "Rede Brasil de Mídia OOH", competência 08/2026)**: `_ITEM_COPARTICIPACAO_RE` exigia fronteira de palavra (`\b`) dos dois lados do código de "Tipo Serviço" (CON/EXA/HOS/CLI/ODO/MED). Neste arquivo real, esse código vem **colado sem espaço** ao final do nome do Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO GUSTAVEXA 40316572..." — mesmo estilo de coluna colada já visto no "Beneficiario" da nota acima, só que aqui na coluna de tipo de serviço), então nenhuma linha de item deste arquivo casava — resultado final era **zero beneficiários** ("Nenhum beneficiário foi encontrado neste arquivo"), mesmo com a detecção do layout e o casamento de linha de pessoa funcionando normalmente. O mesmo arquivo trouxe de quebra um grau de dependência ("OUTROS DEP") e um código de tipo de serviço ("CIR", cirurgia) não previstos. Corrigido: `_ITEM_COPARTICIPACAO_RE` perdeu a fronteira de palavra à esquerda (mantida só à direita) e ganhou "CIR"; "OUTROS DEP" foi acrescentado a `_GRAUS_DEPENDENCIA` (sem isso, mesmo corrigindo o regex do item, a coparticipação dessa dependente cairia por engano na pessoa anterior do bloco, mesmo bug da nota acima). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 2 beneficiários (KARLA VANESSA R$247,74 + RAPHAELA SOUZ R$183,28), somando R$431,02, batendo exatamente com "Total da Familia" impresso; o arquivo de mensalidade da mesma competência (regex própria, não afetada) continuou extraindo os mesmos 3 beneficiários de sempre. ## Planilha padrão via Questor (SQL) Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total. - **Toggle na tela** (`importacao-plano-saude.js`/`.html`, dentro do bloco "1. Planilha padrão"): dois radios, "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente" — o primeiro revela um campo "Competência" **texto livre com máscara MM/AAAA** (`` + `mascaraCompetencia()`/`competenciaParaIso()` em JS — deliberadamente **não** um ``: o seletor nativo do browser foi rejeitado pelo usuário como UX ruim; mesmo padrão de digitação livre já usado em `indicador-desempenho.js`/`pidIndMascaraCompetencia`, copiado aqui em vez de compartilhado, como as demais funções pequenas duplicadas entre telas), o segundo revela o `` de sempre. Trocar de modo limpa o outro campo, pra nunca mandar os dois juntos (o backend também recusa isso). - **Backend**: `ImportacaoPlanoSaudeCreateSerializer.competencia` é um `serializers.DateField()` normal (mesmo padrão de `IndicadorApuracaoCreateSerializer.competencia`) — o frontend já manda o ISO `"AAAA-MM-01"` convertido a partir da máscara, nunca a string mascarada crua. `validate()` exige exatamente uma das duas origens (nunca as duas, nunca nenhuma). - `ImportacaoPlanoSaudeViewSet.create()` (views.py): quando não veio arquivo, resolve a planilha **antes** de criar o registro — `portal_api.planos_saude.questor_planilha.busca_linhas_questor(codigo_empresa, codigo_operadora, competencia)` consulta `sqls.questor.QuestorSQL.consulta_planilha_plano_saude` (só leitura — `select_mappings_query`, nunca `execute`/`execute_returning`, ver [[feedback_bancos_externos_somente_leitura]]) via `DatabaseConnection("questor")`. Uma falha de conexão/consulta aqui devolve 400 direto, sem nada persistido ainda (diferente do caminho de upload, que só sabe se o arquivo é válido depois de já ter salvo o registro — por isso, nesse, o cleanup de arquivo/registro órfão continua sendo necessário). Zero linhas retornadas (empresa sem plano ativo na competência) também é 400. O resultado é serializado de volta pra CSV (`questor_planilha.linhas_para_csv_bytes`, mesmo formato de `leiaute_sistema.CABECALHO`) e salvo como `ContentFile` no próprio campo `planilha_padrao` — preserva o histórico completo mesmo pra importações que nunca tiveram upload. A "trava de conferência do código de empresa" (que confere que a planilha anexada tem alguma linha da empresa da regra) é pulada nesse caminho, redundante já que a consulta já filtrou por esse `codigo_empresa`. - **`pipeline.processa_importacao`** deixou de ler o arquivo sozinho (não recebe mais `caminho_planilha_padrao: str`) — recebe `linhas_sistema_template: List[LinhaSistema]` já pronta, de qualquer uma das duas origens (`le_planilha_padrao(caminho)` pro upload, `busca_linhas_questor(...)` pro Questor) — a decisão de qual usar ficou inteiramente em `views.py create()`. - **Código da operadora**: até então só existia embutido no `label` de `pipeline.OPERADORAS` (ex. `"5060 - Unimed Saúde"`, extraído por *string split* onde só o nome era preciso). Passou a existir como campo próprio (`OPERADORAS[chave]["codigo_operadora"]`, junto de `"nome"`) — é o valor usado pra filtrar a consulta por operadora (`codigooutemp` no Questor, código da OPERADORA, não confundir com `codigo_empresa` do cliente); `pipeline.label_operadora(chave)` calcula o `" - "` de exibição a partir desses dois campos onde ainda é preciso (`lista_operadoras()`, mensagens de erro, `nome_operadora` da importação). - `ImportacaoPlanoSaude.competencia` (`DateField`, null) registra a competência usada — só preenchida quando a origem foi o Questor; exibida na tela de Revisão ao lado do nome da operadora. ## Resolução manual de auditoria por nome Quando o casamento por nome falha (`NOME_DIVERGENTE`/`NAO_CADASTRADO` — ver `matcher.py`, "nunca resolvido por aproximação automática"), o colaborador pode confirmar manualmente que aquele item **é** uma pessoa específica já presente na planilha padrão, em vez de deixar o lançamento parado em auditoria pra sempre. Não é fuzzy matching nem aproximação automática — é sempre uma confirmação humana, explícita, item por item; a regra de "nome exato ou vai pra auditoria" do `matcher.py` continua intocada. - **Endpoint**: `POST /api/importacoes-plano-saude-auditoria/{id}/resolver/` com `{"linha_id": }`. Validações em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver` (views.py): o item precisa ter um motivo em `MOTIVOS_RESOLVIVEIS` e ainda não estar `resolvida` (idempotente — não dá pra resolver de novo, nem trocar o vínculo depois); a linha escolhida precisa (a) ser da mesma importação e do mesmo `tipo_lancamento` do item; (b) ser do mesmo "lado" — titular pra item `tipo="T"`, dependente pra `tipo!="T"` (D/A) — comparando `linha.nome_dependente`/`cpf_dependente` vazios ou não; (c) **ainda estar em branco** (`valor == valor_empresa == "0"`), decisão explícita do usuário pra nunca sobrescrever sem querer um lançamento que já casou automaticamente com outra pessoa do arquivo da operadora. - Ao vincular, o `valor` do item de auditoria é dividido em `valor_empresa`/`valor` pela mesma regra de custeio já salva em `ImportacaoPlanoSaude.custeio_por_tipo[tipo_lancamento]` para aquele tipo de pessoa (titular/dependente) — `matcher.valores_formatados_para_pessoa(valor_total, regra_por_pessoa, tipo_pessoa)` é o único ponto de entrada público do módulo pra isso, reaproveitando as mesmas `_regra_para_pessoa`/`_calcula_valores` do fluxo automático (não existe uma segunda fórmula "manual"). **Exceção**: quando a importação tem `regra_empresa` configurada (ver "Regra empresa" abaixo) e o item é de `tipo_lancamento="mensalidade"`, esse caminho por pessoa não se aplica — bug real visto com dados reais, o valor caía inteiro em desconto do empregado, ignorando a regra empresa. `resolver()` grava o valor bruto do item na linha (placeholder) e chama `_recalcula_familia_regra_empresa(importacao, linha)`, que reúne **todas** as linhas de mensalidade da mesma família (`nome_func` igual) — recuperando o valor bruto de cada uma como `valor_empresa + valor`, soma que preserva o total independente do split aplicado antes — e reaplica a regra empresa (`REGRAS_EMPRESA[chave]["aplica"]`) na família inteira de uma vez, salvando todas as linhas afetadas (`bulk_update`). Precisa reaplicar na família toda, não só na linha recém-vinculada, porque o valor novo muda o total da família e o teto (`_aplica_teto_familia`, priorização dependente→titular) precisa ser redistribuído do zero. - O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — " (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado. - **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria. ## Vínculos de nome salvos (DE/PARA) Depois de "Vincular pessoa" resolver manualmente uma divergência de nome, o usuário perguntou se ela precisava ser refeita em toda execução futura ou se podia ficar guardada, "como se fosse um DE/PARA" — decisão explícita do usuário: sim, guardar e reaplicar automaticamente, mostrando cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação/fuzzy matching (ver `matcher.py`) — o DE/PARA só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez; sem vínculo salvo, o comportamento é idêntico a antes (cai em auditoria). - **Model** (`VinculoNomeOperadora`, migração `0051`): `operadora` (chave de `pipeline.OPERADORAS`), `codigo_empresa` (cru, sem normalizar — ver abaixo por quê), `nome_arquivo_operadora` (o nome divergente do arquivo da operadora, já normalizado via `matcher.normaliza_nome` — é a chave de busca), `nome_func_destino`/`nome_dependente_destino` (o nome real na planilha padrão — só um dos dois preenchido, conforme o vínculo seja de titular ou de dependente), `criado_em`/`criado_por`. `unique_together` em `(operadora, codigo_empresa, nome_arquivo_operadora)`. - **`codigo_empresa` fica cru no model, normalizado só em `views.py`**: importar `empresas_questor.normalizar_codigo_empresa` dentro de `models.py` criaria um import circular (`empresas_questor.py` já importa `EmpresaQuestor` de `models.py`) — por isso a normalização acontece nos dois pontos de uso em `views.py` (`_carrega_vinculos_por_nome`, `resolver()`), que já importam essa função pra outros fins (ver "Nome da empresa (Questor)" acima). - **Gravado em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()`** (mesma view de "Vincular pessoa" acima) — depois de aplicar a resolução manual, `update_or_create` um `VinculoNomeOperadora` com `nome_arquivo_operadora=normaliza_nome(item.nome)` e o destino (`linha.nome_func` se `item.tipo == "T"`, senão `linha.nome_dependente`). Só grava se `linha.codigo_empresa` normalizado não for vazio (sempre o caso na prática). - **Aplicado em `matcher._casa_por_nome`** (não em `_casa_por_cpf` — CPF já é exato por natureza, nunca precisa de DE/PARA): recebe `vinculos_por_nome: Dict[str, VinculoNome]` (`nome normalizado -> VinculoNome`, dataclass "pura" sem ORM em `planos_saude/modelos.py`) e `tipo_lancamento` (só pra rotular o `VinculoAplicado` gerado, o dict em si não é escopado por tipo — o mesmo DE/PARA vale pra mensalidade e coparticipação da mesma operadora+empresa). Quando o titular ou o dependente não bate por nome exato, checa `vinculos_por_nome.get(nome_normalizado)` antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitando `regra_empresa_fn`, já que o vínculo só decide QUAL linha usar — o resto do fluxo de custeio é idêntico ao casamento por nome exato) e registra um `VinculoAplicado` (índice da linha dentro do `tipo_lancamento`, id do vínculo, nome do arquivo da operadora) — devolvido em `ResultadoProcessamento.vinculos_aplicados` (`pipeline.py`) pra `views.py` montar os registros de `ImportacaoPlanoSaudeAlteracao` depois que as linhas estiverem persistidas (no momento do casamento elas ainda não têm `id`). - **`ImportacaoPlanoSaudeViewSet.create()`**: `_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)` (views.py) busca todo `VinculoNomeOperadora` da operadora cujo `codigo_empresa` normalizado apareça em algum `LinhaSistema` da planilha padrão desta importação, monta o dict e passa em `processa_importacao(vinculos_por_nome=...)`. Depois do `bulk_create` das linhas, correlaciona cada `VinculoAplicado.indice_linha` (índice dentro do `tipo_lancamento`, o mesmo usado como `ordem` na criação da linha) com a `ImportacaoPlanoSaudeLinha` já persistida e cria um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=`, `valor_novo=`) por vínculo aplicado. - **`POST /.../reverter/` (botão "Apagar vínculo")**: mesmo endpoint de reverter uma alteração normal (ver "Alterações" abaixo) — pra `TIPO_VINCULO_AUTOMATICO`, zera `valor`/`valor_empresa` da linha (reaplicando a regra empresa da família, se houver, mesma lógica de `_recalcula_familia_regra_empresa`) **e** apaga o `VinculoNomeOperadora` (`SET_NULL` em qualquer outra `ImportacaoPlanoSaudeAlteracao` que o referenciasse) — pra essa divergência voltar a cair em auditoria numa importação futura em vez de ser reaplicada sozinha. Como o vínculo é global (não por importação), apagá-lo afeta todas as importações futuras da mesma operadora+empresa, não só a atual. - **Frontend**: badge próprio (`.ips-alteracao-tipo--vinculo_automatico`, cor `--accent`) na aba Alterações, com o detalhe `"" (arquivo da operadora) → (planilha padrão)` e o botão de ação lendo "Apagar vínculo" em vez de "Reverter" (mesmo endpoint, `pidReverterAlteracaoPlanoSaude`) — a confirmação (`pidConfirm`) também tem um texto próprio avisando que a divergência volta a cair em auditoria. ## Alterações (histórico de edição/inclusão/exclusão de linha, com reversão) Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha"), exclusão de linha e vínculo automático de nome (ver "Vínculos de nome salvos (DE/PARA)" acima) feitos na própria tela de revisão (ou, no caso do vínculo automático, aplicados por `create()` a partir de um DE/PARA já salvo), com um botão pra reverter/apagar cada um individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação, uma exclusão ou um vínculo automático indesejado, sem precisar reprocessar a importação do zero. - **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`, `vinculo_nome` adicionado na `0051`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`/`vinculo_automatico`), `linha` (FK `SET_NULL` — fica `null` quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), `campo`/`valor_anterior`/`valor_novo` (só preenchidos em `edicao`; em `vinculo_automatico`, `valor_novo` guarda o nome do arquivo da operadora), `dados_linha` (JSONField — snapshot de todos os campos editáveis da linha **+** `ordem`, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), `vinculo_nome` (FK `SET_NULL`, só em `vinculo_automatico`), `usuario`, `criado_em`, `revertida`/`revertida_em`. - **Fora de escopo de propósito**: o próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade. Só a reaplicação automática desse vínculo numa importação **futura** vira um registro do tipo `vinculo_automatico`. - **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada ``, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. `vinculo_automatico` é gravado em `ImportacaoPlanoSaudeViewSet.create()` (ver "Vínculos de nome salvos (DE/PARA)" acima), não no `LinhaViewSet`. - **`POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/`** (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) — idempotente, recusa reverter de novo uma alteração já `revertida`. A própria reversão **não** gera um novo registro de alteração (evitaria um loop de "reverter a reversão"): - `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo. - `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão). - `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela. - `vinculo_automatico`: zera `valor`/`valor_empresa` da linha vinculada (reaplicando a regra empresa da família, se `tipo_lancamento == "mensalidade"` e a importação tiver `regra_empresa`) e apaga o `VinculoNomeOperadora` associado — ver "Vínculos de nome salvos (DE/PARA)" acima. - **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao/--vinculo_automatico`, cores dourado/teal/vermelho/`--accent`), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`": "" → """` pra edição, texto fixo pra inclusão/exclusão, `"" → ` pra vínculo automático) e o usuário. A coluna "Ação" mostra "Reverter" ou "Apagar vínculo" (conforme o tipo, com `pidConfirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas. ## Pré-validação de arquivo ao anexar (tela de Nova Importação) Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma). - **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` **e também** `.finaliza()` (ver `OperadoraParser.finaliza()` acima) na mesma instância, somando `individuos` + `individuos_finais` + `auditoria_final` pra decidir se algo foi encontrado — necessário porque uma operadora que devolve dado retido em `finaliza()` (hoje só a Unimed Cascavel) processa cada arquivo **sozinho** aqui, sem ver os demais arquivos da importação real; sem essa chamada extra, um arquivo cujo conteúdo só é liberado em `finaliza()` sempre pareceria vazio (bug real, ver `CHANGELOG.md`). Os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos/itens de auditoria extraídos (arquivo no formato certo mas vazio) também vira `valido=False`. - **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão. - **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do ``, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva. Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`. ## Cadastro de Regras (separado da execução da importação) Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora: - **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `" - "` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora". - **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `` · ``" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`). - **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar". - **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema. - **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado). - **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `" - "`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `" - "` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um `