From b1bfd90521446cd7b8067e41af21d5418f5a3fea Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 10:39:27 -0300 Subject: [PATCH] =?UTF-8?q?Inclus=C3=A3o=20das=20operadoras=20Unimed=20Cas?= =?UTF-8?q?cavel=20e=20Humana=20Saude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/importacao-plano-saude/SKILL.md | 104 ++++++ .../importacao-questor-plano-saude/SKILL.md | 100 +----- portal_api/planos_saude/CHANGELOG.md | 29 +- portal_api/planos_saude/CLAUDE.md | 16 +- portal_api/planos_saude/README.md | 4 +- portal_api/planos_saude/operadoras/base.py | 15 + .../operadoras/unimed_cascavel/__init__.py | 0 .../operadoras/unimed_cascavel/saude.py | 314 ++++++++++++++++++ portal_api/planos_saude/pipeline.py | 12 + portal_api/views.py | 20 +- templates/importacao-plano-saude.html | 2 +- 11 files changed, 516 insertions(+), 100 deletions(-) create mode 100644 .claude/skills/importacao-plano-saude/SKILL.md create mode 100644 portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py create mode 100644 portal_api/planos_saude/operadoras/unimed_cascavel/saude.py diff --git a/.claude/skills/importacao-plano-saude/SKILL.md b/.claude/skills/importacao-plano-saude/SKILL.md new file mode 100644 index 0000000..b24e1dd --- /dev/null +++ b/.claude/skills/importacao-plano-saude/SKILL.md @@ -0,0 +1,104 @@ +--- +name: importacao-plano-saude +description: Guia de manutenção/extensão da etapa de leitura e validação da ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/, tela importacao-plano-saude.html) — extração dos relatórios de operadora (11 parsers já implementados), casamento contra a planilha padrão e regras de negócio (regra de custeio, "Regra empresa", vínculos de nome), independente do sistema contábil de destino. Documenta quais operadoras já estão validadas com dado real, os gaps conhecidos por operadora, e como adicionar uma operadora nova. Direciona pra uma skill específica de sistema contábil (Questor hoje; Contabit no futuro) pra tudo que for leiaute/geração de arquivo final. Usar ao dar manutenção nos parsers, ao investigar uma divergência de valores, ou ao decidir se uma operadora nova pode ser cadastrada com segurança. +--- + +# Importação de Plano de Saúde: leitura, extração e regras de negócio + +## 0. O que este documento é (e o que não é) + +Este SKILL.md documenta a etapa **comum a qualquer sistema contábil de destino**: ler o relatório de faturamento de uma operadora de plano de saúde/odontológico, extrair beneficiários e valores, e aplicar as regras de negócio (custeio, "Regra empresa", nome divergente) que decidem quanto cada lançamento deve valer — tudo isso antes de qualquer formatação específica de sistema. É o complemento "por quê"/"cuidado com X" da documentação técnica, já exaustivamente descrita em `CLAUDE.md` (seção "Importação de Plano de Saúde (Utilitários)"). Antes de tocar em qualquer parser ou regra de custeio, ler os dois: `CLAUDE.md` pra arquitetura (models, endpoints, formato de `custeio_por_tipo`, "Regra empresa", "Vínculos de nome salvos"), este arquivo pra contexto de negócio e pra lista de gaps ainda não confirmados como equivalentes ao processo manual que a ferramenta substitui. + +**Qual sistema contábil é o destino desta importação?** A ferramenta hoje só gera saída pro **Questor** — se for esse o caso (ou se não tiver sido dito o contrário), ler também a skill `importacao-questor-plano-saude` antes de tocar em Cadastro de Regras, planilha padrão, leiaute ou geração do CSV/ZIP final — isso é escopo dela, não deste arquivo. Se o destino for o **Contabit** (formato alternativo de rubricas 338/200/201), avisar o usuário que essa etapa ainda não foi implementada, nem a skill correspondente existe ainda — não inventar um leiaute Contabit sem confirmação explícita (ver gap 4 na seção 3). + +## 1. Operadoras já parametrizadas hoje (fotografia em 25/08/2026) + +A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamento especial no código de extração — toda operadora passa pelo mesmo `OperadoraParser`/`pipeline.processa_importacao` genérico. **É uma fotografia, não um fato permanente**: cresce todo mês, reconsultar `ImportacaoPlanoSaude` (`python manage.py shell`) antes de confiar nela pra uma decisão importante. + +### 1.1 Os 11 parsers de operadora existentes: uso real até agora + +| Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? | +|---|---|---|---| +| `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) | +| `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) | +| `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão". **Tem 2 layouts de relatório**: um com `[Nº Cartão]` entre colchetes e indentação distinguindo titular/dependente (validado empresa 1084), outro sem colchete (Nº Cartão solto) e mesma indentação para titular/dependente, distinguido pela presença de "Total Fam" (validado empresa 503, "TAROBA CONSTRUCOES LTDA", 27/08/2026) — ver `operadoras/dental_uni/odonto_mensalidade.py` | +| `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) | +| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) | +| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** | +| `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) | +| `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) | +| `amil_odonto_mensalidade` (898) | CPF | 0 até 26/08/2026 | Nunca foi rodada dentro da ferramenta até então — ver nota abaixo, primeiro teste real achou e corrigiu um bug de parsing | +| `humana_saude` (5064) | nome | 0 até 27/08/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, construído via o checklist da seção 4.1 direto no primeiro teste com o arquivo-modelo (empresa 1972). **Coparticipação nunca casa automaticamente** (decisão do usuário): sem CPF nem matrícula confiável na tabela "DESPESAS COBRADAS" (nome truncado por largura de coluna), cada evento vira direto um item de auditoria — ver `operadoras/humana/saude.py` | +| `unimed_cascavel_saude` (158) | nome | 0 até 27/08/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, mesma empresa-modelo da Humana (1972). Outra Unimed regional, layout de PDF sem nenhuma sobreposição com `unimed_saude` (5060). **Coparticipação pode vir de duas fontes possíveis pro mesmo mês** (tabela embutida no relatório de mensalidade OU extrato separado — "o modelo de arquivo é gerado pela operadora"), nunca somadas: `OperadoraParser.finaliza()` (hook novo, chamado só depois de ver todos os arquivos da importação) resolve qual usar, preferindo o extrato separado — ver `operadoras/unimed_cascavel/saude.py` | + +**Ressalva sobre a Bradesco Dental (3759):** o `CLAUDE.md` registra que este parser foi escrito só a partir de texto colado numa conversa, nunca confirmado contra o arquivo real. O banco, porém, já tem uma importação **concluída** pra esse operador (empresa 1684), ou seja, alguém rodou um arquivo real depois daquela ressalva ser escrita. "Concluída" só significa que o pipeline processou sem erro e o CSV foi gerado, **não** que os valores foram de fato conferidos linha a linha contra a fatura. Antes de remover a ressalva do `CLAUDE.md`, confirmar com o usuário se essa conferência manual aconteceu. + +**AMIL: primeiro teste real (26/08/2026) achou um bug de parsing, já corrigido.** O parser nunca tinha sido rodado contra um arquivo de verdade — no primeiro teste em produção (empresa 1751, contrato 2831804000), todo arquivo AMIL dava "Nenhum beneficiário foi encontrado" porque o regex exigia espaço entre a coluna do plano e a coluna "Tp.", mas nesse relatório real as duas vêm coladas sem espaço nenhum. Corrigido (ver `portal_api/planos_saude/CLAUDE.md`, seção "Amil Odonto (898)") e validado rodando `extrai()` de ponta a ponta: 161 beneficiários, R$ 1.630,93, batendo com os totais do próprio relatório. Continua valendo o cuidado geral: essa foi a primeira empresa/arquivo real confirmado, então tratar qualquer resultado da AMIL como "conferir contra a fatura" até mais empresas passarem pela ferramenta. + +**Nota sobre o `codigo_operadora`**: hoje esse código vem sempre do cadastro de operadora do **Questor** (única integração existente — usado tanto pro label de exibição quanto pra filtrar a consulta SQL da planilha padrão, ver skill `importacao-questor-plano-saude`). Se o Contabit vier a ter um cadastro de operadora próprio e divergente, isso pode precisar de um campo adicional por sistema — a confirmar quando essa frente for aberta, não assumir que o mesmo código serve pros dois. + +## 2. Regras de negócio que decidem o valor de cada lançamento + +A ferramenta é genérica multi-empresa/multi-operadora. O pipeline, os parsers por operadora e as regras gerais (nunca aproximar nome automaticamente, valor negativo nunca vai pro lançamento final, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) estão descritos em detalhe no `CLAUDE.md`, não repetir aqui. + +Mecanismos que substituem uma decisão manual repetida por uma configuração reaproveitável: + +- **Regra de custeio configurável por empresa+operadora**: quem paga o quê (empresa/empregado/regra específica) é decidido uma vez e reaproveitado todo mês, em vez de decidir de novo a cada competência. Hoje isso é feito via "Cadastro de Regras" (`RegraCusteioPlanoSaude`) — documentado na skill `importacao-questor-plano-saude`, porque nasce ligado ao cadastro de empresa/operadora do Questor (`codigo_empresa`/`codigo_operadora` resolvidos contra o banco de lá). +- **"Regra empresa"** (`portal_api/planos_saude/regras_empresa.py`): cobre custeios negociados que não cabem no desenho padrão "por tipo de lançamento × titular/dependente" — calculados por família inteira ou por critério fixo. Dois exemplos já cadastrados: um teto de R$ 661,61/família na Unimed (`unimed_1778_tecnomyl`, prioridade dependente primeiro e titular absorve o residual) e um critério fixo na Ottimizza/SulAmérica (`sulamerica_5775_ottimizza`). Ver `CLAUDE.md` pra lista completa e como cadastrar uma regra nova. Independente do sistema de destino — a regra decide o valor, não o formato de saída. +- **Vínculos de nome salvos (DE/PARA)** (`VinculoNomeOperadora`): confirmar manualmente uma vez ("Vincular pessoa") que dois nomes divergentes são a mesma pessoa, e o sistema reaplica sozinho nas competências seguintes — nunca aproximação automática, sempre confirmação humana explícita uma vez por divergência. + +## 3. Gaps conhecidos do pipeline genérico (por operadora) + +As regras abaixo foram identificadas comparando o pipeline genérico com o processo manual mais detalhado já visto até hoje (ver seção 5) — são gaps de **operadora/leiaute de custeio**, não peculiaridade de uma empresa só: qualquer empresa que negocie condição parecida com AMIL ou Bradesco pode ser afetada. + +1. **AMIL, tipo "A" (agregado) vs. "D" (dependente direto):** o parser genérico (`operadoras/amil/odonto_mensalidade.py`) extrai o tipo (`T`/`D`/`A`), mas o resto do pipeline trata `D` e `A` como o mesmo "dependente" pra efeito de custeio (`matcher._regra_para_pessoa`, ver `CLAUDE.md`). Em pelo menos um contrato real já visto, a regra negociada exige que **todo tipo A seja 100% descontado do empregado, independente da regra configurada pra dependente**. Cadastrar uma empresa com beneficiários tipo A na AMIL sem confirmar essa condição arrisca custear um agregado pela empresa por engano. +2. **Bradesco, dependente sem linha na planilha padrão:** já apareceu um caso em que a regra negociada era acumular o valor desse dependente na linha do **titular** (não é regra geral do leiaute, é específica daquele contrato). O `matcher.py` genérico, na ausência dessa regra, trata esse caso como "sem cadastro", indo pra auditoria — uma saída **segura** (não lança valor errado, só some do lançamento final até alguém resolver), mas pode não ser o resultado esperado pelo cliente. +3. **Sistema Contabit** (formato alternativo de rubricas 338/200/201, usado por processos manuais antigos): fora do escopo da ferramenta hoje, que só sabe estruturar pro Questor (skill `importacao-questor-plano-saude`). Quando essa frente for aberta, provavelmente vai exigir um `LinhaSistema`/casamento próprios também — o formato de "planilha padrão" contra o qual tudo casa hoje já é moldado no leiaute do Questor (`NOMEFUNC`/`CPFFUNC`/`CODIGOOUTEMP`...), não é só a geração do arquivo final que muda entre sistemas. + +Nenhum desses é motivo pra não usar a ferramenta — são pontos que quem for cadastrar uma empresa com essas operadoras/condições precisa confirmar antes (ou verificar que não se aplicam, ex.: se a empresa não tiver nenhum beneficiário tipo A). Até confirmar, mais seguro conferir manualmente o resultado da tela pra essas condições antes de considerar definitivo. + +## 4. Como adicionar/ajustar um parser de operadora + +Ver `CLAUDE.md` (tabela de operadoras registradas, `OperadoraParser.extrai()`, `pipeline.OPERADORAS`) para o mecanismo técnico. + +### 4.1 Checklist — se a pessoa só anexar o arquivo-modelo + +Objetivo: perguntar tudo que falta **antes** de escrever código, pra não precisar ajustar o parser várias vezes por falta de informação de negócio (que não tem como vir do arquivo sozinho). Nem toda pergunta se beneficia do mesmo momento — perguntar o que o arquivo nunca vai responder **antes** de abrir qualquer coisa; deixar a inspeção responder sozinha o que é auto-determinável; e só formular a pergunta de negócio mais específica **depois** de ver a estrutura real (evita uma pergunta genérica demais, tipo "existe regra especial?" em vez de "como tratar a categoria X que apareceu na tabela?"). + +**1. Perguntar antes de abrir o arquivo** (nunca vem do conteúdo, então inspecionar primeiro só atrasa): + +1. Nome comercial da operadora + o código de cadastro dela no sistema de destino (`codigo_operadora`) — sem isso não dá pra registrar em `pipeline.OPERADORAS` nem montar o label de exibição. Hoje o destino é sempre o Questor (ver nota na seção 1.1); confirmado em duas operadoras diferentes (Dental Uni, Humana) que esse código nunca aparece no arquivo em si, então não há razão pra esperar a inspeção pra perguntar. +2. Qual empresa/código de cliente esse arquivo representa — usado na trava de conferência que já existe (`ImportacaoPlanoSaudeViewSet.create()`). Mesmo quando o nome da pasta/arquivo já sugere um código (ex.: "503 - Dental Uni", "1972 - Humana"), **confirmar em vez de assumir** — é um palpite vindo do nome do arquivo, não do conteúdo. + +**2. Extrair e inspecionar** (sempre com o arquivo real — nunca a partir de texto colado numa conversa, ver [[feedback_pdf_parser_precisa_arquivo_real]]) — isto é autodeterminado, não precisa perguntar: + +3. Abrir com a lib certa pro formato — `pdfplumber` pra PDF com texto selecionável; se `page.chars`/`extract_text()` vier vazio, é OCR (`docling`), não `pdfplumber` (ver nota em `operadoras/bradesco/saude.py`); `openpyxl` pra `.xlsx`; `csv` com fallback de encoding pra `.csv` acentuado. Rodar de fato contra o arquivo, não confiar em inspeção visual. **Cuidado com espaçamento entre palavras**: nem sempre `extract_text()`/`extract_text(layout=True)` preservam os espaços reais do PDF (já visto na Unimed Cascavel, onde só `extract_words(x_tolerance=1)` resolveu) — se as linhas saírem com palavras coladas, inspecionar `page.chars` direto pra confirmar o espaçamento real antes de desenhar qualquer regex. +4. Olhar o texto/linhas extraídas com `repr()`, não a versão "bonita" — indentação, colchetes, colunas coladas sem espaço e quebra de nome em duas linhas só aparecem assim. A partir do texto real, identificar: **se o arquivo traz CPF de cada beneficiário ou só nome** (decide `chave_casamento` — CPF é sempre preferível quando existe; isso se responde lendo o arquivo, nunca perguntando à pessoa); como titular e dependente se distinguem (rótulo? indentação? um campo "total família" só preenchido num dos dois? uma coluna de tipo já em texto explícito?); formato do valor monetário (vírgula BR ou ponto americano); se algum nome quebra em mais de uma linha física ou é truncado por largura de coluna. + +**3. Confirmar depois de ver a estrutura real** (a inspeção já deu contexto suficiente pra fazer a pergunta certa, não uma genérica): + +5. Quais tipos de lançamento vêm nesse arquivo — a inspeção já mostra quais tabelas existem (só mensalidade? só coparticipação? os dois juntos no mesmo arquivo, como a Humana? ou em arquivos separados por tipo, ver "Múltiplos arquivos de operadora" no `CLAUDE.md`) — a pergunta que falta responder é se essa composição **é sempre assim** todo mês, ou se pode variar (ex.: Unimed Cascavel, onde a coparticipação pode vir embutida OU separada dependendo do mês). +6. Existe alguma regra de custeio negociada com o cliente além do padrão "empresa/empregado por tipo × titular/dependente" (teto por família, percentual fixo, um tipo sempre 100% descontado)? Usar o que a inspeção revelou pra perguntar de forma específica — ex.: se apareceu uma categoria "Agregado" na totalização (como no arquivo da Humana), perguntar diretamente como ela deve ser tratada, em vez de só "existe regra especial?" genérico. Se houver regra negociada, pedir **um exemplo numérico já calculado à mão** (ex.: "família com mensalidade R$X, empresa cobre R$Y, empregado paga R$Z") — é contra esse exemplo que a implementação é validada no passo 8 abaixo, não só "parece certo". + +**Escrever e validar:** + +7. Implementar `OperadoraParser.extrai()` em `operadoras//.py` e registrar em `pipeline.OPERADORAS` com o `codigo_operadora` do passo 1. +8. Rodar `extrai()` de ponta a ponta contra o arquivo real e comparar total em R$ + contagem de beneficiários com os totais que o próprio relatório já imprime (nunca só "não deu erro" ou "contagem de linhas parece certa"). +9. Testar pela tela (`validar-arquivo`) e, se possível, uma importação completa contra a planilha padrão real da empresa antes de considerar pronto (planilha padrão hoje é sempre a do Questor — ver skill `importacao-questor-plano-saude`). + +### 4.2 Regras de negócio que nunca devem ser reinterpretadas + +Válidas pra qualquer operadora nova, sem exceção — e independentes do sistema contábil de destino: + +- Nome divergente nunca é resolvido por aproximação automática (fuzzy match). Sempre confirmação humana explícita, mesmo que pareça óbvio. +- Nenhum valor negativo entra no lançamento final, vira auditoria, nunca é zerado/truncado silenciosamente. +- Um mesmo beneficiário pode gerar várias linhas/rubricas no arquivo da operadora (mensalidade mais retroativo, por exemplo). Sempre somar por indivíduo antes de decidir o valor final, nunca tratar cada linha isoladamente. + +## 5. Histórico (contexto, não necessário no dia a dia) + +A ferramenta nasceu documentando o processo manual mensal de uma empresa específica — TECNOMYL (código Questor `1778`, operadoras AMIL/Unimed/Bradesco) —, feito à mão com scripts Python ad-hoc (protótipo em `projects/project/`) seguindo um runbook vivo por competência. Hoje TECNOMYL é só mais uma empresa entre as cadastradas em "Cadastro de Regras" (skill `importacao-questor-plano-saude`), sem tratamento especial no código — esse histórico só importa pra quem for: + +- **Entender a origem das regras genéricas do pipeline** (nome nunca resolvido por aproximação, valor negativo sempre vira auditoria, somar por indivíduo): vieram de decisões validadas com esse cliente no processo manual, documentadas em detalhe no runbook dele. +- **Cadastrar TECNOMYL de vez na ferramenta**: ela ainda não tem `RegraCusteioPlanoSaude` cadastrada hoje. Existe um único registro histórico de importação (Unimed, `regra_empresa=unimed_1778_tecnomyl` já setado) parado em "revisão", de antes do "Cadastro de Regras" existir como tela separada. Antes de cadastrar, ver os gaps da seção 3 (a maioria foi identificada a partir do processo manual dela) e o aviso abaixo sobre vínculos de nome. +- **Vínculos de nome**: as cerca de 30 equivalências de nome divergente já conhecidas do runbook da TECNOMYL (nome na operadora ≠ nome no Questor) não foram pré-carregadas como `VinculoNomeOperadora` — a primeira competência dela rodada pela tela vai gerar auditoria pra cada uma de novo, até serem confirmadas uma vez cada (ver seção 2). +- **Localizar o runbook**: fora deste repositório, só na máquina de quem processa hoje — `Plano de Saúde - TM\MM-AAAA\` (por competência: `Memoria_Importacao_Questor.md`, CSVs gerados, `Faltantes_Questor_MM_AAAA.xlsx`, `Auditoria_Importacao_Questor_MM_AAAA.xlsx`). **Limitação real**: nada neste SKILL.md substitui esse runbook, só resume o que ele documenta de mais estável. Se for necessário que outra pessoa continue sem acesso a essa máquina, os arquivos precisam ser trazidos pra algum lugar compartilhado (git ou outro) — decisão que envolve dados reais de funcionários, não fazer sem o usuário confirmar onde/como. diff --git a/.claude/skills/importacao-questor-plano-saude/SKILL.md b/.claude/skills/importacao-questor-plano-saude/SKILL.md index 7717483..04b0b74 100644 --- a/.claude/skills/importacao-questor-plano-saude/SKILL.md +++ b/.claude/skills/importacao-questor-plano-saude/SKILL.md @@ -1,38 +1,17 @@ --- name: importacao-questor-plano-saude -description: Guia de manutenção/extensão da ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/, tela importacao-plano-saude.html) — atende várias empresas/operadoras reais, com 10 parsers de operadora já implementados. Documenta quais operadoras já estão validadas com dado real (e quais gaps conhecidos existem por operadora), como consultar ao vivo quais empresas têm regra de custeio cadastrada, e como adicionar uma operadora nova. Usar ao dar manutenção nos parsers, ao investigar uma divergência de valores numa importação, ou ao decidir se uma empresa/operadora nova pode ser cadastrada com segurança. +description: Guia da etapa final específica do Questor na ferramenta "Importação de Plano de Saúde" do Portal De Paula (portal_api/planos_saude/) — como os dados já extraídos/validados (ver skill geral `importacao-plano-saude` primeiro) viram um Cadastro de Regras de custeio por empresa+operadora, a planilha padrão via SQL do Questor, e o CSV/ZIP final no leiaute do Questor. Documenta também como auditar manualmente uma importação já concluída contra o relatório de lançamentos do próprio Questor. Usar depois de já saber que o sistema contábil de destino é o Questor — pra extração do arquivo da operadora e regras de negócio genéricas (custeio, nome divergente), ver a skill geral primeiro. --- -# Importação de Plano de Saúde: manutenção e estado atual +# Importação de Plano de Saúde → Questor: estruturação e leiaute final ## 0. O que este documento é (e o que não é) -Este SKILL.md documenta **o processo de negócio** por trás da ferramenta. É o complemento "por quê"/"cuidado com X" da documentação técnica, que já está exaustivamente descrita em `CLAUDE.md` (seção "Importação de Plano de Saúde (Utilitários)"). Antes de tocar em qualquer parser ou regra de custeio, ler os dois: `CLAUDE.md` pra arquitetura (models, endpoints, formato de `custeio_por_tipo`, "Regra empresa", "Vínculos de nome salvos"), este arquivo pra contexto de negócio e pra lista de gaps ainda não confirmados como equivalentes ao processo manual que a ferramenta substitui. +Este SKILL.md documenta a etapa **específica do Questor**: como a informação já extraída do arquivo da operadora e já com as regras de negócio de custeio aplicadas (ver skill `importacao-plano-saude` — leitura e regras de negócio genéricas, sempre o ponto de partida) vira, de fato, um lançamento no leiaute de importação do Questor. Complementa `CLAUDE.md` (models, endpoints, formato de `custeio_por_tipo`, seção "Planilha padrão via Questor (SQL)") com o "por quê"/"cuidado com X" desta etapa. -## 1. Operadoras e empresas já parametrizadas hoje (fotografia do banco em 25/08/2026) +**Hoje o Questor é o único sistema contábil de destino implementado** — se em algum momento surgir uma importação com destino Contabit, não usar este guia pra estruturar a saída; ele ainda não foi implementado (ver gap 3 na skill geral). -A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamento especial no código — toda empresa passa pelo mesmo pipeline genérico, configurada via "Cadastro de Regras" (seção 1.2). Os números abaixo vieram de consultar o banco de produção direto (`RegraCusteioPlanoSaude`/`ImportacaoPlanoSaude`) na rodada em que este documento foi escrito. **É uma fotografia, não um fato permanente**: cresce todo mês, reconsultar antes de confiar nela pra uma decisão importante (`python manage.py shell`, os dois models citados). - -### 1.1 Os 10 parsers de operadora existentes: uso real até agora - -| Operadora (`pipeline.OPERADORAS`) | Casamento | Importações no banco | Alguma concluída? | -|---|---|---|---| -| `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) | -| `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) | -| `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão". **Tem 2 layouts de relatório**: um com `[Nº Cartão]` entre colchetes e indentação distinguindo titular/dependente (validado empresa 1084), outro sem colchete (Nº Cartão solto) e mesma indentação para titular/dependente, distinguido pela presença de "Total Fam" (validado empresa 503, "TAROBA CONSTRUCOES LTDA", 27/08/2026) — ver `operadoras/dental_uni/odonto_mensalidade.py` | -| `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) | -| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) | -| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** | -| `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) | -| `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) | -| `amil_odonto_mensalidade` (898) | CPF | 0 até 26/08/2026 | Nunca foi rodada dentro da ferramenta até então — ver nota abaixo, primeiro teste real achou e corrigiu um bug de parsing | -| `humana_saude` (5064) | nome | 0 até 27/08/2026 | Nunca foi rodada dentro da ferramenta até então — parser novo, construído via o checklist da seção 4.1 direto no primeiro teste com o arquivo-modelo (empresa 1972). **Coparticipação nunca casa automaticamente** (decisão do usuário): sem CPF nem matrícula confiável na tabela "DESPESAS COBRADAS" (nome truncado por largura de coluna), cada evento vira direto um item de auditoria — ver `operadoras/humana/saude.py` | - -**Ressalva sobre a Bradesco Dental (3759):** o `CLAUDE.md` registra que este parser foi escrito só a partir de texto colado numa conversa, nunca confirmado contra o arquivo real. O banco, porém, já tem uma importação **concluída** pra esse operador (empresa 1684), ou seja, alguém rodou um arquivo real depois daquela ressalva ser escrita. "Concluída" só significa que o pipeline processou sem erro e o CSV foi gerado, **não** que os valores foram de fato conferidos linha a linha contra a fatura. Antes de remover a ressalva do `CLAUDE.md`, confirmar com o usuário se essa conferência manual aconteceu. - -**AMIL: primeiro teste real (26/08/2026) achou um bug de parsing, já corrigido.** O parser nunca tinha sido rodado contra um arquivo de verdade — no primeiro teste em produção (empresa 1751, contrato 2831804000), todo arquivo AMIL dava "Nenhum beneficiário foi encontrado" porque o regex exigia espaço entre a coluna do plano e a coluna "Tp.", mas nesse relatório real as duas vêm coladas sem espaço nenhum. Corrigido (ver `portal_api/planos_saude/CLAUDE.md`, seção "Amil Odonto (898)") e validado rodando `extrai()` de ponta a ponta: 161 beneficiários, R$ 1.630,93, batendo com os totais do próprio relatório. Continua valendo o cuidado geral: essa foi a primeira empresa/arquivo real confirmado, então tratar qualquer resultado da AMIL como "conferir contra a fatura" até mais empresas passarem pela ferramenta. - -### 1.2 Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui) +## 1. Empresas com "Cadastro de Regras" salvo (consultar ao vivo, não uma lista fixa aqui) Essa lista é dado puro do banco (`codigo_empresa`/`razao social`/`operadora` de `RegraCusteioPlanoSaude`), sem nenhuma análise em cima — mantê-la fixa aqui só garante que fique desatualizada a cada empresa nova cadastrada pela tela "Cadastro de Regras". Consultar direto quando precisar: @@ -42,65 +21,17 @@ RegraCusteioPlanoSaude.objects.order_by("codigo_empresa").values_list("codigo_em **Único ponto que não é só dado de banco**: a empresa **221** (ROSSONI, PIOTTO & CIA LTDA) tem as 3 operadoras (Bradesco Saúde, Unimed e Itamed) com importação concluída — é a melhor empresa de referência hoje pra testar qualquer mudança no pipeline, exatamente por cobrir três parsers diferentes já validados. -## 2. O que a ferramenta cobre +## 2. Cadastro de Regras — por que vive aqui, não na skill geral -A ferramenta é genérica multi-empresa/multi-operadora — qualquer empresa cadastrada em "Cadastro de Regras" (seção 1.2) passa pelo mesmo pipeline. O pipeline, os parsers por operadora, o formato de custeio configurável e as regras gerais (nunca aproximar nome automaticamente, valor negativo nunca vai pro CSV final, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) estão descritos em detalhe no `CLAUDE.md`, não repetir aqui. +**"Cadastro de Regras" por empresa+operadora** (`RegraCusteioPlanoSaude`): decide "quem paga o quê" (empresa/empregado/regra específica) uma vez, reaproveitado todo mês em vez de decidido de novo a cada competência — mecanismo descrito em detalhe no `CLAUDE.md`. A decisão de custeio em si (quem paga quanto) é uma regra de negócio genérica, mas o **cadastro** dela nasce amarrado ao Questor de fato: `codigo_empresa` e `codigo_operadora` são resolvidos contra o banco de lá (`portal_api/empresas_questor.py`, `resolve_nome_empresa()`, ver "Nome da empresa (Questor)" no `CLAUDE.md`), e é essa combinação que restringe quais operadoras aparecem disponíveis pra uma empresa em "Nova Importação". Por isso o cadastro em si — ao contrário de "Regra empresa"/"Vínculos de nome" (skill geral), que não dependem de nenhum cadastro externo — fica documentado aqui. -Mecanismos que substituem uma decisão manual repetida por uma configuração reaproveitável: +Nenhuma empresa aparece no combobox de "Nova Importação" sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras" (tela separada da execução, ver `CLAUDE.md`). -- **"Cadastro de Regras" por empresa+operadora** (`RegraCusteioPlanoSaude`): decidir "quem paga o quê" uma vez, reaproveitar todo mês, em vez de decidir de novo a cada competência. -- **"Regra empresa"** (`portal_api/planos_saude/regras_empresa.py`): cobre custeios negociados que não cabem no desenho padrão "por tipo de lançamento × titular/dependente" — calculados por família inteira ou por critério fixo. Dois exemplos já cadastrados: um teto de R$ 661,61/família na Unimed (`unimed_1778_tecnomyl`, prioridade dependente primeiro e titular absorve o residual) e um critério fixo na Ottimizza/SulAmérica (`sulamerica_5775_ottimizza`). Ver `CLAUDE.md` pra lista completa e como cadastrar uma regra nova. -- **Vínculos de nome salvos (DE/PARA)** (`VinculoNomeOperadora`): confirmar manualmente uma vez ("Vincular pessoa") que dois nomes divergentes são a mesma pessoa, e o sistema reaplica sozinho nas competências seguintes — nunca aproximação automática, sempre confirmação humana explícita uma vez por divergência. +## 3. Gap conhecido: sem auditoria automática pós-importação -## 3. Gaps conhecidos do pipeline genérico (por operadora) +**Auditoria pós-importação contra o relatório do Questor** (`Plano de Saúde - Lançamentos - Competência MM/AAAA`): não existe essa feature na ferramenta. Ver seção 4 pra reproduzir esse método manualmente, se for pedido. -As regras abaixo foram identificadas comparando o pipeline genérico com o processo manual mais detalhado já visto até hoje (ver seção 6) — são gaps de **operadora/leiaute**, não peculiaridade de uma empresa só: qualquer empresa que negocie condição parecida com AMIL ou Bradesco pode ser afetada. - -1. **AMIL, tipo "A" (agregado) vs. "D" (dependente direto):** o parser genérico (`operadoras/amil/odonto_mensalidade.py`) extrai o tipo (`T`/`D`/`A`), mas o resto do pipeline trata `D` e `A` como o mesmo "dependente" pra efeito de custeio (`matcher._regra_para_pessoa`, ver `CLAUDE.md`). Em pelo menos um contrato real já visto, a regra negociada exige que **todo tipo A seja 100% descontado do empregado, independente da regra configurada pra dependente**. Cadastrar uma empresa com beneficiários tipo A na AMIL sem confirmar essa condição arrisca custear um agregado pela empresa por engano. -2. **Bradesco, dependente sem linha no modelo do Questor:** já apareceu um caso em que a regra negociada era acumular o valor desse dependente na linha do **titular** (não é regra geral do leiaute, é específica daquele contrato). O `matcher.py` genérico, na ausência dessa regra, trata esse caso como "sem cadastro", indo pra auditoria — uma saída **segura** (não lança valor errado, só some do CSV até alguém resolver), mas pode não ser o resultado esperado pelo cliente. -3. **Auditoria pós-importação contra o relatório do Questor** (`Plano de Saúde - Lançamentos - Competência MM/AAAA`): não existe essa feature na ferramenta. Ver seção 5 pra reproduzir esse método manualmente, se for pedido. -4. **Sistema Contabit** (formato alternativo de rubricas 338/200/201, usado por processos manuais antigos): fora do escopo da ferramenta, que só gera o leiaute do Questor. - -Nenhum desses é motivo pra não usar a ferramenta — são pontos que quem for cadastrar uma empresa com essas operadoras/condições precisa confirmar antes (ou verificar que não se aplicam, ex.: se a empresa não tiver nenhum beneficiário tipo A). Até confirmar, mais seguro conferir manualmente o resultado da tela pra essas condições antes de considerar definitivo. - -## 4. Como adicionar/ajustar um parser de operadora - -Ver `CLAUDE.md` (tabela de operadoras registradas, `OperadoraParser.extrai()`, `pipeline.OPERADORAS`) para o mecanismo técnico. - -### 4.1 Checklist — se a pessoa só anexar o arquivo-modelo - -Objetivo: perguntar tudo que falta **antes** de escrever código, pra não precisar ajustar o parser várias vezes por falta de informação de negócio (que não tem como vir do arquivo sozinho). Nem toda pergunta se beneficia do mesmo momento — perguntar o que o arquivo nunca vai responder **antes** de abrir qualquer coisa; deixar a inspeção responder sozinha o que é auto-determinável; e só formular a pergunta de negócio mais específica **depois** de ver a estrutura real (evita uma pergunta genérica demais, tipo "existe regra especial?" em vez de "como tratar a categoria X que apareceu na tabela?"). - -**1. Perguntar antes de abrir o arquivo** (nunca vem do conteúdo, então inspecionar primeiro só atrasa): - -1. Nome comercial da operadora + o código de cadastro dela no Questor (`codigo_operadora`) — sem isso não dá pra registrar em `pipeline.OPERADORAS` nem montar o label de exibição. Confirmado em duas operadoras diferentes (Dental Uni, Humana) que esse código nunca aparece no arquivo em si, então não há razão pra esperar a inspeção pra perguntar. -2. Qual empresa/código de cliente (Questor) esse arquivo representa — usado na trava de conferência que já existe (`ImportacaoPlanoSaudeViewSet.create()`). Mesmo quando o nome da pasta/arquivo já sugere um código (ex.: "503 - Dental Uni", "1972 - Humana"), **confirmar em vez de assumir** — é um palpite vindo do nome do arquivo, não do conteúdo. - -**2. Extrair e inspecionar** (sempre com o arquivo real — nunca a partir de texto colado numa conversa, ver [[feedback_pdf_parser_precisa_arquivo_real]]) — isto é autodeterminado, não precisa perguntar: - -3. Abrir com a lib certa pro formato — `pdfplumber` pra PDF com texto selecionável; se `page.chars`/`extract_text()` vier vazio, é OCR (`docling`), não `pdfplumber` (ver nota em `operadoras/bradesco/saude.py`); `openpyxl` pra `.xlsx`; `csv` com fallback de encoding pra `.csv` acentuado. Rodar de fato contra o arquivo, não confiar em inspeção visual. -4. Olhar o texto/linhas extraídas com `repr()`, não a versão "bonita" — indentação, colchetes, colunas coladas sem espaço e quebra de nome em duas linhas só aparecem assim. A partir do texto real, identificar: **se o arquivo traz CPF de cada beneficiário ou só nome** (decide `chave_casamento` — CPF é sempre preferível quando existe; isso se responde lendo o arquivo, nunca perguntando à pessoa); como titular e dependente se distinguem (rótulo? indentação? um campo "total família" só preenchido num dos dois? uma coluna de tipo já em texto explícito?); formato do valor monetário (vírgula BR ou ponto americano); se algum nome quebra em mais de uma linha física ou é truncado por largura de coluna. - -**3. Confirmar depois de ver a estrutura real** (a inspeção já deu contexto suficiente pra fazer a pergunta certa, não uma genérica): - -5. Quais tipos de lançamento vêm nesse arquivo — a inspeção já mostra quais tabelas existem (só mensalidade? só coparticipação? os dois juntos no mesmo arquivo, como a Humana? ou em arquivos separados por tipo, ver "Múltiplos arquivos de operadora" no `CLAUDE.md`) — a pergunta que falta responder é se essa composição **é sempre assim** todo mês, ou se pode variar. -6. Existe alguma regra de custeio negociada com o cliente além do padrão "empresa/empregado por tipo × titular/dependente" (teto por família, percentual fixo, um tipo sempre 100% descontado)? Usar o que a inspeção revelou pra perguntar de forma específica — ex.: se apareceu uma categoria "Agregado" na totalização (como no arquivo da Humana), perguntar diretamente como ela deve ser tratada, em vez de só "existe regra especial?" genérico. Se houver regra negociada, pedir **um exemplo numérico já calculado à mão** (ex.: "família com mensalidade R$X, empresa cobre R$Y, empregado paga R$Z") — é contra esse exemplo que a implementação é validada no passo 8 abaixo, não só "parece certo". - -**Escrever e validar:** - -7. Implementar `OperadoraParser.extrai()` em `operadoras//.py` e registrar em `pipeline.OPERADORAS` com o `codigo_operadora` do passo 1. -8. Rodar `extrai()` de ponta a ponta contra o arquivo real e comparar total em R$ + contagem de beneficiários com os totais que o próprio relatório já imprime (nunca só "não deu erro" ou "contagem de linhas parece certa"). -9. Testar pela tela (`validar-arquivo`) e, se possível, uma importação completa contra a planilha padrão real da empresa antes de considerar pronto. - -### 4.2 Regras de negócio que nunca devem ser reinterpretadas - -Válidas pra qualquer operadora nova, sem exceção: - -- Nome divergente nunca é resolvido por aproximação automática (fuzzy match). Sempre confirmação humana explícita, mesmo que pareça óbvio. -- Nenhum valor negativo entra no CSV final, vira auditoria, nunca é zerado/truncado silenciosamente. -- Um mesmo beneficiário pode gerar várias linhas/rubricas no arquivo da operadora (mensalidade mais retroativo, por exemplo). Sempre somar por indivíduo antes de decidir o valor final, nunca tratar cada linha isoladamente. - -## 5. Auditoria pós-importação contra o relatório do Questor (ainda manual) +## 4. Auditoria pós-importação contra o relatório do Questor (ainda manual) Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no Questor, e a pessoa tiver em mãos o PDF `Plano de Saúde - Lançamentos - Competência MM/AAAA` exportado do próprio sistema: @@ -110,12 +41,3 @@ Se for pedido pra conferir se o que foi gerado bateu com o que ficou lançado no 4. Pessoa com `VALOREMPRESA=0` e `VALOR=0` pode não gerar lançamento nenhum no sistema, não é divergência. 5. Confirmar que ninguém da lista de pendências/auditoria aparece lançado no sistema. 6. A linha `Total Empresa` no fim do PDF é o total geral de todas as operadoras, serve de conferência rápida contra a soma dos CSVs antes de entrar no detalhe pessoa a pessoa. - -## 6. Histórico (contexto, não necessário no dia a dia) - -A ferramenta nasceu documentando o processo manual mensal de uma empresa específica — TECNOMYL (código Questor `1778`, operadoras AMIL/Unimed/Bradesco) —, feito à mão com scripts Python ad-hoc (protótipo em `projects/project/`) seguindo um runbook vivo por competência. Hoje TECNOMYL é só mais uma empresa entre as cadastradas em "Cadastro de Regras" (seção 1.2), sem tratamento especial no código — esse histórico só importa pra quem for: - -- **Entender a origem das regras genéricas do pipeline** (nome nunca resolvido por aproximação, valor negativo sempre vira auditoria, somar por indivíduo): vieram de decisões validadas com esse cliente no processo manual, documentadas em detalhe no runbook dele. -- **Cadastrar TECNOMYL de vez na ferramenta**: ela ainda não tem `RegraCusteioPlanoSaude` cadastrada hoje. Existe um único registro histórico de importação (Unimed, `regra_empresa=unimed_1778_tecnomyl` já setado) parado em "revisão", de antes do "Cadastro de Regras" existir como tela separada. Antes de cadastrar, ver os gaps da seção 3 (a maioria foi identificada a partir do processo manual dela) e o aviso abaixo sobre vínculos de nome. -- **Vínculos de nome**: as cerca de 30 equivalências de nome divergente já conhecidas do runbook da TECNOMYL (nome na operadora ≠ nome no Questor) não foram pré-carregadas como `VinculoNomeOperadora` — a primeira competência dela rodada pela tela vai gerar auditoria pra cada uma de novo, até serem confirmadas uma vez cada (ver seção 2). -- **Localizar o runbook**: fora deste repositório, só na máquina de quem processa hoje — `Plano de Saúde - TM\MM-AAAA\` (por competência: `Memoria_Importacao_Questor.md`, CSVs gerados, `Faltantes_Questor_MM_AAAA.xlsx`, `Auditoria_Importacao_Questor_MM_AAAA.xlsx`). **Limitação real**: nada neste SKILL.md substitui esse runbook, só resume o que ele documenta de mais estável. Se for necessário que outra pessoa continue sem acesso a essa máquina, os arquivos precisam ser trazidos pra algum lugar compartilhado (git ou outro) — decisão que envolve dados reais de funcionários, não fazer sem o usuário confirmar onde/como. diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index e0f6d7a..b92054c 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -257,8 +257,6 @@ Usuário reportou (empresa 503, "TAROBA CONSTRUCOES LTDA", dois arquivos "503"/" Corrigido acrescentando um segundo regex de linha (`_LINHA_SEM_COLCHETE_RE`, tentado só quando o formato com colchete não bate) em `operadoras/dental_uni/odonto_mensalidade.py`: como a indentação não ajuda nesse layout, titular/dependente passou a ser decidido pela presença da coluna "Total Fam" (só preenchida na linha do titular, mesma regra de negócio já documentada desde a Rodada 49, só que agora usada como sinal em vez de só ser ignorada) — 3 valores monetários na linha = titular, 2 = dependente. O valor do beneficiário é sempre o 2º valor monetário (a coluna "Tx Inc." vem sempre impressa, mesmo "0,00", antes de "Valor Unit"). Validado rodando `extrai()` de ponta a ponta contra os dois arquivos reais: 24 beneficiários/R$397,68 e 10 beneficiários/R$165,70, batendo exatamente com os totais impressos em cada relatório, incluindo nomes quebrados em duas linhas reconstituídos certos (mesma lógica da Rodada 50, sem mudança). O formato original com colchete (empresa 1084) foi reconfirmado sem regressão com um teste dedicado. -O fato de a operadora ter mandado um arquivo por contrato/filial não teve nenhuma relação com o erro — múltiplos arquivos de operadora por importação já são suportados desde a Rodada 83 (mesclados automaticamente). - ### Décima operadora: Humana Saúde (5064) Usuário forneceu o modelo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI - EPP), competência 08/2026 — primeiro teste guiado pelo checklist novo da skill `importacao-questor-plano-saude` (seção 4.1): antes de escrever qualquer código, o arquivo foi inspecionado (`pdfplumber`, `repr()` linha a linha) e as perguntas que não davam pra responder só com o arquivo foram feitas ao usuário (código da operadora no Questor, confirmação do código da empresa, regra de custeio negociada, e se a tabela "TOTALIZAÇÃO POR PLANO" devia ser ignorada). @@ -271,3 +269,30 @@ Usuário forneceu o modelo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI - EPP) ### Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723) Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (`1723`); o código correto é `4723` — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem `OPER_4723_DENTAL_UNI...`). Corrigido `OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"]` em `pipeline.py`; o label de exibição (`"4723 - Dental Uni Odonto"`) é derivado desse campo em todo lugar que usa `label_operadora()`/`lista_operadoras()` (combobox de operadora, mensagens de erro, `nome_operadora` de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (`ImportacaoPlanoSaude.nome_operadora`, texto congelado no momento da criação) não são retroativamente corrigidos. + +### Combobox de Operadora ordenado por código + +Pedido do usuário: o combobox "Operadora" de Nova Importação (e os demais que reaproveitam `lista_operadoras()`) mostrava as operadoras na ordem de inserção em `pipeline.OPERADORAS` (ordem de dict, sem critério nenhum pro usuário). `lista_operadoras()` (`pipeline.py`) passou a ordenar pelo `codigo_operadora` (numérico, menor pro maior) antes de montar a lista `{key, label}` — o frontend (`criarComboboxTexto()`, `importacao-plano-saude.js`) já renderiza os itens na ordem em que chegam da API, sem reordenar sozinho, então bastou ordenar na origem. + +### Décima primeira operadora: Unimed Cascavel (158) + +Usuário forneceu os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, mesma empresa-modelo da Humana): dois relatórios de mensalidade (um por contrato — 183237 e 183210/"Estadual") e um extrato de coparticipação separado. Nome comercial genérico ("Unimed"), mas layout de PDF completamente diferente da "Unimed Saúde" (5060) já cadastrada — outra Unimed regional, código de operadora próprio no Questor (158). + +- **Novo parser** `operadoras/unimed_cascavel/saude.py` (`UnimedCascavelSaude`), registrado em `pipeline.OPERADORAS["unimed_cascavel_saude"]`. `chave_casamento="nome"` — nenhum dos formatos de arquivo traz CPF. +- **Bug de extração descoberto na inspeção (antes de escrever qualquer regex)**: nem `extract_text()` simples nem `extract_text(layout=True, x_density=6)` (a técnica já usada por outros parsers deste pacote) preservam o espaçamento entre palavras deste PDF — as duas fundem tudo sem espaço nenhum ("UNIMEDDECASCAVEL...", nomes de beneficiário colados). Confirmado inspecionando `page.chars` diretamente que o espaçamento real é mais estreito que a tolerância padrão do pdfplumber; corrigido usando `extract_words(x_tolerance=1)` (em vez de `extract_text()`) e reconstruindo cada linha por posição vertical (`top`) — mesma técnica de reconstrução já usada pela Unimed Vitória, por um motivo diferente (lá era quebra de linha física, aqui é fusão de palavra). +- **Coparticipação pode vir de duas fontes possíveis pro mesmo mês, e a operadora não avisa qual vai mandar**: um relatório de mensalidade pode opcionalmente trazer, na mesma página, uma tabela "DESPESAS COBRADAS" já com a coparticipação resumida por beneficiário — ou ela pode vir num arquivo separado "EXTRATO DE ATENDIMENTOS COBRADOS". Confirmado no arquivo-modelo que as duas, quando aparecem juntas, são a MESMA competência (totais batendo centavo a centavo). Resposta do usuário à pergunta "dá pra implementar as duas? o modelo de arquivo é gerado pela operadora" foi sim — implementadas as duas, mas nunca somadas: `OperadoraParser` ganhou um hook novo, `finaliza()` (`operadoras/base.py`, chamado pelo pipeline uma vez depois que TODOS os arquivos da importação já foram processados, default não faz nada — só a Unimed Cascavel sobrescreve por enquanto), usado aqui pra escolher o extrato separado quando presente (mais granular) e só cair pra tabela embutida na ausência dele — nunca os dois juntos. +- **Resolução de família (titular/dependente) só por matrícula, nunca por nome**: a coluna "Usuário" da mensalidade trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana — confirmado que o texto pára exatamente na borda da coluna seguinte), então cruzar o nome truncado da mensalidade com o nome completo do extrato de coparticipação não seria confiável. Em vez disso, a matrícula (idêntica nos dois formatos) é a chave de tudo: `_pessoa_por_matricula` é populado só a partir da tabela de mensalidade (onde a família já vem certa por ordem de bloco) e reaproveitado pra resolver os dois candidatos de coparticipação, que só carregam matrícula + valor. Beneficiário com coparticipação sem nenhuma linha de mensalidade correspondente nesta importação vira `ItemAuditoria` explícito (arquivo de mensalidade daquele contrato não anexado), nunca é descartado em silêncio. +- Perguntado e confirmado com o usuário: se nem a mensalidade nem a coparticipação trouxerem valor pra alguém numa competência, assume-se que não houve despesa (nenhum `ItemAuditoria` gerado só por ausência). +- Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais: mensalidade batendo R$ 4.076,41 + R$ 808,98 = R$ 4.885,39 (12 beneficiários, 2 contratos) e coparticipação batendo R$ 1.067,17 (3 beneficiários) — confirmado também que a tabela embutida (extraída em paralelo, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada. +- **Bug real corrigido no mesmo dia, testando pela tela**: anexar o extrato de coparticipação junto com a mensalidade dava "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação (`POST /.../validar-arquivo/`), mesmo o arquivo estando correto. Causa: `_valida_arquivo_operadora()` (`views.py`) valida cada arquivo **isoladamente**, com uma instância nova do parser, e só olhava o retorno direto de `extrai()` — mas o extrato de coparticipação da Unimed Cascavel devolve `([], [])` de propósito (os dados ficam retidos até `finaliza()`, ver acima), então a pré-validação nunca via nada. Corrigido chamando `parser.finaliza()` também dentro de `_valida_arquivo_operadora()`, somando `individuos` + `individuos_finais` + `auditoria_final` pra decidir se "algo foi encontrado" — sem exigir que a resolução completa (que depende de ver a família inteira, só disponível na importação real com todos os arquivos juntos) já esteja pronta nesse momento. Não afeta nenhuma outra operadora (`finaliza()` default devolve sempre vazio, soma zero). Validado rodando a mesma checagem isolada contra os 3 arquivos reais: 13/2/3 lançamentos encontrados respectivamente (o arquivo de mensalidade com "DESPESAS COBRADAS" embutida agora conta certo os 10 de mensalidade + 3 de coparticipação, em vez de só 10). + +O fato de a operadora ter mandado um arquivo por contrato/filial não teve nenhuma relação com o erro — múltiplos arquivos de operadora por importação já são suportados desde a Rodada 83 (mesclados automaticamente). + +### Skill de negócio dividida em duas (Questor x geral) + +Pedido do usuário: o escritório trabalha com mais de um sistema contábil (Questor hoje, Contabit no futuro), e a etapa final da ferramenta (estruturar/gerar o arquivo de lançamento) diverge entre eles, mesmo com a extração/regras de negócio sendo as mesmas. A skill única `importacao-questor-plano-saude` (que, apesar do nome, cobria a ferramenta inteira) foi dividida: + +- **`importacao-plano-saude`** (nova, skill geral): tabela de operadoras/parsers, checklist de "como adicionar operadora nova", regras de negócio que nunca mudam (nome nunca por aproximação, valor negativo sempre auditoria, somar por indivíduo), "Regra empresa" e "Vínculos de nome salvos", e os gaps de custeio por operadora (AMIL tipo A, Bradesco dependente sem linha) — tudo independente de sistema contábil de destino. Ganhou uma instrução de despacho: confirmar qual é o sistema de destino e carregar a skill específica antes de tocar em leiaute/Cadastro de Regras/geração de arquivo. +- **`importacao-questor-plano-saude`** (existente, esvaziada e focada): ficou só com o que é Questor de fato — "Cadastro de Regras" (por que o cadastro em si, ao contrário da decisão de custeio, nasce amarrado ao `codigo_empresa`/`codigo_operadora` resolvidos contra o banco do Questor) e a auditoria manual pós-importação contra o relatório de lançamentos do Questor. +- Uma terceira skill pro Contabit ainda não existe — a skill geral só documenta o gap e avisa pra não inventar leiaute Contabit sem confirmação, e já registra uma ressalva: o "casamento" hoje (`matcher.py`/`LinhaSistema`) usa um formato de registro moldado no leiaute do Questor, então o Contabit provavelmente vai exigir mais que só uma geração de arquivo diferente — um `LinhaSistema`/matcher próprios também, quando essa frente for aberta. +- Referências cruzadas atualizadas em `portal_api/planos_saude/CLAUDE.md` e `README.md` (skill única → as duas); aproveitado pra corrigir a contagem de parsers no `README.md`, que ainda dizia "9" (desatualizada desde as rodadas da Humana/Unimed Cascavel). diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index afd3ba7..852d681 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -1,6 +1,6 @@ # Importação de Plano de Saúde (Utilitários) -> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/planos_saude/`. Ver também a skill `importacao-questor-plano-saude` (`.claude/skills/`) para contexto de negócio (quais operadoras/empresas já estão validadas, o que falta). +> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações (documentação por app, código continua no mesmo lugar). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.) — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/planos_saude/`. Ver também as duas skills de contexto de negócio (`.claude/skills/`), divididas porque a empresa trabalha com mais de um sistema contábil e as etapas finais divergem entre eles: `importacao-plano-saude` (leitura/extração dos arquivos de operadora e regras de negócio, independente do destino — quais operadoras/empresas já estão validadas, o que falta) e `importacao-questor-plano-saude` (a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final). Uma terceira skill pro Contabit ainda não existe. Primeira e única aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar). @@ -24,11 +24,14 @@ portal_api/planos_saude/ ├── 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) + ├── humana/saude.py Humana Saúde — 5064 (PDF via pdfplumber, mensalidade+coparticipação no mesmo arquivo, casamento por nome — coparticipação nunca casada automaticamente, ver nota abaixo) + └── unimed_cascavel/saude.py Unimed Cascavel — 158 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome — coparticipação pode vir de duas fontes possíveis, nunca somadas juntas, ver nota abaixo e `OperadoraParser.finaliza()`) ``` Pra adicionar uma operadora nova: criar `operadoras//.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. +**`OperadoraParser.finaliza()`** (`operadoras/base.py`) — hook opcional chamado pelo `pipeline.processa_importacao` uma vez, depois que `extrai()` já rodou pra **todos** os arquivos da importação (default: não acrescenta nada, a maioria das operadoras nunca precisa sobrescrever). Existe pra operadora que só consegue decidir algo depois de ver o conjunto completo de arquivos anexados — hoje o único caso real é a Unimed Cascavel (ver abaixo), que usa isso pra escolher entre duas fontes possíveis de coparticipação sem correr risco de somar as duas. + **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`/00` = titular, `/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. @@ -43,6 +46,13 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme **Humana Saúde (5064)** — único parser deste pacote em que a coparticipação **nunca** vira um `Individuo` tentando casamento automático. O PDF "boletim" traz mensalidade e coparticipação juntas no mesmo arquivo (uma tabela de beneficiários, depois "TOTALIZAÇÃO POR PLANO" — resumo agregado sem valor por pessoa, sempre ignorado — e depois "DESPESAS COBRADAS"), mas as duas tabelas usam identificadores diferentes: a de mensalidade tem uma "Matrícula" por beneficiário (`..`, com "Tipo do usuário" já como texto explícito "Titular"/"Dependente"/"Agregado" — não precisa inferir por indentação), enquanto a de "DESPESAS COBRADAS" só tem a matrícula do CONTRATO (não bate com a do beneficiário) e o nome vem truncado por largura de coluna (colado sem espaço no número da conta seguinte quando ultrapassa a largura — ex.: "CINTHIA ADRIANA DE SOUZA SANTOS" vira "CINTHIA ADRIANA DE SOUZ" colado em "...SOUZ39324387"). Sem CPF nem matrícula confiável pra casar, cada evento de coparticipação (já somado por pessoa antes, mesma regra geral de "somar por indivíduo") vira direto um `ItemAuditoria` (`motivo="NAO_CADASTRADO"`) na extração — decisão explícita do usuário, pra sempre exigir "Vincular pessoa" manual em vez de arriscar casar a pessoa errada por causa do corte de nome. Validado contra o arquivo real da empresa 1972 (FRONTEIRA OUTDOOR EIRELI, competência 08/2026): 4 beneficiários de mensalidade somando R$ 1.154,73 e 2 itens de auditoria de coparticipação somando R$ 145,20 (uma pessoa com duas despesas no mês corretamente somada em um único item, não dois) — batendo exatamente com os totais impressos no próprio boletim. **Só uma família no arquivo-modelo**: as posições fixas usadas pra extrair "Titular"/"Usuário" da tabela de despesas (colunas 16 e 34 do texto extraído) não puderam ser confirmadas com um nome bem mais curto que a largura da coluna — reconferir se aparecer uma competência real com mais de uma família. +**Unimed Cascavel (158)** — outra Unimed regional, layout de PDF sem nenhuma sobreposição com a "Unimed Saúde" (5060, Unimed do Estado do Paraná) já cadastrada; nome comercial genérico repetido entre operadoras diferentes, cada uma com seu próprio parser/código no Questor, mesmo padrão de `unimed_oeste_pr`/`unimed_vitoria`. Sem CPF em nenhum dos formatos de arquivo (`chave_casamento="nome"`). + +- **Espaçamento das colunas só sai correto com `extract_words(x_tolerance=1)`**: nem `extract_text()` simples nem `extract_text(layout=True, x_density=6)` bastam aqui — os dois fundem palavras adjacentes sem nenhum espaço (ex.: "UNIMED DE CASCAVEL..." vira "UNIMEDDECASCAVEL...", nomes de beneficiário perdem os espaços internos), confirmado inspecionando `page.chars` diretamente: o espaçamento real entre palavras neste PDF é mais estreito que a tolerância padrão do pdfplumber (a densidade de `x_density=6` do `layout=True` não ajudou; a solução foi baixar o `x_tolerance` de `extract_words()` pra 1). Com isso, as linhas são reconstruídas agrupando palavras por posição vertical (`top`) e concatenando com um único espaço — mesma técnica de "reconstruir a linha a partir de `extract_words()`" já usada pela Unimed Vitória, só que ali por causa de quebra de linha física, aqui por causa da fusão de palavras. +- **Duas fontes possíveis de coparticipação, nunca somadas**: o relatório de mensalidade (`"Matrícula Usuário Plano Tipo do usuário..."`, com `"TOTALIZAÇÃO POR PLANO"` no rodapé) pode opcionalmente trazer, na mesma página, uma tabela `"DESPESAS COBRADAS"` já resumida por beneficiário — **ou** a coparticipação pode vir num arquivo separado `"EXTRATO DE ATENDIMENTOS COBRADOS"` (um bloco por beneficiário, `"Total do usuário:"` já com o valor somado). Confirmado no arquivo-modelo que as duas fontes representam a **mesma competência** quando as duas aparecem juntas (os totais por beneficiário batem centavo a centavo entre as duas). Como "o modelo de arquivo é gerado pela operadora" (decisão explícita do usuário — não dá pra saber de antemão qual formato vai chegar num mês qualquer, e às vezes os dois vêm juntos), o parser nunca soma as duas: `extrai()` só coleta candidatos por matrícula em dois dicionários separados (`_despesas_embutidas`/`_coparticipacao_extrato`, nunca devolvidos direto) e `finaliza()` (chamado pelo pipeline só depois que todos os arquivos da importação já foram processados) decide — prefere o extrato separado quando presente (fonte mais granular), senão cai pra tabela embutida, senão não há coparticipação naquele mês (decisão explícita do usuário: ausência das duas fontes não gera nenhum `ItemAuditoria`, é tratada como "não houve despesa"). +- **Toda resolução de família (titular/dependente) é feita por matrícula, nunca por nome** — a coluna "Usuário" do relatório de mensalidade é estreita e trunca nomes longos sem reticências (mesmo padrão de Amil/Bradesco/Humana, confirmado inspecionando os limites reais de x0/x1 do PDF: o nome pára exatamente na borda da coluna seguinte), então comparar o nome truncado da mensalidade com o nome completo do extrato de coparticipação para resolver `numero_titular`/`tipo` não seria confiável. Em vez disso, `_pessoa_por_matricula` (matrícula -> nome/tipo/numero_titular) é populado só ao processar a tabela de **mensalidade** (onde a família já vem corretamente resolvida por ordem de bloco: titular sempre antes dos próprios dependentes) e reaproveitado em `finaliza()` pra resolver os dois candidatos de coparticipação — que só carregam matrícula + valor, nada de nome. Um beneficiário com coparticipação mas ausente de toda tabela de mensalidade desta importação (arquivo daquele contrato não anexado) vira um `ItemAuditoria` explícito (`NAO_CADASTRADO`), nunca é descartado silenciosamente. Nomes truncados na mensalidade em si seguem o fluxo normal (`NOME_DIVERGENTE` em auditoria, resolvido manualmente uma vez via "Vincular pessoa" — nunca por aproximação). +- Validado rodando `extrai()`/`finaliza()` de ponta a ponta contra os 3 arquivos reais da empresa 1972 (Fronteira Outdoor Ltda, competência 08/2026, 2 contratos — 183237 e 183210/"Estadual"): mensalidade batendo exatamente com os totais impressos (R$ 4.076,41 + R$ 808,98 = R$ 4.885,39, 12 beneficiários) e coparticipação batendo com R$ 1.067,17 (3 beneficiários), confirmando que a tabela embutida (também extraída, mesmos valores) foi corretamente descartada em favor do extrato separado, sem duplicar nada. + **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`. ## Modelos (`portal_api/models.py`) @@ -148,7 +158,7 @@ Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — m Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma). -- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também vira `valido=False`. +- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` **e também** `.finaliza()` (ver `OperadoraParser.finaliza()` acima) na mesma instância, somando `individuos` + `individuos_finais` + `auditoria_final` pra decidir se algo foi encontrado — necessário porque uma operadora que devolve dado retido em `finaliza()` (hoje só a Unimed Cascavel) processa cada arquivo **sozinho** aqui, sem ver os demais arquivos da importação real; sem essa chamada extra, um arquivo cujo conteúdo só é liberado em `finaliza()` sempre pareceria vazio (bug real, ver `CHANGELOG.md`). Os dois gravam o upload num arquivo temporário (`_salva_arquivo_temporario`, `tempfile.NamedTemporaryFile`) só porque essas funções esperam um caminho de arquivo, não um objeto de upload em memória, e apagam o temporário no `finally`; **nada é persistido**. Qualquer exceção do parser (coluna faltando, layout de PDF não reconhecido, CSV com delimitador errado — inclusive o caso real já visto de export com `\t` em vez de `;`) vira `valido=False` com uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos/itens de auditoria extraídos (arquivo no formato certo mas vazio) também vira `valido=False`. - **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão. - **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do ``, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva. diff --git a/portal_api/planos_saude/README.md b/portal_api/planos_saude/README.md index d6f3b33..f2d6026 100644 --- a/portal_api/planos_saude/README.md +++ b/portal_api/planos_saude/README.md @@ -1,8 +1,8 @@ # Importação de Plano de Saúde (Utilitários) -> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (pacote `planos_saude/`, cada operadora parametrizada). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também a skill `importacao-questor-plano-saude` (`.claude/skills/`) para contexto de negócio (quais operadoras/empresas já estão validadas, o que falta). +> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (pacote `planos_saude/`, cada operadora parametrizada). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também as skills de contexto de negócio (`.claude/skills/`): `importacao-plano-saude` (leitura/extração dos arquivos de operadora e regras de negócio, independente do sistema contábil de destino — quais operadoras/empresas já estão validadas, o que falta) e `importacao-questor-plano-saude` (a etapa específica do Questor — Cadastro de Regras, planilha padrão, leiaute final). -Única aplicação dentro de "Utilitários" — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão. Hoje suporta 9 parsers de operadora (Amil, Itamed, Unimed em 3 variantes, Dental Uni, Bradesco em 2 variantes, SulAmérica em 2 variantes) e um banco de regras de custeio por empresa+operadora, com histórico completo de cada importação e reversão de qualquer alteração feita na revisão. +Única aplicação dentro de "Utilitários" — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão. Hoje suporta 11 parsers de operadora (Amil, Itamed, Unimed em 4 variantes, Dental Uni, Bradesco em 2 variantes, SulAmérica em 2 variantes, Humana) e um banco de regras de custeio por empresa+operadora, com histórico completo de cada importação e reversão de qualquer alteração feita na revisão. ## Onde mexer diff --git a/portal_api/planos_saude/operadoras/base.py b/portal_api/planos_saude/operadoras/base.py index 86f2614..a478adc 100644 --- a/portal_api/planos_saude/operadoras/base.py +++ b/portal_api/planos_saude/operadoras/base.py @@ -54,3 +54,18 @@ class OperadoraParser(ABC): a operadora nunca usou antes e não sabemos classificar). """ raise NotImplementedError + + def finaliza(self) -> Tuple[List[Individuo], List[ItemAuditoria]]: + """ + Chamado uma vez pelo pipeline (`processa_importacao`), depois que + `extrai()` já rodou para TODOS os arquivos desta importação — para + uma operadora que só consegue decidir algo depois de ver o conjunto + completo de arquivos anexados (ex.: a mesma coparticipação pode vir + embutida num arquivo OU num arquivo separado, dependendo de como a + operadora gerou o pacote daquele mês — só dá pra saber qual fonte + usar, sem contar valor em dobro, depois de ver todos os arquivos; + ver `operadoras/unimed_cascavel/saude.py`). Default: não acrescenta + nada — a maioria das operadoras resolve tudo dentro de `extrai()` e + não precisa sobrescrever isto. + """ + return [], [] diff --git a/portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py b/portal_api/planos_saude/operadoras/unimed_cascavel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py b/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py new file mode 100644 index 0000000..990f97e --- /dev/null +++ b/portal_api/planos_saude/operadoras/unimed_cascavel/saude.py @@ -0,0 +1,314 @@ +""" +Unimed de Cascavel Cooperativa de Trabalho Médico (código 158) - Saúde. +Mensalidade + Coparticipação. Diferente da "Unimed Saúde" (5060, Unimed do +Estado do Paraná) já cadastrada em pipeline.OPERADORAS — mesmo nome +comercial genérico ("Unimed"), mas layout de relatório completamente +diferente (marcadores próprios, sem nenhuma sobreposição com +`operadoras/unimed/saude.py`). + +Validado contra os 3 arquivos reais do cliente Fronteira Outdoor Ltda +(empresa Questor 1972, competência 08/2026): "Mensalidade Unimed.pdf" +(contrato 183237), "Mensalidade Unimed Estadual.pdf" (contrato 183210) e +"Cooparticipação Unimed.pdf" (extrato analítico). + +Formato de entrada — dois tipos de arquivo, detectados automaticamente pelo +conteúdo (nunca pelo usuário escolhendo um "tipo de documento"): + +1. Relatório de MENSALIDADE ("Matrícula Usuário Plano Tipo do usuário + Nascimento Idade Inclusão Valor", com "TOTALIZAÇÃO POR PLANO" no + rodapé) — uma linha por beneficiário, sem CPF nenhum (chave_casamento + = "nome"). Cada arquivo cobre UM contrato (Matrícula/Usuário se repetem + entre contratos diferentes da mesma empresa, ex.: 183237 x 183210 — mas + nunca dentro do mesmo contrato). + + Esse relatório pode OPCIONALMENTE trazer, na mesma página, uma segunda + tabela "DESPESAS COBRADAS" logo depois de "TOTALIZAÇÃO POR PLANO" — é a + coparticipação daquele mesmo contrato, já resumida por conta/atendimento + (sem CPF, com a matrícula do próprio beneficiário — não a do titular — + na primeira coluna, confirmado comparando contra a tabela de mensalidade + da mesma página). + +2. EXTRATO DE ATENDIMENTOS COBRADOS — coparticipação analítica num arquivo + à parte, um bloco por beneficiário ("Beneficiário: - ..." seguido de um ou mais "Conta"/"Item" e fechado por "Total + do usuário: "). Só usa o valor já somado em "Total do usuário:" + (mesmo espírito de "usar o valor por linha/já agregado, nunca recalcular + a partir do detalhe" já usado noutras operadoras deste pacote). + +**As duas fontes de coparticipação podem representar a MESMA competência** +(confirmado nos 3 arquivos-modelo: os totais por beneficiário da tabela +"DESPESAS COBRADAS" embutida em "Mensalidade Unimed.pdf" batem centavo a +centavo com os totais do extrato separado "Cooparticipação Unimed.pdf" — +ex.: Claudinei da Silva Tristão R$ 964,89 nos dois). Como o modelo de +arquivo é gerado pela própria operadora (decisão explícita do usuário: "é +possível implementar as duas validações? Pois o modelo de arquivo é gerado +pela operadora"), não dá pra saber de antemão se um mês vai trazer só o +extrato separado, só a tabela embutida, ou os dois juntos (redundantes) — +por isso este parser NUNCA soma as duas fontes: usa `extrai()` só para +COLETAR os candidatos de cada arquivo (em `self._despesas_embutidas`/ +`self._coparticipacao_extrato`, chaveados por matrícula, nunca devolvidos +direto) e só decide em `finaliza()` (`OperadoraParser.finaliza()`, chamado +pelo pipeline uma vez depois que TODOS os arquivos da importação já foram +processados) — prefere o extrato separado quando presente (fonte mais +granular/detalhada), senão cai para a tabela embutida, senão não há +coparticipação nesse mês (decisão explícita do usuário: "se não há valores +no arquivo de mensalidade nem de coparticipação, supomos que não houve +despesas nesta competência" — nenhum ItemAuditoria é gerado só por +ausência de coparticipação). + +**Truncamento de nome na tabela de mensalidade** (mesmo padrão já visto em +Amil/Bradesco/Humana): a coluna "Usuário" é estreita (confirmado pelos +limites de x0/x1 reais do PDF — o nome pára exatamente na borda da coluna +"Plano" seguinte) e corta nomes longos sem reticências nem terminar a +última palavra (ex.: "AUGUSTO CESAR GOBETI DOS SA" em vez de "...DOS +SANTOS"). Isso é esperado cair em auditoria "NOME_DIVERGENTE" pra quem +tiver nome mais longo que a coluna — resolvido manualmente uma vez via +"Vincular pessoa" (nunca por aproximação automática, ver CLAUDE.md). Por +isso a coparticipação NUNCA tenta resolver `numero_titular`/`tipo`/`nome` +a partir de nome (que estaria truncado de um jeito e completo de outro, +inviabilizando comparação exata) — em vez disso, tudo é resolvido via +matrícula: `self._pessoa_por_matricula` é populado ao processar a tabela +de MENSALIDADE (matrícula -> nome/tipo/numero_titular, já com a família +corretamente resolvida ali), e tanto a tabela embutida quanto o extrato +separado só carregam matrícula + valor — `finaliza()` cruza os dois. Um +beneficiário com coparticipação mas ausente de toda tabela de mensalidade +desta importação (arquivo de mensalidade daquele contrato não anexado) +vira um ItemAuditoria explícito, nunca é descartado silenciosamente. + +**Espaçamento das colunas só é preservado com `extract_words(x_tolerance= +1)`**: o `extract_text()` padrão (e mesmo `layout=True` com o `x_density` +default) funde palavras adjacentes sem nenhum espaço (ex.: "UNIMED DE +CASCAVEL..." vira "UNIMEDDECASCAVEL...", nomes de beneficiário perdem os +espaços internos) — confirmado inspecionando `page.chars`: o espaçamento +real entre palavras neste PDF é mais estreito que a tolerância padrão do +pdfplumber. Com `x_tolerance=1`, as palavras separam corretamente. Por +isso `_linhas_pdf()` usa `extract_words(x_tolerance=1)` agrupado por +posição vertical (`top`), não `extract_text()`. +""" +import re +import unicodedata +from typing import Dict, List, Optional, Tuple + +import pdfplumber + +from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento +from portal_api.planos_saude.operadoras.base import OperadoraParser + +_MARCADOR_MENSALIDADE = "TOTALIZACAO POR PLANO" +_MARCADOR_DESPESAS_COBRADAS = "DESPESAS COBRADAS" +_MARCADOR_EXTRATO = "EXTRATO DE ATENDIMENTOS COBRADOS" + +_TIPO_POR_ROTULO = {"Titular": "T", "Dependente": "D", "Agregado": "A"} + +_LINHA_MENSALIDADE_RE = re.compile( + r"^(?P\d+)\s+(?P.+?)\s+(?P\d{3,6})\s+" + r"(?PTitular|Dependente|Agregado)\s+\d{2}/\d{2}/\d{4}\s+\d+\s+" + r"\d{2}/\d{2}/\d{4}\s+(?P[\d.,]+)\s*$" +) +# Só precisa de matrícula (já é a chave usada em self._pessoa_por_matricula, +# resolvida a partir da tabela de mensalidade) e do valor — o resto da +# linha (titular/usuário truncados, conta, data, regime, prestador) não é +# usado, ver docstring do módulo. +_LINHA_DESPESA_RE = re.compile( + r"^(?P\d+)\s+.+?\s+\d{9}\s+\d{2}/\d{2}/\d{4}\s+\S+\s+.+?\s+" + r"(?P[\d.,]+)\s*$" +) +_BENEFICIARIO_RE = re.compile( + r"^Benefici\S*rio:\s*(?P\d+)\s*-\s*.+?\s+Idade:", re.IGNORECASE +) +_TOTAL_USUARIO_RE = re.compile(r"^Total do usu\S*rio:\s*(?P[\d.,]+)\s*$", re.IGNORECASE) + + +def _normaliza_marcador(texto: str) -> str: + sem_acento = unicodedata.normalize("NFKD", texto or "") + sem_acento = "".join(c for c in sem_acento if not unicodedata.combining(c)) + return sem_acento.upper() + + +def _valor_para_float(texto: str) -> float: + """'257,09' -> 257.09 '1.234,56' -> 1234.56 (formato BR)""" + texto = texto.strip().replace(".", "").replace(",", ".") + return float(texto) if texto else 0.0 + + +class UnimedCascavelSaude(OperadoraParser): + nome_operadora = "UNIMED CASCAVEL" + chave_casamento = "nome" # nenhum dos dois formatos traz CPF + + def __init__(self) -> None: + # matrícula -> {"nome", "tipo", "numero_titular"}, populado ao + # processar toda tabela de MENSALIDADE vista nesta importação + # (pode ser mais de um arquivo/contrato) — única fonte de verdade + # pra resolver quem é quem na coparticipação (ver docstring). + self._pessoa_por_matricula: Dict[str, dict] = {} + # candidatos de coparticipação por matrícula, um dict por fonte — + # nunca somados entre si, `finaliza()` escolhe um dos dois. + self._despesas_embutidas: Dict[str, float] = {} + self._coparticipacao_extrato: Dict[str, float] = {} + self._extrato_matricula_atual: Optional[str] = None + + # ------------------------------------------------------------------ + # Leitura do PDF (espaçamento só correto com x_tolerance=1, ver docstring) + # ------------------------------------------------------------------ + + def _linhas_pdf(self, caminho_arquivo: str) -> List[str]: + linhas: List[str] = [] + with pdfplumber.open(caminho_arquivo) as pdf: + for page in pdf.pages: + palavras = page.extract_words(x_tolerance=1) + por_linha: Dict[float, List[dict]] = {} + for palavra in palavras: + chave = round(palavra["top"], 1) + por_linha.setdefault(chave, []).append(palavra) + for chave in sorted(por_linha): + palavras_linha = sorted(por_linha[chave], key=lambda p: p["x0"]) + linhas.append(" ".join(p["text"] for p in palavras_linha)) + return linhas + + # ------------------------------------------------------------------ + # Extração + # ------------------------------------------------------------------ + + def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]: + linhas = self._linhas_pdf(caminho_arquivo) + texto_normalizado = _normaliza_marcador(" ".join(linhas)) + + if _MARCADOR_EXTRATO in texto_normalizado: + self._processa_extrato_coparticipacao(linhas) + return [], [] + + if _MARCADOR_MENSALIDADE in texto_normalizado: + return self._processa_mensalidade(linhas), [] + + raise ValueError( + "Layout de PDF da Unimed Cascavel não reconhecido — não é nem o " + "relatório de mensalidade ('TOTALIZAÇÃO POR PLANO') nem o " + "extrato de coparticipação ('EXTRATO DE ATENDIMENTOS COBRADOS')." + ) + + def _processa_mensalidade(self, linhas: List[str]) -> List[Individuo]: + titular_atual: Optional[str] = None + modo_despesas = False + lancamentos: List[Lancamento] = [] + + for linha in linhas: + if _MARCADOR_DESPESAS_COBRADAS in _normaliza_marcador(linha): + modo_despesas = True + continue + + if not modo_despesas: + m = _LINHA_MENSALIDADE_RE.match(linha) + if not m: + continue + matricula = m.group("matricula") + tipo = _TIPO_POR_ROTULO[m.group("tipo")] + nome = m.group("nome").strip() + valor = _valor_para_float(m.group("valor")) + if tipo == "T": + titular_atual = matricula + numero_titular = None + else: + numero_titular = titular_atual + self._pessoa_por_matricula[matricula] = { + "nome": nome, "tipo": tipo, "numero_titular": numero_titular, + } + lancamentos.append(Lancamento( + numero_beneficiario=matricula, + nome=nome, + cpf="", + tipo=tipo, + rubrica="Mensalidade", + valor=valor, + tipo_lancamento="mensalidade", + numero_titular=numero_titular, + )) + else: + m = _LINHA_DESPESA_RE.match(linha) + if not m: + continue + matricula = m.group("matricula") + valor = _valor_para_float(m.group("valor")) + self._despesas_embutidas[matricula] = ( + self._despesas_embutidas.get(matricula, 0.0) + valor + ) + + return self._agrega_por_individuo_e_tipo(lancamentos) + + def _processa_extrato_coparticipacao(self, linhas: List[str]) -> None: + for linha in linhas: + m_benef = _BENEFICIARIO_RE.match(linha) + if m_benef: + self._extrato_matricula_atual = m_benef.group("matricula") + continue + m_total = _TOTAL_USUARIO_RE.match(linha) + if m_total and self._extrato_matricula_atual: + valor = _valor_para_float(m_total.group("valor")) + matricula = self._extrato_matricula_atual + self._coparticipacao_extrato[matricula] = ( + self._coparticipacao_extrato.get(matricula, 0.0) + valor + ) + self._extrato_matricula_atual = None + + # ------------------------------------------------------------------ + # Finalização — resolve a coparticipação só depois de ver todos os + # arquivos desta importação (ver docstring do módulo). + # ------------------------------------------------------------------ + + def finaliza(self) -> Tuple[List[Individuo], List[ItemAuditoria]]: + fonte = self._coparticipacao_extrato or self._despesas_embutidas + individuos: List[Individuo] = [] + auditoria: List[ItemAuditoria] = [] + + for matricula, valor_total in fonte.items(): + pessoa = self._pessoa_por_matricula.get(matricula) + if pessoa is None: + auditoria.append(ItemAuditoria( + motivo="NAO_CADASTRADO", + numero_beneficiario=matricula, + nome="", + cpf="", + tipo="D", + valor=valor_total, + tipo_lancamento="coparticipacao", + detalhe=( + f"Coparticipação de R$ {valor_total:.2f} para a matrícula " + f"{matricula}, sem correspondência na tabela de Mensalidade " + "desta importação — anexe também o arquivo de Mensalidade " + "que contém esse beneficiário." + ), + )) + continue + individuos.append(Individuo( + numero_beneficiario=matricula, + nome=pessoa["nome"], + cpf="", + tipo=pessoa["tipo"], + tipo_lancamento="coparticipacao", + valor_total=valor_total, + rubricas=[f"Coparticipação: +{valor_total:.2f}"], + numero_titular=pessoa["numero_titular"], + )) + return individuos, auditoria + + # ------------------------------------------------------------------ + # Agregação (mesma técnica de todo outro parser deste pacote) + # ------------------------------------------------------------------ + + def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]: + individuos: Dict[Tuple[str, str], Individuo] = {} + ordem = [] + for lc in lancamentos: + chave = (lc.numero_beneficiario, lc.tipo_lancamento) + if chave not in individuos: + individuos[chave] = Individuo( + numero_beneficiario=lc.numero_beneficiario, + nome=lc.nome, + cpf=lc.cpf, + tipo=lc.tipo, + tipo_lancamento=lc.tipo_lancamento, + numero_titular=lc.numero_titular, + ) + ordem.append(chave) + individuos[chave].valor_total += lc.valor + individuos[chave].rubricas.append(f"{lc.rubrica}: {lc.valor:+.2f}") + return [individuos[c] for c in ordem] diff --git a/portal_api/planos_saude/pipeline.py b/portal_api/planos_saude/pipeline.py index f9007fc..9ebb7d6 100644 --- a/portal_api/planos_saude/pipeline.py +++ b/portal_api/planos_saude/pipeline.py @@ -23,6 +23,7 @@ from portal_api.planos_saude.operadoras.itamed.saude import ItamedSaude from portal_api.planos_saude.operadoras.sulamerica.odonto_mensalidade import SulAmericaOdontoMensalidade from portal_api.planos_saude.operadoras.sulamerica.saude import SulAmericaSaude from portal_api.planos_saude.operadoras.unimed.saude import UnimedSaude +from portal_api.planos_saude.operadoras.unimed_cascavel.saude import UnimedCascavelSaude from portal_api.planos_saude.operadoras.unimed_oeste_pr.saude import UnimedOestePrSaude from portal_api.planos_saude.operadoras.unimed_vitoria.saude import UnimedVitoriaSaude @@ -86,6 +87,11 @@ OPERADORAS = { "nome": "SulAmérica", "parser": SulAmericaSaude, }, + "unimed_cascavel_saude": { + "codigo_operadora": "158", + "nome": "Unimed Cascavel", + "parser": UnimedCascavelSaude, + }, } @@ -202,6 +208,12 @@ def processa_importacao( individuos_arquivo, auditoria_arquivo = parser_operadora.extrai(caminho) individuos.extend(individuos_arquivo) auditoria_extracao.extend(auditoria_arquivo) + # Chamado só depois que extrai() já rodou pra todos os arquivos — + # operadoras que precisam ver o conjunto completo antes de decidir algo + # sobrescrevem isto (ver OperadoraParser.finaliza()); a maioria não usa. + individuos_finais, auditoria_final = parser_operadora.finaliza() + individuos.extend(individuos_finais) + auditoria_extracao.extend(auditoria_final) individuos = _agrega_individuos_entre_arquivos(individuos) regra_empresa_fn = None diff --git a/portal_api/views.py b/portal_api/views.py index 2daa222..449e900 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -731,7 +731,20 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict caminho = _salva_arquivo_temporario(arquivo, sufixo) operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key] try: - individuos, _ = operadora_info["parser"]().extrai(caminho) + parser = operadora_info["parser"]() + individuos, _ = parser.extrai(caminho) + # Algumas operadoras (ex.: Unimed Cascavel) não devolvem os dados de + # um arquivo direto em extrai() — retêm num estado interno e só + # resolvem em finaliza() (OperadoraParser.finaliza(), chamado pelo + # pipeline depois de ver TODOS os arquivos da importação real, pra + # decidir entre fontes de dado que se sobrepõem sem contar em + # dobro). Chamado aqui também, com o arquivo sozinho, só pra essa + # pré-validação enxergar que algo FOI encontrado nele — mesmo que a + # resolução completa (casamento por família) só aconteça de verdade + # quando os demais arquivos da importação também estiverem + # presentes. Não tem efeito nenhum pra quem não sobrescreve + # finaliza() (devolve sempre vazio). + individuos_finais, auditoria_final = parser.finaliza() except Exception: return { "valido": False, @@ -744,9 +757,10 @@ def _valida_arquivo_operadora(arquivo: UploadedFile, operadora_key: str) -> dict finally: os.remove(caminho) - if not individuos: + total_encontrado = len(individuos) + len(individuos_finais) + len(auditoria_final) + if not total_encontrado: return {"valido": False, "mensagem": "Nenhum beneficiário foi encontrado neste arquivo."} - return {"valido": True, "mensagem": f"{len(individuos)} lançamento(s) encontrado(s) no arquivo."} + return {"valido": True, "mensagem": f"{total_encontrado} lançamento(s) encontrado(s) no arquivo."} def _monta_csv_linhas_plano_saude(linhas: Iterable[ImportacaoPlanoSaudeLinha]) -> bytes: diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index c9e36f5..9e16b26 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -431,7 +431,7 @@

2. Arquivos da operadora

-

Relatório de faturamento enviado pela operadora do plano (PDF ou CSV), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados, o tipo de cada um é identificado automaticamente.

+

Relatório de faturamento enviado pela operadora do plano (PDF, CSV ou XLSX), com os valores do mês. Algumas operadoras mandam mensalidade e coparticipação em arquivos separados, o tipo de cada um é identificado automaticamente.