216 KiB
🚛 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.
📋 CONTEXTO DO PROJETO (leia antes de tudo)
A Gade Hospitalar precisa controlar o custo de cada operação de entrega (UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).
O banco já existe e é atualizado a cada 1 hora por outro sistema externo:
- Tabela:
public.db_reem_simplerout_2026 - Account: Gade Hospitalar (account_id: 95907)
- NUNCA fazer
DROP TABLE,TRUNCATE,DELETEou migration nessa tabela.
O sistema novo cria suas próprias tabelas no mesmo PostgreSQL (schema separado ou prefixo) e lê a tabela existente apenas para exibir e consolidar entregas.
🏗️ Estado atual — O que já foi feito (Fase 1 ✅)
logistica-controle-custos/
├── Gemfile # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…)
├── Dockerfile # Ruby 3.2.2-slim
├── docker-compose.yml # app + db (postgres:15)
├── tailwind.config.js # Paleta preto #0a0a0a / laranja #f97316 / branco
├── .env.example # Template de variáveis — copiar para .env
├── .gitignore # Protege .env, master.key, credenciais
│
├── config/
│ ├── database.yml # 100% via ENV — nunca hardcode
│ └── routes.rb # Rotas completas para todas as 8 fases
│
├── app/
│ ├── models/
│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela existente db_reem_simplerout_2026
│ │ ├── user.rb # Devise + roles (admin/gerente/operador/motorista) + PIN
│ │ ├── configuracao.rb # Preços configuráveis (entrega, retirada, bonus, desconto)
│ │ ├── consolidacao.rb # Soft delete, enum status, cálculo de progresso
│ │ ├── consolidacao_motorista.rb
│ │ ├── consolidacao_entrega.rb # Enum tipo: normal/retirada/bonus/desconto
│ │ ├── historico_estimado.rb # Job 1h + cálculo ao vivo
│ │ └── auditoria_log.rb # Log de ações críticas
│ │
│ ├── controllers/
│ │ └── application_controller.rb # Auth + Pundit + tema
│ ├── policies/
│ │ └── application_policy.rb # Base Pundit
│ ├── helpers/
│ │ └── application_helper.rb # nav_link_to, moeda(), badge_status(), progress_bar()
│ └── views/layouts/
│ ├── application.html.erb # Layout base dark theme laranja/preto/branco
│ └── _navbar.html.erb # Sidebar responsiva com hamburguer mobile
│
└── db/
├── seeds.rb # Admin: admin@gade.com / Gade@2026! + configs de preço
└── migrate/
├── ..._create_users.rb
├── ..._create_configuracoes.rb
├── ..._create_consolidacoes.rb
├── ..._create_consolidacao_motoristas.rb
├── ..._create_consolidacao_entregas.rb
├── ..._create_historico_estimados.rb
└── ..._create_auditoria_logs.rb
🚀 Setup do zero (próximo dev)
1. Clone
git clone https://git.xenserver.com.br/Cludio-code/logistica-controle-custos.git
cd logistica-controle-custos
2. Configure o .env
cp .env.example .env
nano .env # preencha DB_HOST, DB_PASSWORD, etc.
3. Gere o SECRET_KEY_BASE
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
docker-compose up -d
5. Banco + seeds
docker-compose exec app bundle exec rails db:create db:migrate db:seed
6. Acesse
http://localhost:3000
Login: admin@gade.com
Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO
🔧 Comandos do dia a dia
docker-compose exec app bundle exec rails db:migrate # nova migration
docker-compose exec app bundle exec rails console # console Rails
docker-compose logs -f app # logs em tempo real
docker-compose down # parar tudo
🗺️ Fases de implementação
| Fase | Status | O que faz |
|---|---|---|
| 1 | ✅ Concluída | Setup + Docker + Models + Migrations + Seeds + Layout base |
| 2 | ✅ Concluída (corrigida) | Login e-mail/PIN + CRUD usuários + pilares de preço + toggle tema |
| 3 | ✅ Concluída (corrigida) | Dashboard: cards + gráfico Chart.js + ranking motoristas + navegação por mês |
| 4 | ✅ Concluída | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas |
| 5 | ✅ Concluída | Nova Consolidação + filtros + Wizard Passo 1 |
| 6 | ✅ Concluída | Toggles coloridos + ações em massa + Wizard Passos 2 e 3 (Stimulus) |
| 7 | ✅ Concluída | PDFs Prawn: relatório + extrato com QR code + modal preview |
| 8 | ✅ Concluída | Painel motorista + login PIN/QR + notificações WhatsApp/email |
🎉 PROJETO: 8 de 8 fases implementadas.
🔧 Análise de integração (11/06/2026) — Correções aplicadas
As Fases 2/3 (commit 4724847) foram desenvolvidas em paralelo às Fases 4-8 (commits 90ed705 e 8b3d4bc), e a mesclagem gerou conflitos que quebravam a aplicação. Análise dos commits e correções:
🔴 Problemas críticos encontrados e corrigidos
1. Migration da Fase 2 quebrada (20260610_add_fase2_fields_to_users.rb)
- Timestamp curto (8 dígitos) fazia ela rodar ANTES da
create_users→ erro "table does not exist" - Adicionava coluna
ativoque já existia → erro de coluna duplicada - Criava
pin_acessoduplicando opin_codeexistente - ✅ 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_codeem 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 paraapp/views/admin/+ paths atualizados (admin_usuarios_pathetc.)
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_mese métodomapa_de_precosrestaurados nos models, junto comtipo_label/iconeusados 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::AuditoriaLogsControllertinha rota mas não existia → ✅ criado com view de listagem e filtrostoggle_temaetoggle_ativoexistiam nos controllers mas sem rota → ✅ rotas adicionadas- Controller de configurações usava campo
tipoque não existe (schema usachave) → ✅ corrigido
🟡 Duplicações removidas (arquivos órfãos)
app/controllers/motorista_controller.rb+app/views/motorista/index.html.erb— a rota/motoristausaMotorista::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'ANDcheckout 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:
- WhatsApp: preencha
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKENeTWILIO_WHATSAPP_FROMno.enve mude a configuraçãonotificacao_whatsappparatrueno sistema. Motorista precisa tertelefonecadastrado (formato+5511999998888). - Email: preencha as variáveis
SMTP_*no.enve mudenotificacao_emailparatrue. Motorista precisa teremail. - 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
- Login admin → criar consolidação (nome + período + motoristas)
- Wizard Passo 1 → escolher motorista → Passo 2: classificar entregas com os toggles
- Usar "Marcar todos como Normal" → Passo 3: revisar → próximo motorista
- Com tudo classificado → Finalizar (motoristas notificados)
- Na tela da consolidação → "Ver antes de gerar" → Confirmar → baixar Extrato PDF
- Logout →
/motorista/login→ entrar com PIN do motorista - Conferir cards de valores → baixar próprio extrato → sair
▶️ Próximos passos (deploy)
O projeto está completo. Para colocar em produção:
# 1. Configurar .env com credenciais reais (DB_HOST do PostgreSQL existente)
cp .env.example .env && nano .env
# 2. Subir e migrar
docker-compose up -d --build
docker-compose exec app bundle exec rails db:create db:migrate db:seed
# 3. Ativar o cron do job de 1h
docker-compose exec app bundle exec whenever --update-crontab
# 4. Testar o fluxo end-to-end (seção de teste acima)
Credenciais de teste (seeds):
| Usuário | Login | Senha/PIN |
|---|---|---|
| Admin | admin@gade.com | Gade@2026! |
| Gerente | gerente@gade.com | Gade@2026! |
| Motorista João Silva | PIN | 1234 |
| Motorista Maria Santos | PIN | 5678 |
| Motorista Carlos Souza | PIN | 9012 |
⚠️ Altere as senhas padrão no primeiro acesso em produção.
🧪 Auditoria + Suíte de testes (15/06/2026)
Revisão de código + criação da suíte RSpec (o projeto tinha rspec-rails/factory_bot/capybara no Gemfile, mas nenhum teste e nenhum spec/).
🔴 Bugs corrigidos
1. Erro de sintaxe em Admin::UsuariosController — toggle_ativo tinha entidade: \'User\' (aspas escapadas com barra). O arquivo não carregava → qualquer acesso a /admin/usuarios dava 500. ✅ corrigido.
2. Migration quebrava o setup do zero — 20260610_add_fase2_fields_to_users.rb readicionava a coluna ativo (já criada em create_users) sem guard → PG::DuplicateColumn num db:migrate limpo. ✅ guard unless column_exists? em ativo e pin_acesso.
3. db:seed falhava no 2º motorista — motoristas sem e-mail colidiam no índice único de email (default ""). ✅ seeds agora geram placeholder único (nome.parameterize@motorista.local).
4. Dashboard ignorava o filtro de conta Gade — DashboardController e HistoricoEstimado.{atualizar!,calcular_ao_vivo} contavam entregas de todas as contas. ✅ aplicado .da_conta_gade.
5. valor_total com duas fontes de verdade — o controller somava valor_aplicado (capturado na classificação) e o callback do model recalculava pelos preços atuais de Configuracao → divergência quando os preços mudavam. ✅ ConsolidacaoMotorista#recalcular_valor agora usa valor_aplicado (fonte única).
6. Gate de finalização + classificação sem validação
todas_entregas_classificadas?usava soma agregada com>=, mascarando motoristas pendentes quando outro tinha entregas de sobra. ✅ agora exige cada motorista completo (all?) e respeita o filtro de rotas.classificar/classificar_em_massaaceitavamtracking_id/motoristaarbitrá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:
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_2026stubamEntrega— o banco de teste não contém essa tabela externa.
🔧 Correções + funcionalidades (16/06/2026)
🔴 Erros de runtime corrigidos (reportados em prints)
- Logout dava
No route matches [GET] /auth/logout— o link usavamethod: :delete(rails-ujs), ignorado pelo Turbo. Agora usadata-turbo-method. - /admin/usuarios e /admin/configuracoes estouravam
NoMethodError: admin_ou_gerente?— helpers de papel (admin?,admin_ou_gerente?) movidos para aApplicationPolicy. - Listagem de usuários chamava o helper inexistente
badge_role— criado emApplicationHelper. - Nova consolidação estourava
NoMethodError: new?—ApplicationPolicyagora definenew? = create?eedit? = update?. - Login do motorista (PIN) derrubava
/motoristacomPG::UndefinedTable: relation "consolidacao_motorista" does not exist— o inflector pt-BR resolvia o composto no singular.self.table_namefixado explicitamente emConsolidacao,ConsolidacaoMotorista,ConsolidacaoEntregaeConfiguracao. - Listagem de usuários faltava o helper
badge_status_usuarioe a rotatoggle_ativoestava sem o prefixoadmin_(verbo errado também) — corrigidos. - Passo 3 (revisão) da consolidação ficava em branco:
GROUP BY tipocombinado comORDER BY created_até inválido no PostgreSQL (PG::GroupingError, vira 500/tela branca em produção). Corrigido comreorder(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.pagasagora éstatus='completed' AND checkout IS NOT NULL(antes eracheckin). 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 trocaconsolidacoes.route_idsporvehicle_ids. - Filtro de conta configurável:
account_idna tabela externa é texto e vinha vazio nos registros recentes (o dado de junho estava 100% em conta vazia, e o filtro fixo em95907zerava o dashboard).DB_EXISTING_ACCOUNT_IDagora aceita lista de contas ouall/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 rodarails db:prepareantes do servidor — não precisa mais rodardb:migratena mão após oup.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).
docker-compose exec app bundle exec rake reem:diagnostico
docker-compose exec app bundle exec rake reem:diagnostico INICIO=2026-02-01 FIM=2026-06-30
🔧 Correções + funcionalidades (16/06/2026 — sessão 2)
🔴 Bugs corrigidos
- Dashboard zerado por filtro de conta:
DB_EXISTING_ACCOUNT_IDé uma lista separada por vírgula, não um trecho de SQL. Valor errado (ex.:account_id IN ('95907', '')) zerava tudo. Use95907,(vírgula no fim) para incluir também registros com conta vazia/NULL. O scopeEntrega.da_conta_gadeagora incluiNULLquando 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-cachena página e o gráfico Chart.js virou idempotente (Chart.getChart(ctx)?.destroy()). - 500 ao criar motorista:
users.emaileraNOT NULLcom índice único e default""— o 2º motorista sem e-mail colidia em""(RecordNotUnique). Agora a coluna permiteNULLe e-mail em branco viranil(User#normalizar_email_em_branco); no Postgres váriosNULLconvivem no índice único. - 500 ao gerar relatório PDF (
gerar_pdf_relatorio):@entregas.group(:tipo).countcom.order(:created_at)geravaGROUP BY ... ORDER BY created_at→PG::GroupingError. Corrigido comreorder(nil). Também removidas as opçõescolor:passadas aoPrawn#text(não suportadas — uso defill_color). - Motorista não baixava o próprio extrato ("sem permissão"):
ConsolidacaoPolicy#show?exigepode_consolidar?(falso para motorista). Novoautorizar_extrato!noConsolidacoesController— 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). Novourl_acessorespeita o esquema doAPP_HOST(sem esquema = http). DefinaAPP_HOSTcom 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_massaaceitaacao: 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.emailpassa a permitirNULL; e-mails""existentes viramNULL.
docker-compose exec app bundle exec rails db:migrate
🗄️ Banco existente — referência rápida
| Campo | Uso |
|---|---|
tracking_id |
PK lógico da entrega |
reference_id |
Número da NF |
planned_date |
Data da entrega (filtro principal) |
driver |
Nome do motorista |
status |
'completed' = entrega concluída |
checkout |
NULL = motorista não finalizou (define elegibilidade de pagamento) |
vehicle |
Veículo da entrega (usado no filtro de consolidação) |
route_id |
UUID da operação (não usado no app — substituído por vehicle) |
contact_name |
Nome do local (UBS SACOMA, EMAD VELEIROS…) |
Regra de pagamento: status = 'completed' AND checkout IS NOT NULL
🎨 Design — padrão da marca
| Elemento | Cor | Hex |
|---|---|---|
| Fundo | Preto | #0a0a0a |
| Cards | Cinza escuro | #1a1a1a |
| Destaque / botões | Laranja | #f97316 |
| Textos | Branco | #ffffff |
| Sucesso | Verde | #22c55e |
| Erro/desconto | Vermelho | #ef4444 |
- Fonte mínima 16px desktop / 18px mobile
- Botões mínimo 48px altura (acessibilidade touch)
- Tema escuro como padrão
👥 Perfis de acesso
| Perfil | Login | Acesso |
|---|---|---|
| Admin | email + senha | Tudo |
| Gerente | email + senha | Dashboard + Consolidações + PDFs |
| Operador | email + senha | Consolidações (criar + validar) |
| Motorista | PIN 4 dígitos ou QR | Painel pessoal + baixar próprio PDF |
🩺 Correção (11/06): container caía mostrando ajuda do "rails new"
Sintoma: docker-compose up subia e o log mostrava o help do rails new em loop.
Causa: o repositório tinha os arquivos da aplicação (models/controllers/views) mas não tinha o esqueleto de boot do Rails — sem config/application.rb e config.ru, o comando rails s não reconhece a pasta como um app Rails e cai no modo "criar app novo".
Arquivos adicionados nesta correção:
config.ru # entrada Rack
config/boot.rb, environment.rb # boot do Rails
config/application.rb # módulo GadeLogistica + timezone SP + pt-BR
config/puma.rb # servidor web
config/environments/{development,production,test}.rb
config/importmap.rb # pins do Stimulus/Turbo
config/initializers/devise.rb # obrigatório para o devise_for das rotas
config/locales/pt-BR.yml # formatos de data brasileiros
bin/rails, bin/rake # executáveis
app/javascript/application.js # bootstrap Turbo + Stimulus
app/javascript/controllers/{application,index}.js
Gemfile # + gem propshaft (servir o JS via importmap)
app/views/layouts/application.html.erb # + javascript_importmap_tags (Stimulus não carregava!)
config/routes.rb # devise_for agora aponta para users/sessions (login PIN)
Como subir agora (passo a passo completo)
# 1. Criar o .env (você NÃO tem ele ainda — é cópia do exemplo):
cp .env.example .env
# 2. Gerar a SECRET_KEY_BASE e colar no .env:
openssl rand -hex 64
nano .env # cole em SECRET_KEY_BASE= e preencha DB_HOST/DB_USER/DB_PASSWORD
# 3. Rebuild (Gemfile mudou — gem propshaft):
docker-compose build --no-cache
docker-compose up -d
# 4. Criar banco do sistema + migrar + popular:
docker-compose exec app bundle exec rails db:create db:migrate db:seed
# 5. Conferir:
docker-compose logs -f app # deve mostrar "Listening on http://0.0.0.0:3000"
# Acesse http://SEU_IP:3000 → admin@gade.com / Gade@2026!
🔐 Segurança
- NUNCA versione
.env,config/master.keyou qualquer arquivo com senha - Repositório PRIVADO em git.xenserver.com.br
- Use sempre
ENV['VARIAVEL']no código Ruby
🆕 Atualização 17/06/2026 — Pagamentos, Operações, Dashboard financeiro e UI
Conjunto de melhorias para deixar o sistema pronto para entrega final.
💳 Status de pagamento (Pago / Parcial / Pendente)
- Migration
add_pagamento_to_consolidacao_motoristas: colunaspago_em,pago_por,forma_pagamentoemconsolidacao_motoristas(pagamento é por motorista). ConsolidacaoMotorista:pago?,marcar_pago!(user, forma:),cancelar_pagamento!, scopespagos/pendentes,belongs_to :pagador.Consolidacao#status_pagamento(:pendente/:parcial/:pago),valor_pago,valor_pendente, scopespagamento_pago/pagamento_pendente/pagamento_parcial(filtro da lista).- Tela show: marcar motorista individual ou a operação inteira (com
<select>de forma); estorno. Açõesregistrar_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-mailConsolidacaoMailer#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 viainformation_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) emconsolidacoes;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(defaultgade_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ícioe< 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_pagese faltar espaço — o Prawn não pagina imagem sozinho).
🎨 UI / Identidade visual
- Fonte base maior para leitura:
html { font-size: 17px }(desktop) e18px(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]), commix-blend-mode: screenpara "derrubar" o fundo preto do vídeo. rack-mini-profilerdesativado emconfig/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(verOperacao). - 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) ebadge_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
🔧 Correções — Extrato PDF (18/06/2026)
🔴 Bugs corrigidos
-
500 ao gerar extrato (
gerar_pdf_extrato):Prawn::Errors::IncompatibleStringEncoding(Encoding::UndefinedConversionError). Os PDFs usavam a fonte embutida do Prawn (Helvetica/AFM), que só aceita o charset Windows-1252 — o caractere✓do selo "✓ PAGO" não existe nesse charset e derrubava a geração. ✅ Corrigido:BasePdfagora 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.ttfeDejaVuSans-Bold.ttf(a imagem Dockerruby:3.2.2-slimnão traz fontes do sistema; oCOPY . .do Dockerfile e o volume.:/appgarantem que estejam disponíveis em runtime).
- Fontes versionadas em
-
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_downera frágil e, com a fonte nova, fazia o QR se sobrepor ao texto/assinaturas. ✅ Corrigido:desenhar_qrcodeagora 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 (Usermotorista comlogin_token). Sem conta correspondente,user_motoristaficavanile 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) eusers.nome(digitado no admin). ✅ConsolidacoesController#buscar_user_motoristaagora 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.
- Nome não batia (espaços/maiúsculas) entre
📌 Padrão (novo)
- PDFs com Prawn: usar sempre a fonte TTF registrada no
BasePdf(DejaVu, UTF-8). Nunca contar com a fonte embutida — ela quebra em qualquer caractere fora do Windows-1252.
🆕 Atualização 19/06/2026 — Apontamento manual / Nota avulsa
Permite incluir numa consolidação uma entrega que chegou por fora (ex.: nota que entrou
depois e não está no banco mensal das operações). O usuário digita o número da nota (NF =
reference_id), o sistema busca os dados direto na db_reem_simplerout_2026 (atualizada a cada
hora pela SimpleRoute) e adiciona a entrega ao fechamento do motorista.
A tabela externa continua read-only — o apontamento só lê dela (
Entrega.por_nf, sem filtro de período, restrito à conta Gade viada_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 dodriverda 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
driverda 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) emconsolidacao_entregasdistingue apontamento de entrega elegível do período.Consolidacao#classificadas_countignora osmanual: truepara não estourar a barra de progresso nem o gate de finalização (>= 100%). - Lógica centralizada em
Consolidacao#adicionar_apontamento!(entrega, tipos, user)eConsolidacao#recalcular_motorista!— reusadas pelos dois fluxos (wizard e avulsa). Preço por pilar viaConsolidacaoEntrega.valor_para(tipo)(fonte única). Dados da entrega viaEntrega#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) enota_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 dadivdo controller).
🆕 Migration adicionada (rodar db:migrate)
20260619000001_add_manual_to_consolidacao_entregas.rb
docker-compose exec app bundle exec rails db:migrate
🧪 Testes
spec/models/consolidacao_spec.rb:classificadas_countignora apontamentos manuais eadicionar_apontamento!(multi-tipo, idempotente, inclui o motorista e soma o valor).
🆕 Atualização 22/06/2026 — Pilar Extraordinária, Perfil Externo, Falhadas, Relatórios e Segurança QR
🆕 Funcionalidades
- Pilar "Entrega Extraordinária" — preço coringa para entregas fora do planejamento.
- Novo valor
extraordinarianoenum tipodeConsolidacaoEntrega(soma positiva, como Bônus). Sem migration de enum (colunainteger). - 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_classificacaono controller +pedirValor()no Stimulus).
- Novo valor
- 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+ scopefalhadas; o scopependentesfoi ajustado para não sobrepor (nemcompletednem falha).
- Novo
- 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).
- Por consolidaçã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.
- Login normal por e-mail/senha; enxerga apenas o dashboard. Bloqueado nas demais áreas pelas policies Pundit (
- 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 usarupdate_columns(pulam validações e o callback de recálculo) — arquivar é soft-delete administrativo e deve sempre funcionar.turbo_confirmmovido 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) comdateFormat: 'd/m/Y'. Corrigido passando objetosDate+ separador" até "; seleção de um único dia agora aplica; re-inicialização emturbo:load. - Moeda sem separador de milhar — padronizado
R$ 1.234,56(milhar., decimais,) em: helpermoeda(number_to_currency), PDFs (base_pdf),Configuracao#valor_formatado, mensagens doNotificacaoServicee 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)
docker-compose exec app bundle exec rails db:migrate
20260622000001_add_preco_extraordinaria_configuracao— cria a configpreco_extraordinaria(padrãoR$ 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.docker-compose exec app bundle exec rails runner "puts Entrega.da_conta_gade.distinct.pluck(:status).inspect"
🆕 Atualização 25/06/2026 — Consolidação inclui entregas de insucesso (motorista foi ao local)
A consolidação só considerava entregas concluídas com sucesso. Como o motorista se desloca até o local mesmo nas entregas que falham (insucesso), essas precisam constar na consolidação para serem classificadas e remuneradas conforme o caso.
🆕 Funcionalidades
- Novo conjunto elegível "atendidas" — entregas em que o motorista foi ao local:
status
completed(sucesso) OUfailed(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)+ scopeatendidas(where(status: STATUS_ATENDIDO).com_checkout). Novo helper de instânciaEntrega#falhada?.Entrega.contar_pagas→ renomeado paraEntrega.contar_atendidas(usa o scopeatendidas).Consolidacao#entregas_elegiveis_countpassa 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) usamEntrega.atendidas.- Não afeta o lado financeiro: dashboard, painel do motorista, job de 1h e
HistoricoEstimadocontinuam usando.pagas(sócompleted) — "atendidas" é exclusivo da elegibilidade da consolidação.
📂 Arquivos alterados
app/models/entrega.rb # STATUS_ATENDIDO, scope :atendidas, contar_atendidas, falhada?
app/models/consolidacao.rb # entregas_elegiveis_count usa contar_atendidas
app/controllers/consolidacao_entregas_controller.rb # validar + tracking_ids_elegiveis usam :atendidas
app/views/consolidacao_entregas/validar.html.erb # selo de status por entrega + destaque do insucesso
Sem migration — a mudança é apenas de leitura/regra de negócio sobre a tabela read-only
db_reem_simplerout_2026.
🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente
Dois recursos na consolidação:
1. Apontamento 100% manual (tela de Validar — Passo 2)
Caso real: o motorista A foi até o local mas não entregou; no rastreio que alimenta o
banco, a NF ficou registrada sob o motorista B (que de fato entregou). Logo, a busca por NF
nunca encontra o A — mas o A também recebe, pois foi ao local. O apontamento manual antigo
(busca a NF na base e puxa entrega.driver) não serve.
- Novo lançamento 100% manual, sem consultar a base de rastreio, atribuível a qualquer motorista (seletor com os motoristas da consolidação, ou digita um novo), com NF (texto livre), observação/local e pilar(es) (+ valor da Extraordinária).
- Botão "➕ Apontamento 100% manual" na tela de Validar. Soma ao valor do motorista, mas
não conta no gate
X/Y classificadas(émanual: true, igual aos apontamentos por NF). - A NF digitada aparece na Revisão (Passo 3), na tela da consolidação e no Relatório PDF do
motorista (fallback
nf_manual/obs_manualquando não háEntregapara 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:)— geratracking_idsintético ("MAN-<uuid>") compartilhado pelos pilares (agrupa como UMA entrega); reusarecalcular_motorista!. Actionapontar_manual+ rota + Stimulusapontamento_manual_controller.js.ConsolidacaoMotorista: scopesativos/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. Actionsarquivar_motorista/desarquivar_motorista(autorizaçãoupdate?; 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
docker-compose exec app bundle exec rails db:migrate
docker-compose exec app bundle exec rspec
🆕 Atualização 25/06/2026 — Fechar por veículo na tela de Validar
Um motorista pode usar vários veículos no período. A tela de Validar (Passo 2) mostrava tudo junto e o "Marcar todos como Normal" marcava todas as entregas do motorista. Agora dá para fechar cada carro individualmente.
🆕 Funcionalidades
- Filtro por veículo (chips "🚗 Fechar por veículo") na tela de Validar — só aparece quando o motorista usou mais de um veículo. Clicar num carro filtra a lista para aquele veículo; a barra de progresso e o "Marcar todos"/seleção passam a valer só para o carro selecionado. Há um chip "Todos" para a visão completa.
- Status "fechado" por carro: cada chip mostra
classificadas/elegíveise 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 scopeEntrega.da_veiculo. Grupo "sem veículo" via sentinelaSEM_VEICULO.validaraceitaparams[:vehicle]: recorta@entregase, quando há carro selecionado, os totais da barra (@total_entregas/@classificadas) vêm doveiculos_statusdaquele carro.tracking_ids_elegiveis(motorista, vehicle = nil)eclassificar_em_massapassam o veículo — é o que faz "Marcar todos" fechar só o carro.resumo_json(motorista, vehicle:)devolveclassificadas_escopo(barra por carro) +veiculos_status(refresh dos chips).validacao_controller.js: valueveiculo, targetchipVeiculo,vehiclenos POSTs,atualizarChips()(verde/✓ + contadores). Sem migration — só leitura + UI.
📂 Arquivos
app/controllers/consolidacao_entregas_controller.rb # veiculos_status, validar, tracking_ids_elegiveis, classificar_em_massa, resumo_json
app/views/consolidacao_entregas/validar.html.erb # chips por veículo + barra com escopo
app/javascript/controllers/validacao_controller.js # value veiculo, chips, classificadas_escopo
🆕 Atualização 26/06/2026 — Entrega de termo, caixa de ferramentas, gestão de motoristas e UX da Validação
Pacote de implantação focado em agilizar a precificação e dar feedback direto ao usuário no fluxo de consolidação. Resumo das frentes:
1. 📄 Entrega de termo (entrega sem NF nem código de rastreio)
Lançamento de termos entregues por um motorista — não têm nota fiscal nem tracking_id para
atrelar, só motorista + quantidade a um preço fixo configurável.
- Novo pilar
tipo: termo(enum 5, cor azul) e novo preço "Entrega de Termo" editável em Admin → Configurações (entra automático no card, viaCHAVES_MOEDA). - Lançado como uma linha por lote com a coluna nova
quantidade;valor_aplicadoguarda o total do lote (quantidade × preço), entãorecalcular_motorista!e todas as somas existentes seguem corretas. Modal com stepper − N + (sem as setinhas nativas doinput 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 decountparasum(: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_countseguemanual: 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-sesticky/z-indexpara 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 configpreco_termo(idempotente).
Consolidacao#adicionar_termos!(motorista:, quantidade:, user:)— uma linhaTERMO-<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_tipoagorasum(: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); removidosmarcarTodos/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
docker-compose exec app bundle exec rails db:migrate
🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)
Correção da quebra de layout dos cards de NF na tela Passo 2 (Validar) em telas pequenas e
em tablets, reportada nos prints Imagens para correção/Imagem colada (16).png e (17).png.
🔴 Bugs corrigidos
- Botão "Extra" cortado no celular (print 16) — a fileira dos 5 pilares
(
Normal · Retirada · Bônus · Desc. · Extra) usavaflex gap-1.5semflex-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. Semflex-wrap, os botões transbordavam por cima do endereço/data/veículo. O ponto de virada foi movido paralg(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-wrapimpede o transbordo;lg:shrink-0mantém os botões inteiros no desktop, deixando o texto da nota truncar em vez de espremer os botões).
- Card da entrega:
- 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-wrape ≥ 1024px vai para linha com truncamento do texto. - Sem migration e sem mudança de JS/controller — alteração somente de classes Tailwind na view.
🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)
Nova página /dashboard/operacoes (link 📈 Operações no menu), separada do dashboard
financeiro, focada na qualidade das entregas por operação (UBS Norte, EMAD, …). Reproduz os
painéis das imagens de referência (Imagens para implantação/) e adiciona análise comparativa e mapa.
Acessível a todos menos motorista (DashboardPolicy#operacoes?).
🆕 Funcionalidades
- 3 modos na mesma página (abas):
- 🏥 Operação — uma operação por vez. Sem filtro de data: mostra o conjunto inteiro da
operação e o período exibido vem dos próprios dados (menor/maior
checkout). - 🌐 Global — todas as operações agregadas, aí sim com faixa de datas (flatpickr + atalhos Hoje/7d/Este mês/30d). A faixa de data só vale no Global.
- ⚖️ Comparar — duas operações lado a lado com bloco Comparativo (tabela A | B | Δ com o melhor valor de cada linha em verde + gráfico de barras agrupadas) e detalhe completo recolhível.
- 🏥 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
- 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: CTEROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC)sobredb_reem_simplerout_2026+INNER JOINna tabelagade_entregas_*porreference_id::text = nota_fiscal; agrega tudo em Ruby (visãolinhas= 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 — replicarda_conta_gadezerava tudo quandoDB_EXISTING_ACCOUNT_IDnã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 viramNULLquando não existem — assim oUNION ALLentre operações no Global não quebra (era o errocolumn g.status does not exist). - Segurança: nomes de tabela passam por
Operacao.sanitizar+quote_table_name; afoto_da_fachadaé validada como URLhttp(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_2026read-only egade_entregas_*). Libs front via CDN (Chart.js, Leaflet + leaflet.heat, flatpickr). ⚠️ Em produção, reiniciar o servidor (Puma) após o deploy — o Rails cacheia classes/views.
🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar
Rodada de melhorias no Dashboard de Operações, na tela de Validação do motorista (consolidação) e no layout do site inteiro.
🗺️ Mapa de Operações
- Busca no mapa — campo 🔍 que filtra os pontos por NF, nome do paciente ou veículo, troca para a visão "Pontos", dá zoom nos resultados (busca client-side) e mostra contador / "Nenhum resultado".
- Mostra TODAS as entregas — antes só
completed; agora sucesso + insucesso (óbito e demais pilares de falha), viaEntrega::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(deEntrega) 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 filtrosf_resultado(colunastatus) ef_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
#ef4444para azul#3b82f6nos 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-2nos 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-collapsedno<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_2026read-only egade_entregas_*). ⚠️ Em produção, reiniciar o Puma após o deploy (cache de classes/views).
🔒 Segurança + auto-atualização do painel + cron (06/07/2026)
Revisão de segurança do site em produção (via browser + inspeção de headers) e melhorias no Dashboard de Operações. Sem migration.
🩸 Causa-raiz descoberta — produção em modo development
A página de erro 404 do site expunha o backtrace do Rails e havia o cookie
__profilin (rack-mini-profiler) — sinais de que o container roda em
RAILS_ENV=development. Isso é a origem de várias falhas abaixo (cookie de
sessão sem Secure, sem redirect HTTPS, sem CSP, páginas de exceção vazando
código).
⚠️ Ação necessária no servidor (o
.envnão está no repositório): definirRAILS_ENV=productioneFORCE_SSL=trueno.enve rebuildar. Ao migrar paraproduction, 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). Emapplication.rbfica atrás deENV["FORCE_SSL"]=="true", então funciona mesmo enquanto o deploy roda fora do modo production;production.rbtambém já vem comforce_ssl = true. - rack-mini-profiler desligado (item 3) — no
Gemfilepassou arequire: false: a gem não monta mais o middleware (fim do cookie__profiline da rota/mini-profiler-resources), em qualquer ambiente. - Content Security Policy (item 5) — nova política em
config/initializers/content_security_policy.rbcom 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) fazTurbo.visitna 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 deevery 1.hourpara*/30 8-18(a cada 30 min, 08h-18h), batendo com a cadência real.Dockerfile— instala o pacotecron.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
# 1. No servidor, ajustar o .env:
# RAILS_ENV=production
# FORCE_SSL=true
# 2. Rebuildar e subir (já roda whenever --update-crontab + cron no boot):
docker compose up -d --build
# 3. Validar a CSP no navegador (F12 → console, sem violações) e então trocar
# config/initializers/content_security_policy.rb para report_only = false.
Sem migration. ⚠️ Em produção, reiniciar o Puma após o deploy.
✏️ Atualização 08/07/2026 — Editar Lançamento do SimpliRoute (correção pelo ADM)
Nova tela só para ADM que permite corrigir um lançamento de entrega direto na API do SimpliRoute (ex.: uma entrega marcada como "completa" que na verdade foi óbito). Até então a única forma era entrar manualmente no SimpliRoute. Sem migration.
🆕 Funcionalidades
- Menu → Administração → "✏️ Editar Lançamento" (visível só para
admin). - Busca por NF → o sistema localiza a entrega e mostra o estado atual.
- Campos editáveis:
status(completa/falha/pendente/parcial/cancelada), motivo (óbito, endereço não localizado, mudou-se, etc.), comentário, observações e, no avançado, data/hora + geolocalização do checkout. - Auditoria — toda alteração grava um
AuditoriaLog(quem, quando, de/para). - Foto da fachada / assinatura ficam para uma fase 2 (exigem upload de imagem).
⚙️ Como funciona (técnico)
- A base de rastreio local (
db_reem_simplerout_2026, modelEntrega) é 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
idnumérico (usado na URL de escrita) etracking_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 pelotracking_id(fallback: NF). - Motivo = campo
checkout_observation, que é o UUID de uma lista fixa de 14 motivos (GET /v1/routes/observations/, todostype=failed). É esse UUID que popula a colunaobservation— 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 peloapplication.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
# 1. No servidor, adicionar ao .env (pegue o token em SimpliRoute → Configurações → API):
# SIMPLIROUTE_TOKEN=...
# SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
# 2. Rebuildar/reiniciar:
docker compose up -d --build
# 3. Logar como admin → Administração → "Editar Lançamento" → buscar uma NF e testar.
Sem migration. ⚠️ Em produção, reiniciar o Puma após o deploy (cache de classes/views).
🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)
Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
do sistema. Nada aqui está implementado — são os próximos passos priorizados. A base
técnica já existe (SimpliRoute::Client, SimpliRoute::PlanilhaCarga, token via ENV):
cada item vira uma fase própria quando for priorizado.
1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
POST /v1/routes/visits/ aceita criação em lote: um botão "Subir para o SimpliRoute"
criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
(título NF {nf} - {nome}, janelas 08:00–18:00, lat/long do mês anterior, etc.).
⚠️ Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.
- Anti-duplicação: conferir
reference+planned_dateantes de criar (reexecutar não duplica). - Desfazer:
POST /v1/bulk/delete/visits/permite implementar um "remover carga".
2. 🧾 Relatório de comprovantes de entrega (POD)
GET /v1/plans/visits/{visit_id}/detail/ traz foto, assinatura, hora e GPS de cada
entrega → gerar PDF por operação/mês (reuso do padrão Prawn em app/services/pdf/).
Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").
3. ⚡ Webhooks — painel em tempo real
POST /v1/addons/webhooks/ com eventos visit_checkout, route_started/finished,
on_its_way → endpoint público autenticado no app grava o evento e o painel atualiza
na hora, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
idempotência) e URL pública estável.
4. 📱 Aviso ao paciente via WhatsApp
Evento on_its_way + ETA da API + Twilio já existente (NotificacaoService):
"seu medicamento saiu para entrega". Reduz insucesso por RESPONSÁVEL AUSENTE
(motivo real da lista de observations).
5. 📺 Monitor de rotas ao vivo
GET /v1/plans/{date}/vehicles/ + visitas por rota → tela "Operação de hoje" com cada
veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).
6. 👷 Sincronização de motoristas/veículos
GET /v1/accounts/drivers/ e GET /v1/routes/vehicles/ ↔ usuários motoristas do sistema
(hoje o vínculo é o nome digitado — sujeito a divergência).
7. 📊 Datamart e 🏷️ tags/skills
Export paginado de analytics para enriquecer dashboards; tags/skills para classificar visitas por tipo de material.
🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções
📋 Planilha da Operação — página própria (/dashboard/operacoes/planilha)
Tabela espelho da planilha da operação (uma linha por NF) para pesquisar e acompanhar ocorrências/status, acessada pelo botão "📋 Planilha da operação" no Dashboard de Operações (o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).
- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo, Resultado (badge Entregue/Falha/Pendente), Ocorrência (motivo do insucesso) e status gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
- Busca textual server-side (param
q, qualquer campo) + paginação server-side (parampg, 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, actionplanilha+montar_tabela_espelho(OperacoesDashboardController), viewsplanilha.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
observationdo 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 CTEROW_NUMBERdos 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.xmldo próprio arquivo): cabeçalho ENTREGAS azul1155CC(A–T) + vermelhoC00000(U–Z), quadros do RESUMO em002060/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), rotaoperacoes_planilha_baixar, actionbaixar_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 eramcopilots,receiver,early,window_start, etc. - Tabelas
gade_entregas_*gravam a geo comolat/long(nãolatitude/longitude) — as duas colunas da aba ENTREGAS saíam vazias. VerALIAS_GADE. Load 4não existe no espelho (sóload,load_2,load_3) — sai vazia de propósito.- Coluna
protocolo_de_entregaexiste 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) ecodrivers/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.
SincronizarSeriesAparelhoJobvarre 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 emseries_aparelho(tabela nossa — o espelho é read-only). Um dia que não responde vira aviso, não derruba a rodada.Analytics::PlanilhaEntregas#completar_seriecompleta 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]"ourake "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_valuesque vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo emCAMPOS_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-cole 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-hiddenno<body>(rede de segurança; tabelas largas continuam rolando nos próprios contêineresoverflow-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ãoOperacaoMetricas/PlanilhaCarga). - Excel com
caxlsx(mesmo padrão da planilha de carga SimpliRoute): serviço de linhas + serviço de binário +send_datano controller.
Sem migration. Deploy normal; em produção reiniciar o Puma após o deploy.
🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos)
O negócio passou a diferenciar termo normal e termo especial, cada um com seu preço. O modal de lançamento agora permite informar quantos termos de cada tipo o motorista entregou, num único envio.
🆕 Funcionalidades
- Novo card de preço "📋 Entrega de Termo Especial" em Admin → Configurações
(chave
preco_termo_especial, moeda). Criado com R$ 0,00 — definir o valor real no card antes de usar. O card aparece automático (a view itera todas as configs). - Novo pilar
tipo: termo_especial(enum 6, cor ciano, label "Entrega de Termo Especial") — mesmo comportamento do termo normal: lote numa linha (quantidade× preço),manual: true,tracking_idsintéticoTERMO-<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 configpreco_termo_especial(idempotente) + seed correspondente. Configuracao: chave nova emCHAVES/CHAVES_MOEDA/LABELS/ICONES/mapa_de_precos- helper
preco_termo_especial.
- helper
Consolidacao#adicionar_termos!ganhou o parâmetrotipo:(default'termo', validado emtermo/termo_especial); o preço vem deConsolidacaoEntrega.valor_para(tipo).ConsolidacaoEntregasController#apontar_termolêquantidade(normal) +quantidade_especiale cria um lote por tipo dentro de uma transação, auditando as duas quantidades.contar_manuaissoma aquantidadedos dois tipos de termo; os agregadosgroup(:tipo).sum(:quantidade)já funcionavam sem mudança.- Stimulus:
apontamento_termo_controller.jscom targetsquantidade/quantidadeEspeciale açõesmaisEspecial/menosEspecial;validacao_controller.jscom target opcionalqtdTermoEspecial.
📂 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
docker-compose exec app bundle exec rails db:migrate
🎨 Atualização 20/07/2026 — Ícones da marca (emoji → SVG laranja) + galeria de fotos no Editar Lançamento
Três frentes num dia: (1) troca de todos os ícones do sistema, que eram emojis, por ícones SVG chapados no laranja da marca; (2) galeria com todas as fotos do lançamento na tela de Editar Lançamento; (3) investigação (com veredito) sobre por que não dá para editar as fotos pela API do SimpliRoute. Sem migration.
🎨 1. Ícones — emoji → SVG da marca
Os ícones eram emojis (📊 📋 👥 ⚙️ …), que davam aparência "genérica de IA" e mudavam de
forma/cor conforme o sistema operacional. Agora são SVG chapados, na cor laranja da marca
(#f97316), consistentes em qualquer navegador.
- Sprite próprio em
public/icons.svg— subconjunto do Bootstrap Icons (licença MIT), arquivo único (~33 KB), servido localmente (sem CDN externo). - Helper
icone(nome, …)emapp/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. Usafill="currentColor"→ a cor vem da classe CSS. - Helper
rotulo(nome, texto, …)paralink_to/button_to(que recebem o rótulo como argumento, onde não cabe ERB).nav_link_toganhou a opçãoicon:. - ~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âmetroespaco:commr-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_lancamentono controller reúne todas as fontes — fachada (rastreio),extra_field_values,pictures[]esignature— 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_detailedapontando 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.svgprecisa ir junto no deploy.
🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026)
Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas
🎯 O problema real
Quando um plano é duplicado no SimpliRoute, nasce uma visita nova (outro tracking_id) com a
mesma NF, e a antiga continua existindo. Caso que motivou tudo: NF 82891 com visita em
17/07 (pendente, motorista Thiago Rabello Bittencourt) e outra em 21/07 (sucesso, sem
motorista). A tela mostrava uma só — e depois, por regressão, nenhuma.
🔴 Causas corrigidas (foram 7, em camadas)
Entrega.por_nf(nf).first— pegava uma linha só, semORDER BY. Agora carrega todas e a tela lista as ocorrências para o ADM escolher qual editar.SimpliRoute::Client#resolver_idabortava 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.- 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).
- Um dia com falha derrubava a busca inteira → agora é fail-soft por dia: registra em
falhase segue. A tela informa o período consultado e quais dias falharam. carregar?faltando emEdicaoLancamentoPolicy→ o Pundit levantavaNoMethodError(500), ofetchrecebia HTML e o JS reportava "erro de conexão". Falhou fechado (negou acesso), sem brecha de segurança.- Tela em branco:
renderOcorrencias()montava a lista inteira mas nunca removia a classehiddendo container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível. - Fuso horário:
new Date('2026-07-15')é meia-noite UTC e, em UTC−3, otoLocaledevolvia 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
orderdo flex — sem mover os ~170 linhas de modais que vivem dentro dodata-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 progressotext-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, emapplication.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). Otailwind.config.jsdo repositório não está sendo usado — a config real está embutida no layout (linha 17), e a gemtailwindcss-railsestá no Gemfile sem servir CSS. - Não existe teste para
Admin::EdicaoLancamentosController(spec/não tem nada deedicao_lancamento). Duas das quebras acima — policy faltando ehiddennão removido — seriam pegas por um teste de request/sistema em segundos, sem custar deploy. - Token do SimpliRoute: se passou por chat/e-mail, rotacione em
app2.simpliroute.com.
Sem migration e sem gem nova — controller, policy, service, views e JS. ⚠️ Reiniciar o Puma após o deploy (cache de classes/views).
✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026)
⚠️ STATUS: implementado, ainda NÃO executado. Nada aqui foi rodado contra um banco nem contra os servidores reais (Gmail/Twilio) — não há Ruby nem Postgres na máquina de desenvolvimento. Foi conferida a sintaxe de todos os arquivos
.rbe.erbe 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 prefixo55só é removido quando sobra número demais — senão quebraria o DDD 55 (Santa Maria/RS), onde55991234567já é 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á. AuditoriaLogregistra a mudança comacao: 'editar_notificacoes', mas grava apenassmtp_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
# 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'
- Permissão: logar como gerente → o card não aparece e
/admin/configuracao_notificacaoredireciona com "Você não tem permissão". Como admin → a tela abre. - 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. - 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. - 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_BASEtorna senha e token ilegíveis. O sistema não quebra (volta ao.enve avisa na tela), mas os dois campos precisam ser redigitados. Para desacoplar, definaNOTIFICACAO_SECRETno.envcom uma string longa e fixa. - Não cachear a config em
Rails.cache: oproduction.rbusa:memory_store, que é por processo — a tela pareceria "não salvar" para os outros workers. É 1SELECTpor e-mail. app/views/configuracoes/index.html.erb(fora doadmin/) 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:migrateobrigatório) e sem gem nova —twilio-rubyjá estava no Gemfile. ⚠️ Reiniciar o Puma após o deploy.
💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (20–21/08/2026)
⚠️ STATUS: no ar no ambiente de teste e conferido com dados reais (17 motoristas, 4.976 entregas de agosto). O que continua pendente é a suíte — não há Ruby nem Postgres na máquina de desenvolvimento, então os specs novos foram validados só na sintaxe. Roteiro no fim desta seção.
🎯 O problema
A Reem paga a entrega sem sucesso: o motorista foi até o local, teve o deslocamento e o custo,
e o insucesso é só o desfecho da visita. A consolidação já tratava assim desde sempre
(Entrega::STATUS_ATENDIDO = completed + failed), mas o dashboard não — mostrava um valor
menor que o do fechamento, e ninguém sabia explicar a diferença.
🔴 Correções
1. Dashboard principal contava só as concluídas — dashboard_controller.rb (commit cebd6e2)
A base financeira era Entrega.pagas (só completed). Passou a ser .atendidas
(completed + failed, com checkout) — o mesmo recorte que a consolidação considera elegível,
que é justamente o ponto: tela e fechamento agora partem do mesmo conjunto.
2. A falhada caía no período errado — mesmo commit
As falhas eram filtradas por planned_date, e as concluídas por checkout. Falhada TEM
checkout (o motorista fechou a visita com motivo de insucesso), então o eixo correto é o mesmo
das concluídas. Uma entrega planejada em 31/07 e fechada em 01/08 pertence a agosto — como a
consolidação sempre entendeu.
3. O painel do motorista ficou para trás — motorista/dashboard_controller.rb (commit 4965931)
Continuava em Entrega.pagas + no_periodo (planned_date), ou seja, a lógica antiga inteira.
O motorista via menos do que ia receber — no mês corrente, 124 entregas (~R$ 2.232,00)
invisíveis — e a diferença só aparecia no fechamento. Passou para .atendidas +
no_periodo_checkout, e o rótulo "N entregas feitas e confirmadas", que mentia sobre o número
novo, virou:
R$ 20,00
2 entregas atendidas
1 entregues · 1 sem sucesso (pagas também)
A segunda linha não é enfeite: sem ela o motorista vê um total maior e não tem como conferir de onde veio.
4. A barra do ranking contradizia a ordem do ranking — _ranking_motoristas.html.erb (commit e9db6b7)
A barra era proporcional à quantidade, mas o card é ordenado por valor. Na aba Estimado dá no mesmo (valor = qtd × preço); na Consolidada, bônus/retirada/termo mudam o preço unitário e a barra do 3º (290 entregas, R$ 5.250) saía maior que a do 2º (261 entregas, R$ 5.260). Invertia em três pontos da lista. Agora escala pelo valor, que é o número que ordena.
🆕 Aba "Consolidado" no ranking de motoristas
O card Motoristas ganhou duas abas — a dúvida recorrente era justamente "esse ranking mostra o estimado ou o consolidado?":
| Aba | O que mostra | De onde vem |
|---|---|---|
| Estimado | entregas atendidas × preço da entrega | espelho de rastreio (Entrega.atendidas) |
| Consolidado | valor realmente fechado + quantidade exata de entregas | consolidacao_motoristas / consolidacao_entregas |
Os números divergem de propósito: o estimado cobre tudo que foi atendido no período; o consolidado, só o que já entrou em consolidação finalizada, com bônus/desconto/retirada aplicados. Cada aba diz na tela de onde vem o seu número.
Dois detalhes decidem se a quantidade sai certa:
DISTINCT tracking_id, não contagem de linhas. Uma entrega pode ter vários pilares — Normal + Bônus + Retirada são 3 linhas emconsolidacao_entregaspara 1 entrega. Contar linhas inflaria o número.- Só motoristas ativos. O ranking parte de
@fin_por_motorista(que vem deConsolidacaoMotorista.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
pendentesexcluifailed, 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-consolidadovia Nokogiri — a aba Estimado renderiza o mesmo markup (moeda + "N entregas"), então asserção nobodyinteiro 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
# 1. Suíte (única coisa que não pôde ser executada)
docker compose exec app bundle exec rspec \
spec/requests/dashboard_spec.rb spec/requests/motorista_dashboard_spec.rb
# 2. Depois do deploy: conferir a barra do ranking na aba Consolidado
# (deve encurtar sempre de cima para baixo)
# 3. Painel do motorista com dado real — logar como motorista no teste e
# conferir a linha "N entregues · N sem sucesso (pagas também)"
🔢 Dashboard × Operações: por que os números não batiam — notas x visitas + painel de avulsas (24/08/2026)
⚠️ STATUS: implementado, ainda NÃO executado. Não há Ruby/Bundler nem Postgres na máquina de desenvolvimento — foi conferida a sintaxe de todos os
.rbe.erbalterados. 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_atendidasconta 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
# 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
📣 Notificações: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026)
✅ 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/.erbfoi conferida (com um checker que emula o handler ERB do Rails, porque<%= form_with … do %>não passa no ERB da stdlib) e a doserver.jscomnode --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
# 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_alteradaeagendadojá existem como opção no cadastro e oDespachanteos 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.
🧱 Editor de blocos das mensagens — ETAPA 2 (24/08/2026)
✅ 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,.erbe no JavaScript do editor (node --checksobre 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
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.
🔔 Gatilhos: valor alterado, operação mudou e resumo agendado — ETAPA 3 (24/08/2026)
✅ STATUS: migrations no ar (24/08/2026).
⚠️ Não validado ainda: um disparo real de cada gatilho, o
whenever --update-crontabe a suíte. Sintaxe conferida em todos os.rb,.rakee.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:
DetectarMudancasOperacaoJobcompara 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
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
🛠️ Deploy das notificações: os 3 tropeços e as correções (24/08/2026)
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/enviosem 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.
💡 Ideias para o editor de mensagens — backlog priorizado
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.
👥 Envio para GRUPO do WhatsApp (24/08/2026)
⚠️ STATUS: implementado, não executado. 1 migration nova. Sintaxe conferida em
.rb,.erb, no JavaScript do formulário e noserver.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:
jidDe()noserver.jscolava@s.whatsapp.netnos dígitos — id de grupo nem passava.- 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. Contatoexigia 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
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.
🎨 Ponte desatualizada, nome do Baileys fora da tela e padronização visual (25/08/2026)
⚠️ STATUS: implementado, não executado. Sem migration. Sintaxe conferida nos 16
.erbtocados 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:
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.jsganha rota nova, a ponte precisa de rebuild próprio.docker compose up -d --buildsem 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 owhatsapp/server.jsfoi junto no merge para amain— 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_tokene os métodosbaileys?,baileys_pronto?,BAILEYS_URL_PADRAOmantê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 otextContentdo 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
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.
🧭 O que falta: gatilhos pela tela e blocos ricos na mensagem — backlog
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.
🔐 Atualização 26/08/2026 — Perfis de acesso, variáveis de mensagem e correção de vazamento nos erros
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 semdashboard.verentraria 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:
- 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. - 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.rbjá temconsider_all_requests_local = falseeforce_ssl = true). Enquanto isso não acontece, oERROS_DETALHADOScobre 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_2026rodou 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.
🛠️ 27/08/2026 — Deploy: relógio, arquivos órfãos e tudo que caiu junto
Um relato só, porque foi uma corrente: um problema descoberto expunha o seguinte. Vale mais como mapa de diagnóstico do que como changelog.
| Sintoma na tela | Causa real | Onde se resolveu |
|---|---|---|
| Hora sempre 3h adiantada | Container subia em UTC | Dockerfile (TZ + /etc/localtime) |
| Hora errada depois de reboot | Relógio do host sem sincronia | deploy/ntp-seguro.sh (chrony + NTS) |
env file .env not found |
.env não é versionado e a pasta foi recriada |
recriar no servidor + backup fora da árvore |
Couldn't find Active Storage configuration |
config/storage.yml nunca esteve no git |
arquivo versionado |
port is already allocated |
Porta 3001 vivia como alteração local no compose | ${PORTA_APP:-3001} |
Blocked hosts: 100.75.222.23:3001 |
config.hosts ligado só com o domínio |
HOSTS_PERMITIDOS |
Translation missing … devise.failure |
fallbacks = true caía no próprio pt-BR |
fallbacks = [:en] + traduções |
| 422 "recusada por verificação de segurança" | assume_ssl fixo x acesso por IP em HTTP |
gate do FORCE_SSL |
URI::InvalidURIError: … SEU_IP |
DATABASE_URL com o valor de exemplo |
falha rápida no boot |
1. Relógio: fuso é do container, hora é do host
A imagem ruby:3.2.2-slim sobe em UTC. O Rails mostrava a hora certa
(config.time_zone), mas tudo do sistema operacional ficava adiantado — e o
pior caso não era visual:
O cron rodava no horário errado.
every "*/30 8-18"executava das 05h às 15h de Brasília.
O Dockerfile agora fixa TZ=America/Sao_Paulo e o symlink de
/etc/localtime — os dois, porque o cron do Debian lê o arquivo, não a variável.
Mesmo tratamento no whatsapp/Dockerfile (Alpine precisa do pacote tzdata).
A hora em si não dá para sincronizar de dentro do container: ele lê o clock do
kernel do host, e rodar NTP lá dentro exigiria CAP_SYS_TIME. Por isso
deploy/ntp-seguro.sh, que roda uma vez no servidor e instala chrony com
NTS (RFC 8915): troca de chaves por TLS (TCP 4460) e pacotes NTP assinados.
NTP comum é UDP sem autenticação, e hora errada aqui muda o dia da
consolidação, o recorte do período financeiro e a validade da sessão. O script
usa 3 fontes independentes, desliga as não autenticadas (deixar o pool
padrão anularia o ganho), desativa o systemd-timesyncd e liga o rtcsync.
sudo bash deploy/ntp-seguro.sh
chronyc tracking # 'System time' na casa dos milissegundos
chronyc -N authdata # NTS ativo em cada fonte
2. Restart automático e migrations
restart: unless-stopped no serviço app. Escolhido em vez de always porque
always reergue o container até depois de um stop deliberado, tirando do
operador a chance de deixar o sistema fora do ar de propósito.
O boot saiu do command: de uma linha e virou bin/docker-boot:
- relógio — imprime hora e fuso (se aparecer UTC, o rebuild não pegou);
- pid órfão; 3. gems (o volume
bundle_cachesombreia os da imagem); - migrations —
db:prepare, com espera pelo banco; - cron (best-effort); 6. Puma.
A migration é automática a todo restart. A espera do passo 4 existe porque o
PostgreSQL é externo ao compose: num reboot o Rails pode subir antes de o banco
aceitar conexão e, com unless-stopped, isso viraria loop de reinício
parecendo erro de migration.
Falha rápida: DATABASE_URL vazia ou com o valor de exemplo não é "banco fora
do ar" — nenhuma espera resolve. O boot para na primeira tentativa dizendo o que
preencher, com a credencial mascarada no log. Este projeto não tem
config/database.yml: a conexão inteira sai dessa variável, e ninguém lê
DB_HOST/DB_NAME/DB_USER/DB_PASSWORD.
3. Arquivos que o git não levava — e por isso sumiram juntos
A pasta do deploy foi recriada e levou embora todo arquivo não versionado. Dois apareceram:
.env— está no.gitignorepor conter senha, e assim deve continuar. Guarde uma cópia fora da árvore do projeto e prefiragit pull/reset --hardna própria pasta a recriá-la (arquivo ignorado sobrevive ao pull).config/storage.yml— não tinha motivo para estar de fora. O app fazrequire "rails/all", então o Active Storage sempre carrega e osconfig/environments/*.rbdefinemactive_storage.service = :local: com um serviço definido, o Rails exige o arquivo noinitialize!e o servidor não sobe. Agora é versionado.
4. Configuração do ambiente saiu do .env e entrou no compose
A porta do teste (3001, porque a 3000 está ocupada por outro stack) vivia como
alteração local no docker-compose.yml do servidor — e um git reset --hard
a levou junto, derrubando o ambiente com o proxy apontando para uma porta sem
ninguém escutando. Agora os valores não secretos têm padrão no compose:
ports:
- "${PORTA_APP:-3001}:3000"
environment:
TZ: ${TZ:-America/Sao_Paulo}
APP_HOST: ${APP_HOST:-teste.reemtransportes.com.br}
APP_NAME: "${APP_NAME:-Reem Logística}"
WHATSAPP_URL: ${WHATSAPP_URL:-http://whatsapp:3001}
HOSTS_PERMITIDOS: ${HOSTS_PERMITIDOS:-100.75.222.23}
${VAR:-padrão}: o .env continua mandando; o padrão só entra quando a
variável falta. Segredos (DB_*, SECRET_KEY_BASE, SIMPLIROUTE_TOKEN,
WHATSAPP_TOKEN, SMTP_*, NOTIFICACAO_SECRET) nunca vão para o yaml.
⚠️
APP_HOSTé só o host, semhttps://. O código monta os links como"https://#{APP_HOST}/motorista"(consolidacao_mailer.rb,notificacao_service.rb,gatilhos.rb): com o esquema no valor saihttps://https://…e todo link de e-mail e WhatsApp quebra.
5. Produção de verdade: o que só aparece fora do modo development
O servidor passou a rodar RAILS_ENV=production, e três coisas apareceram:
config.hosts— a linha que parecia só liberar oAPP_HOSTna verdade liga a verificação e recusa todo o resto. Agora entram tambémlocalhost/127.0.0.1(para ocurlde dentro do NAS servir de diagnóstico) e a listaHOSTS_PERMITIDOS.i18n.fallbacks = truesignifica "caia nodefault_locale" — que aqui é o próprio pt-BR. O fallback apontava para si mesmo e chave ausente viravaTranslation missingna tela do usuário (tela de login). Agora[:en], igual aoapplication.rb, e opt-BR.ymlganhou as traduções do Devise (failure,sessions,passwords) — o projeto não usadevise-i18n.assume_sslestava fixo, entãorequest.base_urlviravahttps://e oOrigin: http://de quem abre pelo IP não batia: todo POST voltava 422 ("recusada por uma verificação de segurança") enquanto os GETs passavam e a tela parecia normal. Foi o que quebrou a importação do plano. Um gateFORCE_SSL=falsedevolve o acesso por HTTP para diagnóstico; sem a variável, HTTPS continua obrigatório.
Proxy reverso: o destino tem que ser HTTP na porta do container. Com
https://o Puma respondeAre you trying to open an SSL connection to a non-SSL Puma?.
6. db/schema.rb entrou no .gitignore
Nunca foi versionado (o schema vive nas migrations). Como o compose monta
.:/app, o db:prepare do boot escreve o arquivo no host — e um git add .
no meio de um rebase o capturou e virou conflito de deploy. Em
RAILS_ENV=production ele nem seria gerado (dump_schema_after_migration = false).
Deploy é
git fetch+git reset --hard origin/<branch>, nãopull --rebase: o container reescreveGemfile.lockedb/schema.rbno host, então o checkout produz alteração local sozinho e todo rebase bate de frente com ela.
▶️ Deploy
sudo bash deploy/ntp-seguro.sh # 1x no servidor
git fetch origin && git reset --hard origin/teste
docker compose up -d --build # --build: o fuso entra na imagem
docker compose logs app | grep '\[boot\]'
curl -I http://127.0.0.1:3001/ # separa "app caiu" de "proxy errado"
📂 Arquivos
Dockerfile / whatsapp/Dockerfile # TZ + tzdata + /etc/localtime
docker-compose.yml # restart, PORTA_APP, APP_HOST/NAME, TZ, HOSTS_PERMITIDOS
bin/docker-boot # boot documentado: espera do banco, cron, Puma — NOVO
deploy/ntp-seguro.sh # chrony + NTS no host (1x, root) — NOVO
config/storage.yml # versionado — NOVO
config/environments/production.rb # hosts, i18n fallbacks, gate do FORCE_SSL
config/locales/pt-BR.yml # traduções do Devise
.gitignore # db/schema.rb
.env.example # TZ, PORTA_APP, HOSTS_PERMITIDOS, APP_HOST sem esquema
🚚 Romaneio — a tela recuperou as funções do programa antigo
Referência: o Romaneiro PDF (GADE Hospitalar), o programa em Python que a operação usava. O PDF já estava fechado; o que faltava era a tela.
Veículos: lista visível, não um <select>
Era um <select> com ‹ ›, escolhido para não virar armadilha de scroll com 72
carros. Resolveu o scroll e criou outro problema: o operador via um carro por
vez e perdia a contagem de paradas de cada um — que é justamente a conferência
feita antes de imprimir.
Agora é a lista inteira, cada item com a contagem (GADE_038 · 31), o atual em
laranja, rolagem própria, e as setas ‹ › preservadas para percorrer carro a carro.
Cada veículo é um link de verdade (?veiculo=GADE_038): volta com o botão
"voltar" do navegador e sobrevive a um F5. No celular vira faixa horizontal —
72 itens empilhados empurrariam a tabela para fora da primeira tela.
Na mesma coluna, como no original: motorista do veículo, PDF deste veículo e PDF de TODOS os veículos.
Prévia sob demanda, em sobreposição
Antes o <iframe> do PDF era montado em todo carregamento da tela — e cada
montagem gerava um PDF no servidor, inclusive quando ninguém ia olhar. Agora é o
botão "Ver prévia do PDF": abre sobreposta, fundo escurecido, ✕/Esc/clique
fora fecham (clique dentro do PDF não fecha, senão rolar o documento fecharia
a prévia). Editar com a prévia fechada só a marca como desatualizada; o PDF novo
sai no próximo "Ver prévia".
Busca com "Limpar" e contador vivo
4 de 31 paradas — filtrando por "jardim". A contagem saiu do cabeçalho da
tabela: dois números para a mesma coisa, um atualizando e o outro não, é a
divergência que a diretriz 1 proíbe. Sem contador, uma busca que não casa com nada
deixa a tabela vazia e parece plano vazio.
NOVOS × RECORRENTES no topo
184 com aparelho (NOVOS) · 1863 recorrentes
É o equivalente à caixa de diálogo que o programa antigo mostrava ao aplicar o status. Verde quando há novos, âmbar quando é zero — e zero quase sempre significa importação sem operação vinculada, em que a coluna APARELHO sai vazia para todos. Um romaneio assim parece idêntico a um certo e só revela o erro no papel, com aparelho não entregue.
Logo escolhível
Era fixo em public/logo-gade.png. Agora vem de Configuracao (chave
romaneio_logo, separada do empresa_logo, que é a marca da Reem — o romaneio é
documento do cliente). PNG ou JPG até 2 MB, gravado em storage/logos/ e
não em public/: o que o operador sobe não vira arquivo servido pela web sem
autorização, e o nome do arquivo é gerado pelo sistema, nunca o que vem do
navegador.
Operação deduzida pelo NOME DO PLANO
A operação não trabalha por data — identifica o plano pelo nome
(ENTREGAS EMAD 09.2026). E é a operação do mês que diz quem é NOVO.
Romaneios::CasadorDeOperacao compara por conjunto de palavras + (mês, ano),
não por texto, porque as diferenças são sistemáticas:
| Nome do plano | Tabela da operação |
|---|---|
ENTREGAS EMAD 09.2026 |
gade_entregas_emad_set_2026 |
UBS SUL E LESTE AGOSTO 2026 |
gade_entregas_ubs_sul_leste_ago_2026 |
AVULSAS 3 EMAD/SUDESTE AGOSTO 2026 |
gade_entregas_avulsas_3_emad_sudeste_ago_2026 |
Três armadilhas: o conectivo E, o mês abreviado × por extenso, e o mês
numérico (09.2026) — este só lido como mês quando vem colado ao ano, senão o
3 de "AVULSAS 3" viraria março. Usa a tabela MESES do próprio Operacao
(via Operacao.mes_numero) em vez de duplicá-la.
Empate devolve nil de propósito. Vincular a operação errada é pior do que
não vincular: APARELHO e TELEFONE sairiam preenchidos com dados de outro mês e
ninguém desconfiaria. A tela avisa quando ficou sem operação, e a importação diz o
que deduziu ("Operação EMAD SET 2026 vinculada pelo nome do plano") — dedução
silenciosa que acerta é invisível, mas a que erra é indefensável.
Sobre buscar o plano por nome na API
✅ CORRIGIDO EM 28/08/2026 — o "não é possível" abaixo estava ERRADO
GET /v1/routes/plans/existe e devolve os planos com onameque a operação usa. Verificado contra a API real (bin/sondar_planos): 138 planos, 185 KB, 0,86 s, nomes comoEMAD SETEMBRO 2026eUBS OESTE AGOSTO 2026. Não é documentado. O endpoint até estava na lista de candidatos dobin/sondar_plano_do_dia— o que faltou foi rodar o script; a conclusão "não é possível" foi tirada só da documentação.A cadeia inteira fecha, sem adivinhar data nenhuma:
chamada devolve GET /v1/routes/plans/name,start_date,end_date,created,routes[]GET /v1/routes/routes/{uuid}/vehicle(id),planned_date,total_visits,planGET /v1/plans/routes/{uuid}/visits/order,vehicle_id,reference(NF),titleGET /v1/routes/vehicles/traduz 634185→GADE_057Nenhum filtro funciona nessa rota:
ordering,limit,page,page_size,search,name,planned_date,planned_date__gte,created_at__gteestatusdevolveram os mesmos 138 itens do controle sem parâmetro. Então "os 5 mais recentes" é corte em Ruby depois de baixar tudo —SimpliRoute::Client#planos. Ordena porcreated, e não porstart_date, porque a janela do plano é um INTERVALO (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09) — era exatamente isso que tornava a data impossível de adivinhar.Pela NF também fecha: a visita traz
route(uuid) e a rota trazplan(uuid).Lição: conclusão tirada de documentação não é conclusão. A própria memória desta integração já dizia que a API ignora parâmetro desconhecido em silêncio — ela também não anuncia as rotas que tem.
O texto original, mantido para contexto de como se chegou à conclusão errada:
Não é possível. A documentação (https://documentation.simpliroute.com) não expõe endpoint que liste planos — só
POST /v1/plans/create-plan/(onde o plano temname),GET /v1/plans/{planned_date}/vehicles/eGET /v1/plans/routes/{PLAN_ID}/visits/. A lista de planos que aparece no site deles é da interface web. Por isso o nome é colado pelo operador e casado localmente.
📂 Arquivos
app/views/admin/romaneios/_veiculos.html.erb # lista de veículos — NOVO
app/views/admin/romaneios/_controles.html.erb # busca + Limpar + contador vivo
app/views/admin/romaneios/show.html.erb # 2 zonas + prévia em sobreposição
app/views/admin/romaneios/index.html.erb # logo + "deduzir pelo nome do plano"
app/javascript/controllers/romaneio_controller.js # navegação, filtro, overlay
app/services/romaneios/casador_de_operacao.rb # nome do plano → operação — NOVO
spec/services/romaneios/casador_de_operacao_spec.rb # os nomes reais — NOVO
app/models/romaneio.rb # contagem_aparelho (NOVOS x recorrentes)
app/models/operacao.rb # mes_numero público
app/models/configuracao.rb # chave romaneio_logo
app/services/pdf/romaneio_pdf.rb # logo configurável
app/controllers/admin/romaneios_controller.rb # atualizar_logo + dedução da operação
config/routes.rb # POST atualizar_logo
🩺 28/08/2026 — "A primeira vez que entra dá tela de erro" + os assets 404 que o Cloudflare escondia
Dois problemas achados a partir de um relato ("a primeira vez que entra dá a
tela de erro, em /auth/login"). O primeiro era o relatado; o segundo apareceu
com o navegador aberto na mesma tela e era o mais grave dos dois.
| Sintoma na tela | Causa real | Onde se resolveu |
|---|---|---|
| Primeira entrada do dia parece falha do sistema | devise.failure.unauthenticated sai como flash[:alert] — vermelho, e duas vezes |
Users::SessionsController#new |
| Carrossel e editor de romaneio mortos no teste | RAILS_ENV=production + nada no deploy pré-compilava assets |
bin/docker-boot (passo 5) |
1. O "erro" que era só o Devise pedindo login
Quem abre teste.reemtransportes.com.br deslogado — ou seja, toda primeira
entrada — passa por authenticate_user!, que redireciona para /auth/login
gravando flash[:alert] = "Para continuar, faça login.". Como alert, a
mensagem saía em vermelho e em dois lugares ao mesmo tempo: o toast com
triângulo de alerta no canto superior direito (layouts/application.html.erb) e
a caixa vermelha acima do formulário (devise/sessions/new.html.erb).
Entrar no sistema pelo caminho normal ficava com a cara de sistema quebrado. E
como na segunda visita a pessoa já está em /auth/login sem flash nenhum, a tela
aparecia limpa — daí o "só na primeira vez", que fazia parecer bug intermitente.
A mensagem agora é descartada: a tela já é o formulário de login, o aviso não acrescenta nada.
# app/controllers/users/sessions_controller.rb
flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.unauthenticated', default: nil)
⚠️ O filtro é pela mensagem exata, e não flash.delete(:alert) seco. Senha
errada, conta desativada e timeout ("Sua sessão expirou") chegam ao mesmo
#new pelo mesmo caminho — essas precisam continuar vermelhas. Apagar o
alerta inteiro no new deixaria a pessoa digitando a senha errada para sempre
sem entender por quê.
2. Os assets 404 — e por que ninguém tinha visto
O console da tela de login acusava dois módulos:
Failed to fetch dynamically imported module:
/assets/controllers/carrossel_controller-913b47f7.js -> 404
/assets/controllers/romaneio_controller-0105d794.js -> 404
Não é pouco: carrossel_controller é a paginação por tamanho de tela (a
regra 3 do CLAUDE.md) e romaneio_controller é o editor de romaneio inteiro.
Os dois estavam mortos no ambiente de teste.
O disfarce
À primeira vista pareciam só dois arquivos com problema — os outros dez controllers respondiam 200. Não respondiam. Os 200 vinham do cache do Cloudflare:
$ curl -sI .../validacao_controller-ca4de84f.js
cache-control: public, max-age=31536000, immutable
age: 275844 # ~3,2 dias em cache
cf-cache-status: HIT # ← nunca chegou no servidor
Os assets saem com max-age de um ano. Os dez que "funcionavam" eram cópias
guardadas na borda dias antes; os dois quebrados eram os dois arquivos alterados
depois dessa fotografia. Batendo direto na origem (com querystring, para
furar o cache), todos davam 404.
Lição de diagnóstico: ao investigar asset em produção, olhe
cf-cache-statusantes de concluir qualquer coisa.HITnão é prova de que o servidor está servindo — é prova de que ele serviu algum dia.
A causa
O corpo do 404 era public/404.html, ou seja, a requisição chegou no roteador
do Rails: o servidor de assets do Propshaft nem estava montado. E a resposta do
HTML não trazia o cabeçalho Server-Timing, que só existe em development
(config.server_timing). Somando os dois: o servidor roda
RAILS_ENV=production.
Em production o Propshaft não compila sob demanda — ele resolve o caminho
pelo public/assets/.manifest.json e o arquivo digerido tem que existir em
disco. E nada no deploy gerava isso:
- a linha
RUN bundle exec rails assets:precompiledoDockerfileestava comentada (e não adiantaria descomentar: o compose monta.:/apppor cima e apagaria opublic/assetsda imagem); bin/docker-bootnão tocava no assunto;/public/assetsé gitignorado.
Resultado: o que existia no servidor era o resto de algum precompile manual
antigo. Todo JS alterado depois dele virava 404 silencioso — silencioso porque a
tela continua abrindo (o Tailwind vem de CDN e os ícones são public/
direto), só sem comportamento.
A correção
Novo passo 5 no bin/docker-boot, que roda em todo start de container — logo,
em todo deploy:
if [ "${RAILS_ENV:-development}" = "production" ]; then
rm -rf public/assets tmp/cache/assets
bundle exec rails assets:precompile
else
rm -rf public/assets # em dev o Propshaft calcula o digest ao vivo
fi
- Limpa antes de pré-compilar: sem isso um manifesto antigo convive com
arquivos novos e a divergência volta na primeira alteração de JS. Assim
manifesto e arquivos saem sempre da mesma execução. (
rm -rfem vez derails assets:clobber: faz o mesmo sem pagar outro boot do Rails.) - Falha não derruba o boot:
exit 1aqui viraria loop de reinício com orestart: unless-stoppede deixaria todo mundo de fora. O erro é gritado no log — o que não podia continuar é a falha muda. - Em development apaga
public/assets: sobra de precompile faz o Propshaft voltar a resolver pelo manifesto, e aí alteração de JS só aparece depois de recompilar — parece cache do navegador quando não é.
Como conferir depois do deploy
# 1. os assets do HTML respondem na ORIGEM (querystring fura o cache do CDN)
curl -s https://teste.reemtransportes.com.br/auth/login \
| grep -o '/assets/controllers/[a-z_]*-[a-f0-9]*\.js' | sort -u \
| while read a; do
printf "%s -> " "$a"
curl -s -o /dev/null -w "%{http_code}\n" "https://teste.reemtransportes.com.br$a?cb=$RANDOM"
done
# 2. o passo apareceu no log do boot
docker compose logs app | grep '\[boot\] assets'
Todos têm que responder 200. Se algum der 404, o precompile não rodou ou
falhou — o log do passo 5 diz qual dos dois.
📂 Arquivos
app/controllers/users/sessions_controller.rb # descarta o "faça login" vermelho
bin/docker-boot # passo 5: clobber + precompile
Dockerfile # tira a linha de precompile morta e explica por quê
🆕 Atualização 28/08/2026 — PLANO: Módulo do Cliente (aprovado, NÃO implementado)
⚠️ Isto é um plano de implementação, não uma feature entregue. Foi escrito como ordem de serviço para ser executado em fases (inclusive por modelos menores), com o levantamento do código já feito. Implementar uma fase por vez, na ordem, validando cada uma antes da próxima.
O que é o módulo
Hoje o cliente (Gade Hospitalar) manda uma planilha por mês/operação, que
alguém sobe no banco como tabela gade_entregas_* (fora do sistema). O módulo
do cliente substitui esse fluxo: uma área /cliente com login próprio onde
o próprio cliente:
- Cria operações (ex.: "UBS NORTE — setembro/2026") e lança entregas nelas;
- Adiciona entregas em operações existentes;
- Lança entregas avulsas (fora de qualquer operação);
- Cadastra em massa via planilha .xlsx (o módulo é um espelho da planilha atual — mesmos campos), com conferência antes de gravar;
- Recebe detecção de duplicidade na hora: "você tem X apontamentos duplicados" e "a NF Y já está roteirizada / em curso".
🔒 Regras invioláveis (valem para TODAS as fases)
db_reem_simplerout_2026e asgade_entregas_*são somente leitura: nunca migration, nunca INSERT/UPDATE/DELETE/DROP nelas. O módulo grava apenas em tabelas próprias novas.- Todo nome de tabela externa que entra em SQL passa por
Operacao.sanitizar+quote_table_name(padrão já usado no projeto inteiro). - NÃO transformar
app/models/operacao.rbem ActiveRecord. Ele é um PORO (só métodos de classe) que descobre as tabelas externas pelo catálogo do Postgres e é usado por ~8 services. Os models novos têm outros nomes. - Seguir o CLAUDE.md do projeto: número na tela é contrato; modo de visualização
em aba, não tela nova; mobile-first com alvos de 48px; comentário explica o
porquê; validar com Docker (
ruby -c/ actionview) antes de entregar. - Ao final de cada fase: listar os
git addpor nome (commit/push são do usuário) e lembrar que o deploy para o teste é manual.
📌 Fatos do código que o implementador precisa saber
| Assunto | Onde está | O que copiar |
|---|---|---|
| "Operação" hoje | app/models/operacao.rb (PORO) |
catálogo/whitelist (nomes_validos, sanitizar, label, MESES) — só consumir |
| Colunas da planilha do cliente | app/services/simpli_route/planilha_carga.rb (COLUNAS_OP) |
nota_fiscal, nome_completo, endereco_sem_complemento, complemento, supervisao, telefones; geo nas gade é lat/long |
| Esquema variável por mês | app/services/romaneios/enriquecimento_gade.rb |
detecção de colunas via conn.columns(tabela); só nota_fiscal é garantida |
| Dedup existente (melhor modelo) | app/services/romaneios/importador.rb#deduplicar + RomaneioLinha.chave_para + app/services/romaneios/normalizador.rb |
chave normalizada (NF + título + endereço), primeira ocorrência vence, contagem reportada |
| Rastreio (roteirizado/em curso) | app/models/entrega.rb (db_reem_simplerout_2026, readonly?=true) |
scopes da_conta_gade, por_nf; status da visita indica o andamento |
| Ler .xlsx | gem roo (~2.10, já no Gemfile; usada no romaneio) |
|
| Gerar .xlsx | gem caxlsx (já no Gemfile; usada na planilha SimpliRoute) |
|
| Papéis/permissões | app/models/user.rb (enum role), app/models/permissao.rb, app/models/perfil_acesso.rb, policies em app/policies/ |
Permissao::TODAS + GRUPOS + PADRAO_POR_ROLE (esquecer o PADRAO tira acesso silenciosamente — aviso no próprio model) |
| Migration de permissão (modelo) | db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb |
sincronizar perfis existentes |
| Rotas | config/routes.rb |
seguir o padrão namespace :admin; atenção ao aviso do Inflector pt-BR no próprio arquivo (singular == plural gera _index) |
| Notificações (evento novo) | infra em app/models/notificacao_* / app/services/notificacao/ |
criar evento "cliente lançou entregas" na Fase 5 |
Não existe hoje nenhum conceito de "cliente" no código (nem model, nem coluna, nem namespace) — a fundação é a Fase 0.
Fase 0 — Fundação de acesso (/cliente existe e loga)
User: adicionarcliente: 5ao enumrole.Permissao: novas chaves no grupo novocliente:cliente.ver,cliente.operacoes_criar,cliente.entregas_lancar,cliente.importar_planilha,cliente.avulsas— emTODAS,GRUPOSePADRAO_POR_ROLE(roleclienterecebe todas ascliente.*e NADA de dashboard/consolidação/admin).HOME_POR_PERMISSAO/home_rota: usuário comcliente.vercai em/cliente.- Rotas:
namespace :clientecomroot,resources :operacoes(conferir o plural com o Inflector),resources :entregas,resource :importacao. - Layout
app/views/layouts/cliente.html.erb: identidade visual do projeto (dark, laranja#f97316), sem menus de admin; menu próprio: Operações · Avulsas · Importar planilha · Sair. - Policies Pundit novas baseadas em
user.pode?('cliente.…'); os controllers de/clienteherdam de umCliente::BaseControllerque bloqueia quem não temcliente.ver. - Migration sincronizando perfis + seeds: perfil "Cliente" e um usuário de
teste
cliente@reem.com.
Pronto quando: login com usuário cliente cai em /cliente (vazio, com estado
vazio instrutivo), e esse usuário NÃO acessa /dashboard, /consolidacoes nem
/admin/* (redireciona para /sem-acesso); admin continua acessando tudo.
Fase 1 — Modelagem (tabelas próprias)
ClienteOperacao(cliente_operacoes):nome,mes,ano,status(enum:rascunho→enviada→roteirizada→fechada),criado_por_id(FK users), timestamps. Índice único em[nome, mes, ano](case-insensitive).ClienteEntrega(cliente_entregas):cliente_operacao_id(nulo = avulsa), campos espelhando a planilha atual —nota_fiscal,nome_completo,endereco_sem_complemento,complemento,supervisao,telefones,lat,long,observacoes— maischave_dedup(string normalizada),origem(enum:manual/planilha),criado_por_id, timestamps.chave_dedup: gerada em callback com a MESMA normalização deRomaneios::Normalizador(extrair para um módulo compartilhável se preciso, sem quebrar os chamadores atuais). Índice único parcial em[cliente_operacao_id, chave_dedup]— e validação amigável antes do índice.- Validações:
nota_fiscalobrigatória;nome_completoou endereço presentes.
Pronto quando: migrations rodam no Docker, models com validações e specs de unicidade; nada de tela ainda.
Fase 2 — Telas /cliente (cadastro individual intuitivo)
- Lista de operações: cards (nome, mês/ano, nº de entregas, status, botão "Adicionar entrega"); estado vazio dizendo o que fazer ("Crie sua primeira operação ou importe uma planilha").
- Criar operação: form curto (nome + mês/ano), sem jargão técnico.
- Adicionar entrega: form mobile-first (alvos 48–56px), campos na ordem da planilha, busca de endereço livre; ao salvar, o painel de duplicidade da Fase 4 responde na mesma tela (inline, não outra página).
- Entregas avulsas: mesma tela de lançamento com aba/rótulo "Avulsa (sem operação)" — mesma unidade, rótulo explícito (regra 1 do CLAUDE.md).
- Lista de entregas da operação com edição inline (padrão Stimulus do projeto,
ver
romaneio_controller.js) e remoção com confirmação.
Pronto quando: fluxo completo criar operação → lançar 3 entregas → editar →
remover, no desktop e no celular (validar com agent-browser + set device).
Fase 3 — Importação em massa (.xlsx)
- Modelo para download: gerar com
caxlsxuma planilha com o cabeçalho exato dos campos da Fase 1 + 2 linhas de exemplo (o cliente já conhece esse formato — é a planilha que ele manda hoje). - Upload:
roolendo .xlsx; mapear cabeçalhos com tolerância (acentos, caixa, espaços — reusar normalização existente). - Tela de conferência ANTES de gravar (nada entra direto): resumo com N válidas · N com erro (motivo por linha) · N duplicadas (painel da Fase 4); o cliente escolhe "Importar só as válidas" ou cancelar.
- Gravação transacional (
insert_allou transaction com validação); relatório final na tela com os mesmos números da conferência. - Limites e mensagens: arquivo até ~5 MB / ~5.000 linhas; erro de formato tem mensagem em português dizendo o que corrigir.
Pronto quando: importar a planilha-modelo preenchida com linhas repetidas de propósito grava só as válidas e reporta as duplicadas com os números certos.
Fase 4 — Duplicidade inteligente (Cliente::DetectorDuplicidade)
Serviço único usado pelo form individual (Fase 2) e pela importação (Fase 3).
Entrada: coleção de entregas candidatas (+ operação alvo). Saída: para cada
candidata, status_duplicidade e a evidência. Quatro camadas, nesta ordem:
- Dentro do lote: chave normalizada repetida no próprio arquivo/form —
padrão de
Romaneios::Importador#deduplicar(primeira vence, conta o resto). - Contra o módulo:
ClienteEntregajá gravada com a mesmachave_dedup(na mesma operação ou avulsa do mesmo mês). - Contra as
gade_entregas_*do mesmo mês/ano: SELECT read-only (tabelas viaOperacao.sanitizar+quote_table_name; sónota_fiscalé garantida — detectar colunas viaconn.columnscomo noEnriquecimentoGade). - Contra o rastreio (
Entrega.da_conta_gade.por_nf): se a NF já existe no espelho, classificar pelo status da visita — "já roteirizada" (planejada) ou "em curso/entregue" (checkout feito). Conferir os valores reais de status no espelho antes de fixar o mapeamento.
Na tela (regra 1 do CLAUDE.md — explicar onde o número aparece):
resumo "⚠️ X apontamentos duplicados neste lançamento" + lista expandível
inline com a camada que acusou cada um ("já lançada nesta operação",
"consta na planilha de agosto", "NF 79093 já roteirizada em 27/08").
Duplicata avisa e bloqueia por padrão, com opção explícita "lançar mesmo
assim" (grava com flag duplicada_confirmada para auditoria).
Pronto quando: os 4 cenários têm teste (fixtures/factories) e a tela mostra a contagem e o motivo camada por camada.
Fase 5 — Lado admin + integração
- Visão admin: aba/tela em
/adminlistando lançamentos do cliente por operação/status, com os MESMOS números que o cliente vê (recorte único). - Exportação: baixar as entregas de uma
ClienteOperacaono formato da planilha de carga do SimpliRoute (referência:SimpliRoute::PlanilhaCarga) — é o que fecha o ciclo: o que o cliente lança vira rota. - Notificação: evento novo na infra existente ("Cliente lançou N entregas na operação X") para o grupo do WhatsApp da operação.
- Auditoria: registrar criar/editar/excluir/importar do módulo no padrão de
auditoria existente (
/admin/auditoria_logs). - Transição de status da operação (
enviada→roteirizada) — manual pelo admin nesta fase; automatizar via rastreio fica para depois.
Pronto quando: admin vê, exporta e é notificado; auditoria registra.
Fase 6 — Validação e encerramento
- Adicionar à
docs/BATERIA-DE-TESTES.mdos casos: login cliente; criar operação; lançar entrega; importar .xlsx com duplicatas propositais e conferir as contagens; bloqueio de acesso do cliente às áreas de admin. - Rodar a bateria completa (
/bateria-testes) no ambiente de teste após o deploy manual. - Atualizar este README (marcar fases entregues) e o perfil "Cliente" nos seeds.