# 🚛 Reem Logística — Sistema de Controle de Custos

Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**.

**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus)
**Repositório:** https://git.xenserver.com.br/victor/Reem-Notas

---

> 💡 **Navegação:** as seções abaixo abrem e fecham — clique no título de cada uma.

---

<details>
<summary><strong>📋 CONTEXTO DO PROJETO (leia antes de tudo)</strong></summary>

A **Gade Hospitalar** precisa controlar o custo de cada operação de entrega
(UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).

O banco **já existe** e é atualizado a cada 1 hora por outro sistema externo:
- Tabela: `public.db_reem_simplerout_2026`
- Account: Gade Hospitalar (account_id: 95907)
- **NUNCA** fazer `DROP TABLE`, `TRUNCATE`, `DELETE` ou migration nessa tabela.

O sistema novo cria suas próprias tabelas no mesmo PostgreSQL (schema separado ou prefixo)
e lê a tabela existente apenas para exibir e consolidar entregas.

</details>

---

<details>
<summary><strong>🏗️ Estado atual — O que já foi feito (Fase 1 ✅)</strong></summary>

```
logistica-controle-custos/
├── Gemfile                          # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…)
├── Dockerfile                       # Ruby 3.2.2-slim
├── docker-compose.yml               # app + db (postgres:15)
├── tailwind.config.js               # Paleta preto #0a0a0a / laranja #f97316 / branco
├── .env.example                     # Template de variáveis — copiar para .env
├── .gitignore                       # Protege .env, master.key, credenciais
│
├── config/
│   ├── database.yml                 # 100% via ENV — nunca hardcode
│   └── routes.rb                    # Rotas completas para todas as 8 fases
│
├── app/
│   ├── models/
│   │   ├── entrega.rb               # ⚠️ READ-ONLY — tabela existente db_reem_simplerout_2026
│   │   ├── user.rb                  # Devise + roles (admin/gerente/operador/motorista) + PIN
│   │   ├── configuracao.rb          # Preços configuráveis (entrega, retirada, bonus, desconto)
│   │   ├── consolidacao.rb          # Soft delete, enum status, cálculo de progresso
│   │   ├── consolidacao_motorista.rb
│   │   ├── consolidacao_entrega.rb  # Enum tipo: normal/retirada/bonus/desconto
│   │   ├── historico_estimado.rb    # Job 1h + cálculo ao vivo
│   │   └── auditoria_log.rb         # Log de ações críticas
│   │
│   ├── controllers/
│   │   └── application_controller.rb  # Auth + Pundit + tema
│   ├── policies/
│   │   └── application_policy.rb    # Base Pundit
│   ├── helpers/
│   │   └── application_helper.rb    # nav_link_to, moeda(), badge_status(), progress_bar()
│   └── views/layouts/
│       ├── application.html.erb     # Layout base dark theme laranja/preto/branco
│       └── _navbar.html.erb         # Sidebar responsiva com hamburguer mobile
│
└── db/
    ├── seeds.rb                     # Admin: admin@gade.com / Gade@2026!  + configs de preço
    └── migrate/
        ├── ..._create_users.rb
        ├── ..._create_configuracoes.rb
        ├── ..._create_consolidacoes.rb
        ├── ..._create_consolidacao_motoristas.rb
        ├── ..._create_consolidacao_entregas.rb
        ├── ..._create_historico_estimados.rb
        └── ..._create_auditoria_logs.rb
```

</details>

---

<details>
<summary><strong>🚀 Setup do zero (próximo dev)</strong></summary>

### 1. Clone
```bash
git clone https://git.xenserver.com.br/Cludio-code/logistica-controle-custos.git
cd logistica-controle-custos
```

### 2. Configure o .env
```bash
cp .env.example .env
nano .env   # preencha DB_HOST, DB_PASSWORD, etc.
```

### 3. Gere o SECRET_KEY_BASE
```bash
docker run --rm ruby:3.2.2-slim bash -c "gem install rails --no-doc -q && rails secret"
# Cole o resultado no .env → SECRET_KEY_BASE=...
```

### 4. Suba os containers
```bash
docker-compose up -d
```

### 5. Banco + seeds
```bash
docker-compose exec app bundle exec rails db:create db:migrate db:seed
```

### 6. Acesse
```
http://localhost:3000
Login: admin@gade.com
Senha: Gade@2026!   ← ALTERE NO PRIMEIRO ACESSO
```

</details>

---

<details>
<summary><strong>🔧 Comandos do dia a dia</strong></summary>

```bash
docker-compose exec app bundle exec rails db:migrate   # nova migration
docker-compose exec app bundle exec rails console      # console Rails
docker-compose logs -f app                             # logs em tempo real
docker-compose down                                    # parar tudo
```

</details>

---

<details>
<summary><strong>🗺️ Fases de implementação</strong></summary>

| Fase | Status | O que faz |
|------|--------|-----------|
| **1** | ✅ Concluída | Setup + Docker + Models + Migrations + Seeds + Layout base |
| **2** | ✅ Concluída *(corrigida)* | Login e-mail/PIN + CRUD usuários + pilares de preço + toggle tema |
| **3** | ✅ Concluída *(corrigida)* | Dashboard: cards + gráfico Chart.js + ranking motoristas + navegação por mês |
| **4** | ✅ Concluída | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas |
| **5** | ✅ Concluída | Nova Consolidação + filtros + Wizard Passo 1 |
| **6** | ✅ Concluída | Toggles coloridos + ações em massa + Wizard Passos 2 e 3 (Stimulus) |
| **7** | ✅ Concluída | PDFs Prawn: relatório + extrato com QR code + modal preview |
| **8** | ✅ Concluída | Painel motorista + login PIN/QR + notificações WhatsApp/email |

**🎉 PROJETO: 8 de 8 fases implementadas.**

</details>

---

<details>
<summary><strong>🔧 Análise de integração (11/06/2026) — Correções aplicadas</strong></summary>

As Fases 2/3 (commit `4724847`) foram desenvolvidas em paralelo às Fases 4-8 (commits `90ed705` e `8b3d4bc`), e a mesclagem gerou **conflitos que quebravam a aplicação**. Análise dos commits e correções:

### 🔴 Problemas críticos encontrados e corrigidos

**1. Migration da Fase 2 quebrada** (`20260610_add_fase2_fields_to_users.rb`)
- Timestamp curto (8 dígitos) fazia ela rodar ANTES da `create_users` → erro "table does not exist"
- Adicionava coluna `ativo` que já existia → erro de coluna duplicada
- Criava `pin_acesso` duplicando o `pin_code` existente
- ✅ **Corrigida:** reescrita como `20260101000010_add_devise_trackable_to_users.rb` — só trackable/lockable, timestamp correto

**2. Campo de PIN inconsistente** — Fase 2 usava `pin_acesso`, o model e as Fases 7/8 usam `pin_code`. PINs cadastrados no CRUD não funcionariam no login.
- ✅ **Corrigido:** padronizado `pin_code` em todos os arquivos

**3. Campo de nome inconsistente** — CRUD de usuários usava `:name`, o schema usa `nome` → o CRUD quebrava ao salvar.
- ✅ **Corrigido:** padronizado `nome`

**4. Controllers fora do namespace** — As rotas esperam `Admin::UsuariosController` e `Admin::ConfiguracoesController`, mas os controllers foram criados na raiz → `/admin/usuarios` dava "uninitialized constant" e os controllers ficavam inacessíveis.
- ✅ **Corrigido:** movidos para `app/controllers/admin/` + views para `app/views/admin/` + paths atualizados (`admin_usuarios_path` etc.)

**5. Dashboard (Fase 3) chamava métodos inexistentes** — `Entrega.do_mes` e `Configuracao.mapa_de_precos` foram sobrescritos pelo commit das Fases 4-6 → **a raiz do site quebrava** (root = dashboard#index).
- ✅ **Corrigido:** scope `do_mes` e método `mapa_de_precos` restaurados nos models, junto com `tipo_label`/`icone` usados pelas views de configurações

**6. AuditoriaLog com 2 assinaturas** — Fase 2 chamava `registrar(user, 'acao', ip)` posicional; o model usa keyword args → `ArgumentError` em login PIN, CRUD de usuários e configurações.
- ✅ **Corrigido:** todas as chamadas convertidas para a assinatura do model

**7. Rotas/controllers ausentes**
- `Admin::AuditoriaLogsController` tinha rota mas não existia → ✅ criado com view de listagem e filtros
- `toggle_tema` e `toggle_ativo` existiam nos controllers mas sem rota → ✅ rotas adicionadas
- Controller de configurações usava campo `tipo` que não existe (schema usa `chave`) → ✅ corrigido

### 🟡 Duplicações removidas (arquivos órfãos)
- `app/controllers/motorista_controller.rb` + `app/views/motorista/index.html.erb` — a rota `/motorista` usa `Motorista::DashboardController` (Fase 8)
- `app/views/layouts/_sidebar.html.erb` — o layout renderiza `_navbar.html.erb`
- Seeds dos 3 motoristas de teste (João/1234, Maria/5678, Carlos/9012) restaurados — tinham sido sobrescritos

### ⚠️ Observação sobre login PIN duplicado
Existem **dois fluxos de login por PIN** funcionais: a aba PIN na tela Devise (`/auth/login`, Fase 2) e a tela dedicada (`/motorista/login`, Fase 8 — usada pelo QR Code do extrato). Ambos usam `pin_code` e funcionam; recomenda-se manter os dois (a tela dedicada é melhor para mobile) ou unificar futuramente.

### 📂 Arquivos das Fases 4, 5 e 6

**Fase 4 — Job 1h + Auditoria:**
```
config/schedule.rb                                  # Whenever — roda a cada 1h
lib/tasks/historico.rake                            # rake historico:atualizar
app/jobs/application_job.rb
app/jobs/atualizar_historico_estimado_job.rb        # conta entregas pagas × preco_entrega
app/controllers/api/v1/dashboard_controller.rb      # GET /api/v1/dashboard/metricas
app/controllers/concerns/auditavel.rb               # auditar!(:acao, registro) nos controllers
app/policies/dashboard_policy.rb
```
Ativar o cron no servidor: `bundle exec whenever --update-crontab`
Testar manualmente: `docker-compose exec app bundle exec rake historico:atualizar`

**Fase 5 — Consolidação + Wizard Passo 1:**
```
db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb   # rodar db:migrate!
app/controllers/consolidacoes_controller.rb         # index/new/create/show/wizard/finalizar/arquivar
app/policies/consolidacao_policy.rb
app/views/consolidacoes/index.html.erb              # lista com filtros (status/nome/período/motorista/rota)
app/views/consolidacoes/new.html.erb                # form: nome + 2 date pickers + multi-select motoristas/rotas
app/views/consolidacoes/wizard.html.erb             # Passo 1: lista motoristas com progresso
```

**Fase 6 — Validação + Toggles + Massa:**
```
app/controllers/consolidacao_entregas_controller.rb # validar/classificar/classificar_em_massa/revisar
app/views/consolidacao_entregas/validar.html.erb    # Passo 2: toggles coloridos + checkbox massa + resumo lateral
app/views/consolidacao_entregas/revisar.html.erb    # Passo 3: resumo + próximo motorista / salvar rascunho
app/javascript/controllers/validacao_controller.js  # Stimulus: tempo real (progresso, resumo, toggles)
config/routes.rb                                    # ATUALIZADO — rotas do wizard
```

**Cores dos toggles (padrão do projeto):**
🟧 Laranja = Entrega Normal · 🟫 Laranja escuro = Retirada · ⬜ Branco = Bônus · ⬛ Preto = Desconto

**Regras implementadas:**
- Entregas elegíveis: `status='completed'` AND `checkout IS NOT NULL` (somente leitura da tabela existente)
- Não permite finalizar consolidação com entregas não classificadas (botão desabilitado + validação server-side)
- Consolidação finalizada não pode ser editada por operadores (Pundit)
- Todas ações críticas registradas em `auditoria_logs`

**Fase 7 — PDFs (Prawn + QR Code):**
```
Gemfile                                             # + rqrcode, chunky_png
db/migrate/20260101000009_add_login_token_to_users.rb   # token para QR — rodar db:migrate!
app/services/pdf/base_pdf.rb                        # layout base: cabeçalho preto/laranja + rodapé
app/services/pdf/relatorio_motorista_pdf.rb         # tabela detalhada de entregas + resumo + total
app/services/pdf/extrato_pdf.rb                    # estilo contracheque + QR Code de login rápido
app/controllers/consolidacoes_controller.rb         # ATUALIZADO: gerar_pdf_relatorio/gerar_pdf_extrato/preview_extrato
app/views/consolidacoes/show.html.erb               # botões por motorista + modal de preview
app/views/consolidacoes/preview_extrato.html.erb   # preview HTML antes de confirmar o PDF
app/models/user.rb                                  # ATUALIZADO: regenerar_login_token!
```
Soft delete já implementado: consolidações finalizadas vão para "arquivada", nunca são excluídas.

**Fase 8 — Painel Motorista + Notificações:**
```
Gemfile                                             # + twilio-ruby
app/controllers/motorista/sessoes_controller.rb     # login PIN 4 dígitos + acesso via QR (/motorista/acesso/:token)
app/controllers/motorista/dashboard_controller.rb   # 2 cards: estimado (mês) + consolidado (finalizadas)
app/views/motorista/sessoes/new.html.erb            # teclado numérico gigante mobile-first (envia no 4º dígito)
app/views/motorista/dashboard/index.html.erb        # cards laranja/branco + lista com download do próprio extrato
app/services/notificacao_service.rb                 # WhatsApp (Twilio) + email — falha nunca quebra a finalização
app/mailers/application_mailer.rb
app/mailers/consolidacao_mailer.rb                  # pagamento_fechado
app/views/consolidacao_mailer/pagamento_fechado.html.erb   # email com identidade visual da marca
app/views/layouts/mailer.html.erb
config/initializers/smtp.rb                         # SMTP via ENV (só ativa se SMTP_USERNAME existir)
config/routes.rb                                    # ATUALIZADO: /motorista/login + /motorista/acesso/:token
```

**Notificações — como ativar:**
1. WhatsApp: preencha `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` e `TWILIO_WHATSAPP_FROM` no `.env` e mude a configuração `notificacao_whatsapp` para `true` no sistema. Motorista precisa ter `telefone` cadastrado (formato `+5511999998888`).
2. Email: preencha as variáveis `SMTP_*` no `.env` e mude `notificacao_email` para `true`. Motorista precisa ter `email`.
3. Ao finalizar uma consolidação, cada motorista recebe automaticamente a mensagem com o valor e o link do painel.

**Fluxo do motorista (público de baixo nível técnico):**
- Acessa `/motorista/login` → digita PIN de 4 números num teclado gigante (envia sozinho no 4º dígito)
- OU escaneia o QR Code impresso no extrato → entra direto sem digitar nada
- Vê 2 cards: 🟧 valor estimado do mês ("aproximado") e ⬜ valor fechado para pagamento
- Baixa o próprio extrato das consolidações finalizadas

### 🧪 Teste end-to-end sugerido
1. Login admin → criar consolidação (nome + período + motoristas)
2. Wizard Passo 1 → escolher motorista → Passo 2: classificar entregas com os toggles
3. Usar "Marcar todos como Normal" → Passo 3: revisar → próximo motorista
4. Com tudo classificado → Finalizar (motoristas notificados)
5. Na tela da consolidação → "Ver antes de gerar" → Confirmar → baixar Extrato PDF
6. Logout → `/motorista/login` → entrar com PIN do motorista
7. Conferir cards de valores → baixar próprio extrato → sair

### ▶️ Próximos passos (deploy)

O projeto está completo. Para colocar em produção:

```bash
# 1. Configurar .env com credenciais reais (DB_HOST do PostgreSQL existente)
cp .env.example .env && nano .env

# 2. Subir e migrar
docker-compose up -d --build
docker-compose exec app bundle exec rails db:create db:migrate db:seed

# 3. Ativar o cron do job de 1h
docker-compose exec app bundle exec whenever --update-crontab

# 4. Testar o fluxo end-to-end (seção de teste acima)
```

**Credenciais de teste (seeds):**

| Usuário | Login | Senha/PIN |
|---------|-------|-----------|
| Admin | admin@gade.com | Gade@2026! |
| Gerente | gerente@gade.com | Gade@2026! |
| Motorista João Silva | PIN | 1234 |
| Motorista Maria Santos | PIN | 5678 |
| Motorista Carlos Souza | PIN | 9012 |

⚠️ Altere as senhas padrão no primeiro acesso em produção.

</details>

---

<details>
<summary><strong>🧪 Auditoria + Suíte de testes (15/06/2026)</strong></summary>

Revisão de código + criação da suíte RSpec (o projeto tinha `rspec-rails`/`factory_bot`/`capybara` no Gemfile, mas **nenhum teste** e **nenhum `spec/`**).

### 🔴 Bugs corrigidos

**1. Erro de sintaxe em `Admin::UsuariosController`** — `toggle_ativo` tinha `entidade: \'User\'` (aspas escapadas com barra). O arquivo não carregava → qualquer acesso a `/admin/usuarios` dava 500. ✅ corrigido.

**2. Migration quebrava o setup do zero** — `20260610_add_fase2_fields_to_users.rb` readicionava a coluna `ativo` (já criada em `create_users`) sem guard → `PG::DuplicateColumn` num `db:migrate` limpo. ✅ guard `unless column_exists?` em `ativo` e `pin_acesso`.

**3. `db:seed` falhava no 2º motorista** — motoristas sem e-mail colidiam no índice único de `email` (`default ""`). ✅ seeds agora geram placeholder único (`nome.parameterize@motorista.local`).

**4. Dashboard ignorava o filtro de conta Gade** — `DashboardController` e `HistoricoEstimado.{atualizar!,calcular_ao_vivo}` contavam entregas de **todas** as contas. ✅ aplicado `.da_conta_gade`.

**5. `valor_total` com duas fontes de verdade** — o controller somava `valor_aplicado` (capturado na classificação) e o callback do model recalculava pelos **preços atuais** de `Configuracao` → divergência quando os preços mudavam. ✅ `ConsolidacaoMotorista#recalcular_valor` agora usa `valor_aplicado` (fonte única).

**6. Gate de finalização + classificação sem validação**
- `todas_entregas_classificadas?` usava soma agregada com `>=`, mascarando motoristas pendentes quando outro tinha entregas de sobra. ✅ agora exige cada motorista completo (`all?`) e respeita o filtro de rotas.
- `classificar`/`classificar_em_massa` aceitavam `tracking_id`/`motorista` arbitrários. ✅ validam que o motorista pertence à consolidação e que a entrega é elegível no período/rota.

**7. Coluna órfã `pin_acesso`** — nunca usada (o sistema usa `pin_code`). ✅ removida via migration.

**Minors:** motorista logado conseguia abrir `/dashboard` (✅ redireciona ao painel do motorista); `Date.parse(params[:data])` sem rescue dava 500 (✅ cai em `Date.current`); `arquivar!` não registrava autor (✅ novas colunas `arquivado_por`/`arquivado_em` + `belongs_to :arquivador`); `MotoristaController` + `app/views/motorista/index.html.erb` órfãos e quebrados (`current_user.name`, `where(user:)`, status inexistentes) — ✅ removidos.

### 🆕 Migrations adicionadas (rodar `db:migrate`)
```
db/migrate/20260615000001_remove_pin_acesso_from_users.rb
db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb   # arquivado_por / arquivado_em
```

### ✅ Suíte de testes
```
.rspec
config/database.yml          # ENV-driven, com banco de teste separado (<DB_NAME>_test)
spec/spec_helper.rb · spec/rails_helper.rb
spec/support/{pundit,devise}.rb
spec/factories.rb
spec/models/{user,configuracao,consolidacao,consolidacao_motorista,consolidacao_entrega,auditoria_log}_spec.rb
spec/policies/consolidacao_policy_spec.rb
spec/requests/dashboard_spec.rb
```

Como rodar:
```bash
docker-compose up -d
docker-compose exec app env RAILS_ENV=test bundle exec rails db:create db:migrate
docker-compose exec app bundle exec rspec
```

> Os specs que dependem da tabela read-only `db_reem_simplerout_2026` stubam `Entrega` — o banco de teste não contém essa tabela externa.

</details>

---

<details>
<summary><strong>🔧 Correções + funcionalidades (16/06/2026)</strong></summary>

### 🔴 Erros de runtime corrigidos (reportados em prints)
- **Logout** dava `No route matches [GET] /auth/logout` — o link usava `method: :delete` (rails-ujs), ignorado pelo Turbo. Agora usa `data-turbo-method`.
- **/admin/usuarios** e **/admin/configuracoes** estouravam `NoMethodError: admin_ou_gerente?` — helpers de papel (`admin?`, `admin_ou_gerente?`) movidos para a `ApplicationPolicy`.
- **Listagem de usuários** chamava o helper inexistente `badge_role` — criado em `ApplicationHelper`.
- **Nova consolidação** estourava `NoMethodError: new?` — `ApplicationPolicy` agora define `new? = create?` e `edit? = update?`.
- **Login do motorista (PIN)** derrubava `/motorista` com `PG::UndefinedTable: relation "consolidacao_motorista" does not exist` — o inflector pt-BR resolvia o composto no singular. `self.table_name` fixado explicitamente em `Consolidacao`, `ConsolidacaoMotorista`, `ConsolidacaoEntrega` e `Configuracao`.
- **Listagem de usuários** faltava o helper `badge_status_usuario` e a rota `toggle_ativo` estava sem o prefixo `admin_` (verbo errado também) — corrigidos.
- **Passo 3 (revisão) da consolidação** ficava em branco: `GROUP BY tipo` combinado com `ORDER BY created_at` é inválido no PostgreSQL (`PG::GroupingError`, vira 500/tela branca em produção). Corrigido com `reorder(nil)` no count agrupado.

### 🆕 Funcionalidades
- **Dashboard com filtro de faixa de datas** (Flatpickr, modo range) no lugar da navegação por mês; o controller passou a consultar por período e a respeitar `.da_conta_gade`.
- **Regra de pagamento por checkout:** `Entrega.pagas` agora é `status='completed' AND checkout IS NOT NULL` (antes era `checkin`). Vale para dashboard, painel do motorista, job de 1h e elegibilidade das consolidações.
- **Consolidação por veículo:** o filtro/identificação passou de rota (`route_id`, UUID) para **veículo** (`vehicle`). Migration troca `consolidacoes.route_ids` por `vehicle_ids`.
- **Filtro de conta configurável:** `account_id` na tabela externa é texto e vinha vazio nos registros recentes (o dado de junho estava 100% em conta vazia, e o filtro fixo em `95907` zerava o dashboard). `DB_EXISTING_ACCOUNT_ID` agora aceita lista de contas ou `all`/vazio (sem filtro). Comparação feita como texto.
- **Múltiplos pilares por entrega:** na validação da consolidação cada entrega pode receber mais de um pilar ao mesmo tempo (ex.: Normal + Bônus + Retirada), somando os valores. Os toggles viraram multi-seleção (ligam/desligam por pilar); a unicidade passou a ser `[consolidacao_id, tracking_id, tipo]` e o progresso conta entregas distintas.
- **Tela de "Pilares de Preço"** passou a listar só os pilares de preço (Entrega/Retirada/Bônus/Desconto); Nome da Empresa e Notificações saíram dali (não são preço).

### ⚙️ Automação / diagnóstico
- `docker-compose.yml`: o boot roda `rails db:prepare` antes do servidor — **não precisa mais rodar `db:migrate` na mão** após o `up`.
- `rake reem:diagnostico`: checa a tabela externa por conta/período/status/checkout e o intervalo de datas disponível (útil para depurar dashboard vazio).

```bash
docker-compose exec app bundle exec rake reem:diagnostico
docker-compose exec app bundle exec rake reem:diagnostico INICIO=2026-02-01 FIM=2026-06-30
```

</details>

---

<details>
<summary><strong>🔧 Correções + funcionalidades (16/06/2026 — sessão 2)</strong></summary>

### 🔴 Bugs corrigidos
- **Dashboard zerado por filtro de conta:** `DB_EXISTING_ACCOUNT_ID` é uma **lista separada por vírgula**, não um trecho de SQL. Valor errado (ex.: `account_id IN ('95907', '')`) zerava tudo. Use `95907,` (vírgula no fim) para incluir também registros com conta vazia/NULL. O scope `Entrega.da_conta_gade` agora inclui `NULL` quando há token vazio na lista.
- **Dashboard travava ao voltar (só com F5):** era o cache de preview do Turbo servindo um snapshot congelado. Adicionado `turbo-cache-control: no-cache` na página e o gráfico Chart.js virou idempotente (`Chart.getChart(ctx)?.destroy()`).
- **500 ao criar motorista:** `users.email` era `NOT NULL` com índice único e default `""` — o 2º motorista sem e-mail colidia em `""` (`RecordNotUnique`). Agora a coluna permite `NULL` e e-mail em branco vira `nil` (`User#normalizar_email_em_branco`); no Postgres vários `NULL` convivem no índice único.
- **500 ao gerar relatório PDF** (`gerar_pdf_relatorio`): `@entregas.group(:tipo).count` com `.order(:created_at)` gerava `GROUP BY ... ORDER BY created_at` → `PG::GroupingError`. Corrigido com `reorder(nil)`. Também removidas as opções `color:` passadas ao `Prawn#text` (não suportadas — uso de `fill_color`).
- **Motorista não baixava o próprio extrato** ("sem permissão"): `ConsolidacaoPolicy#show?` exige `pode_consolidar?` (falso para motorista). Novo `autorizar_extrato!` no `ConsolidacoesController` — staff baixa qualquer um; motorista baixa só o próprio (PDF já filtrado pelo nome dele).
- **QR code do extrato não abria:** URL estava fixa em `https://`, mas o servidor roda em **http** (IP:porta). Novo `url_acesso` respeita o esquema do `APP_HOST` (sem esquema = http). Defina `APP_HOST` com o IP/host acessível pelo celular.

### 🆕 Funcionalidades
- **NF + Endereço no relatório PDF:** colunas separadas; a coluna **Tracking** foi substituída por **Veículo** (relatório e tela de revisão / passo 3).
- **Endereço nas telas da consolidação:** linha de endereço nos cards do passo 2 (validação) e coluna Endereço no passo 3 (revisão, paginado 20/50/100).
- **Campos de assinatura nos PDFs:** bloco com assinatura do **Motorista** e do **Administrador** (Reem Transporte) no relatório e no extrato (`BasePdf#assinaturas`).
- **Selecionar todos** no passo 2: checkbox que marca/desmarca todas as entregas de uma vez.
- **Desselecionar em massa:** toggle "Modo remover" — os botões Normal/Retirada/Bônus/Desconto (e "marcar todos") passam a **remover** o pilar das entregas selecionadas (`classificar_em_massa` aceita `acao: remover`).
- **Botões de voltar** entre os passos do wizard (2→1, 3→2) viraram botões visíveis (antes eram texto cinza discreto).

### 🆕 Migration adicionada (rodar `db:migrate`)
- `20260616000003_allow_null_email_for_motoristas` — `users.email` passa a permitir `NULL`; e-mails `""` existentes viram `NULL`.

```bash
docker-compose exec app bundle exec rails db:migrate
```

</details>

---

<details>
<summary><strong>🗄️ Banco existente — referência rápida</strong></summary>

| Campo | Uso |
|-------|-----|
| `tracking_id` | PK lógico da entrega |
| `reference_id` | Número da NF |
| `planned_date` | Data da entrega (filtro principal) |
| `driver` | Nome do motorista |
| `status` | `'completed'` = entrega concluída |
| `checkout` | NULL = motorista não finalizou (define elegibilidade de pagamento) |
| `vehicle` | Veículo da entrega (usado no filtro de consolidação) |
| `route_id` | UUID da operação (não usado no app — substituído por `vehicle`) |
| `contact_name` | Nome do local (UBS SACOMA, EMAD VELEIROS…) |

**Regra de pagamento:** `status = 'completed'` AND `checkout IS NOT NULL`

</details>

---

<details>
<summary><strong>🎨 Design — padrão da marca</strong></summary>

| Elemento | Cor | Hex |
|----------|-----|-----|
| Fundo | Preto | `#0a0a0a` |
| Cards | Cinza escuro | `#1a1a1a` |
| Destaque / botões | Laranja | `#f97316` |
| Textos | Branco | `#ffffff` |
| Sucesso | Verde | `#22c55e` |
| Erro/desconto | Vermelho | `#ef4444` |

- Fonte mínima 16px desktop / 18px mobile
- Botões mínimo 48px altura (acessibilidade touch)
- Tema escuro como padrão

</details>

---

<details>
<summary><strong>👥 Perfis de acesso</strong></summary>

| Perfil | Login | Acesso |
|--------|-------|--------|
| Admin | email + senha | Tudo |
| Gerente | email + senha | Dashboard + Consolidações + PDFs |
| Operador | email + senha | Consolidações (criar + validar) |
| Motorista | PIN 4 dígitos ou QR | Painel pessoal + baixar próprio PDF |

</details>

---

<details>
<summary><strong>🩺 Correção (11/06): container caía mostrando ajuda do "rails new"</strong></summary>

**Sintoma:** `docker-compose up` subia e o log mostrava o help do `rails new` em loop.
**Causa:** o repositório tinha os arquivos da aplicação (models/controllers/views) mas **não tinha o esqueleto de boot do Rails** — sem `config/application.rb` e `config.ru`, o comando `rails s` não reconhece a pasta como um app Rails e cai no modo "criar app novo".

**Arquivos adicionados nesta correção:**
```
config.ru                              # entrada Rack
config/boot.rb, environment.rb         # boot do Rails
config/application.rb                  # módulo GadeLogistica + timezone SP + pt-BR
config/puma.rb                         # servidor web
config/environments/{development,production,test}.rb
config/importmap.rb                    # pins do Stimulus/Turbo
config/initializers/devise.rb          # obrigatório para o devise_for das rotas
config/locales/pt-BR.yml               # formatos de data brasileiros
bin/rails, bin/rake                    # executáveis
app/javascript/application.js          # bootstrap Turbo + Stimulus
app/javascript/controllers/{application,index}.js
Gemfile                                # + gem propshaft (servir o JS via importmap)
app/views/layouts/application.html.erb # + javascript_importmap_tags (Stimulus não carregava!)
config/routes.rb                       # devise_for agora aponta para users/sessions (login PIN)
```

### Como subir agora (passo a passo completo)

```bash
# 1. Criar o .env (você NÃO tem ele ainda — é cópia do exemplo):
cp .env.example .env

# 2. Gerar a SECRET_KEY_BASE e colar no .env:
openssl rand -hex 64
nano .env   # cole em SECRET_KEY_BASE= e preencha DB_HOST/DB_USER/DB_PASSWORD

# 3. Rebuild (Gemfile mudou — gem propshaft):
docker-compose build --no-cache
docker-compose up -d

# 4. Criar banco do sistema + migrar + popular:
docker-compose exec app bundle exec rails db:create db:migrate db:seed

# 5. Conferir:
docker-compose logs -f app    # deve mostrar "Listening on http://0.0.0.0:3000"
# Acesse http://SEU_IP:3000 → admin@gade.com / Gade@2026!
```

</details>

---

<details>
<summary><strong>🔐 Segurança</strong></summary>

- **NUNCA** versione `.env`, `config/master.key` ou qualquer arquivo com senha
- Repositório **PRIVADO** em git.xenserver.com.br
- Use sempre `ENV['VARIAVEL']` no código Ruby

</details>

---

<details>
<summary><strong>🆕 Atualização 17/06/2026 — Pagamentos, Operações, Dashboard financeiro e UI</strong></summary>

Conjunto de melhorias para deixar o sistema pronto para entrega final.

### 💳 Status de pagamento (Pago / Parcial / Pendente)
- Migration `add_pagamento_to_consolidacao_motoristas`: colunas **`pago_em`**, **`pago_por`**,
  **`forma_pagamento`** em `consolidacao_motoristas` (pagamento é **por motorista**).
- `ConsolidacaoMotorista`: `pago?`, `marcar_pago!(user, forma:)`, `cancelar_pagamento!`,
  scopes `pagos`/`pendentes`, `belongs_to :pagador`.
- `Consolidacao#status_pagamento` (`:pendente`/`:parcial`/`:pago`), `valor_pago`, `valor_pendente`,
  scopes `pagamento_pago`/`pagamento_pendente`/`pagamento_parcial` (filtro da lista).
- Tela **show**: marcar motorista individual **ou a operação inteira** (com `<select>` de forma);
  estorno. Ações `registrar_pagamento`/`cancelar_pagamento` (admin/gerente).
- Helper `badge_pagamento`; filtro pago/pendente na **index**; selo no **extrato**.
- **Notificação de pagamento**: `NotificacaoService.notificar_pagamento` (WhatsApp + e-mail
  `ConsolidacaoMailer#pagamento_efetuado`) — falhas só logam.
- **Aviso in-app no painel do motorista**: faixa "Pagamento confirmado" (últimos 7 dias) + selo
  PAGO por consolidação.

### 🗂️ Consolidações por OPERAÇÃO (tabelas externas `gade_entregas_*`)
- PORO **`app/models/operacao.rb`**: descobre as tabelas via `information_schema`, com
  **whitelist anti-injection** (`Operacao.sanitizar` + `quote_table_name`) — nunca interpolar
  nome de tabela cru. `Operacao.todas`, `dados(tabelas)`, `agrupadas_por_mes`.
- `Entrega.da_operacoes(tabelas)`: junta por NF (`reference_id::text = nota_fiscal`).
- Coluna **`operacoes` (jsonb)** em `consolidacoes`; `Consolidacao.com_operacoes(ops)`.
- **Nova consolidação**: multi-seleção de operação **pré-preenche** motoristas/veículos/período
  e **filtra** as entregas por NF; liberdade de marcar **um ou todos** os motoristas da operação.
- ENV opcional `OPERACOES_TABLE_PREFIX` (default `gade_entregas_`).

### 📊 Dashboard financeiro
- **Filtro de operação** no topo (multi-seleção, agrupada por mês, com "Limpar") aplicado a tudo.
- KPIs: **Custo total**, **Ticket médio/entrega**, **Pago**, **A pagar**.
- Gráficos (Chart.js): **custo por operação**, **composição por tipo**, **custo por motorista**,
  **rosca pago × a pagar** + tabelas (pagos por data de pagamento, pendentes).

### 📅 Correção de data — usar `checkout` (data real), não `planned_date`
- `Entrega.no_periodo_checkout(inicio, fim)`: filtro pela **data real da conclusão**, com
  **comparação naïve** (intervalo meio-aberto `>= início` e `< fim+1`), **sem conversão de fuso**
  — igual à análise feita direto no banco.
- Aplicado em `contar_pagas`, elegibilidade do wizard, gráfico principal (`DATE(checkout)`) e na
  exibição da data das entregas.

### 🧾 PDFs (Relatório e Extrato)
- **Relatório**: endereço completo com quebra de linha, larguras de coluna fixas, quebra de
  página antes do RESUMO. **Rodapé via `canvas`** (na margem inferior — não sobrepõe mais o
  conteúdo); margem inferior 72.
- **Extrato**: coluna **Unit.**, os 4 pilares sempre exibidos, **total de entregas + veículos**,
  **selo de pagamento** (PAGO/PENDENTE) e **garantia de espaço para o QR Code**
  (`start_new_page` se faltar espaço — o Prawn não pagina imagem sozinho).

### 🎨 UI / Identidade visual
- **Fonte base maior** para leitura: `html { font-size: 17px }` (desktop) e `18px` (mobile).
- **Contraste** elevado no dashboard (`text-gray-500/600` → `text-gray-400`).
- **Painel do motorista** redesenhado mobile-first (fontes maiores, botões full-width, alvos ≥48px).
- **Favicon** (van laranja): `public/favicon.png`.
- **Logo da Reem** na barra (sidebar + topo mobile): `public/logo-reem.png`.
- **Animação de abertura (splash) após login**: vídeo `public/login-animacao.{webm,mp4}` (WebM +
  MP4 fallback), exibido **uma vez** via flag de sessão (`session[:mostrar_splash]`), com
  `mix-blend-mode: screen` para "derrubar" o fundo preto do vídeo.
- `rack-mini-profiler` desativado em `config/initializers/rack_mini_profiler.rb`.

### 📌 Padrões a seguir (novos)
- **Tabelas externas / read-only**: acesso só por SELECT; nome de tabela em SQL **sempre** via
  whitelist + `quote_table_name` (ver `Operacao`).
- **Datas financeiras**: usar **`checkout`** (`no_periodo_checkout`), comparação naïve sem fuso.
- **Gráficos**: Chart.js via CDN, padrão **IIFE** + `Chart.getChart(ctx)?.destroy()` (evita
  "Canvas already in use" com Turbo).
- **Badges**: `badge_status` (consolidação) e `badge_pagamento` (pagamento).
- **Assets estáticos** (favicon, logo, vídeos) ficam em `public/` e são referenciados por caminho
  absoluto (`/arquivo.ext`).

### Migrations adicionadas hoje
```
20260617000001_add_pagamento_to_consolidacao_motoristas.rb
20260617000002_add_operacoes_to_consolidacoes.rb
```
Aplicar com: `docker-compose exec app bundle exec rails db:migrate`

</details>

---

<details>
<summary><strong>🔧 Correções — Extrato PDF (18/06/2026)</strong></summary>

### 🔴 Bugs corrigidos
- **500 ao gerar extrato** (`gerar_pdf_extrato`): `Prawn::Errors::IncompatibleStringEncoding`
  (`Encoding::UndefinedConversionError`). Os PDFs usavam a fonte **embutida** do Prawn
  (Helvetica/AFM), que só aceita o charset **Windows-1252** — o caractere **`✓`** do selo
  "✓ PAGO" não existe nesse charset e derrubava a geração.
  ✅ **Corrigido:** `BasePdf` agora registra a família **DejaVu Sans (TTF)** e a define como
  fonte padrão (`registrar_fonte_utf8`), com suporte total a **UTF-8**. Vale para todos os PDFs
  (extrato e relatório) — qualquer caractere Unicode passa a funcionar.
  - Fontes versionadas em `app/assets/fonts/DejaVuSans.ttf` e `DejaVuSans-Bold.ttf`
    (a imagem Docker `ruby:3.2.2-slim` não traz fontes do sistema; o `COPY . .` do Dockerfile e
    o volume `.:/app` garantem que estejam disponíveis em runtime).
- **Assinaturas do extrato caíam na 2ª página:** após o QR Code o cursor ficava abaixo do
  limite de quebra (`start_new_page if cursor < 120`), empurrando o bloco de assinaturas para
  uma página nova. ✅ **Corrigido:** espaçamentos compactados com segurança (padding das
  tabelas `[4,8]`, QR Code menor) + folga antes das assinaturas reduzida (`BasePdf#assinaturas`,
  60 → 36, limite de quebra 120 → 100) — **o extrato agora cabe em uma única página**.
- **QR Code sobrepondo / sumindo:** o posicionamento por `move_up`/`move_down` era frágil e,
  com a fonte nova, fazia o QR se sobrepor ao texto/assinaturas. ✅ **Corrigido:**
  `desenhar_qrcode` agora desenha a imagem com **posição absoluta (`at:`)** — que não move o
  cursor — e avança o cursor manualmente pela altura da imagem. Também garantido que cada caixa
  (selo de pagamento, total) tenha folga **≥ a própria altura** para não sobrepor o conteúdo
  seguinte.

- **QR Code não aparecia para alguns motoristas:** o QR aponta para o painel do motorista
  (`/motorista/acesso/:token`), que **exige uma conta de usuário** (`User` motorista com
  `login_token`). Sem conta correspondente, `user_motorista` ficava `nil` e a seção do QR era
  **omitida silenciosamente**. Duas causas tratadas:
  - **Nome não batia** (espaços/maiúsculas) entre `consolidacao_motoristas.motorista_nome`
    (dado externo) e `users.nome` (digitado no admin). ✅ `ConsolidacoesController#buscar_user_motorista`
    agora tenta o match exato e, se falhar, compara nomes **normalizados** (sem espaços
    extras/duplos, case-insensitive).
  - **Motorista sem conta:** ✅ em vez de omitir, o extrato agora desenha um **aviso**
    ("Cadastro de acesso ainda não disponível… solicite ao administrador") no lugar do QR
    (`ExtratoPdf#desenhar_aviso_sem_acesso`). O QR só existe para motoristas **com cadastro**
    no site — comportamento esperado, já que sem conta não há painel para acessar.

### 📌 Padrão (novo)
- **PDFs com Prawn**: usar **sempre** a fonte TTF registrada no `BasePdf` (DejaVu, UTF-8). Nunca
  contar com a fonte embutida — ela quebra em qualquer caractere fora do Windows-1252.

</details>

---

<details>
<summary><strong>🆕 Atualização 19/06/2026 — Apontamento manual / Nota avulsa</strong></summary>

Permite incluir numa consolidação uma **entrega que chegou por fora** (ex.: nota que entrou
depois e não está no banco mensal das operações). O usuário digita o **número da nota (NF =
`reference_id`)**, o sistema busca os dados direto na `db_reem_simplerout_2026` (atualizada a cada
hora pela SimpleRoute) e adiciona a entrega ao fechamento do motorista.

> A tabela externa continua **read-only** — o apontamento só **lê** dela (`Entrega.por_nf`,
> sem filtro de período, restrito à conta Gade via `da_conta_gade`).

### 🆕 Funcionalidades
- **Apontamento dentro de uma consolidação existente** (wizard Passo 1): botão **"➕ Apontamento
  manual"** → modal busca a NF → confere os dados (motorista/veículo/data) → escolhe **um ou
  vários pilares** (Normal/Retirada/Bônus/Desconto) → adiciona.
- **"Nota avulsa"** (tela de Consolidações): botão **"➕ Nota avulsa"** → busca a NF → ao
  prosseguir **cria já a consolidação** (nome `Avulsa nf<NF>`, período = data da entrega → fim do
  mês, motorista vindo do `driver` da entrega) com o apontamento, e abre a tela da consolidação.
- **Múltiplas classificações** por apontamento: cria um pilar (`ConsolidacaoEntrega`) por tipo
  escolhido, somando os valores (mesmo modelo multi-pilar das entregas normais).
- **Motorista automático**: vem do `driver` da entrega; se ainda não estiver na consolidação, é
  incluído.
- **Gestão na tela da consolidação (show)**: seção **"➕ Apontamentos manuais"** lista NF,
  motorista, pilares e valor, com botão de **remover** (a nota inteira). Na revisão (Passo 3) os
  apontamentos aparecem com o selo **"➕ Apontamento"** e botão de remover (por pilar).

### ⚙️ Como funciona (técnico)
- Coluna **`manual` (boolean, default false)** em `consolidacao_entregas` distingue apontamento de
  entrega elegível do período. `Consolidacao#classificadas_count` ignora os `manual: true` para
  **não estourar a barra de progresso** nem o gate de finalização (`>= 100%`).
- Lógica centralizada em **`Consolidacao#adicionar_apontamento!(entrega, tipos, user)`** e
  **`Consolidacao#recalcular_motorista!`** — reusadas pelos dois fluxos (wizard e avulsa). Preço por
  pilar via `ConsolidacaoEntrega.valor_para(tipo)` (fonte única). Dados da entrega via
  `Entrega#resumo_apontamento`.
- Rotas novas:
  - `consolidacoes` (coleção): `GET buscar_nf` (JSON), `POST avulsa`.
  - `consolidacao_entregas` (coleção): `GET buscar_nf`, `POST apontar`, `DELETE remover_apontamento`
    (aceita `?id=` para um pilar ou `?tracking_id=&motorista=` para a nota toda).
- Stimulus: `apontamento_controller.js` (modal do wizard) e `nota_avulsa_controller.js` (modal da
  index). ⚠️ Identificador do Stimulus segue o nome do arquivo em **dash-case**: `nota_avulsa_controller.js`
  → `data-controller="nota-avulsa"` / `data-action="nota-avulsa#..."` (e o elemento da ação precisa
  estar **dentro** da `div` do controller).

### 🆕 Migration adicionada (rodar `db:migrate`)
```
20260619000001_add_manual_to_consolidacao_entregas.rb
```

```bash
docker-compose exec app bundle exec rails db:migrate
```

### 🧪 Testes
- `spec/models/consolidacao_spec.rb`: `classificadas_count` ignora apontamentos manuais e
  `adicionar_apontamento!` (multi-tipo, idempotente, inclui o motorista e soma o valor).

</details>

---

<details>
<summary><strong>🆕 Atualização 22/06/2026 — Pilar Extraordinária, Perfil Externo, Falhadas, Relatórios e Segurança QR</strong></summary>

### 🆕 Funcionalidades
- **Pilar "Entrega Extraordinária"** — preço coringa para entregas fora do planejamento.
  - Novo valor `extraordinaria` no `enum tipo` de `ConsolidacaoEntrega` (soma positiva, como Bônus). Sem migration de enum (coluna `integer`).
  - Aparece em **todas** as telas dos pilares: wizard (passo 1), validação, revisão, nota avulsa, extrato, PDFs e composição do dashboard.
  - **Preço padrão configurável** em *Configurações* (`preco_extraordinaria`) **e** **valor customizável por entrega**: ao marcar "Extra" no wizard abre um prompt já preenchido com o padrão (`valor_classificacao` no controller + `pedirValor()` no Stimulus).
- **Card "Consolidado / Pago"** no topo do dashboard (total consolidado + total já pago do período; reusa `@fin_custo_total` / `@fin_pago`).
- **Entregas falhadas no dashboard** — o card "Total Entregas" agora separa `entregues · pendentes · falhadas`.
  - Novo `Entrega::STATUS_FALHA` + scope `falhadas`; o scope `pendentes` foi ajustado para não sobrepor (nem `completed` nem falha).
- **Relatórios financeiros em PDF (admin):**
  - Por **consolidação** — `Pdf::RelatorioFinanceiroConsolidacaoPdf` (botão "📊 Relatório financeiro" na tela da consolidação).
  - Por **período** — `Pdf::RelatorioFinanceiroPeriodoPdf` (botão "🖨️ Relatório" no dashboard, respeita o filtro de período/operação).
- **Novo perfil de usuário: "Externo (só dashboard)"** (`role: externo`).
  - Login normal por e-mail/senha; enxerga **apenas o dashboard**. Bloqueado nas demais áreas pelas policies Pundit (`pode_consolidar?`/admin). Ideal para enviar a outras gerências visualizarem os indicadores.
- **Filtro de data re-estilizado** — calendário no tema escuro (flatpickr dark + acento laranja) e atalhos rápidos: **Hoje / 7 dias / Este mês / 30 dias**.

### 🔴 Bugs corrigidos
- **Botão "Arquivar"** não funcionava em consolidação zerada / rascunho. `Consolidacao#arquivar!` e `#reativar!` passaram a usar `update_columns` (pulam validações e o callback de recálculo) — arquivar é soft-delete administrativo e deve sempre funcionar. `turbo_confirm` movido para o `<form>` (mais confiável).
- **Caixa do filtro de data** mostrava uma data solta em vez do período. Causa: o flatpickr recebia datas em ISO (`YYYY-MM-DD`) com `dateFormat: 'd/m/Y'`. Corrigido passando objetos `Date` + separador `" até "`; seleção de **um único dia** agora aplica; re-inicialização em `turbo:load`.
- **Moeda sem separador de milhar** — padronizado **`R$ 1.234,56`** (milhar `.`, decimais `,`) em: helper `moeda` (`number_to_currency`), PDFs (`base_pdf`), `Configuracao#valor_formatado`, mensagens do `NotificacaoService` e tooltips/JS do dashboard e do wizard.
- **R$ "quebrado" no painel do motorista** (celular) — os valores grandes quebravam "R$" e o número em linhas separadas. Adicionado `whitespace-nowrap` + fonte responsiva (`text-4xl sm:text-5xl`) nos cards.

### 🔐 Segurança
- **QR Code do extrato não loga mais direto.** Antes, ler o QR (`/motorista/acesso/:token`) autenticava o motorista sem PIN. Agora o QR apenas **identifica** o motorista e leva à tela de PIN; ele precisa digitar o **PIN de 4 dígitos** para entrar — e o PIN tem que ser **do mesmo motorista do QR** (impede usar o QR de outra pessoa). A tela saúda pelo nome quando vem do QR.

### 🆕 Migration adicionada (rodar `db:migrate`)
```bash
docker-compose exec app bundle exec rails db:migrate
```
- `20260622000001_add_preco_extraordinaria_configuracao` — cria a config `preco_extraordinaria` (padrão `R$ 25,00`) em bancos já existentes (idempotente; não sobrescreve valor já definido).

### ✅ Confirmado (25/06/2026)
- O status de falha gravado pelo SimpleRoute é exatamente **`failed`** — `Entrega::STATUS_FALHA = %w[failed]` está correto e dispensa ajuste.
  ```bash
  docker-compose exec app bundle exec rails runner "puts Entrega.da_conta_gade.distinct.pluck(:status).inspect"
  ```

</details>

---

<details>
<summary><strong>🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local)</strong></summary>

A consolidação só considerava entregas **concluídas com sucesso**. Como o motorista
**se desloca até o local mesmo nas entregas que falham (insucesso)**, essas precisam
**constar na consolidação** para serem classificadas e remuneradas conforme o caso.

### 🆕 Funcionalidades
- **Novo conjunto elegível "atendidas"** — entregas em que o motorista foi ao local:
  status **`completed` (sucesso) OU `failed` (insucesso)**, sempre com **checkout** registrado.
  Substitui o antigo critério de só `.pagas` (`completed`) na consolidação.
- **Status visível em cada entrega** na tela de validação (Passo 2): selo **✓ Entregue** (verde)
  ou **⚠️ Insucesso** (vermelho), com o card de insucesso destacado em **borda vermelha** —
  facilita visualmente identificar o que classificar.
- **Insucesso entra para classificação manual**, não com valor automático: o admin escolhe o
  pilar/valor (Normal, Retirada, Bônus, Desconto, Extraordinária) — o motorista foi ao local,
  então a entrega aparece na lista, na contagem e na barra de progresso.

### ⚙️ Como funciona (técnico)
- `Entrega::STATUS_ATENDIDO = (%w[completed] + STATUS_FALHA)` + scope **`atendidas`**
  (`where(status: STATUS_ATENDIDO).com_checkout`). Novo helper de instância `Entrega#falhada?`.
- `Entrega.contar_pagas` → renomeado para **`Entrega.contar_atendidas`** (usa o scope `atendidas`).
- `Consolidacao#entregas_elegiveis_count` passa a contar **atendidas** — reflete no wizard, na
  barra de progresso e no **gate de finalização** (`todas_entregas_classificadas?`): agora as
  entregas de insucesso também precisam ser classificadas antes de finalizar.
- `ConsolidacaoEntregasController`: a lista do Passo 2 (`validar`) e o guard de elegibilidade
  (`tracking_ids_elegiveis`) usam `Entrega.atendidas`.
- **Não afeta o lado financeiro:** dashboard, painel do motorista, job de 1h e `HistoricoEstimado`
  continuam usando `.pagas` (só `completed`) — "atendidas" é exclusivo da elegibilidade da
  consolidação.

### 📂 Arquivos alterados
```
app/models/entrega.rb                               # STATUS_ATENDIDO, scope :atendidas, contar_atendidas, falhada?
app/models/consolidacao.rb                          # entregas_elegiveis_count usa contar_atendidas
app/controllers/consolidacao_entregas_controller.rb # validar + tracking_ids_elegiveis usam :atendidas
app/views/consolidacao_entregas/validar.html.erb    # selo de status por entrega + destaque do insucesso
```

> Sem migration — a mudança é apenas de leitura/regra de negócio sobre a tabela read-only
> `db_reem_simplerout_2026`.

</details>

---

<details>
<summary><strong>🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente</strong></summary>

Dois recursos na consolidação:

### 1. Apontamento 100% manual (tela de Validar — Passo 2)
Caso real: o motorista **A** foi até o local mas **não** entregou; no rastreio que alimenta o
banco, a NF ficou registrada sob o motorista **B** (que de fato entregou). Logo, a busca por NF
nunca encontra o A — mas o A também recebe, pois foi ao local. O apontamento manual antigo
(busca a NF na base e puxa `entrega.driver`) não serve.
- **Novo lançamento 100% manual**, sem consultar a base de rastreio, atribuível a **qualquer
  motorista** (seletor com os motoristas da consolidação, ou digita um novo), com **NF (texto
  livre)**, **observação/local** e **pilar(es)** (+ valor da Extraordinária).
- Botão **"➕ Apontamento 100% manual"** na tela de Validar. Soma ao valor do motorista, mas
  **não** conta no gate `X/Y classificadas` (é `manual: true`, igual aos apontamentos por NF).
- A NF digitada aparece na Revisão (Passo 3), na tela da consolidação e no **Relatório PDF** do
  motorista (fallback `nf_manual`/`obs_manual` quando não há `Entrega` para consultar).

### 2. Arquivar motorista individualmente (reversível)
Numa consolidação em massa, dá para **arquivar** um motorista específico no Passo 1 (botão 🗄️
por card) — ele **sai dos totais, do pagamento e do gate**, mas os dados ficam guardados. Seção
**"🗄️ Motoristas arquivados"** no fim do wizard com **"♻️ Restaurar"**.
- **Não** arquiva motorista **já pago** (estorne antes) — bloqueado com alerta.
- Útil para finalizar a consolidação ignorando um motorista que não deveria estar nela / sem
  entregas, sem perder o registro.

### ⚙️ Como funciona (técnico)
- **Migrations** (rodar `db:migrate`):
  - `20260625000001_add_arquivamento_to_consolidacao_motoristas` — `arquivado_em` / `arquivado_por`.
  - `20260625000002_add_manual_fields_to_consolidacao_entregas` — `nf_manual` / `obs_manual`.
- `Consolidacao#adicionar_apontamento_manual!(motorista:, tipos:, user:, nf:, obs:, valor_extra:)`
  — gera `tracking_id` sintético (`"MAN-<uuid>"`) compartilhado pelos pilares (agrupa como UMA
  entrega); reusa `recalcular_motorista!`. Action `apontar_manual` + rota + Stimulus
  `apontamento_manual_controller.js`.
- `ConsolidacaoMotorista`: scopes `ativos`/`arquivados`, `arquivar!`/`desarquivar!`, `arquivado?`.
  Todo o "conjunto vigente" passou a usar **`.ativos`** (totais, pagamento, gate, notificações,
  PDFs financeiros, painel do motorista e filtros de pagamento do dashboard) — o arquivado não
  entra em nenhum agregado. Actions `arquivar_motorista`/`desarquivar_motorista` (autorização
  `update?`; bloqueiam motorista já pago).

### 📂 Principais arquivos
```
db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb
db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb
app/models/consolidacao.rb · consolidacao_motorista.rb
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
app/controllers/dashboard_controller.rb · motorista/dashboard_controller.rb
app/javascript/controllers/apontamento_manual_controller.js   (novo)
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/views/consolidacoes/wizard.html.erb · show.html.erb
app/services/notificacao_service.rb · pdf/relatorio_motorista_pdf.rb · pdf/relatorio_financeiro_consolidacao_pdf.rb
config/routes.rb
spec/models/consolidacao_spec.rb · consolidacao_motorista_spec.rb
```

```bash
docker-compose exec app bundle exec rails db:migrate
docker-compose exec app bundle exec rspec
```

</details>

---

<details>
<summary><strong>🆕 Atualização 25/06/2026 — Fechar por veículo na tela de Validar</strong></summary>

Um motorista pode usar **vários veículos** no período. A tela de Validar (Passo 2) mostrava tudo
junto e o **"Marcar todos como Normal"** marcava **todas** as entregas do motorista. Agora dá
para **fechar cada carro individualmente**.

### 🆕 Funcionalidades
- **Filtro por veículo** (chips "🚗 Fechar por veículo") na tela de Validar — só aparece quando o
  motorista usou **mais de um veículo**. Clicar num carro filtra a lista para aquele veículo; a
  **barra de progresso** e o **"Marcar todos"/seleção** passam a valer **só para o carro
  selecionado**. Há um chip **"Todos"** para a visão completa.
- **Status "fechado" por carro**: cada chip mostra `classificadas/elegíveis` e fica **verde com ✓**
  quando o carro está 100% classificado — atualiza em tempo real (sem recarregar) ao classificar.
- A **finalização continua por motorista** (quando todos os carros de todos os motoristas estiverem
  prontos) — o gate não mudou.

### ⚙️ Como funciona (técnico)
- `ConsolidacaoEntregasController#veiculos_status(motorista)` → `[{ vehicle, eligible,
  classificadas, fechado }]` (2 queries: elegíveis com a coluna do veículo + classificadas
  não-manuais). Reusa o scope `Entrega.da_veiculo`. Grupo "sem veículo" via sentinela `SEM_VEICULO`.
- `validar` aceita `params[:vehicle]`: recorta `@entregas` e, quando há carro selecionado, os totais
  da barra (`@total_entregas`/`@classificadas`) vêm do `veiculos_status` daquele carro.
- `tracking_ids_elegiveis(motorista, vehicle = nil)` e `classificar_em_massa` passam o veículo —
  é o que faz **"Marcar todos" fechar só o carro**. `resumo_json(motorista, vehicle:)` devolve
  `classificadas_escopo` (barra por carro) + `veiculos_status` (refresh dos chips).
- `validacao_controller.js`: value `veiculo`, target `chipVeiculo`, `vehicle` nos POSTs,
  `atualizarChips()` (verde/✓ + contadores). Sem migration — só leitura + UI.

### 📂 Arquivos
```
app/controllers/consolidacao_entregas_controller.rb  # veiculos_status, validar, tracking_ids_elegiveis, classificar_em_massa, resumo_json
app/views/consolidacao_entregas/validar.html.erb     # chips por veículo + barra com escopo
app/javascript/controllers/validacao_controller.js   # value veiculo, chips, classificadas_escopo
```

</details>

---

<details>
<summary><strong>🆕 Atualização 26/06/2026 — Entrega de termo, caixa de ferramentas, gestão de motoristas e UX da Validação</strong></summary>

Pacote de implantação focado em **agilizar a precificação** e **dar feedback direto** ao usuário no
fluxo de consolidação. Resumo das frentes:

### 1. 📄 Entrega de termo (entrega sem NF nem código de rastreio)
Lançamento de **termos** entregues por um motorista — não têm nota fiscal nem `tracking_id` para
atrelar, só **motorista + quantidade** a um **preço fixo configurável**.
- Novo pilar `tipo: termo` (enum 5, cor azul) e novo preço **"Entrega de Termo"** editável em
  **Admin → Configurações** (entra automático no card, via `CHAVES_MOEDA`).
- Lançado como **uma linha por lote** com a coluna nova **`quantidade`**; `valor_aplicado` guarda o
  total do lote (`quantidade × preço`), então `recalcular_motorista!` e todas as somas existentes
  seguem corretas. Modal com **stepper − N +** (sem as setinhas nativas do `input number`).
- Aparece na lista de **Apontamentos manuais** (badge "Entrega de Termo ×N"), nos PDFs (extrato,
  relatório do motorista e financeiro) e no painel de Resumo — sempre contando por **`quantidade`**
  (relatórios passaram de `count` para `sum(:quantidade)`).

### 2. 🧰 Caixa de ferramentas (toolbox) — botões unificados
- **Passo 2 (Validar):** os botões "Registrar entrega manualmente" e "Entrega de termo" viraram um
  único **"➕ Adicionar lançamento ▾"** com menu suspenso. (O antigo "Apontamento 100% manual" foi
  renomeado para **"Registrar entrega manualmente"** / modal "Registro manual de entrega".)
- **Passo 1 (Wizard):** o "Apontamento manual" virou **"➕ Adicionar / Ferramentas ▾"** com
  **Apontamento manual** + **Adicionar motorista**.
- Reusa um controller Stimulus mínimo `ferramentas_controller.js` (só o dropdown); os modais e
  controllers existentes não foram reescritos.

### 3. 👷 Gestão de motoristas na consolidação
- **Adicionar motorista** a uma consolidação já criada (ou **restaurar** se estava arquivado). O
  campo puxa os **motoristas previstos no período** (atendidas, respeitando filtros de veículo/
  operação) com os **veículos que cada um usou** — lista clicável + datalist.
- **Excluir motorista arquivado** (definitivo, **só admin/gerente**) — apaga os lançamentos dele
  (ligados por `motorista_nome`, sem cascade) e recalcula o total. Bloqueia se já pago. Arquivar
  continua sendo o caminho reversível.
- O **"← Voltar"** do wizard passou a ir para o **resumo da própria consolidação** (não mais a lista).

### 4. 📊 Resumo e barra de progresso com lançamentos manuais
- Painel **"📊 Resumo"** (Passo 2) ganhou linha **Termo** e linha **"Apontamentos manuais: N"**.
- **Barra de progresso** com **2 segmentos**: laranja→verde (elegíveis classificadas) + **azul**
  (lançamentos manuais), com texto "· +N manuais". O gate de finalização **não mudou**
  (`classificadas_count` segue `manual: false`) — é feedback visual e entra no valor.
- "Manuais" conta como **entregas**: cada termo vale sua `quantidade`; cada apontamento manual vale
  1 por nota (`tracking_id`).

### 5. 🧊 UX da tela de Validar
- **Cabeçalho congelado** (no desktop): header, barra de progresso, filtro por veículo, ações em
  massa e o botão de lançamento ficam fixos; **só a lista de NFs rola** (layout flex-column com a
  coluna da lista `overflow-y-auto`). Evitou-se `sticky`/`z-index` para não prender os modais
  (`fixed z-50`) atrás da sidebar (`z-40`).
- **Ações em massa simplificadas**: removidos o botão gigante **"Marcar todos como Normal"** e o
  checkbox **"Modo remover"**. Os botões de pilar em **"Aplicar nos selecionados"** agora são
  **toggle em massa** — aplicam o pilar nos selecionados e, se **todos** já o têm, removem de todos.

### ⚙️ Como funciona (técnico)
- **Migrations** (rodar `db:migrate`):
  - `20260626000001_add_quantidade_to_consolidacao_entregas` — `quantidade` (default 1).
  - `20260626000002_add_preco_termo_configuracao` — cria a config `preco_termo` (idempotente).
- `Consolidacao#adicionar_termos!(motorista:, quantidade:, user:)` — uma linha `TERMO-<uuid>`,
  `manual: true`. `Configuracao.preco_termo` + `ConsolidacaoEntrega.valor_para('termo')`.
- `ConsolidacoesController#adicionar_motorista` / `#excluir_motorista` (member) +
  `motoristas_previstos_no_periodo`. `ConsolidacaoEntregasController#apontar_termo`, `@manuais`,
  `contar_manuais`, `por_tipo` agora `sum(:quantidade)`.
- Stimulus novos: `apontamento_termo_controller.js`, `ferramentas_controller.js`,
  `adicionar_motorista_controller.js`. `validacao_controller.js`: barra com 2 segmentos, linhas
  Termo/Manuais e **toggle em massa** (`pilarAtivo`); removidos `marcarTodos`/`modoRemover`.

### 📂 Principais arquivos
```
db/migrate/20260626000001_add_quantidade_to_consolidacao_entregas.rb
db/migrate/20260626000002_add_preco_termo_configuracao.rb
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
app/javascript/controllers/apontamento_termo_controller.js   (novo)
app/javascript/controllers/ferramentas_controller.js         (novo)
app/javascript/controllers/adicionar_motorista_controller.js (novo)
app/javascript/controllers/validacao_controller.js
app/views/consolidacoes/wizard.html.erb · show.html.erb
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/services/pdf/extrato_pdf.rb · relatorio_motorista_pdf.rb · relatorio_financeiro_consolidacao_pdf.rb
config/routes.rb · db/seeds.rb
```

```bash
docker-compose exec app bundle exec rails db:migrate
```

</details>

<details>
<summary><strong>🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)</strong></summary>

Correção da **quebra de layout** dos cards de NF na tela **Passo 2 (Validar)** em telas pequenas e
em tablets, reportada nos prints `Imagens para correção/Imagem colada (16).png` e `(17).png`.

### 🔴 Bugs corrigidos
- **Botão "Extra" cortado no celular** *(print 16)* — a fileira dos 5 pilares
  (`Normal · Retirada · Bônus · Desc. · Extra`) usava `flex gap-1.5` **sem `flex-wrap`**, então não
  quebrava linha e o último botão estourava a borda do card. Agora os botões **quebram para a linha
  de baixo** quando não cabem.
- **Botões sobrepondo o texto da nota no tablet** *(print 17, ~800px)* — o card virava layout em
  linha já no breakpoint `md` (768px), **exatamente onde a sidebar de 256px passa a aparecer**
  (`md:ml-64`), espremendo o conteúdo. Sem `flex-wrap`, os botões transbordavam por cima do
  endereço/data/veículo. O ponto de virada foi movido para `lg` (1024px), onde há largura real.

### ⚙️ Como funciona (técnico)
- `app/views/consolidacao_entregas/validar.html.erb`:
  - Card da entrega: `flex flex-col md:flex-row md:items-center` → **`flex flex-col lg:flex-row
    lg:items-center`** (empilha no celular **e no tablet**; em linha só no desktop).
  - Grupo de toggles: `flex gap-1.5` → **`flex flex-wrap gap-1.5 lg:shrink-0`** (`flex-wrap` impede
    o transbordo; `lg:shrink-0` mantém os botões inteiros no desktop, deixando o texto da nota
    truncar em vez de espremer os botões).
- Faixa crítica resolvida (768–1023px): antes acumulava *sidebar visível + layout em linha + botões
  sem quebra*. Agora **< 1024px** o card fica empilhado com botões em `flex-wrap` e **≥ 1024px** vai
  para linha com truncamento do texto.
- Sem migration e sem mudança de JS/controller — alteração **somente de classes Tailwind** na view.

</details>

<details open>
<summary><strong>🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)</strong></summary>

Nova página **`/dashboard/operacoes`** (link **📈 Operações** no menu), separada do dashboard
financeiro, focada na **qualidade das entregas** por operação (UBS Norte, EMAD, …). Reproduz os
painéis das imagens de referência (`Imagens para implantação/`) e adiciona análise comparativa e mapa.
Acessível a todos menos motorista (`DashboardPolicy#operacoes?`).

### 🆕 Funcionalidades
- **3 modos na mesma página** (abas):
  - **🏥 Operação** — uma operação por vez. **Sem filtro de data**: mostra o conjunto inteiro da
    operação e o período exibido vem dos próprios dados (menor/maior `checkout`).
  - **🌐 Global** — todas as operações agregadas, aí sim com **faixa de datas** (flatpickr + atalhos
    Hoje/7d/Este mês/30d). A faixa de data **só vale no Global**.
  - **⚖️ Comparar** — duas operações lado a lado com bloco **Comparativo** (tabela A | B | Δ com o
    melhor valor de cada linha em verde + gráfico de barras agrupadas) e detalhe completo recolhível.
- **Painéis** (espelham as imagens 2 e 3): KPIs (Total / Sucesso / Recusas / Pendentes), donut
  **Insucessos %**, **Índices de Falha** (por `observation`), **Por Status** (status_gade
  RECORRENTE/NOVO), **NFs por Motorista**, **Entregas por STS/Unidade** (`contact_name`), **Entregas
  por Dia** (barras empilhadas) e **Mapa**.
- **Cross-filter ao vivo (toggle)** — clicar em qualquer linha das tabelas (Motorista, Unidade,
  Status, Observação) **refiltra todo o dashboard** por aquele valor; clicar de novo na linha ativa
  **remove** o filtro. Chips removíveis + "Limpar tudo" no topo.
- **Mapa de entregas** com:
  - alternância **🔥 Calor** (heatmap) ⇄ **📍 Pontos** (um balão por entrega na coordenada de
    **check-out**);
  - **camadas** 🌙 Escuro (padrão) / 🗺️ Claro / 🛰️ Satélite (todas grátis, sem chave);
  - **tela cheia** (Fullscreen API);
  - **popup** por entrega (NF, destinatário, endereço, unidade, motorista, data) com a **foto da
    fachada** (coluna `foto_da_fachada`), aberta em tamanho cheio ao clicar.
- **Cores** dos gráficos na identidade da marca: laranja `#f97316` (completas) e vermelho `#ef4444`
  (falhas); no comparativo A = laranja, B = azul.

### ⚙️ Como funciona (técnico)
- Núcleo de dados em **`app/services/analytics/operacao_metricas.rb`** (PORO). Monta um SQL que
  **espelha a query de gestão**: CTE `ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout
  DESC)` sobre `db_reem_simplerout_2026` + `INNER JOIN` na tabela `gade_entregas_*` por
  `reference_id::text = nota_fiscal`; agrega tudo em Ruby (visão `linhas` = registros após o
  cross-filter).
- **Sem filtro de conta** (`account_id`): o INNER JOIN com a tabela da operação já restringe ao
  cliente — replicar `da_conta_gade` zerava tudo quando `DB_EXISTING_ACCOUNT_ID` não batia.
- **Schema gade não-uniforme**: só `nota_fiscal` é garantida. Colunas opcionais (`status`,
  `nome_completo`, `endereco_completo`) são detectadas por tabela (`conn.columns`) e viram `NULL`
  quando não existem — assim o `UNION ALL` entre operações no Global não quebra
  (era o erro `column g.status does not exist`).
- **Segurança**: nomes de tabela passam por `Operacao.sanitizar` + `quote_table_name`; a `foto_da_fachada`
  é validada como URL `http(s)` antes de ir ao `<img>`, com escape de HTML no popup.
- **Performance do mapa**: marcadores criados **sob demanda** (só ao abrir "Pontos") e o popup é uma
  **função** — a foto do S3 só é requisitada **ao clicar** no balão (nada é baixado no load). Limite
  de 2000 marcadores.
- **Filtro de período** opcional no serviço (`inicio:`/`fim:`): aplicado só no Global.

### 📂 Arquivos
```
app/services/analytics/operacao_metricas.rb            # NOVO — núcleo de dados/agregações
app/controllers/operacoes_dashboard_controller.rb      # NOVO — modos + cross-filter
app/views/operacoes_dashboard/index.html.erb           # NOVO — filtros, abas, charts, mapa (JS)
app/views/operacoes_dashboard/_painel.html.erb         # NOVO — KPIs/donut/tabelas/gráfico
app/views/operacoes_dashboard/_comparativo.html.erb    # NOVO — tabela A|B|Δ + barras agrupadas
app/views/operacoes_dashboard/_mapa.html.erb           # NOVO — calor/pontos, camadas, tela cheia
app/views/operacoes_dashboard/_tabela_simples.html.erb # NOVO — tabela reutilizável + cross-filter
spec/services/analytics/operacao_metricas_spec.rb      # NOVO — specs das agregações/cross-filter
spec/requests/operacoes_dashboard_spec.rb              # NOVO — autorização por role + modos
config/routes.rb                                       # rota get /dashboard/operacoes
app/policies/dashboard_policy.rb                       # operacoes? (todos menos motorista)
app/views/layouts/_navbar.html.erb                     # link "📈 Operações"
```

> **Sem migration** — tudo lê tabelas existentes (`db_reem_simplerout_2026` read-only e
> `gade_entregas_*`). Libs front via CDN (Chart.js, Leaflet + leaflet.heat, flatpickr).
> ⚠️ Em produção, **reiniciar o servidor** (Puma) após o deploy — o Rails cacheia classes/views.

</details>

<details open>
<summary><strong>🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar</strong></summary>

Rodada de melhorias no **Dashboard de Operações**, na tela de **Validação do motorista**
(consolidação) e no **layout do site inteiro**.

### 🗺️ Mapa de Operações
- **Busca no mapa** — campo 🔍 que filtra os pontos por **NF, nome do paciente ou veículo**,
  troca para a visão "Pontos", dá zoom nos resultados (busca client-side) e mostra contador /
  "Nenhum resultado".
- **Mostra TODAS as entregas** — antes só `completed`; agora **sucesso + insucesso** (óbito e
  demais pilares de falha), via `Entrega::STATUS_ATENDIDO`. Pino **laranja = sucesso**, **azul =
  insucesso**; o popup mostra o status e o **motivo** (`observation`). A **foto da fachada** é
  mantida para todos os casos.
- **Campo veículo** — coluna `vehicle` (de `Entrega`) adicionada à query, ao card e à busca.
- **Modo escuro mais detalhado e com mais contraste** — o tile escuro passou de CARTO `dark_all`
  (minimalista) para **OpenStreetMap completo invertido** (`filter: invert(1) hue-rotate(180deg)
  brightness(.95) contrast(1.05)` na classe `.tiles-escuro-contraste`): mesmo detalhe de ruas/nomes
  do mapa claro, porém escuro e legível. Claro/Satélite ficam intactos.

### 📊 Dashboard de Operações (painéis)
- **Gráficos clicáveis (cross-filter)** — além das tabelas, agora o **donut "Insucessos %"**
  (clique em Completas/Falhas → filtra por `status`) e as barras **"Entregas por Dia"** (clique →
  filtra por **dia** + **resultado** da barra) refiltram **todo** o dashboard. Novos filtros
  `f_resultado` (coluna `status`) e `f_data` (por data de checkout, tratado à parte no serviço via
  `@data`). Chips removíveis + "Limpar tudo", igual às tabelas.
- **Falha em azul** — a cor de insucesso mudou de vermelho `#ef4444` para **azul `#3b82f6`** nos
  KPIs e gráficos (mais contraste no tema escuro).
- **Hover nos cards de KPI** — ao passar o mouse o card destaca, o número aumenta e aparece o
  **% do total** (no card Total, o detalhamento ✅/●/⏳).
- **Respiro no scroll das tabelas** — `pr-2` nos contêineres roláveis (Motoristas / STS) para a
  barra de scroll não colar no texto.

### 🚚 Validação do motorista (consolidação)
- **Filtro de veículos multi-seleção** — antes "um carro de cada vez"; agora dá pra **somar 2+
  veículos**. A lista, a barra de progresso, o **valor total (R$)** e a **contagem por pilar**
  passam a refletir só os veículos selecionados. Chip selecionado fica **laranja** (prioridade
  sobre o verde de "fechado") para a seleção ficar sempre visível.
- **Lupa de busca na listagem de notas** — campo 🔍 sticky no topo da lista que filtra os cards
  por **NF, local, endereço ou veículo**; "Selecionar todos" passa a marcar **apenas os visíveis**
  no filtro.
- **Barra fixa mais compacta** — paddings/margens reduzidos (`p-3 mb-3`) para melhor aproveitamento
  em **monitores pequenos**.
- **Ocultar/mostrar o resumo lateral** — botão 📊 que esconde o resumo e dá **largura total** à
  lista (`lg:col-span-3 → lg:col-span-4`).

### 🧭 Layout global (site inteiro)
- **Recolher/expandir a sidebar do menu principal** — botão flutuante de seta (desktop) que esconde
  o menu e expande o conteúdo (`md:ml-64 → 0`). Estado **lembrado** entre páginas (`localStorage` +
  classe `.sidebar-collapsed` no `<html>`, aplicada antes de pintar → sem "piscar").
- **Indicador discreto** — em repouso o botão é uma **alça fina laranja** sempre visível na borda;
  ao chegar perto com o mouse (ou foco por teclado) ela **expande** no botão com a seta ◀/▶.

### 📂 Arquivos
```
app/services/analytics/operacao_metricas.rb            # veículo, sucesso+insucesso, filtro por dia (@data)
app/controllers/operacoes_dashboard_controller.rb      # CROSS f_resultado + filtro f_data
app/views/operacoes_dashboard/index.html.erb           # busca/dark map, pinos, gráficos clicáveis, KPIs
app/views/operacoes_dashboard/_painel.html.erb         # hover KPIs, cor azul, pr-2 no scroll
app/views/operacoes_dashboard/_mapa.html.erb           # campo de busca no mapa
app/controllers/consolidacao_entregas_controller.rb    # filtro multi-veículo + resumo por escopo
app/views/consolidacao_entregas/validar.html.erb       # chips multi, lupa, barra compacta, toggle resumo
app/javascript/controllers/validacao_controller.js     # veiculos[] , busca, toggleResumo
app/views/layouts/application.html.erb                 # CSS sidebar recolhível + script anti-flash
app/views/layouts/_navbar.html.erb                     # botão flutuante + alça indicadora
```

> **Sem migration** — segue lendo as tabelas existentes (`db_reem_simplerout_2026` read-only e
> `gade_entregas_*`). ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).

</details>

---

<details>
<summary><strong>🔒 Segurança + auto-atualização do painel + cron (06/07/2026)</strong></summary>

Revisão de segurança do site em produção (via browser + inspeção de headers) e
melhorias no **Dashboard de Operações**. **Sem migration.**

### 🩸 Causa-raiz descoberta — produção em modo `development`
A página de erro 404 do site expunha o backtrace do Rails e havia o cookie
`__profilin` (rack-mini-profiler) — sinais de que o container roda em
**`RAILS_ENV=development`**. Isso é a origem de várias falhas abaixo (cookie de
sessão sem `Secure`, sem redirect HTTPS, sem CSP, páginas de exceção vazando
código).

> ⚠️ **Ação necessária no servidor** (o `.env` não está no repositório): definir
> `RAILS_ENV=production` e `FORCE_SSL=true` no `.env` e rebuildar. Ao migrar para
> `production`, validar o carregamento dos assets (Tailwind via CDN + importmap).

### 🔴 Correções de segurança
- **HTTPS obrigatório + cookie `Secure` (itens 1 e 2)** — `config.force_ssl` +
  `assume_ssl` (este último para o TLS terminado no Cloudflare, evitando loop de
  redirect). Em `application.rb` fica atrás de `ENV["FORCE_SSL"]=="true"`, então
  **funciona mesmo enquanto o deploy roda fora do modo production**;
  `production.rb` também já vem com `force_ssl = true`.
- **rack-mini-profiler desligado (item 3)** — no `Gemfile` passou a `require:
  false`: a gem não monta mais o middleware (fim do cookie `__profilin` e da rota
  `/mini-profiler-resources`), em qualquer ambiente.
- **Content Security Policy (item 5)** — nova política em
  `config/initializers/content_security_policy.rb` com allowlist dos CDNs reais
  (Tailwind, Chart.js, Flatpickr, Leaflet, Google Fonts, tiles ArcGIS/OSM).
  Começa em **Report-Only** (só reporta violações, não bloqueia) para não quebrar
  mapa/gráficos; instruções no topo do arquivo para ativar o bloqueio
  (`report_only = false`) depois de validar no console.
- Erro de login genérico ("Invalid email or password.") e recuperação de senha
  **não** revelam se o e-mail existe (sem enumeração de usuários) — **OK**, sem
  mudança. Pendência conhecida: login por **PIN de 4 dígitos sem identificador** —
  avaliar rate-limiting/rack-attack.

### 🔄 Painel de Operações atualiza sozinho (sem F5)
- Novo `app/javascript/controllers/auto_refresh_controller.js` — a cada **5 min**
  (`data-auto-refresh-interval-value`, em ms) faz `Turbo.visit` na própria URL:
  re-renderiza KPIs/gráficos/mapa **sem o flash do F5**, **preserva os filtros**
  (que vivem na query string) e a **posição de rolagem**; pausa em aba oculta ou
  quando há um campo em foco.

### 🕑 Horário da "última atualização" corrigido
- Antes mostrava `Time.now` — o **fuso do container (UTC)** e a **hora de
  renderização** (por isso aparecia adiantado e sempre "agora"). Agora um helper
  (`ultima_atualizacao_dados` / `ultima_atualizacao_label`) calcula o **último
  slot real de sincronização** (a cada 30 min, das 08h às 18h) no fuso de
  Brasília (`Time.current`).

### ⏰ Agendamento (whenever/cron) no Docker
- `config/schedule.rb` — job de histórico passou de `every 1.hour` para
  **`*/30 8-18`** (a cada 30 min, 08h-18h), batendo com a cadência real.
- `Dockerfile` — instala o pacote **`cron`**.
- `docker-compose.yml` — o boot agora roda, em ordem: `db:prepare` →
  **`whenever --update-crontab`** (grava os jobs) → **`cron`** (sobe o daemon) →
  `rails s`. As variáveis do banco chegam ao job via **dotenv** quando o rake
  carrega o Rails.

### 📂 Arquivos
```
config/application.rb                                   # gate FORCE_SSL → force_ssl + assume_ssl
config/environments/production.rb                      # force_ssl = true (+ assume_ssl)
config/initializers/content_security_policy.rb         # CSP (report-only) — NOVO
config/initializers/rack_mini_profiler.rb              # comentário atualizado
Gemfile                                                # rack-mini-profiler, require: false
.env.example                                           # RAILS_ENV=production + FORCE_SSL=true
app/helpers/application_helper.rb                      # ultima_atualizacao_dados / _label
app/javascript/controllers/auto_refresh_controller.js  # auto-refresh do painel — NOVO
app/views/operacoes_dashboard/index.html.erb           # data-controller auto-refresh + horário
config/schedule.rb                                     # cron */30 8-18
lib/tasks/historico.rake                               # desc atualizada
Dockerfile                                             # instala cron
docker-compose.yml                                     # whenever --update-crontab + cron no boot
```

### ▶️ Deploy
```bash
# 1. No servidor, ajustar o .env:
#      RAILS_ENV=production
#      FORCE_SSL=true
# 2. Rebuildar e subir (já roda whenever --update-crontab + cron no boot):
docker compose up -d --build
# 3. Validar a CSP no navegador (F12 → console, sem violações) e então trocar
#    config/initializers/content_security_policy.rb para report_only = false.
```

> **Sem migration.** ⚠️ Em produção, **reiniciar o Puma** após o deploy.

</details>

---

<details>
<summary><strong>✏️ Atualização 08/07/2026 — Editar Lançamento do SimpliRoute (correção pelo ADM)</strong></summary>

Nova tela **só para ADM** que permite **corrigir um lançamento** de entrega direto na
**API do SimpliRoute** (ex.: uma entrega marcada como "completa" que na verdade foi
**óbito**). Até então a única forma era entrar manualmente no SimpliRoute. **Sem migration.**

### 🆕 Funcionalidades
- **Menu → Administração → "✏️ Editar Lançamento"** (visível só para `admin`).
- **Busca por NF** → o sistema localiza a entrega e mostra o estado atual.
- **Campos editáveis:** `status` (completa/falha/pendente/parcial/cancelada), **motivo**
  (óbito, endereço não localizado, mudou-se, etc.), **comentário**, **observações** e,
  no avançado, **data/hora + geolocalização** do checkout.
- **Auditoria** — toda alteração grava um `AuditoriaLog` (quem, quando, de/para).
- **Foto da fachada / assinatura** ficam para uma **fase 2** (exigem upload de imagem).

### ⚙️ Como funciona (técnico)
- A base de rastreio local (`db_reem_simplerout_2026`, model `Entrega`) é **read-only** e
  sincroniza a partir do SimpliRoute. A correção **não** escreve nela — grava na **API**;
  o painel/consolidações só refletem **na próxima sincronização** (a tela avisa isso).
- **Dois identificadores:** a visita tem `id` numérico (usado na URL de escrita) **e**
  `tracking_id` (`SR...`, que é a PK do espelho). O espelho não guarda o id numérico, então
  o serviço **resolve** listando as visitas da data (`planned_date`) e casando pelo
  `tracking_id` (fallback: NF).
- **Motivo** = campo `checkout_observation`, que é o **UUID** de uma lista fixa de 14 motivos
  (`GET /v1/routes/observations/`, todos `type=failed`). É esse UUID que popula a coluna
  `observation` — **não** basta texto livre no comentário.
- A gravação usa **PATCH** (só os campos alterados), para **não** sobrescrever assinatura/geo
  originais sem intenção. Token da API vem de **`SIMPLIROUTE_TOKEN`** (ENV, nunca no git).

### 🔧 Correção — botão estava no menu errado
- O botão foi adicionado, por engano, ao `app/views/layouts/_sidebar.html.erb`, que é um
  **partial morto** (legado da fase 2/3, nunca renderizado). O menu **real** é o
  **`_navbar.html.erb`** (renderizado pelo `application.html.erb`). O link foi movido para lá,
  na seção Administração.

### 📂 Arquivos
```
config/initializers/simpli_route.rb                      # lê SIMPLIROUTE_TOKEN / BASE_URL — NOVO
app/services/simpli_route/client.rb                      # cliente Net::HTTP (observations, resolver_id, PATCH) — NOVO
app/controllers/admin/edicao_lancamentos_controller.rb   # busca/atualiza + AuditoriaLog — NOVO
app/policies/edicao_lancamento_policy.rb                 # admin-only — NOVO
app/views/admin/edicao_lancamentos/show.html.erb         # tela (busca NF + form) — NOVO
config/routes.rb                                         # resource admin/edicao_lancamento
app/views/layouts/_navbar.html.erb                       # link "✏️ Editar Lançamento" (admin)
.env.example                                             # SIMPLIROUTE_TOKEN + SIMPLIROUTE_BASE_URL
```

### ▶️ Deploy
```bash
# 1. No servidor, adicionar ao .env (pegue o token em SimpliRoute → Configurações → API):
#      SIMPLIROUTE_TOKEN=...
#      SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
# 2. Rebuildar/reiniciar:
docker compose up -d --build
# 3. Logar como admin → Administração → "Editar Lançamento" → buscar uma NF e testar.
```

> **Sem migration.** ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).

</details>

---

<details>
<summary><strong>🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)</strong></summary>

Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base
técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV):
cada item vira uma fase própria quando for priorizado.

### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute"
criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
(título `NF {nf} - {nome}`, janelas 08:00–18:00, lat/long do mês anterior, etc.).
> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.**
- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica).
- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga".

### 2. 🧾 Relatório de comprovantes de entrega (POD)
`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada
entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`).
Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").

### 3. ⚡ Webhooks — painel em tempo real
`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`,
`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza
**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
idempotência) e URL pública estável.

### 4. 📱 Aviso ao paciente via WhatsApp
Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`):
"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE**
(motivo real da lista de observations).

### 5. 📺 Monitor de rotas ao vivo
`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada
veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).

### 6. 👷 Sincronização de motoristas/veículos
`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema
(hoje o vínculo é o nome digitado — sujeito a divergência).

### 7. 📊 Datamart e 🏷️ tags/skills
Export paginado de analytics para enriquecer dashboards; tags/skills para classificar
visitas por tipo de material.

</details>

---

<details>
<summary><strong>🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções</strong></summary>

### 📋 Planilha da Operação — página própria (`/dashboard/operacoes/planilha`)
Tabela **espelho da planilha da operação** (uma linha por NF) para pesquisar e acompanhar
ocorrências/status, acessada pelo botão **"📋 Planilha da operação"** no Dashboard de Operações
(o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).
- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo,
  **Resultado** (badge Entregue/Falha/Pendente), **Ocorrência** (motivo do insucesso) e status
  gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
- **Busca textual server-side** (param `q`, qualquer campo) + **paginação server-side**
  (param `pg`, 15/página, janela `« 1 … 7 8 9 … 42 »`) — aguenta as milhares de linhas do Global.
- Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos
  filtros herdados. Reusa as linhas do `Analytics::OperacaoMetricas` (novo método `#buscar`) —
  **nenhuma query nova**.
- Arquivos: rota `operacoes_planilha`, action `planilha` + `montar_tabela_espelho`
  (`OperacoesDashboardController`), views `planilha.html.erb` + `_espelho.html.erb`.

### ⬇️ Download do Excel do cliente — planilha Entregas preenchida
Botão **"⬇️ Baixar Excel preenchido"** na página da planilha: gera o `.xlsx` no formato do modelo
**"Entregas SUDESTE MM.AAAA_FINAL"** com as **3 abas, 1:1** — automatiza o preenchimento que era
feito manualmente para entregar ao cliente.
- **RESUMO** — mês, Total Previsto/Realizado/Performance e os quadros **EMAD** e **UBS** por
  coordenadoria (CRS fixas do modelo) com **motivos de "Não Entregue"** (Óbito, Responsável
  Ausente, Endereço não localizado, Recusa, Paciente não reside, Outros) classificados a partir
  da `observation` do rastreio; linhas Total e Performance %.
- **ENTREGAS** — colunas A–V da própria tabela `gade_entregas_*` (ordem física de importação =
  ordem da planilha original) + **W–Z preenchidas pelo rastreio**: STATUS, ENTREGA (Sim/Não),
  DATA OCORRÊNCIA (data real do checkout) e OCORRÊNCIA — último status de cada NF (mesma CTE
  `ROW_NUMBER` dos painéis).
- **SimpliRoute** — dump cru do rastreio com as **46 colunas exatas** do export original
  (todas as visitas das NFs da operação, incluindo repetidas).
- **Estilo idêntico ao modelo** (cores extraídas do `styles.xml` do próprio arquivo): cabeçalho
  ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em
  `002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
  itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
- **Links das fotos são hiperlinks clicáveis** (azul sublinhado): Nota Fiscal, Termo de
  Recebimento, Foto da Fachada e Relatório de visita abrem o comprovante direto do Excel.
  Qualquer célula que seja URL `http(s)` vira link (`#linkar_urls`).
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
  caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.

#### Nomes de coluna — conferidos no banco real (22/07/2026)
Os nomes eram **deduzidos** e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes
reais foram conferidos com `Entrega.column_names` e estão como 1º candidato em
`RASTREIO_COLUNAS`; os chutes antigos continuam na lista como rede de segurança.
- Espelho usa o padrão do export pt-BR: `codrivers`, `responsible_person`, `advance`,
  `start_of_time_window_1..2`, `end_of_time_window_1..2`, `required_skills`, `optional_skills`,
  `comments`. Corrigidos — antes eram `copilots`, `receiver`, `early`, `window_start`, etc.
- Tabelas `gade_entregas_*` gravam a geo como **`lat`/`long`** (não `latitude`/`longitude`) —
  as duas colunas da aba ENTREGAS saíam vazias. Ver `ALIAS_GADE`.
- **`Load 4` não existe** no espelho (só `load`, `load_2`, `load_3`) — sai vazia de propósito.
- Coluna **`protocolo_de_entrega`** existe no espelho e não estava em lugar nenhum: entrou como
  última coluna (AU), **depois** do layout A–AT do modelo, para não deslocar nada do cliente.
- ⚠️ Vazias por **falta de dado na origem**, não por bug: `contact_phone`, `account_id`,
  `account_name` (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute
  TEM o valor, conferido na NF 79093) e `codrivers`/`required_skills`/`optional_skills`/
  `contact_email`/`load_2`/`load_3` (vazios também no relatório baixado direto do SimpliRoute).
- ⚠️ Os 2 **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é
  controle manual (sai "A iniciar").

#### 🔄 Número de série da base (aparelho) — job em background
A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho **nunca** traz (0 de
~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da `gade_entregas_*`: os seriais
de `num_serie_base` são de NFs **disjuntas** das que têm série no SimpliRoute (0 de 203 batem).
O dado só existe na API, em `extra_field_values` — o mesmo hash das fotos do motorista.
- `SincronizarSeriesAparelhoJob` varre a API por DATA (a API não filtra intervalo; cada dia são
  ~4 MB / ~9 s) em lotes de 6 threads e grava em **`series_aparelho`** (tabela nossa — o espelho
  é read-only). Um dia que não responde vira aviso, não derruba a rodada.
- `Analytics::PlanilhaEntregas#completar_serie` completa **só** as linhas em que o espelho veio
  vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando.
- Agendado em `config/schedule.rb` às **01h** (últimos 45 dias). Backfill de meses antigos:
  `rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"` ou
  `rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"`.
- Se a rodada varrer visitas e não achar **nenhuma** série, o log lista as chaves de
  `extra_field_values` que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo
  em `CAMPOS_SERIE` (foi chute de nome de campo que causou o bug acima).

### 📊 Dashboard financeiro
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
  abas e contador ("1–10 de 47"), client-side (função genérica `paginarTabela`); some com ≤10 itens.
- **Novo gráfico "Pagamentos por dia"** abaixo da "Evolução do custo": barras verdes com o valor
  pago por data de pagamento (`pago_em`) + linha tracejada do **acumulado** — o fluxo de caixa,
  que não existia em nenhuma tela (`build_grafico_pagamentos`). Quando não há pagamento no
  período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado.
  > Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo)
  > e "Resultado por dia" (já existe na tela de Operações).
- O card dos gráficos virou `flex-col` e preenche toda a altura ao lado do ranking de motoristas
  (sumiu o vazio embaixo da linha).

### 🔴 Correções
- **"Rodapé quebrado" no celular:** era uma **barra de rolagem horizontal** (laranja) colada no
  rodapé — a legenda dos gráficos não quebrava linha e estourava ~37px a largura em telas
  <~430px. ✅ Cabeçalhos/legendas dos gráficos com `flex-wrap` + **`overflow-x-hidden` no
  `<body>`** (rede de segurança; tabelas largas continuam rolando nos próprios contêineres
  `overflow-x-auto`).

### 📌 Padrões (reforçados)
- Tabelas externas sempre read-only, nome de tabela via whitelist + `quote_table_name`, colunas
  em whitelist fixa com fallback NULL (padrão `OperacaoMetricas`/`PlanilhaCarga`).
- Excel com `caxlsx` (mesmo padrão da planilha de carga SimpliRoute): serviço de linhas +
  serviço de binário + `send_data` no controller.

> **Sem migration.** Deploy normal; em produção reiniciar o Puma após o deploy.

</details>

---

<details>
<summary><strong>🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos)</strong></summary>

O negócio passou a diferenciar **termo normal** e **termo especial**, cada um com seu preço.
O modal de lançamento agora permite informar **quantos termos de cada tipo** o motorista
entregou, num único envio.

### 🆕 Funcionalidades
- **Novo card de preço "📋 Entrega de Termo Especial"** em **Admin → Configurações**
  (chave `preco_termo_especial`, moeda). Criado com **R$ 0,00** — definir o valor real no
  card antes de usar. O card aparece automático (a view itera todas as configs).
- **Novo pilar `tipo: termo_especial`** (enum 6, cor **ciano**, label "Entrega de Termo
  Especial") — mesmo comportamento do termo normal: lote numa linha (`quantidade` × preço),
  `manual: true`, `tracking_id` sintético `TERMO-<uuid>`.
- **Modal "📄 Entrega de termo" com dois steppers** lado a lado — "📄 Termo Normal"
  (inicia em 1) e "📋 Termo Especial" (inicia em 0), cada um exibindo seu preço unitário.
  Um clique em "Adicionar termos ✓" lança os dois lotes de uma vez (exige ao menos 1 termo
  no total; mínimo 0 em cada stepper).
- **Exibição em todas as telas**: linha "Termo Especial" no painel 📊 Resumo do Passo 2,
  card próprio no Revisar, badge ciano "Entrega de Termo Especial ×N" nos apontamentos
  manuais (show) e nos PDFs (extrato, relatório do motorista e financeiro — automático,
  iteram `TIPO_CORES`).

### ⚙️ Como funciona (técnico)
- **Migration** (rodar `db:migrate`): `20260715000001_add_preco_termo_especial_configuracao`
  — cria a config `preco_termo_especial` (idempotente) + seed correspondente.
- `Configuracao`: chave nova em `CHAVES`/`CHAVES_MOEDA`/`LABELS`/`ICONES`/`mapa_de_precos`
  + helper `preco_termo_especial`.
- `Consolidacao#adicionar_termos!` ganhou o parâmetro **`tipo:`** (default `'termo'`,
  validado em `termo`/`termo_especial`); o preço vem de
  `ConsolidacaoEntrega.valor_para(tipo)`.
- `ConsolidacaoEntregasController#apontar_termo` lê `quantidade` (normal) +
  `quantidade_especial` e cria **um lote por tipo** dentro de uma transação, auditando as
  duas quantidades. `contar_manuais` soma a `quantidade` dos **dois** tipos de termo; os
  agregados `group(:tipo).sum(:quantidade)` já funcionavam sem mudança.
- Stimulus: `apontamento_termo_controller.js` com targets `quantidade`/`quantidadeEspecial`
  e ações `maisEspecial`/`menosEspecial`; `validacao_controller.js` com target opcional
  `qtdTermoEspecial`.

### 📂 Arquivos
```
db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb  (nova)
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
app/controllers/consolidacao_entregas_controller.rb
app/javascript/controllers/apontamento_termo_controller.js · validacao_controller.js
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/views/consolidacoes/show.html.erb
app/services/pdf/relatorio_motorista_pdf.rb
db/seeds.rb · spec/models/configuracao_spec.rb
```

```bash
docker-compose exec app bundle exec rails db:migrate
```

</details>

---

<details>
<summary><strong>🎨 Atualização 20/07/2026 — Ícones da marca (emoji → SVG laranja) + galeria de fotos no Editar Lançamento</strong></summary>

Três frentes num dia: (1) troca de **todos os ícones** do sistema, que eram emojis, por
ícones SVG chapados no laranja da marca; (2) **galeria com todas as fotos** do lançamento na
tela de Editar Lançamento; (3) investigação (com veredito) sobre **por que não dá para
editar as fotos** pela API do SimpliRoute. **Sem migration.**

### 🎨 1. Ícones — emoji → SVG da marca
Os ícones eram **emojis** (📊 📋 👥 ⚙️ …), que davam aparência "genérica de IA" e mudavam de
forma/cor conforme o sistema operacional. Agora são **SVG chapados**, na cor laranja da marca
(`#f97316`), consistentes em qualquer navegador.

- **Sprite próprio** em `public/icons.svg` — subconjunto do **Bootstrap Icons** (licença MIT),
  arquivo único (~33 KB), servido localmente (sem CDN externo).
- **Helper `icone(nome, …)`** em `app/helpers/application_helper.rb`: mapa de nomes semânticos
  em pt-BR (`:caminhao`, `:consolidacoes`, `:dinheiro`, …) → símbolo do sprite. Trocar um ícone
  é mudar uma linha. Usa `fill="currentColor"` → a cor vem da classe CSS.
- **Helper `rotulo(nome, texto, …)`** para `link_to`/`button_to` (que recebem o rótulo como
  argumento, onde não cabe ERB). `nav_link_to` ganhou a opção `icon:`.
- **~260 emojis substituídos em ~33 arquivos** (views, `_navbar`, layout, popups do mapa Leaflet
  e HTML montado em JS na tela de Editar Lançamento).
- **Ícones de status mantêm cor semântica** (sucesso verde, alerta amarelo, erro vermelho) —
  só os de navegação/ação viraram laranja.
- **E-mails** (`consolidacao_mailer`) ficaram sem os emojis decorativos: sprite via `<use href>`
  não funciona em cliente de e-mail.
- **Ajuste fino (mesma data):** ícones **+20%** (padrão `1.05em`→`1.25em`), **espaçamento**
  ícone↔texto (parâmetro `espaco:` com `mr-1`, desligado nos ícones sozinhos/centralizados) e
  **correção de contraste** — ícone laranja sobre fundo laranja ficava invisível (botão
  "Relatório" do dashboard e os ícones das telas de recuperar/redefinir senha → herdam preto).

### 🖼️ 2. Editar Lançamento — galeria com TODAS as fotos
Antes a tela mostrava só **uma** foto genérica. Descobrimos (testando a API real) que as fotos
reais do motorista **não** estão no array `pictures`, e sim em **`extra_field_values`**, em
campos nomeados: **`foto_nf`** (Nota Fiscal), **`foto_prova_visita`** (Prova da visita, presente
até em insucesso), **`foto_relatorio2`** (Relatório) e **`foto_termo`** (Termo, quase sempre vazio).

- Novo método **`SimpliRoute::Client#detalhe_visita`** → `GET /v1/plans/visits/{id}/detail/`
  (fonte de fotos mais completa; best-effort, cai para a visita se falhar).
- **`fotos_do_lancamento`** no controller reúne **todas as fontes** — fachada (rastreio),
  `extra_field_values`, `pictures[]` e `signature` — numa lista **etiquetada** (tipo + origem) e
  **deduplicada por URL**.
- **Galeria** com a 1ª foto em destaque + grade, cada uma com etiqueta colorida por categoria,
  e **visualizador (lightbox)** que amplia e navega (setas do teclado / Esc / link p/ tamanho real).

### 🚫 3. Por que NÃO dá para editar as fotos (limitação do SimpliRoute)
Investigamos todos os caminhos de escrita contra a API real. **A limitação é do SimpliRoute:**

| Caminho | Resultado |
|---|---|
| `PATCH`/`PUT` em `extra_field_values` (todos os formatos) | **HTTP 500** — o servidor deles quebra |
| Editar outros campos (`status`, `notes`) por `PATCH` | Funciona (200) — o bloqueio é **só** das fotos |
| Suspeita de "plano expirado" | Descartada — visita **ativa (de hoje)** dá o mesmo 500 |
| `GET /v1/plans/visits/{id}/detail/` | Somente leitura |
| Checkout mobile `POST /v1/mobile/visit/{id}/checkout/` | **403** — exige token de **motorista**; e refaz o checkout inteiro (hora/GPS/assinatura) |
| Webhook | Canal de **saída** apenas — não escreve de volta |

- **Webhook existente descoberto:** a conta (Gade Hospitalar) tem um webhook `visit_checkout_detailed`
  apontando para um **Google Apps Script** (`script.google.com/…/exec`) — recebe os dados de cada
  entrega finalizada (inclui as URLs das fotos). É **inbound**, não serve para editar.
- **Conclusão:** não há caminho administrativo (nem por API, nem pelo painel web do SimpliRoute)
  para corrigir uma foto após o envio do motorista. Só liberando escrita em `extra_field_values`
  (pela API) ou uma opção de substituir imagem no painel deles.
- **Ferramenta de diagnóstico:** `bin/sondar_fotos_simpliroute --visita <id> [--testar-escrita]` —
  mostra as fotos de todas as fontes e testa (com segurança, mirando campo vazio e restaurando) se
  a escrita é aceita. Roda no servidor: `docker compose exec app bin/sondar_fotos_simpliroute …`.

> ⚠️ **Token do SimpliRoute:** é de **produção** e tem escrita. Se for compartilhado (chat/e-mail),
> **rotacione** depois em `app2.simpliroute.com`.

### 📂 Arquivos
```
public/icons.svg                                   (novo — sprite Bootstrap Icons, MIT)
bin/sondar_fotos_simpliroute                        (novo — sondagem da API de fotos)
app/helpers/application_helper.rb                    (icone / rotulo / nav_link_to)
app/services/simpli_route/client.rb                 (detalhe_visita)
app/controllers/admin/edicao_lancamentos_controller.rb  (fotos_do_lancamento)
app/views/admin/edicao_lancamentos/show.html.erb    (galeria + lightbox)
app/views/layouts/_navbar.html.erb · _sidebar.html.erb · application.html.erb
app/javascript/controllers/validacao_controller.js  (chip usa SVG, não emoji)
+ ~30 views com emojis substituídos (consolidacoes, dashboard, operacoes_dashboard, devise, …)
```

> **Sem migration e sem gem nova** — só views, helpers, JS e um asset estático.
> Em produção, o `public/icons.svg` precisa ir junto no deploy.

</details>

---

<details>
<summary><strong>🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026)</strong></summary>

## Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas

### 🎯 O problema real
Quando um plano é **duplicado** no SimpliRoute, nasce uma **visita nova** (outro `tracking_id`) com a
**mesma NF**, e a antiga continua existindo. Caso que motivou tudo: **NF 82891** com visita em
**17/07** (pendente, motorista Thiago Rabello Bittencourt) e outra em **21/07** (sucesso, sem
motorista). A tela mostrava **uma só** — e depois, por regressão, **nenhuma**.

### 🔴 Causas corrigidas (foram 7, em camadas)
1. **`Entrega.por_nf(nf).first`** — pegava uma linha só, sem `ORDER BY`. Agora carrega todas e a
   tela lista as ocorrências para o ADM escolher qual editar.
2. **`SimpliRoute::Client#resolver_id` abortava na ambiguidade** (`"Mais de uma visita para a NF…"`)
   em vez de deixar escolher. A tela de edição não passa mais por ele.
3. **As datas vinham só do espelho local.** Como a API **não busca NF por intervalo**, a visita de
   17/07 era inalcançável se o painel só conhecia a de 21/07. Entraram campos **De/Até** + botão
   **"Últimos 30 dias"** (varredura dia a dia, teto de 62 dias).
4. **Um dia com falha derrubava a busca inteira** → agora é *fail-soft* por dia: registra em
   `falhas` e segue. A tela informa o período consultado e quais dias falharam.
5. **`carregar?` faltando em `EdicaoLancamentoPolicy`** → o Pundit levantava `NoMethodError` (500),
   o `fetch` recebia HTML e o JS reportava "erro de conexão". Falhou **fechado** (negou acesso),
   sem brecha de segurança.
6. **Tela em branco:** `renderOcorrencias()` montava a lista inteira mas **nunca removia a classe
   `hidden`** do container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível.
7. **Fuso horário:** `new Date('2026-07-15')` é meia-noite **UTC** e, em UTC−3, o `toLocale`
   devolvia **14/07**. Datas puras agora são formatadas direto do texto ISO; horários de checkout
   continuam convertendo (correto para instante).

### 🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026)
| Parâmetro | Resultado |
|---|---|
| **`&search=<NF>`** | ✅ **Funciona e não é documentado.** Um dia cai de **3,8 MB / ~9 s** (1879 visitas) para **~1 KB / ~0,6 s**. Varredura de 31 dias em lotes paralelos: **3,4 s / 3,2 KB** |
| `reference`, `reference_id`, `q`, `title`, `reference__*` | ❌ **Ignorados em silêncio** — devolvem 200 com o dia inteiro |
| `planned_date_from/to`, `since/until`, `__gte/__lte`, `date_from/to` | ❌ **Não existe filtro de intervalo** — todos devolveram 2469, idêntico ao controle **sem parâmetro nenhum** |
| `GET /v1/routes/visits/` sem `planned_date` | ⚠️ Devolve um **conjunto padrão (~2469)** que **não cobre o histórico** — para a NF 82891 voltava só a visita de 21/07 e escondia a de 17/07 |

> ⚠️ **Lição:** parâmetro não registrado no backend Django é **ignorado sem erro**. "Voltou 200 com
> resultados" **não** prova que filtrou — a prova é a **contagem diminuir** em relação ao dia sem o
> filtro. Ferramenta: **`bin/sondar_busca_nf --nf <n> --data <YYYY-MM-DD> [--sem-data]`** (só GET).

---

## Parte 2 — Responsividade

### 🎯 A raiz comum
Os breakpoints do Tailwind (`md:`, `lg:`, `xl:`) enxergam a **janela**, mas o conteúdo perde
**256px** para a sidebar (`main.md:ml-64`). **A mesma janela de 1280px tem duas larguras** conforme
o menu esteja aberto ou recolhido — e grades de colunas fixas não ficam sabendo. Daí "quando a barra
de menu é acionada, quebra o layout". Solução: **`flex-wrap` / `auto-fit + minmax`**, que reagem ao
espaço **real**.

### 🔧 Corrigido
| Tela | Antes | Sintoma | Depois |
|---|---|---|---|
| `consolidacoes/index` | `md:grid-cols-6` | Campos a ~150px; texto do botão **"Filtrar" vazava** para fora do fundo | `flex-wrap` + `basis-*` |
| `dashboard/index` | `xl:grid-cols-5` | **"R$ 82.692,"** — valor cortado pelo `overflow-hidden` do card | `auto-fit,minmax(13rem,1fr)` |
| `consolidacao_entregas/revisar` | `md:grid-cols-7` | 7 cards de **~55px**, destruindo "Extraordinária"/"Termo Especial" | `auto-fit,minmax(9rem,1fr)` |
| `configuracoes` + `admin/configuracoes` | `lg:grid-cols-4` | ~128px úteis para valores em moeda | `auto-fit,minmax(14rem,1fr)` |

**Mantidos de propósito:** `validar.html.erb` (`lg:grid-cols-4` é a divisão 3:1 lista/Resumo, não
grade de cards) e os `xl:grid-cols-3` de gráficos — nesses o conteúdo encolhe sem cortar.

### 📐 Passo 2 (validar) — cabeçalho fixo
Medido a 1280×577: **442px de cabeçalho contra 67px de lista** (menos que um card).

- Voltar + título + progresso passaram a **uma faixa só**; o card de progresso (70px de moldura para
  uma barra de 12px) virou linha de 8px.
- **"Adicionar lançamento"** entrou na barra de ações via `order` do flex — sem mover os ~170 linhas
  de modais que vivem dentro do `data-controller`.
- **Pilares (Normal/Retirada/Bônus/Desconto/Extra) continuam SEMPRE visíveis**, apenas **esmaecidos**
  (`opacity-40`) enquanto não há seleção, com contador e "limpar" aparecendo ao selecionar.
  ⚠️ Uma versão intermediária os **escondia** até haver seleção — revertido: são a ação principal da
  tela e sumir com eles esconde o que dá para fazer de quem ainda não sabe que precisa selecionar.
- Legibilidade preservada: título `text-2xl`, subtítulo e progresso `text-sm`, números em branco/negrito.

**Resultado:** cabeçalho **442px → 210px**, lista **67px → 299px**.

### 📂 Arquivos
```
app/controllers/admin/edicao_lancamentos_controller.rb   # lista ocorrências, período, fail-soft, carregar
app/policies/edicao_lancamento_policy.rb                 # + carregar? (era o 500)
app/services/simpli_route/client.rb                      # visitas_da_data(data, busca:) → &search=
app/views/admin/edicao_lancamentos/show.html.erb         # lista de ocorrências, De/Até, avisos, fuso
config/routes.rb                                         # + get :carregar
bin/sondar_busca_nf                                      # NOVO — sondagem de filtros da API (só GET)
app/views/consolidacao_entregas/validar.html.erb         # cabeçalho compacto + barra de ações
app/javascript/controllers/validacao_controller.js       # atualizarSelecao / limparSelecao
app/views/consolidacao_entregas/revisar.html.erb         # grid-cols-7 → auto-fit
app/views/consolidacoes/index.html.erb                   # filtros → flex-wrap
app/views/dashboard/index.html.erb                       # KPIs → auto-fit
app/views/configuracoes/index.html.erb                   # preços → auto-fit
app/views/admin/configuracoes/index.html.erb             # idem (arquivo distinto, também vivo)
```

### ⚠️ Pendências e alertas
- **Tailwind vem do Play CDN** (`cdn.tailwindcss.com`, em `application.html.erb:15`), que gera CSS no
  navegador em tempo real. A documentação oficial **desaconselha em produção** (~380KB bloqueando a
  renderização, recompilação a cada carregamento). O **`tailwind.config.js` do repositório não está
  sendo usado** — a config real está embutida no layout (linha 17), e a gem `tailwindcss-rails` está
  no Gemfile sem servir CSS.
- **Não existe teste para `Admin::EdicaoLancamentosController`** (`spec/` não tem nada de
  `edicao_lancamento`). Duas das quebras acima — policy faltando e `hidden` não removido — seriam
  pegas por um teste de request/sistema em segundos, sem custar deploy.
- **Token do SimpliRoute:** se passou por chat/e-mail, **rotacione** em `app2.simpliroute.com`.

> **Sem migration e sem gem nova** — controller, policy, service, views e JS.
> ⚠️ Reiniciar o Puma após o deploy (cache de classes/views).

</details>

---

<details>
<summary><strong>✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026)</strong></summary>

> ⚠️ **STATUS: implementado, ainda NÃO executado.** Nada aqui foi rodado contra um banco nem
> contra os servidores reais (Gmail/Twilio) — não há Ruby nem Postgres na máquina de
> desenvolvimento. Foi conferida a sintaxe de todos os arquivos `.rb` e `.erb` e validado o
> algoritmo de normalização de telefone em Ruby puro. **A migration, a suíte e o envio real
> continuam pendentes** — roteiro no fim desta seção.

### 🎯 O problema
Servidor de e-mail e credenciais do Twilio viviam **só no `.env`**: trocar a senha de app do Gmail
ou o número remetente exigia editar o arquivo no servidor e **reiniciar o container**. O ADM não
tinha como fazer nada disso pela interface.

Pior: as chaves `notificacao_whatsapp` e `notificacao_email` existiam em `configuracoes` mas
**nunca tiveram UI** — `Admin::ConfiguracoesController#index` filtra por `CHAVES_MOEDA`. Ligar
notificação só era possível pelo `rails console`.

### 🆕 A tela
**Configurações → card "Notificações e E-mail"** (`/admin/configuracao_notificacao`).
**Só `admin`** — `ConfiguracaoNotificacaoPolicy` é mais restrita que `ConfiguracaoPolicy`, que
libera `index?` para gerente: aqui ficam senha de e-mail e token do Twilio.

O acesso é **exclusivamente pelo card dentro de Configurações** — de propósito não há item no menu
lateral, para não expor um atalho de credenciais na navegação de todo dia.

| Bloco | Campos |
|---|---|
| **Servidor de e-mail (SMTP)** | ativo, servidor, porta, usuário, senha, autenticação, domínio, e-mail e nome do remetente + toggle "avisar motoristas por e-mail" |
| **WhatsApp (Twilio)** | ativo, Account SID, Auth Token, número remetente |
| **Destinatários administrativos** | e-mail do admin, WhatsApp do admin |

Três botões: **Salvar**, **Salvar e enviar e-mail de teste**, **Salvar e enviar WhatsApp de teste**.

### ⚙️ Como funciona (técnico) — os 6 pontos não-óbvios

**1. Hierarquia banco > `.env`, sem quebrar nada.** `ConfiguracaoNotificacao#smtp_settings` devolve
**`nil`** quando não está pronto. O ActionMailer faz `.merge(options || {})` por cima do que o
`config/initializers/smtp.rb` montou no boot — então o fallback para o `.env` é **automático**.
Enquanto os toggles estiverem desligados, o comportamento é **idêntico ao de antes desta tela**.

**2. `default delivery_method_options:`, NÃO um `before_action`.** Um callback que mexesse em
`message.delivery_method` seria **descartado**: `ActionMailer::Base#mail` roda *depois* dos
callbacks e chama `wrap_delivery_behavior!`, que reconfigura a mensagem. O
`delivery_method_options` é lido *dentro* do próprio `mail()`.

**3. `proc`, NÃO lambda.** O Devise avalia o `default from:` com `instance_eval(&proc)`, que passa
1 argumento. Um `-> { }` de aridade 0 estouraria **`ArgumentError` em todo "esqueci minha senha"**.

**4. `config.parent_mailer = 'ApplicationMailer'`** no `devise.rb` — sem isso o reset de senha
continuaria preso ao `.env`. Efeito colateral aceito: os e-mails do Devise passam a usar
`app/views/layouts/mailer.html.erb`.

**5. Segredos cifrados sem `master.key`.** Senha SMTP e Auth Token vão para colunas
`*_cifrado` (AES-256-GCM) via `AtributoCifrado`, com chave derivada do `secret_key_base`. **Não** se
usou ActiveRecord Encryption: exigiria 3 chaves novas, dependeria da ordem dos initializers e
estouraria `Errors::Decryption` na leitura. Aqui o reader faz `rescue → nil`, o app **degrada para
o `.env`** e a tela mostra um banner amarelo pedindo para redigitar.

**6. Testes com `deliver_now` e `raise_delivery_errors = true` forçado.** O `development.rb` define
`raise_delivery_errors = false` e o adapter do ActiveJob é `:async` (thread in-process) — com
`deliver_later` **o teste "passaria" em silêncio mesmo com a senha errada**.

### 🩹 Bug pré-existente corrigido de passagem
`NotificacaoService` mandava `to: "whatsapp:#{user.telefone}"` com o telefone **cru do cadastro**.
Um telefone gravado como `(11) 92005-1157` vira `whatsapp:(11) 92005-1157`, o Twilio devolve
**21211** — e o `rescue` engolia. **Provavelmente nenhum WhatsApp a motorista jamais chegou.**
Agora passa por `ConfiguracaoNotificacao.canal`, que normaliza para E.164.

> Casos cobertos: `11 920051157`, `(11) 92005-1157`, `011 …`, `+55 11 …`, `whatsapp:+55…` →
> `+5511920051157`. O prefixo `55` só é removido quando sobra número demais — senão quebraria o
> **DDD 55** (Santa Maria/RS), onde `55991234567` já é o número completo.

### 🔐 Segurança
- A senha gravada **nunca volta para o HTML** (`password_field value: nil`). Campo em branco
  significa "mantenha a atual" — salvar sem redigitar não apaga o que está lá.
- `AuditoriaLog` registra a mudança com `acao: 'editar_notificacoes'`, mas grava apenas
  `smtp_password_definida: true/false`. **Nunca a senha nem o token** — `dados_novos` é exibido em
  `/admin/auditoria_logs`, que **gerente também acessa**.
- Os erros do Twilio vêm traduzidos (63003/63015 = falta o `join <palavra>` do sandbox, 21608 =
  conta trial só envia a número verificado, 20003 = SID/token inválidos…), para o ADM resolver
  sozinho sem abrir o log do container.

### 🆕 Migration adicionada (rodar `db:migrate`)
```
20260811000001_create_configuracao_notificacoes.rb
```
Tabela **singleton** (índice único em `singleton_guard` — impede dois workers Puma criarem linhas
concorrentes). Copia as flags antigas de `configuracoes` e **não** importa credenciais do `.env`:
os campos nascem vazios e o fallback segue mandando até alguém preencher a tela.

### 📂 Arquivos
```
db/migrate/20260811000001_create_configuracao_notificacoes.rb   # NOVO — tabela singleton
app/models/configuracao_notificacao.rb                          # NOVO — smtp_settings, credenciais, E.164
app/models/concerns/atributo_cifrado.rb                         # NOVO — AES-256-GCM sem master.key
app/policies/configuracao_notificacao_policy.rb                 # NOVO — show?/update? = admin
app/controllers/admin/configuracao_notificacoes_controller.rb   # NOVO — show/update + params[:acao]
app/views/admin/configuracao_notificacoes/show.html.erb         # NOVO — a tela
app/services/notificacao/resultado.rb                           # NOVO — ok?/mensagem/detalhe → flash
app/services/notificacao/teste_email.rb                         # NOVO — erros SMTP em português
app/services/notificacao/teste_whatsapp.rb                      # NOVO — códigos Twilio em português
app/services/notificacao/cliente_twilio.rb                      # NOVO — client com timeout de 15s
app/mailers/teste_mailer.rb · app/views/teste_mailer/teste.html.erb   # NOVO
app/services/notificacao_service.rb                             # lê do banco; unifica os 2 pares duplicados
app/mailers/application_mailer.rb                               # default from: / delivery_method_options: proc
app/mailers/consolidacao_mailer.rb                              # removido o `default from:` que anulava o proc
config/initializers/devise.rb                                   # + parent_mailer
config/initializers/smtp.rb                                     # vira fallback (só comentário)
config/initializers/inflections.rb                              # + irregular 'notificacao'
config/routes.rb                                                # + resource :configuracao_notificacao
app/views/admin/configuracoes/index.html.erb                    # + card "Notificações e E-mail" (só admin)
app/models/configuracao.rb                                      # marca notificacao_* como obsoletas
.env.example                                                    # hierarquia banco > .env + NOTIFICACAO_SECRET
spec/models/configuracao_notificacao_spec.rb                    # NOVO
spec/policies/configuracao_notificacao_policy_spec.rb           # NOVO
spec/requests/admin/configuracao_notificacoes_spec.rb           # NOVO
spec/models/table_names_spec.rb                                 # + ConfiguracaoNotificacao
```

### ⏳ Pendente de execução — roteiro
```bash
# 1. Migrar
docker-compose exec app bundle exec rails db:migrate

# 2. Suíte
docker-compose exec app bundle exec rspec spec/models spec/policies spec/requests

# 3. Confirmar a assinatura da gem (não pôde ser verificada fora do container)
docker-compose exec app bundle exec rails runner \
  'p Twilio::HTTP::Client.instance_method(:initialize).parameters'
```
4. **Permissão:** logar como **gerente** → o card não aparece e `/admin/configuracao_notificacao`
   redireciona com "Você não tem permissão". Como **admin** → a tela abre.
5. **E-mail:** `smtp.gmail.com`, porta 587, **Senha de app de 16 caracteres** (não a senha da
   conta), marcar "Ativar envio de e-mail" → **Salvar e enviar e-mail de teste**. Conferir o spam.
6. **WhatsApp:** SID / Auth Token / número remetente do Twilio, marcar "Ativar WhatsApp" →
   **Salvar e enviar WhatsApp de teste**. ⚠️ **No sandbox, o número que vai RECEBER precisa antes
   mandar `join <sua-palavra>`** para o número do sandbox — sem esse opt-in volta 63003/63015.
7. **Não regressão:** com os toggles desligados, finalizar uma consolidação e conferir que nada
   mudou; e testar o **"Esqueci minha senha"**, que trocou de mailer pai.

### ⚠️ Alertas
- **Rotação do `SECRET_KEY_BASE` torna senha e token ilegíveis.** O sistema não quebra (volta ao
  `.env` e avisa na tela), mas os dois campos precisam ser redigitados. Para desacoplar, defina
  **`NOTIFICACAO_SECRET`** no `.env` com uma string longa e **fixa**.
- **Não cachear a config em `Rails.cache`:** o `production.rb` usa `:memory_store`, que é por
  processo — a tela pareceria "não salvar" para os outros workers. É 1 `SELECT` por e-mail.
- `app/views/configuracoes/index.html.erb` (fora do `admin/`) **não recebeu o card**: não tem rota
  e é código morto — o vivo é `app/views/admin/configuracoes/index.html.erb`.

> **Migration nova** (`db:migrate` obrigatório) e **sem gem nova** — `twilio-ruby` já estava no
> Gemfile. ⚠️ Reiniciar o Puma após o deploy.

</details>


---

<details>
<summary><strong>💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (20–21/08/2026)</strong></summary>

> ⚠️ **STATUS: no ar no ambiente de teste e conferido com dados reais** (17 motoristas,
> 4.976 entregas de agosto). O que **continua pendente é a suíte** — não há Ruby nem Postgres na
> máquina de desenvolvimento, então os specs novos foram validados só na sintaxe. Roteiro no fim
> desta seção.

### 🎯 O problema
A Reem **paga a entrega sem sucesso**: o motorista foi até o local, teve o deslocamento e o custo,
e o insucesso é só o desfecho da visita. A **consolidação** já tratava assim desde sempre
(`Entrega::STATUS_ATENDIDO` = `completed` + `failed`), mas o **dashboard não** — mostrava um valor
**menor que o do fechamento**, e ninguém sabia explicar a diferença.

### 🔴 Correções

**1. Dashboard principal contava só as concluídas** — `dashboard_controller.rb` (commit `cebd6e2`)

A base financeira era `Entrega.pagas` (só `completed`). Passou a ser `.atendidas`
(`completed` + `failed`, com checkout) — **o mesmo recorte que a consolidação considera elegível**,
que é justamente o ponto: tela e fechamento agora partem do mesmo conjunto.

**2. A falhada caía no período errado** — mesmo commit

As falhas eram filtradas por `planned_date`, e as concluídas por `checkout`. **Falhada TEM
checkout** (o motorista fechou a visita com motivo de insucesso), então o eixo correto é o mesmo
das concluídas. Uma entrega planejada em 31/07 e fechada em 01/08 pertence a agosto — como a
consolidação sempre entendeu.

**3. O painel do motorista ficou para trás** — `motorista/dashboard_controller.rb` (commit `4965931`)

Continuava em `Entrega.pagas` + `no_periodo` (planned_date), ou seja, a lógica antiga inteira.
O motorista via **menos do que ia receber** — no mês corrente, 124 entregas (~R$ 2.232,00)
invisíveis — e a diferença só aparecia no fechamento. Passou para `.atendidas` +
`no_periodo_checkout`, e o rótulo *"N entregas feitas e confirmadas"*, que mentia sobre o número
novo, virou:

```
R$ 20,00
2 entregas atendidas
1 entregues · 1 sem sucesso (pagas também)
```

A segunda linha não é enfeite: sem ela o motorista vê um total maior e não tem como conferir de
onde veio.

**4. A barra do ranking contradizia a ordem do ranking** — `_ranking_motoristas.html.erb` (commit `e9db6b7`)

A barra era proporcional à **quantidade**, mas o card é ordenado por **valor**. Na aba Estimado dá
no mesmo (valor = qtd × preço); na Consolidada, bônus/retirada/termo mudam o preço unitário e a
barra do 3º (290 entregas, R$ 5.250) saía **maior** que a do 2º (261 entregas, R$ 5.260).
Invertia em três pontos da lista. Agora escala pelo valor, que é o número que ordena.

### 🆕 Aba "Consolidado" no ranking de motoristas

O card **Motoristas** ganhou duas abas — a dúvida recorrente era justamente *"esse ranking mostra o
estimado ou o consolidado?"*:

| Aba | O que mostra | De onde vem |
|-----|--------------|-------------|
| **Estimado** | entregas atendidas × preço da entrega | espelho de rastreio (`Entrega.atendidas`) |
| **Consolidado** | valor **realmente fechado** + quantidade exata de entregas | `consolidacao_motoristas` / `consolidacao_entregas` |

Os números divergem **de propósito**: o estimado cobre tudo que foi atendido no período; o
consolidado, só o que já entrou em consolidação **finalizada**, com bônus/desconto/retirada
aplicados. Cada aba diz na tela de onde vem o seu número.

Dois detalhes decidem se a quantidade sai certa:

- **`DISTINCT tracking_id`, não contagem de linhas.** Uma entrega pode ter vários pilares —
  Normal + Bônus + Retirada são **3 linhas** em `consolidacao_entregas` para **1 entrega**.
  Contar linhas inflaria o número.
- **Só motoristas ativos.** O ranking parte de `@fin_por_motorista` (que vem de
  `ConsolidacaoMotorista.ativos`), então **arquivado não aparece** — ele saiu do fechamento e não
  tem valor a exibir. Isso também garante que a aba soma exatamente o KPI "Custo total" do topo.

O markup da lista virou a partial `_ranking_motoristas.html.erb`, usada pelas duas abas: os dois
conjuntos têm a mesma forma (`:nome`, `:valor`, `:entregas`) e duplicar o HTML faria as abas
divergirem visualmente na primeira alteração.

### ✅ Conferência com dados reais (teste.reemtransportes.com.br, 21/08/2026)

**Dashboard principal — período 01/08 a 21/08:**

| Card | Na tela | Confere |
|------|---------|---------|
| Valor Estimado | R$ 89.568,00 | 4.976 × R$ 18,00 exato |
| — subtítulo | 4.976 entregas atendidas | 4.852 + 124 |
| Total Entregas | 4.977 | 4.976 atendidas + 1 pendente |

O teste decisivo: 4.852 concluídas × R$ 18 dariam **R$ 87.336,00**. A tela mostra **R$ 89.568,00** —
exatamente **R$ 2.232,00 a mais, que são as 124 sem sucesso**. O insucesso entra no dinheiro, não
só na contagem.

**Consistência interna** (as falhas entram em todo lugar, não só no card):
- Ranking por motorista: os 17 motoristas somam **exatamente 4.976**; se contasse só sucesso daria
  4.852.
- Gráfico "Evolução do custo": a série diária soma **R$ 69.408,00**, idêntico ao valor estimado do
  período 01–14/08 — as falhas caem nos dias certos (eixo checkout).
- Sem dupla contagem: o scope `pendentes` exclui `failed`, então entregue / pendente / sem sucesso
  não se sobrepõem.

**Aba Consolidado — reconciliação com os KPIs:**

| | Soma da aba | KPI do topo |
|---|---|---|
| Valores | **R$ 72.508,00** | R$ 72.508,00 ("Custo total") ✅ |
| Entregas | **3.858** | 3.858 ("entregas classificadas") ✅ |

Bate à vírgula e à unidade. Isso explica também a diferença **3.858 consolidadas × 3.856 atendidas**:
são 2 entregas que entraram no fechamento sem estar na janela de checkout do período (apontamento
manual de NF fora do período, ou consolidação que extrapola as datas). **Não é erro de contagem —
são bases diferentes**, e agora dá para ver as duas lado a lado.

### 🧪 Specs — ⏳ pendentes de execução

`spec/requests/dashboard_spec.rb` (+4 casos) e `spec/requests/motorista_dashboard_spec.rb` (novo,
5 casos). Usam o harness `spec/support/espelho_rastreio.rb`, que monta uma cópia descartável de
`db_reem_simplerout_2026` no banco de teste — sem ele só daria para mockar o método, o que não pega
regressão de **SQL**, que é onde moram os bugs de eixo de data.

O que fica travado:
- a sem sucesso soma no valor e na quantidade;
- entra pelo **checkout** (planejada 31/07 + checkout 01/08 → conta em agosto) e sai quando o
  checkout cai fora;
- pendente sem checkout não vira dinheiro;
- entrega de outro motorista não vaza para o painel;
- na aba Consolidado: 3 linhas de 2 `tracking_id` = **"2 entregas"**, desconto subtraindo,
  arquivado fora da lista, rascunho não entrando.

> As asserções da aba Consolidado são escopadas ao `#ranking-painel-consolidado` via Nokogiri — a
> aba Estimado renderiza o **mesmo markup** (moeda + "N entregas"), então asserção no `body` inteiro
> passaria por acidente.

O painel do motorista não tem filtro de período (é sempre "do dia 1º até hoje"), então o spec
congela a data com `travel_to`; sem isso ele quebraria sozinho ao rodar no dia 1º.

### 📂 Arquivos
```
app/controllers/dashboard_controller.rb              (atendidas + eixo checkout; @ranking_consolidado)
app/controllers/motorista/dashboard_controller.rb    (atendidas + no_periodo_checkout; quebra do card)
app/models/entrega.rb                                (scopes atendidas / falhadas / no_periodo_checkout)
app/views/dashboard/index.html.erb                   (abas Estimado/Consolidado + JS da troca)
app/views/dashboard/_ranking_motoristas.html.erb     (NOVO — lista compartilhada pelas duas abas)
app/views/motorista/dashboard/index.html.erb         (rótulo "atendidas" + linha da quebra)
spec/requests/dashboard_spec.rb                      (+ aba Consolidado)
spec/requests/motorista_dashboard_spec.rb            (NOVO)
spec/support/espelho_rastreio.rb                     (harness da tabela externa)
```

> **Sem migration e sem gem nova** — só controllers, views e specs.

### ⏳ Pendente — roteiro
```bash
# 1. Suíte (única coisa que não pôde ser executada)
docker compose exec app bundle exec rspec \
  spec/requests/dashboard_spec.rb spec/requests/motorista_dashboard_spec.rb

# 2. Depois do deploy: conferir a barra do ranking na aba Consolidado
#    (deve encurtar sempre de cima para baixo)

# 3. Painel do motorista com dado real — logar como motorista no teste e
#    conferir a linha "N entregues · N sem sucesso (pagas também)"
```

</details>

---

<details>
<summary><strong>🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026)</strong></summary>

> ⚠️ **STATUS: implementado, ainda NÃO executado.** Não há Ruby/Bundler nem Postgres na máquina de
> desenvolvimento — foi conferida a sintaxe de todos os `.rb` e `.erb` alterados. **A suíte e a
> validação com dado real continuam pendentes** — roteiro no fim desta seção.

### 🎯 O problema
Mesmo período filtrado, dois números diferentes:

| | Dashboard financeiro | Dashboard de Operações |
|---|---|---|
| Total | 4977 | 4973 |
| Entregues / Sucesso | 4852 | 4851 |
| Falhadas / Recusas | 124 | 122 |
| Pendentes | 1 | 0 |

Não era arredondamento: **as duas telas contam coisas diferentes**, e nada na interface dizia isso.

- O **financeiro** conta **visitas** (idas ao local). É o recorte certo lá, porque é por ida que o
  motorista recebe — `Entrega.contar_atendidas` conta linhas, e a consolidação paga em cima disso.
- **Operações** conta **notas fiscais** (último status de cada NF). É o recorte certo aqui, porque
  é o que o cliente paga e o que confere nos documentos físicos — o mesmo critério da aba ENTREGAS
  da planilha entregue (`Analytics::PlanilhaEntregas`).

Uma NF que falhou dia 10 e foi entregue dia 12 vale **2 no financeiro e 1 em Operações**. Correto
nos dois — mas invisível.

### 🐛 Três defeitos reais por trás disso

**1. O dedup rodava sobre a tabela inteira, não sobre o período.** O `ROW_NUMBER() ... rn = 1`
ficava numa CTE **antes** do filtro de data. Se o último checkout de uma NF era **posterior** ao fim
do período, a visita que aconteceu **dentro** do período sumia da contagem do mês. Subcontagem
silenciosa em todo fechamento. Agora o dedup acontece em `#linhas`, **depois** do período e do
cross-filter.

**2. O dedup escondia todo o insucesso reentregue.** NF que falhou duas vezes antes de entregar
aparecia como 100% de sucesso, e o motivo sumia do "Índices de falha".

**3. O período nem era aplicado no modo Operação.** `montar([@operacao], ...)` era chamado **sem
`inicio:`/`fim:`** e o seletor de data só aparecia no modo Global — comparar as duas telas "no mesmo
período" era literalmente impossível.

### 🆕 O que mudou na tela

**Operações — camada "Visitas ao local"** (abaixo dos 4 cards): visitas realizadas, retentativas,
insucessos por visita e "entregues na 2ª ida ou mais". Os 4 cards de cima seguem contando **notas**.

**Operações — painel "Notas fora da operação"**: NFs entregues no período que **não estão em nenhuma
planilha `gade_entregas_*`** — os planos avulsos e de inclusão. Contavam no financeiro e o
`INNER JOIN` com a tabela da operação as descartava aqui. Agora aparecem com NF, plano/título,
motorista, unidade, data, resultado e motivo, com quebra por plano de origem.

**Operações — período no modo Operação**: o seletor passa a valer também aqui, mas **só quando o
operador escolhe uma faixa** (`inicio`/`fim` na URL). Sem escolha, o recorte segue sendo a operação
inteira — senão abrir uma operação de meses atrás cairia no mês corrente e mostraria zero. Botão
**"Operação inteira"** volta ao recorte natural.

**Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas".

**Financeiro — card "N pendentes" agora é clicável** → `/dashboard/pendentes`, a tela nova
**"Entregas em aberto"**. O número existia desde sempre e **nenhuma tela listava as linhas por trás
dele** — só dava para descobrir por `rails runner`. A lista traz NF, status, motorista, veículo,
unidade, data planejada, **em qual operação a NF está** (ou "fora da operação") e **quantas visitas
o rastreio tem para ela**. Essas duas últimas colunas respondem sozinhas por que a entrega ficou em
aberto e por que o dashboard de Operações não a mostrava.

### 🔍 O que o dado real mostrou (24/08/2026, teste)
A "1 pendente" que não aparecia em Operações era a **NF 85382 — MARIA APARECIDA JESUS SANTOS**:
plano avulso, status `pending`, **sem motorista**, planejada 03/08/2026, STS VILA PRUDENTE _
SAPOPEMBA. Nota **fora da operação** — o `INNER JOIN` a descartava. Confirmado no painel novo.

⚠️ **`title` NÃO é o nome do plano.** No dado real ele traz `NF 89096 - KAIQUE TAUAN DA SILVA` — o
formato da coluna A da planilha de importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o
**destinatário**. Agrupar por ele dava um grupo por NF. `COLUNAS_PLANO` passou a ser
`notes, comments, route_id`; `title` virou a coluna "Destinatário". Quando nenhuma coluna de plano
vem preenchida, a quebra cai para **unidade**, que ainda informa algo. **A coluna que carrega
"(Avulsa)"/"INCLUSÃO" segue não confirmada** — pode ser que o espelho simplesmente não a traga
(o sync já deixa 4 colunas 100% NULL).

### ⚙️ Pontos não-óbvios

**Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma
linha por NF (a última visita). Todos os KPIs, o donut, os motivos, o mapa e a tabela espelho
continuam saindo de `#linhas` — ou seja, **a tela segue batendo com a planilha do cliente**. Só a
faixa nova lê `#visitas`.

**`uniq` por `tracking_id`.** Sem a CTE, se a mesma `nota_fiscal` estiver repetida dentro de uma
tabela de operação (ou em duas tabelas do UNION global), o `INNER JOIN` devolvia a **mesma visita**
mais de uma vez. Agora colapsa.

**Filtro de conta unificado.** `Entrega.condicao_conta_sql` nasceu para as queries cruas de
`Analytics` usarem exatamente o mesmo recorte de `DB_EXISTING_ACCOUNT_ID` do scope
`da_conta_gade` — antes o dashboard filtrava conta e Operações não.

**`NOT IN` com `NULL` devolve zero linhas.** Cada `SELECT` da união em `NotasForaOperacao` filtra
`nota_fiscal IS NOT NULL`; sem isso um único NULL numa planilha deixaria o painel vazio para sempre.
Tem spec para isso.

### 📂 Arquivos
```
app/models/entrega.rb                                    (contas_gade + condicao_conta_sql)
app/models/operacao.rb                                   (por_notas — em que operação cada NF está)
app/services/analytics/operacao_metricas.rb              (visitas x linhas; dedup pós-filtro; conta)
app/services/analytics/notas_fora_operacao.rb            (NOVO — avulsas/inclusão)
app/controllers/operacoes_dashboard_controller.rb        (período no modo Operação; @fora_operacao)
app/controllers/dashboard_controller.rb                  (@notas_atendidas + action #pendentes)
app/views/dashboard/pendentes.html.erb                   (NOVO — tela "Entregas em aberto")
config/routes.rb                                         (GET /dashboard/pendentes)
app/views/operacoes_dashboard/_painel.html.erb           (faixa "Visitas ao local")
app/views/operacoes_dashboard/_fora_operacao.html.erb    (NOVO — painel de avulsas)
app/views/operacoes_dashboard/index.html.erb             (seletor de período + render do painel)
app/views/dashboard/index.html.erb                       (linha "N notas fiscais" + card clicável)
spec/services/analytics/operacao_metricas_spec.rb        (+ NF com retentativa)
spec/services/analytics/notas_fora_operacao_spec.rb      (NOVO)
spec/requests/dashboard_spec.rb                          (+ tela de entregas em aberto)
```

> **Sem migration e sem gem nova** — model, services, controllers, views e specs.

### ⏳ Pendente — roteiro
```bash
# 1. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local)
docker compose exec app bundle exec rspec \
  spec/services/analytics/operacao_metricas_spec.rb \
  spec/services/analytics/notas_fora_operacao_spec.rb \
  spec/requests/dashboard_spec.rb \
  spec/models/entrega_spec.rb

# 2. Achar a coluna do PLANO ("(Avulsa)", "INCLUSÃO"). title JÁ foi descartado
#    (é o destinatário). Dump de uma nota avulsa real para ver onde o plano está:
docker compose exec app bin/rails runner '
  e = Entrega.por_nf(85382).first
  e&.attributes&.reject { |_, v| v.blank? }&.each { |k, v| puts "#{k.ljust(28)} #{v}" }
'

# 3. Com dado real, no mesmo período nas duas telas:
#    financeiro "N notas fiscais"  ==  Operações Global "Total de Entregas"
#    financeiro "Total Entregas"   ==  Operações "Visitas ao local" + notas fora da operação
```

</details>

---

<details>
<summary><strong>📣 Notificações: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026)</strong></summary>

> ✅ **STATUS: NO AR no ambiente de teste (24/08/2026).** Migrations aplicadas, container da ponte
> buildado e **WhatsApp pareado por QR** — número `5511920051157`, conectado às 18:07, intervalo em
> 20s. Telas de contatos, grupos, eventos, envios e conexão validadas no navegador.
>
> ⚠️ **A suíte continua sem rodar** — não há Ruby/Bundler na máquina de desenvolvimento. A sintaxe
> de todos os `.rb`/`.erb` foi conferida (com um checker que emula o handler ERB do Rails, porque
> `<%= form_with … do %>` não passa no ERB da stdlib) e a do `server.js` com `node --check`.

> Esta é a **etapa 1 de 3**. Ver "O que NÃO está aqui" no fim.

### 🎯 O problema
O canal de WhatsApp era Twilio (pago). Além disso, "quem recebe" era implícito: o
`NotificacaoService` procurava o `User` motorista **pelo nome** da consolidação. Quem não é usuário
do sistema — diretoria, cliente, terceiro — não tinha como ser avisado, e não havia tela nenhuma
para controlar isso. O corpo da mensagem era string interpolada em Ruby.

### 🆕 O que existe agora

| Tela | O quê |
|---|---|
| **Notificações → Contatos** | Cadastro manual: nome, WhatsApp e/ou e-mail, grupo. Quem tem só número recebe só WhatsApp. |
| **Notificações → Grupos** | Diretoria, Operação, Motoristas… É o **grupo** que assina os eventos. |
| **Notificações → Eventos** | Cria eventos e marca quais grupos recebem, por qual canal. Gatilho `manual` tem botão "disparar agora". |
| **Notificações → WhatsApp** | Pareamento por **QR code**, status ao vivo, envio de teste, desconectar. |
| **Notificações → Envios** | Log de tudo que saiu: destinatário, canal, situação e o erro real. |

### ⚙️ Pontos não-óbvios

**Container Node novo (`whatsapp/`).** Não existe biblioteca Ruby que fale o protocolo do WhatsApp
Web — é Baileys. A ponte expõe `/status`, `/enviar`, `/logout` e `/health`, protegida por
`WHATSAPP_TOKEN`. A **porta não é publicada** no compose: só o container do Rails alcança. Publicar
exporia um endpoint que manda mensagem em nome da empresa.

**A sessão precisa de volume.** `whatsapp_auth:/data` — sem ele, cada deploy exige escanear o QR
de novo.

**Envio serializado e com intervalo.** Disparo em rajada é o que mais causa banimento no canal não
oficial. A fila do Node serializa e o Rails pausa entre mensagens (`whatsapp_intervalo_segundos`,
nasce em 5s). Como `sleep(5) × 30 contatos` penduraria o Puma por 2min30, o envio roda em
`NotificacaoJob`, **nunca dentro da requisição**.

**`whatsapp_provedor` nasce em `twilio`.** O deploy não muda o comportamento até o ADM parear o QR
e trocar o provedor na tela. `Notificacao::Whatsapp` é o ponto único que escolhe — trocar
Twilio ↔ Baileys é um campo, não um `if` espalhado.

**Nomes de rota ≠ nomes de controller, de propósito.** `grupos_contato` e `eventos_notificacao`
têm singular igual ao plural para o Inflector (que não fala português) e o Rails sufixaria o helper
de index com `_index` — pegadinha silenciosa. As rotas se chamam `grupos`, `eventos`, `envios`.
Por isso o `form_with` dos grupos passa `url:` explícita: a rota polimórfica de `GrupoContato`
procuraria `admin_grupo_contato_path`.

**O aviso pessoal ao motorista não regrediu.** Ele continua recebendo o e-mail formatado do
`ConsolidacaoMailer` (não virou texto puro); o que mudou é que o WhatsApp passa pelo provedor
escolhido e **tudo fica logado**. Os grupos recebem uma cópia via `Despachante`, com
`envolvido: nil` para o motorista não receber duas vezes.

**Log em tabela, não em arquivo.** O envio engole exceção de propósito (um SMTP fora do ar não pode
travar um fechamento). Com sessão QR — que cai sozinha e exige repareamento — "o motorista
recebeu?" vira pergunta de rotina, e a resposta precisava sair do `log/production.log`.

### ⚠️ O risco, dito na tela
Conectar por QR usa a porta do WhatsApp Web por engenharia reversa: está **fora dos Termos do
WhatsApp** e a Meta **pode banir o número** sem aviso. A tela de pareamento diz isso em texto e
recomenda **chip dedicado**, não o número principal da operação.

### 📂 Arquivos
```
db/migrate/20260824000001_create_notificacao_contatos.rb        (NOVO)
db/migrate/20260824000002_create_notificacao_eventos.rb         (NOVO — semeia os 2 eventos atuais)
db/migrate/20260824000003_create_notificacao_envios.rb          (NOVO)
db/migrate/20260824000004_add_baileys_to_configuracao_...rb     (NOVO)
whatsapp/{server.js,package.json,Dockerfile}                    (NOVO — ponte Baileys)
docker-compose.yml                                              (serviço whatsapp + volume)
app/models/{grupo_contato,contato,evento_notificacao}.rb        (NOVO)
app/models/{grupo_evento_assinatura,notificacao_envio}.rb       (NOVO)
app/models/configuracao_notificacao.rb                          (provedor + credenciais Baileys)
app/services/notificacao/{cliente_whatsapp,whatsapp,despachante}.rb (NOVO)
app/services/notificacao_service.rb                             (grupos + provedor + log)
app/jobs/notificacao_job.rb                                     (NOVO)
app/mailers/notificacao_mailer.rb + view                        (NOVO — e-mail genérico)
app/controllers/admin/{grupos_contato,contatos,eventos_notificacao}_controller.rb (NOVO)
app/controllers/admin/{whatsapp_sessoes,notificacao_envios}_controller.rb         (NOVO)
app/policies/{contato,grupo_contato,evento_notificacao,notificacao_envio,whatsapp_sessao}_policy.rb (NOVO)
app/views/admin/{grupos_contato,contatos,eventos_notificacao,whatsapp_sessoes,notificacao_envios}/  (NOVO)
app/views/layouts/_navbar.html.erb                              (seção Notificações)
config/routes.rb + .env.example
spec/{models,services}/…                                        (NOVO)
```

> **4 migrations e 1 container novo.** Nenhuma gem nova no Gemfile.

### ⏳ Pendente — roteiro
```bash
# 1. Gerar o token da ponte e colocar no .env do servidor:
openssl rand -hex 32     # -> WHATSAPP_TOKEN=...
#    e BAILEYS_URL=http://whatsapp:3001

# 2. Subir (a 1ª vez baixa o Baileys; leva alguns minutos):
docker compose up -d --build

#    ⚠️ Se a build da ponte falhar com `npm error syscall spawn git / ENOENT`:
#    falta `git` na imagem. O Baileys puxa `libsignal` de um repositório GIT,
#    não do registry do npm. Já está no whatsapp/Dockerfile (`apk add git`) —
#    se sumir de lá, é isto. NÃO é erro de rede nem de versão do pacote.

# 3. Migrar:
docker compose exec app bin/rails db:migrate

# 4. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local):
docker compose exec app bundle exec rspec \
  spec/models/contato_spec.rb spec/models/evento_notificacao_spec.rb \
  spec/services/notificacao/

# 5. Parear: Notificações → WhatsApp → ler o QR com o CHIP DEDICADO.
#    Depois: Configurações → Notificações → provedor = Baileys, e enviar um teste.

# 6. Cadastrar um grupo, um contato e marcar o grupo nos 2 eventos de sistema.
#    Conferir o resultado em Notificações → Envios.
```

### 🚧 O que NÃO está aqui (etapas 2 e 3)
- **Editor de blocos** (arrastar cabeçalho / tabela de valores / aviso / botão / rodapé, com preview
  e HTML montado no e-mail). Hoje o texto dos 2 eventos de sistema ainda é o do código, e o disparo
  manual usa um campo de texto.
- **Gatilhos novos**: `valor_alterado`, `operacao_alterada` e `agendado` já existem como opção no
  cadastro e o `Despachante` os atende — mas **ainda não há código chamando** esses gatilhos, nem o
  job de varredura do agendado. Um evento com esses gatilhos hoje só dispara pelo botão manual.

</details>

---

<details>
<summary><strong>🧱 Editor de blocos das mensagens — ETAPA 2 (24/08/2026)</strong></summary>

> ✅ **STATUS: NO AR no ambiente de teste (24/08/2026).** Migration aplicada; a tela de eventos já
> mostra o ✓ dos canais com mensagem montada.
>
> ⚠️ **Não validado ainda**: o arrastar/soltar e o preview com dado real, na mão, no navegador — e a
> suíte. Sintaxe conferida em todos os `.rb`, `.erb` e **no JavaScript do editor** (`node --check`
> sobre o `<script>` extraído).

### 🎯 O que mudou
Na etapa 1, o corpo das mensagens ainda era string interpolada em Ruby — mudar uma palavra exigia
deploy. Agora o ADM monta a mensagem **arrastando blocos**, em
`Notificações → Eventos → WhatsApp / E-mail`.

**Blocos:** cabeçalho, texto, tabela de valores, aviso de mudança, botão/link, divisor, rodapé.
Arraste da paleta (ou clique), reordene pela alça, remova no ✕.

**Variáveis:** clique num campo e depois num chip — `{{contato}}`, `{{valor}}`, `{{entregas}}`,
`{{o_que_mudou}}`… A lista muda conforme o **gatilho** do evento (`Notificacao::Variaveis`).

**Um template por evento E por canal:** o mesmo evento tem uma mensagem de WhatsApp e outra de
e-mail, montadas separadamente. O ✓ na lista de eventos diz quais já estão montadas.

### ⚙️ Pontos não-óbvios

**O preview roda no servidor.** O botão chama `POST …/template/:canal/preview`, que instancia o
mesmo `Notificacao::Renderizador` do envio. Uma segunda implementação em JavaScript ficaria mais
rápida e **inevitavelmente divergiria do que é enviado** — e o preview existe justamente para
prometer o contrário. O preview do e-mail é exibido num `<iframe sandbox>` para os estilos do
e-mail não vazarem para o admin.

**Duas saídas do MESMO template.** `#texto` produz o WhatsApp (com `*negrito*` e `•` nas tabelas);
`#html` produz o e-mail com **estilo inline**, porque cliente de e-mail não lê CSS externo. Não usei
`simple_format` no e-mail: ele gera HTML sem os estilos que o Outlook/Gmail precisam.

**Escape em tudo, sempre.** Todo texto do editor e todo valor de variável passa por
`ERB::Util.html_escape` no caminho HTML. O corpo é digitado numa tela e o preview usa o mesmo
renderizador — um escape faltando atingiria **primeiro o próprio admin**. Botão só aceita `http(s)`:
um `javascript:` no href seria clique armado dentro do e-mail. Tem spec para os dois.

**Blocos são sanitizados na gravação.** O estado do editor viaja num campo hidden preenchido por
JavaScript — ou seja, vem de fora. `MensagemTemplate#normalizar_blocos` descarta tipo fora do
catálogo, campo que aquele tipo não tem e o que não for Hash, e limita a 40 blocos / 20 linhas por
tabela.

**A renderização acontece por destinatário.** `{{contato}}` é o primeiro nome de quem recebe — um
render compartilhado mandaria o nome da primeira pessoa para todo mundo. Renderizar é manipulação
de string; o custo por destinatário é irrelevante perto do envio.

**Fallback preservado.** Sem template montado (ou com template ativo porém **vazio**), o evento
continua usando o texto padrão do código. Sem isso, ligar o editor apagaria as notificações que já
funcionavam.

**Amostra ≠ real.** `Variaveis.amostra` só alimenta o preview; `Variaveis.comuns_reais` é o que
entra num envio de verdade. Trocar os dois colocaria uma data fixa dentro da mensagem que o contato
recebe — foi um bug que existiu por alguns minutos no disparo manual e está fixado aqui.

**`jsonb`, não tabela filha.** A ordem faz parte do dado (é lista, não conjunto), cada tipo de bloco
tem campos diferentes, e salvar o template inteiro numa transação evita estado meio-salvo.

### 📂 Arquivos
```
db/migrate/20260824000005_create_mensagem_templates.rb    (NOVO)
app/models/mensagem_template.rb                           (NOVO — sanitização dos blocos)
app/models/evento_notificacao.rb                          (#template, #template_utilizavel)
app/services/notificacao/blocos.rb                        (NOVO — catálogo, fonte única)
app/services/notificacao/variaveis.rb                     (NOVO — por gatilho + amostra)
app/services/notificacao/renderizador.rb                  (NOVO — texto + html)
app/services/notificacao/despachante.rb                   (render por canal e por destinatário)
app/services/notificacao_service.rb                       (passa as variáveis do fechamento)
app/jobs/notificacao_job.rb                               (carrega os dados)
app/mailers/notificacao_mailer.rb + view                  (corpo em HTML x texto)
app/controllers/admin/mensagem_templates_controller.rb    (NOVO — edit/update/preview)
app/views/admin/mensagem_templates/edit.html.erb          (NOVO — editor + SortableJS)
app/views/admin/eventos_notificacao/index.html.erb        (links por canal + ✓)
config/routes.rb
spec/models/mensagem_template_spec.rb                     (NOVO)
spec/services/notificacao/renderizador_spec.rb            (NOVO)
spec/services/notificacao/despachante_spec.rb             (+ template)
```

> **1 migration.** Nenhuma gem nova; o SortableJS vem de CDN, como flatpickr/Chart.js/Leaflet.

### ⏳ Pendente
```bash
docker compose exec app bin/rails db:migrate
docker compose exec app bundle exec rspec spec/services/notificacao/ spec/models/mensagem_template_spec.rb

# Depois: Notificações → Eventos → "WhatsApp" num evento → arrastar blocos →
# "Atualizar preview" → Salvar. Conferir o corpo real em Notificações → Envios.
```

### 🚧 O que falta (etapa 3)
`valor_alterado`, `operacao_alterada` e `agendado` existem como opção de gatilho, o editor já
oferece as variáveis certas para cada um e o `Despachante` os atende — mas **nada no código chama
esses gatilhos ainda**, nem existe o job que varre os agendados vencidos. Um evento com esses
gatilhos hoje só dispara pelo botão manual.

</details>

---

<details>
<summary><strong>🔔 Gatilhos: valor alterado, operação mudou e resumo agendado — ETAPA 3 (24/08/2026)</strong></summary>

> ✅ **STATUS: migrations no ar (24/08/2026).**
>
> ⚠️ **Não validado ainda**: um disparo real de cada gatilho, o `whenever --update-crontab` e a
> suíte. Sintaxe conferida em todos os `.rb`, `.rake` e `.erb`.

### 🎯 O que faltava
Nas etapas 1 e 2, `valor_alterado`, `operacao_alterada` e `agendado` existiam como opção de gatilho
e o editor já oferecia as variáveis certas de cada um — mas **nada no código os chamava**. Um evento
com esses gatilhos só disparava pelo botão manual.

### 🆕 Os três gatilhos, ligados

**`valor_alterado`** → `Consolidacao#recalcular_motorista!`. É o **funil único**: todo caminho que
mexe em pilar, desconto ou lançamento termina ali. Só dispara com a consolidação **finalizada** — em
rascunho o valor muda a cada clique do wizard, e avisar ali seria spam, não informação. Também só
dispara se o valor **realmente mudou**. `{{o_que_mudou}}` sai como
"Valor passou de R$ 4.900,00 para R$ 5.060,00". O próprio motorista entra como *envolvido*, sob o
`notificar_envolvido` do evento — dá para desligar sem perder o aviso à diretoria.

**`operacao_alterada`** → dois caminhos, de propósito:
- **Imediato**: `Admin::EdicaoLancamentosController#atualizar`. Sabe exatamente qual NF e o que
  mudou (`status: pending → completed`).
- **Varredura**: `DetectarMudancasOperacaoJob` compara os números de cada operação com o retrato
  anterior (`operacao_snapshots`) e avisa "3 notas na operação a mais, 1 em aberto a menos".

  Por que os dois: a **maioria** das mudanças da operação não passa pelo nosso código — acontece no
  SimpliRoute e chega pelo sync do espelho, que é read-only aqui. Só o hook interno cobriria a
  minoria dos casos.

**`agendado`** → `DispararEventosAgendadosJob`, de hora em hora pelo cron. Os números vêm de
`Analytics::ResumoOperacao`, que **reusa** `OperacaoMetricas` e `NotasForaOperacao` — o resumo que
chega no WhatsApp não pode dizer coisa diferente do dashboard.

### ⚙️ Pontos não-óbvios

**Varredura horária, não uma linha de cron por evento.** Hora e frequência são escolhidas na TELA e
mudam a qualquer momento; reler o crontab a cada edição acoplaria a UI ao cron do sistema.
`EventoNotificacao#vencido?` + `ultimo_disparo_em` garantem **um disparo por dia**.

**Janelas FECHADAS.** Diária = o dia anterior; semanal = os 7 dias anteriores. Um resumo sobre "hoje"
mudaria de número depois de enviado.

**Período sem movimento não vira mensagem** — mas marca `ultimo_disparo_em` assim mesmo, senão a
varredura tentaria de hora em hora até o dia virar. Resumo zerado num feriado só treina o leitor a
ignorar a mensagem.

**A primeira varredura de cada operação só grava o retrato.** Sem retrato anterior, toda operação
existente pareceria "nova" e o primeiro deploy dispararia uma mensagem por operação cadastrada.

**Os eventos nascem ativos e isso é seguro.** A migration cria "Valor alterado no fechamento" e
"Dados da operação mudaram" como eventos de sistema — sem grupo assinante não há destinatário, então
nada sai até o ADM marcar um grupo. Sem esse seed, `disparar(chave: 'valor_alterado')` não acharia
evento nenhum e o gatilho ficaria mudo.

**Nada nos gatilhos levanta.** Uma notificação não pode derrubar um fechamento nem uma edição de
lançamento — `Notificacao::Gatilhos` engole e loga, como o resto do módulo.

**Custo conhecido da varredura:** `OperacaoMetricas` carrega as visitas em memória para deduplicar
por NF, e o job varre todas as tabelas 8×/dia. É o preço de reusar a contagem do dashboard em vez de
escrever um SQL paralelo que divergiria dele. Se incomodar, o caminho é restringir às operações do
mês corrente e do anterior — as antigas não mudam mais. Está anotado no código.

### 📂 Arquivos
```
db/migrate/20260824000006_create_operacao_snapshots.rb   (NOVO)
db/migrate/20260824000007_semear_eventos_de_gatilho.rb   (NOVO — idempotente)
app/models/operacao_snapshot.rb                          (NOVO — retrato + diferença legível)
app/models/consolidacao.rb                               (hook em recalcular_motorista!)
app/services/notificacao/gatilhos.rb                     (NOVO — os 3 gatilhos)
app/services/analytics/resumo_operacao.rb                (NOVO — reusa as métricas do dashboard)
app/jobs/disparar_eventos_agendados_job.rb               (NOVO)
app/jobs/detectar_mudancas_operacao_job.rb               (NOVO)
app/controllers/admin/edicao_lancamentos_controller.rb   (dispara operacao_alterada)
app/services/notificacao/despachante.rb                  (dono do ultimo_disparo_em saiu daqui)
lib/tasks/notificacao.rake + config/schedule.rb          (cron)
spec/services/notificacao/gatilhos_spec.rb               (NOVO)
spec/jobs/*_spec.rb                                      (NOVO)
```

### ⏳ Pendente
```bash
docker compose exec app bin/rails db:migrate
# O cron do container já roda `whenever --update-crontab` no boot; num container
# em pé, forçar:
docker compose exec app bundle exec whenever --update-crontab
docker compose restart app

docker compose exec app bundle exec rspec spec/jobs spec/services/notificacao spec/models

# Teste manual: criar um evento "Resumo diário" (gatilho Agendado, hora = agora),
# marcar um grupo, e rodar à mão:
docker compose exec app bundle exec rake notificacao:agendados
docker compose exec app bundle exec rake notificacao:mudancas_operacao
```

</details>

---

<details>
<summary><strong>🛠️ Deploy das notificações: os 3 tropeços e as correções (24/08/2026)</strong></summary>

Registro do que quebrou entre "implementado" e "no ar", porque os três são armadilhas que voltam.

### 1. `PG::UndefinedTable: relation "grupo_contatos" does not exist`
`t.references :grupo_contato, foreign_key: true` faz o Rails **pluralizar** o nome para achar a
tabela alvo. O Inflector não fala português: `grupo_contato` → `grupo_contatos` e
`evento_notificacao` → `evento_notificacaos`. As tabelas reais são `grupos_contato` e
`eventos_notificacao` (plural no **primeiro** termo).

**Correção:** `foreign_key: { to_table: :grupos_contato }` nas 5 referências afetadas. `user` e
`contato` pluralizam certo e não precisaram.

> É a MESMA armadilha que já tinha aparecido nas rotas (`grupos`/`eventos`/`envios` em vez dos nomes
> dos controllers). Sempre que uma tabela em português tiver o plural no primeiro termo, `to_table:`
> e nome de rota explícito são obrigatórios.

### 2. `npm error syscall spawn git / ENOENT` na build da ponte
`errno -2` = o **binário** `git` não existe na imagem. O `node:20-alpine` não traz git, e o Baileys
resolve `libsignal` de um **repositório git**, não do registry do npm.

**Correção:** `apk add --no-cache git` no `whatsapp/Dockerfile`. Não é erro de rede, de firewall nem
de versão do pacote — e a linha do `apk` tem comentário explicando, para não ser "limpa" depois.

### 3. Não havia como escolher o provedor
As colunas `whatsapp_provedor`, `baileys_url`, `baileys_token` e `whatsapp_intervalo_segundos`
nasceram na migration, mas a tela **Configurações → Notificações** continuou 100% Twilio. Como o
padrão da coluna é `twilio`, o caminho do QR ficava **inalcançável pela interface** — dava para
parear e nada usaria a ponte. Pior: a tela do QR linkava para lá dizendo que o intervalo se
configurava ali, o que era falso.

**Correção:** seletor de Provedor, campo de intervalo e bloco "Modo QR code" (endereço + token, em
branco caem no `.env`) na tela de configuração; e `Notificacao::TesteWhatsapp` passou a ramificar
pelo provedor em vez de ir direto ao Twilio.

### 4. Evento criado pelo ADM não disparava
`Despachante.disparar(chave:)` buscava **um** evento pela chave. Como o código de negócio chama com
a chave fixa (`'consolidacao_finalizada'`…), só o evento de **sistema** disparava: um evento criado
na tela com o mesmo gatilho ficava mudo para sempre, **sem erro nenhum**. A tela oferecia o gatilho
e não acontecia nada.

**Correção:** `Despachante.disparar_gatilho(gatilho:)` dispara **todos** os eventos ativos daquele
gatilho, cada um com seus grupos e sua mensagem — que é justamente o ponto de poder cadastrar
eventos. O `disparar(chave:)` continua existindo para o botão "disparar agora", que mira um evento
específico.

</details>

---

<details>
<summary><strong>💡 Ideias para o editor de mensagens — backlog priorizado</strong></summary>

Levantado com a tela já em uso. Ordenado por **valor ÷ esforço**; nada aqui está implementado.

### A. Chegar até o editor

O problema, olhando a tela de eventos hoje: os acessos são **dois links de texto cinza**
("WhatsApp" e "E-mail") do lado de "Editar", com o mesmo peso visual. Nada diz que ali se
**escreve a mensagem** — parece mais um filtro de canal. E o ✓ de "já tem mensagem montada" é
pequeno demais para ser lido de relance.

| # | Ideia | Esforço |
|---|---|---|
| A1 | **Botão "Montar mensagem"** com ícone, no lugar dos dois links soltos. Abre o editor com abas WhatsApp/E-mail **dentro** dele, em vez de duas portas separadas. | baixo |
| A2 | **Estado como badge colorido** — `WhatsApp ✓ · E-mail —` em verde/cinza, não link. Mostra num relance o que falta. | baixo |
| A3 | **Miniatura do preview no card do evento**: as 2 primeiras linhas da mensagem renderizada. Responde "o que esse evento manda?" sem entrar. | médio |
| A4 | **Passo seguinte explícito**: ao salvar um evento novo, redirecionar para o editor com "Agora monte a mensagem". Hoje você cria o evento e fica sem pista do que fazer. | baixo |
| A5 | **Item "Mensagens" no menu** — tabela evento × canal com o estado de cada um. Hoje só se chega pelo evento. | médio |
| A6 | **Alerta de evento mudo**: ativo, com grupo assinante e sem mensagem própria → avisar que vai usar o texto padrão do sistema. | baixo |

### B. Design das mensagens

Hoje: 7 blocos, cores fixas no renderizador, só texto.

| # | Ideia | Esforço | Observação |
|---|---|---|---|
| B1 | **Ocultar bloco quando a variável estiver vazia** — checkbox por bloco. Resolve o "⚠️ Alterações: " sem nada depois, que hoje aparece. | baixo | O ganho de qualidade mais barato da lista. |
| B2 | **Enviar teste direto do editor** — "mandar para o meu WhatsApp" sem sair da tela. Hoje: salvar → eventos → disparo manual. | baixo | |
| B3 | **Modelos prontos** ("Aviso de fechamento", "Resumo diário", "Alteração de valor") — começar de um layout em vez da folha em branco. | médio | |
| B4 | **Duplicar template** entre canais e entre eventos ("copiar do WhatsApp para o e-mail"). | baixo | |
| B5 | **Cor de destaque configurável** — hoje o laranja está fixo no `Renderizador`. | baixo | |
| B6 | **Logo no cabeçalho do e-mail** | médio | Precisa de URL absoluta pública ou embed em base64; cliente de e-mail bloqueia imagem remota por padrão. |
| B7 | **Bloco de lista** (bullets) — hoje só existe tabela rótulo/valor. | baixo | |
| B8 | **Preview em moldura de celular** para o WhatsApp e alternância desktop/mobile no e-mail. | médio | Hoje o preview do WhatsApp é um `<pre>` escuro. |
| B9 | **Contador de caracteres** no WhatsApp — mensagem longa vira "ler mais" e o começo é o que decide se abrem. | baixo | |
| B10 | **Bloco de imagem** | **alto** | No e-mail é `<img>`. No WhatsApp exige **enviar mídia**, e a ponte hoje só faz `sendMessage` de texto — mexe no `server.js`, no cliente Ruby e no log de envios. |
| B11 | **Versões do template** — voltar à anterior depois de estragar. | médio | |

### Se fosse escolher três
**A1 + A2** (o editor deixa de ser escondido) e **B1** (mensagem sem buraco quando a variável vem
vazia). Juntos são baixo esforço e resolvem o que mais incomoda no uso diário.

</details>

---

<details>
<summary><strong>👥 Envio para GRUPO do WhatsApp (24/08/2026)</strong></summary>

> ⚠️ **STATUS: implementado, não executado.** 1 migration nova. Sintaxe conferida em `.rb`, `.erb`,
> no JavaScript do formulário e no `server.js`.

### 🎯 O caso de uso
Informe pontual para o **grupo de motoristas** da própria empresa, com o número já dentro do grupo.
É o oposto do padrão de spam — não é mensagem para desconhecido, é aviso interno.

### ⚙️ Por que não funcionava
Grupo no WhatsApp **não é telefone**: é um identificador próprio (`120363012345678901@g.us`). Três
pontos assumiam pessoa:

1. `jidDe()` no `server.js` colava `@s.whatsapp.net` nos dígitos — id de grupo nem passava.
2. A checagem `onWhatsApp()` (que evita mandar para número inexistente, um dos sinais que a Meta usa
   para marcar conta de spam) **responde sobre números** e devolveria vazio para um grupo,
   derrubando um envio válido.
3. `Contato` exigia telefone brasileiro válido.

### 🆕 Como ficou
**Contato ganhou tipo**: `pessoa` ou `grupo_whatsapp`. O grupo entra como Contato de propósito —
herda grupo interno, assinatura de eventos e log de envios. Tabela separada duplicaria essa máquina
inteira.

**A lista de grupos vem da ponte** (`GET /grupos` → `groupFetchAllParticipating`). O id é opaco;
digitar na mão é pedir erro. No formulário, "Buscar grupos" traz os nomes com a contagem de
participantes.

**Grupo restrito é avisado ANTES.** Se o grupo tem "somente administradores" e o número conectado
não é admin, a opção aparece com ⚠️ na lista — senão o envio falharia e o erro só apareceria depois,
no log.

**O nome do grupo é gravado junto com o id** (`whatsapp_grupo_nome`): a tela precisa mostrar algo
legível mesmo com a ponte fora do ar.

**Twilio recusa com mensagem clara.** O Twilio não envia para grupo; em vez de erro genérico, a
resposta diz para trocar o provedor para QR.

### ⚠️ Limites, ditos na tela
- O número **precisa já ser membro** do grupo. A ponte não entra em grupo.
- Grupo com "somente admins" recusa se o número não for admin.
- `{{contato}}` num grupo é o **nome inteiro** do grupo — cortar "Motoristas SP" em "Motoristas"
  só empobreceria o texto.
- Cadastro não fica meio pessoa, meio grupo: ao escolher grupo, telefone e e-mail são limpos.
  Senão a mesma mensagem sairia duas vezes.

### 📂 Arquivos
```
db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb  (NOVO)
whatsapp/server.js                        (jidDe aceita @g.us; GET /grupos; pula onWhatsApp em grupo)
app/models/contato.rb                     (tipo, destino_whatsapp, validações por tipo)
app/services/notificacao/whatsapp.rb      (JID passa direto; Twilio recusa)
app/services/notificacao/cliente_whatsapp.rb (#grupos)
app/services/notificacao/despachante.rb   (usa destino_whatsapp)
app/controllers/admin/contatos_controller.rb + rota  (JSON de grupos, sob demanda)
app/views/admin/contatos/_form.html.erb   (seletor de tipo + busca de grupos)
app/views/admin/contatos/index.html.erb   (marca "grupo")
spec/models/contato_spec.rb, spec/services/notificacao/whatsapp_spec.rb
```

### ⏳ Pendente
```bash
docker compose up -d --build whatsapp     # o server.js mudou
docker compose exec app bin/rails db:migrate

# Depois: Notificações → Contatos → Novo contato → Tipo "Grupo do WhatsApp"
#         → "Buscar grupos" → escolher → pôr num grupo interno que assine o evento.
```

</details>

---

<details>
<summary><strong>🎨 Ponte desatualizada, nome do Baileys fora da tela e padronização visual (25/08/2026)</strong></summary>

> ⚠️ **STATUS: implementado, não executado.** Sem migration. Sintaxe conferida nos 16 `.erb`
> tocados e nos 2 `.rb`. **Testes não rodados** — não há bundler na máquina de desenvolvimento,
> só no container.

### 1. `rota desconhecida` ao buscar os grupos

O botão "Buscar grupos" do cadastro de contato respondia **`rota desconhecida`**. Não era bug do
Rails: é o 404 da própria ponte (`whatsapp/server.js`, fallback de rota inexistente) repassado cru
para a tela.

A rota `GET /grupos` existe no código desde o commit `568f185`, mas **o container `whatsapp` do
ambiente de teste rodava uma imagem construída antes dele**. O deploy subiu o `app` e reaproveitou
a imagem em cache da ponte.

**Correção no servidor:**
```bash
docker compose up -d --build whatsapp
# se o cache ainda entregar a imagem velha:
docker compose build --no-cache whatsapp && docker compose up -d whatsapp
```
O volume `whatsapp_auth` é preservado — **não precisa ler o QR de novo**.

> 🔁 **A armadilha que volta:** toda vez que `whatsapp/server.js` ganha rota nova, a ponte precisa
> de rebuild próprio. `docker compose up -d --build` sem nomear o serviço pode reaproveitar a
> camada em cache, e o sintoma é sempre este: a tela nova chama uma rota que a ponte em pé não
> conhece. Vale conferir também que o `whatsapp/server.js` foi junto no merge para a `main` — o
> recurso de grupos ainda **não está lá**.

**Correção no código:** o 404 da ponte deixou de aparecer cru. `ClienteWhatsapp#mensagem_de_erro`
agora traduz para *"Ponte do WhatsApp desatualizada (não conhece esta rota) — refaça o build do
container `whatsapp`"*. É cosmético e **não substitui o rebuild**; serve para a próxima vez o erro
dizer o que fazer.

### 2. O nome "Baileys" saiu da interface

Nenhum texto que o usuário lê cita mais a biblioteca. A env var mostrada na tela de configuração
virou **`WHATSAPP_URL`**, e `baileys_url_efetiva` lê nesta ordem:

```
banco  →  ENV['WHATSAPP_URL']  →  ENV['BAILEYS_URL']  →  'http://whatsapp:3001'
```

O nome antigo continua sendo lido de propósito: **nenhum `.env` já em produção quebra**.

> **Ficou de fora, de propósito:** as colunas `baileys_url` / `baileys_token` e os métodos
> `baileys?`, `baileys_pronto?`, `BAILEYS_URL_PADRAO` mantêm o nome no banco e no código.
> Renomear exigiria migration + model + controller + service + specs, e **nada disso aparece na
> tela** — os rótulos que o usuário lê já eram "Endereço da ponte" e "Token da ponte". O valor
> `'baileys'` do seletor de provedor também continua: é chave interna, o rótulo visível é
> "QR code (grátis, não oficial)".

### 3. Padronização visual das telas de notificação

As 9 telas do módulo nasceram com um dialeto próprio e destoavam do resto do admin. O padrão de
referência é **Usuários** — a tela mais madura. O que mudou:

| Antes (telas de notificação) | Agora (padrão do admin) |
|---|---|
| Botão `bg-orange-500 text-black`, `py-2.5` | `bg-[#f97316] text-white` com ícone, `min-h-[48px]` |
| `thead` com `bg-white/5`, `py-3`, `font-medium` | `border-b border-white/10`, `py-4`, `font-semibold` |
| Ações "Editar"/"Excluir" em texto | Ícones com `opacity-0 group-hover:opacity-100` |
| Vazio: `<p>` solto fora da tabela | Linha na tabela, ícone `:vazio`, `py-16` |
| Situação em texto colorido | `badge_status_usuario` / `badge_com_icone` |
| Inputs `bg-[#1a1a1a]`, `py-2.5` | `bg-[#0a0a0a]`, `py-3`, `focus:ring` laranja |
| Checkbox de ativo/inativo | Toggle switch, igual ao de Usuários |
| Formulário solto na página | Card `rounded-2xl border` com seções divididas |

**Correções de comportamento que vieram junto:**

- **`render 'shared/flash'` nas telas que não tinham.** Mensagens de sucesso e erro simplesmente
  **não apareciam** em Contatos, Grupos, Eventos e Envios — a ação dava certo e a tela ficava muda.
- **O aviso do "Buscar grupos" ganhou cor semântica** — verde no sucesso, vermelho no erro. Antes
  toda resposta saía em cinza, então "5 grupos encontrados" e "não foi possível falar com a ponte"
  tinham exatamente o mesmo peso visual.
- O rótulo do botão de busca virou um `<span>` próprio: trocar o `textContent` do botão inteiro
  apagava o ícone junto.

**Ganhos de leitura:** avatar distingue pessoa de grupo em Contatos; os filtros de Envios ficaram
agrupados por "Situação" e "Canal" com pílulas; o ponto verde de "Conectado" pulsa; evento
desativado fica esmaecido na lista.

### 📂 Arquivos
```
app/models/configuracao_notificacao.rb           (WHATSAPP_URL com fallback p/ BAILEYS_URL)
app/services/notificacao/cliente_whatsapp.rb     (404 da ponte vira mensagem acionável)
.env.example                                     (WHATSAPP_URL; nome antigo documentado)
app/views/admin/configuracao_notificacoes/show.html.erb   (nome da lib fora da tela)
app/views/admin/contatos/{index,_form,new,edit}.html.erb
app/views/admin/grupos_contato/{index,_form,new,edit}.html.erb
app/views/admin/eventos_notificacao/{index,_form,new,edit}.html.erb
app/views/admin/notificacao_envios/index.html.erb
app/views/admin/whatsapp_sessoes/show.html.erb
app/views/admin/mensagem_templates/edit.html.erb
```

### ⏳ Pendente
```bash
docker compose up -d --build whatsapp     # ⚠️ o que resolve o "rota desconhecida"
docker compose restart app                # views mudaram → cache de views do Puma

docker compose exec app bundle exec rspec

# Conferir na tela: Notificações → Contatos → Novo contato
#   → Tipo "Grupo do WhatsApp" → "Buscar grupos" → a lista deve vir com nome,
#     nº de participantes e ⚠️ nos grupos restritos a admin.
```

### 🐛 Achado não corrigido
`app/views/admin/mensagem_templates/edit.html.erb` carrega o **SortableJS de
`cdn.jsdelivr.net`**. Se a CSP passar de `report_only` para enforcing (pendência já registrada
neste README), o **arrastar-e-soltar dos blocos morre** — o clique na paleta sobrevive, porque o
código tem `if (window.Sortable)`. O conserto é baixar o arquivo para `public/`. Não foi feito por
estar fora do escopo pedido.

</details>

---

<details>
<summary><strong>🧭 O que falta: gatilhos pela tela e blocos ricos na mensagem — backlog</strong></summary>

Complementa o backlog do editor já registrado acima. **Nada aqui está implementado.**

### A. Criação de gatilhos pela tela

**Como é hoje:** o ADM cria quantos **eventos** quiser, mas o **gatilho** sai de uma lista fixa de
seis — `manual`, `consolidacao_finalizada`, `pagamento_efetuado`, `valor_alterado`,
`operacao_alterada`, `agendado`. A tela diz isso ao usuário: *"o gatilho sai de uma lista fixa,
porque gatilho é código"*. E é verdade — cada gatilho tem uma chamada correspondente no código de
negócio (`Notificacao::Gatilhos`, `NotificacaoService`, controllers).

**O que "criar gatilho pela tela" realmente significa** — três níveis, do barato ao caro:

| # | Ideia | Esforço | Observação |
|---|---|---|---|
| A1 | **Gatilho agendado com filtro** — "todo dia 08h **se houver consolidação pendente**". Não é gatilho novo: é o `agendado` com uma condição escolhida numa lista. | baixo | Cobre boa parte do que se pede como "gatilho novo". |
| A2 | **Gatilho por limiar** — "quando o valor alterado passar de R$ X" ou "quando as notas fora da operação passarem de N". Campos: métrica (lista), operador, valor. | médio | Reusa `Analytics::ResumoOperacao` e `OperacaoSnapshot`, que já calculam as métricas. |
| A3 | **Gatilhos que faltam no domínio** — consolidação **arquivada**, contato **sem grupo** há X dias, envio **falhado** (avisar o ADM que o WhatsApp caiu). | médio | São gatilhos de código mesmo: uma constante + a chamada no ponto certo. O caminho já está pavimentado por `disparar_gatilho`. |
| A4 | **Condição livre por regra** (construtor de expressão na tela) | **alto** | Vira uma mini-linguagem: precisa de parser, sandbox e validação. **Não recomendado** — A1+A2 entregam quase tudo por uma fração do custo. |

> ⚠️ **A armadilha aqui:** gatilho cadastrado na tela que **não tem chamada no código** fica mudo
> para sempre, sem erro nenhum — exatamente o bug nº 4 do deploy de 24/08. Qualquer caminho
> escolhido precisa de uma trava: ou a lista continua vindo do código, ou a tela avisa que o
> gatilho ainda não está ligado.

### B. Mais opções na criação da mensagem

**Como é hoje:** 7 blocos (`cabecalho`, `texto`, `tabela`, `aviso`, `botao`, `divisor`, `rodape`),
todos **só texto**, com cores fixas no renderizador.

| # | Ideia | Esforço | Observação |
|---|---|---|---|
| B1 | ✅ **FEITO (26/08/2026)** — Seletor de emoji nos campos de texto. | baixo | Um picker pequeno, sem dependência externa (a CSP é restritiva). Inserir no cursor: a mecânica já existe, é a mesma dos chips de variável. |
| B2 | **Bloco de imagem no e-mail** — `<img>` com URL ou upload. | médio | Cliente de e-mail bloqueia imagem remota por padrão → precisa de URL absoluta pública ou base64. |
| B3 | **Bloco de imagem no WhatsApp** | **alto** | A ponte hoje só faz `sendMessage` de **texto**. Exige `sendMessage(jid, { image })` no `server.js`, endpoint novo, envio multipart no `ClienteWhatsapp`, storage do arquivo e uma coluna no log de envios. **É o item mais caro da lista** — vale só se a demanda for real. |
| B4 | **Formatação do WhatsApp** (`*negrito*`, `_itálico_`, `~riscado~`, ``` `mono` ```) com botões — hoje o ADM precisa saber a sintaxe de cor. | baixo | No e-mail cada marca vira a tag equivalente. |
| B5 | **Bloco de lista com bullets** — hoje só existe tabela rótulo/valor. | baixo | Já estava listado como B7 no backlog anterior. |
| B6 | **Ocultar bloco com variável vazia** — resolve o `"⚠️ Alterações: "` sem nada depois. | baixo | Já listado como B1 antes; **continua sendo o melhor valor ÷ esforço do módulo inteiro**. |
| B7 | **Cor de destaque configurável** — o laranja está fixo no `Renderizador`. | baixo | |
| B8 | **Contador de caracteres no WhatsApp** — mensagem longa vira "ler mais", e o começo é o que decide se abrem. | baixo | |
| B9 | **Bloco de menção** (`@membro`) em grupo do WhatsApp | médio | Baileys exige passar os JIDs em `mentions` além do texto — não basta escrever `@`. |

### Se fosse escolher três
**B6** (mensagem sem buraco quando a variável vem vazia), **B1** (emoji) e **B4** (formatação do
WhatsApp por botão). Os três são de esforço baixo e atacam o que mais se sente escrevendo mensagem
no dia a dia. **B3 (imagem no WhatsApp) fica por último** — é o único que mexe na ponte, no cliente
Ruby e no schema ao mesmo tempo.

</details>

---

<details>
<summary><strong>🔐 Atualização 26/08/2026 — Perfis de acesso, variáveis de mensagem e correção de vazamento nos erros</strong></summary>

Três frentes numa rodada só: **quem vê o quê** (perfis), **o que dá para escrever numa mensagem**
(variáveis + emoji) e **o que o sistema mostrava a mais quando dava erro** (segurança).

---

## 1. Perfis de acesso — o ADM monta o acesso sem deploy

**Antes:** o acesso saía de `users.role` (admin/gerente/operador/motorista/externo) e de
`if admin? || gerente?` espalhado pelas policies. Tirar as Configurações de **um** gerente exigia
editar código e subir versão.

**Agora:** `Administração → Perfis de acesso`. O ADM cria perfis marcando caixas agrupadas por área
e atribui o perfil à pessoa em `Usuários`.

| Peça | Onde | O que faz |
|---|---|---|
| Catálogo de permissões | `app/models/permissao.rb` | **33 chaves** em 4 grupos (`dashboard`, `consolidacao`, `notificacao`, `admin`), cada uma com o **efeito real** escrito ("Baixar a planilha da operação — é dado saindo do sistema"). É código, não tabela: cada chave corresponde a um `authorize` de verdade. |
| Perfil | `app/models/perfil_acesso.rb` + `perfis_acesso` | Nome, descrição, lista de permissões (jsonb), `sistema` (não apagável) e `ativo`. |
| Ligação | `users.perfil_acesso_id` | **Nulo é válido**: sem perfil, vale o padrão do papel antigo (`Permissao::PADRAO_POR_ROLE`) — é o que faz ninguém perder acesso no dia do deploy. |
| Régua | `User#pode?('chave')` | Todas as 13 policies e a navbar passaram a perguntar por **permissão**, não por papel. |

**Tipo de conta ≠ perfil de acesso.** O select antigo se chamava "Perfil de acesso" mas editava
`role`. Agora são dois campos: **Tipo de conta** decide *como a pessoa entra* (motorista entra por
PIN e tem painel próprio); **Perfil de acesso** decide *o que ela enxerga*.

**Migração automática** (`20260826000001..3`): cria os perfis `Administrador`, `Gerente`,
`Operador` e `Externo` a partir do comportamento atual e atribui a cada usuário o perfil do papel
que ele já tinha.

### Três falhas de acesso corrigidas junto

| Falha | O que acontecia | Correção |
|---|---|---|
| **Auto-promoção a admin** | `UserPolicy#update?` libera editar a própria ficha e o form aceitava `role` → qualquer usuário logado virava admin editando o próprio cadastro. | `role` e `perfil_acesso_id` só entram nos strong params de quem tem `admin.usuarios_gerenciar`, e **nunca sobre si mesmo** (`UserPolicy#alterar_acesso?`). |
| **`externo` via o financeiro** | `DashboardController` usava `skip_authorization`: o papel "Externo (só dashboard)" enxergava custo, pagamento e o PDF financeiro. | Dashboard passou pelo Pundit; sem `dashboard.financeiro` os blocos de custo **nem são carregados**. |
| **Conta inativa logava** | Só o login por PIN checava `ativo?`; conta de e-mail/senha desativada entrava normalmente. | `User#active_for_authentication?` (Devise) + mensagem em pt-BR. |

### Duas travas que evitam tiro no pé

- **Último administrador** (`User.administradores_de_acesso`): não dá para salvar um perfil, trocar
  alguém de perfil, desativar ou excluir usuário se isso deixar o sistema **sem ninguém ativo**
  capaz de mexer em perfis e usuários. Sem isso, a volta seria só por console.
- **Tela `/sem-acesso`**: a raiz do app é o dashboard e o "acesso negado" mandava para a raiz — um
  perfil sem `dashboard.ver` entraria em **loop** (nega → raiz → nega). Agora o destino é a primeira
  tela que a pessoa pode abrir (`User#home_rota`), e quem não pode abrir nenhuma cai numa página que
  explica a quem pedir.

---

## 2. Variáveis de mensagem — de 6 fixas para 31 + as suas

**Antes:** `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho. O ADM via 6 variáveis
e qualquer texto com número ("entregas do mês") exigia deploy.

**Agora:** `Notificacao::CatalogoVariaveis`, com quatro origens — e o editor mostra **todas**,
agrupadas:

| Origem | Exemplos | Observação |
|---|---|---|
| **Do que aconteceu** | `{{motorista}}`, `{{valor}}`, `{{consolidacao}}`, `{{nf}}` | Só têm valor no gatilho que as produz. As de outro gatilho aparecem **apagadas com "·"**, avisando que sairiam em branco — liberdade com aviso, em vez de lista curta. |
| **Empresa e data** | `{{empresa}}`, `{{data}}`, `{{hora}}`, `{{dia_semana}}`, `{{mes}}`, `{{mes_ano}}`, `{{link_sistema}}` | Sempre disponíveis. |
| **Números do sistema** | `{{entregas_mes}}`, `{{entregas_hoje}}`, `{{motoristas_ativos_mes}}`, `{{consolidacoes_abertas}}`, `{{total_a_pagar_mes}}`, `{{total_pago_mes}}`, `{{total_consolidado_mes}}` | **Consultam o banco na hora do envio.** É o que permite mandar relatório por WhatsApp/e-mail sem programação. |
| **Suas variáveis** | `{{telefone_suporte}}`, `{{horario_atendimento}}`… | Tela nova: `Notificações → Variáveis`. Valor fixo, mudou ali mudou em todas as mensagens. |

**Duas decisões que importam:**

1. **Resolução sob demanda** (`Notificacao::ResolvedorVariaveis`): só a variável realmente escrita no
   template vira consulta. Resolver o catálogo inteiro em todo disparo faria dezenas de queries para
   preencher variável que ninguém usou.
2. **O contexto do gatilho sempre vence** o catálogo — o `{{valor}}` daquele pagamento nunca é
   trocado por um número genérico. E nenhuma variável derruba um disparo: falha vira texto vazio.

**Emoji** (item B1 do backlog abaixo — ✅ feito): paleta de 27 emojis no editor, insere no cursor
reusando a mecânica dos chips de variável. Sem dependência externa, por causa da CSP.

---

## 3. Segurança — o que a página de erro mostrava

O servidor sobe com `RAILS_ENV=development` (`docker-compose.yml`), e development tinha
`consider_all_requests_local = true`. A página **"Action Controller: Exception caught"** mostra
parâmetros, **sessão** (`session_id`, `_csrf_token`, id do usuário logado), cookies, IP do cliente,
caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocasse um erro.

| Problema | Correção |
|---|---|
| Painel de debug na tela do usuário | Detalhe só com `ERROS_DETALHADOS=true` no `.env` (máquina de desenvolvimento). No servidor, sai a página estática. |
| **Senha e PIN em texto puro no log** — não existia `filter_parameter_logging.rb` neste projeto | Criado, com nomes em português e inglês (`senha`, `pin_code`, `password`, `token`, `whatsapp_token`…). **Era o pior dos três**: log é permanente e vai junto em backup e em suporte. |
| Tela em branco ao desligar o painel | `public/500.html`, `404.html` e `422.html` em português, sem CSS externo (precisam funcionar com a aplicação fora do ar). |
| Páginas de debug salvas versionadas | `Erros/` saiu do git e entrou no `.gitignore`. Nos arquivos há `session_id`, token CSRF, id de usuário e IPs — **não há cookie de sessão assinado**, então ninguém entra no sistema com aquilo, mas não é conteúdo de repositório. |

> ⚠️ **Correção estrutural ainda pendente:** subir o servidor com `RAILS_ENV=production`
> (`production.rb` já tem `consider_all_requests_local = false` e `force_ssl = true`). Enquanto isso
> não acontece, o `ERROS_DETALHADOS` cobre o buraco.

---

## 4. Telas que mudaram nesta rodada

- **Dashboard de Operações**: no modo Operação o período **não recorta mais** os KPIs — eles voltam a
  bater 1-para-1 com a planilha entregue ao cliente. Uma operação mensal executa entregas fora do mês
  do nome (a `gade_entregas_emad_ago_2026` rodou de 30/07 a 12/08): filtrar "01/08 → hoje" mostrava
  **1146 das 2021 NFs**. O período escolhido virou referência no cabeçalho.
- **Consolidações**: aba **"Por motorista"** na própria tela (não é tela nova), com o total de cada
  um, drill-down inline mostrando as consolidações que compõem o valor, PDF completo/resumo/por
  motorista, e o filtro **Consolidado (fechadas) × Geral (com rascunhos)**. O recorte de datas foi
  unificado com o do dashboard financeiro (consolidações que **cruzam** o período + as pagas nele),
  que era a origem de dois valores diferentes para o mesmo motorista.
- **Painel do motorista**: refeito. Período em chips (Este mês / Mês passado / 3 meses / Tudo), card
  do valor **fechado** com "já recebido" e "a receber", PDF do período, e a composição do valor.
  O card de estimativa **nunca aparece junto do fechado** (o estimado costuma ser maior e virava
  cobrança); com o período já fechado, ele dá lugar à contagem de entregas, sem R$.
- **Listas longas** (`carrossel_controller.js`): 5 itens por página no celular, 10 até 1366px, lista
  inteira acima disso. As setas flutuam nas laterais em tela com espaço e viram barra no rodapé no
  celular, onde não há faixa lateral livre.

</details>

---

<details>
<summary><strong>🛠️ 27/08/2026 — Deploy: relógio, arquivos órfãos e tudo que caiu junto</strong></summary>

Um relato só, porque foi **uma corrente**: um problema descoberto expunha o
seguinte. Vale mais como mapa de diagnóstico do que como changelog.

| Sintoma na tela | Causa real | Onde se resolveu |
|---|---|---|
| Hora sempre 3h adiantada | Container subia em **UTC** | `Dockerfile` (TZ + `/etc/localtime`) |
| Hora errada depois de reboot | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (chrony + NTS) |
| `env file .env not found` | `.env` não é versionado e a pasta foi recriada | recriar no servidor + backup fora da árvore |
| `Couldn't find Active Storage configuration` | `config/storage.yml` nunca esteve no git | arquivo versionado |
| `port is already allocated` | Porta 3001 vivia como alteração **local** no compose | `${PORTA_APP:-3001}` |
| `Blocked hosts: 100.75.222.23:3001` | `config.hosts` ligado só com o domínio | `HOSTS_PERMITIDOS` |
| `Translation missing … devise.failure` | `fallbacks = true` caía no próprio pt-BR | `fallbacks = [:en]` + traduções |
| 422 "recusada por verificação de segurança" | `assume_ssl` fixo x acesso por IP em HTTP | gate do `FORCE_SSL` |
| `URI::InvalidURIError: … SEU_IP` | `DATABASE_URL` com o valor de exemplo | falha rápida no boot |

## 1. Relógio: fuso é do container, hora é do host

A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails mostrava a hora certa
(`config.time_zone`), mas **tudo do sistema operacional** ficava adiantado — e o
pior caso não era visual:

> **O cron rodava no horário errado.** `every "*/30 8-18"` executava das **05h às
> 15h** de Brasília.

O `Dockerfile` agora fixa `TZ=America/Sao_Paulo` **e** o symlink de
`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a variável.
Mesmo tratamento no `whatsapp/Dockerfile` (Alpine precisa do pacote `tzdata`).

**A hora em si não dá para sincronizar de dentro do container**: ele lê o clock do
kernel do host, e rodar NTP lá dentro exigiria `CAP_SYS_TIME`. Por isso
`deploy/ntp-seguro.sh`, que roda **uma vez no servidor** e instala **chrony com
NTS** (RFC 8915): troca de chaves por TLS (TCP 4460) e pacotes NTP assinados.
NTP comum é UDP sem autenticação, e hora errada aqui muda **o dia da
consolidação, o recorte do período financeiro e a validade da sessão**. O script
usa 3 fontes independentes, **desliga as não autenticadas** (deixar o `pool`
padrão anularia o ganho), desativa o `systemd-timesyncd` e liga o `rtcsync`.

```bash
sudo bash deploy/ntp-seguro.sh
chronyc tracking      # 'System time' na casa dos milissegundos
chronyc -N authdata   # NTS ativo em cada fonte
```

## 2. Restart automático e migrations

`restart: unless-stopped` no serviço `app`. Escolhido em vez de `always` porque
`always` reergue o container até depois de um `stop` deliberado, tirando do
operador a chance de deixar o sistema fora do ar de propósito.

O boot saiu do `command:` de uma linha e virou **`bin/docker-boot`**:

1. **relógio** — imprime hora e fuso (se aparecer UTC, o rebuild não pegou);
2. **pid órfão**; 3. **gems** (o volume `bundle_cache` sombreia os da imagem);
4. **migrations** — `db:prepare`, com **espera pelo banco**;
5. **cron** (best-effort); 6. **Puma**.

A migration é automática a todo restart. A espera do passo 4 existe porque o
PostgreSQL é externo ao compose: num reboot o Rails pode subir antes de o banco
aceitar conexão e, com `unless-stopped`, isso viraria **loop de reinício**
parecendo erro de migration.

**Falha rápida:** `DATABASE_URL` vazia ou com o valor de exemplo não é "banco fora
do ar" — nenhuma espera resolve. O boot para na primeira tentativa dizendo o que
preencher, **com a credencial mascarada no log**. Este projeto **não tem
`config/database.yml`**: a conexão inteira sai dessa variável, e ninguém lê
`DB_HOST`/`DB_NAME`/`DB_USER`/`DB_PASSWORD`.

## 3. Arquivos que o git não levava — e por isso sumiram juntos

A pasta do deploy foi recriada e levou embora todo arquivo **não versionado**. Dois
apareceram:

- **`.env`** — está no `.gitignore` por conter senha, e assim deve continuar.
  Guarde uma cópia fora da árvore do projeto e prefira `git pull`/`reset --hard`
  na própria pasta a recriá-la (arquivo ignorado sobrevive ao pull).
- **`config/storage.yml`** — não tinha motivo para estar de fora. O app faz
  `require "rails/all"`, então o Active Storage sempre carrega e os
  `config/environments/*.rb` definem `active_storage.service = :local`: com um
  serviço definido, o Rails **exige** o arquivo no `initialize!` e o servidor não
  sobe. **Agora é versionado.**

## 4. Configuração do ambiente saiu do `.env` e entrou no compose

A porta do teste (**3001**, porque a 3000 está ocupada por outro stack) vivia como
alteração **local** no `docker-compose.yml` do servidor — e um `git reset --hard`
a levou junto, derrubando o ambiente com o proxy apontando para uma porta sem
ninguém escutando. Agora os valores **não secretos** têm padrão no compose:

```yaml
ports:
  - "${PORTA_APP:-3001}:3000"
environment:
  TZ:                ${TZ:-America/Sao_Paulo}
  APP_HOST:          ${APP_HOST:-teste.reemtransportes.com.br}
  APP_NAME:          "${APP_NAME:-Reem Logística}"
  WHATSAPP_URL:      ${WHATSAPP_URL:-http://whatsapp:3001}
  HOSTS_PERMITIDOS:  ${HOSTS_PERMITIDOS:-100.75.222.23}
```

`${VAR:-padrão}`: **o `.env` continua mandando**; o padrão só entra quando a
variável falta. Segredos (`DB_*`, `SECRET_KEY_BASE`, `SIMPLIROUTE_TOKEN`,
`WHATSAPP_TOKEN`, `SMTP_*`, `NOTIFICACAO_SECRET`) **nunca** vão para o yaml.

> ⚠️ **`APP_HOST` é só o host, sem `https://`.** O código monta os links como
> `"https://#{APP_HOST}/motorista"` (`consolidacao_mailer.rb`,
> `notificacao_service.rb`, `gatilhos.rb`): com o esquema no valor sai
> `https://https://…` e todo link de e-mail e WhatsApp quebra.

## 5. Produção de verdade: o que só aparece fora do modo development

O servidor passou a rodar `RAILS_ENV=production`, e três coisas apareceram:

- **`config.hosts`** — a linha que parecia só liberar o `APP_HOST` na verdade
  **liga** a verificação e recusa todo o resto. Agora entram também
  `localhost`/`127.0.0.1` (para o `curl` de dentro do NAS servir de diagnóstico) e
  a lista `HOSTS_PERMITIDOS`.
- **`i18n.fallbacks = true`** significa "caia no `default_locale`" — que aqui é o
  próprio pt-BR. O fallback apontava para si mesmo e chave ausente virava
  `Translation missing` **na tela do usuário** (tela de login). Agora `[:en]`,
  igual ao `application.rb`, e o `pt-BR.yml` ganhou as traduções do Devise
  (`failure`, `sessions`, `passwords`) — o projeto não usa `devise-i18n`.
- **`assume_ssl`** estava fixo, então `request.base_url` virava `https://` e o
  `Origin: http://` de quem abre pelo IP não batia: **todo POST voltava 422**
  ("recusada por uma verificação de segurança") enquanto os GETs passavam e a tela
  parecia normal. Foi o que quebrou a importação do plano. Um gate `FORCE_SSL=false`
  devolve o acesso por HTTP para diagnóstico; sem a variável, HTTPS continua
  obrigatório.

> **Proxy reverso:** o destino tem que ser **HTTP** na porta do container. Com
> `https://` o Puma responde
> `Are you trying to open an SSL connection to a non-SSL Puma?`.

## 6. `db/schema.rb` entrou no `.gitignore`

Nunca foi versionado (o schema vive nas migrations). Como o compose monta
`.:/app`, o `db:prepare` do boot escreve o arquivo **no host** — e um `git add .`
no meio de um rebase o capturou e virou conflito de deploy. Em
`RAILS_ENV=production` ele nem seria gerado (`dump_schema_after_migration = false`).

> **Deploy é `git fetch` + `git reset --hard origin/<branch>`**, não `pull --rebase`:
> o container reescreve `Gemfile.lock` e `db/schema.rb` no host, então o checkout
> produz alteração local sozinho e todo rebase bate de frente com ela.

## ▶️ Deploy

```bash
sudo bash deploy/ntp-seguro.sh          # 1x no servidor
git fetch origin && git reset --hard origin/teste
docker compose up -d --build            # --build: o fuso entra na imagem
docker compose logs app | grep '\[boot\]'
curl -I http://127.0.0.1:3001/          # separa "app caiu" de "proxy errado"
```

## 📂 Arquivos

```
Dockerfile / whatsapp/Dockerfile   # TZ + tzdata + /etc/localtime
docker-compose.yml                 # restart, PORTA_APP, APP_HOST/NAME, TZ, HOSTS_PERMITIDOS
bin/docker-boot                    # boot documentado: espera do banco, cron, Puma — NOVO
deploy/ntp-seguro.sh               # chrony + NTS no host (1x, root) — NOVO
config/storage.yml                 # versionado — NOVO
config/environments/production.rb  # hosts, i18n fallbacks, gate do FORCE_SSL
config/locales/pt-BR.yml           # traduções do Devise
.gitignore                         # db/schema.rb
.env.example                       # TZ, PORTA_APP, HOSTS_PERMITIDOS, APP_HOST sem esquema
```

</details>

---

<details>
<summary><strong>🚚 Romaneio — a tela recuperou as funções do programa antigo</strong></summary>

Referência: o **Romaneiro PDF (GADE Hospitalar)**, o programa em Python que a
operação usava. O PDF já estava fechado; o que faltava era a **tela**.

## Veículos: lista visível, não um `<select>`

Era um `<select>` com ‹ ›, escolhido para não virar armadilha de scroll com 72
carros. Resolveu o scroll e criou outro problema: o operador via **um carro por
vez** e perdia a contagem de paradas de cada um — que é justamente a conferência
feita antes de imprimir.

Agora é a lista inteira, cada item com a contagem (`GADE_038 · 31`), o atual em
laranja, rolagem própria, e as setas ‹ › preservadas para percorrer carro a carro.
Cada veículo é um **link de verdade** (`?veiculo=GADE_038`): volta com o botão
"voltar" do navegador e sobrevive a um F5. No celular vira faixa horizontal —
72 itens empilhados empurrariam a tabela para fora da primeira tela.

Na mesma coluna, como no original: **motorista do veículo**, **PDF deste veículo**
e **PDF de TODOS os veículos**.

## Prévia sob demanda, em sobreposição

Antes o `<iframe>` do PDF era montado em **todo** carregamento da tela — e cada
montagem gerava um PDF no servidor, inclusive quando ninguém ia olhar. Agora é o
botão **"Ver prévia do PDF"**: abre sobreposta, fundo escurecido, `✕`/Esc/clique
fora fecham (clique **dentro** do PDF não fecha, senão rolar o documento fecharia
a prévia). Editar com a prévia fechada só a marca como desatualizada; o PDF novo
sai no próximo "Ver prévia".

## Busca com "Limpar" e contador vivo

`4 de 31 paradas — filtrando por "jardim"`. A contagem **saiu do cabeçalho da
tabela**: dois números para a mesma coisa, um atualizando e o outro não, é a
divergência que a diretriz 1 proíbe. Sem contador, uma busca que não casa com nada
deixa a tabela vazia e parece plano vazio.

## NOVOS × RECORRENTES no topo

> **184** com aparelho (NOVOS) · **1863** recorrentes

É o equivalente à caixa de diálogo que o programa antigo mostrava ao aplicar o
status. Verde quando há novos, **âmbar quando é zero** — e zero quase sempre
significa importação **sem operação vinculada**, em que a coluna APARELHO sai
vazia para todos. Um romaneio assim parece idêntico a um certo e só revela o erro
no papel, com aparelho não entregue.

## Logo escolhível

Era fixo em `public/logo-gade.png`. Agora vem de `Configuracao` (chave
`romaneio_logo`, separada do `empresa_logo`, que é a marca da Reem — o romaneio é
documento do **cliente**). PNG ou JPG até 2 MB, gravado em `storage/logos/` e
**não** em `public/`: o que o operador sobe não vira arquivo servido pela web sem
autorização, e o nome do arquivo é gerado pelo sistema, nunca o que vem do
navegador.

## Operação deduzida pelo NOME DO PLANO

A operação não trabalha por data — identifica o plano pelo nome
(`ENTREGAS EMAD 09.2026`). E é a operação do mês que diz quem é **NOVO**.

`Romaneios::CasadorDeOperacao` compara por **conjunto de palavras + (mês, ano)**,
não por texto, porque as diferenças são sistemáticas:

| Nome do plano | Tabela da operação |
|---|---|
| `ENTREGAS EMAD 09.2026` | `gade_entregas_emad_set_2026` |
| `UBS SUL E LESTE AGOSTO 2026` | `gade_entregas_ubs_sul_leste_ago_2026` |
| `AVULSAS 3 EMAD/SUDESTE AGOSTO 2026` | `gade_entregas_avulsas_3_emad_sudeste_ago_2026` |

Três armadilhas: o conectivo **E**, o mês **abreviado × por extenso**, e o mês
**numérico** (`09.2026`) — este só lido como mês quando vem colado ao ano, senão o
`3` de "AVULSAS 3" viraria março. Usa a tabela `MESES` do próprio `Operacao`
(via `Operacao.mes_numero`) em vez de duplicá-la.

**Empate devolve `nil` de propósito.** Vincular a operação errada é pior do que
não vincular: APARELHO e TELEFONE sairiam preenchidos com dados de **outro mês** e
ninguém desconfiaria. A tela avisa quando ficou sem operação, e a importação diz o
que deduziu ("Operação EMAD SET 2026 vinculada pelo nome do plano") — dedução
silenciosa que acerta é invisível, mas a que erra é indefensável.

## Sobre buscar o plano por nome na API

> ### ✅ CORRIGIDO EM 28/08/2026 — o "não é possível" abaixo estava ERRADO
>
> **`GET /v1/routes/plans/` existe** e devolve os planos com o `name` que a
> operação usa. Verificado contra a API real (`bin/sondar_planos`): **138 planos,
> 185 KB, 0,86 s**, nomes como `EMAD SETEMBRO 2026` e `UBS OESTE AGOSTO 2026`.
> Não é documentado. O endpoint até estava na lista de candidatos do
> `bin/sondar_plano_do_dia` — **o que faltou foi rodar o script**; a conclusão
> "não é possível" foi tirada só da documentação.
>
> A cadeia inteira fecha, sem adivinhar data nenhuma:
>
> | chamada | devolve |
> |---|---|
> | `GET /v1/routes/plans/` | `name`, `start_date`, `end_date`, `created`, `routes[]` |
> | `GET /v1/routes/routes/{uuid}/` | `vehicle` (id), `planned_date`, `total_visits`, `plan` |
> | `GET /v1/plans/routes/{uuid}/visits/` | `order`, `vehicle_id`, `reference` (NF), `title` |
> | `GET /v1/routes/vehicles/` | traduz `634185` → `GADE_057` |
>
> **Nenhum filtro funciona** nessa rota: `ordering`, `limit`, `page`, `page_size`,
> `search`, `name`, `planned_date`, `planned_date__gte`, `created_at__gte` e
> `status` devolveram os mesmos 138 itens do controle sem parâmetro. Então "os 5
> mais recentes" é corte em Ruby depois de baixar tudo — `SimpliRoute::Client#planos`.
> Ordena por `created`, e não por `start_date`, porque a janela do plano é um
> INTERVALO (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09) — era exatamente isso que
> tornava a data impossível de adivinhar.
>
> Pela NF também fecha: a visita traz `route` (uuid) e a rota traz `plan` (uuid).
>
> **Lição:** conclusão tirada de documentação não é conclusão. A própria memória
> desta integração já dizia que a API ignora parâmetro desconhecido em silêncio —
> ela também não anuncia as rotas que tem.

O texto original, mantido para contexto de como se chegou à conclusão errada:

> **Não é possível.** A documentação
> (https://documentation.simpliroute.com) não expõe endpoint que **liste** planos —
> só `POST /v1/plans/create-plan/` (onde o plano tem `name`),
> `GET /v1/plans/{planned_date}/vehicles/` e `GET /v1/plans/routes/{PLAN_ID}/visits/`.
> A lista de planos que aparece no site deles é da interface web. Por isso o nome é
> **colado pelo operador** e casado localmente.

## 📂 Arquivos

```
app/views/admin/romaneios/_veiculos.html.erb        # lista de veículos — NOVO
app/views/admin/romaneios/_controles.html.erb       # busca + Limpar + contador vivo
app/views/admin/romaneios/show.html.erb             # 2 zonas + prévia em sobreposição
app/views/admin/romaneios/index.html.erb            # logo + "deduzir pelo nome do plano"
app/javascript/controllers/romaneio_controller.js   # navegação, filtro, overlay
app/services/romaneios/casador_de_operacao.rb       # nome do plano → operação — NOVO
spec/services/romaneios/casador_de_operacao_spec.rb # os nomes reais — NOVO
app/models/romaneio.rb                              # contagem_aparelho (NOVOS x recorrentes)
app/models/operacao.rb                              # mes_numero público
app/models/configuracao.rb                          # chave romaneio_logo
app/services/pdf/romaneio_pdf.rb                    # logo configurável
app/controllers/admin/romaneios_controller.rb       # atualizar_logo + dedução da operação
config/routes.rb                                    # POST atualizar_logo
```

</details>

---

<details>
<summary><strong>🩺 28/08/2026 — "A primeira vez que entra dá tela de erro" + os assets 404 que o Cloudflare escondia</strong></summary>

Dois problemas achados a partir de **um** relato ("a primeira vez que entra dá a
tela de erro, em `/auth/login`"). O primeiro era o relatado; o segundo apareceu
com o navegador aberto na mesma tela e era o mais grave dos dois.

| Sintoma na tela | Causa real | Onde se resolveu |
|---|---|---|
| Primeira entrada do dia parece falha do sistema | `devise.failure.unauthenticated` sai como `flash[:alert]` — vermelho, e **duas vezes** | `Users::SessionsController#new` |
| Carrossel e editor de romaneio mortos no teste | `RAILS_ENV=production` + **nada no deploy pré-compilava assets** | `bin/docker-boot` (passo 5) |

## 1. O "erro" que era só o Devise pedindo login

Quem abre `teste.reemtransportes.com.br` deslogado — ou seja, **toda** primeira
entrada — passa por `authenticate_user!`, que redireciona para `/auth/login`
gravando `flash[:alert] = "Para continuar, faça login."`. Como `alert`, a
mensagem saía **em vermelho e em dois lugares ao mesmo tempo**: o toast com
triângulo de alerta no canto superior direito (`layouts/application.html.erb`) e
a caixa vermelha acima do formulário (`devise/sessions/new.html.erb`).

Entrar no sistema pelo caminho normal ficava com a cara de sistema quebrado. E
como na segunda visita a pessoa já está em `/auth/login` sem flash nenhum, a tela
aparecia limpa — daí o "só na primeira vez", que fazia parecer bug intermitente.

A mensagem agora é **descartada**: a tela já é o formulário de login, o aviso não
acrescenta nada.

```ruby
# app/controllers/users/sessions_controller.rb
flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.unauthenticated', default: nil)
```

⚠️ **O filtro é pela mensagem exata, e não `flash.delete(:alert)` seco.** Senha
errada, conta desativada e `timeout` ("Sua sessão expirou") chegam ao mesmo
`#new` pelo mesmo caminho — essas **precisam** continuar vermelhas. Apagar o
alerta inteiro no `new` deixaria a pessoa digitando a senha errada para sempre
sem entender por quê.

## 2. Os assets 404 — e por que ninguém tinha visto

O console da tela de login acusava dois módulos:

```
Failed to fetch dynamically imported module:
  /assets/controllers/carrossel_controller-913b47f7.js   -> 404
  /assets/controllers/romaneio_controller-0105d794.js    -> 404
```

Não é pouco: `carrossel_controller` é a **paginação por tamanho de tela** (a
regra 3 do `CLAUDE.md`) e `romaneio_controller` é o editor de romaneio inteiro.
Os dois estavam mortos no ambiente de teste.

### O disfarce

À primeira vista pareciam só dois arquivos com problema — os outros dez
controllers respondiam 200. **Não respondiam.** Os 200 vinham do **cache do
Cloudflare**:

```
$ curl -sI .../validacao_controller-ca4de84f.js
cache-control: public, max-age=31536000, immutable
age: 275844                 # ~3,2 dias em cache
cf-cache-status: HIT        # ← nunca chegou no servidor
```

Os assets saem com `max-age` de **um ano**. Os dez que "funcionavam" eram cópias
guardadas na borda dias antes; os dois quebrados eram os dois arquivos alterados
**depois** dessa fotografia. Batendo direto na origem (com querystring, para
furar o cache), **todos** davam 404.

> **Lição de diagnóstico:** ao investigar asset em produção, olhe
> `cf-cache-status` antes de concluir qualquer coisa. `HIT` não é prova de que o
> servidor está servindo — é prova de que ele serviu **algum dia**.

### A causa

O corpo do 404 era `public/404.html`, ou seja, a requisição chegou no **roteador
do Rails**: o servidor de assets do Propshaft nem estava montado. E a resposta do
HTML não trazia o cabeçalho `Server-Timing`, que só existe em development
(`config.server_timing`). Somando os dois: **o servidor roda
`RAILS_ENV=production`**.

Em production o Propshaft **não compila sob demanda** — ele resolve o caminho
pelo `public/assets/.manifest.json` e o arquivo digerido tem que existir em
disco. E nada no deploy gerava isso:

- a linha `RUN bundle exec rails assets:precompile` do `Dockerfile` estava
  **comentada** (e não adiantaria descomentar: o compose monta `.:/app` por cima
  e apagaria o `public/assets` da imagem);
- `bin/docker-boot` não tocava no assunto;
- `/public/assets` é **gitignorado**.

Resultado: o que existia no servidor era o resto de algum `precompile` manual
antigo. Todo JS alterado depois dele virava 404 silencioso — silencioso porque a
tela **continua abrindo** (o Tailwind vem de CDN e os ícones são `public/`
direto), só sem comportamento.

### A correção

Novo **passo 5** no `bin/docker-boot`, que roda em todo start de container — logo,
em todo deploy:

```bash
if [ "${RAILS_ENV:-development}" = "production" ]; then
  rm -rf public/assets tmp/cache/assets
  bundle exec rails assets:precompile
else
  rm -rf public/assets      # em dev o Propshaft calcula o digest ao vivo
fi
```

- **Limpa antes de pré-compilar**: sem isso um manifesto antigo convive com
  arquivos novos e a divergência volta na primeira alteração de JS. Assim
  manifesto e arquivos saem sempre da **mesma execução**. (`rm -rf` em vez de
  `rails assets:clobber`: faz o mesmo sem pagar outro boot do Rails.)
- **Falha não derruba o boot**: `exit 1` aqui viraria loop de reinício com o
  `restart: unless-stopped` e deixaria todo mundo de fora. O erro é gritado no
  log — o que não podia continuar é a falha **muda**.
- **Em development apaga `public/assets`**: sobra de precompile faz o Propshaft
  voltar a resolver pelo manifesto, e aí alteração de JS só aparece depois de
  recompilar — parece cache do navegador quando não é.

### Como conferir depois do deploy

```bash
# 1. os assets do HTML respondem na ORIGEM (querystring fura o cache do CDN)
curl -s https://teste.reemtransportes.com.br/auth/login \
  | grep -o '/assets/controllers/[a-z_]*-[a-f0-9]*\.js' | sort -u \
  | while read a; do
      printf "%s -> " "$a"
      curl -s -o /dev/null -w "%{http_code}\n" "https://teste.reemtransportes.com.br$a?cb=$RANDOM"
    done

# 2. o passo apareceu no log do boot
docker compose logs app | grep '\[boot\] assets'
```

Todos têm que responder **200**. Se algum der 404, o `precompile` não rodou ou
falhou — o log do passo 5 diz qual dos dois.

## 📂 Arquivos

```
app/controllers/users/sessions_controller.rb   # descarta o "faça login" vermelho
bin/docker-boot                                # passo 5: clobber + precompile
Dockerfile                                     # tira a linha de precompile morta e explica por quê
```

</details>

---

<details>
<summary><strong>🆕 Atualização 28/08/2026 — PLANO: Módulo do Cliente (aprovado, NÃO implementado)</strong></summary>

> ⚠️ **Isto é um plano de implementação, não uma feature entregue.** Foi escrito
> como ordem de serviço para ser executado em fases (inclusive por modelos
> menores), com o levantamento do código já feito. Implementar **uma fase por
> vez**, na ordem, validando cada uma antes da próxima.

## O que é o módulo

Hoje o cliente (Gade Hospitalar) manda uma **planilha** por mês/operação, que
alguém sobe no banco como tabela `gade_entregas_*` (fora do sistema). O módulo
do cliente substitui esse fluxo: uma área **`/cliente`** com login próprio onde
o próprio cliente:

1. **Cria operações** (ex.: "UBS NORTE — setembro/2026") e lança entregas nelas;
2. **Adiciona entregas em operações existentes**;
3. Lança **entregas avulsas** (fora de qualquer operação);
4. Cadastra **em massa via planilha .xlsx** (o módulo é um espelho da planilha
   atual — mesmos campos), com conferência antes de gravar;
5. Recebe **detecção de duplicidade na hora**: "você tem X apontamentos
   duplicados" e "a NF Y já está roteirizada / em curso".

## 🔒 Regras invioláveis (valem para TODAS as fases)

- `db_reem_simplerout_2026` e as `gade_entregas_*` são **somente leitura**:
  nunca migration, nunca INSERT/UPDATE/DELETE/DROP nelas. O módulo grava apenas
  em **tabelas próprias novas**.
- Todo nome de tabela externa que entra em SQL passa por `Operacao.sanitizar` +
  `quote_table_name` (padrão já usado no projeto inteiro).
- **NÃO transformar `app/models/operacao.rb` em ActiveRecord.** Ele é um PORO
  (só métodos de classe) que descobre as tabelas externas pelo catálogo do
  Postgres e é usado por ~8 services. Os models novos têm outros nomes.
- Seguir o CLAUDE.md do projeto: número na tela é contrato; modo de visualização
  em aba, não tela nova; mobile-first com alvos de 48px; comentário explica o
  porquê; validar com Docker (`ruby -c` / actionview) antes de entregar.
- Ao final de cada fase: listar os `git add` por nome (commit/push são do
  usuário) e lembrar que o deploy para o teste é manual.

## 📌 Fatos do código que o implementador precisa saber

| Assunto | Onde está | O que copiar |
|---|---|---|
| "Operação" hoje | `app/models/operacao.rb` (PORO) | catálogo/whitelist (`nomes_validos`, `sanitizar`, `label`, `MESES`) — só consumir |
| Colunas da planilha do cliente | `app/services/simpli_route/planilha_carga.rb` (`COLUNAS_OP`) | `nota_fiscal`, `nome_completo`, `endereco_sem_complemento`, `complemento`, `supervisao`, `telefones`; geo nas gade é `lat`/`long` |
| Esquema variável por mês | `app/services/romaneios/enriquecimento_gade.rb` | detecção de colunas via `conn.columns(tabela)`; só `nota_fiscal` é garantida |
| Dedup existente (melhor modelo) | `app/services/romaneios/importador.rb#deduplicar` + `RomaneioLinha.chave_para` + `app/services/romaneios/normalizador.rb` | chave normalizada (NF + título + endereço), primeira ocorrência vence, contagem reportada |
| Rastreio (roteirizado/em curso) | `app/models/entrega.rb` (`db_reem_simplerout_2026`, `readonly?=true`) | scopes `da_conta_gade`, `por_nf`; status da visita indica o andamento |
| Ler .xlsx | gem `roo` (~2.10, já no Gemfile; usada no romaneio) | |
| Gerar .xlsx | gem `caxlsx` (já no Gemfile; usada na planilha SimpliRoute) | |
| Papéis/permissões | `app/models/user.rb` (enum `role`), `app/models/permissao.rb`, `app/models/perfil_acesso.rb`, policies em `app/policies/` | `Permissao::TODAS` + `GRUPOS` + `PADRAO_POR_ROLE` (esquecer o PADRAO tira acesso silenciosamente — aviso no próprio model) |
| Migration de permissão (modelo) | `db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb` | sincronizar perfis existentes |
| Rotas | `config/routes.rb` | seguir o padrão `namespace :admin`; atenção ao aviso do Inflector pt-BR no próprio arquivo (singular == plural gera `_index`) |
| Notificações (evento novo) | infra em `app/models/notificacao_*` / `app/services/notificacao/` | criar evento "cliente lançou entregas" na Fase 5 |

**Não existe hoje nenhum conceito de "cliente" no código** (nem model, nem
coluna, nem namespace) — a fundação é a Fase 0.

## Fase 0 — Fundação de acesso (`/cliente` existe e loga)

1. `User`: adicionar `cliente: 5` ao enum `role`.
2. `Permissao`: novas chaves no grupo novo `cliente`:
   `cliente.ver`, `cliente.operacoes_criar`, `cliente.entregas_lancar`,
   `cliente.importar_planilha`, `cliente.avulsas` — em `TODAS`, `GRUPOS` e
   `PADRAO_POR_ROLE` (role `cliente` recebe todas as `cliente.*` e NADA de
   dashboard/consolidação/admin).
3. `HOME_POR_PERMISSAO` / `home_rota`: usuário com `cliente.ver` cai em `/cliente`.
4. Rotas: `namespace :cliente` com `root`, `resources :operacoes` (conferir o
   plural com o Inflector), `resources :entregas`, `resource :importacao`.
5. Layout `app/views/layouts/cliente.html.erb`: identidade visual do projeto
   (dark, laranja `#f97316`), **sem** menus de admin; menu próprio: Operações ·
   Avulsas · Importar planilha · Sair.
6. Policies Pundit novas baseadas em `user.pode?('cliente.…')`; os controllers
   de `/cliente` herdam de um `Cliente::BaseController` que bloqueia quem não
   tem `cliente.ver`.
7. Migration sincronizando perfis + seeds: perfil "Cliente" e um usuário de
   teste `cliente@reem.com`.

**Pronto quando**: login com usuário cliente cai em `/cliente` (vazio, com estado
vazio instrutivo), e esse usuário NÃO acessa `/dashboard`, `/consolidacoes` nem
`/admin/*` (redireciona para `/sem-acesso`); admin continua acessando tudo.

## Fase 1 — Modelagem (tabelas próprias)

1. `ClienteOperacao` (`cliente_operacoes`): `nome`, `mes`, `ano`,
   `status` (enum: `rascunho` → `enviada` → `roteirizada` → `fechada`),
   `criado_por_id` (FK users), timestamps. Índice único em
   `[nome, mes, ano]` (case-insensitive).
2. `ClienteEntrega` (`cliente_entregas`): `cliente_operacao_id` (**nulo =
   avulsa**), campos espelhando a planilha atual — `nota_fiscal`,
   `nome_completo`, `endereco_sem_complemento`, `complemento`, `supervisao`,
   `telefones`, `lat`, `long`, `observacoes` — mais `chave_dedup` (string
   normalizada), `origem` (enum: `manual`/`planilha`), `criado_por_id`,
   timestamps.
3. `chave_dedup`: gerada em callback com a MESMA normalização de
   `Romaneios::Normalizador` (extrair para um módulo compartilhável se preciso,
   sem quebrar os chamadores atuais). Índice único parcial em
   `[cliente_operacao_id, chave_dedup]` — e validação amigável antes do índice.
4. Validações: `nota_fiscal` obrigatória; `nome_completo` ou endereço presentes.

**Pronto quando**: migrations rodam no Docker, models com validações e specs de
unicidade; nada de tela ainda.

## Fase 2 — Telas `/cliente` (cadastro individual intuitivo)

1. **Lista de operações**: cards (nome, mês/ano, nº de entregas, status, botão
   "Adicionar entrega"); estado vazio dizendo o que fazer ("Crie sua primeira
   operação ou importe uma planilha").
2. **Criar operação**: form curto (nome + mês/ano), sem jargão técnico.
3. **Adicionar entrega**: form mobile-first (alvos 48–56px), campos na ordem da
   planilha, busca de endereço livre; ao salvar, o painel de duplicidade da
   Fase 4 responde na mesma tela (inline, não outra página).
4. **Entregas avulsas**: mesma tela de lançamento com aba/rótulo "Avulsa
   (sem operação)" — mesma unidade, rótulo explícito (regra 1 do CLAUDE.md).
5. Lista de entregas da operação com edição inline (padrão Stimulus do projeto,
   ver `romaneio_controller.js`) e remoção com confirmação.

**Pronto quando**: fluxo completo criar operação → lançar 3 entregas → editar →
remover, no desktop e no celular (validar com agent-browser + `set device`).

## Fase 3 — Importação em massa (.xlsx)

1. **Modelo para download**: gerar com `caxlsx` uma planilha com o cabeçalho
   exato dos campos da Fase 1 + 2 linhas de exemplo (o cliente já conhece esse
   formato — é a planilha que ele manda hoje).
2. **Upload**: `roo` lendo .xlsx; mapear cabeçalhos com tolerância (acentos,
   caixa, espaços — reusar normalização existente).
3. **Tela de conferência ANTES de gravar** (nada entra direto): resumo com
   N válidas · N com erro (motivo por linha) · N duplicadas (painel da Fase 4);
   o cliente escolhe "Importar só as válidas" ou cancelar.
4. Gravação transacional (`insert_all` ou transaction com validação); relatório
   final na tela com os mesmos números da conferência.
5. Limites e mensagens: arquivo até ~5 MB / ~5.000 linhas; erro de formato tem
   mensagem em português dizendo o que corrigir.

**Pronto quando**: importar a planilha-modelo preenchida com linhas repetidas de
propósito grava só as válidas e reporta as duplicadas com os números certos.

## Fase 4 — Duplicidade inteligente (`Cliente::DetectorDuplicidade`)

Serviço único usado pelo form individual (Fase 2) e pela importação (Fase 3).
Entrada: coleção de entregas candidatas (+ operação alvo). Saída: para cada
candidata, `status_duplicidade` e a evidência. **Quatro camadas, nesta ordem:**

1. **Dentro do lote**: chave normalizada repetida no próprio arquivo/form —
   padrão de `Romaneios::Importador#deduplicar` (primeira vence, conta o resto).
2. **Contra o módulo**: `ClienteEntrega` já gravada com a mesma `chave_dedup`
   (na mesma operação ou avulsa do mesmo mês).
3. **Contra as `gade_entregas_*` do mesmo mês/ano**: SELECT read-only (tabelas
   via `Operacao.sanitizar` + `quote_table_name`; só `nota_fiscal` é garantida —
   detectar colunas via `conn.columns` como no `EnriquecimentoGade`).
4. **Contra o rastreio** (`Entrega.da_conta_gade.por_nf`): se a NF já existe no
   espelho, classificar pelo status da visita — **"já roteirizada"** (planejada)
   ou **"em curso/entregue"** (checkout feito). Conferir os valores reais de
   status no espelho antes de fixar o mapeamento.

Na tela (regra 1 do CLAUDE.md — explicar onde o número aparece):
resumo "⚠️ X apontamentos duplicados neste lançamento" + lista expandível
inline com a camada que acusou cada um ("já lançada nesta operação",
"consta na planilha de agosto", "NF 79093 já roteirizada em 27/08").
Duplicata **avisa e bloqueia por padrão**, com opção explícita "lançar mesmo
assim" (grava com flag `duplicada_confirmada` para auditoria).

**Pronto quando**: os 4 cenários têm teste (fixtures/factories) e a tela mostra
a contagem e o motivo camada por camada.

## Fase 5 — Lado admin + integração

1. **Visão admin**: aba/tela em `/admin` listando lançamentos do cliente por
   operação/status, com os MESMOS números que o cliente vê (recorte único).
2. **Exportação**: baixar as entregas de uma `ClienteOperacao` no formato da
   planilha de carga do SimpliRoute (referência: `SimpliRoute::PlanilhaCarga`) —
   é o que fecha o ciclo: o que o cliente lança vira rota.
3. **Notificação**: evento novo na infra existente ("Cliente lançou N entregas
   na operação X") para o grupo do WhatsApp da operação.
4. **Auditoria**: registrar criar/editar/excluir/importar do módulo no padrão de
   auditoria existente (`/admin/auditoria_logs`).
5. Transição de status da operação (`enviada` → `roteirizada`) — manual pelo
   admin nesta fase; automatizar via rastreio fica para depois.

**Pronto quando**: admin vê, exporta e é notificado; auditoria registra.

## Fase 6 — Validação e encerramento

1. Adicionar à `docs/BATERIA-DE-TESTES.md` os casos: login cliente; criar
   operação; lançar entrega; importar .xlsx com duplicatas propositais e conferir
   as contagens; bloqueio de acesso do cliente às áreas de admin.
2. Rodar a bateria completa (`/bateria-testes`) no ambiente de teste após o
   deploy manual.
3. Atualizar este README (marcar fases entregues) e o perfil "Cliente" nos seeds.

</details>
