Implementação das regras da Tecnomyl

This commit is contained in:
Gabriel 2026-09-17 17:10:57 -03:00
parent ca9f53837c
commit 39ea801899
10 changed files with 258 additions and 14 deletions

View File

@ -71,7 +71,16 @@
"Bash(curl -s http://127.0.0.1:8000/static/css/halloween-cobweb.css)", "Bash(curl -s http://127.0.0.1:8000/static/css/halloween-cobweb.css)",
"Bash(\"/c/Users/Depaula/Documents/Portal/.venv/Scripts/python.exe\" _scratch_insert_seasonal_toggle.py)", "Bash(\"/c/Users/Depaula/Documents/Portal/.venv/Scripts/python.exe\" _scratch_insert_seasonal_toggle.py)",
"Bash(rm _scratch_insert_seasonal_toggle.py)", "Bash(rm _scratch_insert_seasonal_toggle.py)",
"Bash(sort -t' ' -k2 -n -u)" "Bash(sort -t' ' -k2 -n -u)",
"Bash(./.venv/Scripts/python.exe -c ' *)",
"Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_tecnomyl.py\")",
"Bash(\"./.venv/Scripts/python.exe\" -c \"import xlrd; print\\(xlrd.__version__\\)\")",
"Bash(\"./.venv/Scripts/pip.exe\" list *)",
"Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_amil_898.py\")",
"Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_amil_pipeline_full.py\")",
"Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_dup_cpf.py\")",
"Bash(\"./.venv/Scripts/python.exe\" manage.py makemigrations --check --dry-run)",
"Bash(\"./.venv/Scripts/pip.exe\" install *)"
] ]
} }
} }

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-09-17 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0073_contabil_observacao_chave_conta_descricao'),
]
operations = [
migrations.AddField(
model_name='importacaoplanosaudedepaulalinha',
name='tipo_pessoa',
field=models.CharField(blank=True, help_text='Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da operadora — usado só por regras de empresa que precisam distinguir dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema.', max_length=1, verbose_name='Tipo de pessoa (T/D/A)'),
),
migrations.AddField(
model_name='importacaoplanosaudelinha',
name='tipo_pessoa',
field=models.CharField(blank=True, help_text='Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da operadora — usado só por regras de empresa que precisam distinguir dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema.', max_length=1, verbose_name='Tipo de pessoa (T/D/A)'),
),
]

View File

@ -711,6 +711,16 @@ class ImportacaoPlanoSaudeLinha(models.Model):
) )
descricao = models.CharField("Descrição", max_length=255, blank=True) descricao = models.CharField("Descrição", max_length=255, blank=True)
ordem = models.PositiveIntegerField("Ordem", default=0) ordem = models.PositiveIntegerField("Ordem", default=0)
tipo_pessoa = models.CharField(
"Tipo de pessoa (T/D/A)",
max_length=1,
blank=True,
help_text=(
"Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da "
"operadora — usado só por regras de empresa que precisam distinguir "
"dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema."
),
)
class Meta: class Meta:
verbose_name = "Linha de importação de plano de saúde" verbose_name = "Linha de importação de plano de saúde"
@ -1063,6 +1073,16 @@ class ImportacaoPlanoSaudeDePaulaLinha(models.Model):
) )
descricao = models.CharField("Descrição", max_length=255, blank=True) descricao = models.CharField("Descrição", max_length=255, blank=True)
ordem = models.PositiveIntegerField("Ordem", default=0) ordem = models.PositiveIntegerField("Ordem", default=0)
tipo_pessoa = models.CharField(
"Tipo de pessoa (T/D/A)",
max_length=1,
blank=True,
help_text=(
"Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da "
"operadora — usado só por regras de empresa que precisam distinguir "
"dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema."
),
)
class Meta: class Meta:
verbose_name = "Linha de importação de plano de saúde - De Paula" verbose_name = "Linha de importação de plano de saúde - De Paula"

View File

@ -344,3 +344,17 @@ Pedido do usuário (2026-09-16), com print da tela de Revisão mostrando um espa
- **`renderReviewResumoCusteio(importacao)`** (`importacao-plano-saude.js`, nova) monta o resumo a partir dos campos já presentes na importação (`tipos_lancamento`/`custeio_por_tipo`/`regra_empresa`/`regra_empresa_label`) — **não** reaproveita `renderResumoRegra()`/`regrasEmpresaCache` de "Nova Importação" porque essa cache só é buscada ao abrir os modais de "Nova Importação"/"Cadastro de Regras", ficando vazia quando a Revisão é aberta direto do histórico. Em vez disso, `reviewResumoTextoTipo()` detecta cobertura por "regra especial da empresa" olhando se `custeio_por_tipo[tipo]` veio vazio (sem `titular`/`dependente`) com `regra_empresa` preenchido — o mesmo sinal que `ImportacaoPlanoSaudeCreateSerializer.validate()` já grava pra tipos cobertos por uma regra empresa (ver "zeram custeio_por_tipo[tipo] só pros tipos em regra_empresa_tipos" no `CLAUDE.md` deste pacote) — evitando uma segunda fonte de verdade sobre quais tipos a regra cobre. - **`renderReviewResumoCusteio(importacao)`** (`importacao-plano-saude.js`, nova) monta o resumo a partir dos campos já presentes na importação (`tipos_lancamento`/`custeio_por_tipo`/`regra_empresa`/`regra_empresa_label`) — **não** reaproveita `renderResumoRegra()`/`regrasEmpresaCache` de "Nova Importação" porque essa cache só é buscada ao abrir os modais de "Nova Importação"/"Cadastro de Regras", ficando vazia quando a Revisão é aberta direto do histórico. Em vez disso, `reviewResumoTextoTipo()` detecta cobertura por "regra especial da empresa" olhando se `custeio_por_tipo[tipo]` veio vazio (sem `titular`/`dependente`) com `regra_empresa` preenchido — o mesmo sinal que `ImportacaoPlanoSaudeCreateSerializer.validate()` já grava pra tipos cobertos por uma regra empresa (ver "zeram custeio_por_tipo[tipo] só pros tipos em regra_empresa_tipos" no `CLAUDE.md` deste pacote) — evitando uma segunda fonte de verdade sobre quais tipos a regra cobre.
- `abrirRevisao()` chama essa função e decide se o bloco inteiro fica visível (`temResumo || temObs`) — antes só aparecia com observação cadastrada, agora aparece por padrão em toda importação com tipo de lançamento selecionado. - `abrirRevisao()` chama essa função e decide se o bloco inteiro fica visível (`temResumo || temObs`) — antes só aparecia com observação cadastrada, agora aparece por padrão em toda importação com tipo de lançamento selecionado.
- Sem migração, sem endpoint novo — só leitura de campos que a Revisão já carregava. - Sem migração, sem endpoint novo — só leitura de campos que a Revisão já carregava.
### Rodada 132 — Regra de empresa da Amil (898) na Tecnomyl + suporte a Excel + `tipo_pessoa`
Depois de validar a regra da Unimed já cadastrada pra Tecnomyl (empresa 1778, ver `CHANGELOG.md` de rodadas anteriores/`CLAUDE.md`), o usuário passou a regra da segunda operadora, Amil Odonto (898): a empresa custeia 100% da mensalidade de titular e dependente direto; agregados (avós, tios, sobrinhos, sogros) pagam 100%, no mesmo valor por pessoa. O arquivo real fornecido (`Demonstrativo de faturamento Tecnomyl 08.2026.xlsx`, competência 08/2026) revelou dois problemas que precisaram ser resolvidos antes da regra em si fazer sentido:
1. **O arquivo real é Excel, não PDF** — o parser Amil Odonto só lia PDF até aqui. `AmilOdontoMensalidade.extrai()` (`operadoras/amil/odonto_mensalidade.py`) passou a detectar a extensão (`.xlsx`/`.xlsm` → novo caminho via `openpyxl`; qualquer outra → o caminho PDF já existente, inalterado), mesmo padrão de detecção por formato já usado pela Unimed Saúde. `_localiza_cabecalho_xlsx` acha a linha de cabeçalho pelo NOME das colunas (`Código`/`Beneficiário`/`CPF`/`Tipo`/`Mensalidade`), não por posição fixa, já que há um número variável de linhas de título/contrato/fatura acima da tabela real. Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 475 beneficiários (217 T/220 D/38 A), cada um pagando um valor fixo por plano (R$ 20,81 no "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — e a coluna "Tipo" do relatório batendo 1:1 com os exemplos de "agregado" repassados pelo cliente (avós, sogros, sobrinhos, pai/mãe, irmãos = 'A'; cônjuge/filho(a) = 'D').
2. **A planilha padrão do sistema não distingue dependente de agregado** — só sabe titular/não-titular (via `nome_dependente`/`cpf_dependente` vazios ou não), então a regra de custeio (que precisa diferenciar 'D' de 'A' por pessoa) não tinha de onde ler essa informação numa `LinhaSistema`/`ImportacaoPlanoSaudeLinha` já casada. Resolvido com um campo novo, `tipo_pessoa` ('T'/'D'/'A'):
- `LinhaSistema.tipo_pessoa` (`modelos.py`, dataclass, default `""`).
- `ImportacaoPlanoSaudeLinha.tipo_pessoa`/`ImportacaoPlanoSaudeDePaulaLinha.tipo_pessoa` (`CharField(max_length=1, blank=True)`, migração `0074`, aditiva — sem backfill necessário, linhas antigas ficam em branco e continuam funcionando normalmente, já que só a regra da Amil/Tecnomyl lê esse campo).
- `matcher.py` grava esse campo **sempre** no momento do casamento (não só quando há regra de empresa ativa), a partir do `Individuo.tipo` do arquivo da operadora — `_casa_por_cpf`: `linha_destino.tipo_pessoa = ind.tipo`; `_casa_por_nome`: `linha_titular.tipo_pessoa = "T"` / `linha_dep.tipo_pessoa = m.tipo`.
- `views.py` (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`/`...DePaulaAuditoriaViewSet.resolver`) também grava `linha.tipo_pessoa = item.tipo` antes de recalcular a família ao "Vincular pessoa" manualmente — essa linha nunca passou pelo casamento automático, então sem isso cairia sempre no fallback "D" (tratando um agregado vinculado à mão como se fosse dependente). `tipo_pessoa` também entrou em `PLANO_SAUDE_CAMPOS_ALTERACAO` (snapshot de `ImportacaoPlanoSaudeAlteracao.dados_linha`), pra uma exclusão revertida recriar a linha com o `tipo_pessoa` correto.
3. **`_regra_amil_898_tecnomyl`** (`regras_empresa.py`, registrada como `amil_898_tecnomyl`): diferente da Tecnomyl-Unimed (teto por família), é uma regra **por pessoa**, sem interação entre membros da família — lê `linha.tipo_pessoa` (fallback "D" se em branco) e manda 100% pra `valor_empresa` (T/D) ou 100% pra `valor` (A). `chave_casamento="cpf"`, `tipos_lancamento=("mensalidade",)`. Validado com o pipeline completo (`processa_importacao` ponta a ponta, com uma planilha padrão sintética) além do teste isolado da função — nenhum erro na divisão titular/dependente vs. agregado.
**Nota operacional pra próxima competência**: alguns dependentes do arquivo real vêm sem CPF (crianças, principalmente) — normalizado pra `"00000000000"`. Como a estratégia "cpf" (`_casa_por_cpf`) não aceita `vinculos_por_nome` (só a estratégia "nome" reaplica automaticamente um vínculo salvo), uma dessas pessoas vinculada manualmente via "Vincular pessoa" precisa ser vinculada de novo toda competência — comportamento pré-existente de qualquer operadora "cpf" (Amil, SulAmérica, MetLife), não introduzido por esta regra. Ainda não cadastrado em `RegraCusteioPlanoSaude` (banco de produção) — cadastro fica a cargo do usuário direto pela tela "Cadastro de Regras", não é feito por script/ORM neste ambiente.

View File

@ -20,7 +20,7 @@ portal_api/planos_saude/
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome) ├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber **ou** Excel via openpyxl — detectado pela extensão do arquivo, ver nota abaixo —, só mensalidade, casamento por CPF)
├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── 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/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) ├── 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)
@ -41,6 +41,10 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
**Amil Odonto tem um segundo cadastro no Questor, código 898** — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`), porque empresas distintas usam um ou outro cadastro. `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, mesma `nome="Amil Odonto"`), reaproveitando a **mesma classe** `AmilOdontoMensalidade` — regras de extração e de custeio são idênticas às de `amil_odonto_mensalidade`/3758, só muda qual código filtra a planilha padrão buscada no Questor (`busca_linhas_questor`, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de `parser`. **Amil Odonto tem um segundo cadastro no Questor, código 898** — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`), porque empresas distintas usam um ou outro cadastro. `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, mesma `nome="Amil Odonto"`), reaproveitando a **mesma classe** `AmilOdontoMensalidade` — regras de extração e de custeio são idênticas às de `amil_odonto_mensalidade`/3758, só muda qual código filtra a planilha padrão buscada no Questor (`busca_linhas_questor`, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de `parser`.
**Amil Odonto (898) também lê Excel, não só PDF** — o cliente Tecnomyl (empresa 1778, cadastro 898) manda o mesmo relatório "Demonstrativo Analítico Faturamento" em `.xlsx`, não em PDF. `AmilOdontoMensalidade.extrai()` detecta a extensão do arquivo (`.xlsx`/`.xlsm` → `_extrai_xlsx`, via `openpyxl`; qualquer outra → o caminho PDF já existente, inalterado) — mesmo padrão de detecção por formato já usado pela Unimed Saúde (CSV/PDF). `_localiza_cabecalho_xlsx` acha a linha de cabeçalho de verdade (`Código`/`Beneficiário`/`CPF`/`Tipo`/`Mensalidade`) procurando pelo NOME das colunas dentro das primeiras 20 linhas, não por um número de linha fixo — o arquivo real tem um número variável de linhas de título/contrato/fatura acima da tabela. "Total Família" é só um subtotal por família (mesmo espírito do "Valor Total" do PDF, item 1 acima — ignorado); o valor de cada linha vem de "Mensalidade", somado por `Código` (um mesmo beneficiário pode se repetir em várias linhas — rubricas normais + devoluções/exclusões retroativas de competências anteriores cobradas na mesma fatura, mesma regra geral de "somar por indivíduo"). A coluna "Tipo" já vem como 'T'/'D'/'A' direto do relatório, sem precisar de nenhuma inferência a partir da "Dependência" (livre, só informativa). CPF normalizado + `.zfill(11)` por segurança (mesmo cuidado da MetLife). Validado rodando `extrai()` de ponta a ponta contra o arquivo real da competência 08/2026 (contrato Tecnomyl): 475 beneficiários (217 titulares/220 dependentes/38 agregados), cada um pagando um valor fixo por plano (R$ 20,81 no plano "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — bate exatamente com a régua "Tipo" T/D/A do relatório real batendo 1:1 com os exemplos de "agregado" (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) repassados pelo cliente pra `amil_898_tecnomyl` (ver "Regra empresa" abaixo). **2 beneficiários com valor final negativo** no arquivo real (exclusão retroativa de competência anterior cobrada nesta fatura) corretamente caem em auditoria (`VALOR_NEGATIVO`), sem relação com o parser em si — comportamento já existente do `matcher.py`, não precisou de nenhum tratamento novo.
**Nota operacional (não é bug, mas vale saber)**: alguns dependentes do arquivo real vêm sem CPF (campo vazio) — normalizado para `"00000000000"`. Como `chave_casamento="cpf"` pra esta operadora, esse tipo de linha só casa automaticamente se a planilha padrão do Questor também tiver um CPF preenchido pra essa pessoa (o caso normal); se o Questor tiver o CPF real da pessoa (diferente de zeros), a linha cai em auditoria "CPF não encontrado" e precisa de "Vincular pessoa" manual. Diferente da estratégia "nome" (`_casa_por_nome`), a estratégia "cpf" (`_casa_por_cpf`) **não** aceita `vinculos_por_nome` — um vínculo salvo ao resolver manualmente esse caso não é reaplicado automaticamente numa competência futura (a mesma pessoa sem CPF precisaria ser vinculada de novo todo mês). Isso não é uma limitação nova desta rodada, é um comportamento pré-existente de qualquer operadora com `chave_casamento="cpf"` (Amil, SulAmérica, MetLife) — só ficou mais visível aqui porque o arquivo real da Tecnomyl tem vários dependentes menores de idade sem CPF cadastrado. Estender o DE/PARA por nome pra também cobrir a estratégia "cpf" seria uma mudança maior, fora do escopo desta rodada — avaliar se vale a pena caso isso vire um incômodo recorrente.
**Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<regional>.<empresa+contrato>.<sequência>-<dv>" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado. **Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<regional>.<empresa+contrato>.<sequência>-<dv>" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado.
**SulAmérica Odonto (4726)** — um único PDF, sem coparticipação (relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto" da própria operadora — é só uma cobrança fixa periódica por beneficiário, não há linha de serviço/atendimento nenhuma). Ao contrário das Unimeds, este relatório traz **CPF de todo mundo** (casamento por CPF, mais seguro) e um campo textual explícito de "Grau parentesco" (TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...) — nenhuma suposição sobre numeração de carteirinha foi necessária aqui. Validado rodando `pdfplumber` e o `pipeline.processa_importacao` completo contra o arquivo real (15 beneficiários, R$ 437,40 no total — bate exatamente com "Total R$ 437,40" impresso no relatório) e a planilha padrão real da empresa 792 (13 dos 15 beneficiários casaram certo por CPF; os 2 ausentes da planilha de teste foram corretamente para auditoria "CPF não encontrado", não ignorados). **Cada família tem um "totalizador" impresso ao final** (ex.: "R$ 87,48" somando os 3 beneficiários de uma família) — por pedido explícito do usuário, esse total **nunca é usado**: o lançamento é sempre feito pela coluna "Valor" de cada linha de beneficiário individual (R$ 29,16 no exemplo), a mesma lógica de "usar o valor por linha, ignorar o subtotal impresso" já aplicada à Unimed do Paraná/Vitória. Particularidade de extração: quando o nome de um beneficiário (ou da família, no cabeçalho) ultrapassa a largura da coluna, o próprio relatório **corta o texto sem reticências e sem terminar de completar a última palavra** (ex.: "DANIELE MARTINS FERREIRA DA SILVA" sai como "DANIELE MARTINS FERREIRA DA" + "SILV" cortado, perdendo o "A" final) — como o casamento é por CPF, isso nunca afeta a correção do lançamento (nome é só exibição), então o parser não tenta reconstruir o nome quebrado, só usa a primeira linha física de cada beneficiário (onde já estão código/CPF/data nascimento/grau/valor completos, nenhum desses quebra, só o nome às vezes). **SulAmérica Odonto (4726)** — um único PDF, sem coparticipação (relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto" da própria operadora — é só uma cobrança fixa periódica por beneficiário, não há linha de serviço/atendimento nenhuma). Ao contrário das Unimeds, este relatório traz **CPF de todo mundo** (casamento por CPF, mais seguro) e um campo textual explícito de "Grau parentesco" (TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...) — nenhuma suposição sobre numeração de carteirinha foi necessária aqui. Validado rodando `pdfplumber` e o `pipeline.processa_importacao` completo contra o arquivo real (15 beneficiários, R$ 437,40 no total — bate exatamente com "Total R$ 437,40" impresso no relatório) e a planilha padrão real da empresa 792 (13 dos 15 beneficiários casaram certo por CPF; os 2 ausentes da planilha de teste foram corretamente para auditoria "CPF não encontrado", não ignorados). **Cada família tem um "totalizador" impresso ao final** (ex.: "R$ 87,48" somando os 3 beneficiários de uma família) — por pedido explícito do usuário, esse total **nunca é usado**: o lançamento é sempre feito pela coluna "Valor" de cada linha de beneficiário individual (R$ 29,16 no exemplo), a mesma lógica de "usar o valor por linha, ignorar o subtotal impresso" já aplicada à Unimed do Paraná/Vitória. Particularidade de extração: quando o nome de um beneficiário (ou da família, no cabeçalho) ultrapassa a largura da coluna, o próprio relatório **corta o texto sem reticências e sem terminar de completar a última palavra** (ex.: "DANIELE MARTINS FERREIRA DA SILVA" sai como "DANIELE MARTINS FERREIRA DA" + "SILV" cortado, perdendo o "A" final) — como o casamento é por CPF, isso nunca afeta a correção do lançamento (nome é só exibição), então o parser não tenta reconstruir o nome quebrado, só usa a primeira linha física de cada beneficiário (onde já estão código/CPF/data nascimento/grau/valor completos, nenhum desses quebra, só o nome às vezes).
@ -86,6 +90,7 @@ Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (c
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa". - `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo. - `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário. - `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.
- `ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeDePaulaLinha` ganharam `tipo_pessoa` (`CharField(max_length=1, blank=True)`, migração `0074`) — 'T'/'D'/'A' (Titular/Dependente/Agregado), espelhando `LinhaSistema.tipo_pessoa` (`planos_saude/modelos.py`). Não é exposto em nenhum serializer (uso só interno, nenhum consumidor de frontend precisa dele) — existe só pra alimentar uma regra de empresa que precise diferenciar dependente de agregado por PESSOA (a planilha padrão do sistema em si só distingue titular/não-titular, via `nome_dependente`/`cpf_dependente` vazios ou não — nunca soube separar "D" de "A"). Ver "Regra empresa" abaixo (`amil_898_tecnomyl`) pro primeiro consumidor real.
## Fluxo e endpoints ## Fluxo e endpoints
@ -217,7 +222,7 @@ O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/copartic
## Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação) ## Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação)
Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio. Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa), por serem um critério fixo (não um percentual/teto configurável) e/ou por dependerem de uma distinção por PESSOA que o sistema não guarda (ex.: dependente vs. agregado — ver `amil_898_tecnomyl`/`tipo_pessoa` abaixo). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio.
**Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação". **Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação".
@ -230,5 +235,10 @@ Cobre regras de custeio negociadas com uma empresa específica que não cabem no
- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade. - **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
- **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`. - **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`.
- **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação). - **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação).
- **`amil_898_tecnomyl`** — Amil Odonto (código 898) na Tecnomyl (empresa 1778), repassada pelo cliente em 09/2026: a empresa custeia 100% da mensalidade de titular e dependente direto (cônjuge, filho(a)); agregados (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) têm a mensalidade 100% descontada do empregado, no MESMO valor por pessoa — a regra é só sobre QUEM paga, não sobre um teto/percentual, por isso cobre os dois planos do contrato (E200 a R$ 20,81/pessoa e "DENTAL 200" a R$ 16,01/pessoa) sem precisar de nenhum valor fixo no código. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF), `tipos_lancamento=("mensalidade",)` — Amil Odonto não traz coparticipação (ver docstring do parser).
- **Diferente das outras duas regras: não usa família nem `_eh_linha_titular`** — é a primeira regra de empresa que precisa diferenciar dependente de agregado por PESSOA, uma distinção que a planilha padrão do sistema não guarda (só sabe titular/não-titular). Por isso `LinhaSistema`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeDePaulaLinha` ganharam `tipo_pessoa` ('T'/'D'/'A', ver "Modelos" acima) — gravado **sempre** (não só quando há uma regra de empresa ativa) pelo `matcher.py` no momento do casamento, a partir do `Individuo.tipo` original do arquivo da operadora (`_casa_por_cpf`: `linha_destino.tipo_pessoa = ind.tipo`; `_casa_por_nome`: `linha_titular.tipo_pessoa = "T"` / `linha_dep.tipo_pessoa = m.tipo`). `_regra_amil_898_tecnomyl` só lê `getattr(linha, "tipo_pessoa", "") or "D"` de cada linha (fallback pra "D" — custeada pela empresa — quando em branco, ex.: linha incluída manualmente via "Adicionar linha", que nunca passa pelo casamento automático).
- **"Vincular pessoa" (resolução manual de auditoria) também grava `tipo_pessoa`**: como a linha resolvida nunca passou pelo casamento automático (senão não estaria em branco), `ImportacaoPlanoSaudeAuditoriaViewSet.resolver`/`...DePaulaAuditoriaViewSet.resolver` gravam `linha.tipo_pessoa = item.tipo` antes de chamar `_recalcula_familia_regra_empresa(_de_paula)` — sem isso, uma linha vinculada manualmente cairia sempre no fallback "D", tratando um agregado vinculado à mão como se fosse dependente.
- **Nota operacional**: dependentes sem CPF no arquivo real da Tecnomyl (comum em crianças) caem em auditoria "CPF não encontrado" se a planilha padrão do Questor tiver o CPF real dessa pessoa — e, ao contrário da estratégia "nome", a estratégia "cpf" não reaplica um vínculo salvo automaticamente numa competência futura (`_casa_por_cpf` não aceita `vinculos_por_nome`), então a mesma pessoa precisa ser vinculada de novo todo mês. Comportamento pré-existente de qualquer operadora "cpf" (Amil, SulAmérica, MetLife), não uma limitação introduzida por esta regra — ver nota em "Formato Excel (898, Tecnomyl)" acima.
- Validado rodando `extrai()` + a regra + o pipeline completo (`processa_importacao`) contra o arquivo real da competência 08/2026 — ver detalhe em "Formato Excel (898, Tecnomyl)" acima.
- **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes). - **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes).
- **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto. - **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto.

View File

@ -240,6 +240,12 @@ def _casa_por_cpf(
auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema.")) auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema."))
continue continue
# Sempre gravado (não só quando há regra_empresa) — é a única forma
# de uma regra de empresa futura saber, por uma LinhaSistema já
# persistida, se ela era um dependente direto ou um agregado no
# arquivo original da operadora (ver `LinhaSistema.tipo_pessoa`).
linha_destino.tipo_pessoa = ind.tipo
if regra_empresa_fn is not None: if regra_empresa_fn is not None:
linhas_e_valores.append((linha_destino, ind.valor_total)) linhas_e_valores.append((linha_destino, ind.valor_total))
else: else:
@ -349,6 +355,7 @@ def _casa_por_nome(
# a partir da planilha, então a chave certa é o nome REAL do # a partir da planilha, então a chave certa é o nome REAL do
# titular na planilha (`linha_titular.nome_func`), não o nome que # titular na planilha (`linha_titular.nome_func`), não o nome que
# veio do arquivo da operadora (que pode ser divergente). # veio do arquivo da operadora (que pode ser divergente).
linha_titular.tipo_pessoa = "T" # sempre gravado, ver nota em _casa_por_cpf
dependentes_da_familia = dependentes_por_titular.get(normaliza_nome(linha_titular.nome_func), {}) dependentes_da_familia = dependentes_por_titular.get(normaliza_nome(linha_titular.nome_func), {})
linhas_e_valores_familia: List[Tuple[LinhaSistema, float]] = [] linhas_e_valores_familia: List[Tuple[LinhaSistema, float]] = []
@ -394,6 +401,8 @@ def _casa_por_nome(
)) ))
continue continue
linha_dep.tipo_pessoa = m.tipo # 'D' ou 'A', ver nota em _casa_por_cpf
if regra_empresa_fn is not None: if regra_empresa_fn is not None:
linhas_e_valores_familia.append((linha_dep, m.valor_total)) linhas_e_valores_familia.append((linha_dep, m.valor_total))
else: else:

View File

@ -84,6 +84,16 @@ class LinhaSistema:
valor_empresa: str valor_empresa: str
valor: str valor: str
descricao: str descricao: str
# 'T'/'D'/'A' (Titular/Dependente/Agregado) — gravado pelo matcher no
# momento do casamento, a partir do Individuo.tipo do arquivo da
# operadora (ver matcher._casa_por_cpf/_casa_por_nome). A planilha
# padrão do sistema em si não distingue dependente de agregado (só
# titular/não-titular, via nome_dependente/cpf_dependente) — este campo
# existe só pra alimentar regras de empresa que precisam dessa
# distinção por pessoa (ex.: portal_api.planos_saude.regras_empresa,
# regra da Amil/Tecnomyl). Fica "" quando a linha nunca foi casada
# automaticamente (ex.: incluída manualmente via "Adicionar linha").
tipo_pessoa: str = ""
def cpf_func_normalizado(self) -> str: def cpf_func_normalizado(self) -> str:
return normaliza_cpf(self.cpf_func) return normaliza_cpf(self.cpf_func)

View File

@ -3,10 +3,36 @@ Amil Odonto - Mensalidade. Porta de
projects/project/operadoras/amil/odonto_mensalidade.py. projects/project/operadoras/amil/odonto_mensalidade.py.
Formato recebido: PDF "Demonstrativo Analítico de Faturamento - Por Formato recebido: PDF "Demonstrativo Analítico de Faturamento - Por
Contrato / Empresa". Esta operadora normalmente não traz coparticipação, Contrato / Empresa" (código 3758) **ou** Excel do mesmo relatório (código
só mensalidade — mas a arquitetura já está pronta para o dia em que 898 — ver "Formato Excel (898, Tecnomyl)" abaixo), detectado automaticamente
precisarmos ler coparticipação de outra operadora (basta criar uma classe pela extensão do arquivo (mesmo padrão de detecção por conteúdo/extensão já
irmã, ex: operadoras/amil/odonto_coparticipacao.py). usado por outras operadoras, ex.: Unimed Saúde CSV/PDF). Esta operadora
normalmente não traz coparticipação, só mensalidade — mas a arquitetura já
está pronta para o dia em que precisarmos ler coparticipação de outra
operadora (basta criar uma classe irmã, ex: operadoras/amil/odonto_coparticipacao.py).
Formato Excel (898, Tecnomyl) — validado contra o arquivo real da
competência 08/2026 (509 linhas, contrato Tecnomyl): mesmo relatório
"Demonstrativo Analítico Faturamento", só que exportado em `.xlsx` em vez
de PDF. O cabeçalho de verdade (`Código`/`Beneficiário`/`Matrícula`/`CPF`/
`Plano`/`Tipo`/`Idade`/`Dependência`/.../`Mensalidade`/`Total Família`) não
fica sempre na mesma linha (acima dele há linhas de título/contrato/fatura
cuja quantidade pode variar) — por isso `_localiza_cabecalho_xlsx` procura a
linha certa pelo NOME das colunas em vez de um número de linha fixo.
"Total Família" é só um subtotal por família (mesmo espírito do "Valor
Total" do PDF — ignorado, ver item 1 abaixo); o valor de cada linha vem da
coluna "Mensalidade". Um mesmo `Código` pode se repetir em várias linhas
(rubricas normais + devoluções/exclusões retroativas de competências
anteriores, cobradas na mesma fatura) — somadas por indivíduo, mesma regra
geral do pacote. "Tipo" já vem como 'T'/'D'/'A' direto do relatório
(confirmado: 'A' cobre exatamente avós/tios/sobrinhos/sogros/pai-mãe/irmãos
— os mesmos exemplos de "agregado" repassados pelo cliente para a regra de
custeio, ver `portal_api.planos_saude.regras_empresa`), então não precisa
de nenhuma inferência a partir da coluna "Dependência" (livre, só
informativa). CPF vem com máscara (pontos/traço) e como texto no arquivo
real — normalizado e preenchido com zeros à esquerda por segurança (mesmo
cuidado já necessário no relatório da MetLife, ver `operadoras/metlife/
odonto_mensalidade.py`).
Particularidades deste relatório (descobertas inspecionando o PDF real): Particularidades deste relatório (descobertas inspecionando o PDF real):
@ -42,11 +68,12 @@ agregados), R$ 1.630,93 no total — bate exatamente com os totais impressos
no próprio relatório. no próprio relatório.
""" """
import re import re
from typing import List, Tuple from typing import Dict, List, Tuple
import openpyxl
import pdfplumber import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento, normaliza_cpf, normaliza_nome
from portal_api.planos_saude.operadoras.base import OperadoraParser from portal_api.planos_saude.operadoras.base import OperadoraParser
_ROW_RE = re.compile(r'^\s*(?P<numero>\d{9})\s+(?P<resto>.+)$') _ROW_RE = re.compile(r'^\s*(?P<numero>\d{9})\s+(?P<resto>.+)$')
@ -69,10 +96,70 @@ def _valor_para_float(texto: str) -> float:
return -valor if negativo else valor return -valor if negativo else valor
# Nomes de coluna esperados no cabeçalho do Excel (898), já normalizados
# (maiúsculo, sem acento) via `normaliza_nome` — usado só pra ACHAR a linha
# de cabeçalho dentro do arquivo, não como chave de casamento.
_COLUNAS_XLSX_ESPERADAS = ("CODIGO", "BENEFICIARIO", "CPF", "TIPO", "MENSALIDADE")
class AmilOdontoMensalidade(OperadoraParser): class AmilOdontoMensalidade(OperadoraParser):
nome_operadora = "AMIL" nome_operadora = "AMIL"
chave_casamento = "cpf" # Amil manda CPF de todo mundo chave_casamento = "cpf" # Amil manda CPF de todo mundo
def _localiza_cabecalho_xlsx(self, ws: "openpyxl.worksheet.worksheet.Worksheet") -> Tuple[int, Dict[str, int]]:
"""Acha a linha de cabeçalho de verdade dentro das primeiras linhas
do relatório (acima dela há linhas de título/contrato/fatura cuja
quantidade varia, então não é seguro fixar um número de linha) —
procura pelo NOME das colunas esperadas (`_COLUNAS_XLSX_ESPERADAS`),
não pela posição. Devolve `(número da linha, {coluna: índice})`."""
for numero_linha, linha in enumerate(ws.iter_rows(min_row=1, max_row=20, values_only=True), start=1):
indices: Dict[str, int] = {}
for indice, valor in enumerate(linha):
if valor is None:
continue
indices[normaliza_nome(str(valor))] = indice
if all(coluna in indices for coluna in _COLUNAS_XLSX_ESPERADAS):
return numero_linha, indices
raise ValueError(
"Layout do Excel da Amil Odonto não reconhecido — cabeçalho "
"esperado (Código/Beneficiário/CPF/Tipo/Mensalidade) não "
"encontrado nas primeiras linhas do arquivo."
)
def _extrai_xlsx(self, caminho_xlsx: str) -> List[Lancamento]:
wb = openpyxl.load_workbook(caminho_xlsx, data_only=True, read_only=True)
ws = wb.worksheets[0]
linha_cabecalho, col = self._localiza_cabecalho_xlsx(ws)
lancamentos: List[Lancamento] = []
for linha in ws.iter_rows(min_row=linha_cabecalho + 1, values_only=True):
codigo = linha[col["CODIGO"]] if col["CODIGO"] < len(linha) else None
if codigo is None:
continue # linha em branco/rodapé, sem código de beneficiário
tipo = str(linha[col["TIPO"]] or "").strip() if col["TIPO"] < len(linha) else ""
if tipo not in ("T", "D", "A"):
continue # linha de rodapé/total (ex.: sem "Tipo" válido)
nome = str(linha[col["BENEFICIARIO"]] or "").strip() if col["BENEFICIARIO"] < len(linha) else ""
cpf_bruto = str(linha[col["CPF"]] or "") if col["CPF"] < len(linha) else ""
valor_bruto = linha[col["MENSALIDADE"]] if col["MENSALIDADE"] < len(linha) else 0
lancamentos.append(Lancamento(
numero_beneficiario=str(codigo),
nome=nome,
# CPF vem com máscara e como texto no arquivo real, mas
# `.zfill(11)` protege contra um export numérico futuro que
# perca o(s) zero(s) à esquerda (mesmo cuidado da MetLife,
# ver operadoras/metlife/odonto_mensalidade.py).
cpf=normaliza_cpf(cpf_bruto).zfill(11),
tipo=tipo,
rubrica="Mensalidade",
valor=float(valor_bruto or 0),
tipo_lancamento="mensalidade",
))
return lancamentos
def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]: def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]:
""" """
Extrai o texto preservando o layout de colunas (equivalente ao Extrai o texto preservando o layout de colunas (equivalente ao
@ -136,7 +223,10 @@ class AmilOdontoMensalidade(OperadoraParser):
return [individuos[c] for c in ordem] return [individuos[c] for c in ordem]
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]: def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
linhas = self._pdf_para_linhas(caminho_arquivo) if caminho_arquivo.lower().endswith((".xlsx", ".xlsm")):
lancamentos = self._parseia_lancamentos(linhas) lancamentos = self._extrai_xlsx(caminho_arquivo)
else:
linhas = self._pdf_para_linhas(caminho_arquivo)
lancamentos = self._parseia_lancamentos(linhas)
individuos = self._agrega_por_individuo(lancamentos) individuos = self._agrega_por_individuo(lancamentos)
return individuos, [] # Amil não gera itens de auditoria na extração return individuos, [] # Amil não gera itens de auditoria na extração

View File

@ -85,6 +85,38 @@ def _regra_unimed_1778_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_
_aplica_teto_familia(linhas_e_valores, teto=661.61) _aplica_teto_familia(linhas_e_valores, teto=661.61)
def _regra_amil_898_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
"""Amil Odonto (código 898) na Tecnomyl, repassada pelo cliente em
09/2026: a empresa custeia 100% da mensalidade de titular e dependente
direto (cônjuge, filho(a)); agregados — avós, tios, sobrinhos, sogros e
similares — têm a mensalidade 100% descontada do empregado, no MESMO
valor (a regra é só sobre quem paga, não sobre um teto/percentual, por
isso cobre os dois planos do contrato — E200 a R$ 20,81/pessoa e
"DENTAL 200" a R$ 16,01/pessoa — sem precisar de nenhum valor fixo
aqui). Confirmado contra o arquivo real da competência 08/2026: todo
titular/dependente/agregado paga exatamente o mesmo valor por pessoa
dentro do mesmo plano, e a discriminação titular/dependente/agregado do
relatório (coluna "Tipo": T/D/A) bate exatamente com os exemplos do
cliente (avós, sogros, sobrinhos = agregado).
Ao contrário da Tecnomyl-Unimed (`_aplica_teto_familia`, um teto por
família), esta regra é por PESSOA, sem nenhuma interação entre membros
da família — usa `linha.tipo_pessoa` ('T'/'D'/'A', gravado pelo
matcher no momento do casamento a partir do Individuo.tipo original do
arquivo da operadora — ver `LinhaSistema.tipo_pessoa` — já que a
planilha padrão do sistema não distingue dependente de agregado por si
só). Uma linha sem `tipo_pessoa` preenchido (nunca passou pelo
casamento automático — ex.: incluída manualmente via "Adicionar linha")
cai no fallback "D": custeada pela empresa, o mesmo comportamento que
já valeria pra ela antes desta regra existir."""
for linha, valor in linhas_e_valores:
valor = max(0.0, valor)
tipo_pessoa = getattr(linha, "tipo_pessoa", "") or "D"
eh_agregado = tipo_pessoa == "A"
linha.valor_empresa = formata_valor_br(0.0 if eh_agregado else valor)
linha.valor = formata_valor_br(valor if eh_agregado else 0.0)
def _regra_sulamerica_5775_ottimizza(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None: def _regra_sulamerica_5775_ottimizza(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None:
"""Ottimizza (código 1889) na SulAmérica (5775) — critério fixo, sem """Ottimizza (código 1889) na SulAmérica (5775) — critério fixo, sem
depender de teto/percentual: a mensalidade do titular é 100% custeada depender de teto/percentual: a mensalidade do titular é 100% custeada
@ -141,6 +173,22 @@ REGRAS_EMPRESA: Dict[str, dict] = {
"configurado nesta importação, sem relação com esta regra." "configurado nesta importação, sem relação com esta regra."
), ),
}, },
"amil_898_tecnomyl": {
"label": "1778 - Amil (Tecnomyl)",
"codigo_empresa": "1778",
"operadora": "amil_odonto_mensalidade_898",
"chave_casamento": "cpf",
"tipos_lancamento": ("mensalidade",),
"aplica": _regra_amil_898_tecnomyl,
"observacoes": (
"A Tecnomyl arca com o valor integral da mensalidade de titular e "
"dependente direto (cônjuge, filho(a)). Agregados — avós, tios, "
"sobrinhos, sogros e similares — têm a mensalidade 100% "
"descontada do empregado, no mesmo valor por pessoa. Cobre os "
"planos do contrato (E200 e DENTAL 200), cada um no seu próprio "
"valor de mensalidade."
),
},
"sulamerica_5775_ottimizza": { "sulamerica_5775_ottimizza": {
"label": "1889 - SulAmérica (5775)", "label": "1889 - SulAmérica (5775)",
"codigo_empresa": "1889", "codigo_empresa": "1889",

View File

@ -878,6 +878,7 @@ def _nome_base_arquivo_gerado_plano_saude(importacao: Any, todas_linhas: Iterabl
PLANO_SAUDE_CAMPOS_ALTERACAO = [ PLANO_SAUDE_CAMPOS_ALTERACAO = [
"codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial", "codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial",
"nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao", "nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao",
"tipo_pessoa",
] ]
@ -1163,6 +1164,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet):
valor_empresa=linha.valor_empresa, valor_empresa=linha.valor_empresa,
valor=linha.valor, valor=linha.valor,
descricao=linha.descricao, descricao=linha.descricao,
tipo_pessoa=linha.tipo_pessoa,
) )
for tipo, ordem, linha in linhas_specs for tipo, ordem, linha in linhas_specs
]) ])
@ -1512,10 +1514,15 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet):
# 100% desconto do empregado). Grava o valor bruto aqui primeiro # 100% desconto do empregado). Grava o valor bruto aqui primeiro
# (valor_empresa="0" é só um placeholder) e reaplica a regra em # (valor_empresa="0" é só um placeholder) e reaplica a regra em
# toda a família, que recupera o valor bruto de cada linha como # toda a família, que recupera o valor bruto de cada linha como
# valor_empresa + valor. # valor_empresa + valor. `tipo_pessoa` também precisa ser
# gravado aqui — esta linha nunca passou pelo casamento
# automático (senão não estaria em branco pra "Vincular
# pessoa"), então uma regra que distinga dependente de agregado
# (ex.: Amil/Tecnomyl) só sabe o tipo de `item.tipo`.
linha.valor_empresa = "0" linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor)) linha.valor = formata_valor_br(float(item.valor))
linha.save(update_fields=["valor_empresa", "valor"]) linha.tipo_pessoa = item.tipo
linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"])
_recalcula_familia_regra_empresa(item.importacao, linha) _recalcula_familia_regra_empresa(item.importacao, linha)
else: else:
regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {}) regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})
@ -1794,6 +1801,7 @@ class ImportacaoPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
valor_empresa=linha.valor_empresa, valor_empresa=linha.valor_empresa,
valor=linha.valor, valor=linha.valor,
descricao=linha.descricao, descricao=linha.descricao,
tipo_pessoa=linha.tipo_pessoa,
) )
for tipo, ordem, linha in linhas_specs for tipo, ordem, linha in linhas_specs
]) ])
@ -2061,9 +2069,12 @@ class ImportacaoPlanoSaudeDePaulaAuditoriaViewSet(viewsets.GenericViewSet):
regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",)) regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",))
) )
if regra_empresa_cobre_este_tipo: if regra_empresa_cobre_este_tipo:
# Ver ImportacaoPlanoSaudeAuditoriaViewSet.resolver — mesmo
# motivo pra gravar `tipo_pessoa` aqui.
linha.valor_empresa = "0" linha.valor_empresa = "0"
linha.valor = formata_valor_br(float(item.valor)) linha.valor = formata_valor_br(float(item.valor))
linha.save(update_fields=["valor_empresa", "valor"]) linha.tipo_pessoa = item.tipo
linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"])
_recalcula_familia_regra_empresa_de_paula(item.importacao, linha) _recalcula_familia_regra_empresa_de_paula(item.importacao, linha)
else: else:
regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {}) regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})