Go to file
2026-08-24 19:00:21 -03:00
2026-08-24 19:00:21 -03:00
2026-08-20 15:48:47 -03:00
2026-06-10 23:37:41 -03:00
2026-08-24 19:00:21 -03:00
2026-08-24 19:00:21 -03:00
2026-08-24 19:00:21 -03:00
2026-06-10 20:12:20 -03:00

🚛 Reem Logística — Sistema de Controle de Custos

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

Stack: Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus) Repositório: https://git.xenserver.com.br/Cludio-code/logistica-controle-custos


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


📋 CONTEXTO DO PROJETO (leia antes de tudo)

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

O banco já existe e é atualizado a cada 1 hora por outro sistema externo:

  • Tabela: public.db_reem_simplerout_2026
  • Account: Gade Hospitalar (account_id: 95907)
  • NUNCA fazer DROP TABLE, TRUNCATE, DELETE ou migration nessa tabela.

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


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

🚀 Setup do zero (próximo dev)

1. Clone

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 ativo que já existia → erro de coluna duplicada
  • Criava pin_acesso duplicando o pin_code existente
  • Corrigida: reescrita como 20260101000010_add_devise_trackable_to_users.rb — só trackable/lockable, timestamp correto

2. Campo de PIN inconsistente — Fase 2 usava pin_acesso, o model e as Fases 7/8 usam pin_code. PINs cadastrados no CRUD não funcionariam no login.

  • Corrigido: padronizado pin_code em todos os arquivos

3. Campo de nome inconsistente — CRUD de usuários usava :name, o schema usa nome → o CRUD quebrava ao salvar.

  • Corrigido: padronizado nome

4. Controllers fora do namespace — As rotas esperam Admin::UsuariosController e Admin::ConfiguracoesController, mas os controllers foram criados na raiz → /admin/usuarios dava "uninitialized constant" e os controllers ficavam inacessíveis.

  • Corrigido: movidos para app/controllers/admin/ + views para app/views/admin/ + paths atualizados (admin_usuarios_path etc.)

5. Dashboard (Fase 3) chamava métodos inexistentesEntrega.do_mes e Configuracao.mapa_de_precos foram sobrescritos pelo commit das Fases 4-6 → a raiz do site quebrava (root = dashboard#index).

  • Corrigido: scope do_mes e método mapa_de_precos restaurados nos models, junto com tipo_label/icone usados pelas views de configurações

6. AuditoriaLog com 2 assinaturas — Fase 2 chamava registrar(user, 'acao', ip) posicional; o model usa keyword args → ArgumentError em login PIN, CRUD de usuários e configurações.

  • Corrigido: todas as chamadas convertidas para a assinatura do model

7. Rotas/controllers ausentes

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

🟡 Duplicações removidas (arquivos órfãos)

  • app/controllers/motorista_controller.rb + app/views/motorista/index.html.erb — a rota /motorista usa Motorista::DashboardController (Fase 8)
  • app/views/layouts/_sidebar.html.erb — o layout renderiza _navbar.html.erb
  • Seeds dos 3 motoristas de teste (João/1234, Maria/5678, Carlos/9012) restaurados — tinham sido sobrescritos

⚠️ Observação sobre login PIN duplicado

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

📂 Arquivos das Fases 4, 5 e 6

Fase 4 — Job 1h + Auditoria:

config/schedule.rb                                  # Whenever — roda a cada 1h
lib/tasks/historico.rake                            # rake historico:atualizar
app/jobs/application_job.rb
app/jobs/atualizar_historico_estimado_job.rb        # conta entregas pagas × preco_entrega
app/controllers/api/v1/dashboard_controller.rb      # GET /api/v1/dashboard/metricas
app/controllers/concerns/auditavel.rb               # auditar!(:acao, registro) nos controllers
app/policies/dashboard_policy.rb

Ativar o cron no servidor: bundle exec whenever --update-crontab Testar manualmente: docker-compose exec app bundle exec rake historico:atualizar

Fase 5 — Consolidação + Wizard Passo 1:

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

Fase 6 — Validação + Toggles + Massa:

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

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

Regras implementadas:

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

Fase 7 — PDFs (Prawn + QR Code):

Gemfile                                             # + rqrcode, chunky_png
db/migrate/20260101000009_add_login_token_to_users.rb   # token para QR — rodar db:migrate!
app/services/pdf/base_pdf.rb                        # layout base: cabeçalho preto/laranja + rodapé
app/services/pdf/relatorio_motorista_pdf.rb         # tabela detalhada de entregas + resumo + total
app/services/pdf/extrato_pdf.rb                    # estilo contracheque + QR Code de login rápido
app/controllers/consolidacoes_controller.rb         # ATUALIZADO: gerar_pdf_relatorio/gerar_pdf_extrato/preview_extrato
app/views/consolidacoes/show.html.erb               # botões por motorista + modal de preview
app/views/consolidacoes/preview_extrato.html.erb   # preview HTML antes de confirmar o PDF
app/models/user.rb                                  # ATUALIZADO: regenerar_login_token!

Soft delete já implementado: consolidações finalizadas vão para "arquivada", nunca são excluídas.

Fase 8 — Painel Motorista + Notificações:

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

Notificações — como ativar:

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

Fluxo do motorista (público de baixo nível técnico):

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

🧪 Teste end-to-end sugerido

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

▶️ Próximos passos (deploy)

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

# 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::UsuariosControllertoggle_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 zero20260610_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 GadeDashboardController e HistoricoEstimado.{atualizar!,calcular_ao_vivo} contavam entregas de todas as contas. aplicado .da_conta_gade.

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

6. Gate de finalização + classificação sem validação

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

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

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

🆕 Migrations adicionadas (rodar db:migrate)

db/migrate/20260615000001_remove_pin_acesso_from_users.rb
db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb   # arquivado_por / arquivado_em

Suíte de testes

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

Como rodar:

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

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


🔧 Correções + funcionalidades (16/06/2026)

🔴 Erros de runtime corrigidos (reportados em prints)

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

🆕 Funcionalidades

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

⚙️ Automação / diagnóstico

  • docker-compose.yml: o boot roda rails db:prepare antes do servidor — não precisa mais rodar db:migrate na mão após o up.
  • rake reem:diagnostico: checa a tabela externa por conta/período/status/checkout e o intervalo de datas disponível (útil para depurar dashboard vazio).
docker-compose exec app bundle exec rake reem:diagnostico
docker-compose exec app bundle exec rake reem:diagnostico INICIO=2026-02-01 FIM=2026-06-30

🔧 Correções + funcionalidades (16/06/2026 — sessão 2)

🔴 Bugs corrigidos

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

🆕 Funcionalidades

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

🆕 Migration adicionada (rodar db:migrate)

  • 20260616000003_allow_null_email_for_motoristasusers.email passa a permitir NULL; e-mails "" existentes viram NULL.
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.key ou qualquer arquivo com senha
  • Repositório PRIVADO em git.xenserver.com.br
  • Use sempre ENV['VARIAVEL'] no código Ruby

🆕 Atualização 17/06/2026 — Pagamentos, Operações, Dashboard financeiro e UI

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

💳 Status de pagamento (Pago / Parcial / Pendente)

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

🗂️ Consolidações por OPERAÇÃO (tabelas externas gade_entregas_*)

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

📊 Dashboard financeiro

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

📅 Correção de data — usar checkout (data real), não planned_date

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

🧾 PDFs (Relatório e Extrato)

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

🎨 UI / Identidade visual

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

📌 Padrões a seguir (novos)

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

Migrations adicionadas hoje

20260617000001_add_pagamento_to_consolidacao_motoristas.rb
20260617000002_add_operacoes_to_consolidacoes.rb

Aplicar com: docker-compose exec app bundle exec rails db:migrate


🔧 Correções — Extrato PDF (18/06/2026)

🔴 Bugs corrigidos

  • 500 ao gerar extrato (gerar_pdf_extrato): Prawn::Errors::IncompatibleStringEncoding (Encoding::UndefinedConversionError). Os PDFs usavam a fonte embutida do Prawn (Helvetica/AFM), que só aceita o charset Windows-1252 — o caractere do selo "✓ PAGO" não existe nesse charset e derrubava a geração. Corrigido: BasePdf agora registra a família DejaVu Sans (TTF) e a define como fonte padrão (registrar_fonte_utf8), com suporte total a UTF-8. Vale para todos os PDFs (extrato e relatório) — qualquer caractere Unicode passa a funcionar.

    • Fontes versionadas em app/assets/fonts/DejaVuSans.ttf e DejaVuSans-Bold.ttf (a imagem Docker ruby:3.2.2-slim não traz fontes do sistema; o COPY . . do Dockerfile e o volume .:/app garantem que estejam disponíveis em runtime).
  • Assinaturas do extrato caíam na 2ª página: após o QR Code o cursor ficava abaixo do limite de quebra (start_new_page if cursor < 120), empurrando o bloco de assinaturas para uma página nova. Corrigido: espaçamentos compactados com segurança (padding das tabelas [4,8], QR Code menor) + folga antes das assinaturas reduzida (BasePdf#assinaturas, 60 → 36, limite de quebra 120 → 100) — o extrato agora cabe em uma única página.

  • QR Code sobrepondo / sumindo: o posicionamento por move_up/move_down era frágil e, com a fonte nova, fazia o QR se sobrepor ao texto/assinaturas. Corrigido: desenhar_qrcode agora desenha a imagem com posição absoluta (at:) — que não move o cursor — e avança o cursor manualmente pela altura da imagem. Também garantido que cada caixa (selo de pagamento, total) tenha folga ≥ a própria altura para não sobrepor o conteúdo seguinte.

  • QR Code não aparecia para alguns motoristas: o QR aponta para o painel do motorista (/motorista/acesso/:token), que exige uma conta de usuário (User motorista com login_token). Sem conta correspondente, user_motorista ficava nil e a seção do QR era omitida silenciosamente. Duas causas tratadas:

    • Nome não batia (espaços/maiúsculas) entre consolidacao_motoristas.motorista_nome (dado externo) e users.nome (digitado no admin). ConsolidacoesController#buscar_user_motorista agora tenta o match exato e, se falhar, compara nomes normalizados (sem espaços extras/duplos, case-insensitive).
    • Motorista sem conta: em vez de omitir, o extrato agora desenha um aviso ("Cadastro de acesso ainda não disponível… solicite ao administrador") no lugar do QR (ExtratoPdf#desenhar_aviso_sem_acesso). O QR só existe para motoristas com cadastro no site — comportamento esperado, já que sem conta não há painel para acessar.

📌 Padrão (novo)

  • PDFs com Prawn: usar sempre a fonte TTF registrada no BasePdf (DejaVu, UTF-8). Nunca contar com a fonte embutida — ela quebra em qualquer caractere fora do Windows-1252.

🆕 Atualização 19/06/2026 — Apontamento manual / Nota avulsa

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

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

🆕 Funcionalidades

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

⚙️ Como funciona (técnico)

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

🆕 Migration adicionada (rodar db:migrate)

20260619000001_add_manual_to_consolidacao_entregas.rb
docker-compose exec app bundle exec rails db:migrate

🧪 Testes

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

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

🆕 Funcionalidades

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

🔴 Bugs corrigidos

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

🔐 Segurança

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

🆕 Migration adicionada (rodar db:migrate)

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

Confirmado (25/06/2026)

  • O status de falha gravado pelo SimpleRoute é exatamente failedEntrega::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) OU failed (insucesso), sempre com checkout registrado. Substitui o antigo critério de só .pagas (completed) na consolidação.
  • Status visível em cada entrega na tela de validação (Passo 2): selo ✓ Entregue (verde) ou ⚠️ Insucesso (vermelho), com o card de insucesso destacado em borda vermelha — facilita visualmente identificar o que classificar.
  • Insucesso entra para classificação manual, não com valor automático: o admin escolhe o pilar/valor (Normal, Retirada, Bônus, Desconto, Extraordinária) — o motorista foi ao local, então a entrega aparece na lista, na contagem e na barra de progresso.

⚙️ Como funciona (técnico)

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

📂 Arquivos alterados

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

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


🆕 Atualização 25/06/2026 — Apontamento 100% manual + Arquivar motorista individualmente

Dois recursos na consolidação:

1. Apontamento 100% manual (tela de Validar — Passo 2)

Caso real: o motorista A foi até o local mas não entregou; no rastreio que alimenta o banco, a NF ficou registrada sob o motorista B (que de fato entregou). Logo, a busca por NF nunca encontra o A — mas o A também recebe, pois foi ao local. O apontamento manual antigo (busca a NF na base e puxa entrega.driver) não serve.

  • Novo lançamento 100% manual, sem consultar a base de rastreio, atribuível a qualquer motorista (seletor com os motoristas da consolidação, ou digita um novo), com NF (texto livre), observação/local e pilar(es) (+ valor da Extraordinária).
  • Botão " Apontamento 100% manual" na tela de Validar. Soma ao valor do motorista, mas não conta no gate X/Y classificadasmanual: true, igual aos apontamentos por NF).
  • A NF digitada aparece na Revisão (Passo 3), na tela da consolidação e no Relatório PDF do motorista (fallback nf_manual/obs_manual quando não há Entrega para consultar).

2. Arquivar motorista individualmente (reversível)

Numa consolidação em massa, dá para arquivar um motorista específico no Passo 1 (botão 🗄️ por card) — ele sai dos totais, do pagamento e do gate, mas os dados ficam guardados. Seção "🗄️ Motoristas arquivados" no fim do wizard com "♻️ Restaurar".

  • Não arquiva motorista já pago (estorne antes) — bloqueado com alerta.
  • Útil para finalizar a consolidação ignorando um motorista que não deveria estar nela / sem entregas, sem perder o registro.

⚙️ Como funciona (técnico)

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

📂 Principais arquivos

db/migrate/20260625000001_add_arquivamento_to_consolidacao_motoristas.rb
db/migrate/20260625000002_add_manual_fields_to_consolidacao_entregas.rb
app/models/consolidacao.rb · consolidacao_motorista.rb
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
app/controllers/dashboard_controller.rb · motorista/dashboard_controller.rb
app/javascript/controllers/apontamento_manual_controller.js   (novo)
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/views/consolidacoes/wizard.html.erb · show.html.erb
app/services/notificacao_service.rb · pdf/relatorio_motorista_pdf.rb · pdf/relatorio_financeiro_consolidacao_pdf.rb
config/routes.rb
spec/models/consolidacao_spec.rb · consolidacao_motorista_spec.rb
docker-compose exec app bundle exec rails db:migrate
docker-compose exec app bundle exec rspec

🆕 Atualização 25/06/2026 — Fechar por veículo na tela de Validar

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

🆕 Funcionalidades

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

⚙️ Como funciona (técnico)

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

📂 Arquivos

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

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

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

1. 📄 Entrega de termo (entrega sem NF nem código de rastreio)

Lançamento de termos entregues por um motorista — não têm nota fiscal nem tracking_id para atrelar, só motorista + quantidade a um preço fixo configurável.

  • Novo pilar tipo: termo (enum 5, cor azul) e novo preço "Entrega de Termo" editável em Admin → Configurações (entra automático no card, via CHAVES_MOEDA).
  • Lançado como uma linha por lote com a coluna nova quantidade; valor_aplicado guarda o total do lote (quantidade × preço), então recalcular_motorista! e todas as somas existentes seguem corretas. Modal com stepper N + (sem as setinhas nativas do input number).
  • Aparece na lista de Apontamentos manuais (badge "Entrega de Termo ×N"), nos PDFs (extrato, relatório do motorista e financeiro) e no painel de Resumo — sempre contando por quantidade (relatórios passaram de count para sum(:quantidade)).

2. 🧰 Caixa de ferramentas (toolbox) — botões unificados

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

3. 👷 Gestão de motoristas na consolidação

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

4. 📊 Resumo e barra de progresso com lançamentos manuais

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

5. 🧊 UX da tela de Validar

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

⚙️ Como funciona (técnico)

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

📂 Principais arquivos

db/migrate/20260626000001_add_quantidade_to_consolidacao_entregas.rb
db/migrate/20260626000002_add_preco_termo_configuracao.rb
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
app/controllers/consolidacoes_controller.rb · consolidacao_entregas_controller.rb
app/javascript/controllers/apontamento_termo_controller.js   (novo)
app/javascript/controllers/ferramentas_controller.js         (novo)
app/javascript/controllers/adicionar_motorista_controller.js (novo)
app/javascript/controllers/validacao_controller.js
app/views/consolidacoes/wizard.html.erb · show.html.erb
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/services/pdf/extrato_pdf.rb · relatorio_motorista_pdf.rb · relatorio_financeiro_consolidacao_pdf.rb
config/routes.rb · db/seeds.rb
docker-compose exec app bundle exec rails db:migrate
🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)

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

🔴 Bugs corrigidos

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

⚙️ Como funciona (técnico)

  • app/views/consolidacao_entregas/validar.html.erb:
    • Card da entrega: flex flex-col md:flex-row md:items-centerflex 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.5flex flex-wrap gap-1.5 lg:shrink-0 (flex-wrap impede o transbordo; lg:shrink-0 mantém os botões inteiros no desktop, deixando o texto da nota truncar em vez de espremer os botões).
  • Faixa crítica resolvida (7681023px): antes acumulava sidebar visível + layout em linha + botões sem quebra. Agora < 1024px o card fica empilhado com botões em flex-wrap e ≥ 1024px vai para linha com truncamento do texto.
  • Sem migration e sem mudança de JS/controller — alteração somente de classes Tailwind na view.
🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)

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

🆕 Funcionalidades

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

⚙️ Como funciona (técnico)

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

📂 Arquivos

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

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

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

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

🗺️ Mapa de Operações

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

📊 Dashboard de Operações (painéis)

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

🚚 Validação do motorista (consolidação)

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

🧭 Layout global (site inteiro)

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

📂 Arquivos

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

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


🔒 Segurança + auto-atualização do painel + cron (06/07/2026)

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

🩸 Causa-raiz descoberta — produção em modo development

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

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

🔴 Correções de segurança

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

🔄 Painel de Operações atualiza sozinho (sem F5)

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

🕑 Horário da "última atualização" corrigido

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

Agendamento (whenever/cron) no Docker

  • config/schedule.rb — job de histórico passou de every 1.hour para */30 8-18 (a cada 30 min, 08h-18h), batendo com a cadência real.
  • Dockerfile — instala o pacote cron.
  • docker-compose.yml — o boot agora roda, em ordem: db:preparewhenever --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, model Entrega) é read-only e sincroniza a partir do SimpliRoute. A correção não escreve nela — grava na API; o painel/consolidações só refletem na próxima sincronização (a tela avisa isso).
  • Dois identificadores: a visita tem id numérico (usado na URL de escrita) e tracking_id (SR..., que é a PK do espelho). O espelho não guarda o id numérico, então o serviço resolve listando as visitas da data (planned_date) e casando pelo tracking_id (fallback: NF).
  • Motivo = campo checkout_observation, que é o UUID de uma lista fixa de 14 motivos (GET /v1/routes/observations/, todos type=failed). É esse UUID que popula a coluna observationnão basta texto livre no comentário.
  • A gravação usa PATCH (só os campos alterados), para não sobrescrever assinatura/geo originais sem intenção. Token da API vem de SIMPLIROUTE_TOKEN (ENV, nunca no git).

🔧 Correção — botão estava no menu errado

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

📂 Arquivos

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

▶️ Deploy

# 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:0018:00, lat/long do mês anterior, etc.).

⚠️ Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.

  • Anti-duplicação: conferir reference + planned_date antes de criar (reexecutar não duplica).
  • Desfazer: POST /v1/bulk/delete/visits/ permite implementar um "remover carga".

2. 🧾 Relatório de comprovantes de entrega (POD)

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

3. Webhooks — painel em tempo real

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

4. 📱 Aviso ao paciente via WhatsApp

Evento on_its_way + ETA da API + Twilio já existente (NotificacaoService): "seu medicamento saiu para entrega". Reduz insucesso por RESPONSÁVEL AUSENTE (motivo real da lista de observations).

5. 📺 Monitor de rotas ao vivo

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

6. 👷 Sincronização de motoristas/veículos

GET /v1/accounts/drivers/ e GET /v1/routes/vehicles/ ↔ usuários motoristas do sistema (hoje o vínculo é o nome digitado — sujeito a divergência).

7. 📊 Datamart e 🏷️ tags/skills

Export paginado de analytics para enriquecer dashboards; tags/skills para classificar visitas por tipo de material.


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

📋 Planilha da Operação — página própria (/dashboard/operacoes/planilha)

Tabela espelho da planilha da operação (uma linha por NF) para pesquisar e acompanhar ocorrências/status, acessada pelo botão "📋 Planilha da operação" no Dashboard de Operações (o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).

  • Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo, Resultado (badge Entregue/Falha/Pendente), Ocorrência (motivo do insucesso) e status gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
  • Busca textual server-side (param q, qualquer campo) + paginação server-side (param pg, 15/página, janela « 1 … 7 8 9 … 42 ») — aguenta as milhares de linhas do Global.
  • Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos filtros herdados. Reusa as linhas do Analytics::OperacaoMetricas (novo método #buscar) — nenhuma query nova.
  • Arquivos: rota operacoes_planilha, action planilha + montar_tabela_espelho (OperacoesDashboardController), views planilha.html.erb + _espelho.html.erb.

⬇️ Download do Excel do cliente — planilha Entregas preenchida

Botão "⬇️ Baixar Excel preenchido" na página da planilha: gera o .xlsx no formato do modelo "Entregas SUDESTE MM.AAAA_FINAL" com as 3 abas, 1:1 — automatiza o preenchimento que era feito manualmente para entregar ao cliente.

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

Nomes de coluna — conferidos no banco real (22/07/2026)

Os nomes eram deduzidos e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes reais foram conferidos com Entrega.column_names e estão como 1º candidato em RASTREIO_COLUNAS; os chutes antigos continuam na lista como rede de segurança.

  • Espelho usa o padrão do export pt-BR: codrivers, responsible_person, advance, start_of_time_window_1..2, end_of_time_window_1..2, required_skills, optional_skills, comments. Corrigidos — antes eram copilots, receiver, early, window_start, etc.
  • Tabelas gade_entregas_* gravam a geo como lat/long (não latitude/longitude) — as duas colunas da aba ENTREGAS saíam vazias. Ver ALIAS_GADE.
  • Load 4 não existe no espelho (só load, load_2, load_3) — sai vazia de propósito.
  • Coluna protocolo_de_entrega existe no espelho e não estava em lugar nenhum: entrou como última coluna (AU), depois do layout AAT do modelo, para não deslocar nada do cliente.
  • ⚠️ Vazias por falta de dado na origem, não por bug: contact_phone, account_id, account_name (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute TEM o valor, conferido na NF 79093) e codrivers/required_skills/optional_skills/ contact_email/load_2/load_3 (vazios também no relatório baixado direto do SimpliRoute).
  • ⚠️ Os 2 gráficos embutidos do RESUMO não são replicados. "Conferência Documentos" é controle manual (sai "A iniciar").

🔄 Número de série da base (aparelho) — job em background

A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho nunca traz (0 de ~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da gade_entregas_*: os seriais de num_serie_base são de NFs disjuntas das que têm série no SimpliRoute (0 de 203 batem). O dado só existe na API, em extra_field_values — o mesmo hash das fotos do motorista.

  • SincronizarSeriesAparelhoJob varre a API por DATA (a API não filtra intervalo; cada dia são ~4 MB / ~9 s) em lotes de 6 threads e grava em series_aparelho (tabela nossa — o espelho é read-only). Um dia que não responde vira aviso, não derruba a rodada.
  • Analytics::PlanilhaEntregas#completar_serie completa as linhas em que o espelho veio vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando.
  • Agendado em config/schedule.rb às 01h (últimos 45 dias). Backfill de meses antigos: rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]" ou rake "simpli_route:series_periodo[2026-07-01,2026-07-31]".
  • Se a rodada varrer visitas e não achar nenhuma série, o log lista as chaves de extra_field_values que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo em CAMPOS_SERIE (foi chute de nome de campo que causou o bug acima).

📊 Dashboard financeiro

  • Paginação nas tabelas de pagamentos ("realizados" e "pendentes"): 10 linhas/página com abas e contador ("110 de 47"), client-side (função genérica paginarTabela); some com ≤10 itens.
  • Novo gráfico "Pagamentos por dia" abaixo da "Evolução do custo": barras verdes com o valor pago por data de pagamento (pago_em) + linha tracejada do acumulado — o fluxo de caixa, que não existia em nenhuma tela (build_grafico_pagamentos). Quando não há pagamento no período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado.

    Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo) e "Resultado por dia" (já existe na tela de Operações).

  • O card dos gráficos virou flex-col e preenche toda a altura ao lado do ranking de motoristas (sumiu o vazio embaixo da linha).

🔴 Correções

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

📌 Padrões (reforçados)

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

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


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

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

🆕 Funcionalidades

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

⚙️ Como funciona (técnico)

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

📂 Arquivos

db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb  (nova)
app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb
app/controllers/consolidacao_entregas_controller.rb
app/javascript/controllers/apontamento_termo_controller.js · validacao_controller.js
app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb
app/views/consolidacoes/show.html.erb
app/services/pdf/relatorio_motorista_pdf.rb
db/seeds.rb · spec/models/configuracao_spec.rb
docker-compose exec app bundle exec rails db:migrate

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

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

🎨 1. Ícones — emoji → SVG da marca

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

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

🖼️ 2. Editar Lançamento — galeria com TODAS as fotos

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

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

🚫 3. Por que NÃO dá para editar as fotos (limitação do SimpliRoute)

Investigamos todos os caminhos de escrita contra a API real. A limitação é do SimpliRoute:

Caminho Resultado
PATCH/PUT em extra_field_values (todos os formatos) HTTP 500 — o servidor deles quebra
Editar outros campos (status, notes) por PATCH Funciona (200) — o bloqueio é das fotos
Suspeita de "plano expirado" Descartada — visita ativa (de hoje) dá o mesmo 500
GET /v1/plans/visits/{id}/detail/ Somente leitura
Checkout mobile POST /v1/mobile/visit/{id}/checkout/ 403 — exige token de motorista; e refaz o checkout inteiro (hora/GPS/assinatura)
Webhook Canal de saída apenas — não escreve de volta
  • Webhook existente descoberto: a conta (Gade Hospitalar) tem um webhook visit_checkout_detailed apontando para um Google Apps Script (script.google.com/…/exec) — recebe os dados de cada entrega finalizada (inclui as URLs das fotos). É inbound, não serve para editar.
  • Conclusão: não há caminho administrativo (nem por API, nem pelo painel web do SimpliRoute) para corrigir uma foto após o envio do motorista. Só liberando escrita em extra_field_values (pela API) ou uma opção de substituir imagem no painel deles.
  • Ferramenta de diagnóstico: bin/sondar_fotos_simpliroute --visita <id> [--testar-escrita] — mostra as fotos de todas as fontes e testa (com segurança, mirando campo vazio e restaurando) se a escrita é aceita. Roda no servidor: docker compose exec app bin/sondar_fotos_simpliroute ….

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

📂 Arquivos

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

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


🔁 NF com mais de um lançamento + varredura de responsividade (2122/07/2026)

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

🎯 O problema real

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

🔴 Causas corrigidas (foram 7, em camadas)

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

🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026)

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

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


Parte 2 — Responsividade

🎯 A raiz comum

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

🔧 Corrigido

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

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

📐 Passo 2 (validar) — cabeçalho fixo

Medido a 1280×577: 442px de cabeçalho contra 67px de lista (menos que um card).

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

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

📂 Arquivos

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

⚠️ Pendências e alertas

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

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


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

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

🎯 O problema

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

Pior: as chaves notificacao_whatsapp e notificacao_email existiam em configuracoes mas nunca tiveram UIAdmin::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). adminConfiguracaoNotificacaoPolicy é mais restrita que ConfiguracaoPolicy, que libera index? para gerente: aqui ficam senha de e-mail e token do Twilio.

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

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

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

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

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

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

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

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

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

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

🩹 Bug pré-existente corrigido de passagem

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

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

🔐 Segurança

  • A senha gravada nunca volta para o HTML (password_field value: nil). Campo em branco significa "mantenha a atual" — salvar sem redigitar não apaga o que está lá.
  • AuditoriaLog registra a mudança com acao: 'editar_notificacoes', mas grava apenas smtp_password_definida: true/false. Nunca a senha nem o tokendados_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'
  1. Permissão: logar como gerente → o card não aparece e /admin/configuracao_notificacao redireciona com "Você não tem permissão". Como admin → a tela abre.
  2. 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.
  3. 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.
  4. Não regressão: com os toggles desligados, finalizar uma consolidação e conferir que nada mudou; e testar o "Esqueci minha senha", que trocou de mailer pai.

⚠️ Alertas

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

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


💰 Entrega sem sucesso entra no pagamento + aba Consolidado no ranking (2021/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ídasdashboard_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ásmotorista/dashboard_controller.rb (commit 4965931)

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

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

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

4. A barra do ranking contradizia a ordem do ranking_ranking_motoristas.html.erb (commit e9db6b7)

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

🆕 Aba "Consolidado" no ranking de motoristas

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

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

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

Dois detalhes decidem se a quantidade sai certa:

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

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

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

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

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

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

Consistência interna (as falhas entram em todo lugar, não só no card):

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

Aba Consolidado — reconciliação com os KPIs:

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

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

🧪 Specs — pendentes de execução

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

O que fica travado:

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

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

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

📂 Arquivos

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

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

Pendente — roteiro

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

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

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

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

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

🎯 O problema

Mesmo período filtrado, dois números diferentes:

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

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

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

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

🐛 Três defeitos reais por trás disso

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

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

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

🆕 O que mudou na tela

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

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

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

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

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/.erb foi conferida (com um checker que emula o handler ERB do Rails, porque <%= form_with … do %> não passa no ERB da stdlib) e a do server.js com node --check.

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

🎯 O problema

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

🆕 O que existe agora

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

⚙️ Pontos não-óbvios

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

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

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

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

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

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

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

⚠️ O risco, dito na tela

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

📂 Arquivos

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

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

Pendente — roteiro

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

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

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

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

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

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

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

🚧 O que NÃO está aqui (etapas 2 e 3)

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

🧱 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, .erb e no JavaScript do editor (node --check sobre o <script> extraído).

🎯 O que mudou

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

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

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

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

⚙️ Pontos não-óbvios

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

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

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

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

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

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

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

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

📂 Arquivos

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

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

Pendente

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-crontab e a suíte. Sintaxe conferida em todos os .rb, .rake e .erb.

🎯 O que faltava

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

🆕 Os três gatilhos, ligados

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

operacao_alterada → dois caminhos, de propósito:

  • Imediato: Admin::EdicaoLancamentosController#atualizar. Sabe exatamente qual NF e o que mudou (status: pending → completed).

  • Varredura: DetectarMudancasOperacaoJob compara os números de cada operação com o retrato anterior (operacao_snapshots) e avisa "3 notas na operação a mais, 1 em aberto a menos".

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

agendadoDispararEventosAgendadosJob, 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_contatogrupo_contatos e evento_notificacaoevento_notificacaos. As tabelas reais são grupos_contato e eventos_notificacao (plural no primeiro termo).

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

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

2. npm error syscall spawn git / ENOENT na build da ponte

errno -2 = o binário git não existe na imagem. O node:20-alpine não traz git, e o Baileys resolve libsignal de um repositório git, não do registry do npm.

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

3. Não havia como escolher o provedor

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

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

4. Evento criado pelo ADM não disparava

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

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


💡 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 coloridoWhatsApp ✓ · 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.

Description
No description provided
Readme 30 MiB
Languages
HTML 80.8%
Ruby 17.8%
JavaScript 1.3%
Shell 0.1%