# Pool — Sistema de R&S, PCS e Onboarding (Pessoal)

App PHP MVC para gestão de Recrutamento e Seleção: Requisições de Pessoal (RP), Plano de Cargos e Salários (PCS), descrições de cargo, centros de custo, funções de cargo, treinamentos, calendário de onboarding e SLA por etapa. **Em desenvolvimento ativo.**

Base: `baseparaphp` (ver `../../baseparaphp/CLAUDE.md`) com extensões locais.

## Ambiente operacional

- **Edição local:** Welington edita os arquivos nesta pasta no OneDrive (Windows). **A app NÃO roda nesta máquina** — esta cópia é a fonte do código, não o ambiente de execução.
- **Servidor de execução:** **Debian + Apache + PHP 8 + SQLite**. Caminho do pool no servidor: `/var/www/html/pool`.
- **URL:** `http://delp5020/pool`.
- **`APP_ENV` no servidor:** `production`. Não existe cópia de dev rodando localmente.
- **Deploy:** **manual**, sem CI/CD. Fluxo padrão:
  1. Editar arquivos localmente (Cowork ou Claude Code).
  2. **WinSCP** — copiar arquivos alterados (mantendo a estrutura) para o servidor.
  3. **Putty/SSH** — executar comandos no servidor (migrations, validações, restart se necessário).
  4. Testar via navegador em `http://delp5020/pool`.

## Convenções obrigatórias para esta sessão e futuras

- **Nunca dar comandos em PowerShell.** Sempre **bash/Linux**, prontos para colar no Putty.
- **Quando criar migration nova**, antes de mandar `php database/setup.php`, lembrar Welington de copiar o `.sql` via WinSCP para `database/migrations/` no servidor.
- **Setup.php é idempotente** mas `ALTER TABLE ADD COLUMN` no SQLite não — re-execuções mostram warning `duplicate column name: <x>`, esperado e inofensivo.
- **Permissões:** o `setup.php` faz `chmod 664` no banco e `775` na pasta `database/`. O Apache (`www-data`) precisa estar no grupo do dono do banco. Se rodar `php setup.php` com seu user pessoal, conferir ownership de `database/app.db` e ajustar com `sudo chown www-data:www-data database/app.db` se Apache não conseguir escrever.

### Protocolo de sincronização do OneDrive em alterações grandes

A pasta do projeto vive no OneDrive (`C:\Users\welington\OneDrive\...`). Em alterações que envolvem **5+ arquivos** ou refatorações encadeadas, o sync do OneDrive pode segurar arquivos intermediários e fazer o WinSCP pegar versão desatualizada.

**Protocolo:**

1. Quando eu (Claude) for iniciar uma alteração grande, abro a resposta com: **`⏸️ Pause o OneDrive antes — vou mexer em N arquivos.`**
2. Welington pausa a sincronização do OneDrive (ícone na bandeja → Pausar sincronização → 2/8/24 horas).
3. Eu aplico todas as mudanças sem competir com sync.
4. No final, sinalizo: **`▶️ Pode despausar o OneDrive. N arquivos prontos para WinSCP.`**
5. Welington retoma a sincronização e faz o deploy via WinSCP.

**Quando NÃO precisa pausar** (overhead desnecessário): 1-2 edits pontuais, correção de label, troca de cor, ajuste de regra simples.

Se esquecer de despausar, a próxima abertura do OneDrive sincroniza normalmente — não há perda de dado, só atraso.

## Estrutura técnica

- **Framework:** `baseparaphp` (Auth local + LDAP, PDO multi-driver, helpers, CSRF, layouts/partials).
- **Extensões locais em `app/Core/`:**
  - `XlsxReader.php` — leitor nativo de `.xlsx` (sem PhpSpreadsheet, sem Composer).
  - `DominioHelper.php` — listas suspensas configuráveis (`config/data/*.json` + tabela `dominios`).
- **Fluxo principal:** máquina de estados das RPs em `RpModel`/`RpController`. Histórico em `rp_historico`. Treinamentos por RP em `rp_treinamentos`.
- **PCS:** níveis salariais (`pcs_niveis`) + funções por nível (`pcs_funcoes`) + **faixas por unidade** (`pcs_faixas`, migration 024). DAP usa esse cadastro para equalizar o salário previsto. Mesma função pode ter faixas diferentes em unidades diferentes (CCT regional).
- **Funções de cargo:** relacionamento **N:M** com centros de custo (`funcoes_centros_custo`, migration 024) — uma mesma função (ex: "Soldador") pode existir em vários CCs/unidades sem duplicação de cadastro.
- **SLA:** prazo por etapa do fluxo (tabela `sla_etapas`), parametrizado por tipo de mão-de-obra (`Direto` / `Outros`).

## Regras de negócio críticas (vivas — refinadas a cada sessão)

### Fluxo de etapas da RP

```
Rascunho
  → pendente_descricao        (quando a função ainda não tem descrição cadastrada — fluxo a refinar)
  → pendente_gestor           (Aprovação Gestor)
  → pendente_dap              (Aprovação DAP)
  → pendente_diretoria        (Aprovação Diretoria)
  → em_recrutamento
  → em_selecao                (antes "em_entrevista" — renomeada em 2026-04)
  → candidato_selecionado
  → admissao_prevista
  → admitido
```

Status `cancelado` é terminal a partir das etapas que permitem cancelamento.

### Cancelamento

| Etapa | Pode cancelar? |
|---|---|
| Rascunho | Sim |
| pendente_descricao | Sim |
| pendente_gestor (Aprovação Gestor) | Sim |
| pendente_dap (Aprovação DAP) | **Não** |
| pendente_diretoria (Aprovação Diretoria) | Sim |
| em_recrutamento em diante | **Não** |

`admitido` só pode ser revertido para `admissao_prevista` por **Administrador** — fluxo normal não retorna.

### Devolução (voltar uma etapa)

Regra geral: cada etapa devolve para a anterior imediata.

**Exceção:** `pendente_dap` e `pendente_diretoria` **nunca recebem RP devolvida**. Sempre que uma devolução cairia sobre elas, vai direto para `pendente_gestor`.

| Origem | Devolve para |
|---|---|
| pendente_dap | pendente_gestor |
| pendente_diretoria | **pendente_gestor** (pula DAP) |
| em_recrutamento | **pendente_gestor** (pula Diretoria e DAP) |
| em_selecao | em_recrutamento |
| candidato_selecionado | em_selecao |
| admissao_prevista | candidato_selecionado |
| admitido | admissao_prevista — **só Administrador** |

### Datas de admissão (Gestor x ReS)

A RP carrega **duas datas** independentes referentes à admissão:

- `rp.data_sugerida_admissao` — sugerida pelo **Gestor** ao criar/editar a RP. É a referência inicial do solicitante.
- `rp.res_data_previsao_adm` — registrada pelo **ReS** na etapa "Registrar Admissão Prevista" (ao avançar de `candidato_selecionado` para `admissao_prevista`).

**Regra do modal "Registrar Admissão Prevista":**

- O ReS **NÃO digita data manualmente**. O input direto é bloqueado.
- O ReS só pode **selecionar entre as datas de onboarding ativas** (carregadas do endpoint `/admin/onboarding/ativas`).
- A `data_sugerida_admissao` aparece como referência (read-only) no topo do modal.
- Ambas as datas ficam preservadas no banco — `data_sugerida_admissao` continua sendo a sugestão original do Gestor; `res_data_previsao_adm` é a data efetivamente acordada com o calendário de onboarding.

### Salário previsto

- `rp.salario_previsto` (REAL) — Gestor preenche, **obrigatório** na aprovação. **Imutável** após o Gestor aprovar.
- `rp.salario_dap` (REAL, NULL) — DAP ajusta opcionalmente; NULL significa que aceitou o valor do Gestor.
- `rp.salario_validado` (INTEGER) — flag do DAP: `0` pendente / `1` validado / `-1` divergente.
- **Valor efetivo:** `COALESCE(salario_dap, salario_previsto)`.

#### Visibilidade por perfil (em todos os estados)

| Perfil | Vê? | Edita? |
|---|---|---|
| Gestor | Sim | Sim (até aprovar) |
| DAP | Sim (Gestor + próprio) | Sim (`salario_dap`) |
| Diretoria | Sim (efetivo) | **Não** |
| **Administrador** | **Sim sempre** | Sim (override) |
| Recrutamento (ReS) e demais | Só **após** Aprovação Diretoria | Não |
| Administrativo | **Nunca** | Não |
| Treinamento e Desenvolvimento (TeD) | **Nunca** | Não |

## Envio de email (SMTP — Office 365)

A app envia emails via SMTP corporativo (padrão Office 365 com STARTTLS na porta 587, AUTH LOGIN). **Não usa Composer / PHPMailer** — implementação própria enxuta para evitar `vendor/` no OneDrive.

### Componentes

- **`app/Core/SmtpClient.php`** — cliente SMTP de baixo nível (sockets nativos). Suporta `STARTTLS`, `AUTH LOGIN`, MAIL FROM/RCPT TO/DATA, dot-stuffing, leitura de respostas multilinha. ~200 linhas.
- **`app/Core/Mailer.php`** — wrapper de alto nível com renderização de templates e suporte a multipart/alternative (texto + HTML).
- **`config/email.php`** — configuração lendo `SMTP_*` do `.env`.
- **`views/emails/_base.php`** — layout HTML base (inline-styled, table-based, compatível com Outlook).
- **`views/emails/notificacao_rp.php`** — template genérico para notificações de mudança de etapa na RP.
- **`database/test_email.php`** — script CLI de smoke-test do envio.

### Configuração (`.env`)

```
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_ENCRYPTION=tls
SMTP_USER=conta@delp.com.br
SMTP_PASS=...                       # App Password se MFA habilitado
SMTP_FROM_EMAIL=conta@delp.com.br   # geralmente igual ao SMTP_USER
SMTP_FROM_NAME="Pool — Controle de Vagas"
SMTP_TIMEOUT=30
SMTP_DEBUG=false                    # true = loga diálogo SMTP em error_log()
```

**Alternativa sem AUTH** (relay corporativo autorizado por IP): deixe `SMTP_USER` e `SMTP_PASS` vazios — o `Mailer` pula a autenticação.

### Uso

```php
use App\Core\Mailer;

Mailer::send([
    'to'       => 'gestor@delp.com.br',          // ou ['email'=>..., 'name'=>...]
    'subject'  => 'RP aguardando sua aprovação',
    'template' => 'notificacao_rp',
    'data'     => [
        'rp'         => $rp,                      // array da RP (precisa: id, numero, funcao, criado_por_nome)
        'etapa'      => 'Aguardando aprovação do Gestor',
        'acao'       => 'Aprovar ou devolver a RP',
        'autor'      => $usuario['name'],
        'observacao' => null,                     // opcional
    ],
    // Opcionais:
    // 'cc'       => 'rh@delp.com.br',
    // 'bcc'      => 'log@delp.com.br',
    // 'reply_to' => 'sistema-pool@delp.com.br',
    // 'from'     => ['email'=>..., 'name'=>...],
    // 'layout'   => 'emails/_base',  (default; passe null para HTML cru)
]);
```

A função retorna `true`/`false` — falhas são logadas em `error_log()`. Em modo debug, o log SMTP completo vai para `/var/log/apache2/error.log`.

### Smoke-test pós-deploy

```bash
cd /var/www/html/pool
php database/test_email.php meu-email-pessoal@empresa.com
```

Se a saída terminar com `✅ Email enviado com sucesso`, a infra de email está funcional.

### Gatilhos automáticos no fluxo da RP

Toda transição de status em `RpController::acao()` chama `App\Services\RpNotifier::notify(...)` e dispara emails para os destinatários certos. Falhas no envio **não derrubam** a ação — são apenas registradas no `email_log` com status `failed`.

**Mapeamento status → destinatários:**

| Status novo | Destinatário |
|---|---|
| `pendente_descricao` | `users` com role `res` |
| `pendente_gestor`    | gerente do CC (`centros_custo.gerente_id`) — fallback `users` role `gestor` |
| `pendente_dap`       | `users` com role `dap` |
| `pendente_diretoria` | diretor do CC (`centros_custo.diretor_id`) — fallback `users` role `diretoria` |
| `em_recrutamento`    | `users` com role `res` |
| `em_selecao`         | (sem notificação — etapa interna do ReS) |
| `candidato_selecionado` | (sem notificação — etapa interna do ReS) |
| `admissao_prevista`  | `users` com role `ted` |
| `admitido`           | `users` com role `ted` (confirmação) |
| `cancelado`          | apenas o solicitante |

**Devolução** (status novo com posição menor que o anterior no fluxo):
- O destinatário do destino recebe (mesma regra acima)
- **+ o solicitante (`criado_por`)** também recebe, para informar que a RP voltou.

Cada email é gravado em `email_log` com:
- `status`: `sent` ou `failed`
- `trigger_event`: `rp.transicao.<status>`, `rp.devolucao.<de>_para_<para>`, ou `rp.cancelado`
- `rp_id`: id da RP (link no admin para abrir)
- `error_message`: mensagem do PHP/SMTP em caso de falha

### Auditoria de emails

A tabela `email_log` (migration 022) registra todos os envios. Acesse via menu lateral **"Logs de Email"** (somente admin).

A página `/admin/email-logs` permite:
- Ver os 200 últimos registros, com filtro por status (sent/failed), busca textual e por RP.
- Estatísticas rápidas (total, enviados, falhas, último envio).
- **Limpar logs** em três modos:
  - **Antigos** (recomendado) — remove apenas logs com mais de N dias
  - **Sucesso** — remove só os logs de envios bem-sucedidos (preserva falhas para diagnóstico)
  - **Todos** — apaga tudo

## Funções N:M com CCs e PCS por unidade (CCT regional)

A partir da **migration 024** (`024_funcoes_nm_pcs_faixas.sql`) o sistema passou a separar dois conceitos que antes estavam acoplados:

1. **Quais CCs uma função existe** — agora é N:M (uma função = vários CCs/unidades).
2. **Qual a faixa salarial dessa função** — agora pode variar por unidade (CCT regional).

### Tabelas novas

- **`funcoes_centros_custo`** — junção N:M entre `funcoes_cargo` e `centros_custo`. Cada linha é um vínculo "função X existe no CC Y". `UNIQUE(funcao_id, centro_custo_id)`.
- **`pcs_faixas`** — faixa salarial por (`nivel_id`, `unidade`). `unidade=''` representa a faixa **default** (aplicada quando não há faixa específica para a unidade da RP). `UNIQUE(nivel_id, unidade)`.

As colunas legadas (`funcoes_cargo.centro_custo_id`, `pcs_niveis.salario_min/padrao/max`) **permanecem como fallback** — não foram removidas. A migration 024 fez o backfill: cada vínculo legado virou uma linha em `funcoes_centros_custo`, e cada nível virou uma faixa default em `pcs_faixas`.

### Resolução do salário em /admin/pcs/buscar?funcao=X&unidade=Y

`PcsController::resolverFaixa()` aplica cascata:

1. `pcs_faixas` com (`nivel_id`, `unidade=Y`) — faixa específica da unidade
2. `pcs_faixas` com (`nivel_id`, `unidade=''`) — faixa default
3. `pcs_niveis.salario_*` — fallback legado (sempre devolve algo)

A resposta JSON inclui `unidade_faixa` para o front mostrar se foi aplicada uma faixa específica ou a default. O banner PCS no formulário e na visualização da RP exibem isso ao lado do nome do nível.

### Cenário Delp (CCT regional)

Soldador em VES (Vespasiano) e em outra unidade são sempre o mesmo nível (`Auxiliar 1` por exemplo) — o que muda é só a **faixa**. Por isso o sistema:

- **Não duplica** o registro da função (modelo N:M evita isso).
- **Não duplica** o nível PCS — um único `Auxiliar 1` cobre todas as unidades.
- **Permite múltiplas faixas** sob o mesmo nível, uma por unidade que tenha CCT diferente.

### Telas envolvidas

- **`/admin/funcoes-cargo`** — modal de criar/editar função tem multi-select de CCs. Tabela mostra badges com lista de CCs vinculados. Importação CSV deduplica por nome de função e cria os vínculos N:M (sem duplicar a função).
- **`/admin/pcs`** — para cada nível, há um botão "Faixas por Unidade" que abre modal com tabela editável (linhas = unidades, colunas = min/padrao/max). Faixa default fica em primeiro lugar.
- **`/rp/nova`** e **`/rp/:id/editar`** — Setor (CC) é selecionado **primeiro**. As opções de Função são **filtradas em JS** pelos CCs vinculados (via `data-cc-ids` no `<option>`). A unidade é resolvida do CC selecionado e usada para consultar a faixa PCS correta (`/admin/pcs/buscar?unidade=...`).
- **`/rp/:id`** — banner PCS e modais (DAP/Gestor) também passam a unidade da RP no fetch para mostrar a faixa certa.

### Modelo `FuncaoCargoModel`

Métodos novos da rodada 2:

- `ccsDaFuncao(int $funcaoId): array` — IDs de CCs vinculados a uma função.
- `mapeamentoPorCC(): array` — `[ccId => [nomesDeFuncao]]` para uso geral.
- `listarPorCC(int $ccId): array` — funções de um CC específico (ordenadas).
- `vincularACCs(int $funcaoId, array $ccIds): void` — UPSERT do conjunto de vínculos (delete + insert).
- `listarComCCs(): array` — listagem que agrega o nome dos CCs via `GROUP_CONCAT`.

### Importação CSV

- **Funções** (`/admin/funcoes-cargo/template`): linhas com mesmo `nome` viram uma única função; várias linhas com nomes de CC criam vínculos N:M. Retorna contadores `funcoes_criadas`, `vinculos_criados`, `vinculos_existentes`.
- **PCS** (`/admin/pcs/template`): coluna `unidade` (vazio = default). Cria nível se não existir e UPSERTa a faixa por (nivel, unidade). Coluna `funcao` opcional para vincular função → nível.

## Multi-role (usuário com múltiplas funções)

Um usuário pode ter mais de uma função no sistema (ex: DAP + ReS, Gestor + Diretoria). O esquema atual:

- `users.role` — perfil **principal**, exibido em listagens. Determina o "default" do usuário.
- `users.roles` — JSON array com todas as funções, ex: `["dap","res"]`. **O role principal sempre está incluído.**

### Helpers no `App\Core\Auth`

```php
Auth::roles()                  // array combinado de todas as funções do usuário logado
Auth::hasRole('dap')           // bool — possui exatamente esse role
Auth::hasAnyRole(['admin','dap']) // bool — possui pelo menos um dos informados
Auth::hasAllRoles(['gestor','diretoria']) // bool — possui todos
```

A lista é cacheada na sessão no momento do login. **Mudanças no cadastro só refletem após novo login.**

### Cadastro/edição

A tela `/admin/usuarios/{novo|editar}` mostra checkboxes para `roles[]` e radios para escolher o **principal**. Marcar/desmarcar uma função atualiza o radio principal automaticamente (UX do form em `views/admin/users/form.php`).

### Persistência

- `UserModel::create` e `update` aceitam `roles` (array) e gravam como JSON. O `serializeRoles` garante que o role principal esteja sempre incluído no array.
- `UserModel::find/findByUsername/all` decodificam `roles` para array PHP via `hydrate`.
- `UserModel::emailsByRole($role)` busca em `role = ? OR roles LIKE '%"role"%'` — pega usuários com o role principal **ou** com o role na lista.

### Cálculo de ações da RP

`RpController::calcularAcoes($rp, $userRoles)` recebe a lista completa de roles do usuário e une as ações de todos os perfis. Admin é override (recebe todas). O cancelamento usa `RpModel::canCancel($status, $role)` iterando pelos roles do usuário.

### Segregação de função (compliance)

`RpController::violaSegregacao($rp, $acao, $userId)` bloqueia o usuário de **atuar duas vezes em etapas decisivas** da mesma RP:

- **Ações decisivas** (avanço de fluxo): `aprovar_gestor`, `validar_salario`, `aprovar_diretoria`, `iniciar_selecao`, `informar_candidato`, `admissao_prevista`, `confirmar_admissao`.
- **Ações isentas**: `enviar` (criação), devoluções, `cancelar`, `reverter_admitido`.
- **Admin é override** — pode executar qualquer ação independente do histórico.

A verificação consulta `rp_historico` procurando transições de avanço onde `usuario_id = atual`. Se encontrar, mostra a mensagem "Ação bloqueada por segregação de função: você já atuou em outra etapa desta RP." e impede a transição.

## Backlog ativo (sessão atual)

- [x] **Fase 1 — Schema:** migration 020 (rename `em_entrevista` → `em_selecao`) + 021 (`salario_dap`)
- [x] **Fase 2 — RpModel:** centralizar regras (`STATUS`/`STATUS_COR`, `canCancel`, `previousStep`, `canSeeSalario`, `canEditSalario`, `salarioEfetivo`, `requiresSalarioOnApprove`)
- [x] **Fase 3 — RpController + views da RP:** cancelamento centralizado via `RpModel::canCancel`; devolução com exceção (DAP/Diretoria/Em Recrutamento → Gestor); modal `aprovar_gestor` com salário obrigatório/pré-carregado; modal `validar_salario` grava em `salario_dap` (não sobrescreve mais o do Gestor); visibilidade de salário via `canSeeSalario` em show/form/print; renomeações `iniciar_entrevista`/`voltar_entrevista` → `iniciar_selecao`/`voltar_selecao`; reverter `admitido` → `admissao_prevista` apenas para Admin.
- [x] **Fase 4 — Periferia:** `em_entrevista` → `em_selecao` em `SlaController`, `DashboardController`, `views/dashboard/index.php`. (Sidebar não tinha referência.)
- [x] **Email Office 365 STARTTLS** — `SmtpClient`, `Mailer`, layout base (identidade Delp), template `notificacao_rp`, script CLI de teste.
- [x] **Gatilhos automáticos no fluxo da RP** — `App\Services\RpNotifier` plugado no `RpController::acao()`. Notifica destinatários certos por status novo (gerente/diretor do CC para pendente_gestor/pendente_diretoria; users por role para os demais). Devolução notifica solicitante. Cancelamento notifica solicitante.
- [x] **Auditoria de emails** — tabela `email_log` (migration 022) + página `/admin/email-logs` com filtros e limpeza (modos antigos/sucesso/todos).
- [x] **Multi-role** — `users.roles` JSON (migration 023) + helpers `Auth::hasRole/hasAnyRole/hasAllRoles` + form de cadastro com checkboxes + multi-role em `RpController::calcularAcoes` + `UserModel::emailsByRole` considera lista. `users.role` segue como perfil principal exibido.
- [x] **Segregação de função** — `RpController::violaSegregacao` bloqueia mesma pessoa em etapas decisivas (admin override).
- [x] **Dashboard enriquecido** — KPIs principais (RPs em aberto, aguardando ação, tempo médio admissão, taxa aprovação), SLA por etapa, volume mensal (gráfico CSS sem JS lib), funil, tempo médio por etapa, top setores/funções, salário vs PCS, pipeline R&S, próximas admissões 14d, notificações 7d. Visibilidade condicionada por `Auth::hasAnyRole`.
- [x] **Notificação em todas as transições** — `RpNotifier` agora notifica o solicitante (`criado_por`) em qualquer transição do fluxo, não apenas em devolução/cancelamento.
- [x] **Modal Aprovar Gestor mostra faixa PCS** (informativa, sem badge de adequação que o DAP recebe).
- [x] **Modal Registrar Admissão Prevista**: ReS não digita data; só seleciona entre datas de onboarding. Mostra a `data_sugerida_admissao` do Gestor como referência.
- [x] **Funções N:M com CCs + PCS por unidade (CCT)** — migration 024 aplicada (342 funcs migradas, faixas default em pcs_faixas). `FuncaoCargoModel` reescrito para N:M, formulário de RP inverte ordem (Setor → Função filtrada). `PcsController` com cascade de resolução por unidade. Telas de admin com modais de "Faixas por Unidade" e multi-select de CCs.
- [ ] **Refinar `pendente_descricao`:** definir gatilho de entrada/saída e responsável.
- [ ] **Refactor `RpController`:** está com 29 KB; quebrar em ações ou em sub-controllers depois das regras estabilizarem.

## Verificação obrigatória após mexer no código

```bash
# No servidor, via Putty
cd /var/www/html/pool
find app config public -name "*.php" -print0 | xargs -0 -n1 php -l | grep -v "No syntax errors"
# Acima deve ficar vazio. Se aparecer alguma linha, há erro de sintaxe.
```

E smoke-test no navegador: login → criar RP rascunho → aprovar Gestor → DAP → Diretoria → fluxo até admitido. Cancelamento e devolução em cada etapa permitida.
