3190 lines
187 KiB
Markdown
3190 lines
187 KiB
Markdown
# 🚛 Reem Logística — Sistema de Controle de Custos
|
||
|
||
Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**.
|
||
|
||
**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus)
|
||
**Repositório:** https://git.xenserver.com.br/victor/Reem-Notas
|
||
|
||
---
|
||
|
||
> 💡 **Navegação:** as seções abaixo abrem e fecham — clique no título de cada uma.
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>📋 CONTEXTO DO PROJETO (leia antes de tudo)</strong></summary>
|
||
|
||
A **Gade Hospitalar** precisa controlar o custo de cada operação de entrega
|
||
(UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).
|
||
|
||
O banco **já existe** e é atualizado a cada 1 hora por outro sistema externo:
|
||
- Tabela: `public.db_reem_simplerout_2026`
|
||
- Account: Gade Hospitalar (account_id: 95907)
|
||
- **NUNCA** fazer `DROP TABLE`, `TRUNCATE`, `DELETE` ou migration nessa tabela.
|
||
|
||
O sistema novo cria suas próprias tabelas no mesmo PostgreSQL (schema separado ou prefixo)
|
||
e lê a tabela existente apenas para exibir e consolidar entregas.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🏗️ Estado atual — O que já foi feito (Fase 1 ✅)</strong></summary>
|
||
|
||
```
|
||
logistica-controle-custos/
|
||
├── Gemfile # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…)
|
||
├── Dockerfile # Ruby 3.2.2-slim
|
||
├── docker-compose.yml # app + db (postgres:15)
|
||
├── tailwind.config.js # Paleta preto #0a0a0a / laranja #f97316 / branco
|
||
├── .env.example # Template de variáveis — copiar para .env
|
||
├── .gitignore # Protege .env, master.key, credenciais
|
||
│
|
||
├── config/
|
||
│ ├── database.yml # 100% via ENV — nunca hardcode
|
||
│ └── routes.rb # Rotas completas para todas as 8 fases
|
||
│
|
||
├── app/
|
||
│ ├── models/
|
||
│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela existente db_reem_simplerout_2026
|
||
│ │ ├── user.rb # Devise + roles (admin/gerente/operador/motorista) + PIN
|
||
│ │ ├── configuracao.rb # Preços configuráveis (entrega, retirada, bonus, desconto)
|
||
│ │ ├── consolidacao.rb # Soft delete, enum status, cálculo de progresso
|
||
│ │ ├── consolidacao_motorista.rb
|
||
│ │ ├── consolidacao_entrega.rb # Enum tipo: normal/retirada/bonus/desconto
|
||
│ │ ├── historico_estimado.rb # Job 1h + cálculo ao vivo
|
||
│ │ └── auditoria_log.rb # Log de ações críticas
|
||
│ │
|
||
│ ├── controllers/
|
||
│ │ └── application_controller.rb # Auth + Pundit + tema
|
||
│ ├── policies/
|
||
│ │ └── application_policy.rb # Base Pundit
|
||
│ ├── helpers/
|
||
│ │ └── application_helper.rb # nav_link_to, moeda(), badge_status(), progress_bar()
|
||
│ └── views/layouts/
|
||
│ ├── application.html.erb # Layout base dark theme laranja/preto/branco
|
||
│ └── _navbar.html.erb # Sidebar responsiva com hamburguer mobile
|
||
│
|
||
└── db/
|
||
├── seeds.rb # Admin: admin@gade.com / Gade@2026! + configs de preço
|
||
└── migrate/
|
||
├── ..._create_users.rb
|
||
├── ..._create_configuracoes.rb
|
||
├── ..._create_consolidacoes.rb
|
||
├── ..._create_consolidacao_motoristas.rb
|
||
├── ..._create_consolidacao_entregas.rb
|
||
├── ..._create_historico_estimados.rb
|
||
└── ..._create_auditoria_logs.rb
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🚀 Setup do zero (próximo dev)</strong></summary>
|
||
|
||
### 1. Clone
|
||
```bash
|
||
git clone https://git.xenserver.com.br/Cludio-code/logistica-controle-custos.git
|
||
cd logistica-controle-custos
|
||
```
|
||
|
||
### 2. Configure o .env
|
||
```bash
|
||
cp .env.example .env
|
||
nano .env # preencha DB_HOST, DB_PASSWORD, etc.
|
||
```
|
||
|
||
### 3. Gere o SECRET_KEY_BASE
|
||
```bash
|
||
docker run --rm ruby:3.2.2-slim bash -c "gem install rails --no-doc -q && rails secret"
|
||
# Cole o resultado no .env → SECRET_KEY_BASE=...
|
||
```
|
||
|
||
### 4. Suba os containers
|
||
```bash
|
||
docker-compose up -d
|
||
```
|
||
|
||
### 5. Banco + seeds
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
||
```
|
||
|
||
### 6. Acesse
|
||
```
|
||
http://localhost:3000
|
||
Login: admin@gade.com
|
||
Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔧 Comandos do dia a dia</strong></summary>
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate # nova migration
|
||
docker-compose exec app bundle exec rails console # console Rails
|
||
docker-compose logs -f app # logs em tempo real
|
||
docker-compose down # parar tudo
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🗺️ Fases de implementação</strong></summary>
|
||
|
||
| Fase | Status | O que faz |
|
||
|------|--------|-----------|
|
||
| **1** | ✅ Concluída | Setup + Docker + Models + Migrations + Seeds + Layout base |
|
||
| **2** | ✅ Concluída *(corrigida)* | Login e-mail/PIN + CRUD usuários + pilares de preço + toggle tema |
|
||
| **3** | ✅ Concluída *(corrigida)* | Dashboard: cards + gráfico Chart.js + ranking motoristas + navegação por mês |
|
||
| **4** | ✅ Concluída | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas |
|
||
| **5** | ✅ Concluída | Nova Consolidação + filtros + Wizard Passo 1 |
|
||
| **6** | ✅ Concluída | Toggles coloridos + ações em massa + Wizard Passos 2 e 3 (Stimulus) |
|
||
| **7** | ✅ Concluída | PDFs Prawn: relatório + extrato com QR code + modal preview |
|
||
| **8** | ✅ Concluída | Painel motorista + login PIN/QR + notificações WhatsApp/email |
|
||
|
||
**🎉 PROJETO: 8 de 8 fases implementadas.**
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔧 Análise de integração (11/06/2026) — Correções aplicadas</strong></summary>
|
||
|
||
As Fases 2/3 (commit `4724847`) foram desenvolvidas em paralelo às Fases 4-8 (commits `90ed705` e `8b3d4bc`), e a mesclagem gerou **conflitos que quebravam a aplicação**. Análise dos commits e correções:
|
||
|
||
### 🔴 Problemas críticos encontrados e corrigidos
|
||
|
||
**1. Migration da Fase 2 quebrada** (`20260610_add_fase2_fields_to_users.rb`)
|
||
- Timestamp curto (8 dígitos) fazia ela rodar ANTES da `create_users` → erro "table does not exist"
|
||
- Adicionava coluna `ativo` que já existia → erro de coluna duplicada
|
||
- Criava `pin_acesso` duplicando o `pin_code` existente
|
||
- ✅ **Corrigida:** reescrita como `20260101000010_add_devise_trackable_to_users.rb` — só trackable/lockable, timestamp correto
|
||
|
||
**2. Campo de PIN inconsistente** — Fase 2 usava `pin_acesso`, o model e as Fases 7/8 usam `pin_code`. PINs cadastrados no CRUD não funcionariam no login.
|
||
- ✅ **Corrigido:** padronizado `pin_code` em todos os arquivos
|
||
|
||
**3. Campo de nome inconsistente** — CRUD de usuários usava `:name`, o schema usa `nome` → o CRUD quebrava ao salvar.
|
||
- ✅ **Corrigido:** padronizado `nome`
|
||
|
||
**4. Controllers fora do namespace** — As rotas esperam `Admin::UsuariosController` e `Admin::ConfiguracoesController`, mas os controllers foram criados na raiz → `/admin/usuarios` dava "uninitialized constant" e os controllers ficavam inacessíveis.
|
||
- ✅ **Corrigido:** movidos para `app/controllers/admin/` + views para `app/views/admin/` + paths atualizados (`admin_usuarios_path` etc.)
|
||
|
||
**5. Dashboard (Fase 3) chamava métodos inexistentes** — `Entrega.do_mes` e `Configuracao.mapa_de_precos` foram sobrescritos pelo commit das Fases 4-6 → **a raiz do site quebrava** (root = dashboard#index).
|
||
- ✅ **Corrigido:** scope `do_mes` e método `mapa_de_precos` restaurados nos models, junto com `tipo_label`/`icone` usados pelas views de configurações
|
||
|
||
**6. AuditoriaLog com 2 assinaturas** — Fase 2 chamava `registrar(user, 'acao', ip)` posicional; o model usa keyword args → `ArgumentError` em login PIN, CRUD de usuários e configurações.
|
||
- ✅ **Corrigido:** todas as chamadas convertidas para a assinatura do model
|
||
|
||
**7. Rotas/controllers ausentes**
|
||
- `Admin::AuditoriaLogsController` tinha rota mas não existia → ✅ criado com view de listagem e filtros
|
||
- `toggle_tema` e `toggle_ativo` existiam nos controllers mas sem rota → ✅ rotas adicionadas
|
||
- Controller de configurações usava campo `tipo` que não existe (schema usa `chave`) → ✅ corrigido
|
||
|
||
### 🟡 Duplicações removidas (arquivos órfãos)
|
||
- `app/controllers/motorista_controller.rb` + `app/views/motorista/index.html.erb` — a rota `/motorista` usa `Motorista::DashboardController` (Fase 8)
|
||
- `app/views/layouts/_sidebar.html.erb` — o layout renderiza `_navbar.html.erb`
|
||
- Seeds dos 3 motoristas de teste (João/1234, Maria/5678, Carlos/9012) restaurados — tinham sido sobrescritos
|
||
|
||
### ⚠️ Observação sobre login PIN duplicado
|
||
Existem **dois fluxos de login por PIN** funcionais: a aba PIN na tela Devise (`/auth/login`, Fase 2) e a tela dedicada (`/motorista/login`, Fase 8 — usada pelo QR Code do extrato). Ambos usam `pin_code` e funcionam; recomenda-se manter os dois (a tela dedicada é melhor para mobile) ou unificar futuramente.
|
||
|
||
### 📂 Arquivos das Fases 4, 5 e 6
|
||
|
||
**Fase 4 — Job 1h + Auditoria:**
|
||
```
|
||
config/schedule.rb # Whenever — roda a cada 1h
|
||
lib/tasks/historico.rake # rake historico:atualizar
|
||
app/jobs/application_job.rb
|
||
app/jobs/atualizar_historico_estimado_job.rb # conta entregas pagas × preco_entrega
|
||
app/controllers/api/v1/dashboard_controller.rb # GET /api/v1/dashboard/metricas
|
||
app/controllers/concerns/auditavel.rb # auditar!(:acao, registro) nos controllers
|
||
app/policies/dashboard_policy.rb
|
||
```
|
||
Ativar o cron no servidor: `bundle exec whenever --update-crontab`
|
||
Testar manualmente: `docker-compose exec app bundle exec rake historico:atualizar`
|
||
|
||
**Fase 5 — Consolidação + Wizard Passo 1:**
|
||
```
|
||
db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb # rodar db:migrate!
|
||
app/controllers/consolidacoes_controller.rb # index/new/create/show/wizard/finalizar/arquivar
|
||
app/policies/consolidacao_policy.rb
|
||
app/views/consolidacoes/index.html.erb # lista com filtros (status/nome/período/motorista/rota)
|
||
app/views/consolidacoes/new.html.erb # form: nome + 2 date pickers + multi-select motoristas/rotas
|
||
app/views/consolidacoes/wizard.html.erb # Passo 1: lista motoristas com progresso
|
||
```
|
||
|
||
**Fase 6 — Validação + Toggles + Massa:**
|
||
```
|
||
app/controllers/consolidacao_entregas_controller.rb # validar/classificar/classificar_em_massa/revisar
|
||
app/views/consolidacao_entregas/validar.html.erb # Passo 2: toggles coloridos + checkbox massa + resumo lateral
|
||
app/views/consolidacao_entregas/revisar.html.erb # Passo 3: resumo + próximo motorista / salvar rascunho
|
||
app/javascript/controllers/validacao_controller.js # Stimulus: tempo real (progresso, resumo, toggles)
|
||
config/routes.rb # ATUALIZADO — rotas do wizard
|
||
```
|
||
|
||
**Cores dos toggles (padrão do projeto):**
|
||
🟧 Laranja = Entrega Normal · 🟫 Laranja escuro = Retirada · ⬜ Branco = Bônus · ⬛ Preto = Desconto
|
||
|
||
**Regras implementadas:**
|
||
- Entregas elegíveis: `status='completed'` AND `checkout IS NOT NULL` (somente leitura da tabela existente)
|
||
- Não permite finalizar consolidação com entregas não classificadas (botão desabilitado + validação server-side)
|
||
- Consolidação finalizada não pode ser editada por operadores (Pundit)
|
||
- Todas ações críticas registradas em `auditoria_logs`
|
||
|
||
**Fase 7 — PDFs (Prawn + QR Code):**
|
||
```
|
||
Gemfile # + rqrcode, chunky_png
|
||
db/migrate/20260101000009_add_login_token_to_users.rb # token para QR — rodar db:migrate!
|
||
app/services/pdf/base_pdf.rb # layout base: cabeçalho preto/laranja + rodapé
|
||
app/services/pdf/relatorio_motorista_pdf.rb # tabela detalhada de entregas + resumo + total
|
||
app/services/pdf/extrato_pdf.rb # estilo contracheque + QR Code de login rápido
|
||
app/controllers/consolidacoes_controller.rb # ATUALIZADO: gerar_pdf_relatorio/gerar_pdf_extrato/preview_extrato
|
||
app/views/consolidacoes/show.html.erb # botões por motorista + modal de preview
|
||
app/views/consolidacoes/preview_extrato.html.erb # preview HTML antes de confirmar o PDF
|
||
app/models/user.rb # ATUALIZADO: regenerar_login_token!
|
||
```
|
||
Soft delete já implementado: consolidações finalizadas vão para "arquivada", nunca são excluídas.
|
||
|
||
**Fase 8 — Painel Motorista + Notificações:**
|
||
```
|
||
Gemfile # + twilio-ruby
|
||
app/controllers/motorista/sessoes_controller.rb # login PIN 4 dígitos + acesso via QR (/motorista/acesso/:token)
|
||
app/controllers/motorista/dashboard_controller.rb # 2 cards: estimado (mês) + consolidado (finalizadas)
|
||
app/views/motorista/sessoes/new.html.erb # teclado numérico gigante mobile-first (envia no 4º dígito)
|
||
app/views/motorista/dashboard/index.html.erb # cards laranja/branco + lista com download do próprio extrato
|
||
app/services/notificacao_service.rb # WhatsApp (Twilio) + email — falha nunca quebra a finalização
|
||
app/mailers/application_mailer.rb
|
||
app/mailers/consolidacao_mailer.rb # pagamento_fechado
|
||
app/views/consolidacao_mailer/pagamento_fechado.html.erb # email com identidade visual da marca
|
||
app/views/layouts/mailer.html.erb
|
||
config/initializers/smtp.rb # SMTP via ENV (só ativa se SMTP_USERNAME existir)
|
||
config/routes.rb # ATUALIZADO: /motorista/login + /motorista/acesso/:token
|
||
```
|
||
|
||
**Notificações — como ativar:**
|
||
1. WhatsApp: preencha `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` e `TWILIO_WHATSAPP_FROM` no `.env` e mude a configuração `notificacao_whatsapp` para `true` no sistema. Motorista precisa ter `telefone` cadastrado (formato `+5511999998888`).
|
||
2. Email: preencha as variáveis `SMTP_*` no `.env` e mude `notificacao_email` para `true`. Motorista precisa ter `email`.
|
||
3. Ao finalizar uma consolidação, cada motorista recebe automaticamente a mensagem com o valor e o link do painel.
|
||
|
||
**Fluxo do motorista (público de baixo nível técnico):**
|
||
- Acessa `/motorista/login` → digita PIN de 4 números num teclado gigante (envia sozinho no 4º dígito)
|
||
- OU escaneia o QR Code impresso no extrato → entra direto sem digitar nada
|
||
- Vê 2 cards: 🟧 valor estimado do mês ("aproximado") e ⬜ valor fechado para pagamento
|
||
- Baixa o próprio extrato das consolidações finalizadas
|
||
|
||
### 🧪 Teste end-to-end sugerido
|
||
1. Login admin → criar consolidação (nome + período + motoristas)
|
||
2. Wizard Passo 1 → escolher motorista → Passo 2: classificar entregas com os toggles
|
||
3. Usar "Marcar todos como Normal" → Passo 3: revisar → próximo motorista
|
||
4. Com tudo classificado → Finalizar (motoristas notificados)
|
||
5. Na tela da consolidação → "Ver antes de gerar" → Confirmar → baixar Extrato PDF
|
||
6. Logout → `/motorista/login` → entrar com PIN do motorista
|
||
7. Conferir cards de valores → baixar próprio extrato → sair
|
||
|
||
### ▶️ Próximos passos (deploy)
|
||
|
||
O projeto está completo. Para colocar em produção:
|
||
|
||
```bash
|
||
# 1. Configurar .env com credenciais reais (DB_HOST do PostgreSQL existente)
|
||
cp .env.example .env && nano .env
|
||
|
||
# 2. Subir e migrar
|
||
docker-compose up -d --build
|
||
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
||
|
||
# 3. Ativar o cron do job de 1h
|
||
docker-compose exec app bundle exec whenever --update-crontab
|
||
|
||
# 4. Testar o fluxo end-to-end (seção de teste acima)
|
||
```
|
||
|
||
**Credenciais de teste (seeds):**
|
||
|
||
| Usuário | Login | Senha/PIN |
|
||
|---------|-------|-----------|
|
||
| Admin | admin@gade.com | Gade@2026! |
|
||
| Gerente | gerente@gade.com | Gade@2026! |
|
||
| Motorista João Silva | PIN | 1234 |
|
||
| Motorista Maria Santos | PIN | 5678 |
|
||
| Motorista Carlos Souza | PIN | 9012 |
|
||
|
||
⚠️ Altere as senhas padrão no primeiro acesso em produção.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🧪 Auditoria + Suíte de testes (15/06/2026)</strong></summary>
|
||
|
||
Revisão de código + criação da suíte RSpec (o projeto tinha `rspec-rails`/`factory_bot`/`capybara` no Gemfile, mas **nenhum teste** e **nenhum `spec/`**).
|
||
|
||
### 🔴 Bugs corrigidos
|
||
|
||
**1. Erro de sintaxe em `Admin::UsuariosController`** — `toggle_ativo` tinha `entidade: \'User\'` (aspas escapadas com barra). O arquivo não carregava → qualquer acesso a `/admin/usuarios` dava 500. ✅ corrigido.
|
||
|
||
**2. Migration quebrava o setup do zero** — `20260610_add_fase2_fields_to_users.rb` readicionava a coluna `ativo` (já criada em `create_users`) sem guard → `PG::DuplicateColumn` num `db:migrate` limpo. ✅ guard `unless column_exists?` em `ativo` e `pin_acesso`.
|
||
|
||
**3. `db:seed` falhava no 2º motorista** — motoristas sem e-mail colidiam no índice único de `email` (`default ""`). ✅ seeds agora geram placeholder único (`nome.parameterize@motorista.local`).
|
||
|
||
**4. Dashboard ignorava o filtro de conta Gade** — `DashboardController` e `HistoricoEstimado.{atualizar!,calcular_ao_vivo}` contavam entregas de **todas** as contas. ✅ aplicado `.da_conta_gade`.
|
||
|
||
**5. `valor_total` com duas fontes de verdade** — o controller somava `valor_aplicado` (capturado na classificação) e o callback do model recalculava pelos **preços atuais** de `Configuracao` → divergência quando os preços mudavam. ✅ `ConsolidacaoMotorista#recalcular_valor` agora usa `valor_aplicado` (fonte única).
|
||
|
||
**6. Gate de finalização + classificação sem validação**
|
||
- `todas_entregas_classificadas?` usava soma agregada com `>=`, mascarando motoristas pendentes quando outro tinha entregas de sobra. ✅ agora exige cada motorista completo (`all?`) e respeita o filtro de rotas.
|
||
- `classificar`/`classificar_em_massa` aceitavam `tracking_id`/`motorista` arbitrários. ✅ validam que o motorista pertence à consolidação e que a entrega é elegível no período/rota.
|
||
|
||
**7. Coluna órfã `pin_acesso`** — nunca usada (o sistema usa `pin_code`). ✅ removida via migration.
|
||
|
||
**Minors:** motorista logado conseguia abrir `/dashboard` (✅ redireciona ao painel do motorista); `Date.parse(params[:data])` sem rescue dava 500 (✅ cai em `Date.current`); `arquivar!` não registrava autor (✅ novas colunas `arquivado_por`/`arquivado_em` + `belongs_to :arquivador`); `MotoristaController` + `app/views/motorista/index.html.erb` órfãos e quebrados (`current_user.name`, `where(user:)`, status inexistentes) — ✅ removidos.
|
||
|
||
### 🆕 Migrations adicionadas (rodar `db:migrate`)
|
||
```
|
||
db/migrate/20260615000001_remove_pin_acesso_from_users.rb
|
||
db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb # arquivado_por / arquivado_em
|
||
```
|
||
|
||
### ✅ Suíte de testes
|
||
```
|
||
.rspec
|
||
config/database.yml # ENV-driven, com banco de teste separado (<DB_NAME>_test)
|
||
spec/spec_helper.rb · spec/rails_helper.rb
|
||
spec/support/{pundit,devise}.rb
|
||
spec/factories.rb
|
||
spec/models/{user,configuracao,consolidacao,consolidacao_motorista,consolidacao_entrega,auditoria_log}_spec.rb
|
||
spec/policies/consolidacao_policy_spec.rb
|
||
spec/requests/dashboard_spec.rb
|
||
```
|
||
|
||
Como rodar:
|
||
```bash
|
||
docker-compose up -d
|
||
docker-compose exec app env RAILS_ENV=test bundle exec rails db:create db:migrate
|
||
docker-compose exec app bundle exec rspec
|
||
```
|
||
|
||
> Os specs que dependem da tabela read-only `db_reem_simplerout_2026` stubam `Entrega` — o banco de teste não contém essa tabela externa.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔧 Correções + funcionalidades (16/06/2026)</strong></summary>
|
||
|
||
### 🔴 Erros de runtime corrigidos (reportados em prints)
|
||
- **Logout** dava `No route matches [GET] /auth/logout` — o link usava `method: :delete` (rails-ujs), ignorado pelo Turbo. Agora usa `data-turbo-method`.
|
||
- **/admin/usuarios** e **/admin/configuracoes** estouravam `NoMethodError: admin_ou_gerente?` — helpers de papel (`admin?`, `admin_ou_gerente?`) movidos para a `ApplicationPolicy`.
|
||
- **Listagem de usuários** chamava o helper inexistente `badge_role` — criado em `ApplicationHelper`.
|
||
- **Nova consolidação** estourava `NoMethodError: new?` — `ApplicationPolicy` agora define `new? = create?` e `edit? = update?`.
|
||
- **Login do motorista (PIN)** derrubava `/motorista` com `PG::UndefinedTable: relation "consolidacao_motorista" does not exist` — o inflector pt-BR resolvia o composto no singular. `self.table_name` fixado explicitamente em `Consolidacao`, `ConsolidacaoMotorista`, `ConsolidacaoEntrega` e `Configuracao`.
|
||
- **Listagem de usuários** faltava o helper `badge_status_usuario` e a rota `toggle_ativo` estava sem o prefixo `admin_` (verbo errado também) — corrigidos.
|
||
- **Passo 3 (revisão) da consolidação** ficava em branco: `GROUP BY tipo` combinado com `ORDER BY created_at` é inválido no PostgreSQL (`PG::GroupingError`, vira 500/tela branca em produção). Corrigido com `reorder(nil)` no count agrupado.
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Dashboard com filtro de faixa de datas** (Flatpickr, modo range) no lugar da navegação por mês; o controller passou a consultar por período e a respeitar `.da_conta_gade`.
|
||
- **Regra de pagamento por checkout:** `Entrega.pagas` agora é `status='completed' AND checkout IS NOT NULL` (antes era `checkin`). Vale para dashboard, painel do motorista, job de 1h e elegibilidade das consolidações.
|
||
- **Consolidação por veículo:** o filtro/identificação passou de rota (`route_id`, UUID) para **veículo** (`vehicle`). Migration troca `consolidacoes.route_ids` por `vehicle_ids`.
|
||
- **Filtro de conta configurável:** `account_id` na tabela externa é texto e vinha vazio nos registros recentes (o dado de junho estava 100% em conta vazia, e o filtro fixo em `95907` zerava o dashboard). `DB_EXISTING_ACCOUNT_ID` agora aceita lista de contas ou `all`/vazio (sem filtro). Comparação feita como texto.
|
||
- **Múltiplos pilares por entrega:** na validação da consolidação cada entrega pode receber mais de um pilar ao mesmo tempo (ex.: Normal + Bônus + Retirada), somando os valores. Os toggles viraram multi-seleção (ligam/desligam por pilar); a unicidade passou a ser `[consolidacao_id, tracking_id, tipo]` e o progresso conta entregas distintas.
|
||
- **Tela de "Pilares de Preço"** passou a listar só os pilares de preço (Entrega/Retirada/Bônus/Desconto); Nome da Empresa e Notificações saíram dali (não são preço).
|
||
|
||
### ⚙️ Automação / diagnóstico
|
||
- `docker-compose.yml`: o boot roda `rails db:prepare` antes do servidor — **não precisa mais rodar `db:migrate` na mão** após o `up`.
|
||
- `rake reem:diagnostico`: checa a tabela externa por conta/período/status/checkout e o intervalo de datas disponível (útil para depurar dashboard vazio).
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rake reem:diagnostico
|
||
docker-compose exec app bundle exec rake reem:diagnostico INICIO=2026-02-01 FIM=2026-06-30
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔧 Correções + funcionalidades (16/06/2026 — sessão 2)</strong></summary>
|
||
|
||
### 🔴 Bugs corrigidos
|
||
- **Dashboard zerado por filtro de conta:** `DB_EXISTING_ACCOUNT_ID` é uma **lista separada por vírgula**, não um trecho de SQL. Valor errado (ex.: `account_id IN ('95907', '')`) zerava tudo. Use `95907,` (vírgula no fim) para incluir também registros com conta vazia/NULL. O scope `Entrega.da_conta_gade` agora inclui `NULL` quando há token vazio na lista.
|
||
- **Dashboard travava ao voltar (só com F5):** era o cache de preview do Turbo servindo um snapshot congelado. Adicionado `turbo-cache-control: no-cache` na página e o gráfico Chart.js virou idempotente (`Chart.getChart(ctx)?.destroy()`).
|
||
- **500 ao criar motorista:** `users.email` era `NOT NULL` com índice único e default `""` — o 2º motorista sem e-mail colidia em `""` (`RecordNotUnique`). Agora a coluna permite `NULL` e e-mail em branco vira `nil` (`User#normalizar_email_em_branco`); no Postgres vários `NULL` convivem no índice único.
|
||
- **500 ao gerar relatório PDF** (`gerar_pdf_relatorio`): `@entregas.group(:tipo).count` com `.order(:created_at)` gerava `GROUP BY ... ORDER BY created_at` → `PG::GroupingError`. Corrigido com `reorder(nil)`. Também removidas as opções `color:` passadas ao `Prawn#text` (não suportadas — uso de `fill_color`).
|
||
- **Motorista não baixava o próprio extrato** ("sem permissão"): `ConsolidacaoPolicy#show?` exige `pode_consolidar?` (falso para motorista). Novo `autorizar_extrato!` no `ConsolidacoesController` — staff baixa qualquer um; motorista baixa só o próprio (PDF já filtrado pelo nome dele).
|
||
- **QR code do extrato não abria:** URL estava fixa em `https://`, mas o servidor roda em **http** (IP:porta). Novo `url_acesso` respeita o esquema do `APP_HOST` (sem esquema = http). Defina `APP_HOST` com o IP/host acessível pelo celular.
|
||
|
||
### 🆕 Funcionalidades
|
||
- **NF + Endereço no relatório PDF:** colunas separadas; a coluna **Tracking** foi substituída por **Veículo** (relatório e tela de revisão / passo 3).
|
||
- **Endereço nas telas da consolidação:** linha de endereço nos cards do passo 2 (validação) e coluna Endereço no passo 3 (revisão, paginado 20/50/100).
|
||
- **Campos de assinatura nos PDFs:** bloco com assinatura do **Motorista** e do **Administrador** (Reem Transporte) no relatório e no extrato (`BasePdf#assinaturas`).
|
||
- **Selecionar todos** no passo 2: checkbox que marca/desmarca todas as entregas de uma vez.
|
||
- **Desselecionar em massa:** toggle "Modo remover" — os botões Normal/Retirada/Bônus/Desconto (e "marcar todos") passam a **remover** o pilar das entregas selecionadas (`classificar_em_massa` aceita `acao: remover`).
|
||
- **Botões de voltar** entre os passos do wizard (2→1, 3→2) viraram botões visíveis (antes eram texto cinza discreto).
|
||
|
||
### 🆕 Migration adicionada (rodar `db:migrate`)
|
||
- `20260616000003_allow_null_email_for_motoristas` — `users.email` passa a permitir `NULL`; e-mails `""` existentes viram `NULL`.
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🗄️ Banco existente — referência rápida</strong></summary>
|
||
|
||
| Campo | Uso |
|
||
|-------|-----|
|
||
| `tracking_id` | PK lógico da entrega |
|
||
| `reference_id` | Número da NF |
|
||
| `planned_date` | Data da entrega (filtro principal) |
|
||
| `driver` | Nome do motorista |
|
||
| `status` | `'completed'` = entrega concluída |
|
||
| `checkout` | NULL = motorista não finalizou (define elegibilidade de pagamento) |
|
||
| `vehicle` | Veículo da entrega (usado no filtro de consolidação) |
|
||
| `route_id` | UUID da operação (não usado no app — substituído por `vehicle`) |
|
||
| `contact_name` | Nome do local (UBS SACOMA, EMAD VELEIROS…) |
|
||
|
||
**Regra de pagamento:** `status = 'completed'` AND `checkout IS NOT NULL`
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🎨 Design — padrão da marca</strong></summary>
|
||
|
||
| Elemento | Cor | Hex |
|
||
|----------|-----|-----|
|
||
| Fundo | Preto | `#0a0a0a` |
|
||
| Cards | Cinza escuro | `#1a1a1a` |
|
||
| Destaque / botões | Laranja | `#f97316` |
|
||
| Textos | Branco | `#ffffff` |
|
||
| Sucesso | Verde | `#22c55e` |
|
||
| Erro/desconto | Vermelho | `#ef4444` |
|
||
|
||
- Fonte mínima 16px desktop / 18px mobile
|
||
- Botões mínimo 48px altura (acessibilidade touch)
|
||
- Tema escuro como padrão
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>👥 Perfis de acesso</strong></summary>
|
||
|
||
| Perfil | Login | Acesso |
|
||
|--------|-------|--------|
|
||
| Admin | email + senha | Tudo |
|
||
| Gerente | email + senha | Dashboard + Consolidações + PDFs |
|
||
| Operador | email + senha | Consolidações (criar + validar) |
|
||
| Motorista | PIN 4 dígitos ou QR | Painel pessoal + baixar próprio PDF |
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🩺 Correção (11/06): container caía mostrando ajuda do "rails new"</strong></summary>
|
||
|
||
**Sintoma:** `docker-compose up` subia e o log mostrava o help do `rails new` em loop.
|
||
**Causa:** o repositório tinha os arquivos da aplicação (models/controllers/views) mas **não tinha o esqueleto de boot do Rails** — sem `config/application.rb` e `config.ru`, o comando `rails s` não reconhece a pasta como um app Rails e cai no modo "criar app novo".
|
||
|
||
**Arquivos adicionados nesta correção:**
|
||
```
|
||
config.ru # entrada Rack
|
||
config/boot.rb, environment.rb # boot do Rails
|
||
config/application.rb # módulo GadeLogistica + timezone SP + pt-BR
|
||
config/puma.rb # servidor web
|
||
config/environments/{development,production,test}.rb
|
||
config/importmap.rb # pins do Stimulus/Turbo
|
||
config/initializers/devise.rb # obrigatório para o devise_for das rotas
|
||
config/locales/pt-BR.yml # formatos de data brasileiros
|
||
bin/rails, bin/rake # executáveis
|
||
app/javascript/application.js # bootstrap Turbo + Stimulus
|
||
app/javascript/controllers/{application,index}.js
|
||
Gemfile # + gem propshaft (servir o JS via importmap)
|
||
app/views/layouts/application.html.erb # + javascript_importmap_tags (Stimulus não carregava!)
|
||
config/routes.rb # devise_for agora aponta para users/sessions (login PIN)
|
||
```
|
||
|
||
### Como subir agora (passo a passo completo)
|
||
|
||
```bash
|
||
# 1. Criar o .env (você NÃO tem ele ainda — é cópia do exemplo):
|
||
cp .env.example .env
|
||
|
||
# 2. Gerar a SECRET_KEY_BASE e colar no .env:
|
||
openssl rand -hex 64
|
||
nano .env # cole em SECRET_KEY_BASE= e preencha DB_HOST/DB_USER/DB_PASSWORD
|
||
|
||
# 3. Rebuild (Gemfile mudou — gem propshaft):
|
||
docker-compose build --no-cache
|
||
docker-compose up -d
|
||
|
||
# 4. Criar banco do sistema + migrar + popular:
|
||
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
||
|
||
# 5. Conferir:
|
||
docker-compose logs -f app # deve mostrar "Listening on http://0.0.0.0:3000"
|
||
# Acesse http://SEU_IP:3000 → admin@gade.com / Gade@2026!
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔐 Segurança</strong></summary>
|
||
|
||
- **NUNCA** versione `.env`, `config/master.key` ou qualquer arquivo com senha
|
||
- Repositório **PRIVADO** em git.xenserver.com.br
|
||
- Use sempre `ENV['VARIAVEL']` no código Ruby
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 17/06/2026 — Pagamentos, Operações, Dashboard financeiro e UI</strong></summary>
|
||
|
||
Conjunto de melhorias para deixar o sistema pronto para entrega final.
|
||
|
||
### 💳 Status de pagamento (Pago / Parcial / Pendente)
|
||
- Migration `add_pagamento_to_consolidacao_motoristas`: colunas **`pago_em`**, **`pago_por`**,
|
||
**`forma_pagamento`** em `consolidacao_motoristas` (pagamento é **por motorista**).
|
||
- `ConsolidacaoMotorista`: `pago?`, `marcar_pago!(user, forma:)`, `cancelar_pagamento!`,
|
||
scopes `pagos`/`pendentes`, `belongs_to :pagador`.
|
||
- `Consolidacao#status_pagamento` (`:pendente`/`:parcial`/`:pago`), `valor_pago`, `valor_pendente`,
|
||
scopes `pagamento_pago`/`pagamento_pendente`/`pagamento_parcial` (filtro da lista).
|
||
- Tela **show**: marcar motorista individual **ou a operação inteira** (com `<select>` de forma);
|
||
estorno. Ações `registrar_pagamento`/`cancelar_pagamento` (admin/gerente).
|
||
- Helper `badge_pagamento`; filtro pago/pendente na **index**; selo no **extrato**.
|
||
- **Notificação de pagamento**: `NotificacaoService.notificar_pagamento` (WhatsApp + e-mail
|
||
`ConsolidacaoMailer#pagamento_efetuado`) — falhas só logam.
|
||
- **Aviso in-app no painel do motorista**: faixa "Pagamento confirmado" (últimos 7 dias) + selo
|
||
PAGO por consolidação.
|
||
|
||
### 🗂️ Consolidações por OPERAÇÃO (tabelas externas `gade_entregas_*`)
|
||
- PORO **`app/models/operacao.rb`**: descobre as tabelas via `information_schema`, com
|
||
**whitelist anti-injection** (`Operacao.sanitizar` + `quote_table_name`) — nunca interpolar
|
||
nome de tabela cru. `Operacao.todas`, `dados(tabelas)`, `agrupadas_por_mes`.
|
||
- `Entrega.da_operacoes(tabelas)`: junta por NF (`reference_id::text = nota_fiscal`).
|
||
- Coluna **`operacoes` (jsonb)** em `consolidacoes`; `Consolidacao.com_operacoes(ops)`.
|
||
- **Nova consolidação**: multi-seleção de operação **pré-preenche** motoristas/veículos/período
|
||
e **filtra** as entregas por NF; liberdade de marcar **um ou todos** os motoristas da operação.
|
||
- ENV opcional `OPERACOES_TABLE_PREFIX` (default `gade_entregas_`).
|
||
|
||
### 📊 Dashboard financeiro
|
||
- **Filtro de operação** no topo (multi-seleção, agrupada por mês, com "Limpar") aplicado a tudo.
|
||
- KPIs: **Custo total**, **Ticket médio/entrega**, **Pago**, **A pagar**.
|
||
- Gráficos (Chart.js): **custo por operação**, **composição por tipo**, **custo por motorista**,
|
||
**rosca pago × a pagar** + tabelas (pagos por data de pagamento, pendentes).
|
||
|
||
### 📅 Correção de data — usar `checkout` (data real), não `planned_date`
|
||
- `Entrega.no_periodo_checkout(inicio, fim)`: filtro pela **data real da conclusão**, com
|
||
**comparação naïve** (intervalo meio-aberto `>= início` e `< fim+1`), **sem conversão de fuso**
|
||
— igual à análise feita direto no banco.
|
||
- Aplicado em `contar_pagas`, elegibilidade do wizard, gráfico principal (`DATE(checkout)`) e na
|
||
exibição da data das entregas.
|
||
|
||
### 🧾 PDFs (Relatório e Extrato)
|
||
- **Relatório**: endereço completo com quebra de linha, larguras de coluna fixas, quebra de
|
||
página antes do RESUMO. **Rodapé via `canvas`** (na margem inferior — não sobrepõe mais o
|
||
conteúdo); margem inferior 72.
|
||
- **Extrato**: coluna **Unit.**, os 4 pilares sempre exibidos, **total de entregas + veículos**,
|
||
**selo de pagamento** (PAGO/PENDENTE) e **garantia de espaço para o QR Code**
|
||
(`start_new_page` se faltar espaço — o Prawn não pagina imagem sozinho).
|
||
|
||
### 🎨 UI / Identidade visual
|
||
- **Fonte base maior** para leitura: `html { font-size: 17px }` (desktop) e `18px` (mobile).
|
||
- **Contraste** elevado no dashboard (`text-gray-500/600` → `text-gray-400`).
|
||
- **Painel do motorista** redesenhado mobile-first (fontes maiores, botões full-width, alvos ≥48px).
|
||
- **Favicon** (van laranja): `public/favicon.png`.
|
||
- **Logo da Reem** na barra (sidebar + topo mobile): `public/logo-reem.png`.
|
||
- **Animação de abertura (splash) após login**: vídeo `public/login-animacao.{webm,mp4}` (WebM +
|
||
MP4 fallback), exibido **uma vez** via flag de sessão (`session[:mostrar_splash]`), com
|
||
`mix-blend-mode: screen` para "derrubar" o fundo preto do vídeo.
|
||
- `rack-mini-profiler` desativado em `config/initializers/rack_mini_profiler.rb`.
|
||
|
||
### 📌 Padrões a seguir (novos)
|
||
- **Tabelas externas / read-only**: acesso só por SELECT; nome de tabela em SQL **sempre** via
|
||
whitelist + `quote_table_name` (ver `Operacao`).
|
||
- **Datas financeiras**: usar **`checkout`** (`no_periodo_checkout`), comparação naïve sem fuso.
|
||
- **Gráficos**: Chart.js via CDN, padrão **IIFE** + `Chart.getChart(ctx)?.destroy()` (evita
|
||
"Canvas already in use" com Turbo).
|
||
- **Badges**: `badge_status` (consolidação) e `badge_pagamento` (pagamento).
|
||
- **Assets estáticos** (favicon, logo, vídeos) ficam em `public/` e são referenciados por caminho
|
||
absoluto (`/arquivo.ext`).
|
||
|
||
### Migrations adicionadas hoje
|
||
```
|
||
20260617000001_add_pagamento_to_consolidacao_motoristas.rb
|
||
20260617000002_add_operacoes_to_consolidacoes.rb
|
||
```
|
||
Aplicar com: `docker-compose exec app bundle exec rails db:migrate`
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔧 Correções — Extrato PDF (18/06/2026)</strong></summary>
|
||
|
||
### 🔴 Bugs corrigidos
|
||
- **500 ao gerar extrato** (`gerar_pdf_extrato`): `Prawn::Errors::IncompatibleStringEncoding`
|
||
(`Encoding::UndefinedConversionError`). Os PDFs usavam a fonte **embutida** do Prawn
|
||
(Helvetica/AFM), que só aceita o charset **Windows-1252** — o caractere **`✓`** do selo
|
||
"✓ PAGO" não existe nesse charset e derrubava a geração.
|
||
✅ **Corrigido:** `BasePdf` agora registra a família **DejaVu Sans (TTF)** e a define como
|
||
fonte padrão (`registrar_fonte_utf8`), com suporte total a **UTF-8**. Vale para todos os PDFs
|
||
(extrato e relatório) — qualquer caractere Unicode passa a funcionar.
|
||
- Fontes versionadas em `app/assets/fonts/DejaVuSans.ttf` e `DejaVuSans-Bold.ttf`
|
||
(a imagem Docker `ruby:3.2.2-slim` não traz fontes do sistema; o `COPY . .` do Dockerfile e
|
||
o volume `.:/app` garantem que estejam disponíveis em runtime).
|
||
- **Assinaturas do extrato caíam na 2ª página:** após o QR Code o cursor ficava abaixo do
|
||
limite de quebra (`start_new_page if cursor < 120`), empurrando o bloco de assinaturas para
|
||
uma página nova. ✅ **Corrigido:** espaçamentos compactados com segurança (padding das
|
||
tabelas `[4,8]`, QR Code menor) + folga antes das assinaturas reduzida (`BasePdf#assinaturas`,
|
||
60 → 36, limite de quebra 120 → 100) — **o extrato agora cabe em uma única página**.
|
||
- **QR Code sobrepondo / sumindo:** o posicionamento por `move_up`/`move_down` era frágil e,
|
||
com a fonte nova, fazia o QR se sobrepor ao texto/assinaturas. ✅ **Corrigido:**
|
||
`desenhar_qrcode` agora desenha a imagem com **posição absoluta (`at:`)** — que não move o
|
||
cursor — e avança o cursor manualmente pela altura da imagem. Também garantido que cada caixa
|
||
(selo de pagamento, total) tenha folga **≥ a própria altura** para não sobrepor o conteúdo
|
||
seguinte.
|
||
|
||
- **QR Code não aparecia para alguns motoristas:** o QR aponta para o painel do motorista
|
||
(`/motorista/acesso/:token`), que **exige uma conta de usuário** (`User` motorista com
|
||
`login_token`). Sem conta correspondente, `user_motorista` ficava `nil` e a seção do QR era
|
||
**omitida silenciosamente**. Duas causas tratadas:
|
||
- **Nome não batia** (espaços/maiúsculas) entre `consolidacao_motoristas.motorista_nome`
|
||
(dado externo) e `users.nome` (digitado no admin). ✅ `ConsolidacoesController#buscar_user_motorista`
|
||
agora tenta o match exato e, se falhar, compara nomes **normalizados** (sem espaços
|
||
extras/duplos, case-insensitive).
|
||
- **Motorista sem conta:** ✅ em vez de omitir, o extrato agora desenha um **aviso**
|
||
("Cadastro de acesso ainda não disponível… solicite ao administrador") no lugar do QR
|
||
(`ExtratoPdf#desenhar_aviso_sem_acesso`). O QR só existe para motoristas **com cadastro**
|
||
no site — comportamento esperado, já que sem conta não há painel para acessar.
|
||
|
||
### 📌 Padrão (novo)
|
||
- **PDFs com Prawn**: usar **sempre** a fonte TTF registrada no `BasePdf` (DejaVu, UTF-8). Nunca
|
||
contar com a fonte embutida — ela quebra em qualquer caractere fora do Windows-1252.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 19/06/2026 — Apontamento manual / Nota avulsa</strong></summary>
|
||
|
||
Permite incluir numa consolidação uma **entrega que chegou por fora** (ex.: nota que entrou
|
||
depois e não está no banco mensal das operações). O usuário digita o **número da nota (NF =
|
||
`reference_id`)**, o sistema busca os dados direto na `db_reem_simplerout_2026` (atualizada a cada
|
||
hora pela SimpleRoute) e adiciona a entrega ao fechamento do motorista.
|
||
|
||
> A tabela externa continua **read-only** — o apontamento só **lê** dela (`Entrega.por_nf`,
|
||
> sem filtro de período, restrito à conta Gade via `da_conta_gade`).
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Apontamento dentro de uma consolidação existente** (wizard Passo 1): botão **"➕ Apontamento
|
||
manual"** → modal busca a NF → confere os dados (motorista/veículo/data) → escolhe **um ou
|
||
vários pilares** (Normal/Retirada/Bônus/Desconto) → adiciona.
|
||
- **"Nota avulsa"** (tela de Consolidações): botão **"➕ Nota avulsa"** → busca a NF → ao
|
||
prosseguir **cria já a consolidação** (nome `Avulsa nf<NF>`, período = data da entrega → fim do
|
||
mês, motorista vindo do `driver` da entrega) com o apontamento, e abre a tela da consolidação.
|
||
- **Múltiplas classificações** por apontamento: cria um pilar (`ConsolidacaoEntrega`) por tipo
|
||
escolhido, somando os valores (mesmo modelo multi-pilar das entregas normais).
|
||
- **Motorista automático**: vem do `driver` da entrega; se ainda não estiver na consolidação, é
|
||
incluído.
|
||
- **Gestão na tela da consolidação (show)**: seção **"➕ Apontamentos manuais"** lista NF,
|
||
motorista, pilares e valor, com botão de **remover** (a nota inteira). Na revisão (Passo 3) os
|
||
apontamentos aparecem com o selo **"➕ Apontamento"** e botão de remover (por pilar).
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- Coluna **`manual` (boolean, default false)** em `consolidacao_entregas` distingue apontamento de
|
||
entrega elegível do período. `Consolidacao#classificadas_count` ignora os `manual: true` para
|
||
**não estourar a barra de progresso** nem o gate de finalização (`>= 100%`).
|
||
- Lógica centralizada em **`Consolidacao#adicionar_apontamento!(entrega, tipos, user)`** e
|
||
**`Consolidacao#recalcular_motorista!`** — reusadas pelos dois fluxos (wizard e avulsa). Preço por
|
||
pilar via `ConsolidacaoEntrega.valor_para(tipo)` (fonte única). Dados da entrega via
|
||
`Entrega#resumo_apontamento`.
|
||
- Rotas novas:
|
||
- `consolidacoes` (coleção): `GET buscar_nf` (JSON), `POST avulsa`.
|
||
- `consolidacao_entregas` (coleção): `GET buscar_nf`, `POST apontar`, `DELETE remover_apontamento`
|
||
(aceita `?id=` para um pilar ou `?tracking_id=&motorista=` para a nota toda).
|
||
- Stimulus: `apontamento_controller.js` (modal do wizard) e `nota_avulsa_controller.js` (modal da
|
||
index). ⚠️ Identificador do Stimulus segue o nome do arquivo em **dash-case**: `nota_avulsa_controller.js`
|
||
→ `data-controller="nota-avulsa"` / `data-action="nota-avulsa#..."` (e o elemento da ação precisa
|
||
estar **dentro** da `div` do controller).
|
||
|
||
### 🆕 Migration adicionada (rodar `db:migrate`)
|
||
```
|
||
20260619000001_add_manual_to_consolidacao_entregas.rb
|
||
```
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
```
|
||
|
||
### 🧪 Testes
|
||
- `spec/models/consolidacao_spec.rb`: `classificadas_count` ignora apontamentos manuais e
|
||
`adicionar_apontamento!` (multi-tipo, idempotente, inclui o motorista e soma o valor).
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 22/06/2026 — Pilar Extraordinária, Perfil Externo, Falhadas, Relatórios e Segurança QR</strong></summary>
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Pilar "Entrega Extraordinária"** — preço coringa para entregas fora do planejamento.
|
||
- Novo valor `extraordinaria` no `enum tipo` de `ConsolidacaoEntrega` (soma positiva, como Bônus). Sem migration de enum (coluna `integer`).
|
||
- Aparece em **todas** as telas dos pilares: wizard (passo 1), validação, revisão, nota avulsa, extrato, PDFs e composição do dashboard.
|
||
- **Preço padrão configurável** em *Configurações* (`preco_extraordinaria`) **e** **valor customizável por entrega**: ao marcar "Extra" no wizard abre um prompt já preenchido com o padrão (`valor_classificacao` no controller + `pedirValor()` no Stimulus).
|
||
- **Card "Consolidado / Pago"** no topo do dashboard (total consolidado + total já pago do período; reusa `@fin_custo_total` / `@fin_pago`).
|
||
- **Entregas falhadas no dashboard** — o card "Total Entregas" agora separa `entregues · pendentes · falhadas`.
|
||
- Novo `Entrega::STATUS_FALHA` + scope `falhadas`; o scope `pendentes` foi ajustado para não sobrepor (nem `completed` nem falha).
|
||
- **Relatórios financeiros em PDF (admin):**
|
||
- Por **consolidação** — `Pdf::RelatorioFinanceiroConsolidacaoPdf` (botão "📊 Relatório financeiro" na tela da consolidação).
|
||
- Por **período** — `Pdf::RelatorioFinanceiroPeriodoPdf` (botão "🖨️ Relatório" no dashboard, respeita o filtro de período/operação).
|
||
- **Novo perfil de usuário: "Externo (só dashboard)"** (`role: externo`).
|
||
- Login normal por e-mail/senha; enxerga **apenas o dashboard**. Bloqueado nas demais áreas pelas policies Pundit (`pode_consolidar?`/admin). Ideal para enviar a outras gerências visualizarem os indicadores.
|
||
- **Filtro de data re-estilizado** — calendário no tema escuro (flatpickr dark + acento laranja) e atalhos rápidos: **Hoje / 7 dias / Este mês / 30 dias**.
|
||
|
||
### 🔴 Bugs corrigidos
|
||
- **Botão "Arquivar"** não funcionava em consolidação zerada / rascunho. `Consolidacao#arquivar!` e `#reativar!` passaram a usar `update_columns` (pulam validações e o callback de recálculo) — arquivar é soft-delete administrativo e deve sempre funcionar. `turbo_confirm` movido para o `<form>` (mais confiável).
|
||
- **Caixa do filtro de data** mostrava uma data solta em vez do período. Causa: o flatpickr recebia datas em ISO (`YYYY-MM-DD`) com `dateFormat: 'd/m/Y'`. Corrigido passando objetos `Date` + separador `" até "`; seleção de **um único dia** agora aplica; re-inicialização em `turbo:load`.
|
||
- **Moeda sem separador de milhar** — padronizado **`R$ 1.234,56`** (milhar `.`, decimais `,`) em: helper `moeda` (`number_to_currency`), PDFs (`base_pdf`), `Configuracao#valor_formatado`, mensagens do `NotificacaoService` e tooltips/JS do dashboard e do wizard.
|
||
- **R$ "quebrado" no painel do motorista** (celular) — os valores grandes quebravam "R$" e o número em linhas separadas. Adicionado `whitespace-nowrap` + fonte responsiva (`text-4xl sm:text-5xl`) nos cards.
|
||
|
||
### 🔐 Segurança
|
||
- **QR Code do extrato não loga mais direto.** Antes, ler o QR (`/motorista/acesso/:token`) autenticava o motorista sem PIN. Agora o QR apenas **identifica** o motorista e leva à tela de PIN; ele precisa digitar o **PIN de 4 dígitos** para entrar — e o PIN tem que ser **do mesmo motorista do QR** (impede usar o QR de outra pessoa). A tela saúda pelo nome quando vem do QR.
|
||
|
||
### 🆕 Migration adicionada (rodar `db:migrate`)
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
```
|
||
- `20260622000001_add_preco_extraordinaria_configuracao` — cria a config `preco_extraordinaria` (padrão `R$ 25,00`) em bancos já existentes (idempotente; não sobrescreve valor já definido).
|
||
|
||
### ✅ Confirmado (25/06/2026)
|
||
- O status de falha gravado pelo SimpleRoute é exatamente **`failed`** — `Entrega::STATUS_FALHA = %w[failed]` está correto e dispensa ajuste.
|
||
```bash
|
||
docker-compose exec app bundle exec rails runner "puts Entrega.da_conta_gade.distinct.pluck(:status).inspect"
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local)</strong></summary>
|
||
|
||
A consolidação só considerava entregas **concluídas com sucesso**. Como o motorista
|
||
**se desloca até o local mesmo nas entregas que falham (insucesso)**, essas precisam
|
||
**constar na consolidação** para serem classificadas e remuneradas conforme o caso.
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Novo conjunto elegível "atendidas"** — entregas em que o motorista foi ao local:
|
||
status **`completed` (sucesso) OU `failed` (insucesso)**, sempre com **checkout** registrado.
|
||
Substitui o antigo critério de só `.pagas` (`completed`) na consolidação.
|
||
- **Status visível em cada entrega** na tela de validação (Passo 2): selo **✓ Entregue** (verde)
|
||
ou **⚠️ Insucesso** (vermelho), com o card de insucesso destacado em **borda vermelha** —
|
||
facilita visualmente identificar o que classificar.
|
||
- **Insucesso entra para classificação manual**, não com valor automático: o admin escolhe o
|
||
pilar/valor (Normal, Retirada, Bônus, Desconto, Extraordinária) — o motorista foi ao local,
|
||
então a entrega aparece na lista, na contagem e na barra de progresso.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- `Entrega::STATUS_ATENDIDO = (%w[completed] + STATUS_FALHA)` + scope **`atendidas`**
|
||
(`where(status: STATUS_ATENDIDO).com_checkout`). Novo helper de instância `Entrega#falhada?`.
|
||
- `Entrega.contar_pagas` → renomeado para **`Entrega.contar_atendidas`** (usa o scope `atendidas`).
|
||
- `Consolidacao#entregas_elegiveis_count` passa a contar **atendidas** — reflete no wizard, na
|
||
barra de progresso e no **gate de finalização** (`todas_entregas_classificadas?`): agora as
|
||
entregas de insucesso também precisam ser classificadas antes de finalizar.
|
||
- `ConsolidacaoEntregasController`: a lista do Passo 2 (`validar`) e o guard de elegibilidade
|
||
(`tracking_ids_elegiveis`) usam `Entrega.atendidas`.
|
||
- **Não afeta o lado financeiro:** dashboard, painel do motorista, job de 1h e `HistoricoEstimado`
|
||
continuam usando `.pagas` (só `completed`) — "atendidas" é exclusivo da elegibilidade da
|
||
consolidação.
|
||
|
||
### 📂 Arquivos alterados
|
||
```
|
||
app/models/entrega.rb # STATUS_ATENDIDO, scope :atendidas, contar_atendidas, falhada?
|
||
app/models/consolidacao.rb # entregas_elegiveis_count usa contar_atendidas
|
||
app/controllers/consolidacao_entregas_controller.rb # validar + tracking_ids_elegiveis usam :atendidas
|
||
app/views/consolidacao_entregas/validar.html.erb # selo de status por entrega + destaque do insucesso
|
||
```
|
||
|
||
> Sem migration — a mudança é apenas de leitura/regra de negócio sobre a tabela read-only
|
||
> `db_reem_simplerout_2026`.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente</strong></summary>
|
||
|
||
Dois recursos na consolidação:
|
||
|
||
### 1. Apontamento 100% manual (tela de Validar — Passo 2)
|
||
Caso real: o motorista **A** foi até o local mas **não** entregou; no rastreio que alimenta o
|
||
banco, a NF ficou registrada sob o motorista **B** (que de fato entregou). Logo, a busca por NF
|
||
nunca encontra o A — mas o A também recebe, pois foi ao local. O apontamento manual antigo
|
||
(busca a NF na base e puxa `entrega.driver`) não serve.
|
||
- **Novo lançamento 100% manual**, sem consultar a base de rastreio, atribuível a **qualquer
|
||
motorista** (seletor com os motoristas da consolidação, ou digita um novo), com **NF (texto
|
||
livre)**, **observação/local** e **pilar(es)** (+ valor da Extraordinária).
|
||
- Botão **"➕ Apontamento 100% manual"** na tela de Validar. Soma ao valor do motorista, mas
|
||
**não** conta no gate `X/Y classificadas` (é `manual: true`, igual aos apontamentos por NF).
|
||
- A NF digitada aparece na Revisão (Passo 3), na tela da consolidação e no **Relatório PDF** do
|
||
motorista (fallback `nf_manual`/`obs_manual` quando não há `Entrega` para consultar).
|
||
|
||
### 2. Arquivar motorista individualmente (reversível)
|
||
Numa consolidação em massa, dá para **arquivar** um motorista específico no Passo 1 (botão 🗄️
|
||
por card) — ele **sai dos totais, do pagamento e do gate**, mas os dados ficam guardados. Seção
|
||
**"🗄️ Motoristas arquivados"** no fim do wizard com **"♻️ Restaurar"**.
|
||
- **Não** arquiva motorista **já pago** (estorne antes) — bloqueado com alerta.
|
||
- Útil para finalizar a consolidação ignorando um motorista que não deveria estar nela / sem
|
||
entregas, sem perder o registro.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- **Migrations** (rodar `db:migrate`):
|
||
- `20260625000001_add_arquivamento_to_consolidacao_motoristas` — `arquivado_em` / `arquivado_por`.
|
||
- `20260625000002_add_manual_fields_to_consolidacao_entregas` — `nf_manual` / `obs_manual`.
|
||
- `Consolidacao#adicionar_apontamento_manual!(motorista:, tipos:, user:, nf:, obs:, valor_extra:)`
|
||
— gera `tracking_id` sintético (`"MAN-<uuid>"`) compartilhado pelos pilares (agrupa como UMA
|
||
entrega); reusa `recalcular_motorista!`. Action `apontar_manual` + rota + Stimulus
|
||
`apontamento_manual_controller.js`.
|
||
- `ConsolidacaoMotorista`: scopes `ativos`/`arquivados`, `arquivar!`/`desarquivar!`, `arquivado?`.
|
||
Todo o "conjunto vigente" passou a usar **`.ativos`** (totais, pagamento, gate, notificações,
|
||
PDFs financeiros, painel do motorista e filtros de pagamento do dashboard) — o arquivado não
|
||
entra em nenhum agregado. Actions `arquivar_motorista`/`desarquivar_motorista` (autorização
|
||
`update?`; bloqueiam motorista já pago).
|
||
|
||
### 📂 Principais arquivos
|
||
```
|
||
db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb
|
||
db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb
|
||
app/models/consolidacao.rb · consolidacao_motorista.rb
|
||
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
|
||
app/controllers/dashboard_controller.rb · motorista/dashboard_controller.rb
|
||
app/javascript/controllers/apontamento_manual_controller.js (novo)
|
||
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
|
||
app/views/consolidacoes/wizard.html.erb · show.html.erb
|
||
app/services/notificacao_service.rb · pdf/relatorio_motorista_pdf.rb · pdf/relatorio_financeiro_consolidacao_pdf.rb
|
||
config/routes.rb
|
||
spec/models/consolidacao_spec.rb · consolidacao_motorista_spec.rb
|
||
```
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
docker-compose exec app bundle exec rspec
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 25/06/2026 — Fechar por veículo na tela de Validar</strong></summary>
|
||
|
||
Um motorista pode usar **vários veículos** no período. A tela de Validar (Passo 2) mostrava tudo
|
||
junto e o **"Marcar todos como Normal"** marcava **todas** as entregas do motorista. Agora dá
|
||
para **fechar cada carro individualmente**.
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Filtro por veículo** (chips "🚗 Fechar por veículo") na tela de Validar — só aparece quando o
|
||
motorista usou **mais de um veículo**. Clicar num carro filtra a lista para aquele veículo; a
|
||
**barra de progresso** e o **"Marcar todos"/seleção** passam a valer **só para o carro
|
||
selecionado**. Há um chip **"Todos"** para a visão completa.
|
||
- **Status "fechado" por carro**: cada chip mostra `classificadas/elegíveis` e fica **verde com ✓**
|
||
quando o carro está 100% classificado — atualiza em tempo real (sem recarregar) ao classificar.
|
||
- A **finalização continua por motorista** (quando todos os carros de todos os motoristas estiverem
|
||
prontos) — o gate não mudou.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- `ConsolidacaoEntregasController#veiculos_status(motorista)` → `[{ vehicle, eligible,
|
||
classificadas, fechado }]` (2 queries: elegíveis com a coluna do veículo + classificadas
|
||
não-manuais). Reusa o scope `Entrega.da_veiculo`. Grupo "sem veículo" via sentinela `SEM_VEICULO`.
|
||
- `validar` aceita `params[:vehicle]`: recorta `@entregas` e, quando há carro selecionado, os totais
|
||
da barra (`@total_entregas`/`@classificadas`) vêm do `veiculos_status` daquele carro.
|
||
- `tracking_ids_elegiveis(motorista, vehicle = nil)` e `classificar_em_massa` passam o veículo —
|
||
é o que faz **"Marcar todos" fechar só o carro**. `resumo_json(motorista, vehicle:)` devolve
|
||
`classificadas_escopo` (barra por carro) + `veiculos_status` (refresh dos chips).
|
||
- `validacao_controller.js`: value `veiculo`, target `chipVeiculo`, `vehicle` nos POSTs,
|
||
`atualizarChips()` (verde/✓ + contadores). Sem migration — só leitura + UI.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/controllers/consolidacao_entregas_controller.rb # veiculos_status, validar, tracking_ids_elegiveis, classificar_em_massa, resumo_json
|
||
app/views/consolidacao_entregas/validar.html.erb # chips por veículo + barra com escopo
|
||
app/javascript/controllers/validacao_controller.js # value veiculo, chips, classificadas_escopo
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 26/06/2026 — Entrega de termo, caixa de ferramentas, gestão de motoristas e UX da Validação</strong></summary>
|
||
|
||
Pacote de implantação focado em **agilizar a precificação** e **dar feedback direto** ao usuário no
|
||
fluxo de consolidação. Resumo das frentes:
|
||
|
||
### 1. 📄 Entrega de termo (entrega sem NF nem código de rastreio)
|
||
Lançamento de **termos** entregues por um motorista — não têm nota fiscal nem `tracking_id` para
|
||
atrelar, só **motorista + quantidade** a um **preço fixo configurável**.
|
||
- Novo pilar `tipo: termo` (enum 5, cor azul) e novo preço **"Entrega de Termo"** editável em
|
||
**Admin → Configurações** (entra automático no card, via `CHAVES_MOEDA`).
|
||
- Lançado como **uma linha por lote** com a coluna nova **`quantidade`**; `valor_aplicado` guarda o
|
||
total do lote (`quantidade × preço`), então `recalcular_motorista!` e todas as somas existentes
|
||
seguem corretas. Modal com **stepper − N +** (sem as setinhas nativas do `input number`).
|
||
- Aparece na lista de **Apontamentos manuais** (badge "Entrega de Termo ×N"), nos PDFs (extrato,
|
||
relatório do motorista e financeiro) e no painel de Resumo — sempre contando por **`quantidade`**
|
||
(relatórios passaram de `count` para `sum(:quantidade)`).
|
||
|
||
### 2. 🧰 Caixa de ferramentas (toolbox) — botões unificados
|
||
- **Passo 2 (Validar):** os botões "Registrar entrega manualmente" e "Entrega de termo" viraram um
|
||
único **"➕ Adicionar lançamento ▾"** com menu suspenso. (O antigo "Apontamento 100% manual" foi
|
||
renomeado para **"Registrar entrega manualmente"** / modal "Registro manual de entrega".)
|
||
- **Passo 1 (Wizard):** o "Apontamento manual" virou **"➕ Adicionar / Ferramentas ▾"** com
|
||
**Apontamento manual** + **Adicionar motorista**.
|
||
- Reusa um controller Stimulus mínimo `ferramentas_controller.js` (só o dropdown); os modais e
|
||
controllers existentes não foram reescritos.
|
||
|
||
### 3. 👷 Gestão de motoristas na consolidação
|
||
- **Adicionar motorista** a uma consolidação já criada (ou **restaurar** se estava arquivado). O
|
||
campo puxa os **motoristas previstos no período** (atendidas, respeitando filtros de veículo/
|
||
operação) com os **veículos que cada um usou** — lista clicável + datalist.
|
||
- **Excluir motorista arquivado** (definitivo, **só admin/gerente**) — apaga os lançamentos dele
|
||
(ligados por `motorista_nome`, sem cascade) e recalcula o total. Bloqueia se já pago. Arquivar
|
||
continua sendo o caminho reversível.
|
||
- O **"← Voltar"** do wizard passou a ir para o **resumo da própria consolidação** (não mais a lista).
|
||
|
||
### 4. 📊 Resumo e barra de progresso com lançamentos manuais
|
||
- Painel **"📊 Resumo"** (Passo 2) ganhou linha **Termo** e linha **"Apontamentos manuais: N"**.
|
||
- **Barra de progresso** com **2 segmentos**: laranja→verde (elegíveis classificadas) + **azul**
|
||
(lançamentos manuais), com texto "· +N manuais". O gate de finalização **não mudou**
|
||
(`classificadas_count` segue `manual: false`) — é feedback visual e entra no valor.
|
||
- "Manuais" conta como **entregas**: cada termo vale sua `quantidade`; cada apontamento manual vale
|
||
1 por nota (`tracking_id`).
|
||
|
||
### 5. 🧊 UX da tela de Validar
|
||
- **Cabeçalho congelado** (no desktop): header, barra de progresso, filtro por veículo, ações em
|
||
massa e o botão de lançamento ficam fixos; **só a lista de NFs rola** (layout flex-column com a
|
||
coluna da lista `overflow-y-auto`). Evitou-se `sticky`/`z-index` para não prender os modais
|
||
(`fixed z-50`) atrás da sidebar (`z-40`).
|
||
- **Ações em massa simplificadas**: removidos o botão gigante **"Marcar todos como Normal"** e o
|
||
checkbox **"Modo remover"**. Os botões de pilar em **"Aplicar nos selecionados"** agora são
|
||
**toggle em massa** — aplicam o pilar nos selecionados e, se **todos** já o têm, removem de todos.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- **Migrations** (rodar `db:migrate`):
|
||
- `20260626000001_add_quantidade_to_consolidacao_entregas` — `quantidade` (default 1).
|
||
- `20260626000002_add_preco_termo_configuracao` — cria a config `preco_termo` (idempotente).
|
||
- `Consolidacao#adicionar_termos!(motorista:, quantidade:, user:)` — uma linha `TERMO-<uuid>`,
|
||
`manual: true`. `Configuracao.preco_termo` + `ConsolidacaoEntrega.valor_para('termo')`.
|
||
- `ConsolidacoesController#adicionar_motorista` / `#excluir_motorista` (member) +
|
||
`motoristas_previstos_no_periodo`. `ConsolidacaoEntregasController#apontar_termo`, `@manuais`,
|
||
`contar_manuais`, `por_tipo` agora `sum(:quantidade)`.
|
||
- Stimulus novos: `apontamento_termo_controller.js`, `ferramentas_controller.js`,
|
||
`adicionar_motorista_controller.js`. `validacao_controller.js`: barra com 2 segmentos, linhas
|
||
Termo/Manuais e **toggle em massa** (`pilarAtivo`); removidos `marcarTodos`/`modoRemover`.
|
||
|
||
### 📂 Principais arquivos
|
||
```
|
||
db/migrate/20260626000001_add_quantidade_to_consolidacao_entregas.rb
|
||
db/migrate/20260626000002_add_preco_termo_configuracao.rb
|
||
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
|
||
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
|
||
app/javascript/controllers/apontamento_termo_controller.js (novo)
|
||
app/javascript/controllers/ferramentas_controller.js (novo)
|
||
app/javascript/controllers/adicionar_motorista_controller.js (novo)
|
||
app/javascript/controllers/validacao_controller.js
|
||
app/views/consolidacoes/wizard.html.erb · show.html.erb
|
||
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
|
||
app/services/pdf/extrato_pdf.rb · relatorio_motorista_pdf.rb · relatorio_financeiro_consolidacao_pdf.rb
|
||
config/routes.rb · db/seeds.rb
|
||
```
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
```
|
||
|
||
</details>
|
||
|
||
<details>
|
||
<summary><strong>🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)</strong></summary>
|
||
|
||
Correção da **quebra de layout** dos cards de NF na tela **Passo 2 (Validar)** em telas pequenas e
|
||
em tablets, reportada nos prints `Imagens para correção/Imagem colada (16).png` e `(17).png`.
|
||
|
||
### 🔴 Bugs corrigidos
|
||
- **Botão "Extra" cortado no celular** *(print 16)* — a fileira dos 5 pilares
|
||
(`Normal · Retirada · Bônus · Desc. · Extra`) usava `flex gap-1.5` **sem `flex-wrap`**, então não
|
||
quebrava linha e o último botão estourava a borda do card. Agora os botões **quebram para a linha
|
||
de baixo** quando não cabem.
|
||
- **Botões sobrepondo o texto da nota no tablet** *(print 17, ~800px)* — o card virava layout em
|
||
linha já no breakpoint `md` (768px), **exatamente onde a sidebar de 256px passa a aparecer**
|
||
(`md:ml-64`), espremendo o conteúdo. Sem `flex-wrap`, os botões transbordavam por cima do
|
||
endereço/data/veículo. O ponto de virada foi movido para `lg` (1024px), onde há largura real.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- `app/views/consolidacao_entregas/validar.html.erb`:
|
||
- Card da entrega: `flex flex-col md:flex-row md:items-center` → **`flex flex-col lg:flex-row
|
||
lg:items-center`** (empilha no celular **e no tablet**; em linha só no desktop).
|
||
- Grupo de toggles: `flex gap-1.5` → **`flex flex-wrap gap-1.5 lg:shrink-0`** (`flex-wrap` impede
|
||
o transbordo; `lg:shrink-0` mantém os botões inteiros no desktop, deixando o texto da nota
|
||
truncar em vez de espremer os botões).
|
||
- Faixa crítica resolvida (768–1023px): antes acumulava *sidebar visível + layout em linha + botões
|
||
sem quebra*. Agora **< 1024px** o card fica empilhado com botões em `flex-wrap` e **≥ 1024px** vai
|
||
para linha com truncamento do texto.
|
||
- Sem migration e sem mudança de JS/controller — alteração **somente de classes Tailwind** na view.
|
||
|
||
</details>
|
||
|
||
<details open>
|
||
<summary><strong>🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)</strong></summary>
|
||
|
||
Nova página **`/dashboard/operacoes`** (link **📈 Operações** no menu), separada do dashboard
|
||
financeiro, focada na **qualidade das entregas** por operação (UBS Norte, EMAD, …). Reproduz os
|
||
painéis das imagens de referência (`Imagens para implantação/`) e adiciona análise comparativa e mapa.
|
||
Acessível a todos menos motorista (`DashboardPolicy#operacoes?`).
|
||
|
||
### 🆕 Funcionalidades
|
||
- **3 modos na mesma página** (abas):
|
||
- **🏥 Operação** — uma operação por vez. **Sem filtro de data**: mostra o conjunto inteiro da
|
||
operação e o período exibido vem dos próprios dados (menor/maior `checkout`).
|
||
- **🌐 Global** — todas as operações agregadas, aí sim com **faixa de datas** (flatpickr + atalhos
|
||
Hoje/7d/Este mês/30d). A faixa de data **só vale no Global**.
|
||
- **⚖️ Comparar** — duas operações lado a lado com bloco **Comparativo** (tabela A | B | Δ com o
|
||
melhor valor de cada linha em verde + gráfico de barras agrupadas) e detalhe completo recolhível.
|
||
- **Painéis** (espelham as imagens 2 e 3): KPIs (Total / Sucesso / Recusas / Pendentes), donut
|
||
**Insucessos %**, **Índices de Falha** (por `observation`), **Por Status** (status_gade
|
||
RECORRENTE/NOVO), **NFs por Motorista**, **Entregas por STS/Unidade** (`contact_name`), **Entregas
|
||
por Dia** (barras empilhadas) e **Mapa**.
|
||
- **Cross-filter ao vivo (toggle)** — clicar em qualquer linha das tabelas (Motorista, Unidade,
|
||
Status, Observação) **refiltra todo o dashboard** por aquele valor; clicar de novo na linha ativa
|
||
**remove** o filtro. Chips removíveis + "Limpar tudo" no topo.
|
||
- **Mapa de entregas** com:
|
||
- alternância **🔥 Calor** (heatmap) ⇄ **📍 Pontos** (um balão por entrega na coordenada de
|
||
**check-out**);
|
||
- **camadas** 🌙 Escuro (padrão) / 🗺️ Claro / 🛰️ Satélite (todas grátis, sem chave);
|
||
- **tela cheia** (Fullscreen API);
|
||
- **popup** por entrega (NF, destinatário, endereço, unidade, motorista, data) com a **foto da
|
||
fachada** (coluna `foto_da_fachada`), aberta em tamanho cheio ao clicar.
|
||
- **Cores** dos gráficos na identidade da marca: laranja `#f97316` (completas) e vermelho `#ef4444`
|
||
(falhas); no comparativo A = laranja, B = azul.
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- Núcleo de dados em **`app/services/analytics/operacao_metricas.rb`** (PORO). Monta um SQL que
|
||
**espelha a query de gestão**: CTE `ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout
|
||
DESC)` sobre `db_reem_simplerout_2026` + `INNER JOIN` na tabela `gade_entregas_*` por
|
||
`reference_id::text = nota_fiscal`; agrega tudo em Ruby (visão `linhas` = registros após o
|
||
cross-filter).
|
||
- **Sem filtro de conta** (`account_id`): o INNER JOIN com a tabela da operação já restringe ao
|
||
cliente — replicar `da_conta_gade` zerava tudo quando `DB_EXISTING_ACCOUNT_ID` não batia.
|
||
- **Schema gade não-uniforme**: só `nota_fiscal` é garantida. Colunas opcionais (`status`,
|
||
`nome_completo`, `endereco_completo`) são detectadas por tabela (`conn.columns`) e viram `NULL`
|
||
quando não existem — assim o `UNION ALL` entre operações no Global não quebra
|
||
(era o erro `column g.status does not exist`).
|
||
- **Segurança**: nomes de tabela passam por `Operacao.sanitizar` + `quote_table_name`; a `foto_da_fachada`
|
||
é validada como URL `http(s)` antes de ir ao `<img>`, com escape de HTML no popup.
|
||
- **Performance do mapa**: marcadores criados **sob demanda** (só ao abrir "Pontos") e o popup é uma
|
||
**função** — a foto do S3 só é requisitada **ao clicar** no balão (nada é baixado no load). Limite
|
||
de 2000 marcadores.
|
||
- **Filtro de período** opcional no serviço (`inicio:`/`fim:`): aplicado só no Global.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/services/analytics/operacao_metricas.rb # NOVO — núcleo de dados/agregações
|
||
app/controllers/operacoes_dashboard_controller.rb # NOVO — modos + cross-filter
|
||
app/views/operacoes_dashboard/index.html.erb # NOVO — filtros, abas, charts, mapa (JS)
|
||
app/views/operacoes_dashboard/_painel.html.erb # NOVO — KPIs/donut/tabelas/gráfico
|
||
app/views/operacoes_dashboard/_comparativo.html.erb # NOVO — tabela A|B|Δ + barras agrupadas
|
||
app/views/operacoes_dashboard/_mapa.html.erb # NOVO — calor/pontos, camadas, tela cheia
|
||
app/views/operacoes_dashboard/_tabela_simples.html.erb # NOVO — tabela reutilizável + cross-filter
|
||
spec/services/analytics/operacao_metricas_spec.rb # NOVO — specs das agregações/cross-filter
|
||
spec/requests/operacoes_dashboard_spec.rb # NOVO — autorização por role + modos
|
||
config/routes.rb # rota get /dashboard/operacoes
|
||
app/policies/dashboard_policy.rb # operacoes? (todos menos motorista)
|
||
app/views/layouts/_navbar.html.erb # link "📈 Operações"
|
||
```
|
||
|
||
> **Sem migration** — tudo lê tabelas existentes (`db_reem_simplerout_2026` read-only e
|
||
> `gade_entregas_*`). Libs front via CDN (Chart.js, Leaflet + leaflet.heat, flatpickr).
|
||
> ⚠️ Em produção, **reiniciar o servidor** (Puma) após o deploy — o Rails cacheia classes/views.
|
||
|
||
</details>
|
||
|
||
<details open>
|
||
<summary><strong>🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar</strong></summary>
|
||
|
||
Rodada de melhorias no **Dashboard de Operações**, na tela de **Validação do motorista**
|
||
(consolidação) e no **layout do site inteiro**.
|
||
|
||
### 🗺️ Mapa de Operações
|
||
- **Busca no mapa** — campo 🔍 que filtra os pontos por **NF, nome do paciente ou veículo**,
|
||
troca para a visão "Pontos", dá zoom nos resultados (busca client-side) e mostra contador /
|
||
"Nenhum resultado".
|
||
- **Mostra TODAS as entregas** — antes só `completed`; agora **sucesso + insucesso** (óbito e
|
||
demais pilares de falha), via `Entrega::STATUS_ATENDIDO`. Pino **laranja = sucesso**, **azul =
|
||
insucesso**; o popup mostra o status e o **motivo** (`observation`). A **foto da fachada** é
|
||
mantida para todos os casos.
|
||
- **Campo veículo** — coluna `vehicle` (de `Entrega`) adicionada à query, ao card e à busca.
|
||
- **Modo escuro mais detalhado e com mais contraste** — o tile escuro passou de CARTO `dark_all`
|
||
(minimalista) para **OpenStreetMap completo invertido** (`filter: invert(1) hue-rotate(180deg)
|
||
brightness(.95) contrast(1.05)` na classe `.tiles-escuro-contraste`): mesmo detalhe de ruas/nomes
|
||
do mapa claro, porém escuro e legível. Claro/Satélite ficam intactos.
|
||
|
||
### 📊 Dashboard de Operações (painéis)
|
||
- **Gráficos clicáveis (cross-filter)** — além das tabelas, agora o **donut "Insucessos %"**
|
||
(clique em Completas/Falhas → filtra por `status`) e as barras **"Entregas por Dia"** (clique →
|
||
filtra por **dia** + **resultado** da barra) refiltram **todo** o dashboard. Novos filtros
|
||
`f_resultado` (coluna `status`) e `f_data` (por data de checkout, tratado à parte no serviço via
|
||
`@data`). Chips removíveis + "Limpar tudo", igual às tabelas.
|
||
- **Falha em azul** — a cor de insucesso mudou de vermelho `#ef4444` para **azul `#3b82f6`** nos
|
||
KPIs e gráficos (mais contraste no tema escuro).
|
||
- **Hover nos cards de KPI** — ao passar o mouse o card destaca, o número aumenta e aparece o
|
||
**% do total** (no card Total, o detalhamento ✅/●/⏳).
|
||
- **Respiro no scroll das tabelas** — `pr-2` nos contêineres roláveis (Motoristas / STS) para a
|
||
barra de scroll não colar no texto.
|
||
|
||
### 🚚 Validação do motorista (consolidação)
|
||
- **Filtro de veículos multi-seleção** — antes "um carro de cada vez"; agora dá pra **somar 2+
|
||
veículos**. A lista, a barra de progresso, o **valor total (R$)** e a **contagem por pilar**
|
||
passam a refletir só os veículos selecionados. Chip selecionado fica **laranja** (prioridade
|
||
sobre o verde de "fechado") para a seleção ficar sempre visível.
|
||
- **Lupa de busca na listagem de notas** — campo 🔍 sticky no topo da lista que filtra os cards
|
||
por **NF, local, endereço ou veículo**; "Selecionar todos" passa a marcar **apenas os visíveis**
|
||
no filtro.
|
||
- **Barra fixa mais compacta** — paddings/margens reduzidos (`p-3 mb-3`) para melhor aproveitamento
|
||
em **monitores pequenos**.
|
||
- **Ocultar/mostrar o resumo lateral** — botão 📊 que esconde o resumo e dá **largura total** à
|
||
lista (`lg:col-span-3 → lg:col-span-4`).
|
||
|
||
### 🧭 Layout global (site inteiro)
|
||
- **Recolher/expandir a sidebar do menu principal** — botão flutuante de seta (desktop) que esconde
|
||
o menu e expande o conteúdo (`md:ml-64 → 0`). Estado **lembrado** entre páginas (`localStorage` +
|
||
classe `.sidebar-collapsed` no `<html>`, aplicada antes de pintar → sem "piscar").
|
||
- **Indicador discreto** — em repouso o botão é uma **alça fina laranja** sempre visível na borda;
|
||
ao chegar perto com o mouse (ou foco por teclado) ela **expande** no botão com a seta ◀/▶.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/services/analytics/operacao_metricas.rb # veículo, sucesso+insucesso, filtro por dia (@data)
|
||
app/controllers/operacoes_dashboard_controller.rb # CROSS f_resultado + filtro f_data
|
||
app/views/operacoes_dashboard/index.html.erb # busca/dark map, pinos, gráficos clicáveis, KPIs
|
||
app/views/operacoes_dashboard/_painel.html.erb # hover KPIs, cor azul, pr-2 no scroll
|
||
app/views/operacoes_dashboard/_mapa.html.erb # campo de busca no mapa
|
||
app/controllers/consolidacao_entregas_controller.rb # filtro multi-veículo + resumo por escopo
|
||
app/views/consolidacao_entregas/validar.html.erb # chips multi, lupa, barra compacta, toggle resumo
|
||
app/javascript/controllers/validacao_controller.js # veiculos[] , busca, toggleResumo
|
||
app/views/layouts/application.html.erb # CSS sidebar recolhível + script anti-flash
|
||
app/views/layouts/_navbar.html.erb # botão flutuante + alça indicadora
|
||
```
|
||
|
||
> **Sem migration** — segue lendo as tabelas existentes (`db_reem_simplerout_2026` read-only e
|
||
> `gade_entregas_*`). ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔒 Segurança + auto-atualização do painel + cron (06/07/2026)</strong></summary>
|
||
|
||
Revisão de segurança do site em produção (via browser + inspeção de headers) e
|
||
melhorias no **Dashboard de Operações**. **Sem migration.**
|
||
|
||
### 🩸 Causa-raiz descoberta — produção em modo `development`
|
||
A página de erro 404 do site expunha o backtrace do Rails e havia o cookie
|
||
`__profilin` (rack-mini-profiler) — sinais de que o container roda em
|
||
**`RAILS_ENV=development`**. Isso é a origem de várias falhas abaixo (cookie de
|
||
sessão sem `Secure`, sem redirect HTTPS, sem CSP, páginas de exceção vazando
|
||
código).
|
||
|
||
> ⚠️ **Ação necessária no servidor** (o `.env` não está no repositório): definir
|
||
> `RAILS_ENV=production` e `FORCE_SSL=true` no `.env` e rebuildar. Ao migrar para
|
||
> `production`, validar o carregamento dos assets (Tailwind via CDN + importmap).
|
||
|
||
### 🔴 Correções de segurança
|
||
- **HTTPS obrigatório + cookie `Secure` (itens 1 e 2)** — `config.force_ssl` +
|
||
`assume_ssl` (este último para o TLS terminado no Cloudflare, evitando loop de
|
||
redirect). Em `application.rb` fica atrás de `ENV["FORCE_SSL"]=="true"`, então
|
||
**funciona mesmo enquanto o deploy roda fora do modo production**;
|
||
`production.rb` também já vem com `force_ssl = true`.
|
||
- **rack-mini-profiler desligado (item 3)** — no `Gemfile` passou a `require:
|
||
false`: a gem não monta mais o middleware (fim do cookie `__profilin` e da rota
|
||
`/mini-profiler-resources`), em qualquer ambiente.
|
||
- **Content Security Policy (item 5)** — nova política em
|
||
`config/initializers/content_security_policy.rb` com allowlist dos CDNs reais
|
||
(Tailwind, Chart.js, Flatpickr, Leaflet, Google Fonts, tiles ArcGIS/OSM).
|
||
Começa em **Report-Only** (só reporta violações, não bloqueia) para não quebrar
|
||
mapa/gráficos; instruções no topo do arquivo para ativar o bloqueio
|
||
(`report_only = false`) depois de validar no console.
|
||
- Erro de login genérico ("Invalid email or password.") e recuperação de senha
|
||
**não** revelam se o e-mail existe (sem enumeração de usuários) — **OK**, sem
|
||
mudança. Pendência conhecida: login por **PIN de 4 dígitos sem identificador** —
|
||
avaliar rate-limiting/rack-attack.
|
||
|
||
### 🔄 Painel de Operações atualiza sozinho (sem F5)
|
||
- Novo `app/javascript/controllers/auto_refresh_controller.js` — a cada **5 min**
|
||
(`data-auto-refresh-interval-value`, em ms) faz `Turbo.visit` na própria URL:
|
||
re-renderiza KPIs/gráficos/mapa **sem o flash do F5**, **preserva os filtros**
|
||
(que vivem na query string) e a **posição de rolagem**; pausa em aba oculta ou
|
||
quando há um campo em foco.
|
||
|
||
### 🕑 Horário da "última atualização" corrigido
|
||
- Antes mostrava `Time.now` — o **fuso do container (UTC)** e a **hora de
|
||
renderização** (por isso aparecia adiantado e sempre "agora"). Agora um helper
|
||
(`ultima_atualizacao_dados` / `ultima_atualizacao_label`) calcula o **último
|
||
slot real de sincronização** (a cada 30 min, das 08h às 18h) no fuso de
|
||
Brasília (`Time.current`).
|
||
|
||
### ⏰ Agendamento (whenever/cron) no Docker
|
||
- `config/schedule.rb` — job de histórico passou de `every 1.hour` para
|
||
**`*/30 8-18`** (a cada 30 min, 08h-18h), batendo com a cadência real.
|
||
- `Dockerfile` — instala o pacote **`cron`**.
|
||
- `docker-compose.yml` — o boot agora roda, em ordem: `db:prepare` →
|
||
**`whenever --update-crontab`** (grava os jobs) → **`cron`** (sobe o daemon) →
|
||
`rails s`. As variáveis do banco chegam ao job via **dotenv** quando o rake
|
||
carrega o Rails.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
config/application.rb # gate FORCE_SSL → force_ssl + assume_ssl
|
||
config/environments/production.rb # force_ssl = true (+ assume_ssl)
|
||
config/initializers/content_security_policy.rb # CSP (report-only) — NOVO
|
||
config/initializers/rack_mini_profiler.rb # comentário atualizado
|
||
Gemfile # rack-mini-profiler, require: false
|
||
.env.example # RAILS_ENV=production + FORCE_SSL=true
|
||
app/helpers/application_helper.rb # ultima_atualizacao_dados / _label
|
||
app/javascript/controllers/auto_refresh_controller.js # auto-refresh do painel — NOVO
|
||
app/views/operacoes_dashboard/index.html.erb # data-controller auto-refresh + horário
|
||
config/schedule.rb # cron */30 8-18
|
||
lib/tasks/historico.rake # desc atualizada
|
||
Dockerfile # instala cron
|
||
docker-compose.yml # whenever --update-crontab + cron no boot
|
||
```
|
||
|
||
### ▶️ Deploy
|
||
```bash
|
||
# 1. No servidor, ajustar o .env:
|
||
# RAILS_ENV=production
|
||
# FORCE_SSL=true
|
||
# 2. Rebuildar e subir (já roda whenever --update-crontab + cron no boot):
|
||
docker compose up -d --build
|
||
# 3. Validar a CSP no navegador (F12 → console, sem violações) e então trocar
|
||
# config/initializers/content_security_policy.rb para report_only = false.
|
||
```
|
||
|
||
> **Sem migration.** ⚠️ Em produção, **reiniciar o Puma** após o deploy.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>✏️ Atualização 08/07/2026 — Editar Lançamento do SimpliRoute (correção pelo ADM)</strong></summary>
|
||
|
||
Nova tela **só para ADM** que permite **corrigir um lançamento** de entrega direto na
|
||
**API do SimpliRoute** (ex.: uma entrega marcada como "completa" que na verdade foi
|
||
**óbito**). Até então a única forma era entrar manualmente no SimpliRoute. **Sem migration.**
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Menu → Administração → "✏️ Editar Lançamento"** (visível só para `admin`).
|
||
- **Busca por NF** → o sistema localiza a entrega e mostra o estado atual.
|
||
- **Campos editáveis:** `status` (completa/falha/pendente/parcial/cancelada), **motivo**
|
||
(óbito, endereço não localizado, mudou-se, etc.), **comentário**, **observações** e,
|
||
no avançado, **data/hora + geolocalização** do checkout.
|
||
- **Auditoria** — toda alteração grava um `AuditoriaLog` (quem, quando, de/para).
|
||
- **Foto da fachada / assinatura** ficam para uma **fase 2** (exigem upload de imagem).
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- A base de rastreio local (`db_reem_simplerout_2026`, model `Entrega`) é **read-only** e
|
||
sincroniza a partir do SimpliRoute. A correção **não** escreve nela — grava na **API**;
|
||
o painel/consolidações só refletem **na próxima sincronização** (a tela avisa isso).
|
||
- **Dois identificadores:** a visita tem `id` numérico (usado na URL de escrita) **e**
|
||
`tracking_id` (`SR...`, que é a PK do espelho). O espelho não guarda o id numérico, então
|
||
o serviço **resolve** listando as visitas da data (`planned_date`) e casando pelo
|
||
`tracking_id` (fallback: NF).
|
||
- **Motivo** = campo `checkout_observation`, que é o **UUID** de uma lista fixa de 14 motivos
|
||
(`GET /v1/routes/observations/`, todos `type=failed`). É esse UUID que popula a coluna
|
||
`observation` — **não** basta texto livre no comentário.
|
||
- A gravação usa **PATCH** (só os campos alterados), para **não** sobrescrever assinatura/geo
|
||
originais sem intenção. Token da API vem de **`SIMPLIROUTE_TOKEN`** (ENV, nunca no git).
|
||
|
||
### 🔧 Correção — botão estava no menu errado
|
||
- O botão foi adicionado, por engano, ao `app/views/layouts/_sidebar.html.erb`, que é um
|
||
**partial morto** (legado da fase 2/3, nunca renderizado). O menu **real** é o
|
||
**`_navbar.html.erb`** (renderizado pelo `application.html.erb`). O link foi movido para lá,
|
||
na seção Administração.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
config/initializers/simpli_route.rb # lê SIMPLIROUTE_TOKEN / BASE_URL — NOVO
|
||
app/services/simpli_route/client.rb # cliente Net::HTTP (observations, resolver_id, PATCH) — NOVO
|
||
app/controllers/admin/edicao_lancamentos_controller.rb # busca/atualiza + AuditoriaLog — NOVO
|
||
app/policies/edicao_lancamento_policy.rb # admin-only — NOVO
|
||
app/views/admin/edicao_lancamentos/show.html.erb # tela (busca NF + form) — NOVO
|
||
config/routes.rb # resource admin/edicao_lancamento
|
||
app/views/layouts/_navbar.html.erb # link "✏️ Editar Lançamento" (admin)
|
||
.env.example # SIMPLIROUTE_TOKEN + SIMPLIROUTE_BASE_URL
|
||
```
|
||
|
||
### ▶️ Deploy
|
||
```bash
|
||
# 1. No servidor, adicionar ao .env (pegue o token em SimpliRoute → Configurações → API):
|
||
# SIMPLIROUTE_TOKEN=...
|
||
# SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
|
||
# 2. Rebuildar/reiniciar:
|
||
docker compose up -d --build
|
||
# 3. Logar como admin → Administração → "Editar Lançamento" → buscar uma NF e testar.
|
||
```
|
||
|
||
> **Sem migration.** ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)</strong></summary>
|
||
|
||
Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
|
||
do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base
|
||
técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV):
|
||
cada item vira uma fase própria quando for priorizado.
|
||
|
||
### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
|
||
`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute"
|
||
criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
|
||
(título `NF {nf} - {nome}`, janelas 08:00–18:00, lat/long do mês anterior, etc.).
|
||
> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.**
|
||
- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica).
|
||
- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga".
|
||
|
||
### 2. 🧾 Relatório de comprovantes de entrega (POD)
|
||
`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada
|
||
entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`).
|
||
Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").
|
||
|
||
### 3. ⚡ Webhooks — painel em tempo real
|
||
`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`,
|
||
`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza
|
||
**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
|
||
idempotência) e URL pública estável.
|
||
|
||
### 4. 📱 Aviso ao paciente via WhatsApp
|
||
Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`):
|
||
"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE**
|
||
(motivo real da lista de observations).
|
||
|
||
### 5. 📺 Monitor de rotas ao vivo
|
||
`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada
|
||
veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).
|
||
|
||
### 6. 👷 Sincronização de motoristas/veículos
|
||
`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema
|
||
(hoje o vínculo é o nome digitado — sujeito a divergência).
|
||
|
||
### 7. 📊 Datamart e 🏷️ tags/skills
|
||
Export paginado de analytics para enriquecer dashboards; tags/skills para classificar
|
||
visitas por tipo de material.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções</strong></summary>
|
||
|
||
### 📋 Planilha da Operação — página própria (`/dashboard/operacoes/planilha`)
|
||
Tabela **espelho da planilha da operação** (uma linha por NF) para pesquisar e acompanhar
|
||
ocorrências/status, acessada pelo botão **"📋 Planilha da operação"** no Dashboard de Operações
|
||
(o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).
|
||
- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo,
|
||
**Resultado** (badge Entregue/Falha/Pendente), **Ocorrência** (motivo do insucesso) e status
|
||
gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
|
||
- **Busca textual server-side** (param `q`, qualquer campo) + **paginação server-side**
|
||
(param `pg`, 15/página, janela `« 1 … 7 8 9 … 42 »`) — aguenta as milhares de linhas do Global.
|
||
- Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos
|
||
filtros herdados. Reusa as linhas do `Analytics::OperacaoMetricas` (novo método `#buscar`) —
|
||
**nenhuma query nova**.
|
||
- Arquivos: rota `operacoes_planilha`, action `planilha` + `montar_tabela_espelho`
|
||
(`OperacoesDashboardController`), views `planilha.html.erb` + `_espelho.html.erb`.
|
||
|
||
### ⬇️ Download do Excel do cliente — planilha Entregas preenchida
|
||
Botão **"⬇️ Baixar Excel preenchido"** na página da planilha: gera o `.xlsx` no formato do modelo
|
||
**"Entregas SUDESTE MM.AAAA_FINAL"** com as **3 abas, 1:1** — automatiza o preenchimento que era
|
||
feito manualmente para entregar ao cliente.
|
||
- **RESUMO** — mês, Total Previsto/Realizado/Performance e os quadros **EMAD** e **UBS** por
|
||
coordenadoria (CRS fixas do modelo) com **motivos de "Não Entregue"** (Óbito, Responsável
|
||
Ausente, Endereço não localizado, Recusa, Paciente não reside, Outros) classificados a partir
|
||
da `observation` do rastreio; linhas Total e Performance %.
|
||
- **ENTREGAS** — colunas A–V da própria tabela `gade_entregas_*` (ordem física de importação =
|
||
ordem da planilha original) + **W–Z preenchidas pelo rastreio**: STATUS, ENTREGA (Sim/Não),
|
||
DATA OCORRÊNCIA (data real do checkout) e OCORRÊNCIA — último status de cada NF (mesma CTE
|
||
`ROW_NUMBER` dos painéis).
|
||
- **SimpliRoute** — dump cru do rastreio com as **46 colunas exatas** do export original
|
||
(todas as visitas das NFs da operação, incluindo repetidas).
|
||
- **Estilo idêntico ao modelo** (cores extraídas do `styles.xml` do próprio arquivo): cabeçalho
|
||
ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em
|
||
`002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
|
||
itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
|
||
- **Links das fotos são hiperlinks clicáveis** (azul sublinhado): Nota Fiscal, Termo de
|
||
Recebimento, Foto da Fachada e Relatório de visita abrem o comprovante direto do Excel.
|
||
Qualquer célula que seja URL `http(s)` vira link (`#linkar_urls`).
|
||
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
|
||
caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
|
||
|
||
#### Nomes de coluna — conferidos no banco real (22/07/2026)
|
||
Os nomes eram **deduzidos** e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes
|
||
reais foram conferidos com `Entrega.column_names` e estão como 1º candidato em
|
||
`RASTREIO_COLUNAS`; os chutes antigos continuam na lista como rede de segurança.
|
||
- Espelho usa o padrão do export pt-BR: `codrivers`, `responsible_person`, `advance`,
|
||
`start_of_time_window_1..2`, `end_of_time_window_1..2`, `required_skills`, `optional_skills`,
|
||
`comments`. Corrigidos — antes eram `copilots`, `receiver`, `early`, `window_start`, etc.
|
||
- Tabelas `gade_entregas_*` gravam a geo como **`lat`/`long`** (não `latitude`/`longitude`) —
|
||
as duas colunas da aba ENTREGAS saíam vazias. Ver `ALIAS_GADE`.
|
||
- **`Load 4` não existe** no espelho (só `load`, `load_2`, `load_3`) — sai vazia de propósito.
|
||
- Coluna **`protocolo_de_entrega`** existe no espelho e não estava em lugar nenhum: entrou como
|
||
última coluna (AU), **depois** do layout A–AT do modelo, para não deslocar nada do cliente.
|
||
- ⚠️ Vazias por **falta de dado na origem**, não por bug: `contact_phone`, `account_id`,
|
||
`account_name` (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute
|
||
TEM o valor, conferido na NF 79093) e `codrivers`/`required_skills`/`optional_skills`/
|
||
`contact_email`/`load_2`/`load_3` (vazios também no relatório baixado direto do SimpliRoute).
|
||
- ⚠️ Os 2 **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é
|
||
controle manual (sai "A iniciar").
|
||
|
||
#### 🔄 Número de série da base (aparelho) — job em background
|
||
A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho **nunca** traz (0 de
|
||
~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da `gade_entregas_*`: os seriais
|
||
de `num_serie_base` são de NFs **disjuntas** das que têm série no SimpliRoute (0 de 203 batem).
|
||
O dado só existe na API, em `extra_field_values` — o mesmo hash das fotos do motorista.
|
||
- `SincronizarSeriesAparelhoJob` varre a API por DATA (a API não filtra intervalo; cada dia são
|
||
~4 MB / ~9 s) em lotes de 6 threads e grava em **`series_aparelho`** (tabela nossa — o espelho
|
||
é read-only). Um dia que não responde vira aviso, não derruba a rodada.
|
||
- `Analytics::PlanilhaEntregas#completar_serie` completa **só** as linhas em que o espelho veio
|
||
vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando.
|
||
- Agendado em `config/schedule.rb` às **01h** (últimos 45 dias). Backfill de meses antigos:
|
||
`rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"` ou
|
||
`rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"`.
|
||
- Se a rodada varrer visitas e não achar **nenhuma** série, o log lista as chaves de
|
||
`extra_field_values` que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo
|
||
em `CAMPOS_SERIE` (foi chute de nome de campo que causou o bug acima).
|
||
|
||
### 📊 Dashboard financeiro
|
||
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
|
||
abas e contador ("1–10 de 47"), client-side (função genérica `paginarTabela`); some com ≤10 itens.
|
||
- **Novo gráfico "Pagamentos por dia"** abaixo da "Evolução do custo": barras verdes com o valor
|
||
pago por data de pagamento (`pago_em`) + linha tracejada do **acumulado** — o fluxo de caixa,
|
||
que não existia em nenhuma tela (`build_grafico_pagamentos`). Quando não há pagamento no
|
||
período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado.
|
||
> Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo)
|
||
> e "Resultado por dia" (já existe na tela de Operações).
|
||
- O card dos gráficos virou `flex-col` e preenche toda a altura ao lado do ranking de motoristas
|
||
(sumiu o vazio embaixo da linha).
|
||
|
||
### 🔴 Correções
|
||
- **"Rodapé quebrado" no celular:** era uma **barra de rolagem horizontal** (laranja) colada no
|
||
rodapé — a legenda dos gráficos não quebrava linha e estourava ~37px a largura em telas
|
||
<~430px. ✅ Cabeçalhos/legendas dos gráficos com `flex-wrap` + **`overflow-x-hidden` no
|
||
`<body>`** (rede de segurança; tabelas largas continuam rolando nos próprios contêineres
|
||
`overflow-x-auto`).
|
||
|
||
### 📌 Padrões (reforçados)
|
||
- Tabelas externas sempre read-only, nome de tabela via whitelist + `quote_table_name`, colunas
|
||
em whitelist fixa com fallback NULL (padrão `OperacaoMetricas`/`PlanilhaCarga`).
|
||
- Excel com `caxlsx` (mesmo padrão da planilha de carga SimpliRoute): serviço de linhas +
|
||
serviço de binário + `send_data` no controller.
|
||
|
||
> **Sem migration.** Deploy normal; em produção reiniciar o Puma após o deploy.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos)</strong></summary>
|
||
|
||
O negócio passou a diferenciar **termo normal** e **termo especial**, cada um com seu preço.
|
||
O modal de lançamento agora permite informar **quantos termos de cada tipo** o motorista
|
||
entregou, num único envio.
|
||
|
||
### 🆕 Funcionalidades
|
||
- **Novo card de preço "📋 Entrega de Termo Especial"** em **Admin → Configurações**
|
||
(chave `preco_termo_especial`, moeda). Criado com **R$ 0,00** — definir o valor real no
|
||
card antes de usar. O card aparece automático (a view itera todas as configs).
|
||
- **Novo pilar `tipo: termo_especial`** (enum 6, cor **ciano**, label "Entrega de Termo
|
||
Especial") — mesmo comportamento do termo normal: lote numa linha (`quantidade` × preço),
|
||
`manual: true`, `tracking_id` sintético `TERMO-<uuid>`.
|
||
- **Modal "📄 Entrega de termo" com dois steppers** lado a lado — "📄 Termo Normal"
|
||
(inicia em 1) e "📋 Termo Especial" (inicia em 0), cada um exibindo seu preço unitário.
|
||
Um clique em "Adicionar termos ✓" lança os dois lotes de uma vez (exige ao menos 1 termo
|
||
no total; mínimo 0 em cada stepper).
|
||
- **Exibição em todas as telas**: linha "Termo Especial" no painel 📊 Resumo do Passo 2,
|
||
card próprio no Revisar, badge ciano "Entrega de Termo Especial ×N" nos apontamentos
|
||
manuais (show) e nos PDFs (extrato, relatório do motorista e financeiro — automático,
|
||
iteram `TIPO_CORES`).
|
||
|
||
### ⚙️ Como funciona (técnico)
|
||
- **Migration** (rodar `db:migrate`): `20260715000001_add_preco_termo_especial_configuracao`
|
||
— cria a config `preco_termo_especial` (idempotente) + seed correspondente.
|
||
- `Configuracao`: chave nova em `CHAVES`/`CHAVES_MOEDA`/`LABELS`/`ICONES`/`mapa_de_precos`
|
||
+ helper `preco_termo_especial`.
|
||
- `Consolidacao#adicionar_termos!` ganhou o parâmetro **`tipo:`** (default `'termo'`,
|
||
validado em `termo`/`termo_especial`); o preço vem de
|
||
`ConsolidacaoEntrega.valor_para(tipo)`.
|
||
- `ConsolidacaoEntregasController#apontar_termo` lê `quantidade` (normal) +
|
||
`quantidade_especial` e cria **um lote por tipo** dentro de uma transação, auditando as
|
||
duas quantidades. `contar_manuais` soma a `quantidade` dos **dois** tipos de termo; os
|
||
agregados `group(:tipo).sum(:quantidade)` já funcionavam sem mudança.
|
||
- Stimulus: `apontamento_termo_controller.js` com targets `quantidade`/`quantidadeEspecial`
|
||
e ações `maisEspecial`/`menosEspecial`; `validacao_controller.js` com target opcional
|
||
`qtdTermoEspecial`.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb (nova)
|
||
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
|
||
app/controllers/consolidacao_entregas_controller.rb
|
||
app/javascript/controllers/apontamento_termo_controller.js · validacao_controller.js
|
||
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
|
||
app/views/consolidacoes/show.html.erb
|
||
app/services/pdf/relatorio_motorista_pdf.rb
|
||
db/seeds.rb · spec/models/configuracao_spec.rb
|
||
```
|
||
|
||
```bash
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🎨 Atualização 20/07/2026 — Ícones da marca (emoji → SVG laranja) + galeria de fotos no Editar Lançamento</strong></summary>
|
||
|
||
Três frentes num dia: (1) troca de **todos os ícones** do sistema, que eram emojis, por
|
||
ícones SVG chapados no laranja da marca; (2) **galeria com todas as fotos** do lançamento na
|
||
tela de Editar Lançamento; (3) investigação (com veredito) sobre **por que não dá para
|
||
editar as fotos** pela API do SimpliRoute. **Sem migration.**
|
||
|
||
### 🎨 1. Ícones — emoji → SVG da marca
|
||
Os ícones eram **emojis** (📊 📋 👥 ⚙️ …), que davam aparência "genérica de IA" e mudavam de
|
||
forma/cor conforme o sistema operacional. Agora são **SVG chapados**, na cor laranja da marca
|
||
(`#f97316`), consistentes em qualquer navegador.
|
||
|
||
- **Sprite próprio** em `public/icons.svg` — subconjunto do **Bootstrap Icons** (licença MIT),
|
||
arquivo único (~33 KB), servido localmente (sem CDN externo).
|
||
- **Helper `icone(nome, …)`** em `app/helpers/application_helper.rb`: mapa de nomes semânticos
|
||
em pt-BR (`:caminhao`, `:consolidacoes`, `:dinheiro`, …) → símbolo do sprite. Trocar um ícone
|
||
é mudar uma linha. Usa `fill="currentColor"` → a cor vem da classe CSS.
|
||
- **Helper `rotulo(nome, texto, …)`** para `link_to`/`button_to` (que recebem o rótulo como
|
||
argumento, onde não cabe ERB). `nav_link_to` ganhou a opção `icon:`.
|
||
- **~260 emojis substituídos em ~33 arquivos** (views, `_navbar`, layout, popups do mapa Leaflet
|
||
e HTML montado em JS na tela de Editar Lançamento).
|
||
- **Ícones de status mantêm cor semântica** (sucesso verde, alerta amarelo, erro vermelho) —
|
||
só os de navegação/ação viraram laranja.
|
||
- **E-mails** (`consolidacao_mailer`) ficaram sem os emojis decorativos: sprite via `<use href>`
|
||
não funciona em cliente de e-mail.
|
||
- **Ajuste fino (mesma data):** ícones **+20%** (padrão `1.05em`→`1.25em`), **espaçamento**
|
||
ícone↔texto (parâmetro `espaco:` com `mr-1`, desligado nos ícones sozinhos/centralizados) e
|
||
**correção de contraste** — ícone laranja sobre fundo laranja ficava invisível (botão
|
||
"Relatório" do dashboard e os ícones das telas de recuperar/redefinir senha → herdam preto).
|
||
|
||
### 🖼️ 2. Editar Lançamento — galeria com TODAS as fotos
|
||
Antes a tela mostrava só **uma** foto genérica. Descobrimos (testando a API real) que as fotos
|
||
reais do motorista **não** estão no array `pictures`, e sim em **`extra_field_values`**, em
|
||
campos nomeados: **`foto_nf`** (Nota Fiscal), **`foto_prova_visita`** (Prova da visita, presente
|
||
até em insucesso), **`foto_relatorio2`** (Relatório) e **`foto_termo`** (Termo, quase sempre vazio).
|
||
|
||
- Novo método **`SimpliRoute::Client#detalhe_visita`** → `GET /v1/plans/visits/{id}/detail/`
|
||
(fonte de fotos mais completa; best-effort, cai para a visita se falhar).
|
||
- **`fotos_do_lancamento`** no controller reúne **todas as fontes** — fachada (rastreio),
|
||
`extra_field_values`, `pictures[]` e `signature` — numa lista **etiquetada** (tipo + origem) e
|
||
**deduplicada por URL**.
|
||
- **Galeria** com a 1ª foto em destaque + grade, cada uma com etiqueta colorida por categoria,
|
||
e **visualizador (lightbox)** que amplia e navega (setas do teclado / Esc / link p/ tamanho real).
|
||
|
||
### 🚫 3. Por que NÃO dá para editar as fotos (limitação do SimpliRoute)
|
||
Investigamos todos os caminhos de escrita contra a API real. **A limitação é do SimpliRoute:**
|
||
|
||
| Caminho | Resultado |
|
||
|---|---|
|
||
| `PATCH`/`PUT` em `extra_field_values` (todos os formatos) | **HTTP 500** — o servidor deles quebra |
|
||
| Editar outros campos (`status`, `notes`) por `PATCH` | Funciona (200) — o bloqueio é **só** das fotos |
|
||
| Suspeita de "plano expirado" | Descartada — visita **ativa (de hoje)** dá o mesmo 500 |
|
||
| `GET /v1/plans/visits/{id}/detail/` | Somente leitura |
|
||
| Checkout mobile `POST /v1/mobile/visit/{id}/checkout/` | **403** — exige token de **motorista**; e refaz o checkout inteiro (hora/GPS/assinatura) |
|
||
| Webhook | Canal de **saída** apenas — não escreve de volta |
|
||
|
||
- **Webhook existente descoberto:** a conta (Gade Hospitalar) tem um webhook `visit_checkout_detailed`
|
||
apontando para um **Google Apps Script** (`script.google.com/…/exec`) — recebe os dados de cada
|
||
entrega finalizada (inclui as URLs das fotos). É **inbound**, não serve para editar.
|
||
- **Conclusão:** não há caminho administrativo (nem por API, nem pelo painel web do SimpliRoute)
|
||
para corrigir uma foto após o envio do motorista. Só liberando escrita em `extra_field_values`
|
||
(pela API) ou uma opção de substituir imagem no painel deles.
|
||
- **Ferramenta de diagnóstico:** `bin/sondar_fotos_simpliroute --visita <id> [--testar-escrita]` —
|
||
mostra as fotos de todas as fontes e testa (com segurança, mirando campo vazio e restaurando) se
|
||
a escrita é aceita. Roda no servidor: `docker compose exec app bin/sondar_fotos_simpliroute …`.
|
||
|
||
> ⚠️ **Token do SimpliRoute:** é de **produção** e tem escrita. Se for compartilhado (chat/e-mail),
|
||
> **rotacione** depois em `app2.simpliroute.com`.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
public/icons.svg (novo — sprite Bootstrap Icons, MIT)
|
||
bin/sondar_fotos_simpliroute (novo — sondagem da API de fotos)
|
||
app/helpers/application_helper.rb (icone / rotulo / nav_link_to)
|
||
app/services/simpli_route/client.rb (detalhe_visita)
|
||
app/controllers/admin/edicao_lancamentos_controller.rb (fotos_do_lancamento)
|
||
app/views/admin/edicao_lancamentos/show.html.erb (galeria + lightbox)
|
||
app/views/layouts/_navbar.html.erb · _sidebar.html.erb · application.html.erb
|
||
app/javascript/controllers/validacao_controller.js (chip usa SVG, não emoji)
|
||
+ ~30 views com emojis substituídos (consolidacoes, dashboard, operacoes_dashboard, devise, …)
|
||
```
|
||
|
||
> **Sem migration e sem gem nova** — só views, helpers, JS e um asset estático.
|
||
> Em produção, o `public/icons.svg` precisa ir junto no deploy.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026)</strong></summary>
|
||
|
||
## Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas
|
||
|
||
### 🎯 O problema real
|
||
Quando um plano é **duplicado** no SimpliRoute, nasce uma **visita nova** (outro `tracking_id`) com a
|
||
**mesma NF**, e a antiga continua existindo. Caso que motivou tudo: **NF 82891** com visita em
|
||
**17/07** (pendente, motorista Thiago Rabello Bittencourt) e outra em **21/07** (sucesso, sem
|
||
motorista). A tela mostrava **uma só** — e depois, por regressão, **nenhuma**.
|
||
|
||
### 🔴 Causas corrigidas (foram 7, em camadas)
|
||
1. **`Entrega.por_nf(nf).first`** — pegava uma linha só, sem `ORDER BY`. Agora carrega todas e a
|
||
tela lista as ocorrências para o ADM escolher qual editar.
|
||
2. **`SimpliRoute::Client#resolver_id` abortava na ambiguidade** (`"Mais de uma visita para a NF…"`)
|
||
em vez de deixar escolher. A tela de edição não passa mais por ele.
|
||
3. **As datas vinham só do espelho local.** Como a API **não busca NF por intervalo**, a visita de
|
||
17/07 era inalcançável se o painel só conhecia a de 21/07. Entraram campos **De/Até** + botão
|
||
**"Últimos 30 dias"** (varredura dia a dia, teto de 62 dias).
|
||
4. **Um dia com falha derrubava a busca inteira** → agora é *fail-soft* por dia: registra em
|
||
`falhas` e segue. A tela informa o período consultado e quais dias falharam.
|
||
5. **`carregar?` faltando em `EdicaoLancamentoPolicy`** → o Pundit levantava `NoMethodError` (500),
|
||
o `fetch` recebia HTML e o JS reportava "erro de conexão". Falhou **fechado** (negou acesso),
|
||
sem brecha de segurança.
|
||
6. **Tela em branco:** `renderOcorrencias()` montava a lista inteira mas **nunca removia a classe
|
||
`hidden`** do container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível.
|
||
7. **Fuso horário:** `new Date('2026-07-15')` é meia-noite **UTC** e, em UTC−3, o `toLocale`
|
||
devolvia **14/07**. Datas puras agora são formatadas direto do texto ISO; horários de checkout
|
||
continuam convertendo (correto para instante).
|
||
|
||
### 🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026)
|
||
| Parâmetro | Resultado |
|
||
|---|---|
|
||
| **`&search=<NF>`** | ✅ **Funciona e não é documentado.** Um dia cai de **3,8 MB / ~9 s** (1879 visitas) para **~1 KB / ~0,6 s**. Varredura de 31 dias em lotes paralelos: **3,4 s / 3,2 KB** |
|
||
| `reference`, `reference_id`, `q`, `title`, `reference__*` | ❌ **Ignorados em silêncio** — devolvem 200 com o dia inteiro |
|
||
| `planned_date_from/to`, `since/until`, `__gte/__lte`, `date_from/to` | ❌ **Não existe filtro de intervalo** — todos devolveram 2469, idêntico ao controle **sem parâmetro nenhum** |
|
||
| `GET /v1/routes/visits/` sem `planned_date` | ⚠️ Devolve um **conjunto padrão (~2469)** que **não cobre o histórico** — para a NF 82891 voltava só a visita de 21/07 e escondia a de 17/07 |
|
||
|
||
> ⚠️ **Lição:** parâmetro não registrado no backend Django é **ignorado sem erro**. "Voltou 200 com
|
||
> resultados" **não** prova que filtrou — a prova é a **contagem diminuir** em relação ao dia sem o
|
||
> filtro. Ferramenta: **`bin/sondar_busca_nf --nf <n> --data <YYYY-MM-DD> [--sem-data]`** (só GET).
|
||
|
||
---
|
||
|
||
## Parte 2 — Responsividade
|
||
|
||
### 🎯 A raiz comum
|
||
Os breakpoints do Tailwind (`md:`, `lg:`, `xl:`) enxergam a **janela**, mas o conteúdo perde
|
||
**256px** para a sidebar (`main.md:ml-64`). **A mesma janela de 1280px tem duas larguras** conforme
|
||
o menu esteja aberto ou recolhido — e grades de colunas fixas não ficam sabendo. Daí "quando a barra
|
||
de menu é acionada, quebra o layout". Solução: **`flex-wrap` / `auto-fit + minmax`**, que reagem ao
|
||
espaço **real**.
|
||
|
||
### 🔧 Corrigido
|
||
| Tela | Antes | Sintoma | Depois |
|
||
|---|---|---|---|
|
||
| `consolidacoes/index` | `md:grid-cols-6` | Campos a ~150px; texto do botão **"Filtrar" vazava** para fora do fundo | `flex-wrap` + `basis-*` |
|
||
| `dashboard/index` | `xl:grid-cols-5` | **"R$ 82.692,"** — valor cortado pelo `overflow-hidden` do card | `auto-fit,minmax(13rem,1fr)` |
|
||
| `consolidacao_entregas/revisar` | `md:grid-cols-7` | 7 cards de **~55px**, destruindo "Extraordinária"/"Termo Especial" | `auto-fit,minmax(9rem,1fr)` |
|
||
| `configuracoes` + `admin/configuracoes` | `lg:grid-cols-4` | ~128px úteis para valores em moeda | `auto-fit,minmax(14rem,1fr)` |
|
||
|
||
**Mantidos de propósito:** `validar.html.erb` (`lg:grid-cols-4` é a divisão 3:1 lista/Resumo, não
|
||
grade de cards) e os `xl:grid-cols-3` de gráficos — nesses o conteúdo encolhe sem cortar.
|
||
|
||
### 📐 Passo 2 (validar) — cabeçalho fixo
|
||
Medido a 1280×577: **442px de cabeçalho contra 67px de lista** (menos que um card).
|
||
|
||
- Voltar + título + progresso passaram a **uma faixa só**; o card de progresso (70px de moldura para
|
||
uma barra de 12px) virou linha de 8px.
|
||
- **"Adicionar lançamento"** entrou na barra de ações via `order` do flex — sem mover os ~170 linhas
|
||
de modais que vivem dentro do `data-controller`.
|
||
- **Pilares (Normal/Retirada/Bônus/Desconto/Extra) continuam SEMPRE visíveis**, apenas **esmaecidos**
|
||
(`opacity-40`) enquanto não há seleção, com contador e "limpar" aparecendo ao selecionar.
|
||
⚠️ Uma versão intermediária os **escondia** até haver seleção — revertido: são a ação principal da
|
||
tela e sumir com eles esconde o que dá para fazer de quem ainda não sabe que precisa selecionar.
|
||
- Legibilidade preservada: título `text-2xl`, subtítulo e progresso `text-sm`, números em branco/negrito.
|
||
|
||
**Resultado:** cabeçalho **442px → 210px**, lista **67px → 299px**.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/controllers/admin/edicao_lancamentos_controller.rb # lista ocorrências, período, fail-soft, carregar
|
||
app/policies/edicao_lancamento_policy.rb # + carregar? (era o 500)
|
||
app/services/simpli_route/client.rb # visitas_da_data(data, busca:) → &search=
|
||
app/views/admin/edicao_lancamentos/show.html.erb # lista de ocorrências, De/Até, avisos, fuso
|
||
config/routes.rb # + get :carregar
|
||
bin/sondar_busca_nf # NOVO — sondagem de filtros da API (só GET)
|
||
app/views/consolidacao_entregas/validar.html.erb # cabeçalho compacto + barra de ações
|
||
app/javascript/controllers/validacao_controller.js # atualizarSelecao / limparSelecao
|
||
app/views/consolidacao_entregas/revisar.html.erb # grid-cols-7 → auto-fit
|
||
app/views/consolidacoes/index.html.erb # filtros → flex-wrap
|
||
app/views/dashboard/index.html.erb # KPIs → auto-fit
|
||
app/views/configuracoes/index.html.erb # preços → auto-fit
|
||
app/views/admin/configuracoes/index.html.erb # idem (arquivo distinto, também vivo)
|
||
```
|
||
|
||
### ⚠️ Pendências e alertas
|
||
- **Tailwind vem do Play CDN** (`cdn.tailwindcss.com`, em `application.html.erb:15`), que gera CSS no
|
||
navegador em tempo real. A documentação oficial **desaconselha em produção** (~380KB bloqueando a
|
||
renderização, recompilação a cada carregamento). O **`tailwind.config.js` do repositório não está
|
||
sendo usado** — a config real está embutida no layout (linha 17), e a gem `tailwindcss-rails` está
|
||
no Gemfile sem servir CSS.
|
||
- **Não existe teste para `Admin::EdicaoLancamentosController`** (`spec/` não tem nada de
|
||
`edicao_lancamento`). Duas das quebras acima — policy faltando e `hidden` não removido — seriam
|
||
pegas por um teste de request/sistema em segundos, sem custar deploy.
|
||
- **Token do SimpliRoute:** se passou por chat/e-mail, **rotacione** em `app2.simpliroute.com`.
|
||
|
||
> **Sem migration e sem gem nova** — controller, policy, service, views e JS.
|
||
> ⚠️ Reiniciar o Puma após o deploy (cache de classes/views).
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026)</strong></summary>
|
||
|
||
> ⚠️ **STATUS: implementado, ainda NÃO executado.** Nada aqui foi rodado contra um banco nem
|
||
> contra os servidores reais (Gmail/Twilio) — não há Ruby nem Postgres na máquina de
|
||
> desenvolvimento. Foi conferida a sintaxe de todos os arquivos `.rb` e `.erb` e validado o
|
||
> algoritmo de normalização de telefone em Ruby puro. **A migration, a suíte e o envio real
|
||
> continuam pendentes** — roteiro no fim desta seção.
|
||
|
||
### 🎯 O problema
|
||
Servidor de e-mail e credenciais do Twilio viviam **só no `.env`**: trocar a senha de app do Gmail
|
||
ou o número remetente exigia editar o arquivo no servidor e **reiniciar o container**. O ADM não
|
||
tinha como fazer nada disso pela interface.
|
||
|
||
Pior: as chaves `notificacao_whatsapp` e `notificacao_email` existiam em `configuracoes` mas
|
||
**nunca tiveram UI** — `Admin::ConfiguracoesController#index` filtra por `CHAVES_MOEDA`. Ligar
|
||
notificação só era possível pelo `rails console`.
|
||
|
||
### 🆕 A tela
|
||
**Configurações → card "Notificações e E-mail"** (`/admin/configuracao_notificacao`).
|
||
**Só `admin`** — `ConfiguracaoNotificacaoPolicy` é mais restrita que `ConfiguracaoPolicy`, que
|
||
libera `index?` para gerente: aqui ficam senha de e-mail e token do Twilio.
|
||
|
||
O acesso é **exclusivamente pelo card dentro de Configurações** — de propósito não há item no menu
|
||
lateral, para não expor um atalho de credenciais na navegação de todo dia.
|
||
|
||
| Bloco | Campos |
|
||
|---|---|
|
||
| **Servidor de e-mail (SMTP)** | ativo, servidor, porta, usuário, senha, autenticação, domínio, e-mail e nome do remetente + toggle "avisar motoristas por e-mail" |
|
||
| **WhatsApp (Twilio)** | ativo, Account SID, Auth Token, número remetente |
|
||
| **Destinatários administrativos** | e-mail do admin, WhatsApp do admin |
|
||
|
||
Três botões: **Salvar**, **Salvar e enviar e-mail de teste**, **Salvar e enviar WhatsApp de teste**.
|
||
|
||
### ⚙️ Como funciona (técnico) — os 6 pontos não-óbvios
|
||
|
||
**1. Hierarquia banco > `.env`, sem quebrar nada.** `ConfiguracaoNotificacao#smtp_settings` devolve
|
||
**`nil`** quando não está pronto. O ActionMailer faz `.merge(options || {})` por cima do que o
|
||
`config/initializers/smtp.rb` montou no boot — então o fallback para o `.env` é **automático**.
|
||
Enquanto os toggles estiverem desligados, o comportamento é **idêntico ao de antes desta tela**.
|
||
|
||
**2. `default delivery_method_options:`, NÃO um `before_action`.** Um callback que mexesse em
|
||
`message.delivery_method` seria **descartado**: `ActionMailer::Base#mail` roda *depois* dos
|
||
callbacks e chama `wrap_delivery_behavior!`, que reconfigura a mensagem. O
|
||
`delivery_method_options` é lido *dentro* do próprio `mail()`.
|
||
|
||
**3. `proc`, NÃO lambda.** O Devise avalia o `default from:` com `instance_eval(&proc)`, que passa
|
||
1 argumento. Um `-> { }` de aridade 0 estouraria **`ArgumentError` em todo "esqueci minha senha"**.
|
||
|
||
**4. `config.parent_mailer = 'ApplicationMailer'`** no `devise.rb` — sem isso o reset de senha
|
||
continuaria preso ao `.env`. Efeito colateral aceito: os e-mails do Devise passam a usar
|
||
`app/views/layouts/mailer.html.erb`.
|
||
|
||
**5. Segredos cifrados sem `master.key`.** Senha SMTP e Auth Token vão para colunas
|
||
`*_cifrado` (AES-256-GCM) via `AtributoCifrado`, com chave derivada do `secret_key_base`. **Não** se
|
||
usou ActiveRecord Encryption: exigiria 3 chaves novas, dependeria da ordem dos initializers e
|
||
estouraria `Errors::Decryption` na leitura. Aqui o reader faz `rescue → nil`, o app **degrada para
|
||
o `.env`** e a tela mostra um banner amarelo pedindo para redigitar.
|
||
|
||
**6. Testes com `deliver_now` e `raise_delivery_errors = true` forçado.** O `development.rb` define
|
||
`raise_delivery_errors = false` e o adapter do ActiveJob é `:async` (thread in-process) — com
|
||
`deliver_later` **o teste "passaria" em silêncio mesmo com a senha errada**.
|
||
|
||
### 🩹 Bug pré-existente corrigido de passagem
|
||
`NotificacaoService` mandava `to: "whatsapp:#{user.telefone}"` com o telefone **cru do cadastro**.
|
||
Um telefone gravado como `(11) 92005-1157` vira `whatsapp:(11) 92005-1157`, o Twilio devolve
|
||
**21211** — e o `rescue` engolia. **Provavelmente nenhum WhatsApp a motorista jamais chegou.**
|
||
Agora passa por `ConfiguracaoNotificacao.canal`, que normaliza para E.164.
|
||
|
||
> Casos cobertos: `11 920051157`, `(11) 92005-1157`, `011 …`, `+55 11 …`, `whatsapp:+55…` →
|
||
> `+5511920051157`. O prefixo `55` só é removido quando sobra número demais — senão quebraria o
|
||
> **DDD 55** (Santa Maria/RS), onde `55991234567` já é o número completo.
|
||
|
||
### 🔐 Segurança
|
||
- A senha gravada **nunca volta para o HTML** (`password_field value: nil`). Campo em branco
|
||
significa "mantenha a atual" — salvar sem redigitar não apaga o que está lá.
|
||
- `AuditoriaLog` registra a mudança com `acao: 'editar_notificacoes'`, mas grava apenas
|
||
`smtp_password_definida: true/false`. **Nunca a senha nem o token** — `dados_novos` é exibido em
|
||
`/admin/auditoria_logs`, que **gerente também acessa**.
|
||
- Os erros do Twilio vêm traduzidos (63003/63015 = falta o `join <palavra>` do sandbox, 21608 =
|
||
conta trial só envia a número verificado, 20003 = SID/token inválidos…), para o ADM resolver
|
||
sozinho sem abrir o log do container.
|
||
|
||
### 🆕 Migration adicionada (rodar `db:migrate`)
|
||
```
|
||
20260811000001_create_configuracao_notificacoes.rb
|
||
```
|
||
Tabela **singleton** (índice único em `singleton_guard` — impede dois workers Puma criarem linhas
|
||
concorrentes). Copia as flags antigas de `configuracoes` e **não** importa credenciais do `.env`:
|
||
os campos nascem vazios e o fallback segue mandando até alguém preencher a tela.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260811000001_create_configuracao_notificacoes.rb # NOVO — tabela singleton
|
||
app/models/configuracao_notificacao.rb # NOVO — smtp_settings, credenciais, E.164
|
||
app/models/concerns/atributo_cifrado.rb # NOVO — AES-256-GCM sem master.key
|
||
app/policies/configuracao_notificacao_policy.rb # NOVO — show?/update? = admin
|
||
app/controllers/admin/configuracao_notificacoes_controller.rb # NOVO — show/update + params[:acao]
|
||
app/views/admin/configuracao_notificacoes/show.html.erb # NOVO — a tela
|
||
app/services/notificacao/resultado.rb # NOVO — ok?/mensagem/detalhe → flash
|
||
app/services/notificacao/teste_email.rb # NOVO — erros SMTP em português
|
||
app/services/notificacao/teste_whatsapp.rb # NOVO — códigos Twilio em português
|
||
app/services/notificacao/cliente_twilio.rb # NOVO — client com timeout de 15s
|
||
app/mailers/teste_mailer.rb · app/views/teste_mailer/teste.html.erb # NOVO
|
||
app/services/notificacao_service.rb # lê do banco; unifica os 2 pares duplicados
|
||
app/mailers/application_mailer.rb # default from: / delivery_method_options: proc
|
||
app/mailers/consolidacao_mailer.rb # removido o `default from:` que anulava o proc
|
||
config/initializers/devise.rb # + parent_mailer
|
||
config/initializers/smtp.rb # vira fallback (só comentário)
|
||
config/initializers/inflections.rb # + irregular 'notificacao'
|
||
config/routes.rb # + resource :configuracao_notificacao
|
||
app/views/admin/configuracoes/index.html.erb # + card "Notificações e E-mail" (só admin)
|
||
app/models/configuracao.rb # marca notificacao_* como obsoletas
|
||
.env.example # hierarquia banco > .env + NOTIFICACAO_SECRET
|
||
spec/models/configuracao_notificacao_spec.rb # NOVO
|
||
spec/policies/configuracao_notificacao_policy_spec.rb # NOVO
|
||
spec/requests/admin/configuracao_notificacoes_spec.rb # NOVO
|
||
spec/models/table_names_spec.rb # + ConfiguracaoNotificacao
|
||
```
|
||
|
||
### ⏳ Pendente de execução — roteiro
|
||
```bash
|
||
# 1. Migrar
|
||
docker-compose exec app bundle exec rails db:migrate
|
||
|
||
# 2. Suíte
|
||
docker-compose exec app bundle exec rspec spec/models spec/policies spec/requests
|
||
|
||
# 3. Confirmar a assinatura da gem (não pôde ser verificada fora do container)
|
||
docker-compose exec app bundle exec rails runner \
|
||
'p Twilio::HTTP::Client.instance_method(:initialize).parameters'
|
||
```
|
||
4. **Permissão:** logar como **gerente** → o card não aparece e `/admin/configuracao_notificacao`
|
||
redireciona com "Você não tem permissão". Como **admin** → a tela abre.
|
||
5. **E-mail:** `smtp.gmail.com`, porta 587, **Senha de app de 16 caracteres** (não a senha da
|
||
conta), marcar "Ativar envio de e-mail" → **Salvar e enviar e-mail de teste**. Conferir o spam.
|
||
6. **WhatsApp:** SID / Auth Token / número remetente do Twilio, marcar "Ativar WhatsApp" →
|
||
**Salvar e enviar WhatsApp de teste**. ⚠️ **No sandbox, o número que vai RECEBER precisa antes
|
||
mandar `join <sua-palavra>`** para o número do sandbox — sem esse opt-in volta 63003/63015.
|
||
7. **Não regressão:** com os toggles desligados, finalizar uma consolidação e conferir que nada
|
||
mudou; e testar o **"Esqueci minha senha"**, que trocou de mailer pai.
|
||
|
||
### ⚠️ Alertas
|
||
- **Rotação do `SECRET_KEY_BASE` torna senha e token ilegíveis.** O sistema não quebra (volta ao
|
||
`.env` e avisa na tela), mas os dois campos precisam ser redigitados. Para desacoplar, defina
|
||
**`NOTIFICACAO_SECRET`** no `.env` com uma string longa e **fixa**.
|
||
- **Não cachear a config em `Rails.cache`:** o `production.rb` usa `:memory_store`, que é por
|
||
processo — a tela pareceria "não salvar" para os outros workers. É 1 `SELECT` por e-mail.
|
||
- `app/views/configuracoes/index.html.erb` (fora do `admin/`) **não recebeu o card**: não tem rota
|
||
e é código morto — o vivo é `app/views/admin/configuracoes/index.html.erb`.
|
||
|
||
> **Migration nova** (`db:migrate` obrigatório) e **sem gem nova** — `twilio-ruby` já estava no
|
||
> Gemfile. ⚠️ Reiniciar o Puma após o deploy.
|
||
|
||
</details>
|
||
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (20–21/08/2026)</strong></summary>
|
||
|
||
> ⚠️ **STATUS: no ar no ambiente de teste e conferido com dados reais** (17 motoristas,
|
||
> 4.976 entregas de agosto). O que **continua pendente é a suíte** — não há Ruby nem Postgres na
|
||
> máquina de desenvolvimento, então os specs novos foram validados só na sintaxe. Roteiro no fim
|
||
> desta seção.
|
||
|
||
### 🎯 O problema
|
||
A Reem **paga a entrega sem sucesso**: o motorista foi até o local, teve o deslocamento e o custo,
|
||
e o insucesso é só o desfecho da visita. A **consolidação** já tratava assim desde sempre
|
||
(`Entrega::STATUS_ATENDIDO` = `completed` + `failed`), mas o **dashboard não** — mostrava um valor
|
||
**menor que o do fechamento**, e ninguém sabia explicar a diferença.
|
||
|
||
### 🔴 Correções
|
||
|
||
**1. Dashboard principal contava só as concluídas** — `dashboard_controller.rb` (commit `cebd6e2`)
|
||
|
||
A base financeira era `Entrega.pagas` (só `completed`). Passou a ser `.atendidas`
|
||
(`completed` + `failed`, com checkout) — **o mesmo recorte que a consolidação considera elegível**,
|
||
que é justamente o ponto: tela e fechamento agora partem do mesmo conjunto.
|
||
|
||
**2. A falhada caía no período errado** — mesmo commit
|
||
|
||
As falhas eram filtradas por `planned_date`, e as concluídas por `checkout`. **Falhada TEM
|
||
checkout** (o motorista fechou a visita com motivo de insucesso), então o eixo correto é o mesmo
|
||
das concluídas. Uma entrega planejada em 31/07 e fechada em 01/08 pertence a agosto — como a
|
||
consolidação sempre entendeu.
|
||
|
||
**3. O painel do motorista ficou para trás** — `motorista/dashboard_controller.rb` (commit `4965931`)
|
||
|
||
Continuava em `Entrega.pagas` + `no_periodo` (planned_date), ou seja, a lógica antiga inteira.
|
||
O motorista via **menos do que ia receber** — no mês corrente, 124 entregas (~R$ 2.232,00)
|
||
invisíveis — e a diferença só aparecia no fechamento. Passou para `.atendidas` +
|
||
`no_periodo_checkout`, e o rótulo *"N entregas feitas e confirmadas"*, que mentia sobre o número
|
||
novo, virou:
|
||
|
||
```
|
||
R$ 20,00
|
||
2 entregas atendidas
|
||
1 entregues · 1 sem sucesso (pagas também)
|
||
```
|
||
|
||
A segunda linha não é enfeite: sem ela o motorista vê um total maior e não tem como conferir de
|
||
onde veio.
|
||
|
||
**4. A barra do ranking contradizia a ordem do ranking** — `_ranking_motoristas.html.erb` (commit `e9db6b7`)
|
||
|
||
A barra era proporcional à **quantidade**, mas o card é ordenado por **valor**. Na aba Estimado dá
|
||
no mesmo (valor = qtd × preço); na Consolidada, bônus/retirada/termo mudam o preço unitário e a
|
||
barra do 3º (290 entregas, R$ 5.250) saía **maior** que a do 2º (261 entregas, R$ 5.260).
|
||
Invertia em três pontos da lista. Agora escala pelo valor, que é o número que ordena.
|
||
|
||
### 🆕 Aba "Consolidado" no ranking de motoristas
|
||
|
||
O card **Motoristas** ganhou duas abas — a dúvida recorrente era justamente *"esse ranking mostra o
|
||
estimado ou o consolidado?"*:
|
||
|
||
| Aba | O que mostra | De onde vem |
|
||
|-----|--------------|-------------|
|
||
| **Estimado** | entregas atendidas × preço da entrega | espelho de rastreio (`Entrega.atendidas`) |
|
||
| **Consolidado** | valor **realmente fechado** + quantidade exata de entregas | `consolidacao_motoristas` / `consolidacao_entregas` |
|
||
|
||
Os números divergem **de propósito**: o estimado cobre tudo que foi atendido no período; o
|
||
consolidado, só o que já entrou em consolidação **finalizada**, com bônus/desconto/retirada
|
||
aplicados. Cada aba diz na tela de onde vem o seu número.
|
||
|
||
Dois detalhes decidem se a quantidade sai certa:
|
||
|
||
- **`DISTINCT tracking_id`, não contagem de linhas.** Uma entrega pode ter vários pilares —
|
||
Normal + Bônus + Retirada são **3 linhas** em `consolidacao_entregas` para **1 entrega**.
|
||
Contar linhas inflaria o número.
|
||
- **Só motoristas ativos.** O ranking parte de `@fin_por_motorista` (que vem de
|
||
`ConsolidacaoMotorista.ativos`), então **arquivado não aparece** — ele saiu do fechamento e não
|
||
tem valor a exibir. Isso também garante que a aba soma exatamente o KPI "Custo total" do topo.
|
||
|
||
O markup da lista virou a partial `_ranking_motoristas.html.erb`, usada pelas duas abas: os dois
|
||
conjuntos têm a mesma forma (`:nome`, `:valor`, `:entregas`) e duplicar o HTML faria as abas
|
||
divergirem visualmente na primeira alteração.
|
||
|
||
### ✅ Conferência com dados reais (teste.reemtransportes.com.br, 21/08/2026)
|
||
|
||
**Dashboard principal — período 01/08 a 21/08:**
|
||
|
||
| Card | Na tela | Confere |
|
||
|------|---------|---------|
|
||
| Valor Estimado | R$ 89.568,00 | 4.976 × R$ 18,00 exato |
|
||
| — subtítulo | 4.976 entregas atendidas | 4.852 + 124 |
|
||
| Total Entregas | 4.977 | 4.976 atendidas + 1 pendente |
|
||
|
||
O teste decisivo: 4.852 concluídas × R$ 18 dariam **R$ 87.336,00**. A tela mostra **R$ 89.568,00** —
|
||
exatamente **R$ 2.232,00 a mais, que são as 124 sem sucesso**. O insucesso entra no dinheiro, não
|
||
só na contagem.
|
||
|
||
**Consistência interna** (as falhas entram em todo lugar, não só no card):
|
||
- Ranking por motorista: os 17 motoristas somam **exatamente 4.976**; se contasse só sucesso daria
|
||
4.852.
|
||
- Gráfico "Evolução do custo": a série diária soma **R$ 69.408,00**, idêntico ao valor estimado do
|
||
período 01–14/08 — as falhas caem nos dias certos (eixo checkout).
|
||
- Sem dupla contagem: o scope `pendentes` exclui `failed`, então entregue / pendente / sem sucesso
|
||
não se sobrepõem.
|
||
|
||
**Aba Consolidado — reconciliação com os KPIs:**
|
||
|
||
| | Soma da aba | KPI do topo |
|
||
|---|---|---|
|
||
| Valores | **R$ 72.508,00** | R$ 72.508,00 ("Custo total") ✅ |
|
||
| Entregas | **3.858** | 3.858 ("entregas classificadas") ✅ |
|
||
|
||
Bate à vírgula e à unidade. Isso explica também a diferença **3.858 consolidadas × 3.856 atendidas**:
|
||
são 2 entregas que entraram no fechamento sem estar na janela de checkout do período (apontamento
|
||
manual de NF fora do período, ou consolidação que extrapola as datas). **Não é erro de contagem —
|
||
são bases diferentes**, e agora dá para ver as duas lado a lado.
|
||
|
||
### 🧪 Specs — ⏳ pendentes de execução
|
||
|
||
`spec/requests/dashboard_spec.rb` (+4 casos) e `spec/requests/motorista_dashboard_spec.rb` (novo,
|
||
5 casos). Usam o harness `spec/support/espelho_rastreio.rb`, que monta uma cópia descartável de
|
||
`db_reem_simplerout_2026` no banco de teste — sem ele só daria para mockar o método, o que não pega
|
||
regressão de **SQL**, que é onde moram os bugs de eixo de data.
|
||
|
||
O que fica travado:
|
||
- a sem sucesso soma no valor e na quantidade;
|
||
- entra pelo **checkout** (planejada 31/07 + checkout 01/08 → conta em agosto) e sai quando o
|
||
checkout cai fora;
|
||
- pendente sem checkout não vira dinheiro;
|
||
- entrega de outro motorista não vaza para o painel;
|
||
- na aba Consolidado: 3 linhas de 2 `tracking_id` = **"2 entregas"**, desconto subtraindo,
|
||
arquivado fora da lista, rascunho não entrando.
|
||
|
||
> As asserções da aba Consolidado são escopadas ao `#ranking-painel-consolidado` via Nokogiri — a
|
||
> aba Estimado renderiza o **mesmo markup** (moeda + "N entregas"), então asserção no `body` inteiro
|
||
> passaria por acidente.
|
||
|
||
O painel do motorista não tem filtro de período (é sempre "do dia 1º até hoje"), então o spec
|
||
congela a data com `travel_to`; sem isso ele quebraria sozinho ao rodar no dia 1º.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/controllers/dashboard_controller.rb (atendidas + eixo checkout; @ranking_consolidado)
|
||
app/controllers/motorista/dashboard_controller.rb (atendidas + no_periodo_checkout; quebra do card)
|
||
app/models/entrega.rb (scopes atendidas / falhadas / no_periodo_checkout)
|
||
app/views/dashboard/index.html.erb (abas Estimado/Consolidado + JS da troca)
|
||
app/views/dashboard/_ranking_motoristas.html.erb (NOVO — lista compartilhada pelas duas abas)
|
||
app/views/motorista/dashboard/index.html.erb (rótulo "atendidas" + linha da quebra)
|
||
spec/requests/dashboard_spec.rb (+ aba Consolidado)
|
||
spec/requests/motorista_dashboard_spec.rb (NOVO)
|
||
spec/support/espelho_rastreio.rb (harness da tabela externa)
|
||
```
|
||
|
||
> **Sem migration e sem gem nova** — só controllers, views e specs.
|
||
|
||
### ⏳ Pendente — roteiro
|
||
```bash
|
||
# 1. Suíte (única coisa que não pôde ser executada)
|
||
docker compose exec app bundle exec rspec \
|
||
spec/requests/dashboard_spec.rb spec/requests/motorista_dashboard_spec.rb
|
||
|
||
# 2. Depois do deploy: conferir a barra do ranking na aba Consolidado
|
||
# (deve encurtar sempre de cima para baixo)
|
||
|
||
# 3. Painel do motorista com dado real — logar como motorista no teste e
|
||
# conferir a linha "N entregues · N sem sucesso (pagas também)"
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026)</strong></summary>
|
||
|
||
> ⚠️ **STATUS: implementado, ainda NÃO executado.** Não há Ruby/Bundler nem Postgres na máquina de
|
||
> desenvolvimento — foi conferida a sintaxe de todos os `.rb` e `.erb` alterados. **A suíte e a
|
||
> validação com dado real continuam pendentes** — roteiro no fim desta seção.
|
||
|
||
### 🎯 O problema
|
||
Mesmo período filtrado, dois números diferentes:
|
||
|
||
| | Dashboard financeiro | Dashboard de Operações |
|
||
|---|---|---|
|
||
| Total | 4977 | 4973 |
|
||
| Entregues / Sucesso | 4852 | 4851 |
|
||
| Falhadas / Recusas | 124 | 122 |
|
||
| Pendentes | 1 | 0 |
|
||
|
||
Não era arredondamento: **as duas telas contam coisas diferentes**, e nada na interface dizia isso.
|
||
|
||
- O **financeiro** conta **visitas** (idas ao local). É o recorte certo lá, porque é por ida que o
|
||
motorista recebe — `Entrega.contar_atendidas` conta linhas, e a consolidação paga em cima disso.
|
||
- **Operações** conta **notas fiscais** (último status de cada NF). É o recorte certo aqui, porque
|
||
é o que o cliente paga e o que confere nos documentos físicos — o mesmo critério da aba ENTREGAS
|
||
da planilha entregue (`Analytics::PlanilhaEntregas`).
|
||
|
||
Uma NF que falhou dia 10 e foi entregue dia 12 vale **2 no financeiro e 1 em Operações**. Correto
|
||
nos dois — mas invisível.
|
||
|
||
### 🐛 Três defeitos reais por trás disso
|
||
|
||
**1. O dedup rodava sobre a tabela inteira, não sobre o período.** O `ROW_NUMBER() ... rn = 1`
|
||
ficava numa CTE **antes** do filtro de data. Se o último checkout de uma NF era **posterior** ao fim
|
||
do período, a visita que aconteceu **dentro** do período sumia da contagem do mês. Subcontagem
|
||
silenciosa em todo fechamento. Agora o dedup acontece em `#linhas`, **depois** do período e do
|
||
cross-filter.
|
||
|
||
**2. O dedup escondia todo o insucesso reentregue.** NF que falhou duas vezes antes de entregar
|
||
aparecia como 100% de sucesso, e o motivo sumia do "Índices de falha".
|
||
|
||
**3. O período nem era aplicado no modo Operação.** `montar([@operacao], ...)` era chamado **sem
|
||
`inicio:`/`fim:`** e o seletor de data só aparecia no modo Global — comparar as duas telas "no mesmo
|
||
período" era literalmente impossível.
|
||
|
||
### 🆕 O que mudou na tela
|
||
|
||
**Operações — camada "Visitas ao local"** (abaixo dos 4 cards): visitas realizadas, retentativas,
|
||
insucessos por visita e "entregues na 2ª ida ou mais". Os 4 cards de cima seguem contando **notas**.
|
||
|
||
**Operações — painel "Notas fora da operação"**: NFs entregues no período que **não estão em nenhuma
|
||
planilha `gade_entregas_*`** — os planos avulsos e de inclusão. Contavam no financeiro e o
|
||
`INNER JOIN` com a tabela da operação as descartava aqui. Agora aparecem com NF, plano/título,
|
||
motorista, unidade, data, resultado e motivo, com quebra por plano de origem.
|
||
|
||
**Operações — período no modo Operação**: o seletor passa a valer também aqui, mas **só quando o
|
||
operador escolhe uma faixa** (`inicio`/`fim` na URL). Sem escolha, o recorte segue sendo a operação
|
||
inteira — senão abrir uma operação de meses atrás cairia no mês corrente e mostraria zero. Botão
|
||
**"Operação inteira"** volta ao recorte natural.
|
||
|
||
**Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas".
|
||
|
||
**Financeiro — card "N pendentes" agora é clicável** → `/dashboard/pendentes`, a tela nova
|
||
**"Entregas em aberto"**. O número existia desde sempre e **nenhuma tela listava as linhas por trás
|
||
dele** — só dava para descobrir por `rails runner`. A lista traz NF, status, motorista, veículo,
|
||
unidade, data planejada, **em qual operação a NF está** (ou "fora da operação") e **quantas visitas
|
||
o rastreio tem para ela**. Essas duas últimas colunas respondem sozinhas por que a entrega ficou em
|
||
aberto e por que o dashboard de Operações não a mostrava.
|
||
|
||
### 🔍 O que o dado real mostrou (24/08/2026, teste)
|
||
A "1 pendente" que não aparecia em Operações era a **NF 85382 — MARIA APARECIDA JESUS SANTOS**:
|
||
plano avulso, status `pending`, **sem motorista**, planejada 03/08/2026, STS VILA PRUDENTE _
|
||
SAPOPEMBA. Nota **fora da operação** — o `INNER JOIN` a descartava. Confirmado no painel novo.
|
||
|
||
⚠️ **`title` NÃO é o nome do plano.** No dado real ele traz `NF 89096 - KAIQUE TAUAN DA SILVA` — o
|
||
formato da coluna A da planilha de importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o
|
||
**destinatário**. Agrupar por ele dava um grupo por NF. `COLUNAS_PLANO` passou a ser
|
||
`notes, comments, route_id`; `title` virou a coluna "Destinatário". Quando nenhuma coluna de plano
|
||
vem preenchida, a quebra cai para **unidade**, que ainda informa algo. **A coluna que carrega
|
||
"(Avulsa)"/"INCLUSÃO" segue não confirmada** — pode ser que o espelho simplesmente não a traga
|
||
(o sync já deixa 4 colunas 100% NULL).
|
||
|
||
### ⚙️ Pontos não-óbvios
|
||
|
||
**Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma
|
||
linha por NF (a última visita). Todos os KPIs, o donut, os motivos, o mapa e a tabela espelho
|
||
continuam saindo de `#linhas` — ou seja, **a tela segue batendo com a planilha do cliente**. Só a
|
||
faixa nova lê `#visitas`.
|
||
|
||
**`uniq` por `tracking_id`.** Sem a CTE, se a mesma `nota_fiscal` estiver repetida dentro de uma
|
||
tabela de operação (ou em duas tabelas do UNION global), o `INNER JOIN` devolvia a **mesma visita**
|
||
mais de uma vez. Agora colapsa.
|
||
|
||
**Filtro de conta unificado.** `Entrega.condicao_conta_sql` nasceu para as queries cruas de
|
||
`Analytics` usarem exatamente o mesmo recorte de `DB_EXISTING_ACCOUNT_ID` do scope
|
||
`da_conta_gade` — antes o dashboard filtrava conta e Operações não.
|
||
|
||
**`NOT IN` com `NULL` devolve zero linhas.** Cada `SELECT` da união em `NotasForaOperacao` filtra
|
||
`nota_fiscal IS NOT NULL`; sem isso um único NULL numa planilha deixaria o painel vazio para sempre.
|
||
Tem spec para isso.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/models/entrega.rb (contas_gade + condicao_conta_sql)
|
||
app/models/operacao.rb (por_notas — em que operação cada NF está)
|
||
app/services/analytics/operacao_metricas.rb (visitas x linhas; dedup pós-filtro; conta)
|
||
app/services/analytics/notas_fora_operacao.rb (NOVO — avulsas/inclusão)
|
||
app/controllers/operacoes_dashboard_controller.rb (período no modo Operação; @fora_operacao)
|
||
app/controllers/dashboard_controller.rb (@notas_atendidas + action #pendentes)
|
||
app/views/dashboard/pendentes.html.erb (NOVO — tela "Entregas em aberto")
|
||
config/routes.rb (GET /dashboard/pendentes)
|
||
app/views/operacoes_dashboard/_painel.html.erb (faixa "Visitas ao local")
|
||
app/views/operacoes_dashboard/_fora_operacao.html.erb (NOVO — painel de avulsas)
|
||
app/views/operacoes_dashboard/index.html.erb (seletor de período + render do painel)
|
||
app/views/dashboard/index.html.erb (linha "N notas fiscais" + card clicável)
|
||
spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa)
|
||
spec/services/analytics/notas_fora_operacao_spec.rb (NOVO)
|
||
spec/requests/dashboard_spec.rb (+ tela de entregas em aberto)
|
||
```
|
||
|
||
> **Sem migration e sem gem nova** — model, services, controllers, views e specs.
|
||
|
||
### ⏳ Pendente — roteiro
|
||
```bash
|
||
# 1. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local)
|
||
docker compose exec app bundle exec rspec \
|
||
spec/services/analytics/operacao_metricas_spec.rb \
|
||
spec/services/analytics/notas_fora_operacao_spec.rb \
|
||
spec/requests/dashboard_spec.rb \
|
||
spec/models/entrega_spec.rb
|
||
|
||
# 2. Achar a coluna do PLANO ("(Avulsa)", "INCLUSÃO"). title JÁ foi descartado
|
||
# (é o destinatário). Dump de uma nota avulsa real para ver onde o plano está:
|
||
docker compose exec app bin/rails runner '
|
||
e = Entrega.por_nf(85382).first
|
||
e&.attributes&.reject { |_, v| v.blank? }&.each { |k, v| puts "#{k.ljust(28)} #{v}" }
|
||
'
|
||
|
||
# 3. Com dado real, no mesmo período nas duas telas:
|
||
# financeiro "N notas fiscais" == Operações Global "Total de Entregas"
|
||
# financeiro "Total Entregas" == Operações "Visitas ao local" + notas fora da operação
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>📣 Notificações: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026)</strong></summary>
|
||
|
||
> ✅ **STATUS: NO AR no ambiente de teste (24/08/2026).** Migrations aplicadas, container da ponte
|
||
> buildado e **WhatsApp pareado por QR** — número `5511920051157`, conectado às 18:07, intervalo em
|
||
> 20s. Telas de contatos, grupos, eventos, envios e conexão validadas no navegador.
|
||
>
|
||
> ⚠️ **A suíte continua sem rodar** — não há Ruby/Bundler na máquina de desenvolvimento. A sintaxe
|
||
> de todos os `.rb`/`.erb` foi conferida (com um checker que emula o handler ERB do Rails, porque
|
||
> `<%= form_with … do %>` não passa no ERB da stdlib) e a do `server.js` com `node --check`.
|
||
|
||
> Esta é a **etapa 1 de 3**. Ver "O que NÃO está aqui" no fim.
|
||
|
||
### 🎯 O problema
|
||
O canal de WhatsApp era Twilio (pago). Além disso, "quem recebe" era implícito: o
|
||
`NotificacaoService` procurava o `User` motorista **pelo nome** da consolidação. Quem não é usuário
|
||
do sistema — diretoria, cliente, terceiro — não tinha como ser avisado, e não havia tela nenhuma
|
||
para controlar isso. O corpo da mensagem era string interpolada em Ruby.
|
||
|
||
### 🆕 O que existe agora
|
||
|
||
| Tela | O quê |
|
||
|---|---|
|
||
| **Notificações → Contatos** | Cadastro manual: nome, WhatsApp e/ou e-mail, grupo. Quem tem só número recebe só WhatsApp. |
|
||
| **Notificações → Grupos** | Diretoria, Operação, Motoristas… É o **grupo** que assina os eventos. |
|
||
| **Notificações → Eventos** | Cria eventos e marca quais grupos recebem, por qual canal. Gatilho `manual` tem botão "disparar agora". |
|
||
| **Notificações → WhatsApp** | Pareamento por **QR code**, status ao vivo, envio de teste, desconectar. |
|
||
| **Notificações → Envios** | Log de tudo que saiu: destinatário, canal, situação e o erro real. |
|
||
|
||
### ⚙️ Pontos não-óbvios
|
||
|
||
**Container Node novo (`whatsapp/`).** Não existe biblioteca Ruby que fale o protocolo do WhatsApp
|
||
Web — é Baileys. A ponte expõe `/status`, `/enviar`, `/logout` e `/health`, protegida por
|
||
`WHATSAPP_TOKEN`. A **porta não é publicada** no compose: só o container do Rails alcança. Publicar
|
||
exporia um endpoint que manda mensagem em nome da empresa.
|
||
|
||
**A sessão precisa de volume.** `whatsapp_auth:/data` — sem ele, cada deploy exige escanear o QR
|
||
de novo.
|
||
|
||
**Envio serializado e com intervalo.** Disparo em rajada é o que mais causa banimento no canal não
|
||
oficial. A fila do Node serializa e o Rails pausa entre mensagens (`whatsapp_intervalo_segundos`,
|
||
nasce em 5s). Como `sleep(5) × 30 contatos` penduraria o Puma por 2min30, o envio roda em
|
||
`NotificacaoJob`, **nunca dentro da requisição**.
|
||
|
||
**`whatsapp_provedor` nasce em `twilio`.** O deploy não muda o comportamento até o ADM parear o QR
|
||
e trocar o provedor na tela. `Notificacao::Whatsapp` é o ponto único que escolhe — trocar
|
||
Twilio ↔ Baileys é um campo, não um `if` espalhado.
|
||
|
||
**Nomes de rota ≠ nomes de controller, de propósito.** `grupos_contato` e `eventos_notificacao`
|
||
têm singular igual ao plural para o Inflector (que não fala português) e o Rails sufixaria o helper
|
||
de index com `_index` — pegadinha silenciosa. As rotas se chamam `grupos`, `eventos`, `envios`.
|
||
Por isso o `form_with` dos grupos passa `url:` explícita: a rota polimórfica de `GrupoContato`
|
||
procuraria `admin_grupo_contato_path`.
|
||
|
||
**O aviso pessoal ao motorista não regrediu.** Ele continua recebendo o e-mail formatado do
|
||
`ConsolidacaoMailer` (não virou texto puro); o que mudou é que o WhatsApp passa pelo provedor
|
||
escolhido e **tudo fica logado**. Os grupos recebem uma cópia via `Despachante`, com
|
||
`envolvido: nil` para o motorista não receber duas vezes.
|
||
|
||
**Log em tabela, não em arquivo.** O envio engole exceção de propósito (um SMTP fora do ar não pode
|
||
travar um fechamento). Com sessão QR — que cai sozinha e exige repareamento — "o motorista
|
||
recebeu?" vira pergunta de rotina, e a resposta precisava sair do `log/production.log`.
|
||
|
||
### ⚠️ O risco, dito na tela
|
||
Conectar por QR usa a porta do WhatsApp Web por engenharia reversa: está **fora dos Termos do
|
||
WhatsApp** e a Meta **pode banir o número** sem aviso. A tela de pareamento diz isso em texto e
|
||
recomenda **chip dedicado**, não o número principal da operação.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260824000001_create_notificacao_contatos.rb (NOVO)
|
||
db/migrate/20260824000002_create_notificacao_eventos.rb (NOVO — semeia os 2 eventos atuais)
|
||
db/migrate/20260824000003_create_notificacao_envios.rb (NOVO)
|
||
db/migrate/20260824000004_add_baileys_to_configuracao_...rb (NOVO)
|
||
whatsapp/{server.js,package.json,Dockerfile} (NOVO — ponte Baileys)
|
||
docker-compose.yml (serviço whatsapp + volume)
|
||
app/models/{grupo_contato,contato,evento_notificacao}.rb (NOVO)
|
||
app/models/{grupo_evento_assinatura,notificacao_envio}.rb (NOVO)
|
||
app/models/configuracao_notificacao.rb (provedor + credenciais Baileys)
|
||
app/services/notificacao/{cliente_whatsapp,whatsapp,despachante}.rb (NOVO)
|
||
app/services/notificacao_service.rb (grupos + provedor + log)
|
||
app/jobs/notificacao_job.rb (NOVO)
|
||
app/mailers/notificacao_mailer.rb + view (NOVO — e-mail genérico)
|
||
app/controllers/admin/{grupos_contato,contatos,eventos_notificacao}_controller.rb (NOVO)
|
||
app/controllers/admin/{whatsapp_sessoes,notificacao_envios}_controller.rb (NOVO)
|
||
app/policies/{contato,grupo_contato,evento_notificacao,notificacao_envio,whatsapp_sessao}_policy.rb (NOVO)
|
||
app/views/admin/{grupos_contato,contatos,eventos_notificacao,whatsapp_sessoes,notificacao_envios}/ (NOVO)
|
||
app/views/layouts/_navbar.html.erb (seção Notificações)
|
||
config/routes.rb + .env.example
|
||
spec/{models,services}/… (NOVO)
|
||
```
|
||
|
||
> **4 migrations e 1 container novo.** Nenhuma gem nova no Gemfile.
|
||
|
||
### ⏳ Pendente — roteiro
|
||
```bash
|
||
# 1. Gerar o token da ponte e colocar no .env do servidor:
|
||
openssl rand -hex 32 # -> WHATSAPP_TOKEN=...
|
||
# e BAILEYS_URL=http://whatsapp:3001
|
||
|
||
# 2. Subir (a 1ª vez baixa o Baileys; leva alguns minutos):
|
||
docker compose up -d --build
|
||
|
||
# ⚠️ Se a build da ponte falhar com `npm error syscall spawn git / ENOENT`:
|
||
# falta `git` na imagem. O Baileys puxa `libsignal` de um repositório GIT,
|
||
# não do registry do npm. Já está no whatsapp/Dockerfile (`apk add git`) —
|
||
# se sumir de lá, é isto. NÃO é erro de rede nem de versão do pacote.
|
||
|
||
# 3. Migrar:
|
||
docker compose exec app bin/rails db:migrate
|
||
|
||
# 4. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local):
|
||
docker compose exec app bundle exec rspec \
|
||
spec/models/contato_spec.rb spec/models/evento_notificacao_spec.rb \
|
||
spec/services/notificacao/
|
||
|
||
# 5. Parear: Notificações → WhatsApp → ler o QR com o CHIP DEDICADO.
|
||
# Depois: Configurações → Notificações → provedor = Baileys, e enviar um teste.
|
||
|
||
# 6. Cadastrar um grupo, um contato e marcar o grupo nos 2 eventos de sistema.
|
||
# Conferir o resultado em Notificações → Envios.
|
||
```
|
||
|
||
### 🚧 O que NÃO está aqui (etapas 2 e 3)
|
||
- **Editor de blocos** (arrastar cabeçalho / tabela de valores / aviso / botão / rodapé, com preview
|
||
e HTML montado no e-mail). Hoje o texto dos 2 eventos de sistema ainda é o do código, e o disparo
|
||
manual usa um campo de texto.
|
||
- **Gatilhos novos**: `valor_alterado`, `operacao_alterada` e `agendado` já existem como opção no
|
||
cadastro e o `Despachante` os atende — mas **ainda não há código chamando** esses gatilhos, nem o
|
||
job de varredura do agendado. Um evento com esses gatilhos hoje só dispara pelo botão manual.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🧱 Editor de blocos das mensagens — ETAPA 2 (24/08/2026)</strong></summary>
|
||
|
||
> ✅ **STATUS: NO AR no ambiente de teste (24/08/2026).** Migration aplicada; a tela de eventos já
|
||
> mostra o ✓ dos canais com mensagem montada.
|
||
>
|
||
> ⚠️ **Não validado ainda**: o arrastar/soltar e o preview com dado real, na mão, no navegador — e a
|
||
> suíte. Sintaxe conferida em todos os `.rb`, `.erb` e **no JavaScript do editor** (`node --check`
|
||
> sobre o `<script>` extraído).
|
||
|
||
### 🎯 O que mudou
|
||
Na etapa 1, o corpo das mensagens ainda era string interpolada em Ruby — mudar uma palavra exigia
|
||
deploy. Agora o ADM monta a mensagem **arrastando blocos**, em
|
||
`Notificações → Eventos → WhatsApp / E-mail`.
|
||
|
||
**Blocos:** cabeçalho, texto, tabela de valores, aviso de mudança, botão/link, divisor, rodapé.
|
||
Arraste da paleta (ou clique), reordene pela alça, remova no ✕.
|
||
|
||
**Variáveis:** clique num campo e depois num chip — `{{contato}}`, `{{valor}}`, `{{entregas}}`,
|
||
`{{o_que_mudou}}`… A lista muda conforme o **gatilho** do evento (`Notificacao::Variaveis`).
|
||
|
||
**Um template por evento E por canal:** o mesmo evento tem uma mensagem de WhatsApp e outra de
|
||
e-mail, montadas separadamente. O ✓ na lista de eventos diz quais já estão montadas.
|
||
|
||
### ⚙️ Pontos não-óbvios
|
||
|
||
**O preview roda no servidor.** O botão chama `POST …/template/:canal/preview`, que instancia o
|
||
mesmo `Notificacao::Renderizador` do envio. Uma segunda implementação em JavaScript ficaria mais
|
||
rápida e **inevitavelmente divergiria do que é enviado** — e o preview existe justamente para
|
||
prometer o contrário. O preview do e-mail é exibido num `<iframe sandbox>` para os estilos do
|
||
e-mail não vazarem para o admin.
|
||
|
||
**Duas saídas do MESMO template.** `#texto` produz o WhatsApp (com `*negrito*` e `•` nas tabelas);
|
||
`#html` produz o e-mail com **estilo inline**, porque cliente de e-mail não lê CSS externo. Não usei
|
||
`simple_format` no e-mail: ele gera HTML sem os estilos que o Outlook/Gmail precisam.
|
||
|
||
**Escape em tudo, sempre.** Todo texto do editor e todo valor de variável passa por
|
||
`ERB::Util.html_escape` no caminho HTML. O corpo é digitado numa tela e o preview usa o mesmo
|
||
renderizador — um escape faltando atingiria **primeiro o próprio admin**. Botão só aceita `http(s)`:
|
||
um `javascript:` no href seria clique armado dentro do e-mail. Tem spec para os dois.
|
||
|
||
**Blocos são sanitizados na gravação.** O estado do editor viaja num campo hidden preenchido por
|
||
JavaScript — ou seja, vem de fora. `MensagemTemplate#normalizar_blocos` descarta tipo fora do
|
||
catálogo, campo que aquele tipo não tem e o que não for Hash, e limita a 40 blocos / 20 linhas por
|
||
tabela.
|
||
|
||
**A renderização acontece por destinatário.** `{{contato}}` é o primeiro nome de quem recebe — um
|
||
render compartilhado mandaria o nome da primeira pessoa para todo mundo. Renderizar é manipulação
|
||
de string; o custo por destinatário é irrelevante perto do envio.
|
||
|
||
**Fallback preservado.** Sem template montado (ou com template ativo porém **vazio**), o evento
|
||
continua usando o texto padrão do código. Sem isso, ligar o editor apagaria as notificações que já
|
||
funcionavam.
|
||
|
||
**Amostra ≠ real.** `Variaveis.amostra` só alimenta o preview; `Variaveis.comuns_reais` é o que
|
||
entra num envio de verdade. Trocar os dois colocaria uma data fixa dentro da mensagem que o contato
|
||
recebe — foi um bug que existiu por alguns minutos no disparo manual e está fixado aqui.
|
||
|
||
**`jsonb`, não tabela filha.** A ordem faz parte do dado (é lista, não conjunto), cada tipo de bloco
|
||
tem campos diferentes, e salvar o template inteiro numa transação evita estado meio-salvo.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260824000005_create_mensagem_templates.rb (NOVO)
|
||
app/models/mensagem_template.rb (NOVO — sanitização dos blocos)
|
||
app/models/evento_notificacao.rb (#template, #template_utilizavel)
|
||
app/services/notificacao/blocos.rb (NOVO — catálogo, fonte única)
|
||
app/services/notificacao/variaveis.rb (NOVO — por gatilho + amostra)
|
||
app/services/notificacao/renderizador.rb (NOVO — texto + html)
|
||
app/services/notificacao/despachante.rb (render por canal e por destinatário)
|
||
app/services/notificacao_service.rb (passa as variáveis do fechamento)
|
||
app/jobs/notificacao_job.rb (carrega os dados)
|
||
app/mailers/notificacao_mailer.rb + view (corpo em HTML x texto)
|
||
app/controllers/admin/mensagem_templates_controller.rb (NOVO — edit/update/preview)
|
||
app/views/admin/mensagem_templates/edit.html.erb (NOVO — editor + SortableJS)
|
||
app/views/admin/eventos_notificacao/index.html.erb (links por canal + ✓)
|
||
config/routes.rb
|
||
spec/models/mensagem_template_spec.rb (NOVO)
|
||
spec/services/notificacao/renderizador_spec.rb (NOVO)
|
||
spec/services/notificacao/despachante_spec.rb (+ template)
|
||
```
|
||
|
||
> **1 migration.** Nenhuma gem nova; o SortableJS vem de CDN, como flatpickr/Chart.js/Leaflet.
|
||
|
||
### ⏳ Pendente
|
||
```bash
|
||
docker compose exec app bin/rails db:migrate
|
||
docker compose exec app bundle exec rspec spec/services/notificacao/ spec/models/mensagem_template_spec.rb
|
||
|
||
# Depois: Notificações → Eventos → "WhatsApp" num evento → arrastar blocos →
|
||
# "Atualizar preview" → Salvar. Conferir o corpo real em Notificações → Envios.
|
||
```
|
||
|
||
### 🚧 O que falta (etapa 3)
|
||
`valor_alterado`, `operacao_alterada` e `agendado` existem como opção de gatilho, o editor já
|
||
oferece as variáveis certas para cada um e o `Despachante` os atende — mas **nada no código chama
|
||
esses gatilhos ainda**, nem existe o job que varre os agendados vencidos. Um evento com esses
|
||
gatilhos hoje só dispara pelo botão manual.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔔 Gatilhos: valor alterado, operação mudou e resumo agendado — ETAPA 3 (24/08/2026)</strong></summary>
|
||
|
||
> ✅ **STATUS: migrations no ar (24/08/2026).**
|
||
>
|
||
> ⚠️ **Não validado ainda**: um disparo real de cada gatilho, o `whenever --update-crontab` e a
|
||
> suíte. Sintaxe conferida em todos os `.rb`, `.rake` e `.erb`.
|
||
|
||
### 🎯 O que faltava
|
||
Nas etapas 1 e 2, `valor_alterado`, `operacao_alterada` e `agendado` existiam como opção de gatilho
|
||
e o editor já oferecia as variáveis certas de cada um — mas **nada no código os chamava**. Um evento
|
||
com esses gatilhos só disparava pelo botão manual.
|
||
|
||
### 🆕 Os três gatilhos, ligados
|
||
|
||
**`valor_alterado`** → `Consolidacao#recalcular_motorista!`. É o **funil único**: todo caminho que
|
||
mexe em pilar, desconto ou lançamento termina ali. Só dispara com a consolidação **finalizada** — em
|
||
rascunho o valor muda a cada clique do wizard, e avisar ali seria spam, não informação. Também só
|
||
dispara se o valor **realmente mudou**. `{{o_que_mudou}}` sai como
|
||
"Valor passou de R$ 4.900,00 para R$ 5.060,00". O próprio motorista entra como *envolvido*, sob o
|
||
`notificar_envolvido` do evento — dá para desligar sem perder o aviso à diretoria.
|
||
|
||
**`operacao_alterada`** → dois caminhos, de propósito:
|
||
- **Imediato**: `Admin::EdicaoLancamentosController#atualizar`. Sabe exatamente qual NF e o que
|
||
mudou (`status: pending → completed`).
|
||
- **Varredura**: `DetectarMudancasOperacaoJob` compara os números de cada operação com o retrato
|
||
anterior (`operacao_snapshots`) e avisa "3 notas na operação a mais, 1 em aberto a menos".
|
||
|
||
Por que os dois: a **maioria** das mudanças da operação não passa pelo nosso código — acontece no
|
||
SimpliRoute e chega pelo sync do espelho, que é read-only aqui. Só o hook interno cobriria a
|
||
minoria dos casos.
|
||
|
||
**`agendado`** → `DispararEventosAgendadosJob`, de hora em hora pelo cron. Os números vêm de
|
||
`Analytics::ResumoOperacao`, que **reusa** `OperacaoMetricas` e `NotasForaOperacao` — o resumo que
|
||
chega no WhatsApp não pode dizer coisa diferente do dashboard.
|
||
|
||
### ⚙️ Pontos não-óbvios
|
||
|
||
**Varredura horária, não uma linha de cron por evento.** Hora e frequência são escolhidas na TELA e
|
||
mudam a qualquer momento; reler o crontab a cada edição acoplaria a UI ao cron do sistema.
|
||
`EventoNotificacao#vencido?` + `ultimo_disparo_em` garantem **um disparo por dia**.
|
||
|
||
**Janelas FECHADAS.** Diária = o dia anterior; semanal = os 7 dias anteriores. Um resumo sobre "hoje"
|
||
mudaria de número depois de enviado.
|
||
|
||
**Período sem movimento não vira mensagem** — mas marca `ultimo_disparo_em` assim mesmo, senão a
|
||
varredura tentaria de hora em hora até o dia virar. Resumo zerado num feriado só treina o leitor a
|
||
ignorar a mensagem.
|
||
|
||
**A primeira varredura de cada operação só grava o retrato.** Sem retrato anterior, toda operação
|
||
existente pareceria "nova" e o primeiro deploy dispararia uma mensagem por operação cadastrada.
|
||
|
||
**Os eventos nascem ativos e isso é seguro.** A migration cria "Valor alterado no fechamento" e
|
||
"Dados da operação mudaram" como eventos de sistema — sem grupo assinante não há destinatário, então
|
||
nada sai até o ADM marcar um grupo. Sem esse seed, `disparar(chave: 'valor_alterado')` não acharia
|
||
evento nenhum e o gatilho ficaria mudo.
|
||
|
||
**Nada nos gatilhos levanta.** Uma notificação não pode derrubar um fechamento nem uma edição de
|
||
lançamento — `Notificacao::Gatilhos` engole e loga, como o resto do módulo.
|
||
|
||
**Custo conhecido da varredura:** `OperacaoMetricas` carrega as visitas em memória para deduplicar
|
||
por NF, e o job varre todas as tabelas 8×/dia. É o preço de reusar a contagem do dashboard em vez de
|
||
escrever um SQL paralelo que divergiria dele. Se incomodar, o caminho é restringir às operações do
|
||
mês corrente e do anterior — as antigas não mudam mais. Está anotado no código.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260824000006_create_operacao_snapshots.rb (NOVO)
|
||
db/migrate/20260824000007_semear_eventos_de_gatilho.rb (NOVO — idempotente)
|
||
app/models/operacao_snapshot.rb (NOVO — retrato + diferença legível)
|
||
app/models/consolidacao.rb (hook em recalcular_motorista!)
|
||
app/services/notificacao/gatilhos.rb (NOVO — os 3 gatilhos)
|
||
app/services/analytics/resumo_operacao.rb (NOVO — reusa as métricas do dashboard)
|
||
app/jobs/disparar_eventos_agendados_job.rb (NOVO)
|
||
app/jobs/detectar_mudancas_operacao_job.rb (NOVO)
|
||
app/controllers/admin/edicao_lancamentos_controller.rb (dispara operacao_alterada)
|
||
app/services/notificacao/despachante.rb (dono do ultimo_disparo_em saiu daqui)
|
||
lib/tasks/notificacao.rake + config/schedule.rb (cron)
|
||
spec/services/notificacao/gatilhos_spec.rb (NOVO)
|
||
spec/jobs/*_spec.rb (NOVO)
|
||
```
|
||
|
||
### ⏳ Pendente
|
||
```bash
|
||
docker compose exec app bin/rails db:migrate
|
||
# O cron do container já roda `whenever --update-crontab` no boot; num container
|
||
# em pé, forçar:
|
||
docker compose exec app bundle exec whenever --update-crontab
|
||
docker compose restart app
|
||
|
||
docker compose exec app bundle exec rspec spec/jobs spec/services/notificacao spec/models
|
||
|
||
# Teste manual: criar um evento "Resumo diário" (gatilho Agendado, hora = agora),
|
||
# marcar um grupo, e rodar à mão:
|
||
docker compose exec app bundle exec rake notificacao:agendados
|
||
docker compose exec app bundle exec rake notificacao:mudancas_operacao
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🛠️ Deploy das notificações: os 3 tropeços e as correções (24/08/2026)</strong></summary>
|
||
|
||
Registro do que quebrou entre "implementado" e "no ar", porque os três são armadilhas que voltam.
|
||
|
||
### 1. `PG::UndefinedTable: relation "grupo_contatos" does not exist`
|
||
`t.references :grupo_contato, foreign_key: true` faz o Rails **pluralizar** o nome para achar a
|
||
tabela alvo. O Inflector não fala português: `grupo_contato` → `grupo_contatos` e
|
||
`evento_notificacao` → `evento_notificacaos`. As tabelas reais são `grupos_contato` e
|
||
`eventos_notificacao` (plural no **primeiro** termo).
|
||
|
||
**Correção:** `foreign_key: { to_table: :grupos_contato }` nas 5 referências afetadas. `user` e
|
||
`contato` pluralizam certo e não precisaram.
|
||
|
||
> É a MESMA armadilha que já tinha aparecido nas rotas (`grupos`/`eventos`/`envios` em vez dos nomes
|
||
> dos controllers). Sempre que uma tabela em português tiver o plural no primeiro termo, `to_table:`
|
||
> e nome de rota explícito são obrigatórios.
|
||
|
||
### 2. `npm error syscall spawn git / ENOENT` na build da ponte
|
||
`errno -2` = o **binário** `git` não existe na imagem. O `node:20-alpine` não traz git, e o Baileys
|
||
resolve `libsignal` de um **repositório git**, não do registry do npm.
|
||
|
||
**Correção:** `apk add --no-cache git` no `whatsapp/Dockerfile`. Não é erro de rede, de firewall nem
|
||
de versão do pacote — e a linha do `apk` tem comentário explicando, para não ser "limpa" depois.
|
||
|
||
### 3. Não havia como escolher o provedor
|
||
As colunas `whatsapp_provedor`, `baileys_url`, `baileys_token` e `whatsapp_intervalo_segundos`
|
||
nasceram na migration, mas a tela **Configurações → Notificações** continuou 100% Twilio. Como o
|
||
padrão da coluna é `twilio`, o caminho do QR ficava **inalcançável pela interface** — dava para
|
||
parear e nada usaria a ponte. Pior: a tela do QR linkava para lá dizendo que o intervalo se
|
||
configurava ali, o que era falso.
|
||
|
||
**Correção:** seletor de Provedor, campo de intervalo e bloco "Modo QR code" (endereço + token, em
|
||
branco caem no `.env`) na tela de configuração; e `Notificacao::TesteWhatsapp` passou a ramificar
|
||
pelo provedor em vez de ir direto ao Twilio.
|
||
|
||
### 4. Evento criado pelo ADM não disparava
|
||
`Despachante.disparar(chave:)` buscava **um** evento pela chave. Como o código de negócio chama com
|
||
a chave fixa (`'consolidacao_finalizada'`…), só o evento de **sistema** disparava: um evento criado
|
||
na tela com o mesmo gatilho ficava mudo para sempre, **sem erro nenhum**. A tela oferecia o gatilho
|
||
e não acontecia nada.
|
||
|
||
**Correção:** `Despachante.disparar_gatilho(gatilho:)` dispara **todos** os eventos ativos daquele
|
||
gatilho, cada um com seus grupos e sua mensagem — que é justamente o ponto de poder cadastrar
|
||
eventos. O `disparar(chave:)` continua existindo para o botão "disparar agora", que mira um evento
|
||
específico.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>💡 Ideias para o editor de mensagens — backlog priorizado</strong></summary>
|
||
|
||
Levantado com a tela já em uso. Ordenado por **valor ÷ esforço**; nada aqui está implementado.
|
||
|
||
### A. Chegar até o editor
|
||
|
||
O problema, olhando a tela de eventos hoje: os acessos são **dois links de texto cinza**
|
||
("WhatsApp" e "E-mail") do lado de "Editar", com o mesmo peso visual. Nada diz que ali se
|
||
**escreve a mensagem** — parece mais um filtro de canal. E o ✓ de "já tem mensagem montada" é
|
||
pequeno demais para ser lido de relance.
|
||
|
||
| # | Ideia | Esforço |
|
||
|---|---|---|
|
||
| A1 | **Botão "Montar mensagem"** com ícone, no lugar dos dois links soltos. Abre o editor com abas WhatsApp/E-mail **dentro** dele, em vez de duas portas separadas. | baixo |
|
||
| A2 | **Estado como badge colorido** — `WhatsApp ✓ · E-mail —` em verde/cinza, não link. Mostra num relance o que falta. | baixo |
|
||
| A3 | **Miniatura do preview no card do evento**: as 2 primeiras linhas da mensagem renderizada. Responde "o que esse evento manda?" sem entrar. | médio |
|
||
| A4 | **Passo seguinte explícito**: ao salvar um evento novo, redirecionar para o editor com "Agora monte a mensagem". Hoje você cria o evento e fica sem pista do que fazer. | baixo |
|
||
| A5 | **Item "Mensagens" no menu** — tabela evento × canal com o estado de cada um. Hoje só se chega pelo evento. | médio |
|
||
| A6 | **Alerta de evento mudo**: ativo, com grupo assinante e sem mensagem própria → avisar que vai usar o texto padrão do sistema. | baixo |
|
||
|
||
### B. Design das mensagens
|
||
|
||
Hoje: 7 blocos, cores fixas no renderizador, só texto.
|
||
|
||
| # | Ideia | Esforço | Observação |
|
||
|---|---|---|---|
|
||
| B1 | **Ocultar bloco quando a variável estiver vazia** — checkbox por bloco. Resolve o "⚠️ Alterações: " sem nada depois, que hoje aparece. | baixo | O ganho de qualidade mais barato da lista. |
|
||
| B2 | **Enviar teste direto do editor** — "mandar para o meu WhatsApp" sem sair da tela. Hoje: salvar → eventos → disparo manual. | baixo | |
|
||
| B3 | **Modelos prontos** ("Aviso de fechamento", "Resumo diário", "Alteração de valor") — começar de um layout em vez da folha em branco. | médio | |
|
||
| B4 | **Duplicar template** entre canais e entre eventos ("copiar do WhatsApp para o e-mail"). | baixo | |
|
||
| B5 | **Cor de destaque configurável** — hoje o laranja está fixo no `Renderizador`. | baixo | |
|
||
| B6 | **Logo no cabeçalho do e-mail** | médio | Precisa de URL absoluta pública ou embed em base64; cliente de e-mail bloqueia imagem remota por padrão. |
|
||
| B7 | **Bloco de lista** (bullets) — hoje só existe tabela rótulo/valor. | baixo | |
|
||
| B8 | **Preview em moldura de celular** para o WhatsApp e alternância desktop/mobile no e-mail. | médio | Hoje o preview do WhatsApp é um `<pre>` escuro. |
|
||
| B9 | **Contador de caracteres** no WhatsApp — mensagem longa vira "ler mais" e o começo é o que decide se abrem. | baixo | |
|
||
| B10 | **Bloco de imagem** | **alto** | No e-mail é `<img>`. No WhatsApp exige **enviar mídia**, e a ponte hoje só faz `sendMessage` de texto — mexe no `server.js`, no cliente Ruby e no log de envios. |
|
||
| B11 | **Versões do template** — voltar à anterior depois de estragar. | médio | |
|
||
|
||
### Se fosse escolher três
|
||
**A1 + A2** (o editor deixa de ser escondido) e **B1** (mensagem sem buraco quando a variável vem
|
||
vazia). Juntos são baixo esforço e resolvem o que mais incomoda no uso diário.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>👥 Envio para GRUPO do WhatsApp (24/08/2026)</strong></summary>
|
||
|
||
> ⚠️ **STATUS: implementado, não executado.** 1 migration nova. Sintaxe conferida em `.rb`, `.erb`,
|
||
> no JavaScript do formulário e no `server.js`.
|
||
|
||
### 🎯 O caso de uso
|
||
Informe pontual para o **grupo de motoristas** da própria empresa, com o número já dentro do grupo.
|
||
É o oposto do padrão de spam — não é mensagem para desconhecido, é aviso interno.
|
||
|
||
### ⚙️ Por que não funcionava
|
||
Grupo no WhatsApp **não é telefone**: é um identificador próprio (`120363012345678901@g.us`). Três
|
||
pontos assumiam pessoa:
|
||
|
||
1. `jidDe()` no `server.js` colava `@s.whatsapp.net` nos dígitos — id de grupo nem passava.
|
||
2. A checagem `onWhatsApp()` (que evita mandar para número inexistente, um dos sinais que a Meta usa
|
||
para marcar conta de spam) **responde sobre números** e devolveria vazio para um grupo,
|
||
derrubando um envio válido.
|
||
3. `Contato` exigia telefone brasileiro válido.
|
||
|
||
### 🆕 Como ficou
|
||
**Contato ganhou tipo**: `pessoa` ou `grupo_whatsapp`. O grupo entra como Contato de propósito —
|
||
herda grupo interno, assinatura de eventos e log de envios. Tabela separada duplicaria essa máquina
|
||
inteira.
|
||
|
||
**A lista de grupos vem da ponte** (`GET /grupos` → `groupFetchAllParticipating`). O id é opaco;
|
||
digitar na mão é pedir erro. No formulário, "Buscar grupos" traz os nomes com a contagem de
|
||
participantes.
|
||
|
||
**Grupo restrito é avisado ANTES.** Se o grupo tem "somente administradores" e o número conectado
|
||
não é admin, a opção aparece com ⚠️ na lista — senão o envio falharia e o erro só apareceria depois,
|
||
no log.
|
||
|
||
**O nome do grupo é gravado junto com o id** (`whatsapp_grupo_nome`): a tela precisa mostrar algo
|
||
legível mesmo com a ponte fora do ar.
|
||
|
||
**Twilio recusa com mensagem clara.** O Twilio não envia para grupo; em vez de erro genérico, a
|
||
resposta diz para trocar o provedor para QR.
|
||
|
||
### ⚠️ Limites, ditos na tela
|
||
- O número **precisa já ser membro** do grupo. A ponte não entra em grupo.
|
||
- Grupo com "somente admins" recusa se o número não for admin.
|
||
- `{{contato}}` num grupo é o **nome inteiro** do grupo — cortar "Motoristas SP" em "Motoristas"
|
||
só empobreceria o texto.
|
||
- Cadastro não fica meio pessoa, meio grupo: ao escolher grupo, telefone e e-mail são limpos.
|
||
Senão a mesma mensagem sairia duas vezes.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb (NOVO)
|
||
whatsapp/server.js (jidDe aceita @g.us; GET /grupos; pula onWhatsApp em grupo)
|
||
app/models/contato.rb (tipo, destino_whatsapp, validações por tipo)
|
||
app/services/notificacao/whatsapp.rb (JID passa direto; Twilio recusa)
|
||
app/services/notificacao/cliente_whatsapp.rb (#grupos)
|
||
app/services/notificacao/despachante.rb (usa destino_whatsapp)
|
||
app/controllers/admin/contatos_controller.rb + rota (JSON de grupos, sob demanda)
|
||
app/views/admin/contatos/_form.html.erb (seletor de tipo + busca de grupos)
|
||
app/views/admin/contatos/index.html.erb (marca "grupo")
|
||
spec/models/contato_spec.rb, spec/services/notificacao/whatsapp_spec.rb
|
||
```
|
||
|
||
### ⏳ Pendente
|
||
```bash
|
||
docker compose up -d --build whatsapp # o server.js mudou
|
||
docker compose exec app bin/rails db:migrate
|
||
|
||
# Depois: Notificações → Contatos → Novo contato → Tipo "Grupo do WhatsApp"
|
||
# → "Buscar grupos" → escolher → pôr num grupo interno que assine o evento.
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🎨 Ponte desatualizada, nome do Baileys fora da tela e padronização visual (25/08/2026)</strong></summary>
|
||
|
||
> ⚠️ **STATUS: implementado, não executado.** Sem migration. Sintaxe conferida nos 16 `.erb`
|
||
> tocados e nos 2 `.rb`. **Testes não rodados** — não há bundler na máquina de desenvolvimento,
|
||
> só no container.
|
||
|
||
### 1. `rota desconhecida` ao buscar os grupos
|
||
|
||
O botão "Buscar grupos" do cadastro de contato respondia **`rota desconhecida`**. Não era bug do
|
||
Rails: é o 404 da própria ponte (`whatsapp/server.js`, fallback de rota inexistente) repassado cru
|
||
para a tela.
|
||
|
||
A rota `GET /grupos` existe no código desde o commit `568f185`, mas **o container `whatsapp` do
|
||
ambiente de teste rodava uma imagem construída antes dele**. O deploy subiu o `app` e reaproveitou
|
||
a imagem em cache da ponte.
|
||
|
||
**Correção no servidor:**
|
||
```bash
|
||
docker compose up -d --build whatsapp
|
||
# se o cache ainda entregar a imagem velha:
|
||
docker compose build --no-cache whatsapp && docker compose up -d whatsapp
|
||
```
|
||
O volume `whatsapp_auth` é preservado — **não precisa ler o QR de novo**.
|
||
|
||
> 🔁 **A armadilha que volta:** toda vez que `whatsapp/server.js` ganha rota nova, a ponte precisa
|
||
> de rebuild próprio. `docker compose up -d --build` sem nomear o serviço pode reaproveitar a
|
||
> camada em cache, e o sintoma é sempre este: a tela nova chama uma rota que a ponte em pé não
|
||
> conhece. Vale conferir também que o `whatsapp/server.js` foi junto no merge para a `main` — o
|
||
> recurso de grupos ainda **não está lá**.
|
||
|
||
**Correção no código:** o 404 da ponte deixou de aparecer cru. `ClienteWhatsapp#mensagem_de_erro`
|
||
agora traduz para *"Ponte do WhatsApp desatualizada (não conhece esta rota) — refaça o build do
|
||
container `whatsapp`"*. É cosmético e **não substitui o rebuild**; serve para a próxima vez o erro
|
||
dizer o que fazer.
|
||
|
||
### 2. O nome "Baileys" saiu da interface
|
||
|
||
Nenhum texto que o usuário lê cita mais a biblioteca. A env var mostrada na tela de configuração
|
||
virou **`WHATSAPP_URL`**, e `baileys_url_efetiva` lê nesta ordem:
|
||
|
||
```
|
||
banco → ENV['WHATSAPP_URL'] → ENV['BAILEYS_URL'] → 'http://whatsapp:3001'
|
||
```
|
||
|
||
O nome antigo continua sendo lido de propósito: **nenhum `.env` já em produção quebra**.
|
||
|
||
> **Ficou de fora, de propósito:** as colunas `baileys_url` / `baileys_token` e os métodos
|
||
> `baileys?`, `baileys_pronto?`, `BAILEYS_URL_PADRAO` mantêm o nome no banco e no código.
|
||
> Renomear exigiria migration + model + controller + service + specs, e **nada disso aparece na
|
||
> tela** — os rótulos que o usuário lê já eram "Endereço da ponte" e "Token da ponte". O valor
|
||
> `'baileys'` do seletor de provedor também continua: é chave interna, o rótulo visível é
|
||
> "QR code (grátis, não oficial)".
|
||
|
||
### 3. Padronização visual das telas de notificação
|
||
|
||
As 9 telas do módulo nasceram com um dialeto próprio e destoavam do resto do admin. O padrão de
|
||
referência é **Usuários** — a tela mais madura. O que mudou:
|
||
|
||
| Antes (telas de notificação) | Agora (padrão do admin) |
|
||
|---|---|
|
||
| Botão `bg-orange-500 text-black`, `py-2.5` | `bg-[#f97316] text-white` com ícone, `min-h-[48px]` |
|
||
| `thead` com `bg-white/5`, `py-3`, `font-medium` | `border-b border-white/10`, `py-4`, `font-semibold` |
|
||
| Ações "Editar"/"Excluir" em texto | Ícones com `opacity-0 group-hover:opacity-100` |
|
||
| Vazio: `<p>` solto fora da tabela | Linha na tabela, ícone `:vazio`, `py-16` |
|
||
| Situação em texto colorido | `badge_status_usuario` / `badge_com_icone` |
|
||
| Inputs `bg-[#1a1a1a]`, `py-2.5` | `bg-[#0a0a0a]`, `py-3`, `focus:ring` laranja |
|
||
| Checkbox de ativo/inativo | Toggle switch, igual ao de Usuários |
|
||
| Formulário solto na página | Card `rounded-2xl border` com seções divididas |
|
||
|
||
**Correções de comportamento que vieram junto:**
|
||
|
||
- **`render 'shared/flash'` nas telas que não tinham.** Mensagens de sucesso e erro simplesmente
|
||
**não apareciam** em Contatos, Grupos, Eventos e Envios — a ação dava certo e a tela ficava muda.
|
||
- **O aviso do "Buscar grupos" ganhou cor semântica** — verde no sucesso, vermelho no erro. Antes
|
||
toda resposta saía em cinza, então "5 grupos encontrados" e "não foi possível falar com a ponte"
|
||
tinham exatamente o mesmo peso visual.
|
||
- O rótulo do botão de busca virou um `<span>` próprio: trocar o `textContent` do botão inteiro
|
||
apagava o ícone junto.
|
||
|
||
**Ganhos de leitura:** avatar distingue pessoa de grupo em Contatos; os filtros de Envios ficaram
|
||
agrupados por "Situação" e "Canal" com pílulas; o ponto verde de "Conectado" pulsa; evento
|
||
desativado fica esmaecido na lista.
|
||
|
||
### 📂 Arquivos
|
||
```
|
||
app/models/configuracao_notificacao.rb (WHATSAPP_URL com fallback p/ BAILEYS_URL)
|
||
app/services/notificacao/cliente_whatsapp.rb (404 da ponte vira mensagem acionável)
|
||
.env.example (WHATSAPP_URL; nome antigo documentado)
|
||
app/views/admin/configuracao_notificacoes/show.html.erb (nome da lib fora da tela)
|
||
app/views/admin/contatos/{index,_form,new,edit}.html.erb
|
||
app/views/admin/grupos_contato/{index,_form,new,edit}.html.erb
|
||
app/views/admin/eventos_notificacao/{index,_form,new,edit}.html.erb
|
||
app/views/admin/notificacao_envios/index.html.erb
|
||
app/views/admin/whatsapp_sessoes/show.html.erb
|
||
app/views/admin/mensagem_templates/edit.html.erb
|
||
```
|
||
|
||
### ⏳ Pendente
|
||
```bash
|
||
docker compose up -d --build whatsapp # ⚠️ o que resolve o "rota desconhecida"
|
||
docker compose restart app # views mudaram → cache de views do Puma
|
||
|
||
docker compose exec app bundle exec rspec
|
||
|
||
# Conferir na tela: Notificações → Contatos → Novo contato
|
||
# → Tipo "Grupo do WhatsApp" → "Buscar grupos" → a lista deve vir com nome,
|
||
# nº de participantes e ⚠️ nos grupos restritos a admin.
|
||
```
|
||
|
||
### 🐛 Achado não corrigido
|
||
`app/views/admin/mensagem_templates/edit.html.erb` carrega o **SortableJS de
|
||
`cdn.jsdelivr.net`**. Se a CSP passar de `report_only` para enforcing (pendência já registrada
|
||
neste README), o **arrastar-e-soltar dos blocos morre** — o clique na paleta sobrevive, porque o
|
||
código tem `if (window.Sortable)`. O conserto é baixar o arquivo para `public/`. Não foi feito por
|
||
estar fora do escopo pedido.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🧭 O que falta: gatilhos pela tela e blocos ricos na mensagem — backlog</strong></summary>
|
||
|
||
Complementa o backlog do editor já registrado acima. **Nada aqui está implementado.**
|
||
|
||
### A. Criação de gatilhos pela tela
|
||
|
||
**Como é hoje:** o ADM cria quantos **eventos** quiser, mas o **gatilho** sai de uma lista fixa de
|
||
seis — `manual`, `consolidacao_finalizada`, `pagamento_efetuado`, `valor_alterado`,
|
||
`operacao_alterada`, `agendado`. A tela diz isso ao usuário: *"o gatilho sai de uma lista fixa,
|
||
porque gatilho é código"*. E é verdade — cada gatilho tem uma chamada correspondente no código de
|
||
negócio (`Notificacao::Gatilhos`, `NotificacaoService`, controllers).
|
||
|
||
**O que "criar gatilho pela tela" realmente significa** — três níveis, do barato ao caro:
|
||
|
||
| # | Ideia | Esforço | Observação |
|
||
|---|---|---|---|
|
||
| A1 | **Gatilho agendado com filtro** — "todo dia 08h **se houver consolidação pendente**". Não é gatilho novo: é o `agendado` com uma condição escolhida numa lista. | baixo | Cobre boa parte do que se pede como "gatilho novo". |
|
||
| A2 | **Gatilho por limiar** — "quando o valor alterado passar de R$ X" ou "quando as notas fora da operação passarem de N". Campos: métrica (lista), operador, valor. | médio | Reusa `Analytics::ResumoOperacao` e `OperacaoSnapshot`, que já calculam as métricas. |
|
||
| A3 | **Gatilhos que faltam no domínio** — consolidação **arquivada**, contato **sem grupo** há X dias, envio **falhado** (avisar o ADM que o WhatsApp caiu). | médio | São gatilhos de código mesmo: uma constante + a chamada no ponto certo. O caminho já está pavimentado por `disparar_gatilho`. |
|
||
| A4 | **Condição livre por regra** (construtor de expressão na tela) | **alto** | Vira uma mini-linguagem: precisa de parser, sandbox e validação. **Não recomendado** — A1+A2 entregam quase tudo por uma fração do custo. |
|
||
|
||
> ⚠️ **A armadilha aqui:** gatilho cadastrado na tela que **não tem chamada no código** fica mudo
|
||
> para sempre, sem erro nenhum — exatamente o bug nº 4 do deploy de 24/08. Qualquer caminho
|
||
> escolhido precisa de uma trava: ou a lista continua vindo do código, ou a tela avisa que o
|
||
> gatilho ainda não está ligado.
|
||
|
||
### B. Mais opções na criação da mensagem
|
||
|
||
**Como é hoje:** 7 blocos (`cabecalho`, `texto`, `tabela`, `aviso`, `botao`, `divisor`, `rodape`),
|
||
todos **só texto**, com cores fixas no renderizador.
|
||
|
||
| # | Ideia | Esforço | Observação |
|
||
|---|---|---|---|
|
||
| B1 | ✅ **FEITO (26/08/2026)** — Seletor de emoji nos campos de texto. | baixo | Um picker pequeno, sem dependência externa (a CSP é restritiva). Inserir no cursor: a mecânica já existe, é a mesma dos chips de variável. |
|
||
| B2 | **Bloco de imagem no e-mail** — `<img>` com URL ou upload. | médio | Cliente de e-mail bloqueia imagem remota por padrão → precisa de URL absoluta pública ou base64. |
|
||
| B3 | **Bloco de imagem no WhatsApp** | **alto** | A ponte hoje só faz `sendMessage` de **texto**. Exige `sendMessage(jid, { image })` no `server.js`, endpoint novo, envio multipart no `ClienteWhatsapp`, storage do arquivo e uma coluna no log de envios. **É o item mais caro da lista** — vale só se a demanda for real. |
|
||
| B4 | **Formatação do WhatsApp** (`*negrito*`, `_itálico_`, `~riscado~`, ``` `mono` ```) com botões — hoje o ADM precisa saber a sintaxe de cor. | baixo | No e-mail cada marca vira a tag equivalente. |
|
||
| B5 | **Bloco de lista com bullets** — hoje só existe tabela rótulo/valor. | baixo | Já estava listado como B7 no backlog anterior. |
|
||
| B6 | **Ocultar bloco com variável vazia** — resolve o `"⚠️ Alterações: "` sem nada depois. | baixo | Já listado como B1 antes; **continua sendo o melhor valor ÷ esforço do módulo inteiro**. |
|
||
| B7 | **Cor de destaque configurável** — o laranja está fixo no `Renderizador`. | baixo | |
|
||
| B8 | **Contador de caracteres no WhatsApp** — mensagem longa vira "ler mais", e o começo é o que decide se abrem. | baixo | |
|
||
| B9 | **Bloco de menção** (`@membro`) em grupo do WhatsApp | médio | Baileys exige passar os JIDs em `mentions` além do texto — não basta escrever `@`. |
|
||
|
||
### Se fosse escolher três
|
||
**B6** (mensagem sem buraco quando a variável vem vazia), **B1** (emoji) e **B4** (formatação do
|
||
WhatsApp por botão). Os três são de esforço baixo e atacam o que mais se sente escrevendo mensagem
|
||
no dia a dia. **B3 (imagem no WhatsApp) fica por último** — é o único que mexe na ponte, no cliente
|
||
Ruby e no schema ao mesmo tempo.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🔐 Atualização 26/08/2026 — Perfis de acesso, variáveis de mensagem e correção de vazamento nos erros</strong></summary>
|
||
|
||
Três frentes numa rodada só: **quem vê o quê** (perfis), **o que dá para escrever numa mensagem**
|
||
(variáveis + emoji) e **o que o sistema mostrava a mais quando dava erro** (segurança).
|
||
|
||
---
|
||
|
||
## 1. Perfis de acesso — o ADM monta o acesso sem deploy
|
||
|
||
**Antes:** o acesso saía de `users.role` (admin/gerente/operador/motorista/externo) e de
|
||
`if admin? || gerente?` espalhado pelas policies. Tirar as Configurações de **um** gerente exigia
|
||
editar código e subir versão.
|
||
|
||
**Agora:** `Administração → Perfis de acesso`. O ADM cria perfis marcando caixas agrupadas por área
|
||
e atribui o perfil à pessoa em `Usuários`.
|
||
|
||
| Peça | Onde | O que faz |
|
||
|---|---|---|
|
||
| Catálogo de permissões | `app/models/permissao.rb` | **33 chaves** em 4 grupos (`dashboard`, `consolidacao`, `notificacao`, `admin`), cada uma com o **efeito real** escrito ("Baixar a planilha da operação — é dado saindo do sistema"). É código, não tabela: cada chave corresponde a um `authorize` de verdade. |
|
||
| Perfil | `app/models/perfil_acesso.rb` + `perfis_acesso` | Nome, descrição, lista de permissões (jsonb), `sistema` (não apagável) e `ativo`. |
|
||
| Ligação | `users.perfil_acesso_id` | **Nulo é válido**: sem perfil, vale o padrão do papel antigo (`Permissao::PADRAO_POR_ROLE`) — é o que faz ninguém perder acesso no dia do deploy. |
|
||
| Régua | `User#pode?('chave')` | Todas as 13 policies e a navbar passaram a perguntar por **permissão**, não por papel. |
|
||
|
||
**Tipo de conta ≠ perfil de acesso.** O select antigo se chamava "Perfil de acesso" mas editava
|
||
`role`. Agora são dois campos: **Tipo de conta** decide *como a pessoa entra* (motorista entra por
|
||
PIN e tem painel próprio); **Perfil de acesso** decide *o que ela enxerga*.
|
||
|
||
**Migração automática** (`20260826000001..3`): cria os perfis `Administrador`, `Gerente`,
|
||
`Operador` e `Externo` a partir do comportamento atual e atribui a cada usuário o perfil do papel
|
||
que ele já tinha.
|
||
|
||
### Três falhas de acesso corrigidas junto
|
||
|
||
| Falha | O que acontecia | Correção |
|
||
|---|---|---|
|
||
| **Auto-promoção a admin** | `UserPolicy#update?` libera editar a própria ficha e o form aceitava `role` → qualquer usuário logado virava admin editando o próprio cadastro. | `role` e `perfil_acesso_id` só entram nos strong params de quem tem `admin.usuarios_gerenciar`, e **nunca sobre si mesmo** (`UserPolicy#alterar_acesso?`). |
|
||
| **`externo` via o financeiro** | `DashboardController` usava `skip_authorization`: o papel "Externo (só dashboard)" enxergava custo, pagamento e o PDF financeiro. | Dashboard passou pelo Pundit; sem `dashboard.financeiro` os blocos de custo **nem são carregados**. |
|
||
| **Conta inativa logava** | Só o login por PIN checava `ativo?`; conta de e-mail/senha desativada entrava normalmente. | `User#active_for_authentication?` (Devise) + mensagem em pt-BR. |
|
||
|
||
### Duas travas que evitam tiro no pé
|
||
|
||
- **Último administrador** (`User.administradores_de_acesso`): não dá para salvar um perfil, trocar
|
||
alguém de perfil, desativar ou excluir usuário se isso deixar o sistema **sem ninguém ativo**
|
||
capaz de mexer em perfis e usuários. Sem isso, a volta seria só por console.
|
||
- **Tela `/sem-acesso`**: a raiz do app é o dashboard e o "acesso negado" mandava para a raiz — um
|
||
perfil sem `dashboard.ver` entraria em **loop** (nega → raiz → nega). Agora o destino é a primeira
|
||
tela que a pessoa pode abrir (`User#home_rota`), e quem não pode abrir nenhuma cai numa página que
|
||
explica a quem pedir.
|
||
|
||
---
|
||
|
||
## 2. Variáveis de mensagem — de 6 fixas para 31 + as suas
|
||
|
||
**Antes:** `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho. O ADM via 6 variáveis
|
||
e qualquer texto com número ("entregas do mês") exigia deploy.
|
||
|
||
**Agora:** `Notificacao::CatalogoVariaveis`, com quatro origens — e o editor mostra **todas**,
|
||
agrupadas:
|
||
|
||
| Origem | Exemplos | Observação |
|
||
|---|---|---|
|
||
| **Do que aconteceu** | `{{motorista}}`, `{{valor}}`, `{{consolidacao}}`, `{{nf}}` | Só têm valor no gatilho que as produz. As de outro gatilho aparecem **apagadas com "·"**, avisando que sairiam em branco — liberdade com aviso, em vez de lista curta. |
|
||
| **Empresa e data** | `{{empresa}}`, `{{data}}`, `{{hora}}`, `{{dia_semana}}`, `{{mes}}`, `{{mes_ano}}`, `{{link_sistema}}` | Sempre disponíveis. |
|
||
| **Números do sistema** | `{{entregas_mes}}`, `{{entregas_hoje}}`, `{{motoristas_ativos_mes}}`, `{{consolidacoes_abertas}}`, `{{total_a_pagar_mes}}`, `{{total_pago_mes}}`, `{{total_consolidado_mes}}` | **Consultam o banco na hora do envio.** É o que permite mandar relatório por WhatsApp/e-mail sem programação. |
|
||
| **Suas variáveis** | `{{telefone_suporte}}`, `{{horario_atendimento}}`… | Tela nova: `Notificações → Variáveis`. Valor fixo, mudou ali mudou em todas as mensagens. |
|
||
|
||
**Duas decisões que importam:**
|
||
|
||
1. **Resolução sob demanda** (`Notificacao::ResolvedorVariaveis`): só a variável realmente escrita no
|
||
template vira consulta. Resolver o catálogo inteiro em todo disparo faria dezenas de queries para
|
||
preencher variável que ninguém usou.
|
||
2. **O contexto do gatilho sempre vence** o catálogo — o `{{valor}}` daquele pagamento nunca é
|
||
trocado por um número genérico. E nenhuma variável derruba um disparo: falha vira texto vazio.
|
||
|
||
**Emoji** (item B1 do backlog abaixo — ✅ feito): paleta de 27 emojis no editor, insere no cursor
|
||
reusando a mecânica dos chips de variável. Sem dependência externa, por causa da CSP.
|
||
|
||
---
|
||
|
||
## 3. Segurança — o que a página de erro mostrava
|
||
|
||
O servidor sobe com `RAILS_ENV=development` (`docker-compose.yml`), e development tinha
|
||
`consider_all_requests_local = true`. A página **"Action Controller: Exception caught"** mostra
|
||
parâmetros, **sessão** (`session_id`, `_csrf_token`, id do usuário logado), cookies, IP do cliente,
|
||
caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocasse um erro.
|
||
|
||
| Problema | Correção |
|
||
|---|---|
|
||
| Painel de debug na tela do usuário | Detalhe só com `ERROS_DETALHADOS=true` no `.env` (máquina de desenvolvimento). No servidor, sai a página estática. |
|
||
| **Senha e PIN em texto puro no log** — não existia `filter_parameter_logging.rb` neste projeto | Criado, com nomes em português e inglês (`senha`, `pin_code`, `password`, `token`, `whatsapp_token`…). **Era o pior dos três**: log é permanente e vai junto em backup e em suporte. |
|
||
| Tela em branco ao desligar o painel | `public/500.html`, `404.html` e `422.html` em português, sem CSS externo (precisam funcionar com a aplicação fora do ar). |
|
||
| Páginas de debug salvas versionadas | `Erros/` saiu do git e entrou no `.gitignore`. Nos arquivos há `session_id`, token CSRF, id de usuário e IPs — **não há cookie de sessão assinado**, então ninguém entra no sistema com aquilo, mas não é conteúdo de repositório. |
|
||
|
||
> ⚠️ **Correção estrutural ainda pendente:** subir o servidor com `RAILS_ENV=production`
|
||
> (`production.rb` já tem `consider_all_requests_local = false` e `force_ssl = true`). Enquanto isso
|
||
> não acontece, o `ERROS_DETALHADOS` cobre o buraco.
|
||
|
||
---
|
||
|
||
## 4. Telas que mudaram nesta rodada
|
||
|
||
- **Dashboard de Operações**: no modo Operação o período **não recorta mais** os KPIs — eles voltam a
|
||
bater 1-para-1 com a planilha entregue ao cliente. Uma operação mensal executa entregas fora do mês
|
||
do nome (a `gade_entregas_emad_ago_2026` rodou de 30/07 a 12/08): filtrar "01/08 → hoje" mostrava
|
||
**1146 das 2021 NFs**. O período escolhido virou referência no cabeçalho.
|
||
- **Consolidações**: aba **"Por motorista"** na própria tela (não é tela nova), com o total de cada
|
||
um, drill-down inline mostrando as consolidações que compõem o valor, PDF completo/resumo/por
|
||
motorista, e o filtro **Consolidado (fechadas) × Geral (com rascunhos)**. O recorte de datas foi
|
||
unificado com o do dashboard financeiro (consolidações que **cruzam** o período + as pagas nele),
|
||
que era a origem de dois valores diferentes para o mesmo motorista.
|
||
- **Painel do motorista**: refeito. Período em chips (Este mês / Mês passado / 3 meses / Tudo), card
|
||
do valor **fechado** com "já recebido" e "a receber", PDF do período, e a composição do valor.
|
||
O card de estimativa **nunca aparece junto do fechado** (o estimado costuma ser maior e virava
|
||
cobrança); com o período já fechado, ele dá lugar à contagem de entregas, sem R$.
|
||
- **Listas longas** (`carrossel_controller.js`): 5 itens por página no celular, 10 até 1366px, lista
|
||
inteira acima disso. As setas flutuam nas laterais em tela com espaço e viram barra no rodapé no
|
||
celular, onde não há faixa lateral livre.
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
<details>
|
||
<summary><strong>🕐 Relógio, restart automático e migrations no boot</strong></summary>
|
||
|
||
## O problema relatado
|
||
|
||
> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas."
|
||
|
||
São **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar:
|
||
|
||
| Sintoma | Causa | Onde se resolve |
|
||
|---|---|---|
|
||
| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) |
|
||
| Hora **derivando / errada depois de reboot** | Relógio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) |
|
||
|
||
### 1. Fuso do container (o "sempre 3h à frente")
|
||
|
||
A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails até mostrava a hora certa
|
||
(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema
|
||
operacional continuava adiantado** — e o pior caso não era visual:
|
||
|
||
> **O cron rodava no horário errado.** `every "*/30 8-18"` no `config/schedule.rb`
|
||
> executava das **05h às 15h** de Brasília, não das 08h às 18h.
|
||
|
||
Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de
|
||
`/etc/localtime` — os dois, porque o cron do Debian lê o arquivo, não a
|
||
variável. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote
|
||
`tzdata`, que não vem na imagem). Dá para ajustar sem rebuild pelo `TZ` do `.env`.
|
||
|
||
### 2. Hora do host — NTP autenticado (NTS)
|
||
|
||
**Não dá para sincronizar o relógio de dentro do container**: ele lê o clock do
|
||
kernel do host. Rodar NTP lá dentro exigiria `CAP_SYS_TIME` e mudaria a hora do
|
||
servidor inteiro a partir de um processo da aplicação.
|
||
|
||
No servidor, uma vez:
|
||
|
||
```bash
|
||
sudo bash deploy/ntp-seguro.sh
|
||
```
|
||
|
||
O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves
|
||
é por TLS (TCP 4460) e os pacotes NTP vêm assinados, então uma resposta forjada
|
||
no caminho é descartada. NTP comum é UDP sem autenticação — e hora errada aqui
|
||
não é detalhe: ela muda **o dia da consolidação, o recorte do período financeiro
|
||
e a validade da sessão**.
|
||
|
||
O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB),
|
||
**desliga as fontes não autenticadas** (deixar o `pool` padrão anularia o ganho),
|
||
desativa o `systemd-timesyncd` (não fala NTS e brigaria pelo relógio), liga o
|
||
`rtcsync` (é o relógio de hardware que dá a hora no boot) e mostra a
|
||
conferência no fim. É idempotente e guarda `.bak` da config.
|
||
|
||
Conferir depois, a qualquer momento:
|
||
|
||
```bash
|
||
chronyc tracking # 'System time' deve ficar na casa dos milissegundos
|
||
chronyc -N authdata # NTS ativo em cada fonte (Cook > 0)
|
||
```
|
||
|
||
> **Firewall:** precisa de saída em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS
|
||
> não fecha e o chrony fica sem fonte.
|
||
|
||
## Restart automático + migrations
|
||
|
||
`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho
|
||
depois de queda do processo e de reboot do servidor, e só fica parado se alguém
|
||
der `docker compose stop/down`. (`always` foi descartado: ele reergue o container
|
||
até depois de um `stop` deliberado, tirando do operador a chance de deixar o
|
||
sistema fora do ar de propósito.)
|
||
|
||
O boot saiu do `command:` de uma linha só e virou **`bin/docker-boot`**, com o
|
||
motivo de cada passo comentado:
|
||
|
||
1. **relógio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild não pegou);
|
||
2. **pid órfão** — sem limpar, o Puma se recusa a subir depois de uma queda;
|
||
3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem);
|
||
4. **migrations** — `db:prepare`, **com espera pelo banco**;
|
||
5. **cron** — `whenever --update-crontab` + daemon, *best-effort*;
|
||
6. **Puma**.
|
||
|
||
Ou seja: **a migration é automática a todo restart** — `db:prepare` cria o banco
|
||
se não existir, aplica as migrations pendentes e só faz seed em banco novo
|
||
(idempotente). Não há passo manual depois do deploy.
|
||
|
||
> **Por que a espera no passo 4:** o PostgreSQL é externo ao compose. Num reboot
|
||
> do servidor o Rails pode subir antes de o banco aceitar conexão — e agora, com
|
||
> `restart: unless-stopped`, isso viraria um **loop de reinício** parecendo erro
|
||
> de migration. São 10 tentativas × 6s. Se falhar depois disso (migration
|
||
> quebrada, credencial errada), o container sai com erro **de propósito**: melhor
|
||
> do que servir a aplicação contra um schema desatualizado.
|
||
|
||
## Deploy
|
||
|
||
```bash
|
||
# 1. No servidor, uma única vez — relógio com NTP autenticado:
|
||
sudo bash deploy/ntp-seguro.sh
|
||
|
||
# 2. Rebuild obrigatório (o fuso entra na imagem):
|
||
docker compose up -d --build
|
||
|
||
# 3. Conferir no log que o container está em -03 e não em UTC:
|
||
docker compose logs app | grep '\[boot\] relógio'
|
||
```
|
||
|
||
## 📂 Arquivos
|
||
|
||
```
|
||
Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot
|
||
whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso
|
||
docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot
|
||
bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO
|
||
deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO
|
||
.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora"
|
||
```
|
||
|
||
</details>
|