Reestruturação dos arquivos claude.md e correção da validação operadora amil - plano odontológico.

This commit is contained in:
Gabriel 2026-08-26 14:29:22 -03:00
parent 7e91ba5e60
commit 2efde773f7
13 changed files with 633 additions and 461 deletions

View File

@ -47,11 +47,11 @@ A ferramenta deixou de ser "a automação da TECNOMYL", hoje atende várias empr
| `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** | Nunca foi rodada nem uma vez dentro da ferramenta |
| `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 |
**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 nunca foi usada de verdade na ferramenta.** O parser existe, é o mesmo código portado do protótipo original, mas nenhuma empresa (TECNOMYL inclusive) passou um arquivo AMIL pela tela ainda. É o operador com menos garantia real de estar 100% certo hoje, mesmo sendo o mais simples dos 9.
**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.
### 3.2 Empresas com "Cadastro de Regras" salvo (13 empresas, 17 combinações empresa+operadora)

544
CLAUDE.md
View File

@ -10,6 +10,29 @@ Até uma rodada anterior, todo o estado (sessão, usuários, perfis de acesso, f
**O que continua só no `localStorage`**: apenas a preferência de tema (claro/escuro e cor do tema) — é preferência de navegador, não dado de negócio, e ficou fora do escopo da migração por decisão explícita do usuário.
## Documentação dividida por aplicação
Este arquivo cobre o que é **transversal** ao Portal (arquitetura, modelo de permissões, API, CSS, animações). A partir de 2026-08-26, a documentação detalhada de cada aplicação foi movida pra fora daqui, pra reduzir conflito de edição quando mais de uma pessoa mexe em aplicações diferentes ao mesmo tempo. Ver `prd.md` pra visão de produto (o quê/pra quem) e `plano.md` pra histórico de decisões.
Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente** pelo Claude Code ao trabalhar dentro da pasta):
| Aplicação | Onde |
|---|---|
| Importação de Plano de Saúde | `portal_api/planos_saude/CLAUDE.md` |
| Indicador de Desempenho | `portal_api/indicadores/CLAUDE.md` |
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/CLAUDE.md` |
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — arquivo em `docs/`, **não** é carregado automaticamente, ler manualmente):
| Aplicação | Onde |
|---|---|
| Ramais (diretório, Telefones Externos, Funções de Telefonia, modal de consulta rápida) | `docs/ramais.md` |
| Links & Ferramentas / Acessos Gerais | `docs/links-ferramentas-acessos-gerais.md` |
| Calendário Individual e Widgets (incl. Eventos Corporativos, feriados, widgets de `portal.html`) | `docs/calendario-individual.md` |
| Favoritos (grade de `portal.html`) | `docs/favoritos.md` |
| Perfis de Acesso / Usuários (telas administrativas, Liderança, inativação) | `docs/perfis-usuarios.md` |
| Solicitações | `docs/solicitacoes.md` |
## Como rodar / testar localmente
### Backend (obrigatório para qualquer teste agora — o frontend não funciona mais sozinho via `file://`/`http.server`)
@ -67,8 +90,8 @@ Duas identidades visuais coexistem **de propósito** hoje: o logo cursivo "D De
**Logo cursivo "D De Paula Contadores"** (D em degradê dourado/marrom + texto, PNG com fundo transparente) — não aparece em nenhum template HTML hoje, só nos PDFs gerados pela aplicação:
- `logo.png` — original, texto **preto**. Serve como fonte pra gerar as outras variantes e é usada diretamente no recibo do Indicador de Desempenho (`indicadores/recibo.py`, `LOGO_PATH`, redimensionada/recomprimida em memória pra impressão — ver "Indicador de Desempenho" abaixo).
- `logo-branco.png` — usada no cabeçalho do PDF de Simulação de Custo de Contratação (`custo_contratacao/pdf.py`, banner marrom escuro, ver "Simulação de Custo de Contratação" abaixo) — até uma rodada anterior também era usada no `sidebar__brand` dos 10 shells, migrada pra marca "P.I.D." (ver abaixo; a UI do Portal e os documentos gerados usam fontes de logo independentes agora). Mesmo D colorido de `logo.png`, mas com o texto recolorido pra branco; gerada programaticamente a partir de `logo.png` (script Python com Pillow: qualquer pixel opaco quase-neutro/escuro — `max(r,g,b) < 70` e `spread(r,g,b) < 12` — virou branco; o D nunca entra nesse filtro porque mesmo na sombra mais escura do degradê ele mantém um matiz quente nitidamente não-neutro). Se o logo oficial mudar, regerar `logo-branco.png` a partir do novo `logo.png` com o mesmo filtro, não editar à mão.
- `logo.png` — original, texto **preto**. Serve como fonte pra gerar as outras variantes e é usada diretamente no recibo do Indicador de Desempenho (`indicadores/recibo.py`, `LOGO_PATH`, redimensionada/recomprimida em memória pra impressão — ver `portal_api/indicadores/CLAUDE.md`).
- `logo-branco.png` — usada no cabeçalho do PDF de Simulação de Custo de Contratação (`custo_contratacao/pdf.py`, banner marrom escuro, ver `portal_api/custo_contratacao/CLAUDE.md`) — até uma rodada anterior também era usada no `sidebar__brand` dos 10 shells, migrada pra marca "P.I.D." (ver abaixo; a UI do Portal e os documentos gerados usam fontes de logo independentes agora). Mesmo D colorido de `logo.png`, mas com o texto recolorido pra branco; gerada programaticamente a partir de `logo.png` (script Python com Pillow: qualquer pixel opaco quase-neutro/escuro — `max(r,g,b) < 70` e `spread(r,g,b) < 12` — virou branco; o D nunca entra nesse filtro porque mesmo na sombra mais escura do degradê ele mantém um matiz quente nitidamente não-neutro). Se o logo oficial mudar, regerar `logo-branco.png` a partir do novo `logo.png` com o mesmo filtro, não editar à mão.
- `logo-mono.png` — versão totalmente monocromática (D **e** texto em branco/cinza claro). Não usada em nenhum consumidor hoje; existe como variante alternativa (útil se algum dia precisar de um logo "chapado" sem o dourado do D).
**Marca nova "P.I.D."**:
@ -97,17 +120,17 @@ Um único app, `portal_api/`:
| Arquivo | Conteúdo |
|---|---|
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo, inclusive "Vínculos de nome salvos (DE/PARA)"). |
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver `docs/perfis-usuarios.md`) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver `docs/links-ferramentas-acessos-gerais.md`), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver `docs/ramais.md`), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver `portal_api/planos_saude/CLAUDE.md`, inclusive "Vínculos de nome salvos (DE/PARA)"). |
| `catalogo.py` | Fonte única da verdade do catálogo de módulos/aplicações do menu (`MODULES`, `MODULE_APPS`) — exposto só leitura via `GET /api/catalogo/`. Ao adicionar uma seção/aplicação nova ao menu, editar **aqui**, não em `static/js/profiles.js` (que só cacheia o payload recebido). |
| `serializers.py` | `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita, aceita `senha`+`perfis`+`departamentos`+`liderados`)/`UsuarioListSerializer` (leitura, `perfis`/`departamentos`/`liderados` aninhados), `CompromissoAgendaSerializer` (`sou_dono`, `dono_nome`, `dono_username`), `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `LinkFerramentaSerializer`, `LinkFerramentaFavoritoSerializer`, `AcessoGeralSecaoSerializer`, `AcessoGeralSerializer`, `RamalSerializer` (só das linhas avulsas — ver seção "Ramais"), `RamalAusenciaSerializer`, `TelefoneExternoSerializer`, `FuncaoTelefoniaSerializer`, `ImportacaoPlanoSaudeCreateSerializer`/`ImportacaoPlanoSaudeListSerializer`/`ImportacaoPlanoSaudeDetailSerializer`/`ImportacaoPlanoSaudeLinhaSerializer`/`ImportacaoPlanoSaudeAuditoriaSerializer` (ver seção "Importação de Plano de Saúde"). |
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). |
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver `docs/links-ferramentas-acessos-gerais.md`/`docs/ramais.md`). |
| `views.py` | `login_view`/`logout_view`/`csrf_view` (auth por sessão), `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view` (ver seção "Liderança"), `departamentos_resumo_view` (ver "Ramais"), `catalogo_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas/links e ferramentas/favoritos de links e ferramentas/seções e linhas de Acessos Gerais/ramais/ausências de ramal/importações de plano de saúde e suas linhas. |
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
| `management/commands/seed_portal.py` | Recria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo` (ver nota abaixo). |
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente) com os valores da planilha antiga — ver seção "Indicador de Desempenho" abaixo. |
| `planos_saude/` | Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de `projects/project/` — ver seção própria abaixo. |
| `custo_contratacao/` | Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (seed/default das faixas de INSS/IRRF, hoje editáveis via `ParametroFiscalCustoContratacao`), `calculo.py` (`ParametrosFiscais` dataclass + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`). Ver seção própria abaixo. |
| `indicadores/` | Pacote Python puro (sem ORM) da ferramenta "Indicador de Desempenho" (Geradoc) — `tipos.py` (deriva o tipo de colaborador por empresa via Tareffa), `leiaute.py` (leitura das planilhas Tareffa/Honorários via `openpyxl`), `pipeline.py` (orquestração, `processa_apuracao`), `entregas.py` (cálculo dos 3 critérios automáticos), `calculo.py` (composição dos percentuais Individual/Grupo/Departamento e valores em R$), `recibo.py` (PDF do recibo por colaborador, via `reportlab`). Ver seção própria abaixo. |
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente) com os valores da planilha antiga — ver `portal_api/indicadores/CLAUDE.md`. |
| `planos_saude/` | Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de `projects/project/` — ver `portal_api/planos_saude/CLAUDE.md`. |
| `custo_contratacao/` | Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (seed/default das faixas de INSS/IRRF, hoje editáveis via `ParametroFiscalCustoContratacao`), `calculo.py` (`ParametrosFiscais` dataclass + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`). Ver `portal_api/custo_contratacao/CLAUDE.md`. |
| `indicadores/` | Pacote Python puro (sem ORM) da ferramenta "Indicador de Desempenho" (Geradoc) — `tipos.py` (deriva o tipo de colaborador por empresa via Tareffa), `leiaute.py` (leitura das planilhas Tareffa/Honorários via `openpyxl`), `pipeline.py` (orquestração, `processa_apuracao`), `entregas.py` (cálculo dos 3 critérios automáticos), `calculo.py` (composição dos percentuais Individual/Grupo/Departamento e valores em R$), `recibo.py` (PDF do recibo por colaborador, via `reportlab`). Ver `portal_api/indicadores/CLAUDE.md`. |
### API (sessão + CSRF, não token)
@ -118,53 +141,53 @@ Um único app, `portal_api/`:
| `/api/auth/logout/` | POST | encerra sessão |
| `/api/me/` | GET | usuário logado + `perfis` + `departamentos` (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + `gerencia_permissoes` + `eh_perfil_inovacao` (perfil "Inovação" vinculado, ver "Ajuda de aplicação" abaixo) + `permissoes_efetivas` (união já calculada no servidor) |
| `/api/me/senha/` | POST | `{senha_atual, nova_senha}` |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver seção "Liderança") |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver `docs/perfis-usuarios.md`) |
| `/api/catalogo/` | GET | módulos/aplicações/subgrupos do menu |
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver "Feriados no Calendário Individual" abaixo |
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver `docs/calendario-individual.md` |
| `/api/usuarios-resumo/` | GET | lista enxuta (`id`/`nome`) de usuários ativos — alimenta o seletor de liderados, sem exigir `gerencia_permissoes` (mesmo padrão de `/api/ramais/usuarios/`) |
| `/api/departamentos-resumo/` | GET | lista enxuta (`id`/`nome`) de departamentos — alimenta os botões de filtro do modal de consulta rápida de Ramais, exige só `ramais-visualizar` (não `gerencia_permissoes` como `/api/departamentos/`) |
| `/api/ajuda-aplicacoes/<app_key>/` | GET/PATCH | texto de "Mais informações" de uma aplicação (ver seção própria abaixo); GET livre a qualquer autenticado, PATCH exige `eh_perfil_inovacao` (perfil "Inovação", checagem de nome fixo, não uma flag em Perfis de Acesso) |
| `/api/perfis/`, `/api/perfis/{codigo}/` | GET/POST/PUT/DELETE | CRUD de perfil — só quem tem `gerencia_permissoes` |
| `/api/departamentos/`, `/api/departamentos/{id}/` | GET/POST/PUT/DELETE | CRUD de departamento — só quem tem `gerencia_permissoes`; usado pela tela de Usuários pra listar o checklist e cadastrar um novo departamento inline (sem tela própria) |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver "Inativar usuário" abaixo) |
| `/api/compromissos/`, `/api/compromissos/{id}/` | GET/POST/PATCH/DELETE | GET já retorna só o que o usuário logado pode ver (próprios + `visibilidade="todos"` + `visibilidade="departamento"` com departamento em comum); campo `notificar_em` (calculado, ver "Calendário Individual e Widgets") indica quando o lembrete passa a valer; criar/editar com `visibilidade` em `departamento`/`todos` (inclusive todo `eh_evento=True`, que força `visibilidade="todos"`) exige `apps["calendario-individual-criar-evento"]` (ver "Eventos Corporativos" abaixo) |
| `/api/categorias-evento/`, `/api/categorias-evento/{id}/` | GET/POST/PATCH/DELETE | cadastro de categorias de evento (`nome`+`cor`) usado pelo Calendário Individual; GET livre a qualquer autenticado, escrita exige `apps["calendario-individual-criar-evento"]` — ver "Eventos Corporativos" abaixo |
| `/api/favoritos/`, `/api/favoritos/{app_id}/` | GET/POST/PATCH/DELETE | chave natural é `app_id`, não um id numérico; `ordem` é gravável via PATCH (drag-and-drop na grade de favoritos, ver "Favoritos" abaixo) |
| `/api/widgets/`, `/api/widgets/{tipo}/` | GET/POST/PATCH/DELETE | chave natural é `tipo`; `ordem` (reordenar por drag-and-drop) e `largura`/`altura` em px (redimensionamento) também são graváveis via PATCH — ver "Calendário Individual e Widgets" abaixo |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver `docs/perfis-usuarios.md`) |
| `/api/compromissos/`, `/api/compromissos/{id}/` | GET/POST/PATCH/DELETE | GET já retorna só o que o usuário logado pode ver (próprios + `visibilidade="todos"` + `visibilidade="departamento"` com departamento em comum); campo `notificar_em` (calculado, ver `docs/calendario-individual.md`) indica quando o lembrete passa a valer; criar/editar com `visibilidade` em `departamento`/`todos` (inclusive todo `eh_evento=True`, que força `visibilidade="todos"`) exige `apps["calendario-individual-criar-evento"]` (ver "Eventos Corporativos" abaixo) |
| `/api/categorias-evento/`, `/api/categorias-evento/{id}/` | GET/POST/PATCH/DELETE | cadastro de categorias de evento (`nome`+`cor`) usado pelo Calendário Individual; GET livre a qualquer autenticado, escrita exige `apps["calendario-individual-criar-evento"]` — ver `docs/calendario-individual.md` |
| `/api/favoritos/`, `/api/favoritos/{app_id}/` | GET/POST/PATCH/DELETE | chave natural é `app_id`, não um id numérico; `ordem` é gravável via PATCH (drag-and-drop na grade de favoritos, ver `docs/favoritos.md`) |
| `/api/widgets/`, `/api/widgets/{tipo}/` | GET/POST/PATCH/DELETE | chave natural é `tipo`; `ordem` (reordenar por drag-and-drop) e `largura`/`altura` em px (redimensionamento) também são graváveis via PATCH — ver `docs/calendario-individual.md` |
| `/api/notificacoes-dispensadas/`, `/api/notificacoes-dispensadas/{notif_id}/` | GET/POST/DELETE | chave natural é `notif_id` (ex.: `"tool-widgets"`, `"event-42"`); `notifications.js` usa GET pra filtrar o que já foi dispensado e POST a cada X/"Limpar tudo" |
| `/api/links-ferramentas/`, `/api/links-ferramentas/{id}/` | GET/POST/PATCH/DELETE | lista **compartilhada** (não por usuário); leitura exige `apps["links-ferramentas-visualizar"]` e escrita exige `apps["links-ferramentas-editar"]` em `permissoes["links-ferramentas"]` (`PermissaoApp`, gate por método em `get_permissions()` — ver "Modelo de permissões" abaixo); POST é `multipart/form-data` (aceita upload de `icone`); `ordem` sempre é atribuída pelo servidor na criação (ignora o que vier no payload), reordenar é PATCH trocando o `ordem` de dois itens |
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado (ver seção "Links & Ferramentas" abaixo) |
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário (ver "Acessos Gerais" abaixo) |
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) — ver seção "Acessos Gerais" abaixo |
| `/api/ramais/` | GET | diretório **mesclado**: todo usuário ativo (linha montada do cadastro, sem precisar de nenhum registro extra) + linhas avulsas de `Ramal`; leitura exige `apps.visualizar` — ver seção "Ramais" abaixo |
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado (ver `docs/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário (ver `docs/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) — ver `docs/links-ferramentas-acessos-gerais.md` |
| `/api/ramais/` | GET | diretório **mesclado**: todo usuário ativo (linha montada do cadastro, sem precisar de nenhum registro extra) + linhas avulsas de `Ramal`; leitura exige `apps.visualizar` — ver `docs/ramais.md` |
| `/api/ramais/`, `/api/ramais/{id}/` | POST/PATCH/DELETE | CRUD só das linhas avulsas (`Ramal`, sem `Usuario` por trás); escrita exige `apps.editar` |
| `/api/ramais/usuarios/` | GET | lista enxuta (`id`/`nome`) de usuários ativos pra alimentar o `<select>` "Lista de Usuários" do modal de Criar Ausência — não é `/api/usuarios/` de propósito (ver seção "Ramais") |
| `/api/ramais/usuarios/` | GET | lista enxuta (`id`/`nome`) de usuários ativos pra alimentar o `<select>` "Lista de Usuários" do modal de Criar Ausência — não é `/api/usuarios/` de propósito (ver `docs/ramais.md`) |
| `/api/ramais/usuarios/{usuario_id}/` | PATCH | `{numero}` — grava direto em `Usuario.ramal`; é como a tela edita o ramal de um colaborador de verdade (exige `apps.editar`) |
| `/api/ramais-ausencias/`, `/api/ramais-ausencias/{id}/` | GET/POST/PATCH/DELETE | períodos de ausência; "ausente agora" nunca é lido daqui direto pelo frontend, vem calculado em `usuario_ausente`/`usuario_ausencia_ativa_id` na listagem de `/api/ramais/`; `PATCH` com `{"encerrada_manualmente": true}` encerra antes do previsto |
| `/api/telefones-externos/`, `/api/telefones-externos/{id}/` | GET/POST/PATCH/DELETE | subtela "Telefones Externos" de `ramais.html`; mesma permissão `PermissaoApp("ramais", ...)` do diretório de Ramais |
| `/api/funcoes-telefonia/`, `/api/funcoes-telefonia/{id}/` | GET/POST/PATCH/DELETE | subtela "Funções de Telefonia" de `ramais.html`; idem, mesma permissão de `ramais`; as 13 linhas padrão vêm de `seed_portal` |
| `/api/importacoes-plano-saude/`, `/api/importacoes-plano-saude/{id}/` | GET/POST | histórico + criação (ver seção "Importação de Plano de Saúde"); `PermissaoApp("utilitarios", "importacao-plano-saude")` (toggle único) pra todos os métodos; POST é `multipart/form-data` (planilha padrão + arquivo da operadora) e roda o pipeline de forma síncrona antes de responder |
| `/api/importacoes-plano-saude/`, `/api/importacoes-plano-saude/{id}/` | GET/POST | histórico + criação (ver `portal_api/planos_saude/CLAUDE.md`); `PermissaoApp("utilitarios", "importacao-plano-saude")` (toggle único) pra todos os métodos; POST é `multipart/form-data` (planilha padrão + arquivo da operadora) e roda o pipeline de forma síncrona antes de responder |
| `/api/importacoes-plano-saude/operadoras/` | GET | `[{key, label}]` das operadoras registradas em `planos_saude.pipeline.OPERADORAS` — alimenta o `<select>` do formulário |
| `/api/importacoes-plano-saude/regras-empresa/` | GET | `[{key, label}]` das regras especiais registradas em `planos_saude.regras_empresa.REGRAS_EMPRESA` — alimenta o modal "Selecionar regra" do checkbox "Regra empresa" (ver seção própria abaixo) |
| `/api/importacoes-plano-saude/regras-empresa/` | GET | `[{key, label}]` das regras especiais registradas em `planos_saude.regras_empresa.REGRAS_EMPRESA` — alimenta o modal "Selecionar regra" do checkbox "Regra empresa" (ver `portal_api/planos_saude/CLAUDE.md`) |
| `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` |
| `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) |
| `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo |
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (`codigo_empresa`+`operadora`, únicos juntos+`regra_empresa_chave`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`; `nome` é sempre derivado, nunca aceito do cliente — ver "Cadastro de Regras" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" |
| `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo |
| `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver `portal_api/planos_saude/CLAUDE.md`) |
| `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver `portal_api/planos_saude/CLAUDE.md` |
| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (`codigo_empresa`+`operadora`, únicos juntos+`regra_empresa_chave`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`; `nome` é sempre derivado, nunca aceito do cliente — ver `portal_api/planos_saude/CLAUDE.md`) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" |
| `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver `portal_api/custo_contratacao/CLAUDE.md` |
| `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado |
| `/api/indicadores-percentuais-tipo/` | GET/POST/DELETE | histórico de percentuais individual/grupo/departamento por tipo de colaborador (`IndicadorPercentualTipo`) — nunca editado in-place, só criado com `vigente_desde` novo; mesma permissão de toggle único `apps["indicador-desempenho"]` em `permissoes["geradoc"]` |
| `/api/indicadores-criterios/`, `/api/indicadores-criterios/{id}/` | GET/POST/PATCH/DELETE | CRUD do cadastro genérico de critérios (`IndicadorCriterio`) — nome/grupo/peso/período/papel/cálculo automático livres, editável pelo RH |
| `/api/indicadores-apuracoes/`, `/api/indicadores-apuracoes/{id}/` | GET/POST/DELETE | apuração mensal (`IndicadorApuracao`); POST é multipart (2 planilhas) e roda `indicadores.pipeline.processa_apuracao()` de forma síncrona dentro de um `transaction.atomic()`, persistindo colaboradores/empresas/respostas já calculados; DELETE também apaga os 2 arquivos de `MEDIA_ROOT` |
| `/api/indicadores-apuracoes/{id}/gerar/` | POST | gera um ZIP com um PDF de recibo por colaborador (`indicadores.recibo.gera_pdf_recibo`), a partir do que já está salvo (não reprocessa as planilhas); `colaborador_ids` opcional no corpo restringe a geração a só esses colaboradores (modal "Gerar Recibos" — um colaborador só, alguns específicos, por departamento ou todos); marca a apuração como `concluida` só quando a seleção cobre **todos** os colaboradores |
| `/api/indicadores-apuracoes/{id}/ajustar-grupo/`, `/recalcular-grupo/` | POST | ajusta (ou reverte) o `pct_grupo` de **todos** os colaboradores de um mesmo `gerente` na apuração de uma vez — "cada gerente representa um grupo" (ver seção própria abaixo) |
| `/api/indicadores-apuracoes/{id}/ajustar-departamento/`, `/recalcular-departamento/` | POST | idem, mas aplica a **todos** os colaboradores do `departamento` (id de um `IndicadorDepartamento`) informado no corpo (`{departamento, pct_departamento}`/`{departamento}`) — cada departamento tem sua própria meta de Departamento, ver "Departamento organizacional" abaixo |
| `/api/indicadores-departamentos/`, `/api/indicadores-departamentos/{id}/` | GET/POST/PATCH/DELETE | cadastro de departamentos (`IndicadorDepartamento`, nome/ativo) — mesma permissão de toggle único do Indicador de Desempenho, ver "Departamento organizacional" abaixo |
| `/api/indicadores-departamentos-gerentes/`, `/api/indicadores-departamentos-gerentes/{id}/` | GET/POST/PATCH/DELETE | relação gerente→departamento (`IndicadorDepartamentoGerente`, `nome_gerente` único) — mesma permissão, ver "Departamento organizacional" abaixo |
| `/api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` | POST | `{codigo_empresa, honorario}` — preenche (ou corrige) o honorário de uma empresa com `honorario_nao_encontrado=True` ou `honorario_ajustado_manualmente=True` de uma vez pra **todos** os colaboradores desta apuração que a têm (mesmo código), recalculando cada um (ver "Empresas sem Honorário"/"Empresas Ajustadas Manualmente" abaixo) |
| `/api/indicadores-apuracoes/{id}/ajustar-grupo/`, `/recalcular-grupo/` | POST | ajusta (ou reverte) o `pct_grupo` de **todos** os colaboradores de um mesmo `gerente` na apuração de uma vez — "cada gerente representa um grupo" (ver `portal_api/indicadores/CLAUDE.md`) |
| `/api/indicadores-apuracoes/{id}/ajustar-departamento/`, `/recalcular-departamento/` | POST | idem, mas aplica a **todos** os colaboradores do `departamento` (id de um `IndicadorDepartamento`) informado no corpo (`{departamento, pct_departamento}`/`{departamento}`) — cada departamento tem sua própria meta de Departamento, ver `portal_api/indicadores/CLAUDE.md` |
| `/api/indicadores-departamentos/`, `/api/indicadores-departamentos/{id}/` | GET/POST/PATCH/DELETE | cadastro de departamentos (`IndicadorDepartamento`, nome/ativo) — mesma permissão de toggle único do Indicador de Desempenho, ver `portal_api/indicadores/CLAUDE.md` |
| `/api/indicadores-departamentos-gerentes/`, `/api/indicadores-departamentos-gerentes/{id}/` | GET/POST/PATCH/DELETE | relação gerente→departamento (`IndicadorDepartamentoGerente`, `nome_gerente` único) — mesma permissão, ver `portal_api/indicadores/CLAUDE.md` |
| `/api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` | POST | `{codigo_empresa, honorario}` — preenche (ou corrige) o honorário de uma empresa com `honorario_nao_encontrado=True` ou `honorario_ajustado_manualmente=True` de uma vez pra **todos** os colaboradores desta apuração que a têm (mesmo código), recalculando cada um (ver `portal_api/indicadores/CLAUDE.md`) |
| `/api/indicadores-apuracoes-colaboradores/{id}/` | GET/PATCH | ajuste manual do `pct_individual` de um colaborador (`pct_individual_ajustado_manualmente=True`); recalcula `valor_total` via `indicadores.calculo.recalcula_colaborador` |
| `/api/indicadores-apuracoes-colaboradores/{id}/recalcular/` | POST | reverte `pct_individual` pro modo automático (limpa o ajuste manual) e recalcula |
| `/api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` | POST | `{validado}` — checklist de revisão do RH, só grava o campo, sem recalcular nada (ver "Checklist de revisão do RH" abaixo) |
| `/api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` | POST | `{colaborador_id}` — reatribui essa linha (empresa+tipo) pra outro colaborador da mesma apuração, recalculando os dois (ver "Corrigir Responsável" abaixo) |
| `/api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` | POST | `{validado}` — checklist de revisão do RH, só grava o campo, sem recalcular nada (ver `portal_api/indicadores/CLAUDE.md`) |
| `/api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` | POST | `{colaborador_id}` — reatribui essa linha (empresa+tipo) pra outro colaborador da mesma apuração, recalculando os dois (ver `portal_api/indicadores/CLAUDE.md`) |
| `/api/indicadores-apuracoes-empresas/{id}/` | GET/PATCH | preenchimento manual do `honorario` de uma empresa com `honorario_nao_encontrado=True` (código não casou com a planilha de Honorários Por Cliente); zera essa flag e recalcula o colaborador |
| `/api/indicadores-apuracoes-respostas/{id}/` | GET/PATCH | edição de uma resposta de critério (SIM/NÃO/NÃO FAZ/NÃO SE APLICA) já existente; recalcula o colaborador |
| `/api/indicadores-apuracoes-respostas/aplicar-em-lote/` | POST | `{resposta_ids, valor}` — aplica o mesmo valor a várias respostas de uma vez (seleção múltipla da tela de revisão), recalculando todos os colaboradores afetados |
@ -188,14 +211,14 @@ Um único app, `portal_api/`:
| `calendario-individual.html` | Agenda pessoal: grade mensal + modal de criar/editar compromisso. |
| `perfis-acesso.html` | CRUD de perfis de acesso (lista + edição com abas Permissões/Usuários do Escritório). |
| `usuarios.html` | CRUD de contas de usuário (lista + edição com checklist de perfis). |
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver seção própria abaixo). |
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver seção "Acessos Gerais" abaixo). |
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver seção "Ramais" abaixo). |
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver seção "Importação de Plano de Saúde" abaixo). |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver seção "Simulação de Custo de Contratação" abaixo). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver seção própria abaixo). |
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver `docs/links-ferramentas-acessos-gerais.md`). |
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver `docs/links-ferramentas-acessos-gerais.md`). |
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver `docs/ramais.md`). |
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver `portal_api/planos_saude/CLAUDE.md`). |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver "Favoritos" abaixo) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver "Solicitações" abaixo) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
## Ordem de `<script>` e por que ela não quebra nada
@ -261,7 +284,7 @@ Alguns módulos não são só "lista de aplicações que aparecem ou não no men
],
```
`links-ferramentas` nasceu com só um par `visualizar`/`editar` flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que `ramais` já tinha passado antes (ver "Navegação por abas em `ramais.html`" abaixo). Cada chave de `tool` é prefixada com o nome da aplicação (`links-ferramentas-visualizar`, não só `visualizar`) porque `permissoes[module_key]["apps"]` é um dict **achatado** — todas as `tools` de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
`links-ferramentas` nasceu com só um par `visualizar`/`editar` flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que `ramais` já tinha passado antes (ver "Navegação por abas em `ramais.html`" em `docs/ramais.md`). Cada chave de `tool` é prefixada com o nome da aplicação (`links-ferramentas-visualizar`, não só `visualizar`) porque `permissoes[module_key]["apps"]` é um dict **achatado** — todas as `tools` de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`renderEntry()`/`renderLeaf()` em `profiles.js`, sem nenhum código de UI novo) — na tela de edição de perfil, "Links & Ferramentas" aparece expansível com "Links & Ferramentas" e "Acessos Gerais" como subgrupos, cada um expansível de novo em "Visualizar"/"Editar", do mesmo jeito que "Auditorias" mostra "Consultoria Tributária" → "Controle Simples Nacional". No backend, a checagem usa `Usuario.permissao_app(module_key, app_key)` (união entre os perfis vinculados, mesma lógica de `permissoes_efetivas()`) e a classe genérica `PermissaoApp(module_key, app_key)` em `permissions.py`, instanciada por view — nenhuma subclasse nova é necessária pra outro módulo/aplicação adotar o mesmo padrão, só instanciar com outra `app_key`.
@ -269,25 +292,7 @@ Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`r
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
### Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)
Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre `#pa-app-search-modal` — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia **todos os perfis** que têm acesso a ela numa tabela só.
- **Fonte dos dados — 100% reaproveitado, sem endpoint novo**: `pidFlattenAplicacoes()` (`profiles.js`) achata `PID_MODULES` × `PID_MODULE_APPS` (já carregados de `GET /api/catalogo/` no load da página) numa lista plana de "aplicações" — uma por entrada de `MODULE_APPS`, seja ela um app simples ou um subgrupo com `tools` (mesma unidade usada em `renderEntry()` da árvore). A busca (`renderAppSearchResults()`) casa o termo contra o label da aplicação, o label do módulo **e** o label de cada `tool` aninhada — esse último é o que permite achar, por exemplo, "Controle Simples Nacional" (o `tool` real dentro do subgrupo "Consultoria Tributária" de Auditorias) mesmo a unidade selecionável sendo o subgrupo inteiro.
- **Painel de gerenciamento** (`renderAppManageTable()`): ao clicar num resultado, mostra uma tabela com uma linha por perfil (`profiles`, o mesmo array já carregado pela tela) e uma coluna de checkbox por `tool` — para app simples sem subgrupo, uma coluna única "Acesso". O cabeçalho de cada coluna usa `tool.label.split(" (")[0]` (corta o parêntese explicativo tipo "Editar (reordenar, incluir e remover cartões)" → "Editar"), sem precisar de um label curto dedicado no catálogo.
- **Salva na hora, por checkbox** (decisão explícita do usuário — sem botão "Salvar" no popup): cada `change` dispara `PATCH /api/perfis/{codigo}/` só com `{permissoes: perfil.permissions}` (`pidUpdatePerfilPermissoes`, PATCH parcial — o `ModelViewSet` já aceita, `PerfilAcessoSerializer` não exige os outros campos fora de `partial_update`). Erro de rede reverte o checkbox e o estado em memória, com um `alert()` simples (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário).
- **Conceder acesso habilita o módulo automaticamente** (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com `enabled=false` naquele perfil, o toggle também vira `perm.enabled = true` no mesmo PATCH — sem isso, o perfil ganharia a chave em `apps` mas o item continuaria escondido no menu (`access.js` esconde o `nav-group`/`nav-subitem` inteiro por `enabled`, não só por app). **Revogar não desabilita o módulo de volta** (outras aplicações dele podem seguir em uso por aquele perfil).
- Perfis inativos (`ativo=False`) aparecem na tabela com o selo `.status-pill--inativo` (mesmo componente da coluna "Status" de `usuarios.html`), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.
### Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla
Substituiu o antigo `<select>` + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).
- **Duas tabelas** (`.pa-users-dual`, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda, `#pa-users-available-*` — todo usuário **ativo** ainda não vinculado a este perfil; à direita, `#pa-users-linked-*` — todo usuário **ativo** já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (`listaParaPainelUsuarios()` filtra `usuariosCacheAtual` por `is_active` antes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa os `perfis` de alguém no backend (`_revogar_acesso_se_inativo()`, ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (`#pa-users-available-select-all`/`#pa-users-linked-select-all`, aplica só sobre as linhas **filtradas** visíveis, mesmo critério de `.checklist-select-all`), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone `↕` que fica `--accent` quando ativo — mesmo padrão `.ua-sort-icon` já usado em `usuarios.html`) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (`.pa-users-table__filters`) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce.
- **Botão de atualizar** (ícone circular, `#pa-users-available-refresh-btn`/`#pa-users-linked-refresh-btn`) refaz `GET /api/usuarios/` (`refreshUsuarios()`, `profiles.js`) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra.
- **Vincular/Desvincular em lote**: o botão de cada painel (`#pa-users-link-btn`/`#pa-users-unlink-btn`, desabilitado enquanto a seleção daquele painel estiver vazia) dispara `bulkAlterarVinculo(kind, vincular)` — um `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`) por usuário selecionado, em paralelo (`Promise.all`), cada um recalculando a própria lista de `perfis` (adiciona ou remove só o `codigo` do perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (`refreshUsuarios()`) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa.
- Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
- Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia).
Duas telas administrativas por cima desse modelo (popup "Nova Aplicação" de `perfis-acesso.html`, aba "Usuários do Escritório") estão documentadas em `docs/perfis-usuarios.md`, não aqui.
## Ajuda de aplicação ("Mais informações")
@ -312,432 +317,55 @@ Botão "?" (`.info-tooltip`, `components.css`) ao lado do nome de uma aplicaçã
- **Só ligado em Importação de Plano de Saúde por ora** (`ips-ajuda-btn` em `importacao-plano-saude.html`, ao lado do `<h2>` dentro de `.ips-title-row`) — o mecanismo (model/endpoint/JS) já é genérico o bastante pra outra aplicação nova só precisar do botão+tooltip no HTML e uma chamada a `pidCriarBotaoAjuda()`, sem nenhum código novo no backend.
- Texto inicial de Importação de Plano de Saúde já estruturado (objetivo/como funciona/cuidados necessários/resultado esperado, em HTML simples — `<p>`/`<strong>`/`<ul>`/`<li>`) e salvo direto no banco, pronto pra revisão/edição do usuário pela própria tela (perfil Inovação).
**`seed_portal.py` não reseta mais `nome` de um perfil já existente** (bug real corrigido nesta rodada, motivado pelo usuário ter renomeado o perfil "Integração e Inovação" código 8 pra "Inovação" — e criado um perfil novo "Integração", código 9): `update_or_create(codigo=..., defaults={"nome": ..., ...})` reescrevia `nome` a cada execução, revertendo qualquer renomeação feita depois pela tela de Perfis de Acesso. Trocado por `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação) + atribuição direta de `ativo`/`gerencia_permissoes`/`permissoes` a cada execução (esses continuam sendo realinhados sempre — é assim que o seed serve pra corrigir uma árvore de permissões corrompida/desatualizada, só `nome` parou de ser tocado). Mesmo espírito de "só inicializa na primeira criação" já usado pra gabriel/bruno.
A inativação/reativação de usuário (`is_active`, incluindo o desvínculo automático de perfis/liderança), a Liderança (gerente/coordenador) e o bug do `seed_portal.py` não resetar mais `nome` de um perfil já existente (ver `[[feedback_seed_nao_reseta_gabriel_bruno]]` na memória) estão documentados em `docs/perfis-usuarios.md`.
## Inativar/reativar usuário (usuarios.html)
## Favoritos
Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:
Grade de aplicações favoritadas na tela Principal (`portal.html`) — o usuário marca itens do menu como favoritos e reordena os cards por drag-and-drop. O ID de cada favorito é derivado da própria estrutura do menu, não de um cadastro à parte.
- **Login novo**: `authenticate()` (usado em `login_view`) roda via `django.contrib.auth.backends.ModelBackend`, que internamente chama `user_can_authenticate()` e recusa (`None`) qualquer usuário com `is_active=False`, mesmo com a senha certa. Como isso faz `authenticate()` retornar `None` tanto pra senha errada quanto pra usuário inativo, `login_view` faz uma checagem manual **só no caminho de falha** (`Usuario.objects.filter(username=username, is_active=False).first()` + `check_password()`) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe.
- **Sessão já aberta**: também não precisa de nenhum middleware/permissão customizado — `ModelBackend.get_user(user_id)` (chamado pelo Django a cada request pra popular `request.user` a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira `AnonymousUser` automaticamente e `IsAuthenticated`/`PodeGerenciarPermissoes` já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.
**Inativar desvincula perfis de acesso e liderança automaticamente** (`_revogar_acesso_se_inativo()`, `serializers.py`, decisão explícita do usuário): sempre que `UsuarioSerializer.create()`/`update()` termina com `usuario.is_active=False`, `perfis` é limpo (`usuario.perfis.clear()`) e o usuário sai do `liderados` de qualquer gerente que o tivesse (`usuario.lideres.clear()` — `lideres` é a relação **reversa** de `Usuario.liderados`; limpar aqui remove `usuario` do lado de quem o lidera, sem afetar quem `usuario` eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a **última** coisa em `create()`/`update()`, depois dos `.set()` de `perfis`/`departamentos`/`liderados` — colocar antes seria inútil, já que o formulário de edição de `usuarios.html` sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um `.set()` posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por `PATCH /api/usuarios/{id}/`; não há um hook equivalente no `admin.py` (uso interno, fora de escopo). **Não é uma trava**: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios `liderados` pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).
Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pill`/`.status-pill--ativo`/`.status-pill--inativo`, mesmo componente que já existia pro `PerfilAcesso.ativo`) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — `PATCH /api/usuarios/{id}/` com `{is_active: !atual}`, com confirmação via `window.confirm`. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam **auto-desativação** (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, `id === me.id`) — no formulário isso aparece como o checkbox desabilitado (`disabled`) quando `account.id === me.id`, em vez de um alerta.
**Filtro de status na lista** (`#ua-status-filtros`, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de `.ind-departamento-chip`): client-side, sobre o array `users` já carregado (`statusFiltro` em `users-admin.js`, aplicado em `renderList()` antes do filtro de busca por texto). Nasce em `"ativos"` por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra `"ativos"` só no load da página, não a cada `renderList()`.
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`) e do modal de Ramais (`ramais-lookup.js`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
**Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição**: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: `codigo_tareffa` via a view já existente em `portal_api/database/` que lê o Tareffa, ver [[project_database_package]] na memória) em vez de digitados à mão, mas essa vinculação de verdade **não foi implementada nesta rodada** — só a estrutura visual. Cada um dos 3 campos (`#ua-codigo-folha`/`#ua-codigo-questor`/`#ua-codigo-tareffa`) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (`.ua-field-link` — borda/raio únicos, botão separado por `border-left`, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre `disabled` com `title="Vinculação com sistema externo ainda não implementada"` — não tem nenhum handler de clique em `users-admin.js`. `codigo_contabit` e `ramal` (também campos cadastrais na mesma seção "Dados Cadastrais") **não** ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o `disabled`, adicionar o handler), não recriar o campo do zero.
## Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"
`Usuario.lideranca` (booleano) marca um usuário como gerente/coordenador de outros; `Usuario.liderados` é um M2M **auto-referenciado** (`"self"`, `symmetrical=False`, `related_name="lideres"`) — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar `lideranca=True` em "debora" e incluir "gabriel" em `liderados` representa "debora é gerente de gabriel".
Dois lugares gravam essa mesma relação:
- **Tela de Usuários** (`usuarios.html`/`users-admin.js`, só quem tem `gerencia_permissoes`): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (`#ua-lideranca`) libera (`hidden`) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia `lideranca`+`liderados` (array de ids) no mesmo payload de `PATCH`/`POST /api/usuarios/`.
- **Modal "Gerenciar Usuário"** (`account.js`, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre `#manage-account-modal`, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o `#password-modal` já existente, mesmo fluxo de antes) e, só se `me.lideranca` for `true`, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de `GET /api/usuarios-resumo/` (não de `/api/usuarios/`, que exige `gerencia_permissoes`) e salvar dispara `PATCH /api/me/liderados/`, que grava na mesma `Usuario.liderados` — `meus_liderados_view` recusa (403) se `request.user.lideranca` for `False`, já que só faz sentido pra quem tem o checkbox marcado.
**Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita** (`.dual-select`, `static/js/dual-select.js` + estilos em `components.css`): substituiu o antigo `.checklist-box` de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver seção própria abaixo), só que genérico o bastante pra rodar tanto em `usuarios.html` quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). `pidCriarSeletorDuplo(config)` (`dual-select.js`, incluído no prefixo de scripts de todo shell, logo depois de `api.js`) é a fábrica compartilhada — recebe as referências de DOM de cada painel (`available`/`linked`: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais `secundariaValor(candidato)` (aqui, `departamentosTexto()`, unindo os nomes dos departamentos por vírgula) e devolve `{ setDados(candidatos, vinculadosIniciais), getVinculadosIds() }`. Cada tela (`users-admin.js`/`account.js`) só chama `setDados()` ao abrir o formulário/modal e `getVinculadosIds()` no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um `Set` em memória até o "Salvar" de fora).
- Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas **filtradas** visíveis, mesmo critério do antigo `.checklist-select-all`), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro).
- `#manage-account-modal-card` (id novo no `.modal-card` do modal "Gerenciar Usuário") ganha a classe `.modal-card--wide` via JS (`account.js`) só quando `me.lideranca` é `true` — o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém.
- `.dual-select`/`.dual-select__*` moram em `components.css` (não em `perfis-acesso.css`), pela mesma razão de `.checklist-box` — o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carrega `perfis-acesso.css`.
- **Usuário inativo nunca aparece nos candidatos** — mesma decisão de "Usuários do Escritório" acima. Em `usuarios.html`, `fillLideradosChecklist()` (`users-admin.js`) filtra `users` por `is_active` antes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porque `GET /api/usuarios-resumo/` (`usuarios_resumo_view`) só devolve usuários ativos.
## Favoritos: como o ID de uma aplicação é derivado
`favorites.js` não depende de nenhum atributo `data-*` dedicado para identificar "o que é favoritável" — ele varre `.sidebar a.nav-item, .sidebar a.nav-subitem` e deriva um ID estável a partir da própria estrutura/texto do menu (`pidCollectFavoritableApps`), igual a antes da migração. Esse ID é o que vira `app_id` em `POST /api/favoritos/` e na URL de `DELETE /api/favoritos/{app_id}/`:
- Se o `<li>` do link já tem `data-section`, o ID é esse valor (ex.: `"ramais"`).
- Caso contrário (é um sub-item dentro de um `nav-group`), o ID é `"<data-section do grupo pai>__<slug do texto do link>"` (ex.: `"portais__portal-do-cliente"`).
O slug (`pidSlug`) normaliza acentos (NFD) e troca sequências de caracteres não `[a-z0-9]` por `-`. Se o texto de um label mudar, o `app_id` derivado muda junto (favoritos existentes referenciando o ID antigo deixam de casar).
**Reordenar os cards favoritos**: `Favorito.ordem` (`PositiveIntegerField`, `Meta.ordering = ["ordem", "id"]`) — mesmo padrão de `LinkFerramenta`/`WidgetUsuario`: `FavoritoViewSet.perform_create` atribui `ordem = max(ordem atual do usuário) + 1`, e reordenar é drag-and-drop nativo em `#app-card-grid` (`favorites.js`, `dragstart`/`dragover`/`drop`, `PATCH /api/favoritos/{app_id}/` só nos itens cujo `ordem` mudou) — mesma mecânica dos outros dois. `.app-card` inteiro é `draggable="true"` (não precisa de um handle separado como os widgets, já que não tem `resize` pra conflitar); o botão de remover (`.app-card__remove`) é `draggable="false"` pra não interferir.
Ver `docs/favoritos.md`.
## Calendário Individual e Widgets
Compromissos (`CompromissoAgenda`) têm um dono (`dono`, FK) e um campo `visibilidade` (`"somente_eu"`/`"departamento"`/`"todos"`, substituiu a antiga flag booleana `compartilhado_com_perfil` — perfil de acesso deixou de ser o critério de compartilhamento). `GET /api/compromissos/` (`CompromissoAgendaViewSet.get_queryset`) já retorna: (a) sempre os próprios compromissos do usuário; (b) todo compromisso com `visibilidade="todos"`, pra qualquer usuário do portal, sem checar perfil/departamento; (c) compromissos com `visibilidade="departamento"` só se `departamento_compartilhado` (FK, `on_delete=SET_NULL`) for um dos departamentos do usuário logado — a lógica de "visível para quem" mora só no backend. O campo `sou_dono` (calculado no serializer) substitui o antigo `ownerLogin === login` do cliente; só o dono edita/exclui (`CompromissoAgendaViewSet.get_object` levanta `PermissionDenied` se não for o dono tentando escrever).
Agenda pessoal de cada usuário (compromissos privados, de departamento ou de todos), com eventos corporativos, feriados nacionais/estaduais do Paraná e lembretes calculados em horário comercial. Inclui também o sistema genérico de widgets configuráveis da tela Principal (drag-and-drop, redimensionamento).
Ao escolher `visibilidade="departamento"` no modal (`calendario-individual.html`/`calendar-individual.js`), um segundo campo aparece (`#ic-event-department-field`) pra escolher **qual** dos próprios departamentos do dono recebe o compartilhamento — decisão explícita do usuário, já que `Usuario.departamentos` é M2M (pode ter mais de um) e "meu departamento" sozinho seria ambíguo nesse caso. As opções desse `<select>` vêm de `me.departamentos` (adicionado a `/api/me/` só pra isso — antes esse endpoint não expunha os próprios departamentos do usuário logado, só o de outros via `UsuarioResumoSerializer`). `CompromissoAgendaSerializer.validate()` exige `departamento_compartilhado` quando `visibilidade="departamento"` e recusa qualquer departamento que não esteja entre os do próprio dono (`request.user.departamentos`) — mesmo que o cliente tente forçar um id de departamento alheio no payload; para as outras duas visibilidades, `departamento_compartilhado` é sempre zerado no servidor, ignorando o que vier no payload.
Ver `docs/calendario-individual.md`.
**Filtro por categoria e cores no calendário** (`.calendar-filters`/`.filter-chip` em `calendario.css` — já existiam no CSS sem nenhum consumidor antes desta funcionalidade): dentro de `.calendar-header`, ao lado do título do mês e do botão "Novo Compromisso" (mesma linha, não numa faixa própria abaixo — `flex-wrap: wrap` no header cobre o caso de não caber tudo numa linha só), uma barra de chips clicáveis (multi-seleção, sem exclusividade) filtra o que aparece no calendário por `pidEventoCategoria(ev)` (`calendar-individual.js`), que não é exatamente `ev.visibilidade` — um compromisso `"somente_eu"` que não é meu (`!ev.sou_dono`) só pode ter chegado pela regra de liderança abaixo, então vira a categoria `"equipe"`, distinta de `"somente_eu"` (meus próprios); e todo `ev.eh_evento` vira a categoria `"evento"` **antes** de qualquer outra checagem (ver "Eventos Corporativos" abaixo), mesmo já sendo sempre `visibilidade="todos"`. As 5 categorias (`somente_eu`/`departamento`/`todos`/`equipe`/`evento`) têm cor fixa própria (`.calendar-event--*` em `calendario.css`) e o chip ativo de cada uma usa a mesma cor — o próprio filtro funciona como legenda. `"somente_eu"` é a exceção: usa `--accent` (o tema de cor que o usuário escolheu, não uma cor fixa); as outras quatro usam tokens fixos novos (`--gold`, reaproveitado do antigo "compartilhado"; `--teal` e `--slate`, adicionados só pra isso em `tokens.css`; `--coral`, adicionado depois só pra `"evento"` — todos com variante mais escura no tema claro pro contraste do texto escuro fixo `#1a1721`) — escolhidos deliberadamente fora das cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa/vermelho) pra nunca coincidir visualmente com o que `--accent` pode assumir — exceto `--coral`, que é laranja e portanto não colide mesmo com "vermelho" na lista. O chip "Minha equipe" (`#ic-filter-equipe`) só aparece (`hidden`) se `me.lideranca`; o chip "Eventos" (`data-filter="evento"`) é sempre visível, já que qualquer perfil pode ver eventos corporativos (só criar um é restrito). Por padrão todos os chips visíveis nascem `is-active` (mostra tudo que o usuário pode ver). Clique simples troca a seleção pra **só** aquele filtro (`activeFilters.clear()` + adiciona só o clicado), exceto se esse filtro já for o único ativo — nesse caso (`activeFilters.size === 1 && activeFilters.has(filtro)`) o clique volta pra visualização padrão (`selecionarTodosOsFiltros()`, todos os chips visíveis ativos), pra sempre existir um caminho de volta ao estado "ver tudo" sem precisar de Shift. `Shift`+clique acrescenta/remove esse filtro dos já selecionados (`event.shiftKey`, mesmo padrão de seleção de arquivos do SO) — é assim que dá pra combinar mais de uma categoria ao mesmo tempo.
## Links & Ferramentas / Acessos Gerais
**Eventos Corporativos** (`CategoriaEvento`, `CompromissoAgenda.eh_evento`/`categoria`/`local`/`modalidade`/`descricao`): compromissos com `visibilidade` em `departamento`/`todos` deixaram de ser livres pra qualquer usuário — criar ou editar um compromisso nesses dois níveis (o que inclui automaticamente todo `eh_evento=True`, já que a validação força `visibilidade="todos"` antes de checar permissão) agora exige `apps["calendario-individual-criar-evento"]` em `permissoes["calendario-individual"]` (`CompromissoAgendaSerializer.validate()`), permissão liberada só para "Integração e Inovação" no `seed_portal.py` por ora (mesmo cuidado de sempre: como `calendario-individual` está em `BASE_KEYS`, sem o override todo perfil nasceria podendo criar evento de departamento/todos e cadastrar categoria). Compromissos `"somente_eu"` continuam livres pra qualquer um, sem essa checagem.
- `CategoriaEvento` (`nome` único + `cor` hex, validada por `validar_cor_categoria_evento`) é um cadastro simples via `/api/categorias-evento/` — GET livre a qualquer autenticado (a cor/nome de uma categoria não é sigilosa), escrita restrita à mesma permissão acima. Não é uma lista fixa no código: quem tem a permissão cadastra categorias novas (ex.: "Reunião", "Treinamento") direto no modal de criar/editar compromisso (botão "+" ao lado do `<select>` de categoria, `#ic-event-categoria-add-btn`, que abre `#ic-categoria-modal`) — `seed_portal.py` popula `CATEGORIAS_EVENTO_SEED` como ponto de partida, mas a lista é editável dali em diante.
- `CompromissoAgenda.eh_evento` marca um compromisso como evento formal (não uma reunião pessoal marcada como "todos") — o checkbox correspondente (`#ic-event-eh-evento`, dentro de `#ic-event-eh-evento-field`) só aparece pra quem tem a permissão de criar evento; marcá-lo força a visibilidade pra "Todos" no próprio formulário. `local` (texto livre), `modalidade` (`presencial`/`remoto`/`hibrido`, `<select>` `#ic-event-modalidade`) e `descricao` (texto livre) são campos extras só relevantes pra evento, mas tecnicamente gravam em qualquer compromisso (o formulário só os expõe quando aplicável). No popup somente-leitura (`#ic-view-modal`), cada um aparece como campo próprio (`#ic-view-local-field`/`#ic-view-modalidade-field`/`#ic-view-descricao-field`), escondido (`hidden`) quando vazio — mesmo padrão dos demais campos condicionais desse popup (ver "Pill do compromisso" acima).
- Visualmente, um evento ganha um bucket de cor próprio (`--coral`) tanto no pill do calendário (`.calendar-event--evento`) quanto no chip de filtro "Eventos" — mesmo sendo sempre `visibilidade="todos"` por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).
Duas aplicações na mesma seção do menu: "Links & Ferramentas" é uma grade de cartões de atalho para ferramentas externas; "Acessos Gerais" é um cadastro de logins/acessos compartilhados da equipe, organizado em seções e linhas.
**Pill do compromisso: sempre "HH:MM Título", nada mais** — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (`cursor:pointer` na classe base `.calendar-event`, não só em `--somente-eu`): se `ev.sou_dono`, abre o modal de edição de sempre (`#ic-modal`, fecha também clicando fora — `event.target === modal`, mesmo padrão de `links-ferramentas.js`/`ramais-lookup.js`); senão, abre um modal novo, só leitura (`#ic-view-modal`/`openViewModal()`, mesmo fecha-ao-clicar-fora), com data/horário por extenso, `dono_nome` (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (`PID_IC_VISIBILIDADE_LABELS`, mapeia `ev.visibilidade` pro texto exibido nos chips) e, só quando `ev.visibilidade === "departamento"`, o nome do departamento (`ev.departamento_compartilhado_nome`, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.
**Célula do dia com altura fixa, lista de compromissos rolável** (`.calendar-day`/`.calendar-day__events` em `calendario.css`): `.calendar-day` tem `height` fixo (108px desktop, 76px no breakpoint mobile — antes era `min-height`, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de `.calendar-day` — `calendar-individual.js` (`render()`) os agrupa num `<div class="calendar-day__events">` (`flex:1; min-height:0; overflow-y:auto`) irmão de `.calendar-day__header`. **`.calendar-day` (o item de grid, não só o `__events` interno) também precisa de `min-height:0` + `overflow:hidden`** — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando `height` explícito) ainda fazia a *linha da grade* crescer pra caber todos os pills, mesmo com a célula e o `overflow-y:auto` do `__events` configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (`min-height:0`/`overflow` não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.
**Agenda completa do dia** (`#ic-day-modal`, `openDayModal()` em `calendar-individual.js`): clicar no número do dia (`.calendar-day__number`, `cursor:pointer` + destaque no hover) abre um popup com **todos** os compromissos do dia (respeitando os filtros ativos, mesmo `eventosDoDia` usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o `.day-modal-list` (`calendario.css`) tem `max-height:360px` próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter `white-space:normal` (podem quebrar linha) em vez do `nowrap`+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), `criarPillCompromisso(ev)`/`criarPillFeriado(iso, nome)` foram extraídas como funções reaproveitadas por `render()` **e** por `openDayModal()` — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).
**Feriados no Calendário Individual** (`GET /api/feriados/?ano=AAAA`, `feriados_view` em `views.py`): usa a lib `holidays` (PyPI, `requirements.txt`) pra devolver os feriados **nacionais + estaduais do Paraná** (`holidays.Brazil(years=ano, subdiv="PR", language="pt_BR")`, categoria `public` — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como `[{"data": "AAAA-MM-DD", "nome": "..."}]`. `language="pt_BR"` é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com `LANG`/`LANGUAGE` em inglês) em vez do `default_language` da classe `Brazil`, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. **De propósito não tem feriado municipal de Foz do Iguaçu aqui** — nenhuma lib de feriados cobre granularidade de município brasileiro (a `holidays` só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.
No frontend (`calendar-individual.js`), `carregarFeriados(ano)` busca e cacheia por ano (`Map` em memória, só refaz a requisição ao trocar de ano); `render()` busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de `viewYear`. Cada célula de dia feriado ganha a classe `.is-feriado` (fundo tingido de vermelho, `rgba(var(--danger-rgb), 0.1)`) e um pill (`.calendar-day__holiday`, mesmo visual dos pills de compromisso — `.calendar-event`, só que com fundo `--danger` fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de `.calendar-day__header`, **fora** de `.calendar-day__events` — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com `text-overflow:ellipsis` quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por `;`, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver `feriados_view`); clicar no pill abre `#ic-holiday-modal` (`openHolidayModal()`, mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.
**Gerente/coordenador vê a agenda individual da equipe**: `CompromissoAgendaViewSet.get_queryset` acrescenta `Q(visibilidade="somente_eu", dono__in=usuario.liderados.all())` às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em `Usuario.liderados` (ver seção "Liderança") também enxerga os compromissos privados (`"somente_eu"`) de cada liderado, mas sem poder editá-los (`sou_dono` continua `False` pra esses, `CompromissoAgendaViewSet.get_object` já barra escrita de quem não é dono). Não há necessidade de checar `usuario.lideranca` explicitamente na query — `liderados` só é populado através de fluxos que já exigem esse flag (ver "Liderança"), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
**Lembrete com horário comercial** (`CompromissoAgenda.lembrete_antecedencia`, opcional — `""` = sem lembrete; `"1h"`/`"2h"`/`"4h"`/`"24h"`): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (`notif-bell`), que já era recalculado a cada carregamento de página (sem processo em segundo plano). `CompromissoAgenda.calcular_notificar_em()` (`models.py`) calcula esse horário contando `lembrete_antecedencia` horas **de expediente** (seg-sex, 8h-18h, `COMPROMISSO_HORARIO_COMERCIAL_INICIO`/`_FIM`) pra trás a partir de `data`+`horario` — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (`_janela_comercial`/`_dia_util_anterior`, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem `horario` definido no compromisso (evento de dia inteiro) ou sem `lembrete_antecedencia`, não há o que calcular e `calcular_notificar_em()` retorna `None`. O resultado é exposto só leitura via `notificar_em` no serializer (ISO datetime ou `null`); `pidBuildEventNotifications()` (`notifications.js`) só inclui um compromisso na lista do sino quando `notificar_em` não é nulo **e** já foi atingido (`now >= notificar_em`) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).
`widgets.js` mantém um registro extensível `PID_WIDGET_TYPES` (`{ "<chave>": { label, description, href, linkLabel, visibleIf? } }`) — hoje existem `"calendario-individual"` e `"links-favoritos"` (ver seção própria abaixo). `href`/`linkLabel` alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra `calendario-individual.html`, então ao adicionar um tipo novo **sempre** preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. `visibleIf(me)` é opcional — quando presente, filtra o tipo tanto do picker (`renderPicker()`) quanto da grade já adicionada (`renderWidgets()`), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: `links-favoritos` só aparece pra quem tem `apps["links-ferramentas-visualizar"]` em `links-ferramentas`). Para adicionar um novo tipo de widget: registrar a entrada em `PID_WIDGET_TYPES` e adicionar um `case`/`if` em `widgetBodyFor()` que retorne o HTML do corpo do card; o picker (`#widget-picker-modal`) e a grade (`#widgets-grid`) já lidam com adicionar/remover genericamente via `/api/widgets/`.
**Reordenar e redimensionar widgets** (`WidgetUsuario.ordem`/`largura`/`altura`, por usuário): `.widgets-grid` é `display:flex; flex-wrap:wrap` (não mais CSS Grid — precisava permitir que cada `.widget-card` tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (`dragstart`/`dragover`/`drop` em `#widgets-grid`, `PATCH /api/widgets/{tipo}/` só nos itens cujo `ordem` mudou) — a diferença é que o `draggable="true"` fica só em `.widget-card__header` (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (`resize: both` em `.widget-card`, ativo no canto inferior direito). Redimensionar usa esse `resize: both` do CSS (sem JS de arraste custom) — um `ResizeObserver` por card (`observeWidgetSizes()`) detecta a mudança de tamanho e salva `largura`/`altura` com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só `.widget-card__body` tem `overflow-y: auto` (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).
## Links & Ferramentas
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver "Favoritos" acima). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em "Favoritos": mudar a forma como um item aparece no menu muda o `app_id` derivado.
`LinkFerramenta` é uma lista **global/compartilhada** (sem FK pra `Usuario`, ao contrário de `Favorito`/`WidgetUsuario`/`NotificacaoDispensada`) — todo usuário com `apps["links-ferramentas-visualizar"]=True` em `permissoes["links-ferramentas"]` vê os mesmos cartões via `GET /api/links-ferramentas/` (ver "Padrão visualizar/editar" acima).
`links-ferramentas.js` também gateia o **conteúdo da própria página** por `apps["links-ferramentas-visualizar"]` (`#lf-no-access`/`#lf-content` em `links-ferramentas.html`, mesmo padrão do `.no-access` de `portal.html`) — isso existe porque o sidebar (`data-section="links-ferramentas"` em `access.js`) só esconde o `nav-group` inteiro com base no `enabled` do módulo (e cada sub-item individualmente com base no seu `-visualizar`), então alguém sem `apps["links-ferramentas-visualizar"]` mas que navegue direto pra URL (ou tenha `enabled=true` sem essa flag, uma combinação tecnicamente possível já que são independentes) via GET no backend recebia 403 e via a tela renderizada com "Nenhum link cadastrado ainda." em vez de uma mensagem de acesso negado. Ao adicionar uma aplicação nova no padrão visualizar/editar, replicar esse gate (como `acessos-gerais.js` já faz) — não basta confiar em `access.js` escondendo o link do menu.
Só quem tem `apps["links-ferramentas-editar"]=True` (`me.permissoes_efetivas["links-ferramentas"].apps["links-ferramentas-editar"]`, já unido no servidor) vê em `links-ferramentas.html` os controles de administração: botão "Adicionar Link" no topo e, em cada cartão, setas de mover para cima/baixo + X de remover (`links-ferramentas.js`, gated no frontend por essa flag, e reforçado no servidor por `PermissaoApp("links-ferramentas", "links-ferramentas-editar")`/`PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")` conforme o método HTTP).
- **Ordenação**: campo `ordem` (inteiro, sem `unique`) em `LinkFerramenta`, `Meta.ordering = ["ordem", "id"]`. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quais `PATCH`es disparar. Duas formas de reordenar na UI, ambas em `links-ferramentas.js`: as setas (`swapOrdem()`) trocam o `ordem` de dois itens adjacentes com duas chamadas `PATCH`; arrastar um cartão (drag-and-drop nativo HTML5, `.lf-card--draggable`/`dragstart`/`dragover`/`drop` no `#lf-grid`) recalcula a lista inteira em memória e envia um `PATCH` só para os itens cujo `ordem` (índice na nova ordem) realmente mudou — como não há `unique` em `ordem`, não tem problema disparar essas chamadas em paralelo (`Promise.all`) mesmo que dois itens fiquem com o mesmo valor por um instante. Ao criar um link novo, o servidor sempre calcula `ordem = max(ordem atual) + 1` em `LinkFerramentaViewSet.perform_create` — qualquer `ordem` enviada pelo cliente no POST é ignorada.
- **Ícone**: `icone` é um `ImageField` opcional (upload real, não URL) — exige Pillow (`requirements.txt`) e `MEDIA_URL`/`MEDIA_ROOT` (`settings.py`, servido em `DEBUG` por `config/urls.py`). Sem ícone, o cartão cai num SVG de fallback (`PID_LINK_DEFAULT_ICON` em `links-ferramentas.js`, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja como `multipart/form-data` (`FormData`), não JSON — ver a nota sobre `pidApiRequest` acima. Limite de tamanho: **2MB**, checado em dois lugares — `validar_tamanho_icone_link` (validator do campo `icone` em `models.py`, é a checagem que vale de verdade, roda via `LinkFerramentaSerializer.is_valid()`) e uma checagem espelhada em `links-ferramentas.js` (`PID_LINK_ICON_MAX_BYTES`, no `change` do input e de novo antes do POST/PATCH) só para dar feedback sem esperar a resposta do servidor. Ao mudar o limite, atualizar os dois lados (e gerar migração — `validators` no campo entra no `deconstruct()`).
- Clicar num cartão sempre abre a URL numa aba nova (`target="_blank"`) — são links externos por definição, não faz sentido navegar embutido no portal.
- Editar um cartão existente (nome, URL e ícone) usa o mesmo modal de "Adicionar Link" (`#lf-add-modal`), reaproveitado em modo edição — o botão de lápis em cada cartão (visível só com `apps["links-ferramentas-editar"]`, ao lado das setas de mover) chama `openModal(link)` pré-preenchendo os campos; salvar despacha `PATCH /api/links-ferramentas/{id}/` (`pidUpdateLink`, multipart igual ao POST) em vez de criar um novo. O campo de ícone fica sempre vazio ao abrir em modo edição (input `type="file"` não aceita valor pré-preenchido por segurança do browser) — não enviar o campo `icone` no PATCH mantém o ícone atual; só enviar substitui.
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
## Acessos Gerais
Segunda aplicação da seção "Links & Ferramentas" (ver acima) — um cadastro de acessos/logins compartilhados (ex.: "login geral de um site"), organizado em **seções e linhas** (inspirado numa tela do Asana que o usuário mostrou como referência): cada seção agrupa várias linhas, e clicar numa linha abre um popup com os detalhes daquele acesso. Dois models novos, sem relação com `LinkFerramenta`:
- `AcessoGeralSecao` (`nome`, `ordem`, `perfis_restritos` M2M pra `PerfilAcesso`, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK pra `Usuario`.
- `AcessoGeral` (`secao` FK, `nome`, `url`, `usuario`, `senha`, `observacoes`, `ordem`) — a linha em si. `senha` é um `CharField` em texto puro (não há criptografia/hash — é um cadastro de referência entre a própria equipe, não um cofre de senhas robusto; se isso precisar mudar no futuro, confirmar com o usuário antes, já que envolve infraestrutura de chave/criptografia nova). `observacoes` guarda **HTML sanitizado** (ver "Observações ricas" abaixo), com um `validators=[validar_tamanho_observacoes_acesso]` (`models.py`) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita um `TextField` sem limite nenhum crescer sem controle.
**Permissão**: mesmo padrão visualizar/editar de Links & Ferramentas, com chaves próprias (`acessos-gerais-visualizar`/`acessos-gerais-editar`, ver "Padrão visualizar/editar" acima) — `AcessoGeralSecaoViewSet`/`AcessoGeralViewSet` (`views.py`) instanciam `PermissaoApp("links-ferramentas", app_key)` com a chave certa por método HTTP. `acessos-gerais.js` gateia o conteúdo da própria página (`#ag-no-access`/`#ag-content`) por `acessos-gerais-visualizar`, mesmo raciocínio do gate de `links-ferramentas.js`.
**Restrição de seção por perfil** (`AcessoGeralSecao.perfis_restritos`): além da permissão de módulo, cada seção pode opcionalmente ser restrita a um subconjunto de `PerfilAcesso` — `perfis_restritos` vazio (padrão) = visível a qualquer um com `acessos-gerais-visualizar`; não vazio = só quem também tiver um desses perfis vinculado. Isso é uma restrição de **dado**, independente da árvore de permissões (não precisa mexer em Perfis de Acesso pra configurar) — é escolhida direto no modal "Adicionar Seção"/"Renomear Seção" (`#ag-secao-form-perfis`, um `.checklist-box` com todos os perfis cadastrados, populado via `pidFetchPerfis()`). O filtro é aplicado em dois lugares no backend, ambos em `views.py`:
- `AcessoGeralSecaoViewSet.get_queryset()` — só devolve seções sem restrição ou com interseção entre `perfis_restritos` e os perfis do usuário logado; `AcessoGeralViewSet.get_queryset()` aplica o mesmo filtro via `secao__perfis_restritos`, pra uma linha nunca vazar de uma seção que o usuário não veria.
- `AcessoGeralSerializer.__init__` também restringe o próprio campo `secao` (o `PrimaryKeyRelatedField` que valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém com `acessos-gerais-editar` mas sem o perfil exigido conseguiria criar uma linha dentro de uma seção restrita só sabendo o id dela, mesmo sem enxergá-la em nenhuma listagem.
Não há exceção pra `gerencia_permissoes`/perfil de acesso total — mesmo "Integração e Inovação" (código 8) fica de fora de uma seção restrita a outro perfil que não o seu, exatamente como qualquer outro perfil (é uma lista de permissão explícita, não um nível hierárquico).
**Ordenação por seção**: `AcessoGeral.ordem` é **escopada por `secao`** (ao contrário de `LinkFerramenta.ordem`, que é global) — `AcessoGeralViewSet.perform_create` calcula `max(ordem)` só entre as linhas da mesma seção. Reordenar (drag-and-drop nativo HTML5, mesma mecânica de `links-ferramentas.js` — `dragstart`/`dragover`/`drop` em `#ag-sections`, delegado num container que tem todas as seções) só é permitido **dentro de uma seção**: `dragover` ignora o alvo se `draggedAcesso.secao !== targetAcesso.secao`, então uma linha nunca muda de seção arrastando. As setas de mover para cima/baixo (`swapOrdem()`) seguem a mesma regra, já que operam sobre `acessosDaSecao(secao.id)`, nunca a lista inteira. Seções em si não têm drag-and-drop — só criar/renomear/excluir; a ordem entre seções é a de criação (`ordem` incrementado pelo servidor, sem UI de reordenar).
**Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir.
- **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` no topo de `serializers.py` — generalizadas nessas constantes desde que o texto de "Mais informações" de uma aplicação passou a reaproveitar o mesmo allowlist, ver "Ajuda de aplicação" abaixo) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach.
- Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio").
**Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido.
Nenhuma tela recalcula união de departamentos/liderança aqui — é uma aplicação isolada, sem relação com `Usuario` além da permissão de quem pode ver/editar (e, agora, do `perfis_restritos` por seção).
Ver `docs/links-ferramentas-acessos-gerais.md`.
## Ramais
O diretório de `ramais.html` é **automático**: `RamalViewSet.list()` (não o `RamalSerializer` — esse serializer só cobre as linhas avulsas via CRUD normal) mescla, a cada `GET /api/ramais/`, duas fontes numa lista só, ordenada por nome:
Diretório de ramais internos — mescla automaticamente todo colaborador ativo (a partir do cadastro de Usuários) com linhas avulsas (telefone de sala, recepção etc.). Inclui também Telefones Externos, Funções de Telefonia e controle de ausência.
1. Todo `Usuario` ativo — a linha é montada direto do cadastro (`nome`, `Usuario.departamentos` juntados por vírgula, `Usuario.ramal`); se o colaborador ainda não tem ramal preenchido, `numero_exibicao` vem vazio e o frontend mostra um badge "Pendente" em vez de reclamar de campo faltando.
2. As linhas avulsas de `Ramal` (sem `Usuario` por trás — telefone de sala, recepção etc.), cadastradas pelo modal "Adicionar Ramal".
Cada item da lista mesclada tem um `id` sintético (`"usuario-<id>"` ou `"avulso-<id>"`) e um campo `tipo` (`"usuario"`/`"avulso"`) que o frontend usa pra decidir qual endpoint chamar ao editar/excluir — não existe mais um model unificando os dois casos com uma FK opcional (essa foi a primeira versão da tela; revertida a pedido do usuário pra eliminar o passo manual de "adicionar" alguém que já tem cadastro).
Segue o mesmo padrão visualizar/editar de Links & Ferramentas (ver acima): leitura exige `apps.visualizar` (liberado a todo perfil, já que `ramais` está em `BASE_KEYS`), escrita exige `apps.editar` — por ora só `True` pra "Integração e Inovação" no `seed_portal.py`, exatamente como pedido; liberar outro perfil não pede código novo, só marcar o app na árvore de Perfis de Acesso.
- **Editar o ramal de um colaborador de verdade**: não existe "criar" — a linha já aparece sozinha. O lápis na linha abre o mesmo modal de Ramal, mas com Nome/Departamento desabilitados (só leitura do cadastro) e só o campo Ramal editável; salvar chama `PATCH /api/ramais/usuarios/{usuario_id}/` (`RamalViewSet.atualizar_ramal_usuario`), que grava direto em `Usuario.ramal` — é assim que a tela demonstra a alteração refletindo no cadastro do usuário.
- **Linha avulsa**: "Adicionar Ramal" sempre cria uma linha avulsa (`POST /api/ramais/`, `nome`/`departamento`/`numero` livres — só `nome` é obrigatório, o resto pode ficar pendente igual a um colaborador de verdade). Editar/excluir uma linha avulsa usa `PATCH`/`DELETE /api/ramais/{avulso_id}/` normalmente; excluir só existe pra esse tipo (não dá pra "excluir" um colaborador daqui — isso é na tela de Usuários).
- **Lista de usuários do modal de Ausência**: `RamalViewSet.usuarios_disponiveis` (`GET /api/ramais/usuarios/`) devolve só `id`/`nome` de usuários ativos, pra alimentar o `<select>` "Lista de Usuários" do modal "Criar Ausência" (o único modal que ainda precisa escolher uma pessoa numa lista — o modal de Ramal não precisa mais, já que a linha do colaborador já existe). Não reaproveita `/api/usuarios/` de propósito — aquele endpoint é restrito a `gerencia_permissoes`, e a permissão de Ramais é deliberadamente desacoplada disso (hoje dá na mesma pessoa, mas não presume que sempre será assim).
- **Ausência** (`RamalAusencia`): um registro por período criado pelo modal "Criar Ausência"; "ausente agora" nunca é armazenado — `RamalAusencia.esta_ativa()` compara a hora atual (`timezone.localtime()`) contra `[data_inicio+hora_inicio, data_fim+hora_volta]` (hora ausente = considera o dia inteiro) toda vez que `RamalViewSet.list()` monta a resposta. Clicar no badge "(AUSENTE)" de uma linha (`data-ram-ver-ausencia`, qualquer um com `apps.visualizar` pode abrir) faz `GET /api/ramais-ausencias/{id}/` e abre o modal "Visualizar Ausência" — campos desabilitados (`<input type="date"/"time">` mostra a data/hora formatada mesmo `disabled`, sem precisar formatar manualmente), com "Editar Ausência"/"Deletar Ausência" visíveis só pra quem tem `apps.editar`. "Editar" habilita os mesmos campos in-place (sem modal novo) e troca os botões por "Cancelar"/"Salvar Ausência" (`PATCH /api/ramais-ausencias/{id}/`); "Deletar" remove o registro (`DELETE`) — não existe mais um botão de "encerrar antes do previsto" separado (a rodada anterior tinha isso via `encerrada_manualmente`; substituído por editar/excluir de verdade, mais simples e é o que foi pedido). O campo `encerrada_manualmente` continua no model (histórico/uso futuro via admin), só não tem mais UI própria.
- **Aniversariante**: comparação de `Usuario.data_aniversario` (mês/dia) com `timezone.localdate()`, feita no mesmo `list()` — mesmo campo que já existia no cadastro de Usuários, sem nada novo ali.
- **Selos de ausente/aniversariante**: `.ram-badge--ausente`/`.ram-badge--aniversario` (`ramais.css`) são selos (pill) com cor de texto/fundo ajustada por tema via `:root[data-theme="light"] .ram-badge--*` — não reaproveitam `--danger`/`--gold` crus porque esses tokens não foram pensados pra texto pequeno sobre um selo (contraste insuficiente). A linha inteira também é tingida (`.ram-row--ausente`/`.ram-row--aniversario` td, aplicado via classe no `<tr>` em `ramais.js`) com a mesma cor do selo, também ajustada por tema — pedido explícito do usuário pra facilitar notar a linha antes mesmo de ler o selo (a versão anterior sem tingimento de linha foi revertida).
- **Férias**: a aba existe (navegação por abas, ver abaixo) mas está **vazia de propósito** — o conteúdo foi adiado pra uma rodada futura; a limitação original ("depende de integração futura com outro banco") continua valendo, só a decisão de já reservar o espaço na navegação é nova.
- **Novo Chamado**: botão que abre um modal com um `<iframe>` apontando para a ferramenta externa de chamados (`https://depaula-tvcorporativa.lovable.app/chamar?token=...`) — decisão explícita de ficar embutido na própria tela em vez de nova aba (diferente do padrão dos demais links externos do portal). O `src` do iframe só é setado na abertura do modal e volta pra `about:blank` ao fechar, pra não deixar a ferramenta carregada em segundo plano.
- **Sem reordenação**: ao contrário de Links & Ferramentas/Widgets, a listagem é sempre alfabética (`sort()` em `list()`), sem `ordem`/drag-and-drop.
- Usuário inativo (`is_active=False`) não aparece mais no diretório (o `list()` filtra `Usuario.objects.filter(is_active=True)`) — diferença deliberada da primeira versão, que ainda mostrava inativos se tivessem uma linha vinculada.
### Navegação por abas em `ramais.html` (subtelas)
`ramais.html` deixou de ser uma tela única — é uma seção com 5 subtelas, navegáveis por abas logo abaixo do cabeçalho: **Ramais** (diretório descrito acima, ativa por padrão), **Responsável no Tareffa** (placeholder vazio), **Telefones Externos**, **Férias** (placeholder vazio) e **Funções de Telefonia**. As abas reaproveitam o CSS genérico `.pa-tabs`/`.pa-tab`/`.pa-tab-panel` (`perfis-acesso.css`, já carregado nesta página desde antes — mesmo padrão usado nas abas Permissões/Usuários de `perfis-acesso.html`), mas com atributos próprios (`data-ram-tab`/`data-ram-tab-panel`) e uma implementação independente em `ramais.js` (`activeRamTab`/`renderRamTabs()`), pra não colidir com `profiles.js`. Os botões "Adicionar Ramal"/"Novo Chamado"/"Criar Ausência" continuam só dentro do painel "Ramais" — cada subtela tem suas próprias ações.
**Permissão — uma dupla visualizar/(editar) por subtela**: cada uma das 5 abas tem sua própria permissão de visualização, e as 3 com conteúdo administrável (Ramais, Telefones Externos, Funções de Telefonia) também têm sua própria permissão de edição — não é mais um único par genérico `ramais.apps.visualizar`/`ramais.apps.editar` cobrindo tudo (esse desenho, usado na primeira versão da navegação por abas, foi revisto no mesmo dia a pedido do usuário: "deve haver permissão de visualização para cada um dos itens e edição para as de ramais, telefone externos e funções de telefonia"). Em `catalogo.MODULE_APPS["ramais"]`, isso é modelado como **5 subgrupos** (mesmo formato `{"key", "label", "tools": [...]}` já usado em Auditorias — reaproveita 100% a árvore de permissões genérica de `profiles.js`, sem UI nova):
```python
"ramais": [
{"key": "ramais-diretorio", "label": "Ramais", "tools": [
{"key": "ramais-visualizar", "label": "Visualizar"},
{"key": "ramais-editar", "label": "Editar (...)"},
]},
{"key": "responsavel-tareffa", "label": "Responsável no Tareffa", "tools": [
{"key": "responsavel-tareffa-visualizar", "label": "Visualizar"},
]},
{"key": "telefones-externos", "label": "Telefones Externos", "tools": [...]},
{"key": "ferias", "label": "Férias", "tools": [{"key": "ferias-visualizar", ...}]},
{"key": "funcoes-telefonia", "label": "Funções de Telefonia", "tools": [...]},
],
```
Cada `ModelViewSet` (`RamalViewSet`/`RamalAusenciaViewSet`, `TelefoneExternoViewSet`, `FuncaoTelefoniaViewSet`) instancia `PermissaoApp("ramais", app_key)` com a chave da própria subtela (ex.: `"telefones-externos-visualizar"`/`"telefones-externos-editar"`) — `RamalAusenciaViewSet` usa as mesmas chaves `ramais-visualizar`/`ramais-editar` do diretório de Ramais, já que ausência é parte dessa subtela, não uma quinta. No frontend, `ramais.js` calcula um `canView`/`canManage` por subtela a partir de `me.permissoes_efetivas.ramais.apps[chave]`, esconde (`hidden`) o botão de cada aba cujo `visualizar` for falso, e escolhe a primeira aba visível como ativa por padrão (em vez de sempre abrir em "Ramais", que pode estar oculta pra esse perfil). `ramais-lookup.js` (modal de consulta rápida no topbar) usa especificamente `ramais-visualizar`, já que só mostra o diretório de Ramais, não as outras subtelas.
**Cuidado com `seed_portal.py`** (mesmo princípio da nota geral em "Padrão visualizar/editar" acima): como `ramais` está em `BASE_KEYS`, `permissions_from_keys()` habilitaria os 8 apps (visualizar de todas as 5 + editar das 3) de uma vez — sem o override, todo perfil nasceria podendo editar. Por isso `seed_portal.py` força `ramais-editar`/`telefones-externos-editar`/`funcoes-telefonia-editar` para `False` explicitamente em todo perfil que não seja "Integração e Inovação", depois de montar o dict — os `*-visualizar` ficam `True` pra todo mundo de propósito ("os demais terão acesso para visualizar todas"). Qualquer mudança de nome/adição de subtela nesse padrão precisa replicar esse mesmo cuidado.
Um perfil só-visualizar vê as 5 abas e as tabelas, mas nunca os botões de Adicionar/editar/excluir em nenhuma delas; um perfil sem `visualizar` numa subtela específica não vê nem a aba dela.
**Telefones Externos** (`TelefoneExterno`, model dedicado sem FK — contatos de fornecedores/terceiros, não de `Usuario`): CRUD simples via `/api/telefones-externos/`, só `nome` obrigatório (`ramal`/`telefone`/`observacoes` opcionais, mesmo padrão de `Ramal` avulso). Dois filtros de busca (`ram-tel-search-nome`/`ram-tel-search-obs`, client-side sobre o array já carregado) — por nome e por observações, ao mesmo tempo, sem OR/AND configurável. A tabela começa vazia (nenhum seed) — o usuário cadastra pela própria tela.
**Funções de Telefonia** (`FuncaoTelefonia`) — comandos padrão da central telefônica (ex.: `*01 + Código de Agente` → LogOn). CRUD via `/api/funcoes-telefonia/`, só `comando` obrigatório. `Meta.ordering = ["comando"]` reproduz sozinho a ordem esperada (`*0, *01, ..., *5, *503, *8`) porque os códigos já nascem em ordem lexicográfica como string — não precisou de um campo `ordem` manual nem de endpoint de reorder, ao contrário de `LinkFerramenta`/`Favorito`/`WidgetUsuario`. Ao contrário de Telefones Externos, esta tabela **é seedada**: `seed_portal.py` popula as 13 linhas padrão (`FUNCOES_TELEFONIA_SEED`, `update_or_create` por `comando`) porque é documentação genérica de central telefônica, não dado específico da empresa — reexecutar `seed_portal` é seguro/idempotente, não duplica nem apaga linhas editadas manualmente (só atualiza `funcao`/`resumo` de um `comando` que já exista).
Nenhuma das duas subtelas tem endpoint de reorder — só criar/editar/excluir, mesmo escopo pedido.
### Modal de consulta rápida ("Ramais")
O botão "Ramais" do topbar (`#ramais-btn`, presente em `portal.html`/`links-ferramentas.html`/`calendario-individual.html` — as únicas 3 páginas que têm esse atalho; texto era "Acessar Ramais", encurtado depois) **não navega** para `ramais.html`; abre um modal somente-leitura (`ramais-lookup.js`/`ramais-lookup.css`) com a mesma listagem mesclada de `GET /api/ramais/`, inspirado numa tela do portal antigo (estilo DataTables: "Mostrar N registros", busca, colunas ordenáveis, paginação). Diferenças pro comportamento antigo do botão:
- Gate de acesso: some (`hidden`) se `permissoes_efetivas.ramais.apps["ramais-visualizar"]` for falso — mesmo padrão de qualquer UI gated por permissão no app.
- Busca é **uma só caixa** (não uma por coluna) que filtra por nome, departamento ou ramal ao mesmo tempo — mais simples que a paginação em duas caixas da própria `ramais.html`.
- **Botões de filtro por departamento** (`.ram-lookup-depto-filters`, acima da tabela): "Todos" + um botão por `Departamento` cadastrado, buscados de `GET /api/departamentos-resumo/` na primeira abertura (endpoint dedicado, `IsAuthenticated` + checagem manual de `permissao_app("ramais", "ramais-visualizar")` — não reaproveita `/api/departamentos/`, que exige `gerencia_permissoes` e bloquearia a maioria dos usuários que só têm acesso ao próprio Ramais). Clicar num botão filtra a listagem pra quem tem aquele departamento entre os seus (`departamento_exibicao.split(",")`, comparação exata após `trim` — não substring, pra não casar um departamento que seja prefixo de outro) e combina com a busca por texto (as duas condições precisam bater). Como os botões são gerados a partir da lista de departamentos vinda da API a cada abertura do modal, cadastrar um departamento novo em Usuários já basta pra ele aparecer aqui — não precisa mexer no frontend.
- Ordenação por coluna (clicar no cabeçalho alterna asc/desc) e paginação (`10`/`25`/`50`/`100` por página) são só client-side, sobre o array já carregado — sem endpoint novo, sem parâmetro de query; os `/api/ramais/`/`/api/departamentos-resumo/` são buscados uma única vez por abertura de página (cacheados em memória enquanto a página não recarrega) e refiltrados/reordenados em JS a cada tecla/clique.
- Botão "Ir para Controle de Ramais" no rodapé é o link de verdade pra `ramais.html` (tela completa, com edição) — o modal em si não tem nenhum controle de escrita, é só consulta.
Ver `docs/ramais.md`.
## Solicitações
Os 6 tópicos do menu "Solicitações" (`catalogo.MODULE_APPS["solicitacoes"]`) não são telas próprias — cada um (exceto "Ordem de Serviço", que ainda não tem link definido e continua com `href="#"`, mesmo padrão de qualquer aplicação-placeholder do portal) é só um link externo (hoje, um formulário do Asana) aberto em **nova aba** (`target="_blank" rel="noopener noreferrer"`), igual ao padrão já usado nos cartões de Links & Ferramentas.
Os itens do menu "Solicitações" não são telas próprias — cada um é um link externo (hoje, um formulário do Asana) aberto em nova aba; não dá pra embutir em iframe porque o Asana bloqueia.
**Por que não embutido em iframe**: a primeira versão desta seção tentava centralizar os 5 links num popup com `<iframe>` (numa página dedicada `solicitacoes.html`), inspirado no "Novo Chamado" de Ramais. Revertido no mesmo dia: o Asana bloqueia ser carregado em iframe de outro domínio via `X-Frame-Options`/`Content-Security-Policy: frame-ancestors` (proteção padrão contra clickjacking), então o navegador recusa a conexão (`net::ERR_BLOCKED_BY_RESPONSE`/"A conexão com form.asana.com foi recusada"). Isso não tem workaround no frontend — não confundir com o iframe de "Novo Chamado" em Ramais ou o de "Calendário De Paula" (ver nota logo após a tabela de páginas, em "Páginas" acima), que funcionam porque aquela outra ferramenta (`depaula-tvcorporativa.lovable.app`) não bloqueia embed. Não reintroduzir esse padrão de iframe pra Solicitações sem confirmar com o usuário que o destino realmente permite ser embutido.
Ver `docs/solicitacoes.md`.
## Simulação de Custo de Contratação (Geradoc)
Ferramenta que substitui a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal — calcula o custo de contratar um Empregado CLT e devolve um PDF pronto pra enviar ao cliente. Permissão de **toggle único** (`apps["simulacao-custo-contratacao"]` em `permissoes["geradoc"]`, sem par visualizar/editar), checada manualmente (`request.user.permissao_app("geradoc", "simulacao-custo-contratacao")`) nas duas views (não são `ModelViewSet` — são funções simples, `POST /api/simulacao-custo-contratacao/gerar/` e `GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`).
Calcula o custo de contratar um Empregado CLT (v1: só essa modalidade) a partir de tabelas fiscais de INSS/IRRF editáveis pelo banco, e devolve um PDF pronto pra enviar ao cliente.
- **Escopo v1: só Empregado CLT.** O pedido original mencionava 5 modalidades (Simples Nacional, Regime Normal, Pró-labore, Empregado, Empregado Doméstico), mas só havia planilha de referência validada pra Empregado CLT — as outras 4 ficam para quando houver uma fonte de regras equivalente confirmada pelo contador; não implementar "seguindo o mesmo padrão" por conta própria.
- **Sem persistência**: `POST /api/simulacao-custo-contratacao/gerar/` é um cálculo pontual — recebe os dados do formulário, calcula (`custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto (`HttpResponse` binário, `Content-Disposition: inline`), nada é salvo no banco. Diferente do padrão "com histórico" de `ImportacaoPlanoSaude`/`IndicadorApuracao`.
- **Tabelas de INSS/IRRF editáveis pelo banco**: `ParametroFiscalCustoContratacao` (`models.py`) é um singleton (`atual()`, sempre `pk=1`, criado sob demanda via `get_or_create`) com `faixas_inss`/`faixas_irrf` em `JSONField` (lista de `{limite_superior, aliquota, deduzir}`) + escalares (teto de desconto de INSS, alíquota/dedução do IRRF acima da última faixa, desconto simplificado do IRRF, dedução por dependente, e os 3 parâmetros da redução da Lei 15.270/2025 — coeficientes A/B e limite de rendimento bruto), editáveis pelo painel colapsável da própria tela (`GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`, mesma permissão de quem usa a simulação). `custo_contratacao/tabelas.py` continua existindo só como **seed/default** da primeira criação da linha (`_faixas_inss_padrao`/`_faixas_irrf_padrao` em `models.py`) — `calculo.py` nunca lê `tabelas.py` direto, sempre recebe um `ParametrosFiscais` (dataclass pura, sem ORM) montado por `ParametroFiscalCustoContratacao.para_calculo()`.
- **Redução de IRRF da Lei nº 15.270/2025** (art. 3º-A da Lei 9.250/1995, vigente desde jan/2026): isenção total até R$5.000 de rendimento bruto mensal, redução decrescente até zerar em R$7.350 — `redução = max(0, coeficiente_a − coeficiente_b × rendimento bruto)`, aplicada por cima do imposto já calculado pela tabela progressiva tradicional (que a lei não alterou), nunca deixando o imposto final negativo.
- **Correção deliberada em relação à planilha original**: a planilha nunca somava a dedução por dependente (R$189,59/dependente) à base do IRRF quando usava o desconto real de INSS — só quando usava o desconto simplificado (que por lei substitui os dois). Confirmado como gap com o usuário e corrigido: ao usar o desconto real de INSS, a dedução por dependente também é subtraída agora (`custo_contratacao/calculo.py`).
- **PDF via `reportlab`** (pure-Python, sem dependência nativa problemática no Windows) — cabeçalho é um banner marrom escuro com `logo-branco.png` + "De Paula Contadores", nas cores reais da marca (dourado `#D3AF4D`, marrom `#4A3C28`, amostradas do próprio `logo.png`), não o roxo do tema de interface do Portal.
- Localização no menu (dentro de **Geradoc**, ao lado de "Gerar Contrato"/"Gerar Procuração") foi escolha explícita do usuário, não Utilitários.
Ver `portal_api/custo_contratacao/CLAUDE.md`.
## Indicador de Desempenho (Geradoc)
Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), antes feita numa planilha (`FISCO CONTABIL *.ods`, em `projects/Indicadores/`) com fórmulas quebradas por edições manuais acumuladas. Mesmo padrão de permissão de **toggle único** de Simulação de Custo de Contratação (`apps["indicador-desempenho"]` em `permissoes["geradoc"]`, checado por `PermissaoApp("geradoc", "indicador-desempenho")` em todos os `ModelViewSet` relacionados). Pacote de negócio em `portal_api/indicadores/` (sem ORM): `tipos.py`, `leiaute.py`, `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py`, `departamentos.py`.
Apuração mensal do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), a partir de planilhas do Tareffa (Serviços/Honorários), com critérios/percentuais configuráveis pela própria tela e geração de recibo em PDF por colaborador.
- **Escopo v1: só o Fiscontábil**, papéis Balancete/Liberação Fiscal/Conciliação Financeira. Outros departamentos ficam pra rodada futura — **exceto pela estrutura de cadastro em si** (ver "Departamento organizacional" abaixo), que já suporta múltiplos departamentos com critérios/percentuais próprios, mesmo que só o Fisco/Contábil tenha regras cadastradas até agora.
- **8 models** (migrações `0024`–`0028`, `0033`–`0035`): `IndicadorDepartamento` (cadastro de departamentos — nome/ativo — usado pra escopar critérios, percentuais e metas de Departamento; ver "Departamento organizacional" abaixo), `IndicadorDepartamentoGerente` (relação gerente→departamento, mantida manualmente pela aplicação), `IndicadorPercentualTipo` (percentuais individual/grupo/departamento por tipo de colaborador **e por departamento**, histórico via `vigente_desde` — nunca editado in-place), `IndicadorCriterio` (cadastro genérico de critério: **departamento**/nome/grupo/peso/período/papel/`calculo_automatico`/`limiar_percentual`), `IndicadorApuracao` (uma apuração mensal — `competencia`, `status` `revisao`/`concluida`, as 2 planilhas anexadas, `avisos` de processamento), `IndicadorApuracaoColaborador` (um colaborador dentro de uma apuração, com `pct_individual`/`pct_grupo`/`pct_departamento` e respectivos flags `*_ajustado_manualmente`, mais `departamento` — FK pra `IndicadorDepartamento`, resolvida na criação via o gerente do colaborador, ver "Departamento organizacional" abaixo), `IndicadorApuracaoEmpresa` (uma empresa/honorário do colaborador naquele mês) e `IndicadorApuracaoResposta` (SIM/NÃO/NÃO FAZ/NÃO SE APLICA de um colaborador para um critério).
- **Tipo do colaborador é derivado por empresa, não é cadastro**: `TIPO_COLABORADOR_INDICADOR_CHOICES` (Contábil+Fiscal/Contador SC/Contador CC/Fiscal/Conciliador) — regra em `indicadores/tipos.py`, validada contra um recibo-modelo real (~99,99% de precisão no teste com 43 colaboradores).
- **3 critérios são calculados automaticamente** a partir da planilha "Serviços Tareffa" (`indicadores/entregas.py`/`pipeline.py`) — entrega de balancetes/liberações fiscais/conciliações no prazo, comparadas contra `IndicadorCriterio.limiar_percentual` pra decidir SIM/NÃO. Todo o resto é sempre marcação manual do RH (SIM/NÃO/NÃO FAZ/NÃO SE APLICA por critério, individual ou em lote). Um critério automático sem nenhum registro do serviço vira **NÃO SE APLICA**, não NÃO FAZ — permite deixar `papel_aplicavel` em branco nesses 3 critérios (um mesmo critério, ex. "balancete", se aplica a 3 tipos ao mesmo tempo, e `papel_aplicavel` só aceita um valor); quem não presta aquele serviço fica de fora do cálculo por conta própria (NÃO SE APLICA é excluído do denominador em `calculo.py`).
- **Fórmula**: `honorario_ajustado = honorario_empresa × pct_individual_do_colaborador`; `valor_individual = honorario_ajustado × percentual_individual(tipo)`; `valor_grupo`/`valor_departamento = valor_individual × percentual_grupo/departamento(tipo) × pct_grupo/departamento_do_colaborador`. `pct_individual` **não é só a média dos critérios Individual** — é a composição ponderada dos 3 níveis (Individual/Grupo/Departamento), cada um pesando conforme o peso médio dos seus próprios critérios aplicáveis na competência (`calculo._combina_niveis`/`_peso_medio_nivel`); só `pct_grupo`/`pct_departamento` continuam sendo a média simples dos próprios critérios, sem composição.
- **"Cada gerente representa um grupo", cada departamento representa um departamento** (não é redundante, ver abaixo): `pct_grupo` é conceitualmente compartilhado por todos os colaboradores com o mesmo `gerente` dentro da apuração, e `pct_departamento` é compartilhado por todos os colaboradores do mesmo `IndicadorDepartamento` (ver "Departamento organizacional" abaixo — não mais um valor único pra toda a apuração) — por isso não são ajustados colaborador a colaborador (`IndicadorApuracaoColaboradorViewSet` só cobre `pct_individual`); `IndicadorApuracaoViewSet.ajustar_grupo`/`recalcular_grupo`/`ajustar_departamento`/`recalcular_departamento` aplicam a mudança de uma vez a todo o grupo/departamento, mantendo os colaboradores em sincronia (`ajustar_departamento`/`recalcular_departamento` recebem `departamento` — o id do `IndicadorDepartamento` — no corpo, filtrando com um `.filter(departamento_id=...)` direto). A tela (`indicador-desempenho.js`) reflete isso com uma tabela de "Metas de Grupo e Departamento" no topo (uma linha de Departamento por `IndicadorDepartamento` + uma linha de Grupo por gerente dentro dele) separada da lista de colaboradores abaixo (que serve só pra revisão individual — percentual Individual, respostas de critério, recibo); botões de filtro por departamento (`#ind-filtro-departamento`, ver "Departamento organizacional" abaixo) restringem a tabela de Metas e a lista de colaboradores a um departamento de cada vez, sem afetar o cálculo de nenhuma meta. Colaborador cujo gerente não está mapeado a nenhum departamento cai num grupo "Sem departamento definido" (sem `<select>` de meta — não há `IndicadorDepartamento` pra aplicar). **A meta de Grupo/Departamento é sempre Sim/Não (100%/0%)**, nunca um percentual livre — decisão explícita do usuário ("será pago ou não") — por isso a coluna "Meta (%)" dessa tabela é um `<select class="ind-meta-select">` com só essas duas opções (`ehSim = valor >= 50` decide qual aparece selecionada ao carregar), não um campo de texto livre; o valor enviado a `ajustar-grupo`/`ajustar-departamento` é sempre `"100"` ou `"0"`. O percentual Individual de cada colaborador continua livre (é uma composição ponderada dos 3 níveis, pode legitimamente ser fracionário — ver acima).
- **Departamento organizacional** (`IndicadorDepartamento`/`IndicadorDepartamentoGerente`/`portal_api.indicadores.departamentos`) — substituiu, numa rodada posterior, o mecanismo de "setor" (coluna bruta "departamento" da planilha Tareffa + fusão automática Contabilidade/Fiscal→Fisco-Contábil + `IndicadorSetorApelido`, cadastro-exceção por colaborador). Agora **critérios e percentuais também são configurados por departamento** (não só as metas de Grupo/Departamento) — decisão explícita do usuário: a regra do Fisco/Contábil pode ser diferente da regra do Condomínio, por exemplo. `IndicadorDepartamento` (nome/ativo) é um cadastro simples, mantido pela própria aplicação (Configurações → Departamentos); a relação com gerentes (`IndicadorDepartamentoGerente`, `nome_gerente` único — um gerente pertence a só um departamento, mas um departamento pode ter vários gerentes, ex.: Fisco/Contábil tem "João Candido Rodrigues" **e** "Lhais Vergilio Delavy") também é mantida manualmente por ora — alimentar isso automaticamente a partir da planilha fica pra uma rodada futura (decisão explícita do usuário). Pra não obrigar o RH a redigitar nomes (arriscando um typo que faria uma apuração futura não casar com o departamento certo), o popup "Gerenciar Gerentes" (`indicador-desempenho.js`) mostra uma lista de **sugestões clicáveis** — `carregarGerentesSugeridos()` busca a apuração mais recente (`GET /api/indicadores-apuracoes/`, já ordenada por `-competencia`/`-criado_em`) e lista os nomes distintos de `colaborador.gerente` que ainda não estão em nenhum `IndicadorDepartamentoGerente`; clicar numa sugestão já cria a relação pra aquele departamento. É só um atalho de UI (não muda a origem do dado) — o campo de texto livre continua disponível pra gerentes que não apareceram na última apuração.
Resolução do departamento de um colaborador: `IndicadorApuracaoViewSet.create()` monta `mapa_gerentes` (`departamentos.carrega_mapa_gerentes()`, `{nome_gerente: departamento_id}`) uma vez e passa pro `pipeline.processa_apuracao()`, que resolve `departamento_id = mapa_gerentes.get(colaborador.gerente)` pra cada colaborador **antes** de decidir quais critérios automáticos calcular pra ele (críticos automáticos também são agrupados por `departamento_id` — `criterios_automaticos_por_departamento`, já que departamentos diferentes podem ter critérios/limiares diferentes). O resultado (`IndicadorApuracaoColaborador.departamento`, FK nullable) é um **retrato daquele momento** — mesmo espírito de `gerente`/`setor` antes dele: se a relação gerente→departamento mudar depois, apurações já criadas não mudam sozinhas. Colaborador cujo gerente não está mapeado a nenhum departamento fica com `departamento=None` e vira um aviso no processamento ("Gerente X não está associado a nenhum departamento..."), além de não ganhar nenhuma `IndicadorApuracaoResposta` (sem departamento, não há de onde vir nenhum critério). `IndicadorApuracaoColaboradorSerializer` expõe `departamento` (id) + `departamento_nome` (com fallback `None`, mesmo padrão de `criado_por_nome`).
**Migração em 3 passos** (mesmo padrão de qualquer FK obrigatória adicionada a uma tabela já populada): `0033` cria os 2 models novos + adiciona `departamento` nullable em `IndicadorCriterio`/`IndicadorPercentualTipo`/`IndicadorApuracaoColaborador` (e remove `setor`/`IndicadorSetorApelido`); `0034` (`RunPython`) cria o departamento "Fisco/Contábil" e aponta todo `IndicadorCriterio`/`IndicadorPercentualTipo` já existente pra ele (é literalmente o que a regra única representava até então); `0035` torna `departamento` obrigatório em `IndicadorCriterio`/`IndicadorPercentualTipo` (não em `IndicadorApuracaoColaborador`, que continua nullable). **Apurações criadas antes desta migração** (e qualquer apuração nova, até o admin mapear os gerentes relevantes em Configurações → Departamentos) ficam com `departamento` em branco em todos os colaboradores — precisam de um backfill pontual ou de serem reprocessadas depois que a relação gerente→departamento existir.
**Limitação conhecida, validada com dados reais**: como a resolução é por `gerente` (não por colaborador), dois subordinados diretos do mesmo gerente sempre caem no mesmo departamento — isso quebra o caso de uma gerente que supervisiona pessoas de **departamentos diferentes**. Ex. real: "Elizangela de Paula Kuhn" supervisiona diretamente os líderes de Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos — departamento "Gerentes") **e** Luciane Gonzaga (que deveria cair em "Rocket", já que ela chefia esse outro departamento) — como todos compartilham o mesmo `gerente`, mapear "Elizangela de Paula Kuhn" → "Gerentes" também classifica Luciane Gonzaga como "Gerentes", não "Rocket". Não existe mais um mecanismo de exceção por colaborador individual (o antigo `IndicadorSetorApelido` cobria exatamente esse tipo de caso) — se isso for um problema real, precisa ser resolvido numa rodada futura (ex.: reintroduzindo uma exceção por nome de colaborador, por cima da relação gerente→departamento).
- **Detalhamento da composição no card do colaborador** (`portal_api.indicadores.calculo.composicao_individual`, exposto como o campo `composicao_individual` de `IndicadorApuracaoColaboradorSerializer`): reconstrói, só pra exibição, o percentual bruto de Individual (antes da composição) e o peso médio de cada um dos 3 níveis (`_peso_medio_nivel`) — dados que `recalcula_colaborador` calcula mas não persiste, por não precisar deles depois de gravar `pct_individual`. No cabeçalho do card (`indicador-desempenho.js`), essa linha ("Individual: X% (peso Y%) · Grupo: X% (peso Y%) · Departamento: X% (peso Y%)") fica ao lado do nome/gerente, numa coluna própria do grid centralizada — não embaixo — e cada um dos 3 níveis fica verde/vermelho conforme bateu 100% ou não; o "Total Indicador" (renomeado de "Individual", que é `pct_individual`, com o lápis de ajuste manual sempre ao lado do valor numa linha que não quebra) fica neutro, sem cor, pra não repetir a mesma informação 4 vezes. `composicao_individual()` usa `colaborador.respostas.all()` (não `.select_related("criterio")`) de propósito, pra reaproveitar o `prefetch_related("colaboradores__respostas__criterio")` que `IndicadorApuracaoViewSet.get_queryset()` aplica só na action `retrieve` — evita 1 query extra por colaborador ao abrir a tela de revisão.
- **Tabela "Metas de Grupo e Departamento" só tem uma forma de responder Sim/Não por critério** — a coluna "Meta (%)" (ajusta `pct_grupo`/`pct_departamento` direto). Existia um segundo `<select>` Sim/Não ao lado do texto de cada critério (bulk, via `aplicar-em-lote`), removido por ser redundante com o da direita; a lista de critérios ali agora é só informativa (nome + peso). Responder um critério específico continua possível por colaborador, dentro da lista de colaboradores abaixo (`renderRespostasGrupoHtml`).
- **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável...") pra nunca reatribuir sem escolha explícita.
- **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar (`renderRevisao()`) — atualiza só o card clicado localmente, pra não fechar outros cards já expandidos nem perder a posição de rolagem no meio de uma conferência longa; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados.
- **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério.
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico.
- **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado.
- **Layout do PDF do recibo** (`indicadores/recibo.py`): o banner "PERCENTUAL DO INDICADOR INDIVIDUAL" sempre mostra o percentual **efetivo/medido** (`calculo.composicao_individual()["total_calculado"]` — a composição dos 3 níveis recalculada na hora, ignorando qualquer ajuste manual), não `colaborador.pct_individual` puro — decisão explícita do usuário: se o RH/Diretoria sobrescreveu o Individual pra 100%, o banner precisa continuar mostrando o que o colaborador de fato atingiu (ex.: 74,29%), não o valor pago. Quando `pct_individual_ajustado_manualmente=True`, uma linha de detalhe abaixo do banner mostra "Percentual Individual Ajustado Pela Direção: **100,00%**." (rótulo renomeado de "ajustado manualmente pelo RH", com o valor ajustado ao lado — antes só dizia que tinha sido ajustado, sem mostrar pra quanto) — os dois números lado a lado deixam claro o que foi medido e o que foi pago. Tabela "Empresas": toda célula (antes só "Empresa" era `Paragraph`, o resto strings soltas) virou `Paragraph` com estilo de alinhamento próprio (`celula_centro`/`celula_direita`/`celula_negrito`/`celula_direita_negrito`, `_estilos()`) — string solta não quebra linha dentro da coluna, e com `ALIGN` à direita/centro um valor mais largo que a coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em negrito, mais largos que a mesma string em peso normal) vazava visualmente por cima da célula vizinha em vez de quebrar linha — bug real visto com dados reais (coluna Tipo cobria "Hon. Ajustado"). Coluna "Tipo" ganhou um dicionário de labels curtos só pro PDF (`TIPO_LABEL_CURTO`: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)" — mesma abreviação já usada informalmente neste documento) porque o label completo não cabia nem quebrando linha numa coluna estreita; a linha de total virou "Total do Indicador" (era "Total Resultado"). Larguras de coluna e padding lateral (`LEFTPADDING`/`RIGHTPADDING`, reduzidos de 6pt padrão do reportlab pra 3pt) ajustados pra caber os maiores valores reais vistos na apuração (ex.: R$ 28.023,16) numa linha só. `_moeda()` usa `&nbsp;` (não espaço comum) entre "R$" e o número — com espaço comum, quando o valor não cabia numa linha só, o reportlab quebrava exatamente ali, deixando "R$" sozinho numa linha acima do número; com espaço não separável, o "R$" fica sempre grudado à esquerda do número (mesmo que precise de mais espaço na coluna pra caber tudo numa linha, resolvido junto pelas larguras/padding acima). Rótulo da linha de detalhe é "Percentual individual ajustado pela direção" (minúsculo, só a primeira letra maiúscula — não "Percentual Individual Ajustado Pela Direção").
- **`aplicar_em_lote`** (`IndicadorApuracaoRespostaViewSet`, `POST /api/indicadores-apuracoes-respostas/aplicar-em-lote/`) aplica o mesmo valor a várias respostas de critério de uma vez — a "múltipla seleção" pedida pelo usuário na tela de revisão.
- **"Empresas sem Honorário"** (`#ind-empresas-sem-honorario-btn`, cor de atenção — `--danger`, mesma linguagem visual do input/selo de honorário não encontrado, **só enquanto houver alguma empresa pendente** — sem nada pra resolver, o botão perde a classe `.ind-empresas-sem-honorario-btn` (volta a `.btn-outline` neutro) e o texto vira "Visualizar Empresas com Honorário Ajustado (N)", apontando direto pra revisão do que já foi ajustado — substituiu o antigo checkbox "Só com honorário não encontrado" que filtrava a lista de colaboradores): abre um modal que agrupa por `codigo_empresa` todas as `IndicadorApuracaoEmpresa` com `honorario_nao_encontrado=True` da apuração — a mesma empresa pode aparecer sob mais de um colaborador (um responsável pelo balancete, outro pela liberação fiscal, outro pela conciliação financeira), mas o honorário é da empresa, não da pessoa. Preencher um valor ali chama `POST /api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` (`IndicadorApuracaoViewSet.ajustar_honorario_empresa`, serializer `IndicadorApuracaoAjusteHonorarioEmpresaSerializer` com `{codigo_empresa, honorario}`), que atualiza **todas** as linhas daquele código nesta apuração de uma vez (recalculando cada colaborador afetado) — diferente de `PATCH /api/indicadores-apuracoes-empresas/{id}/` (ainda existe, ajusta só uma linha por id, usado direto na tabela "Empresas" de dentro do card do colaborador). Os dois caminhos (linha única e em lote) marcam `honorario_ajustado_manualmente=True` na(s) linha(s) afetada(s) — fica permanente (sem UI de reverter, diferente de `pct_individual_ajustado_manualmente`/etc., já que não existe um "automático" pra voltar quando o código nunca casou com a planilha) e vira uma nota "honorário ajustado manualmente" (cor `--accent`) ao lado do valor, na tabela "Empresas" de dentro do card do colaborador — visível só depois que `honorario_nao_encontrado` já foi resolvido (os dois nunca aparecem juntos). Essa tabela também passou a listar por `codigo_empresa` (`IndicadorApuracaoEmpresa.Meta.ordering`, migração `0029`), não mais por nome. Como `codigo_empresa` é `CharField`, ordenar só por ele é ordem alfabética, não numérica — "80"/"503" apareciam depois de "2134" (o caractere `'8'`/`'5'` é "maior" que `'1'`/`'2'`, mesmo o número sendo menor). Corrigido (migração `0030`) ordenando primeiro pelo **tamanho** da string (`Length("codigo_empresa")`) e só depois pelo valor — reproduz a ordem numérica certa pra códigos sem zero à esquerda (string mais curta = número menor, sempre) sem converter pra inteiro, o que quebraria com erro de banco se algum código um dia não fosse só dígitos.
- **"Empresas ajustadas manualmente"** é uma **segunda seção dentro do mesmo popup** "Empresas sem Honorário" — não um segundo botão/modal (revertido de propósito: nasceu como um botão separado, "Verificar Empresas Ajustadas Manualmente", e o usuário pediu pra unificar num popup só, "facilitando a usabilidade da ferramenta"). Fica **escondida por padrão**, atrás de um botão de largura cheia no final da lista principal (`#ind-empresas-ajustadas-toggle-btn`, `.ind-esh-toggle-btn`, com contador — "Visualizar"/"Ocultar Empresas Ajustadas Manualmente (N)") — pedido explícito do usuário logo depois de testar a versão anterior (as duas seções sempre visíveis de uma vez): a lista secundária só deve aparecer sob demanda, no final do modal. Lista, também agrupada por `codigo_empresa`, as empresas com `honorario_ajustado_manualmente=True` — permite **corrigir** um valor já ajustado (campo já vem preenchido com o honorário atual, ao contrário da lista principal, que começa em branco). Reaproveita o mesmo endpoint `ajustar-honorario-empresa` — o filtro do backend cobre `Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True)`, nunca uma empresa cujo honorário só veio certo da planilha e nunca foi mexido. As duas listas compartilham as funções de agrupamento/renderização/ordenação (`empresasAgrupadasPorCodigo`, `renderEmpresaGrupoItemHtml`) em `indicador-desempenho.js`, parametrizadas só pelo filtro; `renderEmpresasHonorario()` sempre re-renderiza a lista principal e só re-renderiza a de "ajustadas" **se a seção já estiver aberta** (ao abrir o popup, essa seção sempre volta a fechar) — necessário porque corrigir uma empresa "sem honorário" faz ela migrar pra lista de "ajustadas" na hora, e essa migração só precisa refletir de imediato se o usuário já estiver olhando pra ela. As duas ficam dentro de um único wrapper que rola (`.ind-esh-scroll`), com título e "Fechar" sempre visíveis fora dele (mesmo `max-height:85vh` do popup). Em cada item, o código aparece **antes** do nome da empresa no cabeçalho (`.ind-esh-codigo` seguido de `.ind-esh-nome`), mesma ordem da tabela "Empresas" do colaborador.
- **"Ajuste Indicador em Lote"** (`#ind-lote-global-btn`, `indicador-desempenho.js`): modal separado do anterior — ajusta `pct_individual` (não critérios) de vários colaboradores **selecionados por nome** de uma vez, pra dois casos binários só: "Ajustar" (`#ind-lote-global-ajustar-btn`, aplica `pct_individual=100` a todos, via `PATCH /api/indicadores-apuracoes-colaboradores/{id}/`) ou "Reverter" (chama a action `recalcular` de cada um, voltando ao cálculo automático) — sem campo de percentual livre. Os dois botões são `btn-solid` (mesma cor) — só "Cancelar" fica `btn-outline`, já que as duas ações são igualmente "reais", não uma primária e uma secundária. Não existe endpoint de lote dedicado pra isso; o frontend dispara um PATCH/POST por colaborador em paralelo (`Promise.all`).
- **Percentuais/critérios são cadastro editável pela tela**, não hardcoded — decisão explícita do usuário pra não fixar no código números incertos vindos da planilha antiga já quebrada. Primeiro histórico populado via `python manage.py seed_indicador_desempenho` (idempotente), com os valores exatos da planilha antiga (`vigente_desde` fixado em 01/01/2024 por falta de data documentada — ajustar se o usuário informar a data real).
- **Bugs de robustez corrigidos ao testar com 43 colaboradores reais**: `openpyxl.load_workbook(..., read_only=True)` precisa de `.close()` explícito (`indicadores/leiaute.py`), senão o Windows mantém o upload memory-mapped e bloqueia excluir a apuração depois; campos percentuais precisaram de `max_digits=7` (não 6) — qualquer `DecimalField` que representa um percentual "de 0 a 100" precisa de `max_digits >= decimal_places + 3` pra caber o "100" exato sem `DataError: numeric field overflow`.
Ver `portal_api/indicadores/CLAUDE.md`.
## Importação de Plano de Saúde (Utilitários)
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).
Importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, SulAmérica...) 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.
**A lógica de negócio em si não nasceu neste projeto** — veio de um pipeline Python já testado e documentado em `projects/importacao-planos-saude.skill` (arquivo `.skill`, é um zip — `SKILL.md` + `scripts/`), com um protótipo funcional em `projects/project/` (CLI `main.py`, nunca tocado pelo Portal, fica só como referência/histórico). Esse pipeline foi portado quase 1:1 para dentro do Django em **`portal_api/planos_saude/`** (pacote Python puro, sem depender do ORM):
```
portal_api/planos_saude/
├── modelos.py Lancamento, Individuo, LinhaSistema, ItemAuditoria (dataclasses)
├── matcher.py casa_individuos_com_planilha() — casamento por CPF ou por nome
├── leiaute_sistema.py CABECALHO, le_planilha_padrao(), formata_valor_br()
├── pipeline.py OPERADORAS (registro), processa_importacao() — orquestração, chamada pela view
└── operadoras/
├── base.py OperadoraParser (interface)
├── amil/odonto_mensalidade.py Amil Odonto (PDF via pdfplumber, só mensalidade, casamento por CPF)
├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo)
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── 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)
```
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**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 Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - <competência>") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`.
### Modelos (`portal_api/models.py`)
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), `planilha_padrao` (`FileField`, mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável. O arquivo (ou arquivos) da operadora vive num model relacionado separado, ver `ImportacaoPlanoSaudeArquivoOperadora` a seguir e "Múltiplos arquivos de operadora" abaixo.
- `ImportacaoPlanoSaudeArquivoOperadora`: um dos relatórios da operadora anexados a uma importação (FK `importacao`, `arquivo` FileField, `ordem`) — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde em PDF) mandam mensalidade e coparticipação em arquivos separados. Substituiu, numa rodada posterior, o antigo `FileField` único `ImportacaoPlanoSaude.arquivo_operadora` (migração em 3 passos — `0048` cria o model novo + torna o campo legado `blank=True`; `0049`, `RunPython`, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo em `MEDIA_ROOT`, sem copiar bytes; `0050` remove o campo legado — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`).
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.
### Fluxo e endpoints
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo em `arquivo_operadora` (lista, ver "Múltiplos arquivos de operadora" abaixo) e roda `pipeline.processa_importacao()` **de forma síncrona** usando os caminhos de todos os arquivos da operadora + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos (planilha + todos os da operadora) + o registro órfão e devolve 400.
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `"<código> - <Nome>"` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend.
- `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão").
- `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`.
`ImportacaoPlanoSaudeLinhaViewSet` (`/api/importacoes-plano-saude-linhas/{id}/`, só GET/PATCH): edição de uma linha por vez, disparada por `blur`/`change` de cada `<input>` na tela de revisão — mesma permissão de toggle único, sem checagem de "dono". `create()`/`partial_update()`/`destroy()` recusam (400) se a importação já estiver `concluida` — ver "Trava de edição pós-conclusão" abaixo.
`ImportacaoPlanoSaudeAuditoriaViewSet` (`/api/importacoes-plano-saude-auditoria/{id}/resolver/`, só `POST`) — ver seção própria abaixo; também recusa se a importação estiver `concluida`.
**Histórico (`#ips-list-table`): ordenação por coluna + filtro "estilo Excel" por coluna, os dois client-side** sobre o array já carregado (`GET /api/importacoes-plano-saude/`, sem paginação/filtro no servidor) — mesmo padrão de ordenação já usado em `#ua-table`/Ramais (`th[data-sort]`, ícone `↕`). O filtro (`criarFiltroColuna()`, `importacao-plano-saude.js`) nasceu como um segundo campo de texto por coluna, mas foi revisto a pedido do usuário pra imitar o filtro de planilha (Excel/Sheets): um botão de funil dentro do próprio `<th>` de cada coluna filtrável (Cód. Empresa/Operadora/Status/Criado por) abre um popup com busca + checklist dos **valores distintos daquela coluna** (reaproveita `.checklist-box`/`.checklist-search`/`.checklist-select-all`/`.modal-checkbox` de `components.css` — mesmo componente já usado nos checklists de Perfis de Acesso), tudo desmarcável/marcável, com "Aplicar"/"Limpar" no rodapé (só aplica no clique, não a cada checkbox — evita re-renderizar a lista principal a cada toque). Os filtros das 4 colunas combinam entre si (AND). `listFiltros[campo]` vale `null` (sem filtro) ou um `Set` dos valores brutos marcados; marcar **todos** os valores existentes equivale a `null` (sem filtro), pra um valor novo que apareça depois (operadora nova, por exemplo) não nascer excluído até o usuário marcá-lo manualmente. O botão de funil ganha `.is-active` (cor de destaque) enquanto a coluna tiver um filtro aplicado — mesmo sinal visual do funil "azul" do Excel. `.pa-table-wrap` normalmente usa `overflow:hidden` pra arredondar os cantos da tabela; só a tabela do histórico (`.ips-list-table-wrap`) sobrescreve pra `visible`, senão o popup (que precisa aparecer por cima das linhas, não só dentro do cabeçalho) seria cortado ali. Qualquer mudança de filtro (ou de ordenação) volta pra página 1. `listEmpty` mostra uma mensagem diferente conforme o caso: "Nenhuma importação realizada ainda." quando o histórico está mesmo vazio, "Nenhuma importação encontrada com esse filtro." quando o vazio é só resultado do filtro aplicado.
### Trava de edição pós-conclusão
Depois que `POST /{id}/gerar/` marca uma importação como `concluida`, editar/incluir/excluir uma linha (`ImportacaoPlanoSaudeLinhaViewSet`), resolver um item de auditoria (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`) ou reverter uma alteração (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) passam a ser recusados (400) — decisão explícita do usuário, depois de ver que a tabela de revisão continuava 100% editável mesmo depois do arquivo já ter sido gerado e entregue. `_garante_importacao_em_revisao(importacao)` (função módulo-level em `views.py`, chamada no início de cada um desses pontos de escrita) é o único lugar que checa isso — levanta `ValidationError` com uma mensagem pedindo pra usar o botão "Editar" (`POST /{id}/reabrir/`) primeiro. `gerar()` em si nunca é bloqueado (pode ser chamado de novo com a importação já `concluida`, só regera o mesmo arquivo a partir do que está salvo).
No frontend (`importacao-plano-saude.js`), a tela de Revisão espelha essa trava puramente pra UX (a validação real é sempre a do backend acima): com `importacaoAtual.status === "concluida"`, toda célula da tabela de Mensalidade/Coparticipação vira texto (não `<input>`, nem Valor Empresa/Valor — a regra de "só Valor Empresa/Valor editáveis" descrita no bullet de `ImportacaoPlanoSaudeLinha` acima só se aplica quando a importação ainda está em revisão), o "×" de remover linha e o botão "Adicionar linha" somem, "Vincular pessoa" (Auditoria) e "Reverter" (Alterações) também somem. O botão "Editar" (`#ips-review-editar-btn`, ao lado de "Gerar Arquivo") aparece só nesse estado e chama `POST /{id}/reabrir/`, atualizando `importacaoAtual` e re-renderizando as abas.
Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela.
### Múltiplos arquivos de operadora
Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação") era um único upload obrigatório — trocado por **1 ou mais arquivos** (pedido explícito do usuário): algumas operadoras mandam mensalidade e coparticipação em arquivos separados (a primeira real: Unimed Saúde, quando manda PDF em vez do CSV único — ver "Parser da Unimed Saúde" abaixo), em vez de um único arquivo com os dois tipos juntos.
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.arquivo_operadora` é um `ListField(child=FileField(), allow_empty=False)` — o DRF já lê múltiplos arquivos do mesmo campo em `multipart/form-data` via `request.data.getlist(...)` (mesma semântica do `QueryDict`), sem tratamento manual extra na view. `create()` cria um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo (`ordem=índice`); `pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)` chama `OperadoraParser.extrai()` **uma vez por caminho** (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só o `pipeline.py`) e concatena os indivíduos/itens de auditoria de todos os arquivos antes de seguir com o casamento normal.
- **`_agrega_individuos_entre_arquivos()` (`pipeline.py`)** — bug real encontrado e corrigido ao testar esta funcionalidade de ponta a ponta: se dois arquivos contribuem indivíduos da MESMA pessoa e do MESMO `tipo_lancamento` (ex.: duas coparticipações do mesmo mês, separadas por período), só concatenar as duas listas não bastava — `casa_individuos_com_planilha`/`_aplica_regra_custeio` (matcher.py) **grava** o valor final na `LinhaSistema` por pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (`valor_total` e `rubricas`) os indivíduos de mesma chave (`numero_beneficiario`, `tipo_lancamento`) **entre arquivos**, logo depois de concatenar as listas — mesmo padrão que cada parser já faz **dentro** de um único arquivo (`_agrega_por_individuo_e_tipo`), só que agora entre arquivos também.
- `perform_destroy()`/os `except` de `create()` (arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam **todos** os arquivos de `importacao.arquivos_operadora.all()` de `MEDIA_ROOT`, não só um.
- **Frontend**: `<input type="file" multiple>` + uma lista dinâmica (`#ips-form-arquivo-list`/`.ips-arquivo-list`, `importacao-plano-saude.js`) no lugar do campo único de sempre — cada arquivo anexado é validado individualmente (mesmo endpoint `POST /.../validar-arquivo/` de sempre, chamado uma vez por arquivo, sem mudança nenhuma nele) e listado com seu próprio status + botão de remover; trocar a operadora revalida todos os arquivos já anexados. No submit, `formData.append("arquivo_operadora", file)` uma vez por arquivo.
#### Parser da Unimed Saúde (PDF): dois relatórios separados, tipo detectado automaticamente
`operadoras/unimed/saude.py` (`unimed_saude`, código 5060) ganhou um segundo formato de entrada, além do CSV único já existente: **dois PDFs** de um cliente real (mensalidade + coparticipação analítico), detectados automaticamente pelo **conteúdo** de cada arquivo — nunca pelo usuário escolhendo um "tipo de documento" (pedido explícito). `UnimedSaude.extrai()` abre o PDF com `pdfplumber` e olha a primeira página: `"BENEFICIARIOS COM FATURAMENTO NO MES"` → relatório de mensalidade (`_extrai_pdf_mensalidade`, uma linha por beneficiário, `extract_text()` simples já basta); `"SERVIÇOS PRESTADOS"`/`"ANALITICO"` → coparticipação analítica (`_extrai_pdf_coparticipacao`, várias linhas de serviço por beneficiário, somadas por pessoa).
- Confirmado com o usuário: a coparticipação devida por beneficiário é a **soma do "Vl Total" de cada linha de serviço** daquele beneficiário — a coluna "Tt Copar" (valor fixo, repetido em toda linha do documento) **não é usada**. Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um item, cuja soma já está no valor do item principal) são ignoradas — senão duplicariam o valor.
- `nome`/`Grau Dep.` (TITULAR/CONJUGE/FILHO(A)/...) só aparecem na primeira linha de cada bloco de atendimento — parsing com estado (mesmo padrão do `ItamedSaude`).
- Valores nos dois PDFs vêm em **formato americano** (ponto decimal, vírgula de milhar — ex. "6,061.74"), ao contrário do formato BR do resto do pipeline — `_valor_pdf_para_float`, função própria, separada de `_valor_para_float` (BR, só pro CSV).
- **`OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)`** (default: devolve `chave_casamento`, mesmo valor de sempre — método novo, backward-compatible pra todo outro parser) porque a coparticipação analítica da Unimed em PDF precisou de uma estratégia de casamento **diferente da mensalidade dentro da mesma operadora**: o "Beneficiario" desse relatório vem colado sem espaço com o nome e o grau de dependência (ex.: "0975.0167003824292ANDREIA STORMTITULAR") e o **nome sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM") — inviabilizando casamento por nome. Como esse relatório traz CPF completo e confiável, `UnimedSaude` usa `"cpf"` só pra `tipo_lancamento="coparticipacao"` quando a origem foi esse PDF (rastreado numa flag de instância, `self._veio_de_pdf_coparticipacao`, setada em `extrai()`); mensalidade (sem CPF em nenhum dos dois formatos) continua em `"nome"`. `pipeline.processa_importacao` chama `chave_casamento_para_tipo(tipo_lancamento)` em vez do atributo fixo.
- **Validado contra os dois arquivos reais** (não só texto colado numa conversa — o texto que sai de um PDF colado no chat **não é** o que `pdfplumber.extract_text()` de fato produz, então não serve pra desenhar regex com confiança; só o arquivo real confirma). Bate exatamente com "Total da Familia"/"Total da Sequencia" impresso no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e com o casamento por CPF contra a planilha padrão real da empresa — toda família presente na planilha bateu centavo a centavo; a família ausente da planilha de teste foi corretamente pra auditoria, não ignorada silenciosamente.
### Planilha padrão via Questor (SQL)
Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total.
- **Toggle na tela** (`importacao-plano-saude.js`/`.html`, dentro do bloco "1. Planilha padrão"): dois radios, "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente" — o primeiro revela um campo "Competência" **texto livre com máscara MM/AAAA** (`<input type="text" inputmode="numeric" placeholder="MM/AAAA" maxlength="7">` + `mascaraCompetencia()`/`competenciaParaIso()` em JS — deliberadamente **não** um `<input type="month">`: o seletor nativo do browser foi rejeitado pelo usuário como UX ruim; mesmo padrão de digitação livre já usado em `indicador-desempenho.js`/`pidIndMascaraCompetencia`, copiado aqui em vez de compartilhado, como as demais funções pequenas duplicadas entre telas), o segundo revela o `<input type="file">` de sempre. Trocar de modo limpa o outro campo, pra nunca mandar os dois juntos (o backend também recusa isso).
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.competencia` é um `serializers.DateField()` normal (mesmo padrão de `IndicadorApuracaoCreateSerializer.competencia`) — o frontend já manda o ISO `"AAAA-MM-01"` convertido a partir da máscara, nunca a string mascarada crua. `validate()` exige exatamente uma das duas origens (nunca as duas, nunca nenhuma).
- `ImportacaoPlanoSaudeViewSet.create()` (views.py): quando não veio arquivo, resolve a planilha **antes** de criar o registro — `portal_api.planos_saude.questor_planilha.busca_linhas_questor(codigo_empresa, codigo_operadora, competencia)` consulta `sqls.questor.QuestorSQL.consulta_planilha_plano_saude` (só leitura — `select_mappings_query`, nunca `execute`/`execute_returning`, ver [[feedback_bancos_externos_somente_leitura]]) via `DatabaseConnection("questor")`. Uma falha de conexão/consulta aqui devolve 400 direto, sem nada persistido ainda (diferente do caminho de upload, que só sabe se o arquivo é válido depois de já ter salvo o registro — por isso, nesse, o cleanup de arquivo/registro órfão continua sendo necessário). Zero linhas retornadas (empresa sem plano ativo na competência) também é 400. O resultado é serializado de volta pra CSV (`questor_planilha.linhas_para_csv_bytes`, mesmo formato de `leiaute_sistema.CABECALHO`) e salvo como `ContentFile` no próprio campo `planilha_padrao` — preserva o histórico completo mesmo pra importações que nunca tiveram upload. A "trava de conferência do código de empresa" (que confere que a planilha anexada tem alguma linha da empresa da regra) é pulada nesse caminho, redundante já que a consulta já filtrou por esse `codigo_empresa`.
- **`pipeline.processa_importacao`** deixou de ler o arquivo sozinho (não recebe mais `caminho_planilha_padrao: str`) — recebe `linhas_sistema_template: List[LinhaSistema]` já pronta, de qualquer uma das duas origens (`le_planilha_padrao(caminho)` pro upload, `busca_linhas_questor(...)` pro Questor) — a decisão de qual usar ficou inteiramente em `views.py create()`.
- **Código da operadora**: até então só existia embutido no `label` de `pipeline.OPERADORAS` (ex. `"5060 - Unimed Saúde"`, extraído por *string split* onde só o nome era preciso). Passou a existir como campo próprio (`OPERADORAS[chave]["codigo_operadora"]`, junto de `"nome"`) — é o valor usado pra filtrar a consulta por operadora (`codigooutemp` no Questor, código da OPERADORA, não confundir com `codigo_empresa` do cliente); `pipeline.label_operadora(chave)` calcula o `"<código> - <Nome>"` de exibição a partir desses dois campos onde ainda é preciso (`lista_operadoras()`, mensagens de erro, `nome_operadora` da importação).
- `ImportacaoPlanoSaude.competencia` (`DateField`, null) registra a competência usada — só preenchida quando a origem foi o Questor; exibida na tela de Revisão ao lado do nome da operadora.
### Resolução manual de auditoria por nome
Quando o casamento por nome falha (`NOME_DIVERGENTE`/`NAO_CADASTRADO` — ver `matcher.py`, "nunca resolvido por aproximação automática"), o colaborador pode confirmar manualmente que aquele item **é** uma pessoa específica já presente na planilha padrão, em vez de deixar o lançamento parado em auditoria pra sempre. Não é fuzzy matching nem aproximação automática — é sempre uma confirmação humana, explícita, item por item; a regra de "nome exato ou vai pra auditoria" do `matcher.py` continua intocada.
- **Endpoint**: `POST /api/importacoes-plano-saude-auditoria/{id}/resolver/` com `{"linha_id": <id>}`. Validações em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver` (views.py): o item precisa ter um motivo em `MOTIVOS_RESOLVIVEIS` e ainda não estar `resolvida` (idempotente — não dá pra resolver de novo, nem trocar o vínculo depois); a linha escolhida precisa (a) ser da mesma importação e do mesmo `tipo_lancamento` do item; (b) ser do mesmo "lado" — titular pra item `tipo="T"`, dependente pra `tipo!="T"` (D/A) — comparando `linha.nome_dependente`/`cpf_dependente` vazios ou não; (c) **ainda estar em branco** (`valor == valor_empresa == "0"`), decisão explícita do usuário pra nunca sobrescrever sem querer um lançamento que já casou automaticamente com outra pessoa do arquivo da operadora.
- Ao vincular, o `valor` do item de auditoria é dividido em `valor_empresa`/`valor` pela mesma regra de custeio já salva em `ImportacaoPlanoSaude.custeio_por_tipo[tipo_lancamento]` para aquele tipo de pessoa (titular/dependente) — `matcher.valores_formatados_para_pessoa(valor_total, regra_por_pessoa, tipo_pessoa)` é o único ponto de entrada público do módulo pra isso, reaproveitando as mesmas `_regra_para_pessoa`/`_calcula_valores` do fluxo automático (não existe uma segunda fórmula "manual"). **Exceção**: quando a importação tem `regra_empresa` configurada (ver "Regra empresa" abaixo) e o item é de `tipo_lancamento="mensalidade"`, esse caminho por pessoa não se aplica — bug real visto com dados reais, o valor caía inteiro em desconto do empregado, ignorando a regra empresa. `resolver()` grava o valor bruto do item na linha (placeholder) e chama `_recalcula_familia_regra_empresa(importacao, linha)`, que reúne **todas** as linhas de mensalidade da mesma família (`nome_func` igual) — recuperando o valor bruto de cada uma como `valor_empresa + valor`, soma que preserva o total independente do split aplicado antes — e reaplica a regra empresa (`REGRAS_EMPRESA[chave]["aplica"]`) na família inteira de uma vez, salvando todas as linhas afetadas (`bulk_update`). Precisa reaplicar na família toda, não só na linha recém-vinculada, porque o valor novo muda o total da família e o teto (`_aplica_teto_familia`, priorização dependente→titular) precisa ser redistribuído do zero.
- O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado.
- **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria.
### Vínculos de nome salvos (DE/PARA)
Depois de "Vincular pessoa" resolver manualmente uma divergência de nome, o usuário perguntou se ela precisava ser refeita em toda execução futura ou se podia ficar guardada, "como se fosse um DE/PARA" — decisão explícita do usuário: sim, guardar e reaplicar automaticamente, mostrando cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação/fuzzy matching (ver `matcher.py`) — o DE/PARA só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez; sem vínculo salvo, o comportamento é idêntico a antes (cai em auditoria).
- **Model** (`VinculoNomeOperadora`, migração `0051`): `operadora` (chave de `pipeline.OPERADORAS`), `codigo_empresa` (cru, sem normalizar — ver abaixo por quê), `nome_arquivo_operadora` (o nome divergente do arquivo da operadora, já normalizado via `matcher.normaliza_nome` — é a chave de busca), `nome_func_destino`/`nome_dependente_destino` (o nome real na planilha padrão — só um dos dois preenchido, conforme o vínculo seja de titular ou de dependente), `criado_em`/`criado_por`. `unique_together` em `(operadora, codigo_empresa, nome_arquivo_operadora)`.
- **`codigo_empresa` fica cru no model, normalizado só em `views.py`**: importar `empresas_questor.normalizar_codigo_empresa` dentro de `models.py` criaria um import circular (`empresas_questor.py` já importa `EmpresaQuestor` de `models.py`) — por isso a normalização acontece nos dois pontos de uso em `views.py` (`_carrega_vinculos_por_nome`, `resolver()`), que já importam essa função pra outros fins (ver "Nome da empresa (Questor)" acima).
- **Gravado em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()`** (mesma view de "Vincular pessoa" acima) — depois de aplicar a resolução manual, `update_or_create` um `VinculoNomeOperadora` com `nome_arquivo_operadora=normaliza_nome(item.nome)` e o destino (`linha.nome_func` se `item.tipo == "T"`, senão `linha.nome_dependente`). Só grava se `linha.codigo_empresa` normalizado não for vazio (sempre o caso na prática).
- **Aplicado em `matcher._casa_por_nome`** (não em `_casa_por_cpf` — CPF já é exato por natureza, nunca precisa de DE/PARA): recebe `vinculos_por_nome: Dict[str, VinculoNome]` (`nome normalizado -> VinculoNome`, dataclass "pura" sem ORM em `planos_saude/modelos.py`) e `tipo_lancamento` (só pra rotular o `VinculoAplicado` gerado, o dict em si não é escopado por tipo — o mesmo DE/PARA vale pra mensalidade e coparticipação da mesma operadora+empresa). Quando o titular ou o dependente não bate por nome exato, checa `vinculos_por_nome.get(nome_normalizado)` antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitando `regra_empresa_fn`, já que o vínculo só decide QUAL linha usar — o resto do fluxo de custeio é idêntico ao casamento por nome exato) e registra um `VinculoAplicado` (índice da linha dentro do `tipo_lancamento`, id do vínculo, nome do arquivo da operadora) — devolvido em `ResultadoProcessamento.vinculos_aplicados` (`pipeline.py`) pra `views.py` montar os registros de `ImportacaoPlanoSaudeAlteracao` depois que as linhas estiverem persistidas (no momento do casamento elas ainda não têm `id`).
- **`ImportacaoPlanoSaudeViewSet.create()`**: `_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)` (views.py) busca todo `VinculoNomeOperadora` da operadora cujo `codigo_empresa` normalizado apareça em algum `LinhaSistema` da planilha padrão desta importação, monta o dict e passa em `processa_importacao(vinculos_por_nome=...)`. Depois do `bulk_create` das linhas, correlaciona cada `VinculoAplicado.indice_linha` (índice dentro do `tipo_lancamento`, o mesmo usado como `ordem` na criação da linha) com a `ImportacaoPlanoSaudeLinha` já persistida e cria um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=<vínculo>`, `valor_novo=<nome do arquivo da operadora>`) por vínculo aplicado.
- **`POST /.../reverter/` (botão "Apagar vínculo")**: mesmo endpoint de reverter uma alteração normal (ver "Alterações" abaixo) — pra `TIPO_VINCULO_AUTOMATICO`, zera `valor`/`valor_empresa` da linha (reaplicando a regra empresa da família, se houver, mesma lógica de `_recalcula_familia_regra_empresa`) **e** apaga o `VinculoNomeOperadora` (`SET_NULL` em qualquer outra `ImportacaoPlanoSaudeAlteracao` que o referenciasse) — pra essa divergência voltar a cair em auditoria numa importação futura em vez de ser reaplicada sozinha. Como o vínculo é global (não por importação), apagá-lo afeta todas as importações futuras da mesma operadora+empresa, não só a atual.
- **Frontend**: badge próprio (`.ips-alteracao-tipo--vinculo_automatico`, cor `--accent`) na aba Alterações, com o detalhe `"<nome do arquivo>" (arquivo da operadora) → <nome vinculado> (planilha padrão)` e o botão de ação lendo "Apagar vínculo" em vez de "Reverter" (mesmo endpoint, `pidReverterAlteracaoPlanoSaude`) — a confirmação (`pidConfirm`) também tem um texto próprio avisando que a divergência volta a cair em auditoria.
### Alterações (histórico de edição/inclusão/exclusão de linha, com reversão)
Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha"), exclusão de linha e vínculo automático de nome (ver "Vínculos de nome salvos (DE/PARA)" acima) feitos na própria tela de revisão (ou, no caso do vínculo automático, aplicados por `create()` a partir de um DE/PARA já salvo), com um botão pra reverter/apagar cada um individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação, uma exclusão ou um vínculo automático indesejado, sem precisar reprocessar a importação do zero.
- **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`, `vinculo_nome` adicionado na `0051`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`/`vinculo_automatico`), `linha` (FK `SET_NULL` — fica `null` quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), `campo`/`valor_anterior`/`valor_novo` (só preenchidos em `edicao`; em `vinculo_automatico`, `valor_novo` guarda o nome do arquivo da operadora), `dados_linha` (JSONField — snapshot de todos os campos editáveis da linha **+** `ordem`, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), `vinculo_nome` (FK `SET_NULL`, só em `vinculo_automatico`), `usuario`, `criado_em`, `revertida`/`revertida_em`.
- **Fora de escopo de propósito**: o próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade. Só a reaplicação automática desse vínculo numa importação **futura** vira um registro do tipo `vinculo_automatico`.
- **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. `vinculo_automatico` é gravado em `ImportacaoPlanoSaudeViewSet.create()` (ver "Vínculos de nome salvos (DE/PARA)" acima), não no `LinhaViewSet`.
- **`POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/`** (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) — idempotente, recusa reverter de novo uma alteração já `revertida`. A própria reversão **não** gera um novo registro de alteração (evitaria um loop de "reverter a reversão"):
- `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo.
- `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão).
- `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
- `vinculo_automatico`: zera `valor`/`valor_empresa` da linha vinculada (reaplicando a regra empresa da família, se `tipo_lancamento == "mensalidade"` e a importação tiver `regra_empresa`) e apaga o `VinculoNomeOperadora` associado — ver "Vínculos de nome salvos (DE/PARA)" acima.
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao/--vinculo_automatico`, cores dourado/teal/vermelho/`--accent`), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão, `"<nome do arquivo>" → <nome vinculado>` pra vínculo automático) e o usuário. A coluna "Ação" mostra "Reverter" ou "Apagar vínculo" (conforme o tipo, com `pidConfirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.
### Pré-validação de arquivo ao anexar (tela de Nova Importação)
Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).
- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — 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`.
- **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `<input type="file">` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão.
- **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `<input>` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva.
Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`.
### Cadastro de Regras (separado da execução da importação)
Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora:
- **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `"<código> - <nome>"` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora".
- **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `<código - nome>` · `<operadora>`" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`).
- **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar".
- **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema.
- **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado).
- **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `"<código> - <nome>"`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `"<código> - <nome>"` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um `<select>` nativo) — clicar na seta mostra a lista completa de novo, ou, se já houver algo selecionado, as **outras** opções cadastradas (sem repetir a já escolhida). Existe porque só focar o campo com um valor já preenchido filtra a lista pelo texto atual, então só mostraria de novo o item já selecionado — a seta é o jeito de "trocar fácil" pedido pelo usuário, no mesmo espírito de um filtro de BI (clicar, ver todas as opções, escolher outra).
O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/coparticipação × titular/dependente/regra específica) e as funções JS que o operam (`coletarCusteioAtual()`, `mensagemErroCusteio()`, `aplicarCusteio()`, `limparCusteioForm()`) foram **movidos** (não duplicados) de "Nova Importação" pro modal de Cadastro — mesmos ids de DOM, mesma lógica, só relocados; "Nova Importação" monta o `FormData` do submit direto a partir do objeto `regraResolvidaAtual` em memória (`montarFormDataDeRegra()`), não mais lendo inputs (que não existem mais ali).
- **Campos de `RegraCusteioPlanoSaude`**: `codigo_empresa` (obrigatório — o código do cliente/empresa; **não confundir** com o código de cadastro da operadora no Questor, que já aparece dentro do label de `pipeline.OPERADORAS`, ex. `"5060 - Unimed Saúde"` — são códigos diferentes), `operadora` (obrigatória agora, validada contra `pipeline.OPERADORAS`), `regra_empresa_chave` (ver "Regra empresa" abaixo), `tipos_lancamento`/`custeio_por_tipo` (mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`) e `observacoes`. `Meta.unique_together = [["codigo_empresa", "operadora"]]` — validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia). `nome` **deixou de ser digitado** pelo usuário — é sempre derivado em `RegraCusteioPlanoSaudeSerializer.validate()` como `"<codigo_empresa> - <nome da operadora sem o código dela>"` (campo `read_only=True` na API); mantido como campo de model só pra não precisar tocar em todo lugar que já lê `.nome`/`regra_custeio_salva_nome`.
- **Migração em 3 passos** (mesmo padrão já usado pra `IndicadorDepartamento`, migrations `0033`/`0034`/`0035`): `0041` adiciona `codigo_empresa`/`regra_empresa_chave` (blank) + torna `operadora` obrigatória; `0042` (RunPython) faz o backfill de `codigo_empresa` a partir do `nome` existente (`nome.split(" - ", 1)[0].strip()`); `0043` torna `codigo_empresa` obrigatório e adiciona o `unique_together`. `Meta.ordering` usa `[Length("codigo_empresa"), "codigo_empresa", "operadora"]` (mesmo padrão de `IndicadorApuracaoEmpresa`) pra ordenar o código como número, não como string.
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` continuam chamando a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa. `UniqueTogetherValidator` é declarado explicitamente em `Meta.validators` (não só o automático do DRF), pra manter a mensagem de erro em português.
- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`) registra qual regra foi aplicada numa importação — agora praticamente sempre preenchida (já que "Nova Importação" só resolve custeio a partir de uma regra cadastrada), mas o campo continua opcional a nível de API (a garantia de "sempre passar por uma regra cadastrada" é uma trava de UI, não uma obrigatoriedade no backend). Alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão, como antes.
- **Trava de conferência do código de empresa** (`ImportacaoPlanoSaudeViewSet.create()`, depois do processamento e antes do `bulk_create` das linhas): se `regra_custeio_salva` está presente, confere que ao menos uma linha da planilha padrão processada tem `codigo_empresa` igual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outros `except` desse método) e devolve 400 com mensagem clara — evita aplicar a regra de uma empresa a uma planilha de outra por engano. Vale pra toda regra aplicada, inclusive as com `regra_empresa_chave` (onde é redundante com a checagem que `regras_empresa.valida_regra_empresa()` já faz — proteção extra contra o registro em `REGRAS_EMPRESA` ficar dessincronizado da `RegraCusteioPlanoSaude` correspondente).
**Nome da empresa (Questor)** — primeiro consumidor real do pacote `database/` (ver [[project_database_package]] na memória): resolve e cacheia localmente o nome de uma empresa a partir do seu `codigo_empresa`, pra mostrar `"<código> - <nome>"` em vez de só o código nas telas acima.
- **`EmpresaQuestor`** (models.py, migração `0044`): `codigo_empresa` (único) + `nome_empresa`, um cache local simples — sem relação de FK com `RegraCusteioPlanoSaude` (é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmo `codigo_empresa` com operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha de `EmpresaQuestor`).
- **`portal_api/empresas_questor.py`, `resolve_nome_empresa(codigo_empresa)`**: olha o cache primeiro; só na ausência dele consulta o Questor (`database.connection.DatabaseConnection("questor")` — chave em **minúsculas**, `DatabaseSettings` normaliza as chaves de `DATABASE__<NOME>__*` do `.env` assim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executando `sqls.questor.QuestorSQL.consulta_nome_empresa()` (`select codigoempresa, nomeempresa from empresa where codigoempresa = :codigo_empresa` — a consulta exata fornecida pelo usuário, só parametrizada), e persiste o resultado antes de devolver — nunca precisa repetir a consulta pro mesmo código depois. Qualquer falha (código inexistente, `codigoempresa` do Questor é `smallint` e um código fora da faixa numérica levanta `DataError`, banco inacessível) é capturada e devolve `None` — nunca propaga a exceção, já que isso é só informativo, nunca bloqueia cadastrar/editar/excluir uma regra.
- **`normalizar_codigo_empresa(valor)`** (mesmo arquivo): remove zero à esquerda (`"092"` → `"92"`) — decisão explícita do usuário, pra sempre ter um único código canônico por empresa (o `codigoempresa` do Questor é `smallint`, então "092"/"92" já eram a mesma linha lá; sem normalizar no Portal, apareciam como duas empresas "diferentes"). Aplicada em toda entrada de `codigo_empresa` vinda de fora: `resolve_nome_empresa()`, `RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa()` (o que é de fato salvo em `RegraCusteioPlanoSaude.codigo_empresa`), a action `nome-empresa` (devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência em `ImportacaoPlanoSaudeViewSet.create()` (normaliza os dois lados antes de comparar, já que o código bruto da planilha pode ter zero à esquerda enquanto o da regra não tem mais). **Nunca** aplicada a `ImportacaoPlanoSaudeLinha.codigo_empresa` em si (precisa continuar exatamente como veio da planilha, pra não alterar o que é reexportado) — só normalizada no momento de uma comparação/exibição pontual (ver `_nome_empresa_cacheado()`, que normaliza antes de consultar `EmpresaQuestor` a partir do código cru de uma linha).
- **`sqls/questor.py`** (pacote novo na raiz do projeto, ao lado de `database/` — seguindo a convenção "uma pasta `sqls/` por projeto consumidor, um arquivo por banco" já documentada na memória): classe `QuestorSQL`, hoje só `consulta_nome_empresa()`. Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendo `SQLQuery(sql=dedent(...), params={...})`; **nunca** usar `execute`/`execute_returning` desses bancos sem autorização explícita (ver [[feedback_bancos_externos_somente_leitura]]).
- **Correção em `database/settings.py`** (arquivo compartilhado, não específico desta ferramenta): `SUPPORTED_DRIVERS["postgresql"]` apontava pra `"postgresql+psycopg2"`, mas o `.venv` do Portal só tem `psycopg` (v3) instalado, não `psycopg2` — `ModuleNotFoundError` ao tentar conectar. Corrigido pra `"postgresql+psycopg"` (dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalar `psycopg2-binary` à parte. Se `database/` for reaproveitado por outro projeto que dependa especificamente de `psycopg2` (comportamento antigo), essa mudança precisaria ser revisitada — não é o caso hoje.
- **Resolução automática pra regras já existentes**: `RegraCusteioPlanoSaudeSerializer.get_nome_empresa()` chama `resolve_nome_empresa()` a cada leitura (não só ao criar/editar) — então regras cadastradas antes deste campo existir tiveram o nome resolvido e cacheado sozinho, na primeira vez que a lista foi carregada depois do deploy, sem precisar de nenhum backfill manual. Já `ImportacaoPlanoSaudeDetailSerializer.get_nome_empresa()` (tela de Revisão) só lê o cache (`_nome_empresa_cacheado()`, sem chamar `resolve_nome_empresa()`) — essa tela é consultada com muito mais frequência, e o nome já deveria estar cacheado desde que a regra foi cadastrada/editada, então não vale pagar o custo de uma consulta ao Questor ali.
- **Frontend** (`importacao-plano-saude.js`): `GET /api/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=X` (`pidBuscarNomeEmpresaPlanoSaude`) é chamado tanto num debounce de 350ms a cada tecla digitada no campo "Código da empresa" de "+ Nova regra" (`agendarAtualizarNomeEmpresaNovo()`, pedido explícito do usuário pra não precisar esperar o campo perder o foco) quanto no `blur` (imediato, cancela o debounce pendente) — a função de fato (`atualizarNomeEmpresaNovo()`) mostra "Buscando nome da empresa...", depois o nome resolvido ou "Empresa não encontrada no Questor.", e **reescreve o próprio campo** com o `codigo_empresa` normalizado devolvido pela resposta (ex.: usuário digita "092", campo passa a mostrar "92" assim que resolve). Os comboboxes de "Empresa" (Cadastro de Regras e Nova Importação) não fazem nenhuma chamada nova — `labelEmpresa()` monta `"<código> - <nome>"` direto do array `regras` já carregado, que já vem com `nome_empresa` resolvido pelo backend.
### Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação)
Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio.
**Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação".
- **Mutuamente exclusivo por TIPO, não em bloco**: no formulário de Cadastro de Regras, escolher uma regra especial trava (marca + desabilita + esconde os radios titular/dependente) só os checkboxes "Mensalidade"/"Coparticipação" que essa regra específica cobre — `aplicarTiposRegraEmpresa()` em `importacao-plano-saude.js`, chamada sempre que a regra selecionada muda (ao marcar/desmarcar o checkbox, ou ao escolher uma regra no picker). Uma regra que só cobre mensalidade (Tecnomyl) deixa "Coparticipação" livre pra configuração manual normalmente — mesmo comportamento de antes pra essa regra específica; a novidade é só que agora isso é decidido pelos `tipos_lancamento` de CADA regra, não fixo no código do formulário. Um checkbox travado (`.disabled`) não dispara `change` por clique do usuário, então a exclusividade mútua não precisa de nenhuma lógica extra nos handlers de "Mensalidade"/"Coparticipação" — só o handler de "Regra especial da empresa"/a seleção no picker chamam `aplicarTiposRegraEmpresa()`.
- No backend, `RegraCusteioPlanoSaudeSerializer.validate()`/`ImportacaoPlanoSaudeCreateSerializer.validate()` calculam `regra_empresa_tipos` (interseção entre `REGRAS_EMPRESA[chave]["tipos_lancamento"]` e os tipos selecionados — erro claro se vier vazia), conferem `chave_casamento_para_tipo(tipo) == REGRAS_EMPRESA[chave]["chave_casamento"]` pra cada tipo coberto (não mais um "exige nome" hardcoded) e zeram `custeio_por_tipo[tipo]` só pros tipos em `regra_empresa_tipos` (os demais tipos selecionados continuam com custeio manual normal). `regras_empresa.valida_regra_empresa(regra_empresa_key, chave_casamento_por_tipo, linhas_sistema, tipos_selecionados)` devolve `(aplica, tipos_cobertos)` — `pipeline.processa_importacao` passa `regra_empresa_fn` pra `casa_individuos_com_planilha` só quando `tipo_lancamento in tipos_cobertos`.
- **`matcher._casa_por_cpf` passou a suportar `regra_empresa_fn`** (antes só `_casa_por_nome` suportava) — sem agrupar por família (essa estratégia não tem esse conceito): acumula `(linha, valor_total)` de todo indivíduo casado por CPF e chama `regra_empresa_fn(linhas_e_valores, tipo_lancamento)` uma vez só, no fim, com todos os pares do tipo de lançamento inteiro. `aplica(linhas_e_valores, tipo_lancamento)` é a assinatura de toda regra agora (segundo argumento novo) — permite uma mesma função se comportar diferente por tipo (ver Ottimizza abaixo); a Tecnomyl recebe o parâmetro mas ignora (só é chamada pra "mensalidade" mesmo, via `tipos_lancamento`).
- **Registro** (`REGRAS_EMPRESA`): cada entrada tem `label`, `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada), `operadora`, `chave_casamento`, `tipos_lancamento`, `aplica` (função que faz o cálculo) e `observacoes`. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (`GET /api/importacoes-plano-saude/regras-empresa/` já reflete o registro, incluindo `tipos_lancamento`, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação").
- **Observações da regra, só-leitura na tela de Revisão** (`#ips-review-regra-empresa-obs`) — inalterado: `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` resolve `REGRAS_EMPRESA[obj.regra_empresa]["observacoes"]` a cada carregamento; o mesmo bloco cai pra `regra_custeio_salva_observacoes` quando não há regra empresa.
- **`unimed_1778_tecnomyl`** — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026. `chave_casamento="nome"` (precisa agrupar família), `tipos_lancamento=("mensalidade",)` — coparticipação dela segue sempre o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra.
- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
- **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`.
- **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação).
- **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes).
- **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto.
Ver `portal_api/planos_saude/CLAUDE.md`.
## CSS — organização entre arquivos
@ -746,7 +374,7 @@ Cobre regras de custeio negociadas com uma empresa específica que não cabem no
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver seção "Liderança" — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
| `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`. O topbar da tela inicial não tem mais título/slogan nenhum (`<h1 id="portal-title">` — chegou a existir brevemente com o slogan "Grandes aplicações de todos os tamanhos" em fonte "Pinyon Script"/dourado, removido a pedido do usuário na mesma rodada; ver `login.css` abaixo pra onde o slogan acabou indo) — o `<link>` do Google Fonts em `portal.html` também foi removido junto, já que não sobrou nenhum uso de fonte customizada nessa página. |
@ -754,7 +382,7 @@ Cobre regras de custeio negociadas com uma empresa específica que não cabem no
| `links-ferramentas.css` | `.lf-*` — só usado em `links-ferramentas.html`. |
| `acessos-gerais.css` | `.ag-*` — só usado em `acessos-gerais.html`. |
| `ramais.css` | `.ram-*` — só usado em `ramais.html`; a tabela em si reaproveita `.pa-table*`/`.pa-row-actions` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS próprio). |
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver "Ramais" abaixo), usado em `portal.html`/`links-ferramentas.html`/`calendario-individual.html`. Tabela **autocontida** (não reaproveita `.pa-table` porque essas 3 páginas não carregam `perfis-acesso.css`). |
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver `docs/ramais.md`), usado em `portal.html`/`links-ferramentas.html`/`calendario-individual.html`. Tabela **autocontida** (não reaproveita `.pa-table` porque essas 3 páginas não carregam `perfis-acesso.css`). |
| `importacao-plano-saude.css` | `.ips-*` — só usado em `importacao-plano-saude.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.pa-table-wrap` na tabela editável da revisão e nas abas. |
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |

View File

@ -0,0 +1,38 @@
# Calendário Individual e Widgets
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.). Este arquivo não é auto-carregado pelo Claude Code (não há pacote Python dedicado, o código vive em `portal_api/models.py`/`views.py`/`serializers.py` junto com o resto) — leia manualmente ao mexer nesta aplicação. Inclui também o sistema genérico de Widgets de `portal.html` (`portal_api/models.py` `WidgetUsuario`, `static/js/widgets.js`), já que "Calendário Individual" foi o primeiro tipo de widget e os dois nasceram juntos.
Compromissos (`CompromissoAgenda`) têm um dono (`dono`, FK) e um campo `visibilidade` (`"somente_eu"`/`"departamento"`/`"todos"`, substituiu a antiga flag booleana `compartilhado_com_perfil` — perfil de acesso deixou de ser o critério de compartilhamento). `GET /api/compromissos/` (`CompromissoAgendaViewSet.get_queryset`) já retorna: (a) sempre os próprios compromissos do usuário; (b) todo compromisso com `visibilidade="todos"`, pra qualquer usuário do portal, sem checar perfil/departamento; (c) compromissos com `visibilidade="departamento"` só se `departamento_compartilhado` (FK, `on_delete=SET_NULL`) for um dos departamentos do usuário logado — a lógica de "visível para quem" mora só no backend. O campo `sou_dono` (calculado no serializer) substitui o antigo `ownerLogin === login` do cliente; só o dono edita/exclui (`CompromissoAgendaViewSet.get_object` levanta `PermissionDenied` se não for o dono tentando escrever).
Ao escolher `visibilidade="departamento"` no modal (`calendario-individual.html`/`calendar-individual.js`), um segundo campo aparece (`#ic-event-department-field`) pra escolher **qual** dos próprios departamentos do dono recebe o compartilhamento — decisão explícita do usuário, já que `Usuario.departamentos` é M2M (pode ter mais de um) e "meu departamento" sozinho seria ambíguo nesse caso. As opções desse `<select>` vêm de `me.departamentos` (adicionado a `/api/me/` só pra isso — antes esse endpoint não expunha os próprios departamentos do usuário logado, só o de outros via `UsuarioResumoSerializer`). `CompromissoAgendaSerializer.validate()` exige `departamento_compartilhado` quando `visibilidade="departamento"` e recusa qualquer departamento que não esteja entre os do próprio dono (`request.user.departamentos`) — mesmo que o cliente tente forçar um id de departamento alheio no payload; para as outras duas visibilidades, `departamento_compartilhado` é sempre zerado no servidor, ignorando o que vier no payload.
**Filtro por categoria e cores no calendário** (`.calendar-filters`/`.filter-chip` em `calendario.css` — já existiam no CSS sem nenhum consumidor antes desta funcionalidade): dentro de `.calendar-header`, ao lado do título do mês e do botão "Novo Compromisso" (mesma linha, não numa faixa própria abaixo — `flex-wrap: wrap` no header cobre o caso de não caber tudo numa linha só), uma barra de chips clicáveis (multi-seleção, sem exclusividade) filtra o que aparece no calendário por `pidEventoCategoria(ev)` (`calendar-individual.js`), que não é exatamente `ev.visibilidade` — um compromisso `"somente_eu"` que não é meu (`!ev.sou_dono`) só pode ter chegado pela regra de liderança abaixo, então vira a categoria `"equipe"`, distinta de `"somente_eu"` (meus próprios); e todo `ev.eh_evento` vira a categoria `"evento"` **antes** de qualquer outra checagem (ver "Eventos Corporativos" abaixo), mesmo já sendo sempre `visibilidade="todos"`. As 5 categorias (`somente_eu`/`departamento`/`todos`/`equipe`/`evento`) têm cor fixa própria (`.calendar-event--*` em `calendario.css`) e o chip ativo de cada uma usa a mesma cor — o próprio filtro funciona como legenda. `"somente_eu"` é a exceção: usa `--accent` (o tema de cor que o usuário escolheu, não uma cor fixa); as outras quatro usam tokens fixos novos (`--gold`, reaproveitado do antigo "compartilhado"; `--teal` e `--slate`, adicionados só pra isso em `tokens.css`; `--coral`, adicionado depois só pra `"evento"` — todos com variante mais escura no tema claro pro contraste do texto escuro fixo `#1a1721`) — escolhidos deliberadamente fora das cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa/vermelho) pra nunca coincidir visualmente com o que `--accent` pode assumir — exceto `--coral`, que é laranja e portanto não colide mesmo com "vermelho" na lista. O chip "Minha equipe" (`#ic-filter-equipe`) só aparece (`hidden`) se `me.lideranca`; o chip "Eventos" (`data-filter="evento"`) é sempre visível, já que qualquer perfil pode ver eventos corporativos (só criar um é restrito). Por padrão todos os chips visíveis nascem `is-active` (mostra tudo que o usuário pode ver). Clique simples troca a seleção pra **só** aquele filtro (`activeFilters.clear()` + adiciona só o clicado), exceto se esse filtro já for o único ativo — nesse caso (`activeFilters.size === 1 && activeFilters.has(filtro)`) o clique volta pra visualização padrão (`selecionarTodosOsFiltros()`, todos os chips visíveis ativos), pra sempre existir um caminho de volta ao estado "ver tudo" sem precisar de Shift. `Shift`+clique acrescenta/remove esse filtro dos já selecionados (`event.shiftKey`, mesmo padrão de seleção de arquivos do SO) — é assim que dá pra combinar mais de uma categoria ao mesmo tempo.
**Eventos Corporativos** (`CategoriaEvento`, `CompromissoAgenda.eh_evento`/`categoria`/`local`/`modalidade`/`descricao`): compromissos com `visibilidade` em `departamento`/`todos` deixaram de ser livres pra qualquer usuário — criar ou editar um compromisso nesses dois níveis (o que inclui automaticamente todo `eh_evento=True`, já que a validação força `visibilidade="todos"` antes de checar permissão) agora exige `apps["calendario-individual-criar-evento"]` em `permissoes["calendario-individual"]` (`CompromissoAgendaSerializer.validate()`), permissão liberada só para "Integração e Inovação" no `seed_portal.py` por ora (mesmo cuidado de sempre: como `calendario-individual` está em `BASE_KEYS`, sem o override todo perfil nasceria podendo criar evento de departamento/todos e cadastrar categoria). Compromissos `"somente_eu"` continuam livres pra qualquer um, sem essa checagem.
- `CategoriaEvento` (`nome` único + `cor` hex, validada por `validar_cor_categoria_evento`) é um cadastro simples via `/api/categorias-evento/` — GET livre a qualquer autenticado (a cor/nome de uma categoria não é sigilosa), escrita restrita à mesma permissão acima. Não é uma lista fixa no código: quem tem a permissão cadastra categorias novas (ex.: "Reunião", "Treinamento") direto no modal de criar/editar compromisso (botão "+" ao lado do `<select>` de categoria, `#ic-event-categoria-add-btn`, que abre `#ic-categoria-modal`) — `seed_portal.py` popula `CATEGORIAS_EVENTO_SEED` como ponto de partida, mas a lista é editável dali em diante.
- `CompromissoAgenda.eh_evento` marca um compromisso como evento formal (não uma reunião pessoal marcada como "todos") — o checkbox correspondente (`#ic-event-eh-evento`, dentro de `#ic-event-eh-evento-field`) só aparece pra quem tem a permissão de criar evento; marcá-lo força a visibilidade pra "Todos" no próprio formulário. `local` (texto livre), `modalidade` (`presencial`/`remoto`/`hibrido`, `<select>` `#ic-event-modalidade`) e `descricao` (texto livre) são campos extras só relevantes pra evento, mas tecnicamente gravam em qualquer compromisso (o formulário só os expõe quando aplicável). No popup somente-leitura (`#ic-view-modal`), cada um aparece como campo próprio (`#ic-view-local-field`/`#ic-view-modalidade-field`/`#ic-view-descricao-field`), escondido (`hidden`) quando vazio — mesmo padrão dos demais campos condicionais desse popup (ver "Pill do compromisso" abaixo).
- Visualmente, um evento ganha um bucket de cor próprio (`--coral`) tanto no pill do calendário (`.calendar-event--evento`) quanto no chip de filtro "Eventos" — mesmo sendo sempre `visibilidade="todos"` por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).
**Pill do compromisso: sempre "HH:MM Título", nada mais** — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (`cursor:pointer` na classe base `.calendar-event`, não só em `--somente-eu`): se `ev.sou_dono`, abre o modal de edição de sempre (`#ic-modal`, fecha também clicando fora — `event.target === modal`, mesmo padrão de `links-ferramentas.js`/`ramais-lookup.js`); senão, abre um modal novo, só leitura (`#ic-view-modal`/`openViewModal()`, mesmo fecha-ao-clicar-fora), com data/horário por extenso, `dono_nome` (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (`PID_IC_VISIBILIDADE_LABELS`, mapeia `ev.visibilidade` pro texto exibido nos chips) e, só quando `ev.visibilidade === "departamento"`, o nome do departamento (`ev.departamento_compartilhado_nome`, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.
**Célula do dia com altura fixa, lista de compromissos rolável** (`.calendar-day`/`.calendar-day__events` em `calendario.css`): `.calendar-day` tem `height` fixo (108px desktop, 76px no breakpoint mobile — antes era `min-height`, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de `.calendar-day` — `calendar-individual.js` (`render()`) os agrupa num `<div class="calendar-day__events">` (`flex:1; min-height:0; overflow-y:auto`) irmão de `.calendar-day__header`. **`.calendar-day` (o item de grid, não só o `__events` interno) também precisa de `min-height:0` + `overflow:hidden`** — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando `height` explícito) ainda fazia a *linha da grade* crescer pra caber todos os pills, mesmo com a célula e o `overflow-y:auto` do `__events` configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (`min-height:0`/`overflow` não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.
**Agenda completa do dia** (`#ic-day-modal`, `openDayModal()` em `calendar-individual.js`): clicar no número do dia (`.calendar-day__number`, `cursor:pointer` + destaque no hover) abre um popup com **todos** os compromissos do dia (respeitando os filtros ativos, mesmo `eventosDoDia` usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o `.day-modal-list` (`calendario.css`) tem `max-height:360px` próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter `white-space:normal` (podem quebrar linha) em vez do `nowrap`+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), `criarPillCompromisso(ev)`/`criarPillFeriado(iso, nome)` foram extraídas como funções reaproveitadas por `render()` **e** por `openDayModal()` — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).
**Feriados no Calendário Individual** (`GET /api/feriados/?ano=AAAA`, `feriados_view` em `views.py`): usa a lib `holidays` (PyPI, `requirements.txt`) pra devolver os feriados **nacionais + estaduais do Paraná** (`holidays.Brazil(years=ano, subdiv="PR", language="pt_BR")`, categoria `public` — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como `[{"data": "AAAA-MM-DD", "nome": "..."}]`. `language="pt_BR"` é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com `LANG`/`LANGUAGE` em inglês) em vez do `default_language` da classe `Brazil`, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. **De propósito não tem feriado municipal de Foz do Iguaçu aqui** — nenhuma lib de feriados cobre granularidade de município brasileiro (a `holidays` só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.
No frontend (`calendar-individual.js`), `carregarFeriados(ano)` busca e cacheia por ano (`Map` em memória, só refaz a requisição ao trocar de ano); `render()` busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de `viewYear`. Cada célula de dia feriado ganha a classe `.is-feriado` (fundo tingido de vermelho, `rgba(var(--danger-rgb), 0.1)`) e um pill (`.calendar-day__holiday`, mesmo visual dos pills de compromisso — `.calendar-event`, só que com fundo `--danger` fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de `.calendar-day__header`, **fora** de `.calendar-day__events` — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com `text-overflow:ellipsis` quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por `;`, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver `feriados_view`); clicar no pill abre `#ic-holiday-modal` (`openHolidayModal()`, mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.
**Gerente/coordenador vê a agenda individual da equipe**: `CompromissoAgendaViewSet.get_queryset` acrescenta `Q(visibilidade="somente_eu", dono__in=usuario.liderados.all())` às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em `Usuario.liderados` (ver `docs/perfis-usuarios.md`, seção "Liderança") também enxerga os compromissos privados (`"somente_eu"`) de cada liderado, mas sem poder editá-los (`sou_dono` continua `False` pra esses, `CompromissoAgendaViewSet.get_object` já barra escrita de quem não é dono). Não há necessidade de checar `usuario.lideranca` explicitamente na query — `liderados` só é populado através de fluxos que já exigem esse flag (ver `docs/perfis-usuarios.md`), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
**Lembrete com horário comercial** (`CompromissoAgenda.lembrete_antecedencia`, opcional — `""` = sem lembrete; `"1h"`/`"2h"`/`"4h"`/`"24h"`): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (`notif-bell`), que já era recalculado a cada carregamento de página (sem processo em segundo plano). `CompromissoAgenda.calcular_notificar_em()` (`models.py`) calcula esse horário contando `lembrete_antecedencia` horas **de expediente** (seg-sex, 8h-18h, `COMPROMISSO_HORARIO_COMERCIAL_INICIO`/`_FIM`) pra trás a partir de `data`+`horario` — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (`_janela_comercial`/`_dia_util_anterior`, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem `horario` definido no compromisso (evento de dia inteiro) ou sem `lembrete_antecedencia`, não há o que calcular e `calcular_notificar_em()` retorna `None`. O resultado é exposto só leitura via `notificar_em` no serializer (ISO datetime ou `null`); `pidBuildEventNotifications()` (`notifications.js`) só inclui um compromisso na lista do sino quando `notificar_em` não é nulo **e** já foi atingido (`now >= notificar_em`) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).
## Widgets (`portal.html`)
`widgets.js` mantém um registro extensível `PID_WIDGET_TYPES` (`{ "<chave>": { label, description, href, linkLabel, visibleIf? } }`) — hoje existem `"calendario-individual"` e `"links-favoritos"` (ver `docs/links-ferramentas-acessos-gerais.md`). `href`/`linkLabel` alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra `calendario-individual.html`, então ao adicionar um tipo novo **sempre** preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. `visibleIf(me)` é opcional — quando presente, filtra o tipo tanto do picker (`renderPicker()`) quanto da grade já adicionada (`renderWidgets()`), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: `links-favoritos` só aparece pra quem tem `apps["links-ferramentas-visualizar"]` em `links-ferramentas`). Para adicionar um novo tipo de widget: registrar a entrada em `PID_WIDGET_TYPES` e adicionar um `case`/`if` em `widgetBodyFor()` que retorne o HTML do corpo do card; o picker (`#widget-picker-modal`) e a grade (`#widgets-grid`) já lidam com adicionar/remover genericamente via `/api/widgets/`.
**Reordenar e redimensionar widgets** (`WidgetUsuario.ordem`/`largura`/`altura`, por usuário): `.widgets-grid` é `display:flex; flex-wrap:wrap` (não mais CSS Grid — precisava permitir que cada `.widget-card` tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (`dragstart`/`dragover`/`drop` em `#widgets-grid`, `PATCH /api/widgets/{tipo}/` só nos itens cujo `ordem` mudou) — a diferença é que o `draggable="true"` fica só em `.widget-card__header` (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (`resize: both` em `.widget-card`, ativo no canto inferior direito). Redimensionar usa esse `resize: both` do CSS (sem JS de arraste custom) — um `ResizeObserver` por card (`observeWidgetSizes()`) detecta a mudança de tamanho e salva `largura`/`altura` com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só `.widget-card__body` tem `overflow-y: auto` (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).
## CSS
`calendario.css` (`.calendar-*` — grade mensal, células de dia, nav do mês; os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`) e `widgets.css` (`.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`).

14
docs/favoritos.md Normal file
View File

@ -0,0 +1,14 @@
# Favoritos: como o ID de uma aplicação é derivado
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Este arquivo não é auto-carregado pelo Claude Code — leia manualmente ao mexer em `favorites.js` ou no sidebar/menu.
`favorites.js` não depende de nenhum atributo `data-*` dedicado para identificar "o que é favoritável" — ele varre `.sidebar a.nav-item, .sidebar a.nav-subitem` e deriva um ID estável a partir da própria estrutura/texto do menu (`pidCollectFavoritableApps`), igual a antes da migração. Esse ID é o que vira `app_id` em `POST /api/favoritos/` e na URL de `DELETE /api/favoritos/{app_id}/`:
- Se o `<li>` do link já tem `data-section`, o ID é esse valor (ex.: `"ramais"`).
- Caso contrário (é um sub-item dentro de um `nav-group`), o ID é `"<data-section do grupo pai>__<slug do texto do link>"` (ex.: `"portais__portal-do-cliente"`).
O slug (`pidSlug`) normaliza acentos (NFD) e troca sequências de caracteres não `[a-z0-9]` por `-`. Se o texto de um label mudar, o `app_id` derivado muda junto (favoritos existentes referenciando o ID antigo deixam de casar) — mesmo caveat vale se a estrutura do menu mudar (ex.: um item vira `nav-group` expansível, ver `docs/links-ferramentas-acessos-gerais.md`, "Links & Ferramentas": um favorito antigo `app_id === "links-ferramentas"` parou de casar quando a seção virou dois sub-itens).
**Reordenar os cards favoritos**: `Favorito.ordem` (`PositiveIntegerField`, `Meta.ordering = ["ordem", "id"]`) — mesmo padrão de `LinkFerramenta`/`WidgetUsuario`: `FavoritoViewSet.perform_create` atribui `ordem = max(ordem atual do usuário) + 1`, e reordenar é drag-and-drop nativo em `#app-card-grid` (`favorites.js`, `dragstart`/`dragover`/`drop`, `PATCH /api/favoritos/{app_id}/` só nos itens cujo `ordem` mudou) — mesma mecânica de Widgets/Links & Ferramentas (ver `docs/calendario-individual.md`/`docs/links-ferramentas-acessos-gerais.md`). `.app-card` inteiro é `draggable="true"` (não precisa de um handle separado como os widgets, já que não tem `resize` pra conflitar); o botão de remover (`.app-card__remove`) é `draggable="false"` pra não interferir.
`.app-card*` mora em `components.css` (junto de outra UI genérica reutilizável).

View File

@ -0,0 +1,50 @@
# Links & Ferramentas / Acessos Gerais
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.). Este arquivo não é auto-carregado pelo Claude Code (não há pacote Python dedicado, o código vive em `portal_api/models.py`/`views.py`/`serializers.py` junto com o resto) — leia manualmente ao mexer nesta aplicação.
## Links & Ferramentas
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver `docs/favoritos.md`). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em `docs/favoritos.md`: mudar a forma como um item aparece no menu muda o `app_id` derivado.
`LinkFerramenta` é uma lista **global/compartilhada** (sem FK pra `Usuario`, ao contrário de `Favorito`/`WidgetUsuario`/`NotificacaoDispensada`) — todo usuário com `apps["links-ferramentas-visualizar"]=True` em `permissoes["links-ferramentas"]` vê os mesmos cartões via `GET /api/links-ferramentas/` (ver "Padrão visualizar/editar", `CLAUDE.md` na raiz).
`links-ferramentas.js` também gateia o **conteúdo da própria página** por `apps["links-ferramentas-visualizar"]` (`#lf-no-access`/`#lf-content` em `links-ferramentas.html`, mesmo padrão do `.no-access` de `portal.html`) — isso existe porque o sidebar (`data-section="links-ferramentas"` em `access.js`) só esconde o `nav-group` inteiro com base no `enabled` do módulo (e cada sub-item individualmente com base no seu `-visualizar`), então alguém sem `apps["links-ferramentas-visualizar"]` mas que navegue direto pra URL (ou tenha `enabled=true` sem essa flag, uma combinação tecnicamente possível já que são independentes) via GET no backend recebia 403 e via a tela renderizada com "Nenhum link cadastrado ainda." em vez de uma mensagem de acesso negado. Ao adicionar uma aplicação nova no padrão visualizar/editar, replicar esse gate (como `acessos-gerais.js` já faz) — não basta confiar em `access.js` escondendo o link do menu.
Só quem tem `apps["links-ferramentas-editar"]=True` (`me.permissoes_efetivas["links-ferramentas"].apps["links-ferramentas-editar"]`, já unido no servidor) vê em `links-ferramentas.html` os controles de administração: botão "Adicionar Link" no topo e, em cada cartão, setas de mover para cima/baixo + X de remover (`links-ferramentas.js`, gated no frontend por essa flag, e reforçado no servidor por `PermissaoApp("links-ferramentas", "links-ferramentas-editar")`/`PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")` conforme o método HTTP).
- **Ordenação**: campo `ordem` (inteiro, sem `unique`) em `LinkFerramenta`, `Meta.ordering = ["ordem", "id"]`. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quais `PATCH`es disparar. Duas formas de reordenar na UI, ambas em `links-ferramentas.js`: as setas (`swapOrdem()`) trocam o `ordem` de dois itens adjacentes com duas chamadas `PATCH`; arrastar um cartão (drag-and-drop nativo HTML5, `.lf-card--draggable`/`dragstart`/`dragover`/`drop` no `#lf-grid`) recalcula a lista inteira em memória e envia um `PATCH` só para os itens cujo `ordem` (índice na nova ordem) realmente mudou — como não há `unique` em `ordem`, não tem problema disparar essas chamadas em paralelo (`Promise.all`) mesmo que dois itens fiquem com o mesmo valor por um instante. Ao criar um link novo, o servidor sempre calcula `ordem = max(ordem atual) + 1` em `LinkFerramentaViewSet.perform_create` — qualquer `ordem` enviada pelo cliente no POST é ignorada.
- **Ícone**: `icone` é um `ImageField` opcional (upload real, não URL) — exige Pillow (`requirements.txt`) e `MEDIA_URL`/`MEDIA_ROOT` (`settings.py`, servido em `DEBUG` por `config/urls.py`). Sem ícone, o cartão cai num SVG de fallback (`PID_LINK_DEFAULT_ICON` em `links-ferramentas.js`, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja como `multipart/form-data` (`FormData`), não JSON. Limite de tamanho: **2MB**, checado em dois lugares — `validar_tamanho_icone_link` (validator do campo `icone` em `models.py`, é a checagem que vale de verdade, roda via `LinkFerramentaSerializer.is_valid()`) e uma checagem espelhada em `links-ferramentas.js` (`PID_LINK_ICON_MAX_BYTES`, no `change` do input e de novo antes do POST/PATCH) só para dar feedback sem esperar a resposta do servidor. Ao mudar o limite, atualizar os dois lados (e gerar migração — `validators` no campo entra no `deconstruct()`).
- Clicar num cartão sempre abre a URL numa aba nova (`target="_blank"`) — são links externos por definição, não faz sentido navegar embutido no portal.
- Editar um cartão existente (nome, URL e ícone) usa o mesmo modal de "Adicionar Link" (`#lf-add-modal`), reaproveitado em modo edição — o botão de lápis em cada cartão (visível só com `apps["links-ferramentas-editar"]`, ao lado das setas de mover) chama `openModal(link)` pré-preenchendo os campos; salvar despacha `PATCH /api/links-ferramentas/{id}/` (`pidUpdateLink`, multipart igual ao POST) em vez de criar um novo. O campo de ícone fica sempre vazio ao abrir em modo edição (input `type="file"` não aceita valor pré-preenchido por segurança do browser) — não enviar o campo `icone` no PATCH mantém o ícone atual; só enviar substitui.
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu, ver `docs/favoritos.md`): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`, ver `docs/calendario-individual.md` pro resto do sistema de widgets): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
## Acessos Gerais
Segunda aplicação da seção "Links & Ferramentas" (ver acima) — um cadastro de acessos/logins compartilhados (ex.: "login geral de um site"), organizado em **seções e linhas** (inspirado numa tela do Asana que o usuário mostrou como referência): cada seção agrupa várias linhas, e clicar numa linha abre um popup com os detalhes daquele acesso. Dois models novos, sem relação com `LinkFerramenta`:
- `AcessoGeralSecao` (`nome`, `ordem`, `perfis_restritos` M2M pra `PerfilAcesso`, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK pra `Usuario`.
- `AcessoGeral` (`secao` FK, `nome`, `url`, `usuario`, `senha`, `observacoes`, `ordem`) — a linha em si. `senha` é um `CharField` em texto puro (não há criptografia/hash — é um cadastro de referência entre a própria equipe, não um cofre de senhas robusto; se isso precisar mudar no futuro, confirmar com o usuário antes, já que envolve infraestrutura de chave/criptografia nova). `observacoes` guarda **HTML sanitizado** (ver "Observações ricas" abaixo), com um `validators=[validar_tamanho_observacoes_acesso]` (`models.py`) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita um `TextField` sem limite nenhum crescer sem controle.
**Permissão**: mesmo padrão visualizar/editar de Links & Ferramentas, com chaves próprias (`acessos-gerais-visualizar`/`acessos-gerais-editar`, ver "Padrão visualizar/editar" no `CLAUDE.md` da raiz) — `AcessoGeralSecaoViewSet`/`AcessoGeralViewSet` (`views.py`) instanciam `PermissaoApp("links-ferramentas", app_key)` com a chave certa por método HTTP. `acessos-gerais.js` gateia o conteúdo da própria página (`#ag-no-access`/`#ag-content`) por `acessos-gerais-visualizar`, mesmo raciocínio do gate de `links-ferramentas.js`.
**Restrição de seção por perfil** (`AcessoGeralSecao.perfis_restritos`): além da permissão de módulo, cada seção pode opcionalmente ser restrita a um subconjunto de `PerfilAcesso` — `perfis_restritos` vazio (padrão) = visível a qualquer um com `acessos-gerais-visualizar`; não vazio = só quem também tiver um desses perfis vinculado. Isso é uma restrição de **dado**, independente da árvore de permissões (não precisa mexer em Perfis de Acesso pra configurar) — é escolhida direto no modal "Adicionar Seção"/"Renomear Seção" (`#ag-secao-form-perfis`, um `.checklist-box` com todos os perfis cadastrados, populado via `pidFetchPerfis()`). O filtro é aplicado em dois lugares no backend, ambos em `views.py`:
- `AcessoGeralSecaoViewSet.get_queryset()` — só devolve seções sem restrição ou com interseção entre `perfis_restritos` e os perfis do usuário logado; `AcessoGeralViewSet.get_queryset()` aplica o mesmo filtro via `secao__perfis_restritos`, pra uma linha nunca vazar de uma seção que o usuário não veria.
- `AcessoGeralSerializer.__init__` também restringe o próprio campo `secao` (o `PrimaryKeyRelatedField` que valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém com `acessos-gerais-editar` mas sem o perfil exigido conseguiria criar uma linha dentro de uma seção restrita só sabendo o id dela, mesmo sem enxergá-la em nenhuma listagem.
Não há exceção pra `gerencia_permissoes`/perfil de acesso total — mesmo "Integração e Inovação" (código 8) fica de fora de uma seção restrita a outro perfil que não o seu, exatamente como qualquer outro perfil (é uma lista de permissão explícita, não um nível hierárquico).
**Ordenação por seção**: `AcessoGeral.ordem` é **escopada por `secao`** (ao contrário de `LinkFerramenta.ordem`, que é global) — `AcessoGeralViewSet.perform_create` calcula `max(ordem)` só entre as linhas da mesma seção. Reordenar (drag-and-drop nativo HTML5, mesma mecânica de `links-ferramentas.js` — `dragstart`/`dragover`/`drop` em `#ag-sections`, delegado num container que tem todas as seções) só é permitido **dentro de uma seção**: `dragover` ignora o alvo se `draggedAcesso.secao !== targetAcesso.secao`, então uma linha nunca muda de seção arrastando. As setas de mover para cima/baixo (`swapOrdem()`) seguem a mesma regra, já que operam sobre `acessosDaSecao(secao.id)`, nunca a lista inteira. Seções em si não têm drag-and-drop — só criar/renomear/excluir; a ordem entre seções é a de criação (`ordem` incrementado pelo servidor, sem UI de reordenar).
**Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir.
- **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` no topo de `serializers.py` — generalizadas nessas constantes desde que o texto de "Mais informações" de uma aplicação passou a reaproveitar o mesmo allowlist, ver "Ajuda de aplicação" no `CLAUDE.md` da raiz) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach.
- Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio").
**Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido.
Nenhuma tela recalcula união de departamentos/liderança aqui — é uma aplicação isolada, sem relação com `Usuario` além da permissão de quem pode ver/editar (e, agora, do `perfis_restritos` por seção).
## CSS
`links-ferramentas.css` (`.lf-*`) e `acessos-gerais.css` (`.ag-*`) — um arquivo por página, só usados em suas respectivas telas.

63
docs/perfis-usuarios.md Normal file
View File

@ -0,0 +1,63 @@
# Perfis de Acesso / Usuários (telas administrativas)
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para o **modelo de permissões em si** (formato do JSON de `PerfilAcesso.permissoes`, padrão visualizar/editar, `permissoes_efetivas()`) — esse conteúdo é transversal e continua lá porque toda aplicação do Portal depende dele. Este arquivo cobre só as telas administrativas (`perfis-acesso.html`/`usuarios.html`) em si. Não é auto-carregado pelo Claude Code — leia manualmente ao mexer nessas duas telas.
## Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)
Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre `#pa-app-search-modal` — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia **todos os perfis** que têm acesso a ela numa tabela só.
- **Fonte dos dados — 100% reaproveitado, sem endpoint novo**: `pidFlattenAplicacoes()` (`profiles.js`) achata `PID_MODULES` × `PID_MODULE_APPS` (já carregados de `GET /api/catalogo/` no load da página) numa lista plana de "aplicações" — uma por entrada de `MODULE_APPS`, seja ela um app simples ou um subgrupo com `tools` aninhadas (mesma unidade usada em `renderEntry()` da árvore). A busca (`renderAppSearchResults()`) casa o termo contra o label da aplicação, o label do módulo **e** o label de cada `tool` aninhada — esse último é o que permite achar, por exemplo, "Controle Simples Nacional" (o `tool` real dentro do subgrupo "Consultoria Tributária" de Auditorias) mesmo a unidade selecionável sendo o subgrupo inteiro.
- **Painel de gerenciamento** (`renderAppManageTable()`): ao clicar num resultado, mostra uma tabela com uma linha por perfil (`profiles`, o mesmo array já carregado pela tela) e uma coluna de checkbox por `tool` — para app simples sem subgrupo, uma coluna única "Acesso". O cabeçalho de cada coluna usa `tool.label.split(" (")[0]` (corta o parêntese explicativo tipo "Editar (reordenar, incluir e remover cartões)" → "Editar"), sem precisar de um label curto dedicado no catálogo.
- **Salva na hora, por checkbox** (decisão explícita do usuário — sem botão "Salvar" no popup): cada `change` dispara `PATCH /api/perfis/{codigo}/` só com `{permissoes: perfil.permissions}` (`pidUpdatePerfilPermissoes`, PATCH parcial — o `ModelViewSet` já aceita, `PerfilAcessoSerializer` não exige os outros campos fora de `partial_update`). Erro de rede reverte o checkbox e o estado em memória, com `pidAlert()` (ver "Modal de confirmação genérico" no `CLAUDE.md` da raiz — mesmo padrão de outros toggles imediatos do app, ex. inativar usuário).
- **Conceder acesso habilita o módulo automaticamente** (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com `enabled=false` naquele perfil, o toggle também vira `perm.enabled = true` no mesmo PATCH — sem isso, o perfil ganharia a chave em `apps` mas o item continuaria escondido no menu (`access.js` esconde o `nav-group`/`nav-subitem` inteiro por `enabled`, não só por app). **Revogar não desabilita o módulo de volta** (outras aplicações dele podem seguir em uso por aquele perfil).
- Perfis inativos (`ativo=False`) aparecem na tabela com o selo `.status-pill--inativo` (mesmo componente da coluna "Status" de `usuarios.html`), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.
## Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla
Substituiu o antigo `<select>` + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).
- **Duas tabelas** (`.pa-users-dual`, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda, `#pa-users-available-*` — todo usuário **ativo** ainda não vinculado a este perfil; à direita, `#pa-users-linked-*` — todo usuário **ativo** já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (`listaParaPainelUsuarios()` filtra `usuariosCacheAtual` por `is_active` antes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa os `perfis` de alguém no backend (`_revogar_acesso_se_inativo()`, ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (`#pa-users-available-select-all`/`#pa-users-linked-select-all`, aplica só sobre as linhas **filtradas** visíveis, mesmo critério de `.checklist-select-all`), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone `↕` que fica `--accent` quando ativo — mesmo padrão `.ua-sort-icon` já usado em `usuarios.html`) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (`.pa-users-table__filters`) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce.
- **Botão de atualizar** (ícone circular, `#pa-users-available-refresh-btn`/`#pa-users-linked-refresh-btn`) refaz `GET /api/usuarios/` (`refreshUsuarios()`, `profiles.js`) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra.
- **Vincular/Desvincular em lote**: o botão de cada painel (`#pa-users-link-btn`/`#pa-users-unlink-btn`, desabilitado enquanto a seleção daquele painel estiver vazia) dispara `bulkAlterarVinculo(kind, vincular)` — um `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`) por usuário selecionado, em paralelo (`Promise.all`), cada um recalculando a própria lista de `perfis` (adiciona ou remove só o `codigo` do perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (`refreshUsuarios()`) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa.
- Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
- Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia).
## Inativar/reativar usuário (usuarios.html)
Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:
- **Login novo**: `authenticate()` (usado em `login_view`) roda via `django.contrib.auth.backends.ModelBackend`, que internamente chama `user_can_authenticate()` e recusa (`None`) qualquer usuário com `is_active=False`, mesmo com a senha certa. Como isso faz `authenticate()` retornar `None` tanto pra senha errada quanto pra usuário inativo, `login_view` faz uma checagem manual **só no caminho de falha** (`Usuario.objects.filter(username=username, is_active=False).first()` + `check_password()`) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe.
- **Sessão já aberta**: também não precisa de nenhum middleware/permissão customizado — `ModelBackend.get_user(user_id)` (chamado pelo Django a cada request pra popular `request.user` a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira `AnonymousUser` automaticamente e `IsAuthenticated`/`PodeGerenciarPermissoes` já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.
**Inativar desvincula perfis de acesso e liderança automaticamente** (`_revogar_acesso_se_inativo()`, `serializers.py`, decisão explícita do usuário): sempre que `UsuarioSerializer.create()`/`update()` termina com `usuario.is_active=False`, `perfis` é limpo (`usuario.perfis.clear()`) e o usuário sai do `liderados` de qualquer gerente que o tivesse (`usuario.lideres.clear()` — `lideres` é a relação **reversa** de `Usuario.liderados`; limpar aqui remove `usuario` do lado de quem o lidera, sem afetar quem `usuario` eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a **última** coisa em `create()`/`update()`, depois dos `.set()` de `perfis`/`departamentos`/`liderados` — colocar antes seria inútil, já que o formulário de edição de `usuarios.html` sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um `.set()` posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por `PATCH /api/usuarios/{id}/`; não há um hook equivalente no `admin.py` (uso interno, fora de escopo). **Não é uma trava**: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios `liderados` pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).
Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pill`/`.status-pill--ativo`/`.status-pill--inativo`, mesmo componente que já existia pro `PerfilAcesso.ativo`) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — `PATCH /api/usuarios/{id}/` com `{is_active: !atual}`, com confirmação via `window.confirm`. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam **auto-desativação** (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, `id === me.id`) — no formulário isso aparece como o checkbox desabilitado (`disabled`) quando `account.id === me.id`, em vez de um alerta.
**Filtro de status na lista** (`#ua-status-filtros`, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de `.ind-departamento-chip`): client-side, sobre o array `users` já carregado (`statusFiltro` em `users-admin.js`, aplicado em `renderList()` antes do filtro de busca por texto). Nasce em `"ativos"` por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra `"ativos"` só no load da página, não a cada `renderList()`.
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`, ver `portal_api/planos_saude/CLAUDE.md`) e do modal de Ramais (`ramais-lookup.js`, ver `docs/ramais.md`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
**Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição**: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: `codigo_tareffa` via a view já existente em `portal_api/database/` que lê o Tareffa, ver [[project_database_package]] na memória) em vez de digitados à mão, mas essa vinculação de verdade **não foi implementada nesta rodada** — só a estrutura visual. Cada um dos 3 campos (`#ua-codigo-folha`/`#ua-codigo-questor`/`#ua-codigo-tareffa`) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (`.ua-field-link` — borda/raio únicos, botão separado por `border-left`, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre `disabled` com `title="Vinculação com sistema externo ainda não implementada"` — não tem nenhum handler de clique em `users-admin.js`. `codigo_contabit` e `ramal` (também campos cadastrais na mesma seção "Dados Cadastrais") **não** ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o `disabled`, adicionar o handler), não recriar o campo do zero.
## Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"
`Usuario.lideranca` (booleano) marca um usuário como gerente/coordenador de outros; `Usuario.liderados` é um M2M **auto-referenciado** (`"self"`, `symmetrical=False`, `related_name="lideres"`) — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar `lideranca=True` em "debora" e incluir "gabriel" em `liderados` representa "debora é gerente de gabriel".
Dois lugares gravam essa mesma relação:
- **Tela de Usuários** (`usuarios.html`/`users-admin.js`, só quem tem `gerencia_permissoes`): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (`#ua-lideranca`) libera (`hidden`) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia `lideranca`+`liderados` (array de ids) no mesmo payload de `PATCH`/`POST /api/usuarios/`.
- **Modal "Gerenciar Usuário"** (`account.js`, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre `#manage-account-modal`, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o `#password-modal` já existente, mesmo fluxo de antes) e, só se `me.lideranca` for `true`, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de `GET /api/usuarios-resumo/` (não de `/api/usuarios/`, que exige `gerencia_permissoes`) e salvar dispara `PATCH /api/me/liderados/`, que grava na mesma `Usuario.liderados` — `meus_liderados_view` recusa (403) se `request.user.lideranca` for `False`, já que só faz sentido pra quem tem o checkbox marcado.
**Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita** (`.dual-select`, `static/js/dual-select.js` + estilos em `components.css`): substituiu o antigo `.checklist-box` de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver acima), só que genérico o bastante pra rodar tanto em `usuarios.html` quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). `pidCriarSeletorDuplo(config)` (`dual-select.js`, incluído no prefixo de scripts de todo shell, logo depois de `api.js`) é a fábrica compartilhada — recebe as referências de DOM de cada painel (`available`/`linked`: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais `secundariaValor(candidato)` (aqui, `departamentosTexto()`, unindo os nomes dos departamentos por vírgula) e devolve `{ setDados(candidatos, vinculadosIniciais), getVinculadosIds() }`. Cada tela (`users-admin.js`/`account.js`) só chama `setDados()` ao abrir o formulário/modal e `getVinculadosIds()` no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um `Set` em memória até o "Salvar" de fora).
- Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas **filtradas** visíveis, mesmo critério do antigo `.checklist-select-all`), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro).
- `#manage-account-modal-card` (id novo no `.modal-card` do modal "Gerenciar Usuário") ganha a classe `.modal-card--wide` via JS (`account.js`) só quando `me.lideranca` é `true` — o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém.
- `.dual-select`/`.dual-select__*` moram em `components.css` (não numa folha de estilo dedicada), pela mesma razão de `.checklist-box` — o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carrega `perfis-acesso.css`.
- **Usuário inativo nunca aparece nos candidatos** — mesma decisão de "Usuários do Escritório" acima. Em `usuarios.html`, `fillLideradosChecklist()` (`users-admin.js`) filtra `users` por `is_active` antes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porque `GET /api/usuarios-resumo/` (`usuarios_resumo_view`) só devolve usuários ativos.
## `seed_portal.py` não reseta `nome` de um perfil já existente
Bug real corrigido, motivado pelo usuário ter renomeado o perfil "Integração e Inovação" código 8 pra "Inovação" — e criado um perfil novo "Integração", código 9: `update_or_create(codigo=..., defaults={"nome": ..., ...})` reescrevia `nome` a cada execução, revertendo qualquer renomeação feita depois pela tela de Perfis de Acesso. Trocado por `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação) + atribuição direta de `ativo`/`gerencia_permissoes`/`permissoes` a cada execução (esses continuam sendo realinhados sempre — é assim que o seed serve pra corrigir uma árvore de permissões corrompida/desatualizada, só `nome` parou de ser tocado). Mesmo espírito de "só inicializa na primeira criação" já usado pra gabriel/bruno. Ver `[[feedback_seed_nao_reseta_gabriel_bruno]]` na memória.
## CSS
`perfis-acesso.css` (`.pa-*`) cobre as duas telas — inclui as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`, mesmo o arquivo se chamando "perfis-acesso".

70
docs/ramais.md Normal file
View File

@ -0,0 +1,70 @@
# Ramais
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.). Este arquivo não é auto-carregado pelo Claude Code (não há pacote Python dedicado a Ramais, o código vive em `portal_api/models.py`/`views.py`/`serializers.py` junto com o resto) — leia manualmente ao mexer nesta aplicação.
O diretório de `ramais.html` é **automático**: `RamalViewSet.list()` (não o `RamalSerializer` — esse serializer só cobre as linhas avulsas via CRUD normal) mescla, a cada `GET /api/ramais/`, duas fontes numa lista só, ordenada por nome:
1. Todo `Usuario` ativo — a linha é montada direto do cadastro (`nome`, `Usuario.departamentos` juntados por vírgula, `Usuario.ramal`); se o colaborador ainda não tem ramal preenchido, `numero_exibicao` vem vazio e o frontend mostra um badge "Pendente" em vez de reclamar de campo faltando.
2. As linhas avulsas de `Ramal` (sem `Usuario` por trás — telefone de sala, recepção etc.), cadastradas pelo modal "Adicionar Ramal".
Cada item da lista mesclada tem um `id` sintético (`"usuario-<id>"` ou `"avulso-<id>"`) e um campo `tipo` (`"usuario"`/`"avulso"`) que o frontend usa pra decidir qual endpoint chamar ao editar/excluir — não existe mais um model unificando os dois casos com uma FK opcional (essa foi a primeira versão da tela; revertida a pedido do usuário pra eliminar o passo manual de "adicionar" alguém que já tem cadastro).
Segue o mesmo padrão visualizar/editar de Links & Ferramentas (ver `CLAUDE.md` na raiz, seção "Padrão visualizar/editar" dentro de "Modelo de permissões"): leitura exige `apps.visualizar` (liberado a todo perfil, já que `ramais` está em `BASE_KEYS`), escrita exige `apps.editar` — por ora só `True` pra "Integração e Inovação" no `seed_portal.py`, exatamente como pedido; liberar outro perfil não pede código novo, só marcar o app na árvore de Perfis de Acesso.
- **Editar o ramal de um colaborador de verdade**: não existe "criar" — a linha já aparece sozinha. O lápis na linha abre o mesmo modal de Ramal, mas com Nome/Departamento desabilitados (só leitura do cadastro) e só o campo Ramal editável; salvar chama `PATCH /api/ramais/usuarios/{usuario_id}/` (`RamalViewSet.atualizar_ramal_usuario`), que grava direto em `Usuario.ramal` — é assim que a tela demonstra a alteração refletindo no cadastro do usuário.
- **Linha avulsa**: "Adicionar Ramal" sempre cria uma linha avulsa (`POST /api/ramais/`, `nome`/`departamento`/`numero` livres — só `nome` é obrigatório, o resto pode ficar pendente igual a um colaborador de verdade). Editar/excluir uma linha avulsa usa `PATCH`/`DELETE /api/ramais/{avulso_id}/` normalmente; excluir só existe pra esse tipo (não dá pra "excluir" um colaborador daqui — isso é na tela de Usuários).
- **Lista de usuários do modal de Ausência**: `RamalViewSet.usuarios_disponiveis` (`GET /api/ramais/usuarios/`) devolve só `id`/`nome` de usuários ativos, pra alimentar o `<select>` "Lista de Usuários" do modal "Criar Ausência" (o único modal que ainda precisa escolher uma pessoa numa lista — o modal de Ramal não precisa mais, já que a linha do colaborador já existe). Não reaproveita `/api/usuarios/` de propósito — aquele endpoint é restrito a `gerencia_permissoes`, e a permissão de Ramais é deliberadamente desacoplada disso (hoje dá na mesma pessoa, mas não presume que sempre será assim).
- **Ausência** (`RamalAusencia`): um registro por período criado pelo modal "Criar Ausência"; "ausente agora" nunca é armazenado — `RamalAusencia.esta_ativa()` compara a hora atual (`timezone.localtime()`) contra `[data_inicio+hora_inicio, data_fim+hora_volta]` (hora ausente = considera o dia inteiro) toda vez que `RamalViewSet.list()` monta a resposta. Clicar no badge "(AUSENTE)" de uma linha (`data-ram-ver-ausencia`, qualquer um com `apps.visualizar` pode abrir) faz `GET /api/ramais-ausencias/{id}/` e abre o modal "Visualizar Ausência" — campos desabilitados (`<input type="date"/"time">` mostra a data/hora formatada mesmo `disabled`, sem precisar formatar manualmente), com "Editar Ausência"/"Deletar Ausência" visíveis só pra quem tem `apps.editar`. "Editar" habilita os mesmos campos in-place (sem modal novo) e troca os botões por "Cancelar"/"Salvar Ausência" (`PATCH /api/ramais-ausencias/{id}/`); "Deletar" remove o registro (`DELETE`) — não existe mais um botão de "encerrar antes do previsto" separado (a rodada anterior tinha isso via `encerrada_manualmente`; substituído por editar/excluir de verdade, mais simples e é o que foi pedido). O campo `encerrada_manualmente` continua no model (histórico/uso futuro via admin), só não tem mais UI própria.
- **Aniversariante**: comparação de `Usuario.data_aniversario` (mês/dia) com `timezone.localdate()`, feita no mesmo `list()` — mesmo campo que já existia no cadastro de Usuários, sem nada novo ali.
- **Selos de ausente/aniversariante**: `.ram-badge--ausente`/`.ram-badge--aniversario` (`ramais.css`) são selos (pill) com cor de texto/fundo ajustada por tema via `:root[data-theme="light"] .ram-badge--*` — não reaproveitam `--danger`/`--gold` crus porque esses tokens não foram pensados pra texto pequeno sobre um selo (contraste insuficiente). A linha inteira também é tingida (`.ram-row--ausente`/`.ram-row--aniversario` td, aplicado via classe no `<tr>` em `ramais.js`) com a mesma cor do selo, também ajustada por tema — pedido explícito do usuário pra facilitar notar a linha antes mesmo de ler o selo (a versão anterior sem tingimento de linha foi revertida).
- **Férias**: a aba existe (navegação por abas, ver abaixo) mas está **vazia de propósito** — o conteúdo foi adiado pra uma rodada futura; a limitação original ("depende de integração futura com outro banco") continua valendo, só a decisão de já reservar o espaço na navegação é nova.
- **Novo Chamado**: botão que abre um modal com um `<iframe>` apontando para a ferramenta externa de chamados (`https://depaula-tvcorporativa.lovable.app/chamar?token=...`) — decisão explícita de ficar embutido na própria tela em vez de nova aba (diferente do padrão dos demais links externos do portal). O `src` do iframe só é setado na abertura do modal e volta pra `about:blank` ao fechar, pra não deixar a ferramenta carregada em segundo plano.
- **Sem reordenação**: ao contrário de Links & Ferramentas/Widgets, a listagem é sempre alfabética (`sort()` em `list()`), sem `ordem`/drag-and-drop.
- Usuário inativo (`is_active=False`) não aparece mais no diretório (o `list()` filtra `Usuario.objects.filter(is_active=True)`) — diferença deliberada da primeira versão, que ainda mostrava inativos se tivessem uma linha vinculada.
## Navegação por abas em `ramais.html` (subtelas)
`ramais.html` deixou de ser uma tela única — é uma seção com 5 subtelas, navegáveis por abas logo abaixo do cabeçalho: **Ramais** (diretório descrito acima, ativa por padrão), **Responsável no Tareffa** (placeholder vazio), **Telefones Externos**, **Férias** (placeholder vazio) e **Funções de Telefonia**. As abas reaproveitam o CSS genérico `.pa-tabs`/`.pa-tab`/`.pa-tab-panel` (`perfis-acesso.css`, já carregado nesta página desde antes — mesmo padrão usado nas abas Permissões/Usuários de `perfis-acesso.html`), mas com atributos próprios (`data-ram-tab`/`data-ram-tab-panel`) e uma implementação independente em `ramais.js` (`activeRamTab`/`renderRamTabs()`), pra não colidir com `profiles.js`. Os botões "Adicionar Ramal"/"Novo Chamado"/"Criar Ausência" continuam só dentro do painel "Ramais" — cada subtela tem suas próprias ações.
**Permissão — uma dupla visualizar/(editar) por subtela**: cada uma das 5 abas tem sua própria permissão de visualização, e as 3 com conteúdo administrável (Ramais, Telefones Externos, Funções de Telefonia) também têm sua própria permissão de edição — não é mais um único par genérico `ramais.apps.visualizar`/`ramais.apps.editar` cobrindo tudo (esse desenho, usado na primeira versão da navegação por abas, foi revisto no mesmo dia a pedido do usuário: "deve haver permissão de visualização para cada um dos itens e edição para as de ramais, telefone externos e funções de telefonia"). Em `catalogo.MODULE_APPS["ramais"]`, isso é modelado como **5 subgrupos** (mesmo formato `{"key", "label", "tools": [...]}` já usado em Auditorias — reaproveita 100% a árvore de permissões genérica de `profiles.js`, sem UI nova):
```python
"ramais": [
{"key": "ramais-diretorio", "label": "Ramais", "tools": [
{"key": "ramais-visualizar", "label": "Visualizar"},
{"key": "ramais-editar", "label": "Editar (...)"},
]},
{"key": "responsavel-tareffa", "label": "Responsável no Tareffa", "tools": [
{"key": "responsavel-tareffa-visualizar", "label": "Visualizar"},
]},
{"key": "telefones-externos", "label": "Telefones Externos", "tools": [...]},
{"key": "ferias", "label": "Férias", "tools": [{"key": "ferias-visualizar", ...}]},
{"key": "funcoes-telefonia", "label": "Funções de Telefonia", "tools": [...]},
],
```
Cada `ModelViewSet` (`RamalViewSet`/`RamalAusenciaViewSet`, `TelefoneExternoViewSet`, `FuncaoTelefoniaViewSet`) instancia `PermissaoApp("ramais", app_key)` com a chave da própria subtela (ex.: `"telefones-externos-visualizar"`/`"telefones-externos-editar"`) — `RamalAusenciaViewSet` usa as mesmas chaves `ramais-visualizar`/`ramais-editar` do diretório de Ramais, já que ausência é parte dessa subtela, não uma quinta. No frontend, `ramais.js` calcula um `canView`/`canManage` por subtela a partir de `me.permissoes_efetivas.ramais.apps[chave]`, esconde (`hidden`) o botão de cada aba cujo `visualizar` for falso, e escolhe a primeira aba visível como ativa por padrão (em vez de sempre abrir em "Ramais", que pode estar oculta pra esse perfil). `ramais-lookup.js` (modal de consulta rápida no topbar) usa especificamente `ramais-visualizar`, já que só mostra o diretório de Ramais, não as outras subtelas.
**Cuidado com `seed_portal.py`** (mesmo princípio da nota geral em "Padrão visualizar/editar", ver `CLAUDE.md` na raiz): como `ramais` está em `BASE_KEYS`, `permissions_from_keys()` habilitaria os 8 apps (visualizar de todas as 5 + editar das 3) de uma vez — sem o override, todo perfil nasceria podendo editar. Por isso `seed_portal.py` força `ramais-editar`/`telefones-externos-editar`/`funcoes-telefonia-editar` para `False` explicitamente em todo perfil que não seja "Integração e Inovação", depois de montar o dict — os `*-visualizar` ficam `True` pra todo mundo de propósito ("os demais terão acesso para visualizar todas"). Qualquer mudança de nome/adição de subtela nesse padrão precisa replicar esse mesmo cuidado.
Um perfil só-visualizar vê as 5 abas e as tabelas, mas nunca os botões de Adicionar/editar/excluir em nenhuma delas; um perfil sem `visualizar` numa subtela específica não vê nem a aba dela.
**Telefones Externos** (`TelefoneExterno`, model dedicado sem FK — contatos de fornecedores/terceiros, não de `Usuario`): CRUD simples via `/api/telefones-externos/`, só `nome` obrigatório (`ramal`/`telefone`/`observacoes` opcionais, mesmo padrão de `Ramal` avulso). Dois filtros de busca (`ram-tel-search-nome`/`ram-tel-search-obs`, client-side sobre o array já carregado) — por nome e por observações, ao mesmo tempo, sem OR/AND configurável. A tabela começa vazia (nenhum seed) — o usuário cadastra pela própria tela.
**Funções de Telefonia** (`FuncaoTelefonia`) — comandos padrão da central telefônica (ex.: `*01 + Código de Agente` → LogOn). CRUD via `/api/funcoes-telefonia/`, só `comando` obrigatório. `Meta.ordering = ["comando"]` reproduz sozinho a ordem esperada (`*0, *01, ..., *5, *503, *8`) porque os códigos já nascem em ordem lexicográfica como string — não precisou de um campo `ordem` manual nem de endpoint de reorder, ao contrário de `LinkFerramenta`/`Favorito`/`WidgetUsuario`. Ao contrário de Telefones Externos, esta tabela **é seedada**: `seed_portal.py` popula as 13 linhas padrão (`FUNCOES_TELEFONIA_SEED`, `update_or_create` por `comando`) porque é documentação genérica de central telefônica, não dado específico da empresa — reexecutar `seed_portal` é seguro/idempotente, não duplica nem apaga linhas editadas manualmente (só atualiza `funcao`/`resumo` de um `comando` que já exista).
Nenhuma das duas subtelas tem endpoint de reorder — só criar/editar/excluir, mesmo escopo pedido.
## Modal de consulta rápida ("Ramais")
O botão "Ramais" do topbar (`#ramais-btn`, presente em `portal.html`/`links-ferramentas.html`/`calendario-individual.html` — as únicas 3 páginas que têm esse atalho; texto era "Acessar Ramais", encurtado depois) **não navega** para `ramais.html`; abre um modal somente-leitura (`ramais-lookup.js`/`ramais-lookup.css`) com a mesma listagem mesclada de `GET /api/ramais/`, inspirado numa tela do portal antigo (estilo DataTables: "Mostrar N registros", busca, colunas ordenáveis, paginação). Diferenças pro comportamento antigo do botão:
- Gate de acesso: some (`hidden`) se `permissoes_efetivas.ramais.apps["ramais-visualizar"]` for falso — mesmo padrão de qualquer UI gated por permissão no app.
- Busca é **uma só caixa** (não uma por coluna) que filtra por nome, departamento ou ramal ao mesmo tempo — mais simples que a paginação em duas caixas da própria `ramais.html`.
- **Botões de filtro por departamento** (`.ram-lookup-depto-filters`, acima da tabela): "Todos" + um botão por `Departamento` cadastrado, buscados de `GET /api/departamentos-resumo/` na primeira abertura (endpoint dedicado, `IsAuthenticated` + checagem manual de `permissao_app("ramais", "ramais-visualizar")` — não reaproveita `/api/departamentos/`, que exige `gerencia_permissoes` e bloquearia a maioria dos usuários que só têm acesso ao próprio Ramais). Clicar num botão filtra a listagem pra quem tem aquele departamento entre os seus (`departamento_exibicao.split(",")`, comparação exata após `trim` — não substring, pra não casar um departamento que seja prefixo de outro) e combina com a busca por texto (as duas condições precisam bater). Como os botões são gerados a partir da lista de departamentos vinda da API a cada abertura do modal, cadastrar um departamento novo em Usuários já basta pra ele aparecer aqui — não precisa mexer no frontend.
- Ordenação por coluna (clicar no cabeçalho alterna asc/desc) e paginação (`10`/`25`/`50`/`100` por página) são só client-side, sobre o array já carregado — sem endpoint novo, sem parâmetro de query; os `/api/ramais/`/`/api/departamentos-resumo/` são buscados uma única vez por abertura de página (cacheados em memória enquanto a página não recarrega) e refiltrados/reordenados em JS a cada tecla/clique.
- Botão "Ir para Controle de Ramais" no rodapé é o link de verdade pra `ramais.html` (tela completa, com edição) — o modal em si não tem nenhum controle de escrita, é só consulta.
## CSS
`ramais.css` (`.ram-*`) — a tabela em si reaproveita `.pa-table*`/`.pa-row-actions` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS próprio). O modal de consulta rápida (`#ramais-btn`) tem CSS próprio em `ramais-lookup.css` (`.ram-lookup-*`), autocontido (não reaproveita `.pa-table` porque as 3 páginas que o usam não carregam `perfis-acesso.css`).

7
docs/solicitacoes.md Normal file
View File

@ -0,0 +1,7 @@
# Solicitações
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Não é auto-carregado pelo Claude Code — leia manualmente ao mexer nesta seção do menu.
Os 6 tópicos do menu "Solicitações" (`catalogo.MODULE_APPS["solicitacoes"]`) não são telas próprias — cada um (exceto "Ordem de Serviço", que ainda não tem link definido e continua com `href="#"`, mesmo padrão de qualquer aplicação-placeholder do portal) é só um link externo (hoje, um formulário do Asana) aberto em **nova aba** (`target="_blank" rel="noopener noreferrer"`), igual ao padrão já usado nos cartões de Links & Ferramentas.
**Por que não embutido em iframe**: a primeira versão desta seção tentava centralizar os 5 links num popup com `<iframe>` (numa página dedicada `solicitacoes.html`), inspirado no "Novo Chamado" de Ramais. Revertido no mesmo dia: o Asana bloqueia ser carregado em iframe de outro domínio via `X-Frame-Options`/`Content-Security-Policy: frame-ancestors` (proteção padrão contra clickjacking), então o navegador recusa a conexão (`net::ERR_BLOCKED_BY_RESPONSE`/"A conexão com form.asana.com foi recusada"). Isso não tem workaround no frontend — não confundir com o iframe de "Novo Chamado" em Ramais (`docs/ramais.md`) ou o de "Calendário De Paula" (ver `CLAUDE.md` na raiz, seção "Páginas"), que funcionam porque aquela outra ferramenta (`depaula-tvcorporativa.lovable.app`) não bloqueia embed. Não reintroduzir esse padrão de iframe pra Solicitações sem confirmar com o usuário que o destino realmente permite ser embutido.

View File

@ -0,0 +1,13 @@
# Simulação de Custo de Contratação (Geradoc)
> 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/custo_contratacao/`. Ver também a skill `simulacao-custo-contratacao` (`.claude/skills/`) para contexto de negócio (por quê, limitações conhecidas).
Ferramenta que substitui a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal — calcula o custo de contratar um Empregado CLT e devolve um PDF pronto pra enviar ao cliente. Permissão de **toggle único** (`apps["simulacao-custo-contratacao"]` em `permissoes["geradoc"]`, sem par visualizar/editar), checada manualmente (`request.user.permissao_app("geradoc", "simulacao-custo-contratacao")`) nas duas views (não são `ModelViewSet` — são funções simples, `POST /api/simulacao-custo-contratacao/gerar/` e `GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`).
- **Escopo v1: só Empregado CLT.** O pedido original mencionava 5 modalidades (Simples Nacional, Regime Normal, Pró-labore, Empregado, Empregado Doméstico), mas só havia planilha de referência validada pra Empregado CLT — as outras 4 ficam para quando houver uma fonte de regras equivalente confirmada pelo contador; não implementar "seguindo o mesmo padrão" por conta própria.
- **Sem persistência**: `POST /api/simulacao-custo-contratacao/gerar/` é um cálculo pontual — recebe os dados do formulário, calcula (`custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto (`HttpResponse` binário, `Content-Disposition: inline`), nada é salvo no banco. Diferente do padrão "com histórico" de `ImportacaoPlanoSaude`/`IndicadorApuracao`.
- **Tabelas de INSS/IRRF editáveis pelo banco**: `ParametroFiscalCustoContratacao` (`models.py`) é um singleton (`atual()`, sempre `pk=1`, criado sob demanda via `get_or_create`) com `faixas_inss`/`faixas_irrf` em `JSONField` (lista de `{limite_superior, aliquota, deduzir}`) + escalares (teto de desconto de INSS, alíquota/dedução do IRRF acima da última faixa, desconto simplificado do IRRF, dedução por dependente, e os 3 parâmetros da redução da Lei 15.270/2025 — coeficientes A/B e limite de rendimento bruto), editáveis pelo painel colapsável da própria tela (`GET`/`PATCH /api/parametros-fiscais-custo-contratacao/`, mesma permissão de quem usa a simulação). `custo_contratacao/tabelas.py` continua existindo só como **seed/default** da primeira criação da linha (`_faixas_inss_padrao`/`_faixas_irrf_padrao` em `models.py`) — `calculo.py` nunca lê `tabelas.py` direto, sempre recebe um `ParametrosFiscais` (dataclass pura, sem ORM) montado por `ParametroFiscalCustoContratacao.para_calculo()`.
- **Redução de IRRF da Lei nº 15.270/2025** (art. 3º-A da Lei 9.250/1995, vigente desde jan/2026): isenção total até R$5.000 de rendimento bruto mensal, redução decrescente até zerar em R$7.350 — `redução = max(0, coeficiente_a − coeficiente_b × rendimento bruto)`, aplicada por cima do imposto já calculado pela tabela progressiva tradicional (que a lei não alterou), nunca deixando o imposto final negativo.
- **Correção deliberada em relação à planilha original**: a planilha nunca somava a dedução por dependente (R$189,59/dependente) à base do IRRF quando usava o desconto real de INSS — só quando usava o desconto simplificado (que por lei substitui os dois). Confirmado como gap com o usuário e corrigido: ao usar o desconto real de INSS, a dedução por dependente também é subtraída agora (`custo_contratacao/calculo.py`).
- **PDF via `reportlab`** (pure-Python, sem dependência nativa problemática no Windows) — cabeçalho é um banner marrom escuro com `logo-branco.png` + "De Paula Contadores", nas cores reais da marca (dourado `#D3AF4D`, marrom `#4A3C28`, amostradas do próprio `logo.png`), não o roxo do tema de interface do Portal.
- Localização no menu (dentro de **Geradoc**, ao lado de "Gerar Contrato"/"Gerar Procuração") foi escolha explícita do usuário, não Utilitários.

View File

@ -0,0 +1,33 @@
# Indicador de Desempenho (Geradoc)
> 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/indicadores/`. Ver também a skill `indicador-desempenho` (`.claude/skills/`) para contexto de negócio (por quê, limitações conhecidas).
Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), antes feita numa planilha (`FISCO CONTABIL *.ods`, em `projects/Indicadores/`) com fórmulas quebradas por edições manuais acumuladas. Mesmo padrão de permissão de **toggle único** de Simulação de Custo de Contratação (`apps["indicador-desempenho"]` em `permissoes["geradoc"]`, checado por `PermissaoApp("geradoc", "indicador-desempenho")` em todos os `ModelViewSet` relacionados). Pacote de negócio em `portal_api/indicadores/` (sem ORM): `tipos.py`, `leiaute.py`, `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py`, `departamentos.py`.
- **Escopo v1: só o Fiscontábil**, papéis Balancete/Liberação Fiscal/Conciliação Financeira. Outros departamentos ficam pra rodada futura — **exceto pela estrutura de cadastro em si** (ver "Departamento organizacional" abaixo), que já suporta múltiplos departamentos com critérios/percentuais próprios, mesmo que só o Fisco/Contábil tenha regras cadastradas até agora.
- **8 models** (migrações `0024`–`0028`, `0033`–`0035`): `IndicadorDepartamento` (cadastro de departamentos — nome/ativo — usado pra escopar critérios, percentuais e metas de Departamento; ver "Departamento organizacional" abaixo), `IndicadorDepartamentoGerente` (relação gerente→departamento, mantida manualmente pela aplicação), `IndicadorPercentualTipo` (percentuais individual/grupo/departamento por tipo de colaborador **e por departamento**, histórico via `vigente_desde` — nunca editado in-place), `IndicadorCriterio` (cadastro genérico de critério: **departamento**/nome/grupo/peso/período/papel/`calculo_automatico`/`limiar_percentual`), `IndicadorApuracao` (uma apuração mensal — `competencia`, `status` `revisao`/`concluida`, as 2 planilhas anexadas, `avisos` de processamento), `IndicadorApuracaoColaborador` (um colaborador dentro de uma apuração, com `pct_individual`/`pct_grupo`/`pct_departamento` e respectivos flags `*_ajustado_manualmente`, mais `departamento` — FK pra `IndicadorDepartamento`, resolvida na criação via o gerente do colaborador, ver "Departamento organizacional" abaixo), `IndicadorApuracaoEmpresa` (uma empresa/honorário do colaborador naquele mês) e `IndicadorApuracaoResposta` (SIM/NÃO/NÃO FAZ/NÃO SE APLICA de um colaborador para um critério).
- **Tipo do colaborador é derivado por empresa, não é cadastro**: `TIPO_COLABORADOR_INDICADOR_CHOICES` (Contábil+Fiscal/Contador SC/Contador CC/Fiscal/Conciliador) — regra em `indicadores/tipos.py`, validada contra um recibo-modelo real (~99,99% de precisão no teste com 43 colaboradores).
- **3 critérios são calculados automaticamente** a partir da planilha "Serviços Tareffa" (`indicadores/entregas.py`/`pipeline.py`) — entrega de balancetes/liberações fiscais/conciliações no prazo, comparadas contra `IndicadorCriterio.limiar_percentual` pra decidir SIM/NÃO. Todo o resto é sempre marcação manual do RH (SIM/NÃO/NÃO FAZ/NÃO SE APLICA por critério, individual ou em lote). Um critério automático sem nenhum registro do serviço vira **NÃO SE APLICA**, não NÃO FAZ — permite deixar `papel_aplicavel` em branco nesses 3 critérios (um mesmo critério, ex. "balancete", se aplica a 3 tipos ao mesmo tempo, e `papel_aplicavel` só aceita um valor); quem não presta aquele serviço fica de fora do cálculo por conta própria (NÃO SE APLICA é excluído do denominador em `calculo.py`).
- **Fórmula**: `honorario_ajustado = honorario_empresa × pct_individual_do_colaborador`; `valor_individual = honorario_ajustado × percentual_individual(tipo)`; `valor_grupo`/`valor_departamento = valor_individual × percentual_grupo/departamento(tipo) × pct_grupo/departamento_do_colaborador`. `pct_individual` **não é só a média dos critérios Individual** — é a composição ponderada dos 3 níveis (Individual/Grupo/Departamento), cada um pesando conforme o peso médio dos seus próprios critérios aplicáveis na competência (`calculo._combina_niveis`/`_peso_medio_nivel`); só `pct_grupo`/`pct_departamento` continuam sendo a média simples dos próprios critérios, sem composição.
- **"Cada gerente representa um grupo", cada departamento representa um departamento** (não é redundante, ver abaixo): `pct_grupo` é conceitualmente compartilhado por todos os colaboradores com o mesmo `gerente` dentro da apuração, e `pct_departamento` é compartilhado por todos os colaboradores do mesmo `IndicadorDepartamento` (ver "Departamento organizacional" abaixo — não mais um valor único pra toda a apuração) — por isso não são ajustados colaborador a colaborador (`IndicadorApuracaoColaboradorViewSet` só cobre `pct_individual`); `IndicadorApuracaoViewSet.ajustar_grupo`/`recalcular_grupo`/`ajustar_departamento`/`recalcular_departamento` aplicam a mudança de uma vez a todo o grupo/departamento, mantendo os colaboradores em sincronia (`ajustar_departamento`/`recalcular_departamento` recebem `departamento` — o id do `IndicadorDepartamento` — no corpo, filtrando com um `.filter(departamento_id=...)` direto). A tela (`indicador-desempenho.js`) reflete isso com uma tabela de "Metas de Grupo e Departamento" no topo (uma linha de Departamento por `IndicadorDepartamento` + uma linha de Grupo por gerente dentro dele) separada da lista de colaboradores abaixo (que serve só pra revisão individual — percentual Individual, respostas de critério, recibo); botões de filtro por departamento (`#ind-filtro-departamento`, ver "Departamento organizacional" abaixo) restringem a tabela de Metas e a lista de colaboradores a um departamento de cada vez, sem afetar o cálculo de nenhuma meta. Colaborador cujo gerente não está mapeado a nenhum departamento cai num grupo "Sem departamento definido" (sem `<select>` de meta — não há `IndicadorDepartamento` pra aplicar). **A meta de Grupo/Departamento é sempre Sim/Não (100%/0%)**, nunca um percentual livre — decisão explícita do usuário ("será pago ou não") — por isso a coluna "Meta (%)" dessa tabela é um `<select class="ind-meta-select">` com só essas duas opções (`ehSim = valor >= 50` decide qual aparece selecionada ao carregar), não um campo de texto livre; o valor enviado a `ajustar-grupo`/`ajustar-departamento` é sempre `"100"` ou `"0"`. O percentual Individual de cada colaborador continua livre (é uma composição ponderada dos 3 níveis, pode legitimamente ser fracionário — ver acima).
- **Departamento organizacional** (`IndicadorDepartamento`/`IndicadorDepartamentoGerente`/`portal_api.indicadores.departamentos`) — substituiu, numa rodada posterior, o mecanismo de "setor" (coluna bruta "departamento" da planilha Tareffa + fusão automática Contabilidade/Fiscal→Fisco-Contábil + `IndicadorSetorApelido`, cadastro-exceção por colaborador). Agora **critérios e percentuais também são configurados por departamento** (não só as metas de Grupo/Departamento) — decisão explícita do usuário: a regra do Fisco/Contábil pode ser diferente da regra do Condomínio, por exemplo. `IndicadorDepartamento` (nome/ativo) é um cadastro simples, mantido pela própria aplicação (Configurações → Departamentos); a relação com gerentes (`IndicadorDepartamentoGerente`, `nome_gerente` único — um gerente pertence a só um departamento, mas um departamento pode ter vários gerentes, ex.: Fisco/Contábil tem "João Candido Rodrigues" **e** "Lhais Vergilio Delavy") também é mantida manualmente por ora — alimentar isso automaticamente a partir da planilha fica pra uma rodada futura (decisão explícita do usuário). Pra não obrigar o RH a redigitar nomes (arriscando um typo que faria uma apuração futura não casar com o departamento certo), o popup "Gerenciar Gerentes" (`indicador-desempenho.js`) mostra uma lista de **sugestões clicáveis** — `carregarGerentesSugeridos()` busca a apuração mais recente (`GET /api/indicadores-apuracoes/`, já ordenada por `-competencia`/`-criado_em`) e lista os nomes distintos de `colaborador.gerente` que ainda não estão em nenhum `IndicadorDepartamentoGerente`; clicar numa sugestão já cria a relação pra aquele departamento. É só um atalho de UI (não muda a origem do dado) — o campo de texto livre continua disponível pra gerentes que não apareceram na última apuração.
Resolução do departamento de um colaborador: `IndicadorApuracaoViewSet.create()` monta `mapa_gerentes` (`departamentos.carrega_mapa_gerentes()`, `{nome_gerente: departamento_id}`) uma vez e passa pro `pipeline.processa_apuracao()`, que resolve `departamento_id = mapa_gerentes.get(colaborador.gerente)` pra cada colaborador **antes** de decidir quais critérios automáticos calcular pra ele (críticos automáticos também são agrupados por `departamento_id` — `criterios_automaticos_por_departamento`, já que departamentos diferentes podem ter critérios/limiares diferentes). O resultado (`IndicadorApuracaoColaborador.departamento`, FK nullable) é um **retrato daquele momento** — mesmo espírito de `gerente`/`setor` antes dele: se a relação gerente→departamento mudar depois, apurações já criadas não mudam sozinhas. Colaborador cujo gerente não está mapeado a nenhum departamento fica com `departamento=None` e vira um aviso no processamento ("Gerente X não está associado a nenhum departamento..."), além de não ganhar nenhuma `IndicadorApuracaoResposta` (sem departamento, não há de onde vir nenhum critério). `IndicadorApuracaoColaboradorSerializer` expõe `departamento` (id) + `departamento_nome` (com fallback `None`, mesmo padrão de `criado_por_nome`).
**Migração em 3 passos** (mesmo padrão de qualquer FK obrigatória adicionada a uma tabela já populada): `0033` cria os 2 models novos + adiciona `departamento` nullable em `IndicadorCriterio`/`IndicadorPercentualTipo`/`IndicadorApuracaoColaborador` (e remove `setor`/`IndicadorSetorApelido`); `0034` (`RunPython`) cria o departamento "Fisco/Contábil" e aponta todo `IndicadorCriterio`/`IndicadorPercentualTipo` já existente pra ele (é literalmente o que a regra única representava até então); `0035` torna `departamento` obrigatório em `IndicadorCriterio`/`IndicadorPercentualTipo` (não em `IndicadorApuracaoColaborador`, que continua nullable). **Apurações criadas antes desta migração** (e qualquer apuração nova, até o admin mapear os gerentes relevantes em Configurações → Departamentos) ficam com `departamento` em branco em todos os colaboradores — precisam de um backfill pontual ou de serem reprocessadas depois que a relação gerente→departamento existir.
**Limitação conhecida, validada com dados reais**: como a resolução é por `gerente` (não por colaborador), dois subordinados diretos do mesmo gerente sempre caem no mesmo departamento — isso quebra o caso de uma gerente que supervisiona pessoas de **departamentos diferentes**. Ex. real: "Elizangela de Paula Kuhn" supervisiona diretamente os líderes de Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos — departamento "Gerentes") **e** Luciane Gonzaga (que deveria cair em "Rocket", já que ela chefia esse outro departamento) — como todos compartilham o mesmo `gerente`, mapear "Elizangela de Paula Kuhn" → "Gerentes" também classifica Luciane Gonzaga como "Gerentes", não "Rocket". Não existe mais um mecanismo de exceção por colaborador individual (o antigo `IndicadorSetorApelido` cobria exatamente esse tipo de caso) — se isso for um problema real, precisa ser resolvido numa rodada futura (ex.: reintroduzindo uma exceção por nome de colaborador, por cima da relação gerente→departamento).
- **Detalhamento da composição no card do colaborador** (`portal_api.indicadores.calculo.composicao_individual`, exposto como o campo `composicao_individual` de `IndicadorApuracaoColaboradorSerializer`): reconstrói, só pra exibição, o percentual bruto de Individual (antes da composição) e o peso médio de cada um dos 3 níveis (`_peso_medio_nivel`) — dados que `recalcula_colaborador` calcula mas não persiste, por não precisar deles depois de gravar `pct_individual`. No cabeçalho do card (`indicador-desempenho.js`), essa linha ("Individual: X% (peso Y%) · Grupo: X% (peso Y%) · Departamento: X% (peso Y%)") fica ao lado do nome/gerente, numa coluna própria do grid centralizada — não embaixo — e cada um dos 3 níveis fica verde/vermelho conforme bateu 100% ou não; o "Total Indicador" (renomeado de "Individual", que é `pct_individual`, com o lápis de ajuste manual sempre ao lado do valor numa linha que não quebra) fica neutro, sem cor, pra não repetir a mesma informação 4 vezes. `composicao_individual()` usa `colaborador.respostas.all()` (não `.select_related("criterio")`) de propósito, pra reaproveitar o `prefetch_related("colaboradores__respostas__criterio")` que `IndicadorApuracaoViewSet.get_queryset()` aplica só na action `retrieve` — evita 1 query extra por colaborador ao abrir a tela de revisão.
- **Tabela "Metas de Grupo e Departamento" só tem uma forma de responder Sim/Não por critério** — a coluna "Meta (%)" (ajusta `pct_grupo`/`pct_departamento` direto). Existia um segundo `<select>` Sim/Não ao lado do texto de cada critério (bulk, via `aplicar-em-lote`), removido por ser redundante com o da direita; a lista de critérios ali agora é só informativa (nome + peso). Responder um critério específico continua possível por colaborador, dentro da lista de colaboradores abaixo (`renderRespostasGrupoHtml`).
- **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável...") pra nunca reatribuir sem escolha explícita.
- **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar (`renderRevisao()`) — atualiza só o card clicado localmente, pra não fechar outros cards já expandidos nem perder a posição de rolagem no meio de uma conferência longa; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados.
- **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério.
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico.
- **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado.
- **Layout do PDF do recibo** (`indicadores/recibo.py`): o banner "PERCENTUAL DO INDICADOR INDIVIDUAL" sempre mostra o percentual **efetivo/medido** (`calculo.composicao_individual()["total_calculado"]` — a composição dos 3 níveis recalculada na hora, ignorando qualquer ajuste manual), não `colaborador.pct_individual` puro — decisão explícita do usuário: se o RH/Diretoria sobrescreveu o Individual pra 100%, o banner precisa continuar mostrando o que o colaborador de fato atingiu (ex.: 74,29%), não o valor pago. Quando `pct_individual_ajustado_manualmente=True`, uma linha de detalhe abaixo do banner mostra "Percentual Individual Ajustado Pela Direção: **100,00%**." (rótulo renomeado de "ajustado manualmente pelo RH", com o valor ajustado ao lado — antes só dizia que tinha sido ajustado, sem mostrar pra quanto) — os dois números lado a lado deixam claro o que foi medido e o que foi pago. Tabela "Empresas": toda célula (antes só "Empresa" era `Paragraph`, o resto strings soltas) virou `Paragraph` com estilo de alinhamento próprio (`celula_centro`/`celula_direita`/`celula_negrito`/`celula_direita_negrito`, `_estilos()`) — string solta não quebra linha dentro da coluna, e com `ALIGN` à direita/centro um valor mais largo que a coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em negrito, mais largos que a mesma string em peso normal) vazava visualmente por cima da célula vizinha em vez de quebrar linha — bug real visto com dados reais (coluna Tipo cobria "Hon. Ajustado"). Coluna "Tipo" ganhou um dicionário de labels curtos só pro PDF (`TIPO_LABEL_CURTO`: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)" — mesma abreviação já usada informalmente neste documento) porque o label completo não cabia nem quebrando linha numa coluna estreita; a linha de total virou "Total do Indicador" (era "Total Resultado"). Larguras de coluna e padding lateral (`LEFTPADDING`/`RIGHTPADDING`, reduzidos de 6pt padrão do reportlab pra 3pt) ajustados pra caber os maiores valores reais vistos na apuração (ex.: R$ 28.023,16) numa linha só. `_moeda()` usa `&nbsp;` (não espaço comum) entre "R$" e o número — com espaço comum, quando o valor não cabia numa linha só, o reportlab quebrava exatamente ali, deixando "R$" sozinho numa linha acima do número; com espaço não separável, o "R$" fica sempre grudado à esquerda do número (mesmo que precise de mais espaço na coluna pra caber tudo numa linha, resolvido junto pelas larguras/padding acima). Rótulo da linha de detalhe é "Percentual individual ajustado pela direção" (minúsculo, só a primeira letra maiúscula — não "Percentual Individual Ajustado Pela Direção").
- **`aplicar_em_lote`** (`IndicadorApuracaoRespostaViewSet`, `POST /api/indicadores-apuracoes-respostas/aplicar-em-lote/`) aplica o mesmo valor a várias respostas de critério de uma vez — a "múltipla seleção" pedida pelo usuário na tela de revisão.
- **"Empresas sem Honorário"** (`#ind-empresas-sem-honorario-btn`, cor de atenção — `--danger`, mesma linguagem visual do input/selo de honorário não encontrado, **só enquanto houver alguma empresa pendente** — sem nada pra resolver, o botão perde a classe `.ind-empresas-sem-honorario-btn` (volta a `.btn-outline` neutro) e o texto vira "Visualizar Empresas com Honorário Ajustado (N)", apontando direto pra revisão do que já foi ajustado — substituiu o antigo checkbox "Só com honorário não encontrado" que filtrava a lista de colaboradores): abre um modal que agrupa por `codigo_empresa` todas as `IndicadorApuracaoEmpresa` com `honorario_nao_encontrado=True` da apuração — a mesma empresa pode aparecer sob mais de um colaborador (um responsável pelo balancete, outro pela liberação fiscal, outro pela conciliação financeira), mas o honorário é da empresa, não da pessoa. Preencher um valor ali chama `POST /api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/` (`IndicadorApuracaoViewSet.ajustar_honorario_empresa`, serializer `IndicadorApuracaoAjusteHonorarioEmpresaSerializer` com `{codigo_empresa, honorario}`), que atualiza **todas** as linhas daquele código nesta apuração de uma vez (recalculando cada colaborador afetado) — diferente de `PATCH /api/indicadores-apuracoes-empresas/{id}/` (ainda existe, ajusta só uma linha por id, usado direto na tabela "Empresas" de dentro do card do colaborador). Os dois caminhos (linha única e em lote) marcam `honorario_ajustado_manualmente=True` na(s) linha(s) afetada(s) — fica permanente (sem UI de reverter, diferente de `pct_individual_ajustado_manualmente`/etc., já que não existe um "automático" pra voltar quando o código nunca casou com a planilha) e vira uma nota "honorário ajustado manualmente" (cor `--accent`) ao lado do valor, na tabela "Empresas" de dentro do card do colaborador — visível só depois que `honorario_nao_encontrado` já foi resolvido (os dois nunca aparecem juntos). Essa tabela também passou a listar por `codigo_empresa` (`IndicadorApuracaoEmpresa.Meta.ordering`, migração `0029`), não mais por nome. Como `codigo_empresa` é `CharField`, ordenar só por ele é ordem alfabética, não numérica — "80"/"503" apareciam depois de "2134" (o caractere `'8'`/`'5'` é "maior" que `'1'`/`'2'`, mesmo o número sendo menor). Corrigido (migração `0030`) ordenando primeiro pelo **tamanho** da string (`Length("codigo_empresa")`) e só depois pelo valor — reproduz a ordem numérica certa pra códigos sem zero à esquerda (string mais curta = número menor, sempre) sem converter pra inteiro, o que quebraria com erro de banco se algum código um dia não fosse só dígitos.
- **"Empresas ajustadas manualmente"** é uma **segunda seção dentro do mesmo popup** "Empresas sem Honorário" — não um segundo botão/modal (revertido de propósito: nasceu como um botão separado, "Verificar Empresas Ajustadas Manualmente", e o usuário pediu pra unificar num popup só, "facilitando a usabilidade da ferramenta"). Fica **escondida por padrão**, atrás de um botão de largura cheia no final da lista principal (`#ind-empresas-ajustadas-toggle-btn`, `.ind-esh-toggle-btn`, com contador — "Visualizar"/"Ocultar Empresas Ajustadas Manualmente (N)") — pedido explícito do usuário logo depois de testar a versão anterior (as duas seções sempre visíveis de uma vez): a lista secundária só deve aparecer sob demanda, no final do modal. Lista, também agrupada por `codigo_empresa`, as empresas com `honorario_ajustado_manualmente=True` — permite **corrigir** um valor já ajustado (campo já vem preenchido com o honorário atual, ao contrário da lista principal, que começa em branco). Reaproveita o mesmo endpoint `ajustar-honorario-empresa` — o filtro do backend cobre `Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True)`, nunca uma empresa cujo honorário só veio certo da planilha e nunca foi mexido. As duas listas compartilham as funções de agrupamento/renderização/ordenação (`empresasAgrupadasPorCodigo`, `renderEmpresaGrupoItemHtml`) em `indicador-desempenho.js`, parametrizadas só pelo filtro; `renderEmpresasHonorario()` sempre re-renderiza a lista principal e só re-renderiza a de "ajustadas" **se a seção já estiver aberta** (ao abrir o popup, essa seção sempre volta a fechar) — necessário porque corrigir uma empresa "sem honorário" faz ela migrar pra lista de "ajustadas" na hora, e essa migração só precisa refletir de imediato se o usuário já estiver olhando pra ela. As duas ficam dentro de um único wrapper que rola (`.ind-esh-scroll`), com título e "Fechar" sempre visíveis fora dele (mesmo `max-height:85vh` do popup). Em cada item, o código aparece **antes** do nome da empresa no cabeçalho (`.ind-esh-codigo` seguido de `.ind-esh-nome`), mesma ordem da tabela "Empresas" do colaborador.
- **"Ajuste Indicador em Lote"** (`#ind-lote-global-btn`, `indicador-desempenho.js`): modal separado do anterior — ajusta `pct_individual` (não critérios) de vários colaboradores **selecionados por nome** de uma vez, pra dois casos binários só: "Ajustar" (`#ind-lote-global-ajustar-btn`, aplica `pct_individual=100` a todos, via `PATCH /api/indicadores-apuracoes-colaboradores/{id}/`) ou "Reverter" (chama a action `recalcular` de cada um, voltando ao cálculo automático) — sem campo de percentual livre. Os dois botões são `btn-solid` (mesma cor) — só "Cancelar" fica `btn-outline`, já que as duas ações são igualmente "reais", não uma primária e uma secundária. Não existe endpoint de lote dedicado pra isso; o frontend dispara um PATCH/POST por colaborador em paralelo (`Promise.all`).
- **Percentuais/critérios são cadastro editável pela tela**, não hardcoded — decisão explícita do usuário pra não fixar no código números incertos vindos da planilha antiga já quebrada. Primeiro histórico populado via `python manage.py seed_indicador_desempenho` (idempotente), com os valores exatos da planilha antiga (`vigente_desde` fixado em 01/01/2024 por falta de data documentada — ajustar se o usuário informar a data real).
- **Bugs de robustez corrigidos ao testar com 43 colaboradores reais**: `openpyxl.load_workbook(..., read_only=True)` precisa de `.close()` explícito (`indicadores/leiaute.py`), senão o Windows mantém o upload memory-mapped e bloqueia excluir a apuração depois; campos percentuais precisaram de `max_digits=7` (não 6) — qualquer `DecimalField` que representa um percentual "de 0 a 100" precisa de `max_digits >= decimal_places + 3` pra caber o "100" exato sem `DataError: numeric field overflow`.

View File

@ -0,0 +1,196 @@
# 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).
Primeira e única aplicação dentro de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu — decisão explícita do usuário, não recriar sem confirmar de novo) — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico (Amil, Unimed, ...) e gera o arquivo de lançamento no leiaute fixo do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. Ao contrário dos módulos com tela administrável (Links & Ferramentas, Acessos Gerais, Ramais), essa ferramenta usa permissão de **toggle único** (`{"key": "importacao-plano-saude", "label": "..."}`, entrada flat em `catalogo.MODULE_APPS["utilitarios"]`, sem par visualizar/editar) — quem tem acesso pode fazer todo o fluxo (criar, revisar/editar, gerar), sem conceito de "dono" da importação (mesmo espírito compartilhado de `LinkFerramenta`/`AcessoGeral`). Por ser um app flat, não precisou de nenhum override em `seed_portal.py` (esse cuidado só existe pra pares visualizar/editar).
**A lógica de negócio em si não nasceu neste projeto** — veio de um pipeline Python já testado e documentado em `projects/importacao-planos-saude.skill` (arquivo `.skill`, é um zip — `SKILL.md` + `scripts/`), com um protótipo funcional em `projects/project/` (CLI `main.py`, nunca tocado pelo Portal, fica só como referência/histórico). Esse pipeline foi portado quase 1:1 para dentro do Django em **`portal_api/planos_saude/`** (pacote Python puro, sem depender do ORM):
```
portal_api/planos_saude/
├── modelos.py Lancamento, Individuo, LinhaSistema, ItemAuditoria (dataclasses)
├── matcher.py casa_individuos_com_planilha() — casamento por CPF ou por nome
├── leiaute_sistema.py CABECALHO, le_planilha_padrao(), formata_valor_br()
├── pipeline.py OPERADORAS (registro), processa_importacao() — orquestração, chamada pela view
└── operadoras/
├── base.py OperadoraParser (interface)
├── unimed/saude.py Unimed Saúde — CSV (mensalidade+coparticipação no mesmo arquivo) **ou** 2 PDFs separados (um por tipo), detectados automaticamente pelo conteúdo; mensalidade por nome, coparticipação por CPF (ver "Múltiplos arquivos de operadora" abaixo)
├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome)
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── amil/odonto_mensalidade.py Amil Odonto — 898 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo)
└── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo)
```
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**Amil Odonto (898)** — PDF "Demonstrativo Analítico de Faturamento - Por Contrato / Empresa", só mensalidade, casamento por CPF. **Bug real corrigido (rodada em que este parser foi validado pela primeira vez contra um arquivo real, contrato 2831804000)**: o regex de parsing de linha exigia espaço (`\s+`) entre a coluna do plano (ex.: "DENTAL BRONZE DOC R PADRÃO") e a coluna "Tp." logo em seguida, mas nesse relatório real as duas colunas vêm **coladas sem nenhum espaço** ("PADRÃOT", "PADRÃOD", "PADRÃOA") — não é um problema de `x_density` do `pdfplumber` (testado de 6 até 20, sem efeito), o espaço realmente não existe no PDF de origem. Toda linha falhava o match silenciosamente, resultando em "Nenhum beneficiário foi encontrado neste arquivo" pra qualquer arquivo AMIL. Corrigido trocando esse `\s+` por `\s*` em `_AFTER_CPF_RE` (`operadoras/amil/odonto_mensalidade.py`). Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 161 beneficiários (124 titulares/35 dependentes/2 agregados), R$ 1.630,93 no total, batendo exatamente com os totais impressos no próprio relatório.
**Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "<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 Saúde (5775, Ottimizza)** — diferente de todos os outros parsers deste pacote: não é PDF/CSV extraído de um relatório da operadora, é uma planilha `.xlsx` ("Informações Plano de Saúde - <competência>") com colunas `Empr.`/`Cod.`/`Tipo Plano`/`CPF`/`Nome`/`Benefício Mensalidade`/`Desconto Mensalidade`/`Benefício Coparticipação`/`Desconto Coparticipação` — "Benefício" é a parte que a empresa paga, "Desconto" a parte descontada do empregado, já separadas por beneficiário nessa planilha. Casamento por CPF (`chave_casamento="cpf"`). **Decisão explícita do usuário**: este parser não trata essa divisão — só soma "Benefício Mensalidade" + "Desconto Mensalidade" num único `valor_total` de mensalidade, e "Benefício Coparticipação" + "Desconto Coparticipação" num único `valor_total` de coparticipação, por beneficiário (mesmo formato de `Individuo.valor_total` usado por toda outra operadora deste pacote — nenhum campo novo em `Individuo`/`Lancamento`). Quem decide como esse total se divide entre empresa e empregado é a "Regra especial da empresa" cadastrada como "1889 - SulAmérica (5775)" (ver "Regra empresa" logo abaixo), não este parser. `numero_beneficiario` usa o CPF normalizado, não a coluna "Cod." — validado contra o arquivo real (competência 08/2026, 76 beneficiários) um deles (LOUISE LEMOS EIGAT) aparecia em duas linhas com o mesmo valor de desconto, uma delas com "Cod." salvo como número em vez de texto no Excel (perdendo precisão nos últimos dígitos: "...118" virou "...100") — como o casamento nunca usa essa coluna, ela não afeta a correção do lançamento, mas também não serve como chave de agregação confiável; usar o CPF como chave faz as duas linhas somarem (mesma regra geral "nunca tratar cada linha isoladamente"), em vez de uma sobrescrever a outra por acaso. Validado rodando `openpyxl` de fato contra o arquivo real: 88 indivíduos extraídos (75 de mensalidade + 13 de coparticipação), com os totais batendo centavo a centavo com a soma bruta das 4 colunas da planilha.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`.
## Modelos (`portal_api/models.py`)
- `ImportacaoPlanoSaude`: uma execução da ferramenta — `operadora`/`nome_operadora`, `tipos_lancamento` (JSONField, lista), `custeio_por_tipo` (JSONField, `{"mensalidade": {"titular": {"modo": "empresa"|"empregado"|"especifica", "limite_valor": float|None, "percentual": float|None}, "dependente": {...}}, "coparticipacao": {...}}` — ver regra de custeio acima), `regra_empresa` (CharField, blank — chave de `planos_saude.regras_empresa.REGRAS_EMPRESA` quando "mensalidade" foi custeada por uma regra especial em vez do `custeio_por_tipo["mensalidade"]` normal, ver "Regra empresa" abaixo), `planilha_padrao` (`FileField`, mesmo padrão de validator de tamanho de `LinkFerramenta.icone`, só que 15MB em vez de 2MB — é `blank=True` desde que passou a poder vir de uma busca no Questor em vez de upload, ver "Planilha padrão via Questor (SQL)" abaixo), `competencia` (DateField, null — só preenchida quando a origem da planilha padrão foi essa busca no Questor), `status` (`revisao`/`concluida`), `criado_por`, `criado_em`/`concluida_em`. **Com histórico**: decisão explícita do usuário — cada importação fica salva (quem fez, quando, arquivos), não é um fluxo descartável. O arquivo (ou arquivos) da operadora vive num model relacionado separado, ver `ImportacaoPlanoSaudeArquivoOperadora` a seguir e "Múltiplos arquivos de operadora" abaixo.
- `ImportacaoPlanoSaudeArquivoOperadora`: um dos relatórios da operadora anexados a uma importação (FK `importacao`, `arquivo` FileField, `ordem`) — a maioria das operadoras manda só um, mas algumas (ex.: Unimed Saúde em PDF) mandam mensalidade e coparticipação em arquivos separados. Substituiu, numa rodada posterior, o antigo `FileField` único `ImportacaoPlanoSaude.arquivo_operadora` (migração em 3 passos — `0048` cria o model novo + torna o campo legado `blank=True`; `0049`, `RunPython`, cria uma linha por importação já existente reapontando pro mesmo caminho já salvo em `MEDIA_ROOT`, sem copiar bytes; `0050` remove o campo legado — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`).
- `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.
- `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa".
- `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo.
- `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário.
## Fluxo e endpoints
`ImportacaoPlanoSaudeViewSet` (`/api/importacoes-plano-saude/`, `PermissaoApp("utilitarios", "importacao-plano-saude")` pra todos os métodos):
- `create()` (multipart, `ImportacaoPlanoSaudeCreateSerializer` valida a entrada) resolve a planilha padrão (upload **ou** busca no Questor — ver "Planilha padrão via Questor (SQL)" abaixo), salva o model + um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo em `arquivo_operadora` (lista, ver "Múltiplos arquivos de operadora" abaixo) e roda `pipeline.processa_importacao()` **de forma síncrona** usando os caminhos de todos os arquivos da operadora + a lista de `LinhaSistema` já resolvida — sem fila/Celery, o arquivo típico processa em menos de um request. Se o processamento falhar (PDF num layout desconhecido etc.), apaga os arquivos recém-salvos (planilha + todos os da operadora) + o registro órfão e devolve 400.
- `GET /operadoras/` (`@action` sem detail) devolve `pipeline.lista_operadoras()` — fonte única pro combobox pesquisável "Operadora" do formulário (`#ips-operadora-combo`, mesmo padrão de "Regra de custeio salva" — ver "Regras de custeio salvas" abaixo), sem duplicar a lista em JS. `label` já vem no formato `"<código> - <Nome>"` (ex.: `"3755 - Itamed Saúde"`) — o código é o de cadastro da operadora no Questor, pedido explícito do usuário pra identificar a operadora sem ambiguidade (útil quando duas operadoras têm nome parecido); editar em `pipeline.OPERADORAS`, não formatar o código separadamente no frontend.
- `POST /{id}/gerar/` monta o(s) CSV(s) a partir das **linhas já salvas** (isto é, já com qualquer edição feita na revisão — não reprocessa os arquivos originais) usando `leiaute_sistema.CABECALHO`; 1 tipo de lançamento vira um `.csv` direto, 2 tipos (mensalidade + coparticipação) viram um `.zip` com um `.csv` por tipo (`zipfile` em memória). Sempre marca `status="concluida"` (+ `concluida_em`) — pode ser chamada de novo enquanto `concluida` (regera o mesmo arquivo a partir do que já está salvo), mas a partir daí toda edição de linha/auditoria/alteração fica bloqueada até reabrir (ver `reabrir()` abaixo e "Trava de edição pós-conclusão").
- `POST /{id}/reabrir/` volta `status="revisao"` (zera `concluida_em`) — contrapartida de `gerar()`, é o único jeito de voltar a editar uma importação concluída. Botão "Editar" na tela de Revisão, visível só quando `status === "concluida"`.
`ImportacaoPlanoSaudeLinhaViewSet` (`/api/importacoes-plano-saude-linhas/{id}/`, só GET/PATCH): edição de uma linha por vez, disparada por `blur`/`change` de cada `<input>` na tela de revisão — mesma permissão de toggle único, sem checagem de "dono". `create()`/`partial_update()`/`destroy()` recusam (400) se a importação já estiver `concluida` — ver "Trava de edição pós-conclusão" abaixo.
`ImportacaoPlanoSaudeAuditoriaViewSet` (`/api/importacoes-plano-saude-auditoria/{id}/resolver/`, só `POST`) — ver seção própria abaixo; também recusa se a importação estiver `concluida`.
**Histórico (`#ips-list-table`): ordenação por coluna + filtro "estilo Excel" por coluna, os dois client-side** sobre o array já carregado (`GET /api/importacoes-plano-saude/`, sem paginação/filtro no servidor) — mesmo padrão de ordenação já usado em `#ua-table`/Ramais (`th[data-sort]`, ícone `↕`). O filtro (`criarFiltroColuna()`, `importacao-plano-saude.js`) nasceu como um segundo campo de texto por coluna, mas foi revisto a pedido do usuário pra imitar o filtro de planilha (Excel/Sheets): um botão de funil dentro do próprio `<th>` de cada coluna filtrável (Cód. Empresa/Operadora/Status/Criado por) abre um popup com busca + checklist dos **valores distintos daquela coluna** (reaproveita `.checklist-box`/`.checklist-search`/`.checklist-select-all`/`.modal-checkbox` de `components.css` — mesmo componente já usado nos checklists de Perfis de Acesso), tudo desmarcável/marcável, com "Aplicar"/"Limpar" no rodapé (só aplica no clique, não a cada checkbox — evita re-renderizar a lista principal a cada toque). Os filtros das 4 colunas combinam entre si (AND). `listFiltros[campo]` vale `null` (sem filtro) ou um `Set` dos valores brutos marcados; marcar **todos** os valores existentes equivale a `null` (sem filtro), pra um valor novo que apareça depois (operadora nova, por exemplo) não nascer excluído até o usuário marcá-lo manualmente. O botão de funil ganha `.is-active` (cor de destaque) enquanto a coluna tiver um filtro aplicado — mesmo sinal visual do funil "azul" do Excel. `.pa-table-wrap` normalmente usa `overflow:hidden` pra arredondar os cantos da tabela; só a tabela do histórico (`.ips-list-table-wrap`) sobrescreve pra `visible`, senão o popup (que precisa aparecer por cima das linhas, não só dentro do cabeçalho) seria cortado ali. Qualquer mudança de filtro (ou de ordenação) volta pra página 1. `listEmpty` mostra uma mensagem diferente conforme o caso: "Nenhuma importação realizada ainda." quando o histórico está mesmo vazio, "Nenhuma importação encontrada com esse filtro." quando o vazio é só resultado do filtro aplicado.
## Trava de edição pós-conclusão
Depois que `POST /{id}/gerar/` marca uma importação como `concluida`, editar/incluir/excluir uma linha (`ImportacaoPlanoSaudeLinhaViewSet`), resolver um item de auditoria (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`) ou reverter uma alteração (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) passam a ser recusados (400) — decisão explícita do usuário, depois de ver que a tabela de revisão continuava 100% editável mesmo depois do arquivo já ter sido gerado e entregue. `_garante_importacao_em_revisao(importacao)` (função módulo-level em `views.py`, chamada no início de cada um desses pontos de escrita) é o único lugar que checa isso — levanta `ValidationError` com uma mensagem pedindo pra usar o botão "Editar" (`POST /{id}/reabrir/`) primeiro. `gerar()` em si nunca é bloqueado (pode ser chamado de novo com a importação já `concluida`, só regera o mesmo arquivo a partir do que está salvo).
No frontend (`importacao-plano-saude.js`), a tela de Revisão espelha essa trava puramente pra UX (a validação real é sempre a do backend acima): com `importacaoAtual.status === "concluida"`, toda célula da tabela de Mensalidade/Coparticipação vira texto (não `<input>`, nem Valor Empresa/Valor — a regra de "só Valor Empresa/Valor editáveis" descrita no bullet de `ImportacaoPlanoSaudeLinha` acima só se aplica quando a importação ainda está em revisão), o "×" de remover linha e o botão "Adicionar linha" somem, "Vincular pessoa" (Auditoria) e "Reverter" (Alterações) também somem. O botão "Editar" (`#ips-review-editar-btn`, ao lado de "Gerar Arquivo") aparece só nesse estado e chama `POST /{id}/reabrir/`, atualizando `importacaoAtual` e re-renderizando as abas.
Clicar em "Gerar Arquivo" (`gerarBtn`) sempre volta pro histórico (`showView("list")` + `refreshList()`) depois do download disparar — decisão explícita do usuário, já que a partir daí a importação está `concluida` e travada (ver acima), não há mais nada pra revisar de imediato na própria tela.
## Múltiplos arquivos de operadora
Até uma rodada anterior, "Arquivo da operadora" (passo 2 de "Nova Importação") era um único upload obrigatório — trocado por **1 ou mais arquivos** (pedido explícito do usuário): algumas operadoras mandam mensalidade e coparticipação em arquivos separados (a primeira real: Unimed Saúde, quando manda PDF em vez do CSV único — ver "Parser da Unimed Saúde" abaixo), em vez de um único arquivo com os dois tipos juntos.
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.arquivo_operadora` é um `ListField(child=FileField(), allow_empty=False)` — o DRF já lê múltiplos arquivos do mesmo campo em `multipart/form-data` via `request.data.getlist(...)` (mesma semântica do `QueryDict`), sem tratamento manual extra na view. `create()` cria um `ImportacaoPlanoSaudeArquivoOperadora` por arquivo (`ordem=índice`); `pipeline.processa_importacao(operadora_key, caminhos_arquivo_operadora: List[str], ...)` chama `OperadoraParser.extrai()` **uma vez por caminho** (nenhum parser existente muda de assinatura — quem ganha a responsabilidade de iterar é só o `pipeline.py`) e concatena os indivíduos/itens de auditoria de todos os arquivos antes de seguir com o casamento normal.
- **`_agrega_individuos_entre_arquivos()` (`pipeline.py`)** — bug real encontrado e corrigido ao testar esta funcionalidade de ponta a ponta: se dois arquivos contribuem indivíduos da MESMA pessoa e do MESMO `tipo_lancamento` (ex.: duas coparticipações do mesmo mês, separadas por período), só concatenar as duas listas não bastava — `casa_individuos_com_planilha`/`_aplica_regra_custeio` (matcher.py) **grava** o valor final na `LinhaSistema` por pessoa, não acumula, então o segundo arquivo processado sobrescrevia o valor do primeiro em vez de somar. Corrigido somando (`valor_total` e `rubricas`) os indivíduos de mesma chave (`numero_beneficiario`, `tipo_lancamento`) **entre arquivos**, logo depois de concatenar as listas — mesmo padrão que cada parser já faz **dentro** de um único arquivo (`_agrega_por_individuo_e_tipo`), só que agora entre arquivos também.
- `perform_destroy()`/os `except` de `create()` (arquivo ilegível, regra empresa incompatível, código de empresa não confere) apagam **todos** os arquivos de `importacao.arquivos_operadora.all()` de `MEDIA_ROOT`, não só um.
- **Frontend**: `<input type="file" multiple>` + uma lista dinâmica (`#ips-form-arquivo-list`/`.ips-arquivo-list`, `importacao-plano-saude.js`) no lugar do campo único de sempre — cada arquivo anexado é validado individualmente (mesmo endpoint `POST /.../validar-arquivo/` de sempre, chamado uma vez por arquivo, sem mudança nenhuma nele) e listado com seu próprio status + botão de remover; trocar a operadora revalida todos os arquivos já anexados. No submit, `formData.append("arquivo_operadora", file)` uma vez por arquivo.
### Parser da Unimed Saúde (PDF): dois relatórios separados, tipo detectado automaticamente
`operadoras/unimed/saude.py` (`unimed_saude`, código 5060) ganhou um segundo formato de entrada, além do CSV único já existente: **dois PDFs** de um cliente real (mensalidade + coparticipação analítico), detectados automaticamente pelo **conteúdo** de cada arquivo — nunca pelo usuário escolhendo um "tipo de documento" (pedido explícito). `UnimedSaude.extrai()` abre o PDF com `pdfplumber` e olha a primeira página: `"BENEFICIARIOS COM FATURAMENTO NO MES"` → relatório de mensalidade (`_extrai_pdf_mensalidade`, uma linha por beneficiário, `extract_text()` simples já basta); `"SERVIÇOS PRESTADOS"`/`"ANALITICO"` → coparticipação analítica (`_extrai_pdf_coparticipacao`, várias linhas de serviço por beneficiário, somadas por pessoa).
- Confirmado com o usuário: a coparticipação devida por beneficiário é a **soma do "Vl Total" de cada linha de serviço** daquele beneficiário — a coluna "Tt Copar" (valor fixo, repetido em toda linha do documento) **não é usada**. Linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento informativo de um item, cuja soma já está no valor do item principal) são ignoradas — senão duplicariam o valor.
- `nome`/`Grau Dep.` (TITULAR/CONJUGE/FILHO(A)/...) só aparecem na primeira linha de cada bloco de atendimento — parsing com estado (mesmo padrão do `ItamedSaude`).
- Valores nos dois PDFs vêm em **formato americano** (ponto decimal, vírgula de milhar — ex. "6,061.74"), ao contrário do formato BR do resto do pipeline — `_valor_pdf_para_float`, função própria, separada de `_valor_para_float` (BR, só pro CSV).
- **`OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)`** (default: devolve `chave_casamento`, mesmo valor de sempre — método novo, backward-compatible pra todo outro parser) porque a coparticipação analítica da Unimed em PDF precisou de uma estratégia de casamento **diferente da mensalidade dentro da mesma operadora**: o "Beneficiario" desse relatório vem colado sem espaço com o nome e o grau de dependência (ex.: "0975.0167003824292ANDREIA STORMTITULAR") e o **nome sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM") — inviabilizando casamento por nome. Como esse relatório traz CPF completo e confiável, `UnimedSaude` usa `"cpf"` só pra `tipo_lancamento="coparticipacao"` quando a origem foi esse PDF (rastreado numa flag de instância, `self._veio_de_pdf_coparticipacao`, setada em `extrai()`); mensalidade (sem CPF em nenhum dos dois formatos) continua em `"nome"`. `pipeline.processa_importacao` chama `chave_casamento_para_tipo(tipo_lancamento)` em vez do atributo fixo.
- **Validado contra os dois arquivos reais** (não só texto colado numa conversa — o texto que sai de um PDF colado no chat **não é** o que `pdfplumber.extract_text()` de fato produz, então não serve pra desenhar regex com confiança; só o arquivo real confirma). Bate exatamente com "Total da Familia"/"Total da Sequencia" impresso no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e com o casamento por CPF contra a planilha padrão real da empresa — toda família presente na planilha bateu centavo a centavo; a família ausente da planilha de teste foi corretamente pra auditoria, não ignorada silenciosamente.
## Planilha padrão via Questor (SQL)
Até uma rodada anterior, a "planilha padrão" (cadastro dos beneficiários, sem valores — o mesmo que `le_planilha_padrao` lê de um CSV) só chegava por upload manual, exportado à mão do Questor. O usuário forneceu e validou uma consulta SQL equivalente contra o próprio banco do Questor, então "Nova Importação" ganhou um segundo caminho: buscar essa planilha automaticamente a partir de empresa (já resolvida pela `RegraCusteioPlanoSaude` escolhida) + operadora + competência (mês/ano digitado na tela) — **sem precisar mais exportar/anexar nada** nesse caso. O upload manual continua existindo como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário, não uma substituição total.
- **Toggle na tela** (`importacao-plano-saude.js`/`.html`, dentro do bloco "1. Planilha padrão"): dois radios, "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente" — o primeiro revela um campo "Competência" **texto livre com máscara MM/AAAA** (`<input type="text" inputmode="numeric" placeholder="MM/AAAA" maxlength="7">` + `mascaraCompetencia()`/`competenciaParaIso()` em JS — deliberadamente **não** um `<input type="month">`: o seletor nativo do browser foi rejeitado pelo usuário como UX ruim; mesmo padrão de digitação livre já usado em `indicador-desempenho.js`/`pidIndMascaraCompetencia`, copiado aqui em vez de compartilhado, como as demais funções pequenas duplicadas entre telas), o segundo revela o `<input type="file">` de sempre. Trocar de modo limpa o outro campo, pra nunca mandar os dois juntos (o backend também recusa isso).
- **Backend**: `ImportacaoPlanoSaudeCreateSerializer.competencia` é um `serializers.DateField()` normal (mesmo padrão de `IndicadorApuracaoCreateSerializer.competencia`) — o frontend já manda o ISO `"AAAA-MM-01"` convertido a partir da máscara, nunca a string mascarada crua. `validate()` exige exatamente uma das duas origens (nunca as duas, nunca nenhuma).
- `ImportacaoPlanoSaudeViewSet.create()` (views.py): quando não veio arquivo, resolve a planilha **antes** de criar o registro — `portal_api.planos_saude.questor_planilha.busca_linhas_questor(codigo_empresa, codigo_operadora, competencia)` consulta `sqls.questor.QuestorSQL.consulta_planilha_plano_saude` (só leitura — `select_mappings_query`, nunca `execute`/`execute_returning`, ver [[feedback_bancos_externos_somente_leitura]]) via `DatabaseConnection("questor")`. Uma falha de conexão/consulta aqui devolve 400 direto, sem nada persistido ainda (diferente do caminho de upload, que só sabe se o arquivo é válido depois de já ter salvo o registro — por isso, nesse, o cleanup de arquivo/registro órfão continua sendo necessário). Zero linhas retornadas (empresa sem plano ativo na competência) também é 400. O resultado é serializado de volta pra CSV (`questor_planilha.linhas_para_csv_bytes`, mesmo formato de `leiaute_sistema.CABECALHO`) e salvo como `ContentFile` no próprio campo `planilha_padrao` — preserva o histórico completo mesmo pra importações que nunca tiveram upload. A "trava de conferência do código de empresa" (que confere que a planilha anexada tem alguma linha da empresa da regra) é pulada nesse caminho, redundante já que a consulta já filtrou por esse `codigo_empresa`.
- **`pipeline.processa_importacao`** deixou de ler o arquivo sozinho (não recebe mais `caminho_planilha_padrao: str`) — recebe `linhas_sistema_template: List[LinhaSistema]` já pronta, de qualquer uma das duas origens (`le_planilha_padrao(caminho)` pro upload, `busca_linhas_questor(...)` pro Questor) — a decisão de qual usar ficou inteiramente em `views.py create()`.
- **Código da operadora**: até então só existia embutido no `label` de `pipeline.OPERADORAS` (ex. `"5060 - Unimed Saúde"`, extraído por *string split* onde só o nome era preciso). Passou a existir como campo próprio (`OPERADORAS[chave]["codigo_operadora"]`, junto de `"nome"`) — é o valor usado pra filtrar a consulta por operadora (`codigooutemp` no Questor, código da OPERADORA, não confundir com `codigo_empresa` do cliente); `pipeline.label_operadora(chave)` calcula o `"<código> - <Nome>"` de exibição a partir desses dois campos onde ainda é preciso (`lista_operadoras()`, mensagens de erro, `nome_operadora` da importação).
- `ImportacaoPlanoSaude.competencia` (`DateField`, null) registra a competência usada — só preenchida quando a origem foi o Questor; exibida na tela de Revisão ao lado do nome da operadora.
## Resolução manual de auditoria por nome
Quando o casamento por nome falha (`NOME_DIVERGENTE`/`NAO_CADASTRADO` — ver `matcher.py`, "nunca resolvido por aproximação automática"), o colaborador pode confirmar manualmente que aquele item **é** uma pessoa específica já presente na planilha padrão, em vez de deixar o lançamento parado em auditoria pra sempre. Não é fuzzy matching nem aproximação automática — é sempre uma confirmação humana, explícita, item por item; a regra de "nome exato ou vai pra auditoria" do `matcher.py` continua intocada.
- **Endpoint**: `POST /api/importacoes-plano-saude-auditoria/{id}/resolver/` com `{"linha_id": <id>}`. Validações em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver` (views.py): o item precisa ter um motivo em `MOTIVOS_RESOLVIVEIS` e ainda não estar `resolvida` (idempotente — não dá pra resolver de novo, nem trocar o vínculo depois); a linha escolhida precisa (a) ser da mesma importação e do mesmo `tipo_lancamento` do item; (b) ser do mesmo "lado" — titular pra item `tipo="T"`, dependente pra `tipo!="T"` (D/A) — comparando `linha.nome_dependente`/`cpf_dependente` vazios ou não; (c) **ainda estar em branco** (`valor == valor_empresa == "0"`), decisão explícita do usuário pra nunca sobrescrever sem querer um lançamento que já casou automaticamente com outra pessoa do arquivo da operadora.
- Ao vincular, o `valor` do item de auditoria é dividido em `valor_empresa`/`valor` pela mesma regra de custeio já salva em `ImportacaoPlanoSaude.custeio_por_tipo[tipo_lancamento]` para aquele tipo de pessoa (titular/dependente) — `matcher.valores_formatados_para_pessoa(valor_total, regra_por_pessoa, tipo_pessoa)` é o único ponto de entrada público do módulo pra isso, reaproveitando as mesmas `_regra_para_pessoa`/`_calcula_valores` do fluxo automático (não existe uma segunda fórmula "manual"). **Exceção**: quando a importação tem `regra_empresa` configurada (ver "Regra empresa" abaixo) e o item é de `tipo_lancamento="mensalidade"`, esse caminho por pessoa não se aplica — bug real visto com dados reais, o valor caía inteiro em desconto do empregado, ignorando a regra empresa. `resolver()` grava o valor bruto do item na linha (placeholder) e chama `_recalcula_familia_regra_empresa(importacao, linha)`, que reúne **todas** as linhas de mensalidade da mesma família (`nome_func` igual) — recuperando o valor bruto de cada uma como `valor_empresa + valor`, soma que preserva o total independente do split aplicado antes — e reaplica a regra empresa (`REGRAS_EMPRESA[chave]["aplica"]`) na família inteira de uma vez, salvando todas as linhas afetadas (`bulk_update`). Precisa reaplicar na família toda, não só na linha recém-vinculada, porque o valor novo muda o total da família e o teto (`_aplica_teto_familia`, priorização dependente→titular) precisa ser redistribuído do zero.
- O item **nunca é apagado nem some da lista**: fica marcado `resolvida=True` + `linha_vinculada` (FK), e a tela mostra um selo "Resolvido — <nome>" (verde, mesma linguagem visual de `.status-pill--ativo`) no lugar do botão "Vincular pessoa" — mantém o rastro de que aquele valor entrou por confirmação manual, não pelo casamento automático (mesma filosofia de histórico completo do resto do módulo). `get_resumo_por_tipo` (serializers.py) só conta itens **não resolvidos** em `total_auditoria`, pra não inflar o contador de pendências com algo que já foi lançado.
- **Frontend** (`importacao-plano-saude.js`): a coluna "Ação" da aba Auditoria (`panelHtmlAuditoria()`) mostra o botão "Vincular pessoa" só quando `PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)` e `!item.resolvida`. O modal `#ips-vincular-modal` lista candidatos **sem nenhuma chamada de API nova** — filtra em memória a partir de `importacaoAtual.linhas` (já carregado na revisão) por `tipo_lancamento` igual, "lado" (titular/dependente) igual e ainda em branco (`candidatosVincular()`), com uma caixa de busca por nome (`renderVincularLista()`, mesmo componente `.checklist-box`/`.checklist-search` de outras telas, aqui com `<input type="radio">` — seleção única, não múltipla). Confirmar chama `pidResolverAuditoriaPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude` pra recarregar `importacaoAtual` (mesmo padrão de "adicionar/remover linha" já usado na página) antes de re-renderizar as abas — a tabela do próprio tipo de lançamento também reflete o novo valor lançado, não só a aba Auditoria.
## Vínculos de nome salvos (DE/PARA)
Depois de "Vincular pessoa" resolver manualmente uma divergência de nome, o usuário perguntou se ela precisava ser refeita em toda execução futura ou se podia ficar guardada, "como se fosse um DE/PARA" — decisão explícita do usuário: sim, guardar e reaplicar automaticamente, mostrando cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação/fuzzy matching (ver `matcher.py`) — o DE/PARA só existe depois que um humano confirmou explicitamente aquela divergência específica uma vez; sem vínculo salvo, o comportamento é idêntico a antes (cai em auditoria).
- **Model** (`VinculoNomeOperadora`, migração `0051`): `operadora` (chave de `pipeline.OPERADORAS`), `codigo_empresa` (cru, sem normalizar — ver abaixo por quê), `nome_arquivo_operadora` (o nome divergente do arquivo da operadora, já normalizado via `matcher.normaliza_nome` — é a chave de busca), `nome_func_destino`/`nome_dependente_destino` (o nome real na planilha padrão — só um dos dois preenchido, conforme o vínculo seja de titular ou de dependente), `criado_em`/`criado_por`. `unique_together` em `(operadora, codigo_empresa, nome_arquivo_operadora)`.
- **`codigo_empresa` fica cru no model, normalizado só em `views.py`**: importar `empresas_questor.normalizar_codigo_empresa` dentro de `models.py` criaria um import circular (`empresas_questor.py` já importa `EmpresaQuestor` de `models.py`) — por isso a normalização acontece nos dois pontos de uso em `views.py` (`_carrega_vinculos_por_nome`, `resolver()`), que já importam essa função pra outros fins (ver "Nome da empresa (Questor)" acima).
- **Gravado em `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()`** (mesma view de "Vincular pessoa" acima) — depois de aplicar a resolução manual, `update_or_create` um `VinculoNomeOperadora` com `nome_arquivo_operadora=normaliza_nome(item.nome)` e o destino (`linha.nome_func` se `item.tipo == "T"`, senão `linha.nome_dependente`). Só grava se `linha.codigo_empresa` normalizado não for vazio (sempre o caso na prática).
- **Aplicado em `matcher._casa_por_nome`** (não em `_casa_por_cpf` — CPF já é exato por natureza, nunca precisa de DE/PARA): recebe `vinculos_por_nome: Dict[str, VinculoNome]` (`nome normalizado -> VinculoNome`, dataclass "pura" sem ORM em `planos_saude/modelos.py`) e `tipo_lancamento` (só pra rotular o `VinculoAplicado` gerado, o dict em si não é escopado por tipo — o mesmo DE/PARA vale pra mensalidade e coparticipação da mesma operadora+empresa). Quando o titular ou o dependente não bate por nome exato, checa `vinculos_por_nome.get(nome_normalizado)` antes de cair em auditoria; se achar e a linha de destino existir na planilha, resolve normalmente (inclusive respeitando `regra_empresa_fn`, já que o vínculo só decide QUAL linha usar — o resto do fluxo de custeio é idêntico ao casamento por nome exato) e registra um `VinculoAplicado` (índice da linha dentro do `tipo_lancamento`, id do vínculo, nome do arquivo da operadora) — devolvido em `ResultadoProcessamento.vinculos_aplicados` (`pipeline.py`) pra `views.py` montar os registros de `ImportacaoPlanoSaudeAlteracao` depois que as linhas estiverem persistidas (no momento do casamento elas ainda não têm `id`).
- **`ImportacaoPlanoSaudeViewSet.create()`**: `_carrega_vinculos_por_nome(operadora_key, linhas_sistema_template)` (views.py) busca todo `VinculoNomeOperadora` da operadora cujo `codigo_empresa` normalizado apareça em algum `LinhaSistema` da planilha padrão desta importação, monta o dict e passa em `processa_importacao(vinculos_por_nome=...)`. Depois do `bulk_create` das linhas, correlaciona cada `VinculoAplicado.indice_linha` (índice dentro do `tipo_lancamento`, o mesmo usado como `ordem` na criação da linha) com a `ImportacaoPlanoSaudeLinha` já persistida e cria um `ImportacaoPlanoSaudeAlteracao` (`tipo=TIPO_VINCULO_AUTOMATICO`, `vinculo_nome=<vínculo>`, `valor_novo=<nome do arquivo da operadora>`) por vínculo aplicado.
- **`POST /.../reverter/` (botão "Apagar vínculo")**: mesmo endpoint de reverter uma alteração normal (ver "Alterações" abaixo) — pra `TIPO_VINCULO_AUTOMATICO`, zera `valor`/`valor_empresa` da linha (reaplicando a regra empresa da família, se houver, mesma lógica de `_recalcula_familia_regra_empresa`) **e** apaga o `VinculoNomeOperadora` (`SET_NULL` em qualquer outra `ImportacaoPlanoSaudeAlteracao` que o referenciasse) — pra essa divergência voltar a cair em auditoria numa importação futura em vez de ser reaplicada sozinha. Como o vínculo é global (não por importação), apagá-lo afeta todas as importações futuras da mesma operadora+empresa, não só a atual.
- **Frontend**: badge próprio (`.ips-alteracao-tipo--vinculo_automatico`, cor `--accent`) na aba Alterações, com o detalhe `"<nome do arquivo>" (arquivo da operadora) → <nome vinculado> (planilha padrão)` e o botão de ação lendo "Apagar vínculo" em vez de "Reverter" (mesmo endpoint, `pidReverterAlteracaoPlanoSaude`) — a confirmação (`pidConfirm`) também tem um texto próprio avisando que a divergência volta a cair em auditoria.
## Alterações (histórico de edição/inclusão/exclusão de linha, com reversão)
Quarta aba da revisão (ao lado de Mensalidade/Coparticipação/Auditoria) — mostra cada edição de campo, inclusão manual de linha ("Adicionar linha"), exclusão de linha e vínculo automático de nome (ver "Vínculos de nome salvos (DE/PARA)" acima) feitos na própria tela de revisão (ou, no caso do vínculo automático, aplicados por `create()` a partir de um DE/PARA já salvo), com um botão pra reverter/apagar cada um individualmente. Objetivo: dar visibilidade e uma saída fácil pra um erro de digitação, uma exclusão ou um vínculo automático indesejado, sem precisar reprocessar a importação do zero.
- **Model** (`ImportacaoPlanoSaudeAlteracao`, migração `0038`, `vinculo_nome` adicionado na `0051`): um registro por operação, nunca apagado (mesmo espírito de `resolvida` em `ImportacaoPlanoSaudeAuditoria` — histórico completo). `tipo` (`edicao`/`inclusao`/`exclusao`/`vinculo_automatico`), `linha` (FK `SET_NULL` — fica `null` quando a linha em si já não existe mais: foi excluída, ou era uma inclusão já revertida), `campo`/`valor_anterior`/`valor_novo` (só preenchidos em `edicao`; em `vinculo_automatico`, `valor_novo` guarda o nome do arquivo da operadora), `dados_linha` (JSONField — snapshot de todos os campos editáveis da linha **+** `ordem`, capturado no momento da operação; é o que permite recriar a linha ao reverter uma exclusão e identificar a linha na tela mesmo depois dela ter sido excluída), `vinculo_nome` (FK `SET_NULL`, só em `vinculo_automatico`), `usuario`, `criado_em`, `revertida`/`revertida_em`.
- **Fora de escopo de propósito**: o próprio ato de "Vincular pessoa" (resolução manual de um item de auditoria) não gera um registro aqui — já tem seu próprio rastro (o selo "Resolvido" na aba Auditoria); duplicar o registro nas duas abas só confundiria qual é a fonte da verdade. Só a reaplicação automática desse vínculo numa importação **futura** vira um registro do tipo `vinculo_automatico`.
- **Onde é gravado**: as três operações de `ImportacaoPlanoSaudeLinhaViewSet` (`perform_create`/`perform_update`/`perform_destroy`, `views.py`) — `perform_update` compara `serializer.validated_data` contra `serializer.instance` (os valores **antes** do `.save()`) e grava um `ImportacaoPlanoSaudeAlteracao` por campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, por `change` de cada `<input>`, mas o backend não assume isso — trata qualquer PATCH multi-campo corretamente). `_snapshot_linha_plano_saude()` (módulo-level, reaproveitado nos três pontos) monta o `dados_linha`. `vinculo_automatico` é gravado em `ImportacaoPlanoSaudeViewSet.create()` (ver "Vínculos de nome salvos (DE/PARA)" acima), não no `LinhaViewSet`.
- **`POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/`** (`ImportacaoPlanoSaudeAlteracaoViewSet.reverter`) — idempotente, recusa reverter de novo uma alteração já `revertida`. A própria reversão **não** gera um novo registro de alteração (evitaria um loop de "reverter a reversão"):
- `edicao`: só possível se `linha` ainda existir (não excluída depois); grava `valor_anterior` de volta no campo.
- `inclusao`: só possível se `linha` ainda existir; deleta a linha diretamente (bypassa `ImportacaoPlanoSaudeLinhaViewSet.perform_destroy`, então não cria um registro `exclusao` pra essa reversão).
- `exclusao`: sempre possível (a linha já está excluída por definição) — recria uma `ImportacaoPlanoSaudeLinha` nova a partir do snapshot em `dados_linha` (+ `tipo_lancamento` guardado à parte) e aponta `alteracao.linha` pra ela.
- `vinculo_automatico`: zera `valor`/`valor_empresa` da linha vinculada (reaplicando a regra empresa da família, se `tipo_lancamento == "mensalidade"` e a importação tiver `regra_empresa`) e apaga o `VinculoNomeOperadora` associado — ver "Vínculos de nome salvos (DE/PARA)" acima.
- **Frontend** (`importacao-plano-saude.js`, `panelHtmlAlteracoes()`): lista já vem do backend ordenada do mais recente pro mais antigo (`Meta.ordering = ["-criado_em"]`); sem ordenação/redimensionamento de coluna, ao contrário das abas de linha/auditoria — é um log, não uma planilha editável. Cada linha mostra data/hora, um badge de tipo (`.ips-alteracao-tipo--edicao/--inclusao/--exclusao/--vinculo_automatico`, cores dourado/teal/vermelho/`--accent`), o lançamento, o nome identificado pela linha (`linha_nome`, do serializer — usa o snapshot quando a linha já não existe mais), uma descrição da alteração (`"<campo>: "<anterior>" → "<novo>""` pra edição, texto fixo pra inclusão/exclusão, `"<nome do arquivo>" → <nome vinculado>` pra vínculo automático) e o usuário. A coluna "Ação" mostra "Reverter" ou "Apagar vínculo" (conforme o tipo, com `pidConfirm`, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chama `pidReverterAlteracaoPlanoSaude()` e refaz `pidFetchImportacaoPlanoSaude()` (mesmo padrão de adicionar/remover linha e de "Vincular pessoa") antes de re-renderizar as abas.
## Pré-validação de arquivo ao anexar (tela de Nova Importação)
Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora) só eram validados juntos, no `create()`, e um erro de formato virava a mensagem genérica "O formato de um dos arquivos não está conforme o esperado" — sem dizer qual dos dois. Agora cada anexo é validado sozinho, no momento em que é selecionado, reaproveitando exatamente o mesmo parser que `create()` usaria — sem duplicar nenhuma regra de leiaute em JS (o parsing de PDF/CSV é Python-only, então isso teria que ser uma chamada ao servidor de qualquer forma).
- **Endpoint**: `POST /api/importacoes-plano-saude/validar-arquivo/` (multipart `{tipo: "planilha"|"operadora", arquivo, operadora?}`) — sempre `200 {"valido": bool, "mensagem": str}`, nunca um erro HTTP pra "arquivo errado" (esse é um resultado esperado da validação, não uma falha de requisição; só falta de `arquivo`/`tipo` inválido/`operadora` ausente quando `tipo="operadora"` vira 400 de verdade). `_valida_planilha_padrao()` roda `leiaute_sistema.le_planilha_padrao()`; `_valida_arquivo_operadora()` roda `OPERADORAS[operadora_key]["parser"]().extrai()` — 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`.
- **Bug real (SulAmérica 5775, .xlsx)**: `_valida_arquivo_operadora()` escolhia o sufixo do arquivo temporário só entre `.pdf`/`.csv` (`sufixo = ".pdf" if nome.endswith(".pdf") else ".csv"` — hardcoded pros formatos que existiam até então). Um upload `.xlsx` caía no `else` e era salvo com sufixo `.csv`; `openpyxl.load_workbook()` recusa abrir um arquivo cujo sufixo não seja `.xlsx`/`.xlsm`/`.xltx`/`.xltm` (`InvalidFileException`), mesmo com conteúdo válido — a pré-validação sempre falhava pra essa operadora com a mensagem genérica "Não foi possível reconhecer este arquivo...", travando o passo "2. Arquivos da operadora" antes mesmo de chegar em `create()`. Corrigido preservando a extensão real do upload (`os.path.splitext(arquivo.name)[1]`) em vez de adivinhar entre dois formatos fixos — generaliza pra qualquer extensão que uma operadora futura venha a usar, não só as três já vistas. O `accept=".csv,.pdf"` do `<input type="file">` de "Arquivo(s) da operadora)" (`#ips-form-arquivo`) também precisou virar `accept=".csv,.pdf,.xlsx"`, senão o seletor de arquivo do navegador já filtra `.xlsx` pra fora antes do usuário conseguir escolher o arquivo. **Nota pra quando adicionar outro formato**: o fluxo real de `create()` (`ImportacaoPlanoSaudeViewSet.create`) nunca teve esse bug — usa `arquivo.arquivo.path` (caminho real salvo pelo `FileField` do Django, que preserva a extensão original), só a pré-validação manipulava um arquivo temporário com sufixo escolhido à mão.
- **Frontend** (`importacao-plano-saude.js`): `criarValidadorArquivo()` é a fábrica reaproveitada pelos dois campos (`validadorPlanilha`/`validadorArquivo`) — no `change` do `<input type="file">`, chama `pidValidarArquivoPlanoSaude()` e mostra o resultado abaixo do campo (`.ips-file-field__status`, cores diferentes pra pendente/ok/erro). Cada campo ganhou um botão de remover (`.ips-file-field__remove`, ícone X — só aparece com um arquivo anexado) que limpa o `<input>` e o estado de validação, pro colaborador poder tentar outro arquivo sem precisar recarregar a página quando o anexado voltar como divergente. Trocar a operadora depois de já ter anexado o arquivo dela (`formOperadora` `change`) reexecuta a validação automaticamente (`revalidarSeAnexado()`) — o parser usado depende de qual operadora está selecionada, então um arquivo validado contra a operadora errada precisa ser checado de novo. O botão "Processar" bloqueia (`ehInvalido()`) se qualquer um dos dois arquivos já voltou `valido=False` — mas isso é só uma segunda barreira de UX; o `create()` no servidor continua sendo a validação real e definitiva.
Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`.
## Cadastro de Regras (separado da execução da importação)
Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora:
- **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `"<código> - <nome>"` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora".
- **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `<código - nome>` · `<operadora>`" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`).
- **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar".
- **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema.
- **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado).
- **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `"<código> - <nome>"`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `"<código> - <nome>"` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um `<select>` nativo) — clicar na seta mostra a lista completa de novo, ou, se já houver algo selecionado, as **outras** opções cadastradas (sem repetir a já escolhida). Existe porque só focar o campo com um valor já preenchido filtra a lista pelo texto atual, então só mostraria de novo o item já selecionado — a seta é o jeito de "trocar fácil" pedido pelo usuário, no mesmo espírito de um filtro de BI (clicar, ver todas as opções, escolher outra).
O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/coparticipação × titular/dependente/regra específica) e as funções JS que o operam (`coletarCusteioAtual()`, `mensagemErroCusteio()`, `aplicarCusteio()`, `limparCusteioForm()`) foram **movidos** (não duplicados) de "Nova Importação" pro modal de Cadastro — mesmos ids de DOM, mesma lógica, só relocados; "Nova Importação" monta o `FormData` do submit direto a partir do objeto `regraResolvidaAtual` em memória (`montarFormDataDeRegra()`), não mais lendo inputs (que não existem mais ali).
- **Campos de `RegraCusteioPlanoSaude`**: `codigo_empresa` (obrigatório — o código do cliente/empresa; **não confundir** com o código de cadastro da operadora no Questor, que já aparece dentro do label de `pipeline.OPERADORAS`, ex. `"5060 - Unimed Saúde"` — são códigos diferentes), `operadora` (obrigatória agora, validada contra `pipeline.OPERADORAS`), `regra_empresa_chave` (ver "Regra empresa" abaixo), `tipos_lancamento`/`custeio_por_tipo` (mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`) e `observacoes`. `Meta.unique_together = [["codigo_empresa", "operadora"]]` — validado contra os 12 registros reais existentes antes de impor a restrição (nenhuma combinação se repetia). `nome` **deixou de ser digitado** pelo usuário — é sempre derivado em `RegraCusteioPlanoSaudeSerializer.validate()` como `"<codigo_empresa> - <nome da operadora sem o código dela>"` (campo `read_only=True` na API); mantido como campo de model só pra não precisar tocar em todo lugar que já lê `.nome`/`regra_custeio_salva_nome`.
- **Migração em 3 passos** (mesmo padrão já usado pra `IndicadorDepartamento`, migrations `0033`/`0034`/`0035`): `0041` adiciona `codigo_empresa`/`regra_empresa_chave` (blank) + torna `operadora` obrigatória; `0042` (RunPython) faz o backfill de `codigo_empresa` a partir do `nome` existente (`nome.split(" - ", 1)[0].strip()`); `0043` torna `codigo_empresa` obrigatório e adiciona o `unique_together`. `Meta.ordering` usa `[Length("codigo_empresa"), "codigo_empresa", "operadora"]` (mesmo padrão de `IndicadorApuracaoEmpresa`) pra ordenar o código como número, não como string.
- **Validação reaproveitada, não duplicada**: `RegraCusteioPlanoSaudeSerializer.validate()` e `ImportacaoPlanoSaudeCreateSerializer.validate()` continuam chamando a mesma função módulo-level `_monta_regra_custeio()` (`serializers.py`) pra validar/parsear cada combinação tipo×pessoa. `UniqueTogetherValidator` é declarado explicitamente em `Meta.validators` (não só o automático do DRF), pra manter a mensagem de erro em português.
- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`) registra qual regra foi aplicada numa importação — agora praticamente sempre preenchida (já que "Nova Importação" só resolve custeio a partir de uma regra cadastrada), mas o campo continua opcional a nível de API (a garantia de "sempre passar por uma regra cadastrada" é uma trava de UI, não uma obrigatoriedade no backend). Alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão, como antes.
- **Trava de conferência do código de empresa** (`ImportacaoPlanoSaudeViewSet.create()`, depois do processamento e antes do `bulk_create` das linhas): se `regra_custeio_salva` está presente, confere que ao menos uma linha da planilha padrão processada tem `codigo_empresa` igual ao da regra; se não bater, desfaz a importação (mesmo padrão de cleanup dos outros `except` desse método) e devolve 400 com mensagem clara — evita aplicar a regra de uma empresa a uma planilha de outra por engano. Vale pra toda regra aplicada, inclusive as com `regra_empresa_chave` (onde é redundante com a checagem que `regras_empresa.valida_regra_empresa()` já faz — proteção extra contra o registro em `REGRAS_EMPRESA` ficar dessincronizado da `RegraCusteioPlanoSaude` correspondente).
**Nome da empresa (Questor)** — primeiro consumidor real do pacote `database/` (ver [[project_database_package]] na memória): resolve e cacheia localmente o nome de uma empresa a partir do seu `codigo_empresa`, pra mostrar `"<código> - <nome>"` em vez de só o código nas telas acima.
- **`EmpresaQuestor`** (models.py, migração `0044`): `codigo_empresa` (único) + `nome_empresa`, um cache local simples — sem relação de FK com `RegraCusteioPlanoSaude` (é uma propriedade da empresa, não da regra; várias regras podem compartilhar o mesmo `codigo_empresa` com operadoras diferentes, ex. "221" com Bradesco/Itamed/Unimed, e todas reaproveitam a mesma linha de `EmpresaQuestor`).
- **`portal_api/empresas_questor.py`, `resolve_nome_empresa(codigo_empresa)`**: olha o cache primeiro; só na ausência dele consulta o Questor (`database.connection.DatabaseConnection("questor")` — chave em **minúsculas**, `DatabaseSettings` normaliza as chaves de `DATABASE__<NOME>__*` do `.env` assim, ao contrário do que o padrão de nomenclatura das próprias env vars sugere) executando `sqls.questor.QuestorSQL.consulta_nome_empresa()` (`select codigoempresa, nomeempresa from empresa where codigoempresa = :codigo_empresa` — a consulta exata fornecida pelo usuário, só parametrizada), e persiste o resultado antes de devolver — nunca precisa repetir a consulta pro mesmo código depois. Qualquer falha (código inexistente, `codigoempresa` do Questor é `smallint` e um código fora da faixa numérica levanta `DataError`, banco inacessível) é capturada e devolve `None` — nunca propaga a exceção, já que isso é só informativo, nunca bloqueia cadastrar/editar/excluir uma regra.
- **`normalizar_codigo_empresa(valor)`** (mesmo arquivo): remove zero à esquerda (`"092"` → `"92"`) — decisão explícita do usuário, pra sempre ter um único código canônico por empresa (o `codigoempresa` do Questor é `smallint`, então "092"/"92" já eram a mesma linha lá; sem normalizar no Portal, apareciam como duas empresas "diferentes"). Aplicada em toda entrada de `codigo_empresa` vinda de fora: `resolve_nome_empresa()`, `RegraCusteioPlanoSaudeSerializer.validate_codigo_empresa()` (o que é de fato salvo em `RegraCusteioPlanoSaude.codigo_empresa`), a action `nome-empresa` (devolve o código já normalizado, pro frontend reescrever o campo), e a trava de conferência em `ImportacaoPlanoSaudeViewSet.create()` (normaliza os dois lados antes de comparar, já que o código bruto da planilha pode ter zero à esquerda enquanto o da regra não tem mais). **Nunca** aplicada a `ImportacaoPlanoSaudeLinha.codigo_empresa` em si (precisa continuar exatamente como veio da planilha, pra não alterar o que é reexportado) — só normalizada no momento de uma comparação/exibição pontual (ver `_nome_empresa_cacheado()`, que normaliza antes de consultar `EmpresaQuestor` a partir do código cru de uma linha).
- **`sqls/questor.py`** (pacote novo na raiz do projeto, ao lado de `database/` — seguindo a convenção "uma pasta `sqls/` por projeto consumidor, um arquivo por banco" já documentada na memória): classe `QuestorSQL`, hoje só `consulta_nome_empresa()`. Adicionar uma consulta nova ao Questor/Tareffa segue o mesmo padrão — método estático devolvendo `SQLQuery(sql=dedent(...), params={...})`; **nunca** usar `execute`/`execute_returning` desses bancos sem autorização explícita (ver [[feedback_bancos_externos_somente_leitura]]).
- **Correção em `database/settings.py`** (arquivo compartilhado, não específico desta ferramenta): `SUPPORTED_DRIVERS["postgresql"]` apontava pra `"postgresql+psycopg2"`, mas o `.venv` do Portal só tem `psycopg` (v3) instalado, não `psycopg2` — `ModuleNotFoundError` ao tentar conectar. Corrigido pra `"postgresql+psycopg"` (dialeto psycopg3 do SQLAlchemy), reaproveitando a dependência que já existe em vez de instalar `psycopg2-binary` à parte. Se `database/` for reaproveitado por outro projeto que dependa especificamente de `psycopg2` (comportamento antigo), essa mudança precisaria ser revisitada — não é o caso hoje.
- **Resolução automática pra regras já existentes**: `RegraCusteioPlanoSaudeSerializer.get_nome_empresa()` chama `resolve_nome_empresa()` a cada leitura (não só ao criar/editar) — então regras cadastradas antes deste campo existir tiveram o nome resolvido e cacheado sozinho, na primeira vez que a lista foi carregada depois do deploy, sem precisar de nenhum backfill manual. Já `ImportacaoPlanoSaudeDetailSerializer.get_nome_empresa()` (tela de Revisão) só lê o cache (`_nome_empresa_cacheado()`, sem chamar `resolve_nome_empresa()`) — essa tela é consultada com muito mais frequência, e o nome já deveria estar cacheado desde que a regra foi cadastrada/editada, então não vale pagar o custo de uma consulta ao Questor ali.
- **Frontend** (`importacao-plano-saude.js`): `GET /api/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=X` (`pidBuscarNomeEmpresaPlanoSaude`) é chamado tanto num debounce de 350ms a cada tecla digitada no campo "Código da empresa" de "+ Nova regra" (`agendarAtualizarNomeEmpresaNovo()`, pedido explícito do usuário pra não precisar esperar o campo perder o foco) quanto no `blur` (imediato, cancela o debounce pendente) — a função de fato (`atualizarNomeEmpresaNovo()`) mostra "Buscando nome da empresa...", depois o nome resolvido ou "Empresa não encontrada no Questor.", e **reescreve o próprio campo** com o `codigo_empresa` normalizado devolvido pela resposta (ex.: usuário digita "092", campo passa a mostrar "92" assim que resolve). Os comboboxes de "Empresa" (Cadastro de Regras e Nova Importação) não fazem nenhuma chamada nova — `labelEmpresa()` monta `"<código> - <nome>"` direto do array `regras` já carregado, que já vem com `nome_empresa` resolvido pelo backend.
## Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação)
Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio.
**Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação".
- **Mutuamente exclusivo por TIPO, não em bloco**: no formulário de Cadastro de Regras, escolher uma regra especial trava (marca + desabilita + esconde os radios titular/dependente) só os checkboxes "Mensalidade"/"Coparticipação" que essa regra específica cobre — `aplicarTiposRegraEmpresa()` em `importacao-plano-saude.js`, chamada sempre que a regra selecionada muda (ao marcar/desmarcar o checkbox, ou ao escolher uma regra no picker). Uma regra que só cobre mensalidade (Tecnomyl) deixa "Coparticipação" livre pra configuração manual normalmente — mesmo comportamento de antes pra essa regra específica; a novidade é só que agora isso é decidido pelos `tipos_lancamento` de CADA regra, não fixo no código do formulário. Um checkbox travado (`.disabled`) não dispara `change` por clique do usuário, então a exclusividade mútua não precisa de nenhuma lógica extra nos handlers de "Mensalidade"/"Coparticipação" — só o handler de "Regra especial da empresa"/a seleção no picker chamam `aplicarTiposRegraEmpresa()`.
- No backend, `RegraCusteioPlanoSaudeSerializer.validate()`/`ImportacaoPlanoSaudeCreateSerializer.validate()` calculam `regra_empresa_tipos` (interseção entre `REGRAS_EMPRESA[chave]["tipos_lancamento"]` e os tipos selecionados — erro claro se vier vazia), conferem `chave_casamento_para_tipo(tipo) == REGRAS_EMPRESA[chave]["chave_casamento"]` pra cada tipo coberto (não mais um "exige nome" hardcoded) e zeram `custeio_por_tipo[tipo]` só pros tipos em `regra_empresa_tipos` (os demais tipos selecionados continuam com custeio manual normal). `regras_empresa.valida_regra_empresa(regra_empresa_key, chave_casamento_por_tipo, linhas_sistema, tipos_selecionados)` devolve `(aplica, tipos_cobertos)` — `pipeline.processa_importacao` passa `regra_empresa_fn` pra `casa_individuos_com_planilha` só quando `tipo_lancamento in tipos_cobertos`.
- **`matcher._casa_por_cpf` passou a suportar `regra_empresa_fn`** (antes só `_casa_por_nome` suportava) — sem agrupar por família (essa estratégia não tem esse conceito): acumula `(linha, valor_total)` de todo indivíduo casado por CPF e chama `regra_empresa_fn(linhas_e_valores, tipo_lancamento)` uma vez só, no fim, com todos os pares do tipo de lançamento inteiro. `aplica(linhas_e_valores, tipo_lancamento)` é a assinatura de toda regra agora (segundo argumento novo) — permite uma mesma função se comportar diferente por tipo (ver Ottimizza abaixo); a Tecnomyl recebe o parâmetro mas ignora (só é chamada pra "mensalidade" mesmo, via `tipos_lancamento`).
- **Registro** (`REGRAS_EMPRESA`): cada entrada tem `label`, `codigo_empresa` (código da empresa na planilha padrão pra qual a regra foi negociada), `operadora`, `chave_casamento`, `tipos_lancamento`, `aplica` (função que faz o cálculo) e `observacoes`. Pra cadastrar uma regra nova: escrever a função e registrar aqui — nada mais precisa mudar (`GET /api/importacoes-plano-saude/regras-empresa/` já reflete o registro, incluindo `tipos_lancamento`, consumido tanto pelo seletor dentro do Cadastro de Regras quanto pelo resumo só-leitura de "Nova Importação").
- **Observações da regra, só-leitura na tela de Revisão** (`#ips-review-regra-empresa-obs`) — inalterado: `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` resolve `REGRAS_EMPRESA[obj.regra_empresa]["observacoes"]` a cada carregamento; o mesmo bloco cai pra `regra_custeio_salva_observacoes` quando não há regra empresa.
- **`unimed_1778_tecnomyl`** — Tecnomyl (código 1778 na Unimed) tem ajuda de custo de até R$ 661,61 por família (titular + dependentes juntos, não por pessoa): família com mensalidade total acima do teto tem o excedente descontado do empregado; igual ou abaixo do teto, a empresa cobre 100%. Repassada pelo cliente em 08/2026. `chave_casamento="nome"` (precisa agrupar família), `tipos_lancamento=("mensalidade",)` — coparticipação dela segue sempre o custeio normal configurado no mesmo cadastro (radios titular/dependente), sem nenhuma ligação com a regra.
- **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade.
- **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`.
- **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação).
- **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes).
- **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto.

View File

@ -26,9 +26,20 @@ Particularidades deste relatório (descobertas inspecionando o PDF real):
— pai/mãe, irmãos). No leiaute do sistema, D e A são tratados da
mesma forma (linha de dependente comum).
5. A coluna do plano (ex: "DENTAL BRONZE DOC R PADRÃO") vem colada sem
NENHUM espaço na coluna Tp. logo em seguida (ex: "PADRÃOT", "PADRÃOD")
— não é um problema de x_density do pdfplumber, o espaço realmente não
existe no PDF de origem. Por isso o regex usa `\s*` (não `\s+`) entre
plano e Tp.
A extração de texto usa pdfplumber com `layout=True` para preservar o
alinhamento das colunas (mesmo efeito do antigo `pdftotext -layout`, mas
sem depender de um binário externo do poppler instalado no servidor).
Validado rodando `extrai()` contra um arquivo real (contrato 2831804000,
competência 08/2026): 161 beneficiários (124 titulares/35 dependentes/2
agregados), R$ 1.630,93 no total — bate exatamente com os totais impressos
no próprio relatório.
"""
import re
from typing import List, Tuple
@ -41,7 +52,7 @@ from portal_api.planos_saude.operadoras.base import OperadoraParser
_ROW_RE = re.compile(r'^\s*(?P<numero>\d{9})\s+(?P<resto>.+)$')
_CPF_RE = re.compile(r'(\d{11})')
_AFTER_CPF_RE = re.compile(
r'^(?P<plano>.+?)\s+(?P<tp>[TDA])\s+(?P<idade>\d{1,3})\s*'
r'^(?P<plano>.+?)\s*(?P<tp>[TDA])\s+(?P<idade>\d{1,3})\s*'
r'(?P<dependencia>[A-Za-zÀ-ÿ/()]+(?:\s[A-Za-zÀ-ÿ/()]+)?)?\s+'
r'(?P<data>\d{2}/\d{2}/\d{4})\s+'
r'(?P<rubrica>.+?)\s+'

49
prd.md Normal file
View File

@ -0,0 +1,49 @@
# prd.md — Product Requirements Document
> O que é o Portal De Paula, para quem é e o que ele faz — visão de produto, não de implementação. "Como construir" e em que ordem fica em `plano.md`; "como o código funciona" fica em `CLAUDE.md`.
## O que é
Portal interno da De Paula Contadores ("Portal De Paula") — ponto único de acesso a ferramentas, cadastros e informações do dia a dia do escritório, substituindo planilhas manuais e processos dispersos por telas e fluxos centralizados, com controle de acesso por perfil.
## Quem usa
Colaboradores do escritório, autenticados por login/senha (sessão Django). O acesso a cada módulo/aplicação é controlado por **Perfis de Acesso** — um usuário pode ter mais de um perfil vinculado, e o que ele vê no menu é a união das permissões de todos os seus perfis. Um perfil especial (`gerencia_permissoes=True`) administra os próprios perfis e usuários; hoje é o perfil "Inovação".
## Funcionalidades principais
### Base (todo usuário autenticado)
- **Principal** — busca de aplicações, grade de favoritos, widgets personalizáveis.
- **Calendário Individual** — agenda pessoal (compromissos privados, de departamento ou de todos), feriados nacionais/estaduais, eventos corporativos (para quem tem permissão de criar), agenda da equipe para gerentes/coordenadores.
- **Links & Ferramentas** — atalhos para ferramentas externas, em cartões.
- **Acessos Gerais** — cadastro de logins/acessos compartilhados da equipe, organizados em seções.
- **Ramais** — diretório de ramais internos (automático a partir do cadastro de usuários + linhas avulsas), telefones externos, funções de telefonia, controle de ausência.
- **Solicitações** — atalhos para formulários externos (chamados, indicações, sugestões).
### Administração (perfil com `gerencia_permissoes`)
- **Perfis de Acesso** — CRUD de perfis e da árvore de permissões por módulo/aplicação.
- **Usuários** — CRUD de contas, vínculo de perfis/departamentos, liderança (gerente → liderados), inativação.
### Geradoc (perfis com acesso liberado)
- **Simulação de Custo de Contratação** — calcula o custo de contratar um Empregado CLT e gera PDF pronto para o cliente. Escopo atual: só Empregado CLT.
- **Indicador de Desempenho** — apuração mensal do indicador de desempenho do Fiscontábil, a partir de planilhas do Tareffa, com geração de recibo em PDF por colaborador. Escopo atual: só departamento Fisco/Contábil.
### Utilitários (perfis com acesso liberado)
- **Importação de Plano de Saúde** — importa o 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 casou automaticamente.
### Reservados no menu, sem tela própria ainda
- **Portais** (Portal do Cliente, Portal Fiscal), **Relatórios** (Relatório Setorial), **Relatórios Gerenciais** (Visão Diretoria, Visão Gerencial), **Integrações** (Questor), **Auditorias** (Consultoria Tributária, Fisco/Contábil) — já existem no catálogo de permissões (menu, controle de acesso), mas ainda não têm funcionalidade implementada.
## Fora de escopo, por decisão explícita (não implementar sem confirmar de novo)
- Simulação de Custo de Contratação: as outras 4 modalidades (Simples Nacional, Regime Normal, Pró-labore, Empregado Doméstico) — faltam fontes de regra validadas.
- Indicador de Desempenho: departamentos além do Fisco/Contábil — a resolução por gerente tem uma limitação conhecida que precisa ser resolvida antes de expandir.
- Ramais: abas "Férias" e "Responsável no Tareffa" — espaço reservado na navegação, conteúdo adiado.
- Um calendário interno tipo "Calendário De Paula" — hoje é um iframe para ferramenta externa, de propósito.
- Preferência de tema continua só no `localStorage` do navegador, não migra para o banco.
## Onde encontrar o resto
- **Como construir / ordem de execução**: `plano.md`.
- **Como o código funciona hoje** (arquitetura, models, endpoints, decisões técnicas): `CLAUDE.md`.
- **Contexto de negócio por aplicação** (por quê, limitações conhecidas): skills em `.claude/skills/`.