portal_publico/portal_api/planos_saude/CHANGELOG.md

84 KiB
Raw Blame History

Changelog — Importação de Plano de Saúde

Histórico específico desta aplicação, extraído de plano.md (mesma numeração de rodada usada lá, para referência cruzada). É a aplicação com mais rodadas do projeto — cada operadora nova, cada regra de custeio e cada bug de parser tem sua própria entrada abaixo.

Rodada 36 — Importação de Plano de Saúde (Utilitários)

Primeira aplicação de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu nesta mesma rodada). 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 do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. A lógica de negócio veio de um pipeline já testado (projects/importacao-planos-saude.skill + protótipo em projects/project/), portado quase 1:1 pra dentro do Django em portal_api/planos_saude/ (pacote Python puro, sem ORM).

Decisões principais: permissão de toggle único (quem tem acesso faz o fluxo inteiro — criar, revisar, gerar); histórico completo (cada importação fica salva, diferente de um fluxo descartável); regra de custeio (empresa/empregado/específica, com limite de valor e/ou percentual) configurável por tipo de lançamento e tipo de beneficiário na tela, em vez da regra fixa do pipeline original; resolução manual de auditoria por nome (confirmação humana item por item quando o casamento automático falha, nunca fuzzy matching); pré-validação de cada arquivo anexado antes do envio final, reaproveitando o mesmo parser Python que o create() usaria. Migrações 0021_importacaoplanosaude_importacaoplanosaudeauditoria_and_more e 0022_importacaoplanosaudeauditoria_resolucao_manual. Detalhe completo em CLAUDE.md desta pasta.

Rodada 47 — Bug: SuspiciousFileOperation ao anexar arquivo com nome muito longo

Testando em produção real, upload de um arquivo da operadora com nome de arquivo original bem longo (ex.: "LEIAUTE_IMPORTACAO_DESPESAS_MEDICAS_EMP_92_PRESCINOTTI_CIA_LTDA...OPER_5060_UNIMED_DO_ESTADO_DO_PARANA_-_FEDERACAO_ESTADUAL_DA.CSV") deu 400 com django.core.exceptions.SuspiciousFileOperation: Storage can not find an available filename ... Please make sure that the corresponding file field allows sufficient "max_length".

Causa: ImportacaoPlanoSaude.planilha_padrao/arquivo_operadora (FileField) não tinham max_length explícito — o padrão do Django é 100, insuficiente pra upload_to="planos_saude/planilha_padrao/" (ou .../operadora/) somado a um nome de arquivo original longo (nome de arquivo real do cliente, fora do controle do Portal) + o sufixo que a storage acrescenta pra evitar colisão.

Corrigido definindo max_length=255 nos dois campos (portal_api/models.py, migração 0036_alter_importacaoplanosaude_arquivo_operadora_and_more, aplicada no ambiente local). Mesmo cuidado vale pra qualquer FileField/ImageField novo que aceite nome de arquivo originado fora do Portal (upload do usuário) — o padrão de 100 caracteres do Django é apertado demais pra nomes de arquivo reais de operadoras/clientes.

Rodada 48 — Banco de regras de custeio salvas

Pedido do usuário, testando a importação da empresa 92 (Unimed): em vez de exportar/importar um arquivo .json com a regra de custeio preenchida (mecanismo puramente client-side, sem persistência — nada guardado no banco, sem nome, sem observação), ele queria um banco de regras de verdade: salvar a configuração usada como "092 - Unimed", escolhê-la numa lista em importações futuras, poder editá-la depois e anexar uma observação livre (ex.: "Empresa não desconta plano do empregado XX").

O que mudou:

  • Model novo RegraCusteioPlanoSaude (migração 0037_regracusteioplanosaude) — nome/operadora/tipos_lancamento/custeio_por_tipo (mesmo formato dos campos homônimos de ImportacaoPlanoSaude) + observacoes (texto livre) + criado_por/criado_em/atualizado_em. Lista compartilhada, sem "dono", mesma permissão de toggle único da ferramenta (PermissaoApp("utilitarios", "importacao-plano-saude")).
  • RegraCusteioPlanoSaudeViewSet (CRUD completo, GET/POST/PATCH/DELETE) registrado em /api/regras-custeio-plano-saude/, seguindo o mesmo padrão de IndicadorPercentualTipoViewSet (perform_create grava criado_por).
  • Validação de custeio extraída pra uma função compartilhada (_monta_regra_custeio(), serializers.py) — antes só existia dentro de ImportacaoPlanoSaudeCreateSerializer._valida_regra_especifica(); extraída pra módulo-level e reaproveitada por RegraCusteioPlanoSaudeSerializer.validate(), pra não duplicar a regra de negócio (parsing BR, faixa 0–100 do percentual, "ao menos limite ou percentual") em dois serializers que podiam divergir com o tempo. ImportacaoPlanoSaudeCreateSerializer foi refatorado pra chamar essa mesma função — comportamento idêntico, validado com teste manual comparando a saída antes/depois do refactor.
  • Round-trip float↔texto BR: uma regra salva volta do GET com limite_valor/percentual já como float (formato final persistido), mas a validação de entrada só entende texto BR ("150,00"). _valor_custeio_para_texto_br() normaliza um float de volta pra BR (via formata_valor_br, já existente em leiaute_sistema.py) antes de repassar pro parser — sem isso, reenviar uma regra sem editar o custeio (ex.: só corrigindo o nome) corromperia o valor ("150.0" seria lido como 15000 por parse_valor_br, que remove pontos como separador de milhar). Validado via shell: criar uma regra, pegar validated_data de volta e revalidar como se fosse um update sem mudanças reproduz exatamente o mesmo resultado.
  • Frontend (importacao-plano-saude.js/.html/.css): a seção "Regra de custeio" do formulário de Nova Importação trocou os botões "Exportar regra"/"Importar regra" por um <select> de regras salvas + "Aplicar" (preenche o formulário inteiro, incluindo a operadora se ainda existir na lista — aplicarRegraNoFormulario()), "Salvar regra atual..." (abre #ips-regra-save-modal pra nomear/descrever, nascendo em modo "atualizar" quando a regra aplicada ainda existe, com uma checkbox pra virar "criar nova" em vez de sobrescrever) e "Ver regras salvas" (#ips-regras-modal, lista com Aplicar/Excluir por linha). "Editar" uma regra não é uma tela separada — é aplicar, ajustar o que quiser nos campos normais do formulário, e salvar de novo (decisão deliberada pra não duplicar a grade de custeio dentro de um segundo modal). A validação de "custeio completo pros tipos marcados" (mensagemErroCusteio()) foi extraída do handler do botão "Processar" pra ser reaproveitada por "Salvar regra atual..." também.
  • Testado via Django test client (shell): criar/listar/atualizar/excluir uma regra pelo endpoint real, e confirmado que criado_por grava certo.
  • Ajuste de posição, no mesmo dia: a pedido do usuário, a seção "Regra de custeio salva" moveu do final do formulário (depois de "Tipo de importação") pro início, antes até de "Operadora" — já que aplicar uma regra também preenche a operadora, faz mais sentido esse ser o primeiro passo do fluxo. A borda de separação (.ips-regra-field) virou border-bottom (era border-top), já que agora separa do campo abaixo (Operadora), não de cima.

Rodada 49 — Quarta operadora: Dental Uni Odonto

Usuário forneceu um PDF real ("1084 - RELATORIO DENTAL UNI 072026.pdf", relatório "BENEFICIÁRIOS") + a planilha padrão (leiaute Questor) já casada como referência, descrevendo o formato: coluna "Beneficiário" traz titular e dependentes juntos (dependentes com indentação um pouco maior), sem CPF pra ninguém, coluna "Valor Unit" é o valor a custear/descontar de cada um.

  • Novo parser operadoras/dental_uni/odonto_mensalidade.py (DentalUniOdontoMensalidade), registrado em pipeline.OPERADORAS como dental_uni_odonto_mensalidade/"Dental Uni Odonto". chave_casamento = "nome" (sem CPF no arquivo, igual Unimed/Itamed) — só mensalidade (sem coluna de coparticipação nesse relatório).
  • Titular vs dependente por indentação, não por rótulo: ao contrário da Itamed (que tem "Titula"/"Dependente" escrito no início da linha), este relatório não rotula nada — só indenta o texto do dependente um pouco mais que o do titular. O parser resolve isso comparando a indentação de cada linha com a indentação da primeira linha de beneficiário do arquivo (sempre um titular, por construção do relatório): igual ou menor → Titular; maior → Dependente.
  • Nome quebrado em duas linhas: um titular do próprio exemplo ("SUZILAINE ZENATTI MEYER BEZERRA") tem o nome longo o bastante pra quebrar em duas linhas físicas no texto extraído do PDF, com o "[Nº Cartão]" só aparecendo na linha seguinte. O parser acumula linhas "órfãs" que parecem nome (só letras maiúsculas/espaços — nomes no relatório vêm 100% em caixa alta, o que distingue confiavelmente uma continuação de nome de qualquer outro texto do PDF, que nunca vem inteiramente maiúsculo) até encontrar a linha com o cartão, e usa a indentação da PRIMEIRA linha do bloco (não a da linha do cartão) pra decidir titular/dependente.
  • Extração do valor por padrão, não por posição de coluna: como o número de datas antes do "Valor Unit" pode variar (ex.: uma linha com Data Exclusão preenchida teria uma data a mais), o parser não conta colunas — pega sempre o PRIMEIRO número no formato monetário (vírgula decimal) depois do "[Nº Cartão]", já que datas (dd/mm/aaaa) nunca coincidem com esse padrão. A coluna "Total Fam" (só preenchida na linha do titular, soma da família) é ignorada de propósito, mesmo espírito da "Valor Total" da Amil.
  • Validado com o exemplo real (via script no shell do Django, não pelo formulário — ver caveat abaixo): os 11 lançamentos do PDF (4 famílias) foram extraídos corretamente, incluindo o nome quebrado em duas linhas, e o casamento com a planilha padrão fornecida bateu certo para 10 dos 11 — o 11º ("HELOISA NUNEZ RAMBO" no PDF vs "HELOISA NUNES RAMBO" na planilha, uma divergência real entre os dois arquivos de exemplo) caiu corretamente em auditoria (NOME_DIVERGENTE), exatamente o comportamento esperado (nunca resolvido por aproximação automática).
  • Caveat importante: o parser foi escrito a partir do texto extraído do PDF mostrado na conversa, sem rodar o pdfplumber de verdade contra o arquivo binário (não ficou salvo em nenhum lugar acessível pelo ambiente de desenvolvimento). A indentação exata que o pdfplumber com layout=True vai produzir pro PDF real pode diferir da observada — o parser usa indentação relativa (comparada com a primeira linha do próprio arquivo, não um número fixo) exatamente para tolerar isso, mas só validar de verdade com o botão "Selecionar arquivo" (2. Arquivo da operadora) da tela de Nova Importação, que já chama esse parser isoladamente via /importacoes-plano-saude/validar-arquivo/ sem precisar de uma importação completa — mesmo caminho que a Unimed também vai precisar percorrer antes de ter um PDF real (hoje _extrai_pdf da Unimed é só um NotImplementedError explícito por esse motivo).

Rodada 50 — Dois bugs corrigidos testando a Dental Uni com o PDF real

Dois problemas apareceram ao testar de fato (o "caveat" da rodada 49 se confirmou útil):

  1. Regressão em ImportacaoPlanoSaudeDetailSerializer (afetava TODAS as operadoras, não só a Dental Uni): POST /api/importacoes-plano-saude/ dava 500 (AttributeError: 'ImportacaoPlanoSaudeDetailSerializer' object has no attribute 'get_resumo_por_tipo') — o frontend mostrava só "Erro ao processar a solicitação." (mensagem genérica que pidErrorMessageFrom() usa quando a resposta não é JSON, ver api.js). Causa: ao inserir RegraCusteioPlanoSaudeSerializer logo depois de ImportacaoPlanoSaudeDetailSerializer na rodada 48, o método get_resumo_por_tipo() (que já existia, definido depois do class Meta da primeira classe) ficou fisicamente entre as duas — como Python não usa chaves pra delimitar classe, ele passou a pertencer à classe nova (RegraCusteioPlanoSaudeSerializer) por indentação, não à original. Corrigido movendo o método de volta pro lugar certo. Validado recriando uma importação completa via shell e conferindo que a serialização da resposta não quebra mais, além de reconfirmar que o CRUD de regras de custeio continua funcionando.
  2. Ordem de junção do nome quebrado em duas linhas estava invertida: testando com o PDF real, "SUZILAINE ZENATTI MEYER" (titular) ficou sem "BEZERRA" (foi pra auditoria como pessoa não cadastrada, exigindo vínculo manual) e o dependente seguinte virou "BEZERRA JOAO LUCAS MEYER BEZERRA" (nem dava pra vincular, porque não existe ninguém com esse nome na planilha nem parecido o suficiente). A hipótese original (baseada só na inspeção visual do PDF, sem rodar o pdfplumber de verdade) era que o "[Nº Cartão]" e os valores apareciam depois de todas as linhas do nome; o comportamento real do pdfplumber é o oposto — o cartão/valores ficam grudados na primeira linha do nome, e o excedente (quando o nome quebra) sobra sozinho numa linha própria depois, antes do próximo beneficiário. Corrigido invertendo a lógica: cada linha com "[Nº Cartão]" agora é processada na hora (não espera nada depois dela); uma linha órfã em CAIXA ALTA sem colchete é anexada ao nome do último lançamento já adicionado (nunca ao próximo). Revalidado com um teste reproduzindo a estrutura real (cartão na linha do "SUZILAINE ZENATTI MEYER", "BEZERRA" sozinho na linha seguinte, "JOAO LUCAS MEYER BEZERRA" depois) — os 11 beneficiários das 4 famílias voltaram a bater certo, incluindo o titular com nome quebrado reconstituído corretamente e o dependente seguinte sem o prefixo indevido.

Lição prática: sem o PDF real rodando de fato no pdfplumber, a extração de texto mostrada por inspeção visual pode enganar sobre a ORDEM em que o excedente de uma célula quebrada aparece — vale sempre desconfiar de qualquer heurística de "juntar linhas" escrita sem testar contra o parser de verdade.

Rodada 51 — Bug (não específico da Dental Uni): planilha padrão em Windows-1252 quebrava a leitura

Testando com uma segunda empresa (planilha padrão com "SOPHIA FERNANDES GONÇALVES", um nome com "Ç"), o campo "1. Planilha padrão (Questor)" recusava o arquivo com "Este arquivo não parece ser a planilha padrão exportada do Questor...", mesmo o CSV tendo exatamente o cabeçalho esperado.

Causa: le_planilha_padrao() (leiaute_sistema.py) sempre abria o arquivo como encoding="utf-8-sig", fixo. A planilha exportada do Questor, quando tem algum nome com acento, às vezes sai em Windows-1252/ANSI, não UTF-8 — decodificar um byte como 0xC7 ("Ç" em cp1252) como UTF-8 estoura UnicodeDecodeError. E como _valida_planilha_padrao()/create() capturam qualquer exceção genericamente (pra dar uma mensagem amigável quando o arquivo realmente está errado), o erro real (encoding) ficava escondido atrás da mensagem "não parece ser a planilha padrão" — nada a ver com o leiaute de colunas em si, que estava certo.

Corrigido com um fallback de encoding, mesmo espírito do encoding="latin-1" que o parser CSV da Unimed já usa: _decodifica_planilha() (nova função em leiaute_sistema.py) lê os bytes crus e tenta utf-8-sig primeiro (não muda nada pro caso comum sem acento, onde os bytes são idênticos nos dois formatos); só cai pra cp1252 se a decodificação UTF-8 falhar. le_planilha_padrao() passou a ler de um io.StringIO sobre esse texto já decodificado, em vez de abrir o arquivo diretamente com um encoding fixo. Validado com teste cobrindo os 3 casos (UTF-8 sem BOM, UTF-8 com BOM, cp1252) e reproduzindo o arquivo real do usuário (17 beneficiários, valida certo agora).

Vale a mesma observação de robustez pro arquivo_operadora de qualquer operadora nova baseada em CSV (a Unimed já se protegeu disso; Dental Uni é PDF, não é afetada) — se aparecer o mesmo tipo de erro genérico de "formato não reconhecido" pra um CSV com acento, suspeitar de encoding antes de desconfiar do leiaute de colunas.

Rodada 52 — Quinta operadora: Unimed Oeste do Paraná

Usuário forneceu um PDF real ("Demonstrativo Junho.2026.pdf", "Resumo de Faturamento" emitido pela ACIME — associação comercial que fatura em nome da Unimed Oeste do Paraná) + a planilha padrão correspondente, descrevendo o formato: empregados e dependentes aparecem na coluna "Serviço/Produto", o TIPO (mensalidade/coparticipação) também é decidido por essa mesma coluna ("Convenio Unimed" = mensalidade, o resto = coparticipação), e o valor usado é "Val. Total".

  • Novo parser operadoras/unimed_oeste_pr/saude.py (UnimedOestePrSaude), registrado em pipeline.OPERADORAS como unimed_oeste_pr_saude/"Unimed Oeste do Paraná" — deliberadamente separado do unimed_saude já existente, apesar do nome parecido: aquele espera um CSV com colunas próprias ("Id. Benef."/"Tipo Benef.", export direto da Unimed), este é um PDF de fatura da ACIME com um formato completamente diferente (nem CPF nem coluna de tipo dedicada). chave_casamento = "nome" (sem CPF no arquivo).
  • Cada pessoa pode ter mais de um "Nro." (contrato) — ex.: "ALINE PATRICIA RAMOS" aparece em dois blocos "(T) ALINE PATRICIA RAMOS - Nro.: ..." com números de contrato diferentes (um pro plano base/Convênio, outro pro Aditivo de resgate aéreo). Por isso o parser agrupa por NOME (não por "Nro.", que varia por contrato da mesma pessoa), diferente de todas as operadoras anteriores que usavam um número de carteirinha/cartão estável por pessoa.
  • Tipo de lançamento decidido pelo texto da própria descrição, linha a linha (não por bloco/contrato inteiro): dentro do MESMO bloco "Nro.", a linha "Convenio Unimed..." conta como mensalidade e a linha "Taxa Administrativa Unimed..." — que fica junto, no mesmo contrato — conta como coparticipação, por instrução explícita do usuário ("Convenio Unimed é o valor de mensalidade e os demais são coparticipação"). Sinalizado ao usuário como algo a confirmar — não é o desenho mais intuitivo (taxa administrativa normalmente anda junto do valor de mensalidade), mas foi implementado ao pé da letra da instrução recebida.
  • Duas variações de quebra de linha no PDF precisaram de tratamento: (a) quando a coluna "Prestador" está vazia (ex. "ADITIVO UNIMED AIR TERRESTRE..."), a descrição e os 3 números (Qtd/Val.Unit/Val.Total) saem em linhas físicas separadas — o parser junta uma linha-só-texto com a linha-só-números que vem logo depois; (b) quando a coluna "Prestador" tem texto longo (ex. "ASSOCIACAO MISSIONARIA DE BENEFICENCIA DAS IRMAS SERVAS DO E"), esse texto transborda pra linha(s) DEPOIS dos números já lançados — como não sobra número nenhum nessas linhas de transbordo, elas são descartadas sem gerar lançamento extra (não precisamos do conteúdo de "Prestador" pra nada).
  • Validado com o PDF de exemplo completo: reproduzindo as 4 pessoas (1 família de uma pessoa só + 1 família com titular e 2 dependentes), a soma de todos os lançamentos bateu exatamente com o "Total Faturados: 4.628,37" impresso no próprio PDF — confirma que nenhuma linha foi perdida nem contada em dobro, inclusive nos dois casos de quebra de linha acima. Casamento com a planilha padrão também testado (mensalidade e coparticipação separadas): as 4 pessoas casaram automaticamente, 0 itens de auditoria.
  • Mesmo caveat das duas últimas rodadas: escrito a partir do texto extraído mostrado na conversa, sem rodar o pdfplumber de verdade contra o PDF binário — validar com o botão "Selecionar arquivo" antes de confiar em produção.

Rodada 53 — Regra de custeio salva: <select> virou combobox pesquisável

Com o banco de regras salvas crescendo (8 regras já cadastradas pelo usuário entre as 5 operadoras), o <select> nativo do campo "Regra de custeio salva" deixou de ser prático — sem busca, precisava rolar a lista inteira toda vez.

Trocado por um combobox pesquisável (#ips-regra-combo): um <input type="text"> (#ips-regra-search) que funciona tanto como campo de busca quanto como "display" do valor selecionado, com uma lista flutuante (#ips-regra-combo-list, position:absolute abaixo do input) que filtra pelas regras cujo nome contém o texto digitado (case-insensitive) — abre no foco (mostrando todas) e a cada tecla digitada; fecha ao clicar fora (listener de click no document, checando !ipsRegraCombo.contains(event.target)) ou ao escolher um item. Mesmo espírito de busca+lista já usado em "Vincular pessoa", só que aqui o campo de busca dobra como o "valor exibido" no lugar de uma <option> selecionada.

Estado novo em JS: regraSelecionadaId (o que está de fato escolhido no combobox — diferente de regraAplicadaId, que reflete o que está refletido nos CAMPOS do formulário). Digitar de novo no campo depois de já ter selecionado algo invalida regraSelecionadaId até o usuário clicar numa regra da lista — sem isso, "Aplicar" poderia aplicar uma regra antiga enquanto o texto exibido já era outra busca, incoerência que o <select> antigo não tinha (mudar o texto de um <select> só é possível escolhendo uma opção de verdade).

renderRegraSelect() (populava as <option>) foi substituída por renderRegraComboList(filtro); refreshRegras() deixou de re-renderizar um <select> inteiro e passou só a limpar a seleção se a regra escolhida tiver sido excluída em outro lugar enquanto isso (ex.: via o modal "Ver regras salvas").

Rodada 55 — Regra de custeio salva: "Salvar regra atual..." movido pro final do formulário + botão "Limpar seleção"

Ajuste de usabilidade pedido pelo usuário na seção "Regra de custeio salva" do formulário de Nova Importação: "Salvar regra atual..." saiu de junto de Aplicar/Ver regras salvas (topo do formulário) e passou pro final (depois de "Tipo de importação", antes do botão "Processar") — salvar só faz sentido depois de parametrizar o custeio, é o último passo do fluxo, não um botão que deveria ficar ao lado de Aplicar. Aplicar/Limpar seleção/Ver regras salvas continuam no topo, já que aplicar uma regra continua sendo o primeiro passo natural (preenche operadora + custeio de uma vez).

Novo botão "Limpar seleção" (#ips-regra-limpar-btn, ao lado de Aplicar) resolve o caso de aplicar a regra errada por engano: desfaz tanto o rastreamento (regraSelecionadaId/regraAplicadaId, texto do combobox, observações) quanto o próprio custeio que a regra preencheu (tipos de lançamento, radios de custeio, limite/percentual de cada combinação tipo×pessoa) — as duas partes de resetForm() que faziam isso foram extraídas em limparCusteioForm()/limparRegraSelecionada() pra serem reaproveitadas aqui. De propósito não mexe em Operadora nem nos arquivos já anexados — só desfaz o que uma regra aplicada de fato preenche em massa.

Rodada 56 — Aba "Alterações" com histórico e reversão

Pedido do usuário: na tela de revisão (Mensalidade/Coparticipação/Auditoria), incluir uma quarta aba "Alterações" que registra cada edição de valor, exclusão de linha e inclusão manual de linha feita na revisão, permitindo verificar e reverter cada uma.

Novo model ImportacaoPlanoSaudeAlteracao (migração 0038) — um registro por operação, nunca apagado (revertida/revertida_em marcam quando o usuário desfez). ImportacaoPlanoSaudeLinhaViewSet passou a gravar um registro a cada perform_create/perform_update/perform_destroy, com um snapshot completo da linha (dados_linha, JSONField) — necessário porque uma linha excluída deixa de existir, então o snapshot é o único jeito de mostrar/recriar essa linha depois. Novo endpoint POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/ desfaz uma alteração específica: edição volta o campo, inclusão remove a linha, exclusão recria a linha a partir do snapshot — idempotente, e a própria reversão não gera um novo registro (evita loop).

De propósito, o valor lançado por "Vincular pessoa" (resolução manual de auditoria) não entra nessa aba — já tem seu próprio rastro (selo "Resolvido" na aba Auditoria).

Rodada 57 — Bug no parser da Itamed: reajuste de mensalidade não estava sendo somado ao valor lançado

Usuário reportou, testando o arquivo real de 08/2026: para "Elizangela de Paula Kuhn", a aplicação lançou R$ 900,65 de mensalidade, quando o correto era R$ 964,87 (a soma que a própria linha-resumo da pessoa mostra em "VI.Pré-Estab."/"VI mensal.").

Causa: na mini-tabela "Item/Valor" de cada pessoa, quando há reajuste no mês, o valor vem separado em duas linhas — "Reajuste - Variação de custo" (ex.: 64,22) e "Preço pré-estabelecido" (ex.: 900,65), que juntas somam o valor real da mensalidade (964,87). operadoras/itamed/saude.py só tinha regex pra "Preço pré-estabelecido"/"Co-participação" — a linha de reajuste não casava com nada e era simplesmente ignorada, subtraindo o valor do reajuste da mensalidade de todo mundo, todo mês com reajuste (não era um caso raro: no arquivo de teste, as 14 pessoas tinham essa linha).

Corrigido adicionando _REAJUSTE_RE e lançando "Reajuste - Variação de custo" também como tipo_lancamento="mensalidade" — soma automaticamente com "Preço pré-estabelecido" na agregação por indivíduo (_agrega_por_individuo_e_tipo, sem mudança nenhuma nela). Validado rodando o parser direto contra media/planos_saude/operadora/501_Itamed.PDF: os 3 totais da página de resumo (14 beneficiários, R$ 4.475,41 de mensalidade, R$ 600,63 de coparticipação) bateram exatamente depois da correção.

Rodada 58 — Operadora ganha código de cadastro no nome + combobox pesquisável

Pedido do usuário: no <select> "Operadora" da tela de Nova Importação, incluir o código de cadastro de cada operadora no Questor antes do nome, e permitir buscar por nome (a lista tende a crescer, mesmo motivo que já tinha levado "Regra de custeio salva" a virar combobox na rodada 53).

pipeline.OPERADORAS — label de cada operadora passou a vir pré-formatado como "<código> - <Nome>": 1723 - Dental Uni Odonto, 3755 - Itamed Saúde, 3758 - Amil Odonto, 4709 - Unimed Oeste do Paraná, 5060 - Unimed Saúde (códigos informados pelo usuário). Como GET /operadoras/ é a fonte única do rótulo em todo lugar que exibe operadora (combobox do formulário, operadoraLabel() na lista de regras salvas, nome_operadora de cada importação), a mudança aparece em todos esses lugares sem precisar tocar em nenhum deles.

O <select id="ips-form-operadora"> virou um combobox pesquisável (#ips-operadora-combo), mesmo componente visual de "Regra de custeio salva" — as classes CSS que antes eram .ips-regra-combo* foram generalizadas pra .ips-combo* (importacao-plano-saude.css) pra serem reaproveitadas pelos dois campos, sem duplicar estilo. Diferença de implementação: Operadora não tem um botão "Aplicar" separado — o valor de fato submetido viaja num <input type="hidden" id="ips-form-operadora">, e escolher um item da lista já grava o valor na hora (selecionarOperadora()), preservando todo o código existente que lia formOperadora.value (validação do form, payload de submit, payload de "Salvar regra atual...", pré-validação de arquivo por operadora).

Ajuste pedido na sequência: "Limpar seleção" (da regra de custeio) passou a chamar também limparOperadoraSelecionada(), não só limparRegraSelecionada()/limparCusteioForm() — já que aplicar uma regra pode ter preenchido a Operadora junto, desfazer a seleção da regra precisa desfazer isso também.

Rodada 59 — "Regra empresa" — custeio especial de mensalidade por família (primeira: Tecnomyl/Unimed)

Pedido do usuário: incluir uma empresa (Tecnomyl, cliente na Unimed, código 1778) cuja regra de custeio não cabe no desenho normal "por tipo de lançamento × titular/dependente" — a Tecnomyl paga até R$ 661,61 de mensalidade por família inteira (titular + todos os dependentes juntos, não por pessoa): família acima do teto tem o excedente descontado do empregado; igual ou abaixo, a empresa cobre 100%. O usuário deixou claro que regras assim serão sempre cadastradas diretamente por programação (nunca pela tela) e não têm relação nenhuma com o banco de "Regras de custeio salvas" (RegraCusteioPlanoSaude) já existente.

Terceiro checkbox em "Tipo de importação" (ao lado de Mensalidade/Coparticipação): "Regra empresa" — mutuamente exclusivo com "Mensalidade" (as duas configuram o mesmo tipo de lançamento "mensalidade", só que de formas diferentes). Ao marcar, aparece um botão "Selecionar regra" que abre um modal listando as regras cadastradas em portal_api/planos_saude/regras_empresa.py (REGRAS_EMPRESA, registro fixo no código — nada de tela de cadastro).

Decisão de engenharia (não pedida explicitamente, mas necessária): como o teto é por família e a planilha padrão do sistema é uma linha por pessoa, o valor custeado pela empresa é distribuído proporcionalmente entre a linha do titular e a de cada dependente (não dá pra simplesmente jogar tudo na linha do titular — quebra sempre que a mensalidade do titular sozinho já é menor que o teto). Implementado em regras_empresa._aplica_teto_familia(), com a última linha da família absorvendo o resto do arredondamento (mesmo cuidado já usado em matcher._calcula_valores). matcher._casa_por_nome() ganhou um segundo modo (regra_empresa_fn): em vez de aplicar o custeio linha a linha, acumula todas as linhas/valores da família primeiro e só aplica a regra especial no fim do laço daquela família. A distribuição proporcional foi corrigida na rodada 60 (abaixo).

Validações de segurança adicionadas (regras_empresa.valida_regra_empresa, exceção própria RegraEmpresaIncompativelError capturada à parte em views.py pra mostrar a mensagem certa em vez do erro genérico de arquivo): só funciona com operadora de casamento por nome (a agregação por família depende do agrupamento que já existe em _casa_por_nome; Amil, por CPF, não suporta ainda) e só se a planilha padrão anexada tiver alguma linha com o codigo_empresa esperado pela regra (trava contra aplicar a regra da Tecnomyl numa planilha de outra empresa por engano).

Modelo ganhou ImportacaoPlanoSaude.regra_empresa (migração 0039). Validado rodando o pipeline direto (fora do Django test runner, via manage.py shell-style script) com os dois exemplos exatos passados pelo cliente (família de R$800 → R$661,61/R$138,39) e com duas famílias reais extraídas do arquivo de teste anexado (abaixo do teto → 100% empresa) — bateu exatamente nos dois casos.

Rodada 60 — "Regra empresa" (Tecnomyl/Unimed): correção da prioridade de distribuição do teto — dependentes primeiro

Testando com dados reais (família Caroline Fernandes + dependente Luciano Ramos Xavier, R$873,69 de mensalidade no total), o usuário reportou que a distribuição proporcional implementada na rodada 59 estava errada: pediu explicitamente pra priorizar abater o valor dos dependentes primeiro, e só depois disso — se sobrar teto — aplicar no titular; se ainda faltar descontar depois de esgotar o teto nos dependentes, aí sim cai desconto no titular (ou no próprio dependente, se os dependentes sozinhos já estourarem o teto).

regras_empresa._aplica_teto_familia() reescrita: em vez de repartir min(total_familia, teto) proporcionalmente entre todas as linhas, agora percorre primeiro os dependentes (na ordem em que aparecem), cada um recebendo valor_empresa = min(seu valor, teto_restante), e só no fim processa o titular com o que sobrou do teto (teto_restante). Validado reproduzindo exatamente o caso real reportado: dependente (R$559,57) sai 100% custeado pela empresa (sobra R$102,04 de teto), titular (R$314,12) fica com valor_empresa=R$102,04/valor=R$212,08 — bateu com os números que o usuário mostrou como "como deve ficar".

Rodada 61 — "Regra empresa": "Vincular pessoa" (resolução de auditoria) ignorava a regra empresa

Usuário reportou, testando dados reais: numa importação com "Regra empresa" ativa, ao vincular manualmente um item de auditoria (nome divergente) a uma linha, o sistema lançava o valor inteiro como desconto do empregado (valor_empresa=0) em vez de aplicar o teto de R$661,61 da Tecnomyl.

Causa: ImportacaoPlanoSaudeAuditoriaViewSet.resolver sempre usava o custeio normal por pessoa (matcher.valores_formatados_para_pessoa, lendo custeio_por_tipo[tipo_lancamento]) — mas quando a importação usa regra empresa, esse campo fica vazio ({}) de propósito (ver rodada 59), então caía no padrão {"modo": "empregado"} (100% desconto), sem nenhuma noção de família/teto.

Corrigido: quando o item é de mensalidade e a importação tem regra_empresa configurada, resolver() grava o valor bruto na linha (placeholder) e chama a nova _recalcula_familia_regra_empresa(importacao, linha) (views.py) — reúne todas as linhas de mensalidade da mesma família (mesmo nome_func), recupera o valor bruto de cada uma como valor_empresa + valor (soma que preserva o total não importa qual split foi aplicado antes) e reaplica a regra empresa na família inteira de uma vez (bulk_update). Precisa reaplicar em todas as linhas, não só a recém-vinculada, porque o valor novo muda o total da família e o teto precisa ser redistribuído do zero (dependentes primeiro, titular absorve o residual — mesma prioridade da rodada 60).

regras_empresa._aplica_teto_familia deixou de depender do método LinhaSistema.eh_linha_titular() (virou _eh_linha_titular() duck-typed, checando nome_dependente/cpf_dependente direto) — precisa rodar tanto contra LinhaSistema (pipeline) quanto contra ImportacaoPlanoSaudeLinha (model Django, usado só no recálculo pós-vincular).

Validado com um teste de integração real (dentro de uma transação revertida de propósito, nada commitado): simulou vincular primeiro o dependente (R$172,51, abaixo do teto sozinho) e depois o titular (R$162,69) da mesma família reportada pelo usuário (Rafael Cornelius/Viviani Busko Souza) — os dois ficaram 100% custeados pela empresa em cada etapa, batendo com o esperado (família de R$335,20 no total, bem abaixo do teto de R$661,61).

Rodada 62 — "Regra empresa" ganha campo de observações, exibido só-leitura na tela de Revisão

Pedido do usuário: quando a importação usa uma regra empresa com observação cadastrada, mostrar essa observação na tela de Revisão — sem permitir edição, só pra conferência — entre o cabeçalho ("Revisão" + informações da execução) e as abas Mensalidade/Coparticipação/Auditoria/Alterações.

REGRAS_EMPRESA (regras_empresa.py) ganhou o campo opcional observacoes (texto livre) — a regra unimed_1778_tecnomyl já nasceu com uma explicando o teto de R$661,61 e a prioridade dependentes→titular (rodada 60). Exposto via ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes (resolvido a cada carregamento a partir do registro no código, não persistido — se o texto do registro mudar depois, importações antigas refletem o texto novo também). Frontend: novo bloco #ips-review-regra-empresa-obs em importacao-plano-saude.html, populado por abrirRevisao() e escondido quando a importação não usa regra empresa ou a regra não tem observação cadastrada.

Rodada 63 — "Regras de custeio salvas" também ganham observação exibida na Revisão

Usuário testou uma importação (código 1601, "1601 - Unimed Oeste PR", regra de custeio salva com observação "Custeado integralmente pelo empregado e sócio...") e notou que a observação não aparecia na tela de Revisão — só a de "Regra empresa" (rodada 62) tinha esse tratamento.

ImportacaoPlanoSaude ganhou regra_custeio_salva (FK opcional, SET_NULL, pra RegraCusteioPlanoSaude, migração 0040) — só registro informativo de qual regra salva (se alguma) foi aplicada no formulário antes do "Processar", preenchido no submit com regraAplicadaId (JS) quando "Regra empresa" não está marcada. As duas nunca vêm preenchidas juntas: marcar "Regra empresa" já limpa o rastreamento da regra de custeio salva (limparRegraSelecionada()), e aplicar uma regra de custeio salva já desliga "Regra empresa" (limparRegraEmpresa() dentro de aplicarCusteio()) — esse segundo ponto já existia da rodada 59, só faltava o primeiro (adicionado agora).

O mesmo bloco #ips-review-regra-empresa-obs (rodada 62) foi reaproveitado: se não há observação de regra empresa mas há regra_custeio_salva_observacoes (novo campo em ImportacaoPlanoSaudeDetailSerializer, lendo RegraCusteioPlanoSaude.observacoes de verdade via FK), o bloco mostra essa observação com o rótulo "Observações da regra de custeio salva — <nome>".

Rodada 64 — Nova operadora: Bradesco Saúde (1386) — primeiro PDF sem texto selecionável, extração via OCR (docling)

Usuário trouxe um modelo novo de fatura (Bradesco Saúde, empresa 221 - Rossoni Piotto) e perguntou se dava pra ler/converter pra parametrizar a importação, já que o PDF é "formato de imagem, sem texto selecionável". Confirmado empiricamente rodando pdfplumber contra o arquivo real baixado (Downloads/221 -BRADESCO SAUDE.pdf): page.chars/page.extract_text() vêm vazios nas 3 páginas — o documento é uma composição de dezenas de imagens raster por página (cada faixa da tabela é um bitmap próprio), sem nenhuma camada de texto. É o primeiro caso desse tipo entre as operadoras do módulo; todas as outras (Amil/Itamed/Unimed/Dental Uni/Unimed Oeste PR) têm texto selecionável e usam pdfplumber.

Depois de comparar duas ferramentas de conversão (MarkItDown vs. Docling — MarkItDown não tem OCR/reconstrução de tabela embutidos, feito pra documento já estruturado; Docling tem OCR local + modelo dedicado de estrutura de tabela, TableFormer), a escolha foi Docling — adicionado ao requirements.txt (pesado: traz torch/transformers/opencv-python/pandas como dependência transitiva, ~90 pacotes novos no freeze).

Bug real encontrado testando contra o arquivo de verdade (por isso vale sempre testar contra o arquivo real antes de confiar, não só contra o texto que o próprio Docling "acha" que extraiu): nas colunas estreitas "Mês/Ano"/"Valor" (sob o cabeçalho mesclado "Lançamento"), o TableFormer às vezes junta as duas numa célula só, e às vezes o valor de uma pessoa "vaza" pra célula da linha anterior (visto de fato: o valor da GABRIELA saiu dentro da célula "Valor" da MILENA, deixando a própria célula da GABRIELA vazia). Subir a resolução de renderização (images_scale=4) não mudou nada — não é falta de resolução, é erro de fronteira de célula do modelo mesmo. Contorno implementado em operadoras/bradesco/saude.py (_extrai_valores_area): em vez de confiar em qual célula/linha especificamente carrega o valor, concatena-se todo o texto da área "Mês/Ano+Valor" de cada linha (de cima para baixo) e extrai-se TODOS os valores monetários encontrados, preservando a ordem de leitura — depois redistribui 1 valor por linha de beneficiário, na mesma ordem (a ordem de leitura continua certa mesmo quando a fronteira de célula erra). Se a contagem final não bater 1:1 com o número de linhas, levanta erro em vez de arriscar lançar o valor errado em alguém.

Titular x dependente é decidido pela coluna "Certif." (formato <família>/<sufixo>, sufixo "00" = titular, qualquer outro = dependente daquela família — confirmado pelo usuário), não por uma coluna "Tipo" dedicada como as outras operadoras. Casamento com a planilha padrão é por nome (chave_casamento = "nome", igual Itamed/Unimed/Dental Uni/Unimed Oeste PR — este arquivo também não traz CPF). Coparticipação ("Part. Seg.") ainda não foi validada com nenhum arquivo real com valor diferente de zero — o usuário disse "acreditamos que ficariam" ali; o parser já está pronto pra gerar lançamento de coparticipação quando isso acontecer (mesmo padrão "ausência de valor = zero, não gera Individuo" da Itamed), mas isso é uma suposição a confirmar, não um fato validado.

Validado rodando o parser (BradescoSaude.extrai) e o pipeline completo (processa_importacao) direto contra o arquivo real de 08/2026 + a planilha padrão de exemplo fornecida pelo usuário (empresa 221): os 4 valores de mensalidade saíram exatamente certos (incluindo o caso MILENA/GABRIELA acima) e o casamento por nome bateu certo para os 4 beneficiários presentes na planilha (mais um, LUIZ CARLOS PIOTTO, corretamente ficou sem lançamento por não aparecer na fatura daquele mês).

Registrado em pipeline.OPERADORAS como "bradesco_saude": {"label": "1386 - Bradesco Saúde", ...}.

Rodada 80 — Buscar a planilha padrão do Questor via SQL

Até aqui, "Nova Importação" exigia exportar manualmente do Questor a "planilha padrão" (CSV) e anexá-la, além do relatório da operadora. Usuário validou uma consulta SQL contra o Questor (mesmo pacote database/ só-leitura já usado em empresas_questor.py) que devolve exatamente essa informação a partir de codigo_empresa (já cadastrado em RegraCusteioPlanoSaude) + operadora + competência (mês/ano) — eliminando o passo manual na maioria dos casos.

  • Upload manual continua existindo, como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário. "Nova Importação" ganhou um toggle "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente", com um campo "Competência" (<input type="month">) só no primeiro modo.
  • 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 precisava só do nome. Separado em campos próprios (codigo_operadora/nome), com label_operadora() calculando o label onde ainda é exibido — elimina o split e dá um valor confiável pra filtrar a consulta por operadora.
  • Nova consulta (sqls.questor.QuestorSQL.consulta_planilha_plano_saude) e novo módulo portal_api/planos_saude/questor_planilha.py (busca_linhas_questor/linhas_para_csv_bytes) — a busca é resolvida antes de criar o registro ImportacaoPlanoSaude (uma falha de conexão nunca deixa nada órfão pra limpar, diferente do caminho de upload). pipeline.processa_importacao deixou de ler o arquivo sozinho (caminho_planilha_padrao: str) e passou a receber a lista de LinhaSistema já resolvida (linhas_sistema_template), de qualquer uma das duas origens — decisão de qual usar ficou em views.py.
  • Mesmo vindo do Questor, um CSV é gerado (mesmo formato do upload) e salvo no campo planilha_padrao — preserva o histórico completo já documentado pra toda importação. Novo campo ImportacaoPlanoSaude.competencia (DateField, null) registra a competência usada, só quando a origem foi o Questor (migração 0045).
  • A "trava de conferência do código de empresa" (existente desde a rodada do Cadastro de Regras) é pulada quando a origem é Questor — redundante, já que a própria consulta já filtrou por aquele codigo_empresa.

Ajuste no mesmo dia, depois do primeiro teste real: o campo "Competência" nasceu como <input type="month"> — usuário rejeitou o seletor nativo do browser como "horrível" e pediu o mesmo padrão já usado no Indicador de Desempenho: texto livre com máscara MM/AAAA (mascaraCompetencia()/competenciaParaIso(), cópia local das mesmas funções de indicador-desempenho.js), o usuário digita "082026" e o campo forma "08/2026" sozinho. Backend acompanhou: competencia no serializer é um DateField normal (mesmo padrão de IndicadorApuracaoCreateSerializer), recebendo o ISO "AAAA-MM-01" já convertido pelo JS, nunca a string mascarada crua.

Segundo ajuste, mesmo teste real: a coluna DATAINICIAL do CSV gerado via Questor saía no formato ISO (2023-12-01, linha["datainicial"].isoformat() — a renderização padrão do Postgres pra uma coluna date), mas o leiaute real de importação do sistema espera DD/MM/AAAA (01/12/2023) nessa coluna — mesmo formato que uma planilha padrão exportada manualmente do Questor já usa. Corrigido em questor_planilha.busca_linhas_questor() pra linha["datainicial"].strftime("%d/%m/%Y"). data_inicial é sempre um passthrough string em todo o pipeline (nenhum outro ponto faz parsing de data nele, só grava/lê a string), então a mudança não exigiu tocar em mais nenhum arquivo. Nenhum registro histórico ficou com o formato errado — a única importação de teste que chegou a usar a busca via Questor (antes deste ajuste) já tinha sido excluída pelo próprio usuário.

Terceiro ajuste, mesmo dia — regra de edição da tabela de revisão revista: até aqui, todo campo de ImportacaoPlanoSaudeLinha era editável em qualquer linha na tela de Revisão (decisão de uma rodada anterior). Vendo dados reais de uma importação via Questor (22 linhas, várias famílias), o usuário pediu pra restringir: só Valor Empresa/Valor editáveis numa linha que já veio do processamento (upload ou Questor) — os demais campos (cadastro da pessoa: nome, CPF, código, data...) só ficam editáveis numa linha incluída manualmente via "Adicionar linha", e só nela. Implementado 100% no frontend (importacao-plano-saude.js, linhasIncluidasManualmente()), sem campo novo: reaproveita o sinal que já existia em ImportacaoPlanoSaudeAlteracao (toda linha criada via "Adicionar linha" já gerava um registro tipo="inclusao" ali, só pra alimentar a aba "Alterações" — agora também decide, no cliente, quais linhas ganham os campos extras liberados). O backend continua aceitando PATCH em qualquer campo (nenhuma trava nova no serializer/model) — é uma restrição de UI, não de permissão.

Quarto ajuste, mesmo dia — trava de edição depois de "Gerar Arquivo": o ajuste anterior já restringia quais campos, mas o usuário notou que uma importação já concluida (arquivo já gerado/entregue) continuava 100% editável — pediu pra travar de vez, com um botão "Editar" explícito pra reabrir quando for realmente necessário corrigir algo depois da conclusão. Diferente do ajuste anterior, este saiu também no backend: _garante_importacao_em_revisao(importacao) (nova função em views.py) é chamada em todo ponto que escreve dentro de uma importação já criada — ImportacaoPlanoSaudeLinhaViewSet.perform_create/update/destroy, ImportacaoPlanoSaudeAuditoriaViewSet.resolver, ImportacaoPlanoSaudeAlteracaoViewSet.reverter — e recusa (400) se status == "concluida". Novo endpoint POST /api/importacoes-plano-saude/{id}/reabrir/ (ImportacaoPlanoSaudeViewSet.reabrir) volta status="revisao" e zera concluida_em — único jeito de destravar. gerar() em si nunca é bloqueado (sempre pode regerar/rebaixar o arquivo já concluído). No frontend, a tela de Revisão some com todo controle de edição (inputs viram texto, "×"/"Adicionar linha"/"Vincular pessoa"/"Reverter" somem) quando status === "concluida", e mostra um botão "Editar" (ao lado de "Gerar Arquivo") que chama o reabrir() e volta tudo ao normal. Testado ponta a ponta via APIRequestFactory/force_authenticate (sem afetar dados reais, dentro de uma transação revertida): reabrir muda o status, edita normalmente em revisão, e volta a bloquear ao marcar concluída de novo.

Quinto ajuste, mesmo dia: "Gerar Arquivo" passou a voltar direto pro histórico depois do download disparar (showView("list") + refreshList()) — como a importação já fica concluida/travada nesse momento (ajuste anterior), não fazia sentido continuar na tela de Revisão sem nada pra fazer ali.

Sexto ajuste, mesmo dia — filtro no histórico: com o histórico crescendo (23 importações já), o usuário pediu pra filtrar a lista, não só ordenar — ex.: ver todas as execuções da empresa 221, ou combinar empresa 221 + operadora 3755. Primeira versão foi um campo de texto por coluna (Cód. Empresa/Operadora/Criado por) + um <select> pra Status. 100% client-side, sem endpoint novo.

Sétimo ajuste, mesmo dia — filtro revisado pra "estilo Excel": usuário pediu pra trocar os campos de texto por um filtro de planilha de verdade — clicar num ícone de funil na coluna e marcar/desmarcar os valores que aparecem numa lista, como no AutoFilter do Excel. Reescrito (criarFiltroColuna()) como um popup por coluna com busca + checklist dos valores distintos daquela coluna (reaproveita .checklist-box de Perfis de Acesso, sem CSS/componente novo pra isso) e botões "Aplicar"/"Limpar" — os filtros das 4 colunas continuam combinando entre si (AND). Precisou de um ajuste de CSS colateral: .pa-table-wrap corta com overflow:hidden pra arredondar os cantos da tabela, o que cortaria o popup também — resolvido com uma classe extra só nesta tabela (.ips-list-table-wrap) sobrescrevendo pra overflow:visible.

Rodada 83 — Múltiplos arquivos de operadora + Unimed Saúde em PDF

Cliente real (Fallkner Ribeiro Borges) em que a Unimed manda dois PDFs separados (mensalidade + coparticipação analítico) em vez do CSV único já suportado — diferente de toda operadora até então, que sempre mandava um único arquivo. Duas mudanças, decididas em planejamento explícito antes de implementar dado o tamanho:

  • "Arquivo da operadora" passou a aceitar 1+ arquivos — mudança de arquitetura que afeta todas as operadoras, não só a Unimed. ImportacaoPlanoSaude.arquivo_operadora (FileField único) foi substituído por ImportacaoPlanoSaudeArquivoOperadora (FK + arquivo + ordem), migração em 3 passos (0048 cria o model novo + torna o campo legado blank=True; 0049 faz o backfill via RunPython, reapontando pro mesmo caminho já salvo sem copiar bytes; 0050 remove o campo legado) — mesmo padrão já usado em IndicadorDepartamento/RegraCusteioPlanoSaude.codigo_empresa. pipeline.processa_importacao passou a receber uma lista de caminhos, chamando OperadoraParser.extrai() uma vez por arquivo. Frontend: <input type="file" multiple> + lista dinâmica com validação e remoção individuais.
  • Bug real encontrado testando de ponta a ponta (2 arquivos idênticos da mesma operadora/tipo, via APIRequestFactory real): simplesmente concatenar os indivíduos de cada arquivo não bastava — se dois arquivos contribuem pra mesma pessoa e mesmo tipo_lancamento, _aplica_regra_custeio (matcher.py) grava o valor por linha, não acumula, então o segundo arquivo sobrescrevia o valor do primeiro. Corrigido com _agrega_individuos_entre_arquivos() (pipeline.py), somando por (numero_beneficiario, tipo_lancamento) antes do casamento — mesmo padrão que cada parser já fazia dentro de um arquivo, agora replicado entre arquivos.
  • Parser da Unimed Saúde (operadoras/unimed/saude.py) ganhou o segundo formato: extrai() detecta automaticamente, pelo conteúdo da primeira página, se o PDF é o relatório de mensalidade ("BENEFICIARIOS COM FATURAMENTO NO MES") ou o analítico de coparticipação ("SERVIÇOS PRESTADOS"/"ANALITICO") — nunca pede pro usuário escolher. Os dois usam pdfplumber com layout=True (mesma técnica já validada em ItamedSaude). Confirmado com o usuário: a coparticipação por beneficiário é a soma do "Vl Total" de cada serviço (a coluna "Tt Copar", valor fixo repetido em todo o documento, não é usada).
  • 3 bugs reais encontrados e corrigidos testando com PDFs gerados via reportlab (reproduzindo linha a linha o texto dos exemplos, através do fluxo completo de create(), não mocks): (1) os valores nos PDFs vêm em formato americano (ponto decimal, vírgula de milhar), diferente do formato BR do resto do pipeline — _valor_pdf_para_float separada; (2) o grau "FILHO(A)" nunca casava porque a regex usava \b logo depois de ), que não é caractere de palavra (\b nunca bate entre dois não-palavra) — trocado por (?=\s|$); (3) numero_titular de um dependente estava sendo setado como o código da família, mas o resto do pipeline (nomes_titular_por_numero/_casa_por_nome em matcher.py) espera o numero_beneficiario do próprio titular ali — sem isso, a família nunca era resolvida e tudo caía em auditoria "sem titular identificado".
  • Ressalva que permanecia: o arquivo real da Unimed nunca tinha sido processado (só o texto/imagem colados na conversa) — confirmada como necessária no mesmo dia: o usuário testou pela tela e a coparticipação deu "Nenhum beneficiário foi encontrado neste arquivo."

Ajuste no mesmo dia, depois do teste real — usuário forneceu o caminho dos dois arquivos no disco: em vez de tentar adivinhar a estrutura de novo a partir de texto colado (já teria sido a terceira vez), pedido e recebido o caminho local dos PDFs, rodando o pdfplumber de verdade contra eles. Duas descobertas reais, só possíveis com o arquivo de verdade — o texto de um PDF colado numa conversa não é o que pdfplumber.extract_text() de fato produz, então as duas primeiras tentativas (acima) estavam desenhadas sobre uma estrutura que nunca existiu:

  • Mensalidade: extract_text() simples (sem layout=True) já devolve linhas bem formadas — a regex funcionou de primeira contra o arquivo real, bateu exatamente com "Total por Contratante: 6.061,74".
  • Coparticipação analítica: benef+nome+grau vêm colados sem espaço nenhum (ex.: "0975.0167003824292ANDREIA STORMTITULAR") — corrigido ajustando a regex pra não exigir espaço entre eles. Mas surgiu um problema mais sério: o nome do beneficiário sai truncado em ~13 caracteres por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM"), o que faria casamento por nome falhar sistematicamente pra qualquer nome mais longo que a coluna — não é um bug de regex, é informação perdida de verdade no relatório. Como esse mesmo relatório traz CPF completo e confiável, a solução foi trocar a estratégia de casamento: OperadoraParser ganhou chave_casamento_para_tipo(tipo_lancamento) (default: mesmo valor de sempre, backward-compatible pra todo outro parser) e UnimedSaude a sobrescreve pra devolver "cpf" só quando tipo_lancamento="coparticipacao" e a origem foi o PDF (rastreado numa flag de instância setada em extrai() — mensalidade, sem CPF em nenhum formato, continua em "nome"). Também corrigido: linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento de um item já somado no valor principal) estavam sendo contadas como itens de serviço de verdade (colidindo com a mesma regex de tipo de serviço), duplicando o valor — agora ignoradas explicitamente.
  • Validado de ponta a ponta com os dois arquivos reais através do fluxo completo de create(): bateu exatamente com "Total da Familia"/"Total da Sequencia" impressos no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e, usando a planilha padrão real da empresa 1123, cada família presente na planilha casou centavo a centavo — a única família ausente da planilha de teste foi corretamente pra auditoria "não cadastrado", não ignorada.

Rodada 84 — Vínculos de nome salvos (DE/PARA) — reaplicação automática de "Vincular pessoa"

Usuário perguntou se a resolução manual de nome divergente ("Vincular pessoa") precisava ser refeita em toda execução futura, ou se podia ficar guardada "como se fosse um DE/PARA". Confirmado: sim — guardar, reaplicar automaticamente em importações futuras da mesma operadora+empresa, e mostrar cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação — só existe depois de uma confirmação humana explícita (ver "Vínculos de nome salvos (DE/PARA)" no CLAUDE.md desta pasta pro detalhamento completo).

  • Novo model VinculoNomeOperadora (migração 0051, junto com ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO + FK vinculo_nome) — codigo_empresa fica cru no model (normalizado só em views.py, pra evitar um import circular entre models.py e empresas_questor.py, que já importa de models.py).
  • matcher._casa_por_nome ganhou vinculos_por_nome/tipo_lancamento: quando titular ou dependente não bate por nome exato, consulta o DE/PARA antes de cair em auditoria; cada aplicação automática vira um VinculoAplicado (dataclass pura, sem ORM), devolvido por pipeline.processa_importacao em ResultadoProcessamento.vinculos_aplicados.
  • ImportacaoPlanoSaudeAuditoriaViewSet.resolver() passou a gravar (update_or_create) o VinculoNomeOperadora depois de aplicar a resolução manual. ImportacaoPlanoSaudeViewSet.create() busca os vínculos relevantes (_carrega_vinculos_por_nome) antes de processar, e depois do bulk_create das linhas correlaciona cada VinculoAplicado (por índice dentro do tipo de lançamento) com a linha já persistida, criando um ImportacaoPlanoSaudeAlteracao por vínculo aplicado.
  • Botão "Apagar vínculo" (aba Alterações, mesmo endpoint reverter()) zera o valor lançado na linha (redistribuindo a regra empresa da família, se houver) e apaga o VinculoNomeOperadora — a divergência volta a cair em auditoria nas próximas importações.
  • Testado via APIRequestFactory dentro de uma transação revertida (nada persistido nos dados reais): matcher.py aplicando/não aplicando o DE/PARA corretamente, resolver() criando o vínculo, _carrega_vinculos_por_nome encontrando-o, e reverter() zerando a linha + apagando o vínculo.

Rodada 85 — Nova operadora: Unimed Vitória (4750)

Usuário forneceu os dois PDFs reais do cliente Weitnauer Brasil (empresa 792 na planilha padrão): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), sempre em arquivos separados — nenhum dos dois traz CPF, casamento por nome.

  • operadoras/unimed_vitoria/saude.py (UnimedVitoriaSaude), registrada em pipeline.OPERADORAS["unimed_vitoria_saude"]. Detecção automática do tipo pelo conteúdo (marcador "DEMONSTRATIVO" vs. "CO-PARTICIPA" na página 1), mesmo espírito da Unimed do Paraná.
  • Particularidade de extração: no PDF de mensalidade, a coluna de nome quebra em 2 linhas físicas quando o nome é longo, num top diferente (mas próximo) da linha de dados — nem extract_text() nem extract_text(layout=True) resolvem isso sem ambiguidade. Solução: reconstruir as linhas a partir de extract_words(extra_attrs=["fontname","size"]) agrupadas por posição vertical (tolerância calibrada contra o arquivo real) e usar sempre o cabeçalho em negrito (nome completo, sem quebra, distinguido por ser 100% maiúsculo e não começar com dígito) como fonte do nome — nunca a linha de dados quebrada. No PDF de coparticipação, as colunas não têm espaço literal nenhum entre si (só posição) — extract_words() tokeniza certo, concatenar page.chars direto colaria "1CONSULTA" sem espaço.
  • Validado rodando o parser e o pipeline.processa_importacao completo contra os dois arquivos reais + a planilha padrão real (empresa 792): mensalidade bateu R$ 340,74 e coparticipação R$ 55,57 (os mesmos valores impressos no próprio relatório), casamento por nome correto contra a planilha, zero itens de auditoria.
  • Limitação conhecida, não validada: os dois arquivos de exemplo só têm titular, sem nenhum dependente, e nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito. A classificação usada (sequência "00" da carteirinha = titular, qualquer outra = dependente; família = tudo antes da sequência) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente — testar com um caso real antes de confiar nela de olhos fechados (ver CLAUDE.md desta pasta, seção "Unimed Vitória (4750)").

Rodada 86 — Nova operadora: SulAmérica Odonto (4726)

Usuário forneceu o PDF real do cliente Weitnauer Brasil (empresa 792 na planilha padrão, competência 08/2026) — relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto", plano "ODONTO MAIS PME", só mensalidade (sem coparticipação). Diferente das duas Unimeds já portadas: este relatório traz CPF de todo mundo e um campo textual explícito de "Grau parentesco" — casamento por CPF, sem nenhuma suposição sobre numeração de carteirinha.

  • operadoras/sulamerica/odonto_mensalidade.py (SulAmericaOdontoMensalidade), registrada em pipeline.OPERADORAS["sulamerica_odonto_mensalidade"].
  • Usuário avisou que a coluna "Valor" tem um totalizador por família impresso no relatório, mas o lançamento deve ser feito por beneficiário — o parser sempre lê o valor da linha individual (última coluna monetária de cada linha de beneficiário), nunca a linha de subtotal da família (que nem tem código nenhum pra casar com nada, então já ficaria de fora naturalmente).
  • Mesma técnica de reconstrução de linha por posição (extract_words() agrupadas por top) já usada na Unimed Vitória, porque o nome de um beneficiário longo quebra pro relatório — só que aqui o corte é mais agressivo (o próprio relatório trunca a última letra da palavra, ex. "SILV" em vez de "SILVA"), sem prejuízo nenhum já que o casamento é por CPF, não por nome.
  • Validado rodando o parser e o pipeline.processa_importacao completo contra o arquivo real + a planilha padrão real: 15 beneficiários extraídos, R$ 437,40 no total (bate com "Total R$ 437,40" impresso no relatório); 13 casaram certo por CPF contra a planilha padrão de teste, os outros 2 (ausentes dessa planilha) foram corretamente para auditoria "CPF não encontrado" em vez de ignorados/silenciosos.

Operadoras adicionadas depois desta rodada (Bradesco Dental/3759, Amil Odonto/898, SulAmérica Saúde/5775 via Ottimizza) não têm uma rodada numerada correspondente registrada em plano.md — o estado atual de cada uma está documentado em CLAUDE.md desta pasta.

Bug real — Unimed Saúde (5060, PDF de coparticipação): grau "COMPANHEIRO" truncado não reconhecido

Usuário reportou (competência 09/2026, empresa Questor 604, arquivo "COP GERAL UNIMED.pdf") que uma família com coparticipação em titular e dependente ao mesmo tempo (primeiro caso real desse tipo — até então só se via titular sozinho) teve o valor todo lançado no titular. Investigado rodando pdfplumber contra o arquivo real: o dependente MILTON ERNESTO tinha grau "COMPANHEIRO", que a largura fixa de 10 caracteres da coluna "Grau Dep." trunca pra "COMPANHEIR" — valor ausente de _GRAUS_DEPENDENCIA (operadoras/unimed/saude.py). 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 (o titular da mesma família). Corrigido acrescentando "COMPANHEIRO"/"COMPANHEIRA"/"COMPANHEIR" a _GRAUS_DEPENDENCIA; validado rodando extrai() de ponta a ponta contra o arquivo real — família R$144,35 passou a sair como titular R$134,33 + dependente R$10,02 (bate com "Total da Familia" impresso), e o total geral do arquivo (R$606,61) bateu com a soma dos "Total da Familia" das 3 famílias do documento. A importação já existente no banco (id 88) já tinha sido corrigida manualmente pelo usuário na tela de Revisão antes deste fix, então não precisou de correção retroativa.

Bug real + melhoria — edição de célula na Revisão não refletia na hora, expressão de soma/subtração no Valor

Usuário reportou, testando a correção acima pela tela: depois de editar Valor/Valor Empresa numa linha, os contadores do topo ("com valor lançado"/"em auditoria") e a aba Alterações continuavam mostrando o estado de antes da edição, só atualizando de verdade depois de sair da importação e reabri-la. Causa: o handler de change das células (importacao-plano-saude.js) já mandava o PATCH pro servidor, mas nunca atualizava importacaoAtual nem chamava renderTabs() depois — diferente de toda outra ação da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), que já recarrega a importação inteira e re-renderiza. Corrigido replicando o mesmo padrão: depois do PATCH ter sucesso, importacaoAtual = await pidFetchImportacaoPlanoSaude(...) seguido de renderTabs().

Aproveitando a mesma conversa, usuário pediu que as células de Valor/Valor Empresa aceitem uma expressão de soma/subtração digitada direto (ex.: "15,30-15" pra abater R$15 do valor, sem precisar calcular fora e digitar o resultado pronto). Implementado client-side: pidAvaliaExpressaoValorMonetario() reconhece números em formato BR (vírgula decimal, ponto de milhar opcional) separados por +/- e, se o texto digitado for uma expressão válida (sobra zero caractere não reconhecido), substitui o campo pelo resultado já calculado antes do PATCH — um valor negativo isolado (ex.: "-15,30") continua sendo só um número, não uma expressão (só conta como expressão se houver operador depois do primeiro caractere). O backend não teve nenhuma mudança — valor/valor_empresa continuam sendo CharField sem validação de formato, então já aceitava (e continua aceitando) qualquer string; a expressão nunca chega até lá, só o resultado.

Bug real — Dental Uni Odonto: segundo layout de relatório (sem colchete no Nº Cartão) travava a extração inteira

Usuário reportou (empresa 503, "TAROBA CONSTRUCOES LTDA", dois arquivos "503"/"503-2" — um por contrato/filial, 774977 e 785666) que os dois arquivos davam "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação. Investigado rodando pdfplumber contra os dois PDFs reais: o relatório "Relatório de Beneficiários" da Dental Uni tem uma segunda variante de renderização, sem o [Nº Cartão] entre colchetes que o parser exigia desde a Rodada 49 — o número vem solto, colado direto depois do nome ("774977 ALEX PATRICIO VISOLI 00202577667800001301 25/04/1991 09/11/2023 0,00 16,57 33,14") — e, nessa variante, titular e dependente têm a MESMA indentação (a heurística de indentação da Rodada 49 não se aplica).

Corrigido acrescentando um segundo regex de linha (_LINHA_SEM_COLCHETE_RE, tentado só quando o formato com colchete não bate) em operadoras/dental_uni/odonto_mensalidade.py: como a indentação não ajuda nesse layout, titular/dependente passou a ser decidido pela presença da coluna "Total Fam" (só preenchida na linha do titular, mesma regra de negócio já documentada desde a Rodada 49, só que agora usada como sinal em vez de só ser ignorada) — 3 valores monetários na linha = titular, 2 = dependente. O valor do beneficiário é sempre o 2º valor monetário (a coluna "Tx Inc." vem sempre impressa, mesmo "0,00", antes de "Valor Unit"). Validado rodando extrai() de ponta a ponta contra os dois arquivos reais: 24 beneficiários/R$397,68 e 10 beneficiários/R$165,70, batendo exatamente com os totais impressos em cada relatório, incluindo nomes quebrados em duas linhas reconstituídos certos (mesma lógica da Rodada 50, sem mudança). O formato original com colchete (empresa 1084) foi reconfirmado sem regressão com um teste dedicado.

Décima operadora: Humana Saúde (5064)

Usuário forneceu o modelo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI - EPP), competência 08/2026 — primeiro teste guiado pelo checklist novo da skill importacao-questor-plano-saude (seção 4.1): antes de escrever qualquer código, o arquivo foi inspecionado (pdfplumber, repr() linha a linha) e as perguntas que não davam pra responder só com o arquivo foram feitas ao usuário (código da operadora no Questor, confirmação do código da empresa, regra de custeio negociada, e se a tabela "TOTALIZAÇÃO POR PLANO" devia ser ignorada).

  • Novo parser operadoras/humana/saude.py (HumanaSaude), registrado em pipeline.OPERADORAS["humana_saude"] (código 5064). Único arquivo com mensalidade e coparticipação juntas, mas em duas tabelas com identificadores diferentes: mensalidade tem uma "Matrícula" por beneficiário (com "Tipo do usuário" já em texto explícito — não precisa inferir titular/dependente por indentação, ao contrário de Dental Uni/Itamed); "DESPESAS COBRADAS" (coparticipação) só tem a matrícula do CONTRATO, e o nome vem truncado por largura de coluna, colado sem espaço na conta seguinte quando ultrapassa a largura.
  • Decisão explícita do usuário, descoberta durante a inspeção: sem CPF nem matrícula confiável nessa segunda tabela, coparticipação nunca tenta casamento automático — cada evento (já somado por pessoa) vira direto um ItemAuditoria (NAO_CADASTRADO) na extração, exigindo sempre "Vincular pessoa" manual. Primeiro parser do pacote a gerar itens de auditoria já na extração por essa razão (os demais só devolvem auditoria via matcher.py, depois de tentar e falhar o casamento).
  • Validado rodando extrai() de ponta a ponta contra o arquivo real: 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 num único item, não dois — evita que o segundo item seja recusado ao tentar vincular a mesma linha já preenchida pelo primeiro), 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.

Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723)

Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (1723); o código correto é 4723 — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem OPER_4723_DENTAL_UNI...). Corrigido OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"] em pipeline.py; o label de exibição ("4723 - Dental Uni Odonto") é derivado desse campo em todo lugar que usa label_operadora()/lista_operadoras() (combobox de operadora, mensagens de erro, nome_operadora de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (ImportacaoPlanoSaude.nome_operadora, texto congelado no momento da criação) não são retroativamente corrigidos.

Combobox de Operadora ordenado por código

Pedido do usuário: o combobox "Operadora" de Nova Importação (e os demais que reaproveitam lista_operadoras()) mostrava as operadoras na ordem de inserção em pipeline.OPERADORAS (ordem de dict, sem critério nenhum pro usuário). lista_operadoras() (pipeline.py) passou a ordenar pelo codigo_operadora (numérico, menor pro maior) antes de montar a lista {key, label} — o frontend (criarComboboxTexto(), importacao-plano-saude.js) já renderiza os itens na ordem em que chegam da API, sem reordenar sozinho, então bastou ordenar na origem.

Décima primeira operadora: Unimed Cascavel (158)

Usuário forneceu os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, mesma empresa-modelo da Humana): dois relatórios de mensalidade (um por contrato — 183237 e 183210/"Estadual") e um extrato de coparticipação separado. Nome comercial genérico ("Unimed"), mas layout de PDF completamente diferente da "Unimed Saúde" (5060) já cadastrada — outra Unimed regional, código de operadora próprio no Questor (158).

  • Novo parser operadoras/unimed_cascavel/saude.py (UnimedCascavelSaude), registrado em pipeline.OPERADORAS["unimed_cascavel_saude"]. chave_casamento="nome" — nenhum dos formatos de arquivo traz CPF.
  • Bug de extração descoberto na inspeção (antes de escrever qualquer regex): nem extract_text() simples nem extract_text(layout=True, x_density=6) (a técnica já usada por outros parsers deste pacote) preservam o espaçamento entre palavras deste PDF — as duas fundem tudo sem espaço nenhum ("UNIMEDDECASCAVEL...", nomes de beneficiário colados). Confirmado inspecionando page.chars diretamente que o espaçamento real é mais estreito que a tolerância padrão do pdfplumber; corrigido usando extract_words(x_tolerance=1) (em vez de extract_text()) e reconstruindo cada linha por posição vertical (top) — mesma técnica de reconstrução já usada pela Unimed Vitória, por um motivo diferente (lá era quebra de linha física, aqui é fusão de palavra).
  • Coparticipação pode vir de duas fontes possíveis pro mesmo mês, e a operadora não avisa qual vai mandar: um relatório de mensalidade pode opcionalmente trazer, na mesma página, uma tabela "DESPESAS COBRADAS" já com a coparticipação resumida por beneficiário — ou ela pode vir num arquivo separado "EXTRATO DE ATENDIMENTOS COBRADOS". Confirmado no arquivo-modelo que as duas, quando aparecem juntas, são a MESMA competência (totais batendo centavo a centavo). Resposta do usuário à pergunta "dá pra implementar as duas? o modelo de arquivo é gerado pela operadora" foi sim — implementadas as duas, mas nunca somadas: OperadoraParser ganhou um hook novo, finaliza() (operadoras/base.py, chamado pelo pipeline uma vez depois que TODOS os arquivos da importação já foram processados, default não faz nada — só a Unimed Cascavel sobrescreve por enquanto), usado aqui pra escolher o extrato separado quando presente (mais granular) e só cair pra tabela embutida na ausência dele — nunca os dois juntos.
  • Resolução de família (titular/dependente) só por matrícula, nunca por nome: a coluna "Usuário" da mensalidade trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana — confirmado que o texto pára exatamente na borda da coluna seguinte), então cruzar o nome truncado da mensalidade com o nome completo do extrato de coparticipação não seria confiável. Em vez disso, a matrícula (idêntica nos dois formatos) é a chave de tudo: _pessoa_por_matricula é populado só a partir da tabela de mensalidade (onde a família já vem certa por ordem de bloco) e reaproveitado pra resolver os dois candidatos de coparticipação, que só carregam matrícula + valor. Beneficiário com coparticipação sem nenhuma linha de mensalidade correspondente nesta importação vira ItemAuditoria explícito (arquivo de mensalidade daquele contrato não anexado), nunca é descartado em silêncio.
  • Perguntado e confirmado com o usuário: se nem a mensalidade nem a coparticipação trouxerem valor pra alguém numa competência, assume-se que não houve despesa (nenhum ItemAuditoria gerado só por ausência).
  • Validado rodando extrai()/finaliza() de ponta a ponta contra os 3 arquivos reais: mensalidade batendo R$ 4.076,41 + R$ 808,98 = R$ 4.885,39 (12 beneficiários, 2 contratos) e coparticipação batendo R$ 1.067,17 (3 beneficiários) — confirmado também que a tabela embutida (extraída em paralelo, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada.
  • Bug real corrigido no mesmo dia, testando pela tela: anexar o extrato de coparticipação junto com a mensalidade dava "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação (POST /.../validar-arquivo/), mesmo o arquivo estando correto. Causa: _valida_arquivo_operadora() (views.py) valida cada arquivo isoladamente, com uma instância nova do parser, e só olhava o retorno direto de extrai() — mas o extrato de coparticipação da Unimed Cascavel devolve ([], []) de propósito (os dados ficam retidos até finaliza(), ver acima), então a pré-validação nunca via nada. Corrigido chamando parser.finaliza() também dentro de _valida_arquivo_operadora(), somando individuos + individuos_finais + auditoria_final pra decidir se "algo foi encontrado" — sem exigir que a resolução completa (que depende de ver a família inteira, só disponível na importação real com todos os arquivos juntos) já esteja pronta nesse momento. Não afeta nenhuma outra operadora (finaliza() default devolve sempre vazio, soma zero). Validado rodando a mesma checagem isolada contra os 3 arquivos reais: 13/2/3 lançamentos encontrados respectivamente (o arquivo de mensalidade com "DESPESAS COBRADAS" embutida agora conta certo os 10 de mensalidade + 3 de coparticipação, em vez de só 10).

O fato de a operadora ter mandado um arquivo por contrato/filial não teve nenhuma relação com o erro — múltiplos arquivos de operadora por importação já são suportados desde a Rodada 83 (mesclados automaticamente).

Skill de negócio dividida em duas (Questor x geral)

Pedido do usuário: o escritório trabalha com mais de um sistema contábil (Questor hoje, Contabit no futuro), e a etapa final da ferramenta (estruturar/gerar o arquivo de lançamento) diverge entre eles, mesmo com a extração/regras de negócio sendo as mesmas. A skill única importacao-questor-plano-saude (que, apesar do nome, cobria a ferramenta inteira) foi dividida:

  • importacao-plano-saude (nova, skill geral): tabela de operadoras/parsers, checklist de "como adicionar operadora nova", regras de negócio que nunca mudam (nome nunca por aproximação, valor negativo sempre auditoria, somar por indivíduo), "Regra empresa" e "Vínculos de nome salvos", e os gaps de custeio por operadora (AMIL tipo A, Bradesco dependente sem linha) — tudo independente de sistema contábil de destino. Ganhou uma instrução de despacho: confirmar qual é o sistema de destino e carregar a skill específica antes de tocar em leiaute/Cadastro de Regras/geração de arquivo.
  • importacao-questor-plano-saude (existente, esvaziada e focada): ficou só com o que é Questor de fato — "Cadastro de Regras" (por que o cadastro em si, ao contrário da decisão de custeio, nasce amarrado ao codigo_empresa/codigo_operadora resolvidos contra o banco do Questor) e a auditoria manual pós-importação contra o relatório de lançamentos do Questor.
  • Uma terceira skill pro Contabit ainda não existe — a skill geral só documenta o gap e avisa pra não inventar leiaute Contabit sem confirmação, e já registra uma ressalva: o "casamento" hoje (matcher.py/LinhaSistema) usa um formato de registro moldado no leiaute do Questor, então o Contabit provavelmente vai exigir mais que só uma geração de arquivo diferente — um LinhaSistema/matcher próprios também, quando essa frente for aberta.
  • Referências cruzadas atualizadas em portal_api/planos_saude/CLAUDE.md e README.md (skill única → as duas); aproveitado pra corrigir a contagem de parsers no README.md, que ainda dizia "9" (desatualizada desde as rodadas da Humana/Unimed Cascavel).
  • Redirecionamento reforçado, mesmo dia: usuário perguntou o que aconteceria se a skill importacao-questor-plano-saude fosse chamada direto pra cadastrar uma operadora nova — o aviso original (uma frase solta na seção 0, tipo "ver skill X, sempre o ponto de partida") dependia de inferência, não era uma trava amarrada ao cenário específico. Trocado pelos dois lados por um "pare aqui" explícito logo no topo: a skill geral (importacao-plano-saude) avisa pra quem for mexer em Cadastro de Regras/planilha padrão/leiaute/geração de arquivo ir pra skill do Questor; a skill do Questor avisa pra quem for adicionar/ajustar parser de operadora ir pra skill geral (seção 4) antes de escrever qualquer código ou fazer qualquer pergunta ao usuário. Aproveitado pra corrigir uma referência cruzada errada (importacao-plano-saude apontava "gap 4" quando o gap do Contabit é o item 3 da seção 3).

Bug real — Unimed Saúde (5060, PDF de coparticipação): código de "Tipo Serviço" colado ao Prestador não reconhecido

Usuário reportou (empresa Questor 1970, "Rede Brasil de Mídia OOH LTDA", competência 08/2026) "Nenhum beneficiário foi encontrado neste arquivo" ao anexar o arquivo de coparticipação, enquanto a mensalidade da mesma competência processou normalmente (3 lançamentos). Investigado rodando pdfplumber/extrai() de ponta a ponta contra o arquivo real: a detecção de layout e o casamento da linha de pessoa (_PESSOA_COPARTICIPACAO_RE) funcionavam normalmente, mas nenhuma linha de item de serviço casava com _ITEM_COPARTICIPACAO_RE — o resultado final era sempre zero indivíduos.

Causa: _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, esse código vem colado sem espaço nenhum ao final do nome do Prestador (ex.: "...MARCELO FABRICCON 10101012...", "...LUCIANO GUSTAVEXA 40316572..." — mesmo estilo de coluna colada já visto no "Beneficiario" desde a rodada 83, só que numa coluna diferente), então a fronteira à esquerda nunca era satisfeita. O mesmo arquivo revelou, de quebra, mais dois valores não previstos: o grau de dependência "OUTROS DEP" (ausente de _GRAUS_DEPENDENCIA) e o código de tipo de serviço "CIR" (cirurgia — "Implante de dispositivo").

Corrigido em operadoras/unimed/saude.py: _ITEM_COPARTICIPACAO_RE perdeu a fronteira de palavra à esquerda (mantida só à direita, pra não casar um código no meio de outra palavra) e ganhou "CIR"; "OUTROS DEP" foi acrescentado a _GRAUS_DEPENDENCIA — sem esse segundo ajuste, mesmo com o regex do item corrigido, a coparticipação dessa dependente cairia por engano na pessoa anterior do bloco (mesmo bug do "COMPANHEIRO" truncado, ver 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 — bate exatamente com "Total da Familia: 431,02" impresso no relatório; reconfirmado, sem regressão, que a mensalidade da mesma competência continua extraindo os mesmos 3 beneficiários de antes.

"Regra específica": novo critério "Limite de desconto do empregado"

Usuário pediu uma terceira modalidade dentro de "Regra específica" (Cadastro de Regras): até então só existiam critérios que protegem o gasto da EMPRESA (limite_valor: teto de quanto ela cobre; percentual: fração do valor custeada por ela) — faltava a direção oposta, um teto de quanto é descontado do empregado, com a empresa absorvendo o restante sem limite algum (exemplo dado: mensalidade de R$150/R$200, desconto sempre limitado a R$10, empresa cobre R$140/R$190).

  • Backend: _monta_regra_custeio() (serializers.py) ganhou um quarto parâmetro (limite_desconto_empregado_bruto) e passou a montar regra["limite_desconto_empregado"]; matcher._calcula_valores() ganhou um branch novo que, quando esse campo vem preenchido, calcula valor_empregado = min(valor_total, limite_desconto_empregado) e deriva valor_empresa como o complemento — mutuamente exclusivo com limite_valor/percentual (validado explicitamente em _monta_regra_custeio, erro claro se os dois grupos vierem preenchidos juntos), porque os dois protegem lados opostos do valor (teto da empresa vs. teto do empregado) e misturá-los não tem uma resolução determinística única quando entram em conflito. ImportacaoPlanoSaudeCreateSerializer ganhou os 4 campos limite_desconto_empregado_<tipo>_<pessoa> (mesmo padrão de limite_valor_.../percentual_... já existentes); RegraCusteioPlanoSaudeSerializer.validate() passou o novo campo adiante também. Nenhuma migração — continua dentro do mesmo JSONField (custeio_por_tipo), só um campo novo dentro do dict de cada combinação tipo×pessoa quando modo="especifica".
  • Frontend (importacao-plano-saude.html/.js): terceiro campo "Limite de desconto do empregado" acrescentado às 4 caixas de "Regra específica" (mensalidade/coparticipação × titular/dependente), num agrupamento visual separado (.ips-regra-especifica__alt, linha divisória) dos dois campos existentes, com hint próprio explicando a exclusividade. atualizarExclusividadeRegraEspecifica() (nova) desabilita ao vivo um grupo de campos assim que o outro é preenchido (não deixa o usuário sequer tentar preencher os dois) — chamada a cada tecla digitada nos três campos e sempre que o formulário é limpo (limparCusteioForm()) ou repopulado a partir de uma regra salva (aplicarCusteio()). coletarCusteioAtual(), mensagemErroCusteio(), resumoModoPessoa() (resumo só-leitura de "Nova Importação") e montarFormDataDeRegra() atualizados pra ler/validar/exibir/enviar o campo novo.