portal_publico/portal_api/planos_saude/CLAUDE.md

97 KiB
Raw Blame History

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/<nome>/<arquivo>.py implementando OperadoraParser.extrai() (devolve (List[Individuo], List[ItemAuditoria])) e registrar em pipeline.OPERADORAS. Antes de escrever o parser, ler projects/importacao-planos-saude.skill — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.

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." (<família>/00 = titular, <família>/01, /02... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". Ressalva importante: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar pdfplumber/docling de verdade) — a extração via pdfplumber foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas ainda precisa ser confirmada rodando o parser contra o arquivo real (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via docling, como a Bradesco Saúde.

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 ".<empresa+contrato>.<sequência>-" = 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 - <competência>") 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 (<plano>.<família>.<pessoa>, 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.
  • 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 "<código> - <Nome>" (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 <input> 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 <th> 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 <input>, 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: <input type="file" multiple> + 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 (<input type="text" inputmode="numeric" placeholder="MM/AAAA" maxlength="7"> + mascaraCompetencia()/competenciaParaIso() em JS — deliberadamente não um <input type="month">: 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 <input type="file"> 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 "<código> - <Nome>" 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": <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 <input type="radio"> — 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=<vínculo>, valor_novo=<nome do arquivo da operadora>) 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 "<nome do arquivo>" (arquivo da operadora) → <nome vinculado> (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 <input>, 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 ("<campo>: "<anterior>" → "<novo>"" pra edição, texto fixo pra inclusão/exclusão, "<nome do arquivo>" → <nome vinculado> 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 <input type="file"> 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 <input type="file">, 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 <input> 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 "<código> - <nome>" — 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: <código - nome> · <operadora>" (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 "<código> - <nome>") 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 "<código> - <nome>" 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 <select> nativo) — clicar na seta mostra a lista completa de novo, ou, se já houver algo selecionado, as outras opções cadastradas (sem repetir a já escolhida). Existe porque só focar o campo com um valor já preenchido filtra a lista pelo texto atual, então só mostraria de novo o item já selecionado — a seta é o jeito de "trocar fácil" pedido pelo usuário, no mesmo espírito de um filtro de BI (clicar, ver todas as opções, escolher outra).

O bloco de checkboxes/radios de custeio (.ips-tipo-field, mensalidade/coparticipação × titular/dependente/regra específica) e as funções JS que o operam (coletarCusteioAtual(), mensagemErroCusteio(), aplicarCusteio(), limparCusteioForm()) foram movidos (não duplicados) de "Nova Importação" pro modal de Cadastro — mesmos ids de DOM, mesma lógica, só relocados; "Nova Importação" monta o FormData do submit direto a partir do objeto regraResolvidaAtual em memória (montarFormDataDeRegra()), não mais lendo inputs (que não existem mais ali).

  • Campos de RegraCusteioPlanoSaude: codigo_empresa (obrigatório — o código do cliente/empresa; não confundir com o código de cadastro da operadora no Questor, que já aparece dentro do label de pipeline.OPERADORAS, ex. "5060 - Unimed Saúde" — são códigos diferentes), operadora (obrigatória agora, validada contra pipeline.OPERADORAS), regra_empresa_chave (ver "Regra empresa" abaixo), tipos_lancamento/custeio_por_tipo (mesmo formato dos campos homônimos de ImportacaoPlanoSaude) e observacoes. Meta.unique_together = [["codigo_empresa", "operadora"]] — validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia). nome deixou de ser digitado pelo usuário — é sempre derivado em RegraCusteioPlanoSaudeSerializer.validate() como "<codigo_empresa> - <nome da operadora sem o código dela>" (campo read_only=True na API); mantido como campo de model só pra não precisar tocar em todo lugar que já lê .nome/regra_custeio_salva_nome.
  • Migração em 3 passos (mesmo padrão já usado pra IndicadorDepartamento, migrations 0033/0034/0035): 0041 adiciona codigo_empresa/regra_empresa_chave (blank) + torna operadora obrigatória; 0042 (RunPython) faz o backfill de codigo_empresa a partir do nome existente (nome.split(" - ", 1)[0].strip()); 0043 torna codigo_empresa obrigatório e adiciona o unique_together. Meta.ordering usa [Length("codigo_empresa"), "codigo_empresa", "operadora"] (mesmo padrão de IndicadorApuracaoEmpresa) pra ordenar o código como número, não como string.
  • Validação reaproveitada, não duplicada: RegraCusteioPlanoSaudeSerializer.validate() e ImportacaoPlanoSaudeCreateSerializer.validate() continuam chamando a mesma função módulo-level _monta_regra_custeio() (serializers.py) pra validar/parsear cada combinação tipo×pessoa. UniqueTogetherValidator é declarado explicitamente em Meta.validators (não só o automático do DRF), pra manter a mensagem de erro em português.
  • ImportacaoPlanoSaude.regra_custeio_salva (FK opcional, SET_NULL) registra qual regra foi aplicada numa importação — agora praticamente sempre preenchida (já que "Nova Importação" só resolve custeio a partir de uma regra cadastrada), mas o campo continua opcional a nível de API (a garantia de "sempre passar por uma regra cadastrada" é uma trava de UI, não uma obrigatoriedade no backend). Alimenta regra_custeio_salva_nome/regra_custeio_salva_observacoes na tela de Revisão, como antes.
  • Trava de conferência do código de empresa (ImportacaoPlanoSaudeViewSet.create(), depois do processamento e antes do bulk_create das linhas): se regra_custeio_salva está presente, confere que ao menos uma linha da planilha padrão processada tem codigo_empresa igual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outros except desse método) e devolve 400 com mensagem clara — evita aplicar a regra de uma empresa a uma planilha de outra por engano. Vale pra toda regra aplicada, inclusive as com regra_empresa_chave (onde é redundante com a checagem que regras_empresa.valida_regra_empresa() já faz — proteção extra contra o registro em REGRAS_EMPRESA ficar dessincronizado da RegraCusteioPlanoSaude correspondente).

Nome da empresa (Questor) — primeiro consumidor real do pacote database/ (ver project_database_package na memória): resolve e cacheia localmente o nome de uma empresa a partir do seu codigo_empresa, pra mostrar "<código> - <nome>" em vez de só o código nas telas acima.

  • EmpresaQuestor (models.py, migração 0044): codigo_empresa (único) + nome_empresa, um cache local simples — sem relação de FK com RegraCusteioPlanoSaude (é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmo codigo_empresa com operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha de EmpresaQuestor).
  • portal_api/empresas_questor.py, resolve_nome_empresa(codigo_empresa): olha o cache primeiro; só na ausência dele consulta o Questor (database.connection.DatabaseConnection("questor") — chave em minúsculas, DatabaseSettings normaliza as chaves de DATABASE__<NOME>__* do .env assim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executando sqls.questor.QuestorSQL.consulta_nome_empresa() (select codigoempresa, nomeempresa from empresa where codigoempresa = :codigo_empresa — a consulta exata fornecida pelo usuário, só parametrizada), e persiste o resultado antes de devolver — nunca precisa repetir a consulta pro mesmo código depois. Qualquer falha (código inexistente, codigoempresa do Questor é smallint e um código fora da faixa numérica levanta DataError, banco inacessível) é capturada e devolve None — nunca propaga a exceção, já que isso é só informativo, nunca bloqueia cadastrar/editar/excluir uma regra.
  • normalizar_codigo_empresa(valor) (mesmo arquivo): remove zero à esquerda ("092" → "92") — decisão explícita do usuário, pra sempre ter um único código canônico por empresa (o codigoempresa do Questor é smallint, então "092"/"92" já eram a mesma linha lá; sem normalizar no Portal, apareciam como duas empresas "diferentes"). Aplicada em toda entrada de codigo_empresa vinda de fora: resolve_nome_empresa(), RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa() (o que é de fato salvo em RegraCusteioPlanoSaude.codigo_empresa), a action nome-empresa (devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência em ImportacaoPlanoSaudeViewSet.create() (normaliza os dois lados antes de comparar, já que o código bruto da planilha pode ter zero à esquerda enquanto o da regra não tem mais). Nunca aplicada a ImportacaoPlanoSaudeLinha.codigo_empresa em si (precisa continuar exatamente como veio da planilha, pra não alterar o que é reexportado) — só normalizada no momento de uma comparação/exibição pontual (ver _nome_empresa_cacheado(), que normaliza antes de consultar EmpresaQuestor a partir do código cru de uma linha).
  • sqls/questor.py (pacote novo na raiz do projeto, ao lado de database/ — seguindo a convenção "uma pasta sqls/ por projeto consumidor, um arquivo por banco" já documentada na memória): classe QuestorSQL, hoje só consulta_nome_empresa(). Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendo SQLQuery(sql=dedent(...), params={...}); nunca usar execute/execute_returning desses bancos sem autorização explícita (ver feedback_bancos_externos_somente_leitura).
  • Correção em database/settings.py (arquivo compartilhado, não específico desta ferramenta): SUPPORTED_DRIVERS["postgresql"] apontava pra "postgresql+psycopg2", mas o .venv do Portal só tem psycopg (v3) instalado, não psycopg2 — ModuleNotFoundError ao tentar conectar. Corrigido pra "postgresql+psycopg" (dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalar psycopg2-binary à parte. Se database/ for reaproveitado por outro projeto que dependa especificamente de psycopg2 (comportamento antigo), essa mudança precisaria ser revisitada — não é o caso hoje.
  • Resolução automática pra regras já existentes: RegraCusteioPlanoSaudeSerializer.get_nome_empresa() chama resolve_nome_empresa() a cada leitura (não só ao criar/editar) — então regras cadastradas antes deste campo existir tiveram o nome resolvido e cacheado sozinho, na primeira vez que a lista foi carregada depois do deploy, sem precisar de nenhum backfill manual. Já ImportacaoPlanoSaudeDetailSerializer.get_nome_empresa() (tela de Revisão) só lê o cache (_nome_empresa_cacheado(), sem chamar resolve_nome_empresa()) — essa tela é consultada com muito mais frequência, e o nome já deveria estar cacheado desde que a regra foi cadastrada/editada, então não vale pagar o custo de uma consulta ao Questor ali.
  • Frontend (importacao-plano-saude.js): GET /api/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=X (pidBuscarNomeEmpresaPlanoSaude) é chamado tanto num debounce de 350ms a cada tecla digitada no campo "Código da empresa" de "+ Nova regra" (agendarAtualizarNomeEmpresaNovo(), pedido explícito do usuário pra não precisar esperar o campo perder o foco) quanto no blur (imediato, cancela o debounce pendente) — a função de fato (atualizarNomeEmpresaNovo()) mostra "Buscando nome da empresa...", depois o nome resolvido ou "Empresa não encontrada no Questor.", e reescreve o próprio campo com o codigo_empresa normalizado devolvido pela resposta (ex.: usuário digita "092", campo passa a mostrar "92" assim que resolve). Os comboboxes de "Empresa" (Cadastro de Regras e Nova Importação) não fazem nenhuma chamada nova — labelEmpresa() monta "<código> - <nome>" direto do array regras já carregado, que já vem com nome_empresa resolvido pelo backend.

Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação)

Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por família inteira (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (portal_api/planos_saude/regras_empresa.py, REGRAS_EMPRESA: Dict[str, dict]) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo RegraCusteioPlanoSaude.regra_empresa_chave (chave de REGRAS_EMPRESA) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio.

Deixou de ser exclusivo de "mensalidade" — cada regra em REGRAS_EMPRESA agora declara tipos_lancamento (quais tipos ela cobre — a Tecnomyl abaixo só cobre ("mensalidade",), a Ottimizza abaixo cobre ("mensalidade", "coparticipacao")) e chave_casamento (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra "Regra especial da empresa" (#ips-form-tipo-regra-empresa, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação".

  • Mutuamente exclusivo por TIPO, não em bloco: no formulário de Cadastro de Regras, escolher uma regra especial trava (marca + desabilita + esconde os radios titular/dependente) só os checkboxes "Mensalidade"/"Coparticipação" que essa regra específica cobre — aplicarTiposRegraEmpresa() em importacao-plano-saude.js, chamada sempre que a regra selecionada muda (ao marcar/desmarcar o checkbox, ou ao escolher uma regra no picker). Uma regra que só cobre mensalidade (Tecnomyl) deixa "Coparticipação" livre pra configuração manual normalmente — mesmo comportamento de antes pra essa regra específica; a novidade é só que agora isso é decidido pelos tipos_lancamento de CADA regra, não fixo no código do formulário. Um checkbox travado (.disabled) não dispara change por clique do usuário, então a exclusividade mútua não precisa de nenhuma lógica extra nos handlers de "Mensalidade"/"Coparticipação" — só o handler de "Regra especial da empresa"/a seleção no picker chamam aplicarTiposRegraEmpresa().
  • No backend, RegraCusteioPlanoSaudeSerializer.validate()/ImportacaoPlanoSaudeCreateSerializer.validate() calculam regra_empresa_tipos (interseção entre REGRAS_EMPRESA[chave]["tipos_lancamento"] e os tipos selecionados — erro claro se vier vazia), conferem chave_casamento_para_tipo(tipo) == REGRAS_EMPRESA[chave]["chave_casamento"] pra cada tipo coberto (não mais um "exige nome" hardcoded) e zeram custeio_por_tipo[tipo] só pros tipos em regra_empresa_tipos (os demais tipos selecionados continuam com custeio manual normal). regras_empresa.valida_regra_empresa(regra_empresa_key, chave_casamento_por_tipo, linhas_sistema, tipos_selecionados) devolve (aplica, tipos_cobertos) — pipeline.processa_importacao passa regra_empresa_fn pra casa_individuos_com_planilha só quando tipo_lancamento in tipos_cobertos.
  • matcher._casa_por_cpf passou a suportar regra_empresa_fn (antes só _casa_por_nome suportava) — sem agrupar por família (essa estratégia não tem esse conceito): acumula (linha, valor_total) de todo indivíduo casado por CPF e chama regra_empresa_fn(linhas_e_valores, tipo_lancamento) uma vez só, no fim, com todos os pares do tipo de lançamento inteiro. aplica(linhas_e_valores, tipo_lancamento) é a assinatura de toda regra agora (segundo argumento novo) — permite uma mesma função se comportar diferente por tipo (ver Ottimizza abaixo); a Tecnomyl recebe o parâmetro mas ignora (só é chamada pra "mensalidade" mesmo, via tipos_lancamento).
  • Registro (REGRAS_EMPRESA): cada entrada tem label, codigo_empresa (código da empresa na planilha padrão pra qual a regra foi negociada), operadora, chave_casamento, tipos_lancamento, aplica (função que faz o cálculo) e observacoes. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (GET /api/importacoes-plano-saude/regras-empresa/ já reflete o registro, incluindo tipos_lancamento, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação").
  • Observações da regra, só-leitura na tela de Revisão (#ips-review-regra-empresa-obs) — inalterado: ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes resolve REGRAS_EMPRESA[obj.regra_empresa]["observacoes"] a cada carregamento; o mesmo bloco cai pra regra_custeio_salva_observacoes quando não há regra empresa.
  • unimed_1778_tecnomyl — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026. chave_casamento="nome" (precisa agrupar família), tipos_lancamento=("mensalidade",) — coparticipação dela segue sempre o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra.
    • Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual (regras_empresa._aplica_teto_familia) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto proporcionalmente entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo valor_empresa = min(seu valor, o que sobrou do teto); só depois de todos os dependentes processados o titular absorve o que sobrou do teto (teto_restante), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com valor_empresa=559,57/valor=0 (coberto integralmente), sobra 661,61-559,57=102,04 de teto pro titular, que sai com valor_empresa=102,04/valor=212,08. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com valor_empresa=0 (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
    • _aplica_teto_familia é duck-typed de propósito (linhas_e_valores: List[Tuple[Any, float]], _eh_linha_titular() própria em vez de LinhaSistema.eh_linha_titular()): roda tanto contra LinhaSistema (pipeline, na criação da importação) quanto contra ImportacaoPlanoSaudeLinha (model Django, no recálculo pós "Vincular pessoa" — ver views._recalcula_familia_regra_empresa e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (nome_dependente/cpf_dependente/valor_empresa/valor), só a segunda não tem o método eh_linha_titular().
  • sulamerica_5775_ottimizza — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. chave_casamento="cpf" (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — _regra_sulamerica_5775_ottimizza decide por linha, olhando só _eh_linha_titular(linha) e o tipo_lancamento recebido), tipos_lancamento=("mensalidade", "coparticipacao") — as duas cobertas pela mesma função, que ramifica por tipo_lancamento. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando pipeline.processa_importacao de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação).
  • Trava de compatibilidade generalizada: regras_empresa.valida_regra_empresa() recusa explicitamente (RegraEmpresaIncompativelError, capturada à parte em views.py pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os tipos_lancamento da regra, (b) a operadora escolhida não usa a chave_casamento que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o codigo_empresa esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes).
  • "Vincular pessoa" (resolução manual de auditoria) também generalizada: _recalcula_familia_regra_empresa (views.py) filtra por linha.tipo_lancamento (o tipo da própria linha resolvida), não mais fixo em "mensalidade", e passa esse tipo como segundo argumento pra regra["aplica"]; resolver() decide se aplica esse caminho checando se item.tipo_lancamento está em REGRAS_EMPRESA[chave]["tipos_lancamento"], não mais comparando com a string "mensalidade" direto.