# 🚛 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/Cludio-code/logistica-controle-custos
---
> 💡 **Navegação:** as seções abaixo abrem e fecham — clique no título de cada uma.
---
📋 CONTEXTO DO PROJETO (leia antes de tudo)
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.
---
🏗️ Estado atual — O que já foi feito (Fase 1 ✅)
```
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
```
---
🚀 Setup do zero (próximo dev)
### 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
```
---
🔧 Comandos do dia a dia
```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
```
---
🗺️ Fases de implementação
| 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.**
---
🔧 Análise de integração (11/06/2026) — Correções aplicadas
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.
---
🧪 Auditoria + Suíte de testes (15/06/2026)
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 (_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.
---
🔧 Correções + funcionalidades (16/06/2026)
### 🔴 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
```
---
🔧 Correções + funcionalidades (16/06/2026 — sessão 2)
### 🔴 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
```
---
🗄️ Banco existente — referência rápida
| 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`
---
🎨 Design — padrão da marca
| 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
---
👥 Perfis de acesso
| 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 |
---
🩺 Correção (11/06): container caía mostrando ajuda do "rails new"
**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!
```
---
🔐 Segurança
- **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
---
🆕 Atualização 17/06/2026 — Pagamentos, Operações, Dashboard financeiro e UI
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 `
---
🔧 Correções — Extrato PDF (18/06/2026)
### 🔴 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.
---
🆕 Atualização 19/06/2026 — Apontamento manual / Nota avulsa
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`, 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).
---
🆕 Atualização 22/06/2026 — Pilar Extraordinária, Perfil Externo, Falhadas, Relatórios e Segurança QR
### 🆕 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 `
---
🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local)
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`.
---
🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente
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-"`) 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
```
---
🆕 Atualização 25/06/2026 — Fechar por veículo na tela de Validar
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
```
---
🆕 Atualização 26/06/2026 — Entrega de termo, caixa de ferramentas, gestão de motoristas e UX da Validação
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-`,
`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
```
🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)
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.
🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)
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 ``, 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.
🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar
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 ``, 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).
---
🔒 Segurança + auto-atualização do painel + cron (06/07/2026)
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.
---
✏️ Atualização 08/07/2026 — Editar Lançamento do SimpliRoute (correção pelo ADM)
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).
---
🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)
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.
---
🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções
### 📋 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
``** (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.
---
🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos)
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-`.
- **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
```
---
🎨 Atualização 20/07/2026 — Ícones da marca (emoji → SVG laranja) + galeria de fotos no Editar Lançamento
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 `
---
🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026)
## 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=`** | ✅ **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 --data [--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).
---
✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026)
> ⚠️ **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 ` 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 `** 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.
---
💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (20–21/08/2026)
> ⚠️ **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)"
```
---
🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026)
> ⚠️ **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".
### ⚙️ 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/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)
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")
spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa)
spec/services/analytics/notas_fora_operacao_spec.rb (NOVO)
```
> **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/models/entrega_spec.rb
# 2. Conferir a coluna que carrega o nome do PLANO ("(Avulsa)", "INCLUSÃO").
# NotasForaOperacao::COLUNAS_PLANO tenta title, notes, comments, route_id nessa ordem.
# Se o painel mostrar "SEM PLANO IDENTIFICADO", a coluna certa é outra:
docker compose exec app bin/rails runner 'puts Entrega.column_names.sort'
# 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
```