125 KiB
Importação de Plano de Saúde (Utilitários)
Movido do
CLAUDE.mdda raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). VerCLAUDE.mdna raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro deportal_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) eimportacao-questor-plano-saude(a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final). Uma terceira skill pro Contabit ainda não existe.
Primeira aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo; a segunda é a variante "- De Paula", ver a seção logo abaixo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de toggle único ({"key": "importacao-plano-saude", "label": "..."}, entrada flat em catalogo.MODULE_APPS["utilitarios"], sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de LinkFerramenta/AcessoGeral). Por ser um app flat, não precisou de nenhum override em seed_portal.py (esse cuidado só existe pra pares visualizar/editar).
A lógica de negócio em si não nasceu neste projeto — veio de um pipeline Python já testado e documentado em projects/importacao-planos-saude.skill (arquivo .skill, é um zip — SKILL.md + scripts/), com um protótipo funcional em projects/project/ (CLI main.py, nunca tocado pelo Portal, fica só como referência/histórico). Esse pipeline foi portado quase 1:1 para dentro do Django em portal_api/planos_saude/ (pacote Python puro, sem depender do ORM):
portal_api/planos_saude/
├── modelos.py Lancamento, Individuo, LinhaSistema, ItemAuditoria (dataclasses)
├── matcher.py casa_individuos_com_planilha() — casamento por CPF ou por nome
├── leiaute_sistema.py CABECALHO, le_planilha_padrao(), formata_valor_br()
├── pipeline.py OPERADORAS (registro), processa_importacao() — orquestração, chamada pela view
└── operadoras/
├── base.py OperadoraParser (interface)
├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo)
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber **ou** Excel via openpyxl — detectado pela extensão do arquivo, ver nota abaixo —, só mensalidade, casamento por CPF)
├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo)
├── humana/saude.py Humana Saúde — 5064 (PDF via pdfplumber, mensalidade+coparticipação no mesmo arquivo, casamento por nome — coparticipação nunca casada automaticamente, ver nota abaixo)
├── unimed_cascavel/saude.py Unimed Cascavel — 158 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome — coparticipação pode vir de duas fontes possíveis, nunca somadas juntas, ver nota abaixo e `OperadoraParser.finaliza()`)
└── metlife/odonto_mensalidade.py MetLife Odonto — 3761 (PDF via pdfplumber, só mensalidade, casamento por CPF, extração por posição de coluna em vez de regex de linha, ver nota abaixo)
Pra adicionar uma operadora nova: criar operadoras/<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 (3758) — PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", só mensalidade, casamento por CPF. Bug real corrigido (rodada em que este parser foi validado pela primeira vez contra um arquivo real, contrato 2831804000): o regex de parsing de linha exigia espaço (\s+) entre a coluna do plano (ex.: "DENTAL BRONZE DOC R PADRÃO") e a coluna "Tp." logo em seguida, mas nesse relatório real as duas colunas vêm coladas sem nenhum espaço ("PADRÃOT", "PADRÃOD", "PADRÃOA") — não é um problema de x_density do pdfplumber (testado de 6 até 20, sem efeito), o espaço realmente não existe no PDF de origem. Toda linha falhava o match silenciosamente, resultando em "Nenhum beneficiário foi encontrado neste arquivo" pra qualquer arquivo AMIL. Corrigido trocando esse \s+ por \s* em _AFTER_CPF_RE (operadoras/amil/odonto_mensalidade.py). Validado rodando extrai() de ponta a ponta contra o arquivo real: 161 beneficiários (124 titulares/35 dependentes/2 agregados), R$ 1.630,93 no total, batendo exatamente com os totais impressos no próprio relatório. Nota: até esta rodada, este parser aparecia documentado (aqui e na skill importacao-plano-saude) com o código "898" — divergência de documentação desde a primeira versão do arquivo, nunca refletida em pipeline.OPERADORAS (sempre foi 3758); corrigido nesta rodada, ver item abaixo.
Amil Odonto tem um segundo cadastro no Questor, código 898 — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com codigo_operadora diferentes (CODIGOOUTEMP), porque empresas distintas usam um ou outro cadastro. pipeline.OPERADORAS ganhou uma segunda chave, amil_odonto_mensalidade_898 (codigo_operadora="898", mesma nome="Amil Odonto"), reaproveitando a mesma classe AmilOdontoMensalidade — regras de extração e de custeio são idênticas às de amil_odonto_mensalidade/3758, só muda qual código filtra a planilha padrão buscada no Questor (busca_linhas_questor, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de parser.
Amil Odonto (898) também lê Excel, não só PDF — o cliente Tecnomyl (empresa 1778, cadastro 898) manda o mesmo relatório "Demonstrativo Analítico Faturamento" em .xlsx, não em PDF. AmilOdontoMensalidade.extrai() detecta a extensão do arquivo (.xlsx/.xlsm → _extrai_xlsx, via openpyxl; qualquer outra → o caminho PDF já existente, inalterado) — mesmo padrão de detecção por formato já usado pela Unimed Saúde (CSV/PDF). _localiza_cabecalho_xlsx acha a linha de cabeçalho de verdade (Código/Beneficiário/CPF/Tipo/Mensalidade) procurando pelo NOME das colunas dentro das primeiras 20 linhas, não por um número de linha fixo — o arquivo real tem um número variável de linhas de título/contrato/fatura acima da tabela. "Total Família" é só um subtotal por família (mesmo espírito do "Valor Total" do PDF, item 1 acima — ignorado); o valor de cada linha vem de "Mensalidade", somado por Código (um mesmo beneficiário pode se repetir em várias linhas — rubricas normais + devoluções/exclusões retroativas de competências anteriores cobradas na mesma fatura, mesma regra geral de "somar por indivíduo"). A coluna "Tipo" já vem como 'T'/'D'/'A' direto do relatório, sem precisar de nenhuma inferência a partir da "Dependência" (livre, só informativa). CPF normalizado + .zfill(11) por segurança (mesmo cuidado da MetLife). Validado rodando extrai() de ponta a ponta contra o arquivo real da competência 08/2026 (contrato Tecnomyl): 475 beneficiários (217 titulares/220 dependentes/38 agregados), cada um pagando um valor fixo por plano (R$ 20,81 no plano "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — bate exatamente com a régua "Tipo" T/D/A do relatório real batendo 1:1 com os exemplos de "agregado" (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) repassados pelo cliente pra amil_898_tecnomyl (ver "Regra empresa" abaixo). 2 beneficiários com valor final negativo no arquivo real (exclusão retroativa de competência anterior cobrada nesta fatura) corretamente caem em auditoria (VALOR_NEGATIVO), sem relação com o parser em si — comportamento já existente do matcher.py, não precisou de nenhum tratamento novo.
Nota operacional (não é bug, mas vale saber): alguns dependentes do arquivo real vêm sem CPF (campo vazio) — normalizado para "00000000000". Como chave_casamento="cpf" pra esta operadora, esse tipo de linha só casa automaticamente se a planilha padrão do Questor também tiver um CPF preenchido pra essa pessoa (o caso normal); se o Questor tiver o CPF real da pessoa (diferente de zeros), a linha cai em auditoria "CPF não encontrado" e precisa de "Vincular pessoa" manual. Diferente da estratégia "nome" (_casa_por_nome), a estratégia "cpf" (_casa_por_cpf) não aceita vinculos_por_nome — um vínculo salvo ao resolver manualmente esse caso não é reaplicado automaticamente numa competência futura (a mesma pessoa sem CPF precisaria ser vinculada de novo todo mês). Isso não é uma limitação nova desta rodada, é um comportamento pré-existente de qualquer operadora com chave_casamento="cpf" (Amil, SulAmérica, MetLife) — só ficou mais visível aqui porque o arquivo real da Tecnomyl tem vários dependentes menores de idade sem CPF cadastrado. Estender o DE/PARA por nome pra também cobrir a estratégia "cpf" seria uma mudança maior, fora do escopo desta rodada — avaliar se vale a pena caso isso vire um incômodo recorrente.
Unimed Vitória (4750) — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (pdfplumber rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num top próximo mas não igual — nem extract_text() nem extract_text(layout=True) resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de extract_words(extra_attrs=["fontname","size"]) agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige extract_words() em vez de concatenar page.chars direto. Titular/dependente é uma suposição não validada: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha ".<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): nemextract_text()simples nemextract_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 inspecionandopage.charsdiretamente: o espaçamento real entre palavras neste PDF é mais estreito que a tolerância padrão do pdfplumber (a densidade dex_density=6dolayout=Truenão ajudou; a solução foi baixar ox_tolerancedeextract_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 deextract_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) efinaliza()(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 nenhumItemAuditoria, é 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/tiponã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 emfinaliza()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 umItemAuditoriaexplícito (NAO_CADASTRADO), nunca é descartado silenciosamente. Nomes truncados na mensalidade em si seguem o fluxo normal (NOME_DIVERGENTEem auditoria, resolvido manualmente uma vez via "Vincular pessoa" — nunca por aproximação). - Validado rodando
extrai()/finaliza()de ponta a ponta contra os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, 2 contratos — 183237 e 183210/"Estadual"): mensalidade batendo exatamente com os totais impressos (R$ 4.076,41 + R$ 808,98 = R$ 4.885,39, 12 beneficiários) e coparticipação batendo com R$ 1.067,17 (3 beneficiários), confirmando que a tabela embutida (também extraída, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada.
MetLife Odonto (3761) — relatório "Detalhamento de Mensalidade" (NF-e da própria MetLife), só mensalidade, casamento por CPF. Confirmado com o usuário: segue a regra padrão de custeio (mensalidade separada por titular/dependente), sem "Regra empresa". Diferente dos demais parsers deste pacote, a extração é feita bucketizando extract_words() por posição de coluna (x0), não por regex de linha inteira — necessário porque o Nome não tem largura fixa nem separador (ex.: "MICHELE REGINA DA SILVA EUGENIO"), então um regex de .+? até a próxima coluna arriscaria errar a fronteira; a mesma técnica generaliza de graça pra colunas opcionais (Parentesco/Nº Funcional, vazias em boa parte das linhas) sem precisar de grupos regex opcionais complicados.
- Titular/dependente é decidido pela coluna Parentesco estar vazia ou não (nunca pelo sufixo do código de beneficiário, ex.
.00/.01) — decisão tomada depois de um caso real já no próprio arquivo-modelo: um dependente (mãe de um titular) veio com o código malformado no próprio PDF de origem (50696900.000100.01, 8 dígitos na primeira parte em vez dos 6 esperados — confirmado inspecionandopage.extract_words()que a string malformada já existe no PDF, não é artefato de extração; a "Adesão" desse dependente era bem mais recente que o resto da família, sugerindo inclusão tardia com erro do sistema da própria operadora). A coluna Parentesco continuou confiável nesse caso, então a classificação não foi afetada;numero_beneficiarioguarda o código bruto tal como veio (mesmo malformado), só como identificador de exibição, nunca como chave de casamento. - CPF sai sem zero(s) à esquerda quando o valor real da pessoa começa com 0 — confirmado comparando com o formato real de
CPFFUNC/CPFDEPENDENTEna planilha padrão do Questor (sempre 11 dígitos,XXX.XXX.XXX-XX): os CPFs deste relatório MetLife saem com 9 a 11 dígitos (ex.:"795136978", 9 dígitos), típico de um campo numérico que perdeu o(s) zero(s) à esquerda ao ser gerado a partir de planilha. Sem corrigir, o casamento por CPF falharia silenciosamente ("CPF não encontrado") pra toda pessoa cujo CPF real começa com zero — corrigido com.zfill(11)na extração (operadoras/metlife/odonto_mensalidade.py), seguro porque CPF brasileiro sempre tem 11 dígitos. - Validado rodando
extrai()de ponta a ponta contra o arquivo real (competência 09/2026, empresa "OESTEFOZ NEW CORRETORA DE SEGUROS LTDA", 3 famílias): 8 beneficiários (3 titulares + 5 dependentes), R$ 120,00 no total — bate exatamente com "TOTAL DE MENSALIDADES: 8 itens 120,00" impresso no próprio relatório. Ainda não rodado contra a planilha padrão real de nenhuma empresa (nenhumaRegraCusteioPlanoSaudecadastrada pra esta operadora até o momento). - Não confirmado ainda: o arquivo-modelo só tinha nomes/planos curtos, cabendo numa única linha física — nome ou plano que ultrapasse a largura da coluna e quebre em duas linhas físicas ainda não foi visto; reconferir se aparecer numa competência real.
Diferença deliberada em relação ao pipeline original: lá, o valor do mês sempre gravava na coluna VALOR (desconto do empregado), nunca em VALOREMPRESA — regra fixa. Aqui, o usuário escolhe na tela de nova importação, por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente) — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" ({"modo": "empresa"}), "Descontado do empregado" ({"modo": "empregado"}, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com NOMEFUNC/CPFFUNC do leiaute do Questor, que é outra coisa) ou "Regra específica" ({"modo": "especifica", "limite_valor": float|None, "percentual": float|None, "limite_desconto_empregado": float|None}).
Dentro da regra específica, dois grupos de critério, mutuamente exclusivos entre si (validado em _monta_regra_custeio, serializers.py, e refletido no formulário desabilitando um grupo assim que o outro é preenchido — atualizarExclusividadeRegraEspecifica(), importacao-plano-saude.js):
limite_valor/percentualprotegem o gasto da empresa:limite_valoré um teto de quanto ela cobre (o excedente vira desconto do empregado) epercentualé a fração do valor do mês custeada por ela (o resto vira desconto). Podem vir só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no menor valor custeado pela empresa (mais restritivo), decisão explícita do usuário.limite_desconto_empregadoprotege o gasto do empregado (adicionado a pedido do usuário, ex.: mensalidade de R$150/R$200 com desconto sempre limitado a R$10, a empresa absorve o restante — R$140/R$190): teto de quanto é descontado dele, sem limite algum pro que sobra pra empresa. Direção oposta da anterior — combinar os dois grupos não teria uma resolução determinística única quando entrassem em conflito (ex.: um teto de empresa que por si só implicaria um desconto maior que o teto de empregado permitido), por isso o formulário nunca deixa preencher os dois grupos ao mesmo tempo pra uma mesma combinação tipo×pessoa.
Essa divisão é calculada por matcher._calcula_valores(valor_total, regra) (chamada por _aplica_regra_custeio, que grava valor_empresa/valor os dois juntos a partir do mesmo valor_total) — note que o valor "protegido" (empresa ou empregado, conforme o grupo de critério usado) é arredondado primeiro e o outro é derivado como o complemento exato (valor_total menos o protegido, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada Individuo/LinhaSistema é resolvido por matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa) — tipo_pessoa 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de _casa_por_cpf/_casa_por_nome antes de _aplica_regra_custeio.
"Importação de Plano de Saúde - De Paula" (segunda instância da mesma ferramenta)
Existe uma segunda aplicação no menu Utilitários, importacao-plano-saude-de-paula ("Importação de Plano de Saúde - De Paula"), que é a mesma ferramenta apontada para o plano de saúde dos próprios colaboradores do escritório, não dos clientes. Os docstrings de ImportacaoPlanoSaudeDePaula (models.py) e o bloco de comentário das views (views.py, logo antes de _garante_importacao_de_paula_em_revisao) apontam para esta seção.
Por que uma cópia em vez de um campo/filtro no mesmo histórico: o dado é de natureza diferente (dado pessoal de colaborador do escritório, não dado de cliente), e a separação garante que nada cruza entre as duas, nem por engano, nem por um filtro esquecido numa query. Em troca, tudo que for regra de negócio é compartilhado sem duplicação.
O que é duplicado (tabelas e permissão próprias, sem nenhuma FK cruzando com as originais):
| Original | Variante De Paula |
|---|---|
ImportacaoPlanoSaude |
ImportacaoPlanoSaudeDePaula |
ImportacaoPlanoSaudeArquivoOperadora |
ImportacaoPlanoSaudeDePaulaArquivoOperadora |
ImportacaoPlanoSaudeLinha |
ImportacaoPlanoSaudeDePaulaLinha |
ImportacaoPlanoSaudeAuditoria |
ImportacaoPlanoSaudeDePaulaAuditoria |
ImportacaoPlanoSaudeAlteracao |
ImportacaoPlanoSaudeDePaulaAlteracao |
RegraCusteioPlanoSaude |
RegraCusteioPlanoSaudeDePaula |
VinculoNomeOperadora |
VinculoNomeOperadoraDePaula |
/api/importacoes-plano-saude/ (+ -linhas, -auditoria, -alteracoes) |
os mesmos com sufixo -de-paula |
/api/regras-custeio-plano-saude/ |
/api/regras-custeio-plano-saude-de-paula/ |
templates/importacao-plano-saude.html |
templates/importacao-plano-saude-de-paula.html |
upload em media/planos_saude/ |
upload em media/planos_saude_de_paula/ |
O DE/PARA de nomes (VinculoNomeOperadoraDePaula) é tabela separada de propósito: um vínculo criado na aplicação de clientes nunca vale para a dos colaboradores, e vice-versa.
O que é compartilhado (nada disso foi copiado): todo o pacote portal_api/planos_saude/ (parsers de operadora, matcher, pipeline, regras_empresa, leiaute do Questor) e os helpers puros de views.py (_salva_arquivo_temporario, _valida_planilha_padrao, _valida_arquivo_operadora, _monta_csv_linhas_plano_saude, _snapshot_linha_plano_saude). Consequência prática: adicionar uma operadora nova, corrigir um parser ou mexer numa regra de custeio vale automaticamente para as duas aplicações — não existe (nem deve existir) uma versão "De Paula" de nenhum arquivo do pacote.
Frontend: um arquivo só, parametrizado. static/js/importacao-plano-saude.js e static/css/importacao-plano-saude.css servem as duas páginas. O template da variante define window.PID_IPS_CONFIG num <script> inline antes de carregar o JS, com appKey, os 5 prefixos de endpoint e tituloAjuda; a página original não define nada, e o Object.assign no topo do JS cai nos defaults, que são os valores da aplicação original. Ao mexer no JS, o caminho certo é sempre PID_IPS_CONFIG.endpointX, nunca uma rota literal — uma rota escrita na mão faria a variante gravar no histórico errado.
Divergência conhecida e deliberada: o template da variante não tem os três elementos do resumo da regra de custeio na tela de Revisão (ips-review-resumo-tipos, ips-review-resumo-mensalidade, ips-review-resumo-coparticipacao), adicionados à página original depois da duplicação. O JS testa cada um antes de usar (if (reviewResumoTipos) {...}), então a variante simplesmente não mostra esse bloco, sem erro. Se for para igualar as duas telas, é só copiar o markup; o JS já está pronto.
Permissão: PermissaoApp("utilitarios", "importacao-plano-saude-de-paula"), independente da original — quem tem acesso a uma não ganha acesso à outra. Foi a primeira aplicação a seguir a convenção de "nasce restrita ao perfil Inovação" por lidar com dado pessoal de colaborador do escritório (seed_portal.py força a chave para False em todo perfil que não seja Integração e Inovação; ver o comentário no próprio arquivo e "Convenção pra aplicação nova que lide com dado sensível/pessoal" no CLAUDE.md da raiz). A aplicação original, anterior a essa decisão, continua com a visibilidade ampla de sempre.
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 deplanos_saude.regras_empresa.REGRAS_EMPRESAquando "mensalidade" foi custeada por uma regra especial em vez docusteio_por_tipo["mensalidade"]normal, ver "Regra empresa" abaixo),planilha_padrao(FileField, mesmo padrão de validator de tamanho deLinkFerramenta.icone, só que 15MB em vez de 2MB — éblank=Truedesde 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, verImportacaoPlanoSaudeArquivoOperadoraa seguir e "Múltiplos arquivos de operadora" abaixo.ImportacaoPlanoSaudeArquivoOperadora: um dos relatórios da operadora anexados a uma importação (FKimportacao,arquivoFileField,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 antigoFileFieldúnicoImportacaoPlanoSaude.arquivo_operadora(migração em 3 passos —0048cria o model novo + torna o campo legadoblank=True;0049,RunPython, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo emMEDIA_ROOT, sem copiar bytes;0050remove o campo legado — mesmo padrão já usado emIndicadorDepartamento/RegraCusteioPlanoSaude.codigo_empresa).ImportacaoPlanoSaudeLinha: uma linha da planilha padrão já casada com o valor do mês (espelhaLinhaSistemacampo 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 deImportacaoPlanoSaudeAlteracaojá carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo.valor/valor_empresaficam comoCharFieldno mesmo formato string do pipeline ("51,69"/"0"), nãoDecimalField, 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 nesseCharField, 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,importacaoAtualsó era atualizado por outras ações da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), então editar uma célula deixava o resumo por tipo (contadores "linhas no total"/"com valor lançado"/"em auditoria") e a aba Alterações com o estado de antes da edição até o usuário sair e reabrir a importação do zero. - Linha de total no rodapé da tabela (pedido explícito do usuário): as abas de Mensalidade/Coparticipação (
panelHtmlParaTipo(),importacao-plano-saude.js) ganharam uma última linha (.ips-grid-row--totais, fundo/negrito iguais ao cabeçalho) somando Valor Empresa e Valor de todas as linhas do tipo (não só as visíveis num scroll, e independente da ordenação da coluna) — rótulo "Total" fica na coluna "Nome Funcionário", as demais colunas ficam em branco.pidValorRevisaoParaNumero()(extraída decomparaValorRevisao(), mesma função reaproveitada) converte cada"1.234,56"BR pra número antes de somar; linhas em auditoria (valor "0") entram na soma sem alterá-la. Some sozinha quando a aba está vazia (mesmolinhas.lengthque já decide a linha "Nenhuma linha."), não é uma linha nova editável nem geraImportacaoPlanoSaudeAlteracao.- Fixa no fim da área visível (
importacao-plano-saude.css, pedido explícito do usuário):.ips-grid-row--totais .ips-grid-cellusaposition:sticky; bottom:0— gruda no rodapé de.ips-table-scroll(o ancestral comoverflow-y) independente de até onde o usuário rolou a tabela ou de quanto ela foi expandida (wireAutoExpandScroll(), ver abaixo — sticky é recalculado contra o tamanho atual do container, não um valor fixo em px).z-index:2(maior que o1da coluna de ações comum) garante que a linha continue por cima das linhas de dado que passam por trás dela ao rolar.
- Fixa no fim da área visível (
- Alça de redimensionar só de altura, não mais de largura (
.ips-table-scroll,importacao-plano-saude.css): caixa de cada aba (Mensalidade/Coparticipação/Auditoria/Alterações) nasceu comresize:both(420px de altura inicial, até 80vh), chegou a virar altura totalmente livre (sem caixa/scroll interno, uma rodada revertida) e voltou pra caixa de 420px — só que agora comresize:verticalem vez deresize:both: o usuário só arrasta a alça pra aumentar a altura (ver mais linhas de uma vez), a largura já rola sozinha viaoverflow-x:auto, sem precisar de alça própria pra isso. O redimensionar de coluna (wireColumnResize(), alça em cada cabeçalho) é outro mecanismo, independente, e não foi afetado por nenhuma dessas mudanças. - Duplo clique na alça alterna expandir/voltar (
wireAutoExpandScroll(),importacao-plano-saude.js, chamada junto dewireColumnResize()emrenderPanels()): a alça nativa doresizenão é um elemento do DOM, então o duplo clique é reconhecido pela posição do clique dentro dos últimos ~20px do canto inferior direito de.ips-table-scroll(PID_IPS_RESIZE_HANDLE_HIT_PX), não por um alvo específico. Primeiro duplo clique:wrap.style.maxHeight="none"(senão o teto de 80vh continuaria cortando) ewrap.style.height = wrap.scrollHeight + "px"— mesma ideia de "autofit" de largura de coluna de uma planilha (Excel/Sheets), só que de altura;wrap.dataset.autoExpandidomarca o estado, guardando oheight/max-heightinline de antes (dataset.alturaAnterior/maxAlturaAnterior, string vazia = "sem inline style", volta a valer o CSS padrão). Segundo duplo clique: restaura exatamente esses dois valores guardados (pedido explícito do usuário — "reverter para como estava antes", não necessariamente os 420px padrão, caso o usuário já tivesse arrastado a alça manualmente antes de expandir) e limpa o estado. Mesma limitação do redimensionamento manual por arrasto: é um ajuste inline,renderPanels()recria os elementos do zero (qualquer edição de célula, adicionar/remover linha etc.), então sempre nasce não-expandido de novo.
- 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 (
ImportacaoPlanoSaudeAuditoria: espelhaItemAuditoria— os campos extraídos do arquivo da operadora (motivo/nome/valor/detalhe...) são read-only na tela;resolvida/linha_vinculadasã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_INVALIDOsã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 deLinkFerramenta/AcessoGeral— o próprio model não tem FK pra nada; éImportacaoPlanoSaude.regra_custeio_salvaque aponta pra cá (opcional,SET_NULL), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.ImportacaoPlanoSaudeLinha/ImportacaoPlanoSaudeDePaulaLinhaganharamtipo_pessoa(CharField(max_length=1, blank=True), migração0074) — 'T'/'D'/'A' (Titular/Dependente/Agregado), espelhandoLinhaSistema.tipo_pessoa(planos_saude/modelos.py). Não é exposto em nenhum serializer (uso só interno, nenhum consumidor de frontend precisa dele) — existe só pra alimentar uma regra de empresa que precise diferenciar dependente de agregado por PESSOA (a planilha padrão do sistema em si só distingue titular/não-titular, vianome_dependente/cpf_dependentevazios ou não — nunca soube separar "D" de "A"). Ver "Regra empresa" abaixo (amil_898_tecnomyl) pro primeiro consumidor real.
Fluxo e endpoints
ImportacaoPlanoSaudeViewSet (/api/importacoes-plano-saude/, PermissaoApp("utilitarios", "importacao-plano-saude") pra todos os métodos):
create()(multipart,ImportacaoPlanoSaudeCreateSerializervalida a entrada) resolve a planilha padrão (upload ou busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + umImportacaoPlanoSaudeArquivoOperadorapor arquivo emarquivo_operadora(lista, ver "Múltiplos arquivos de operadora" abaixo) e rodapipeline.processa_importacao()de forma síncrona usando os caminhos de todos os arquivos da operadora + a lista deLinhaSistemajá 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/(@actionsem detail) devolvepipeline.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.labeljá 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 empipeline.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) usandoleiaute_sistema.CABECALHO; 1 tipo de lançamento vira um.csvdireto, 2 tipos (mensalidade + coparticipação) viram um.zipcom um.csvpor tipo (zipfileem memória). Sempre marcastatus="concluida"(+concluida_em) — pode ser chamada de novo enquantoconcluida(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 (verreabrir()abaixo e "Trava de edição pós-conclusão"). Nome do arquivo baixado (_nome_base_arquivo_gerado_plano_saude(),views.py, reaproveitada também porImportacaoPlanoSaudeDePaulaViewSet.gerar()):"<código empresa> - <código operadora>"(ex.:"1970 - 5060.zip","1970 - 5060 - mensalidade.csv") — pedido explícito do usuário, o nome genérico antigo (importacao_plano_saude_<id>.zip) não dava pra distinguir, já baixado, duas execuções da mesma empresa com operadoras diferentes. O código de empresa vem da primeiraImportacaoPlanoSaudeLinhanão vazia (todas as linhas de uma importação compartilham o mesmo código, mesma premissa de_codigo_empresa_da_importacaoemserializers.py) e o de operadora vem depipeline.OPERADORAS[importacao.operadora]["codigo_operadora"]; sem um dos dois (ex.: nenhuma linha com código de empresa preenchido), cai pro nome genérico de sempre com o id (importacao_plano_saude_<id>/importacao_plano_saude_de_paula_<id>), pra nunca gerar um nome vazio ou colidir entre duas importações.POST /{id}/reabrir/voltastatus="revisao"(zeraconcluida_em) — contrapartida degerar(), é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quandostatus === "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é umListField(child=FileField(), allow_empty=False)— o DRF já lê múltiplos arquivos do mesmo campo emmultipart/form-dataviarequest.data.getlist(...)(mesma semântica doQueryDict), sem tratamento manual extra na view.create()cria umImportacaoPlanoSaudeArquivoOperadorapor arquivo (ordem=índice);pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)chamaOperadoraParser.extrai()uma vez por caminho (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só opipeline.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 MESMOtipo_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 naLinhaSistemapor pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (valor_totalerubricas) 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()/osexceptdecreate()(arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam todos os arquivos deimportacao.arquivos_operadora.all()deMEDIA_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 endpointPOST /.../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 doItamedSaude).- 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). OperadoraParserganhouchave_casamento_para_tipo(tipo_lancamento)(default: devolvechave_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,UnimedSaudeusa"cpf"só pratipo_lancamento="coparticipacao"quando a origem foi esse PDF (rastreado numa flag de instância,self._veio_de_pdf_coparticipacao, setada emextrai()); mensalidade (sem CPF em nenhum dos dois formatos) continua em"nome".pipeline.processa_importacaochamachave_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_DEPENDENCIAnã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 nopessoa_atualanterior — 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 rodandoextrai()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 (ImportacaoPlanoSaudeAlteracaoids 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_REexigia 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_REperdeu 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 rodandoextrai()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. - Bug real corrigido (empresa Questor 1879, MARV Empreendimentos, competência 09/2026): a coluna "Mat/Orig" (entre o grau e o CPF) vem preenchida em algumas linhas (ex.: "AMANDA DESTROTITULAR 241 067.678.609-05"), e
_PESSOA_COPARTICIPACAO_REnão previa isso. A linha da pessoa não casava e, quando era a primeira da família, todos os itens dela eram descartados em silêncio, sem ir pra auditoria (AMANDA R$ 55,00 e JESSICA R$ 142,48 sumiram). O mesmo arquivo trouxe o código de serviço "ANE" (anestesia), não reconhecido (R$ 208,18 faltando na MARIA LUCIENE). Corrigido aceitando a coluna opcional e acrescentando "ANE" a_ITEM_COPARTICIPACAO_RE; validado contra o arquivo real: 6 beneficiários, R$ 2.102,94, batendo com "Total do Titulo" e com cada "Total da Familia" (antes: 4 beneficiários, R$ 1.697,28). Mensalidade do mesmo mês não foi afetada (R$ 5.438,89, 12 beneficiários, batendo com "Total por Contratante"). A importação id 97 (em revisão) foi criada antes da correção e precisa ser refeita.
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 emindicador-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é umserializers.DateField()normal (mesmo padrão deIndicadorApuracaoCreateSerializer.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)consultasqls.questor.QuestorSQL.consulta_planilha_plano_saude(só leitura —select_mappings_query, nuncaexecute/execute_returning, ver feedback_bancos_externos_somente_leitura) viaDatabaseConnection("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 deleiaute_sistema.CABECALHO) e salvo comoContentFileno próprio campoplanilha_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 essecodigo_empresa.pipeline.processa_importacaodeixou de ler o arquivo sozinho (não recebe maiscaminho_planilha_padrao: str) — recebelinhas_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 emviews.py create().- Código da operadora: até então só existia embutido no
labeldepipeline.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 (codigooutempno Questor, código da OPERADORA, não confundir comcodigo_empresado 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_operadorada 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 emImportacaoPlanoSaudeAuditoriaViewSet.resolver(views.py): o item precisa ter um motivo emMOTIVOS_RESOLVIVEISe ainda não estarresolvida(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 mesmotipo_lancamentodo item; (b) ser do mesmo "lado" — titular pra itemtipo="T", dependente pratipo!="T"(D/A) — comparandolinha.nome_dependente/cpf_dependentevazios 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
valordo item de auditoria é dividido emvalor_empresa/valorpela mesma regra de custeio já salva emImportacaoPlanoSaude.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_valoresdo fluxo automático (não existe uma segunda fórmula "manual"). Exceção: quando a importação temregra_empresaconfigurada (ver "Regra empresa" abaixo) e o item é detipo_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_funcigual) — recuperando o valor bruto de cada uma comovalor_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 emtotal_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ó quandoPID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)e!item.resolvida. O modal#ips-vincular-modallista candidatos sem nenhuma chamada de API nova — filtra em memória a partir deimportacaoAtual.linhas(já carregado na revisão) portipo_lancamentoigual, "lado" (titular/dependente) igual e ainda em branco (candidatosVincular()), com uma caixa de busca por nome (renderVincularLista(), mesmo componente.checklist-box/.checklist-searchde outras telas, aqui com<input type="radio">— seleção única, não múltipla). Confirmar chamapidResolverAuditoriaPlanoSaude()e refazpidFetchImportacaoPlanoSaudepra recarregarimportacaoAtual(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ção0051):operadora(chave depipeline.OPERADORAS),codigo_empresa(cru, sem normalizar — ver abaixo por quê),nome_arquivo_operadora(o nome divergente do arquivo da operadora, já normalizado viamatcher.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_togetherem(operadora, codigo_empresa, nome_arquivo_operadora). codigo_empresafica cru no model, normalizado só emviews.py: importarempresas_questor.normalizar_codigo_empresadentro demodels.pycriaria um import circular (empresas_questor.pyjá importaEmpresaQuestordemodels.py) — por isso a normalização acontece nos dois pontos de uso emviews.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_createumVinculoNomeOperadoracomnome_arquivo_operadora=normaliza_nome(item.nome)e o destino (linha.nome_funcseitem.tipo == "T", senãolinha.nome_dependente). Só grava selinha.codigo_empresanormalizado 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): recebevinculos_por_nome: Dict[str, VinculoNome](nome normalizado -> VinculoNome, dataclass "pura" sem ORM emplanos_saude/modelos.py) etipo_lancamento(só pra rotular oVinculoAplicadogerado, 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, checavinculos_por_nome.get(nome_normalizado)antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitandoregra_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 umVinculoAplicado(índice da linha dentro dotipo_lancamento, id do vínculo, nome do arquivo da operadora) — devolvido emResultadoProcessamento.vinculos_aplicados(pipeline.py) praviews.pymontar os registros deImportacaoPlanoSaudeAlteracaodepois que as linhas estiverem persistidas (no momento do casamento elas ainda não têmid). ImportacaoPlanoSaudeViewSet.create():_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)(views.py) busca todoVinculoNomeOperadorada operadora cujocodigo_empresanormalizado apareça em algumLinhaSistemada planilha padrão desta importação, monta o dict e passa emprocessa_importacao(vinculos_por_nome=...). Depois dobulk_createdas linhas, correlaciona cadaVinculoAplicado.indice_linha(índice dentro dotipo_lancamento, o mesmo usado comoordemna criação da linha) com aImportacaoPlanoSaudeLinhajá persistida e cria umImportacaoPlanoSaudeAlteracao(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) — praTIPO_VINCULO_AUTOMATICO, zeravalor/valor_empresada linha (reaplicando a regra empresa da família, se houver, mesma lógica de_recalcula_familia_regra_empresa) e apaga oVinculoNomeOperadora(SET_NULLem qualquer outraImportacaoPlanoSaudeAlteracaoque 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ção0038,vinculo_nomeadicionado na0051): um registro por operação, nunca apagado (mesmo espírito deresolvidaemImportacaoPlanoSaudeAuditoria— histórico completo).tipo(edicao/inclusao/exclusao/vinculo_automatico),linha(FKSET_NULL— ficanullquando 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 emedicao; emvinculo_automatico,valor_novoguarda 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(FKSET_NULL, só emvinculo_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_updatecomparaserializer.validated_datacontraserializer.instance(os valores antes do.save()) e grava umImportacaoPlanoSaudeAlteracaopor campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, porchangede 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 odados_linha.vinculo_automaticoé gravado emImportacaoPlanoSaudeViewSet.create()(ver "Vínculos de nome salvos (DE/PARA)" acima), não noLinhaViewSet. 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 selinhaainda existir (não excluída depois); gravavalor_anteriorde volta no campo.inclusao: só possível selinhaainda existir; deleta a linha diretamente (bypassaImportacaoPlanoSaudeLinhaViewSet.perform_destroy, então não cria um registroexclusaopra essa reversão).exclusao: sempre possível (a linha já está excluída por definição) — recria umaImportacaoPlanoSaudeLinhanova a partir do snapshot emdados_linha(+tipo_lancamentoguardado à parte) e apontaalteracao.linhapra ela.vinculo_automatico: zeravalor/valor_empresada linha vinculada (reaplicando a regra empresa da família, setipo_lancamento == "mensalidade"e a importação tiverregra_empresa) e apaga oVinculoNomeOperadoraassociado — 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, compidConfirm, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chamapidReverterAlteracaoPlanoSaude()e refazpidFetchImportacaoPlanoSaude()(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?}) — sempre200 {"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 dearquivo/tipoinválido/operadoraausente quandotipo="operadora"vira 400 de verdade)._valida_planilha_padrao()rodaleiaute_sistema.le_planilha_padrao();_valida_arquivo_operadora()rodaOPERADORAS[operadora_key]["parser"]().extrai()e também.finaliza()(verOperadoraParser.finaliza()acima) na mesma instância, somandoindividuos+individuos_finais+auditoria_finalpra decidir se algo foi encontrado — necessário porque uma operadora que devolve dado retido emfinaliza()(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 emfinaliza()sempre pareceria vazio (bug real, verCHANGELOG.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 nofinally; 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\tem vez de;) viravalido=Falsecom uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos/itens de auditoria extraídos (arquivo no formato certo mas vazio) também viravalido=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.xlsxcaía noelsee 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 emcreate(). 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. Oaccept=".csv,.pdf"do<input type="file">de "Arquivo(s) da operadora)" (#ips-form-arquivo) também precisou viraraccept=".csv,.pdf,.xlsx", senão o seletor de arquivo do navegador já filtra.xlsxpra fora antes do usuário conseguir escolher o arquivo. Nota pra quando adicionar outro formato: o fluxo real decreate()(ImportacaoPlanoSaudeViewSet.create) nunca teve esse bug — usaarquivo.arquivo.path(caminho real salvo peloFileFielddo Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão.
- Bug real (SulAmérica 5775, .xlsx):
- Frontend (
importacao-plano-saude.js):criarValidadorArquivo()é a fábrica reaproveitada pelos dois campos (validadorPlanilha/validadorArquivo) — nochangedo<input type="file">, chamapidValidarArquivoPlanoSaude()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 (formOperadorachange) 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á voltouvalido=False— mas isso é só uma segunda barreira de UX; ocreate()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 umaRegraCusteioPlanoSaudeé 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 mesmoonChangede 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 depipeline.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,hiddenalternado 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, viaunique_together) pra descobrir o mesmo problema. - Reabrir o modal nunca mostra o estado anterior por um instante:
abrirCadastroRegras()chamaregracadMostrarVazio()de forma síncrona, antes de qualquerawait(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).
- Indicador de modo (
- "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 depipeline.OPERADORAS, ex."5060 - Unimed Saúde"— são códigos diferentes),operadora(obrigatória agora, validada contrapipeline.OPERADORAS),regra_empresa_chave(ver "Regra empresa" abaixo),tipos_lancamento/custeio_por_tipo(mesmo formato dos campos homônimos deImportacaoPlanoSaude) eobservacoes.Meta.unique_together = [["codigo_empresa", "operadora"]]— validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia).nomedeixou de ser digitado pelo usuário — é sempre derivado emRegraCusteioPlanoSaudeSerializer.validate()como"<codigo_empresa> - <nome da operadora sem o código dela>"(camporead_only=Truena 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, migrations0033/0034/0035):0041adicionacodigo_empresa/regra_empresa_chave(blank) + tornaoperadoraobrigatória;0042(RunPython) faz o backfill decodigo_empresaa partir donomeexistente (nome.split(" - ", 1)[0].strip());0043tornacodigo_empresaobrigatório e adiciona ounique_together.Meta.orderingusa[Length("codigo_empresa"), "codigo_empresa", "operadora"](mesmo padrão deIndicadorApuracaoEmpresa) pra ordenar o código como número, não como string. - Validação reaproveitada, não duplicada:
RegraCusteioPlanoSaudeSerializer.validate()eImportacaoPlanoSaudeCreateSerializer.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 emMeta.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). Alimentaregra_custeio_salva_nome/regra_custeio_salva_observacoesna tela de Revisão, como antes.- Trava de conferência do código de empresa (
ImportacaoPlanoSaudeViewSet.create(), depois do processamento e antes dobulk_createdas linhas): seregra_custeio_salvaestá presente, confere que ao menos uma linha da planilha padrão processada temcodigo_empresaigual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outrosexceptdesse 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 comregra_empresa_chave(onde é redundante com a checagem queregras_empresa.valida_regra_empresa()já faz — proteção extra contra o registro emREGRAS_EMPRESAficar dessincronizado daRegraCusteioPlanoSaudecorrespondente).
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ção0044):codigo_empresa(único) +nome_empresa, um cache local simples — sem relação de FK comRegraCusteioPlanoSaude(é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmocodigo_empresacom operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha deEmpresaQuestor).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,DatabaseSettingsnormaliza as chaves deDATABASE__<NOME>__*do.envassim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executandosqls.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,codigoempresado Questor ésmallinte um código fora da faixa numérica levantaDataError, banco inacessível) é capturada e devolveNone— 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 (ocodigoempresado 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 decodigo_empresavinda de fora:resolve_nome_empresa(),RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa()(o que é de fato salvo emRegraCusteioPlanoSaude.codigo_empresa), a actionnome-empresa(devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência emImportacaoPlanoSaudeViewSet.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 aImportacaoPlanoSaudeLinha.codigo_empresaem 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 consultarEmpresaQuestora partir do código cru de uma linha).sqls/questor.py(pacote novo na raiz do projeto, ao lado dedatabase/— seguindo a convenção "uma pastasqls/por projeto consumidor, um arquivo por banco" já documentada na memória): classeQuestorSQL, hoje sóconsulta_nome_empresa(). Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendoSQLQuery(sql=dedent(...), params={...}); nunca usarexecute/execute_returningdesses 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.venvdo Portal só tempsycopg(v3) instalado, nãopsycopg2—ModuleNotFoundErrorao tentar conectar. Corrigido pra"postgresql+psycopg"(dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalarpsycopg2-binaryà parte. Sedatabase/for reaproveitado por outro projeto que dependa especificamente depsycopg2(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()chamaresolve_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 chamarresolve_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 noblur(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 ocodigo_empresanormalizado 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 arrayregrasjá carregado, que já vem comnome_empresaresolvido 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), por serem um critério fixo (não um percentual/teto configurável) e/ou por dependerem de uma distinção por PESSOA que o sistema não guarda (ex.: dependente vs. agregado — ver amil_898_tecnomyl/tipo_pessoa abaixo). 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()emimportacao-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 pelostipos_lancamentode CADA regra, não fixo no código do formulário. Um checkbox travado (.disabled) não disparachangepor 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 chamamaplicarTiposRegraEmpresa(). - No backend,
RegraCusteioPlanoSaudeSerializer.validate()/ImportacaoPlanoSaudeCreateSerializer.validate()calculamregra_empresa_tipos(interseção entreREGRAS_EMPRESA[chave]["tipos_lancamento"]e os tipos selecionados — erro claro se vier vazia), conferemchave_casamento_para_tipo(tipo) == REGRAS_EMPRESA[chave]["chave_casamento"]pra cada tipo coberto (não mais um "exige nome" hardcoded) e zeramcusteio_por_tipo[tipo]só pros tipos emregra_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_importacaopassaregra_empresa_fnpracasa_individuos_com_planilhasó quandotipo_lancamento in tipos_cobertos. matcher._casa_por_cpfpassou a suportarregra_empresa_fn(antes só_casa_por_nomesuportava) — 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 chamaregra_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, viatipos_lancamento).- Registro (
REGRAS_EMPRESA): cada entrada temlabel,codigos_empresa(tupla dos códigos de empresa na planilha padrão pra os quais a regra foi negociada),operadora,chave_casamento,tipos_lancamento,aplica(função que faz o cálculo) eobservacoes.- Por que uma tupla e não um código só (mudou numa rodada posterior, a pedido do usuário): uma mesma condição negociada pode valer pra várias empresas do MESMO grupo econômico. Caso real que motivou: ao cadastrar a regra de custeio da empresa 1855 (H2O Innovation, grupo Tecnomyl) o usuário levou um "Esta regra especial foi cadastrada para a empresa código 1778, não para 1855" — a trava funcionando como projetada, mas o desenho de 1 regra = 1 empresa não previa grupo. A alternativa seria duplicar a regra (uma entrada por empresa, mesmo cálculo) — pior, porque o dia que o teto mudar é preciso lembrar de mexer nas duas. Só incluir um código novo na tupla com confirmação de que as condições são idênticas: o cálculo não olha o código da empresa pra nada, então um código a mais aqui aplica silenciosamente o teto de um cliente na folha de outro. 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, incluindotipos_lancamento, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação").
- Por que uma tupla e não um código só (mudou numa rodada posterior, a pedido do usuário): uma mesma condição negociada pode valer pra várias empresas do MESMO grupo econômico. Caso real que motivou: ao cadastrar a regra de custeio da empresa 1855 (H2O Innovation, grupo Tecnomyl) o usuário levou um "Esta regra especial foi cadastrada para a empresa código 1778, não para 1855" — a trava funcionando como projetada, mas o desenho de 1 regra = 1 empresa não previa grupo. A alternativa seria duplicar a regra (uma entrada por empresa, mesmo cálculo) — pior, porque o dia que o teto mudar é preciso lembrar de mexer nas duas. Só incluir um código novo na tupla com confirmação de que as condições são idênticas: o cálculo não olha o código da empresa pra nada, então um código a mais aqui aplica silenciosamente o teto de um cliente na folha de outro. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (
- Resumo de custeio + observações, só-leitura na tela de Revisão (
#ips-review-regra-empresa-obs, entre o cabeçalho "Revisão" e as abas Mensalidade/Coparticipação/Auditoria/Alterações — pedido explícito do usuário, rodada 108, "assim o usuário tem certeza que está conferindo com base na regra do cliente"): mostra sempre "Tipos cobertos" + uma linha por tipo de lançamento (Mensalidade/Coparticipação— "Titular: .../Dependente: ..." ou "usa regra especial da empresa — ", mesmo texto derenderResumoRegra()de "Nova Importação", calculado à parte emrenderReviewResumoCusteio()/reviewResumoTextoTipo()porque a Revisão não temregrasEmpresaCachecarregado quando aberta direto do histórico — cobertura por regra especial é detectada porcusteio_por_tipo[tipo]vir vazio comregra_empresapreenchido, não por consulta a essa cache), seguido da observação quando existir uma.ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoesresolveREGRAS_EMPRESA[obj.regra_empresa]["observacoes"]a cada carregamento; o mesmo bloco cai praregra_custeio_salva_observacoesquando não há regra empresa. unimed_1778_tecnomyl— grupo Tecnomyl na Unimed (codigos_empresa=("1778", "1855", "1872", "1927")— 1778 Tecnomyl Brasil, 1855 H2O Innovation, 1872 GS3 Digital e 1927 YVY Agricultura Digital, mesmas condições confirmadas pelo usuário em 2026-09-22; a chave da regra manteve o nome antigo, que carrega só o 1778, pra não invalidar oregra_empresa_chavejá gravado nas regras de custeio e importações existentes) 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 recebendovalor_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 comvalor_empresa=559,57/valor=0(coberto integralmente), sobra661,61-559,57=102,04de teto pro titular, que sai comvalor_empresa=102,04/valor=212,08. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica comvalor_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 deLinhaSistema.eh_linha_titular()): roda tanto contraLinhaSistema(pipeline, na criação da importação) quanto contraImportacaoPlanoSaudeLinha(model Django, no recálculo pós "Vincular pessoa" — verviews._recalcula_familia_regra_empresae "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étodoeh_linha_titular().
- Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual (
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_ottimizzadecide por linha, olhando só_eh_linha_titular(linha)e otipo_lancamentorecebido),tipos_lancamento=("mensalidade", "coparticipacao")— as duas cobertas pela mesma função, que ramifica portipo_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 rodandopipeline.processa_importacaode 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).amil_898_tecnomyl— Amil Odonto (código 898) no grupo Tecnomyl (codigos_empresa=("1778", "1855", "1872", "1927"), os mesmos quatro da regra da Unimed acima — estendida pro grupo em 2026-09-22, logo depois dela, a pedido do usuário), repassada pelo cliente em 09/2026: a empresa custeia 100% da mensalidade de titular e dependente direto (cônjuge, filho(a)); agregados (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) têm a mensalidade 100% descontada do empregado, no MESMO valor por pessoa — a regra é só sobre QUEM paga, não sobre um teto/percentual, por isso cobre os dois planos do contrato (E200 a R$ 20,81/pessoa e "DENTAL 200" a R$ 16,01/pessoa) sem precisar de nenhum valor fixo no código.chave_casamento="cpf"(o parser já resolve cada indivíduo por CPF),tipos_lancamento=("mensalidade",)— Amil Odonto não traz coparticipação (ver docstring do parser).- Diferente das outras duas regras: não usa família nem
_eh_linha_titular— é a primeira regra de empresa que precisa diferenciar dependente de agregado por PESSOA, uma distinção que a planilha padrão do sistema não guarda (só sabe titular/não-titular). Por issoLinhaSistema/ImportacaoPlanoSaudeLinha/ImportacaoPlanoSaudeDePaulaLinhaganharamtipo_pessoa('T'/'D'/'A', ver "Modelos" acima) — gravado sempre (não só quando há uma regra de empresa ativa) pelomatcher.pyno momento do casamento, a partir doIndividuo.tipooriginal do arquivo da operadora (_casa_por_cpf:linha_destino.tipo_pessoa = ind.tipo;_casa_por_nome:linha_titular.tipo_pessoa = "T"/linha_dep.tipo_pessoa = m.tipo)._regra_amil_898_tecnomylsó lêgetattr(linha, "tipo_pessoa", "") or "D"de cada linha (fallback pra "D" — custeada pela empresa — quando em branco, ex.: linha incluída manualmente via "Adicionar linha", que nunca passa pelo casamento automático). - "Vincular pessoa" (resolução manual de auditoria) também grava
tipo_pessoa: como a linha resolvida nunca passou pelo casamento automático (senão não estaria em branco),ImportacaoPlanoSaudeAuditoriaViewSet.resolver/...DePaulaAuditoriaViewSet.resolvergravamlinha.tipo_pessoa = item.tipoantes de chamar_recalcula_familia_regra_empresa(_de_paula)— sem isso, uma linha vinculada manualmente cairia sempre no fallback "D", tratando um agregado vinculado à mão como se fosse dependente. - Nota operacional: dependentes sem CPF no arquivo real da Tecnomyl (comum em crianças) caem em auditoria "CPF não encontrado" se a planilha padrão do Questor tiver o CPF real dessa pessoa — e, ao contrário da estratégia "nome", a estratégia "cpf" não reaplica um vínculo salvo automaticamente numa competência futura (
_casa_por_cpfnão aceitavinculos_por_nome), então a mesma pessoa precisa ser vinculada de novo todo mês. Comportamento pré-existente de qualquer operadora "cpf" (Amil, SulAmérica, MetLife), não uma limitação introduzida por esta regra — ver nota em "Formato Excel (898, Tecnomyl)" acima. - Validado rodando
extrai()+ a regra + o pipeline completo (processa_importacao) contra o arquivo real da competência 08/2026 — ver detalhe em "Formato Excel (898, Tecnomyl)" acima.
- Diferente das outras duas regras: não usa família nem
- Trava de compatibilidade generalizada:
regras_empresa.valida_regra_empresa()recusa explicitamente (RegraEmpresaIncompativelError, capturada à parte emviews.pypra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre ostipos_lancamentoda regra, (b) a operadora escolhida não usa achave_casamentoque a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com nenhum doscodigos_empresaesperados 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). A mesma conferência de código existe no CADASTRO da regra (RegraCusteioPlanoSaudeSerializer.validate()/ImportacaoPlanoSaudeCreateSerializer.validate(),serializers.py): ao marcar "Regra especial da empresa", o código da empresa sendo cadastrada precisa estar entre oscodigos_empresada regra escolhida. - "Vincular pessoa" (resolução manual de auditoria) também generalizada:
_recalcula_familia_regra_empresa(views.py) filtra porlinha.tipo_lancamento(o tipo da própria linha resolvida), não mais fixo em"mensalidade", e passa esse tipo como segundo argumento praregra["aplica"];resolver()decide se aplica esse caminho checando seitem.tipo_lancamentoestá emREGRAS_EMPRESA[chave]["tipos_lancamento"], não mais comparando com a string"mensalidade"direto.
API (tabela completa)
| Endpoint | Método | Uso |
|---|---|---|
/api/importacoes-plano-saude/, /api/importacoes-plano-saude/{id}/ |
GET/POST | histórico + criação; PermissaoApp("utilitarios", "importacao-plano-saude") (toggle único) pra todos os métodos; POST é multipart/form-data (planilha padrão + arquivo da operadora) e roda o pipeline de forma síncrona antes de responder |
/api/importacoes-plano-saude/operadoras/ |
GET | [{key, label}] das operadoras registradas em planos_saude.pipeline.OPERADORAS — alimenta o <select> do formulário |
/api/importacoes-plano-saude/regras-empresa/ |
GET | [{key, label}] das regras especiais registradas em planos_saude.regras_empresa.REGRAS_EMPRESA — alimenta o modal "Selecionar regra" do checkbox "Regra empresa" |
/api/importacoes-plano-saude/{id}/gerar/ |
POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como concluida |
/api/importacoes-plano-saude-linhas/, /api/importacoes-plano-saude-linhas/{id}/ |
GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um ImportacaoPlanoSaudeAlteracao |
/api/importacoes-plano-saude-alteracoes/{id}/reverter/ |
POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão |
/api/regras-custeio-plano-saude/, /api/regras-custeio-plano-saude/{id}/ |
GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (codigo_empresa+operadora, únicos juntos+regra_empresa_chave+tipos_lancamento+custeio_por_tipo+observacoes; nome é sempre derivado, nunca aceito do cliente) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" |
Estes endpoints moravam na tabela de API do
CLAUDE.mdda raiz e foram trazidos para cá: endpoint de aplicação mora na doc da aplicação. A raiz mantém só os transversais (auth,/api/me/, catálogo, perfis, usuários, favoritos, widgets, compromissos, notificações).