From e5308153777cdbe04ea7a71d6c06f5cfae45c799a2778b0df3b591e3c1d79e71 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 11 Aug 2026 13:31:22 -0300 Subject: [PATCH 1/7] =?UTF-8?q?Adi=C3=A7=C3=A3o=20da=20fun=C3=A7=C3=A3o=20?= =?UTF-8?q?de=20configura=C3=A7=C3=A3o=20de=20email=20e=20whatts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 18 ++ .../configuracao_notificacoes_controller.rb | 101 +++++++ app/mailers/application_mailer.rb | 19 +- app/mailers/consolidacao_mailer.rb | 3 +- app/mailers/teste_mailer.rb | 14 + app/models/concerns/atributo_cifrado.rb | 71 +++++ app/models/configuracao.rb | 4 + app/models/configuracao_notificacao.rb | 192 +++++++++++++ .../configuracao_notificacao_policy.rb | 22 ++ app/services/notificacao/cliente_twilio.rb | 26 ++ app/services/notificacao/resultado.rb | 27 ++ app/services/notificacao/teste_email.rb | 69 +++++ app/services/notificacao/teste_whatsapp.rb | 74 +++++ app/services/notificacao_service.rb | 160 +++++------ .../configuracao_notificacoes/show.html.erb | 262 ++++++++++++++++++ app/views/admin/configuracoes/index.html.erb | 20 ++ app/views/layouts/_navbar.html.erb | 1 + app/views/teste_mailer/teste.html.erb | 42 +++ config/initializers/devise.rb | 7 + config/initializers/inflections.rb | 3 + config/initializers/smtp.rb | 9 +- config/routes.rb | 6 + ...000001_create_configuracao_notificacoes.rb | 62 +++++ spec/models/configuracao_notificacao_spec.rb | 189 +++++++++++++ spec/models/table_names_spec.rb | 3 +- .../configuracao_notificacao_policy_spec.rb | 15 + .../admin/configuracao_notificacoes_spec.rb | 134 +++++++++ 27 files changed, 1461 insertions(+), 92 deletions(-) create mode 100644 app/controllers/admin/configuracao_notificacoes_controller.rb create mode 100644 app/mailers/teste_mailer.rb create mode 100644 app/models/concerns/atributo_cifrado.rb create mode 100644 app/models/configuracao_notificacao.rb create mode 100644 app/policies/configuracao_notificacao_policy.rb create mode 100644 app/services/notificacao/cliente_twilio.rb create mode 100644 app/services/notificacao/resultado.rb create mode 100644 app/services/notificacao/teste_email.rb create mode 100644 app/services/notificacao/teste_whatsapp.rb create mode 100644 app/views/admin/configuracao_notificacoes/show.html.erb create mode 100644 app/views/teste_mailer/teste.html.erb create mode 100644 db/migrate/20260811000001_create_configuracao_notificacoes.rb create mode 100644 spec/models/configuracao_notificacao_spec.rb create mode 100644 spec/policies/configuracao_notificacao_policy_spec.rb create mode 100644 spec/requests/admin/configuracao_notificacoes_spec.rb diff --git a/.env.example b/.env.example index d07d150..709af88 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,15 @@ SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui # deixe vazio/false para não forçar https em localhost. FORCE_SSL=true +# ══════════════════════════════════════════════════════════════ +# NOTIFICAÇÕES (WhatsApp e e-mail) +# +# ⚠️ Estas variáveis são apenas FALLBACK. A configuração preferencial fica no +# BANCO e é editada pelo ADM em Configurações → Notificações e E-mail +# (/admin/configuracao_notificacao), valendo sem reiniciar o container. +# Enquanto a tela não for preenchida e ativada, valem os valores abaixo. +# ══════════════════════════════════════════════════════════════ + # ── Twilio — WhatsApp (opcional, Fase 8) ───────────────────── TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= @@ -57,6 +66,15 @@ SMTP_USERNAME= SMTP_PASSWORD= SMTP_DOMAIN=gade.com.br +# ── Criptografia dos segredos gravados pela tela (opcional) ── +# A senha do SMTP e o token do Twilio salvos no banco são cifrados com uma +# chave derivada do SECRET_KEY_BASE. Se você trocar o SECRET_KEY_BASE, esses +# dois valores viram ilegíveis (o sistema volta a usar o .env e a tela avisa +# para redigitar). Defina NOTIFICACAO_SECRET com uma string longa e FIXA para +# desacoplar os segredos da rotação do SECRET_KEY_BASE. +# ⚠️ Se mudar depois de já ter salvo, os valores gravados também se perdem. +NOTIFICACAO_SECRET= + # ── App ─────────────────────────────────────────────────────── APP_HOST=localhost:3000 APP_NAME=Reem Logística diff --git a/app/controllers/admin/configuracao_notificacoes_controller.rb b/app/controllers/admin/configuracao_notificacoes_controller.rb new file mode 100644 index 0000000..1352454 --- /dev/null +++ b/app/controllers/admin/configuracao_notificacoes_controller.rb @@ -0,0 +1,101 @@ +# app/controllers/admin/configuracao_notificacoes_controller.rb +# +# Tela única (singleton) de SMTP + WhatsApp. Só admin — ver +# ConfiguracaoNotificacaoPolicy. +class Admin::ConfiguracaoNotificacoesController < ApplicationController + before_action :set_config + + CAMPOS = %i[ + smtp_ativo smtp_address smtp_port smtp_username smtp_password smtp_domain + smtp_autenticacao remetente_email remetente_nome + email_notificacoes_ativo email_admin + whatsapp_ativo twilio_account_sid twilio_auth_token twilio_from whatsapp_admin + ].freeze + + def show + authorize @config + end + + # PATCH /admin/configuracao_notificacao + # + # Os botões "enviar teste" são submits DESTE MESMO form, diferenciados por + # params[:acao]. Assim o teste sempre usa o que está na tela — evita o + # clássico "digitei a senha nova, cliquei em testar, e ele testou a antiga". + def update + authorize @config + + antes = resumo + + unless @config.update(config_params) + flash.now[:alert] = @config.errors.full_messages.to_sentence + return render(:show, status: :unprocessable_entity) + end + + auditar!(antes) + + case params[:acao] + when 'testar_email' + redirecionar(Notificacao::TesteEmail.new(@config, usuario: current_user).executar) + when 'testar_whatsapp' + redirecionar(Notificacao::TesteWhatsapp.new(@config).executar) + else + redirect_to admin_configuracao_notificacao_path, + notice: 'Configurações de notificação salvas.' + end + end + + private + + def set_config + @config = ConfiguracaoNotificacao.instancia + end + + def redirecionar(resultado) + redirect_to admin_configuracao_notificacao_path, **resultado.para_flash + end + + def config_params + permitidos = params.require(:configuracao_notificacao).permit(*CAMPOS) + # Campo de senha em branco significa "mantenha a que já está gravada" — a + # tela nunca exibe o valor, então salvar sem redigitar não pode apagá-lo. + permitidos.delete(:smtp_password) if permitidos[:smtp_password].blank? + permitidos.delete(:twilio_auth_token) if permitidos[:twilio_auth_token].blank? + permitidos + end + + # ⚠️ NUNCA colocar senha ou token aqui: AuditoriaLog#dados_novos é exibido em + # /admin/auditoria_logs, que gerente também acessa. Só o booleano de + # "está definida ou não". + def resumo + { + smtp_ativo: @config.smtp_ativo, + smtp_address: @config.smtp_address, + smtp_port: @config.smtp_port, + smtp_username: @config.smtp_username, + smtp_domain: @config.smtp_domain, + smtp_autenticacao: @config.smtp_autenticacao, + remetente_email: @config.remetente_email, + remetente_nome: @config.remetente_nome, + email_admin: @config.email_admin, + email_notificacoes_ativo: @config.email_notificacoes_ativo, + whatsapp_ativo: @config.whatsapp_ativo, + twilio_account_sid: @config.twilio_account_sid, + twilio_from: @config.twilio_from, + whatsapp_admin: @config.whatsapp_admin, + smtp_password_definida: @config.smtp_password?, + twilio_auth_token_definido: @config.twilio_auth_token? + } + end + + def auditar!(antes) + AuditoriaLog.registrar( + user: current_user, + acao: 'editar_notificacoes', + entidade: 'ConfiguracaoNotificacao', + entidade_id: @config.id, + dados_anteriores: antes, + dados_novos: resumo, + request: request + ) + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 60e89c5..c74dca7 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,5 +1,22 @@ # app/mailers/application_mailer.rb +# +# O remetente e as credenciais SMTP saem do BANCO (ConfiguracaoNotificacao), em +# tempo de ENVIO — mudar na tela /admin/configuracao_notificacao vale sem +# reiniciar o container. +# +# ⚠️ Tem que ser `proc`, NÃO lambda. O Devise avalia o `default from:` com +# `instance_eval(&proc)`, que passa 1 argumento; um lambda de aridade 0 +# estouraria ArgumentError em todo "esqueci minha senha". +# +# ⚠️ E tem que ser `delivery_method_options`, NÃO um before_action mexendo em +# `message.delivery_method`: o `mail()` roda DEPOIS dos callbacks e chama +# `wrap_delivery_behavior!`, que sobrescreveria o callback. O +# `delivery_method_options` é lido dentro do próprio `mail()` e MERGEADO por +# cima do smtp_settings da classe — é daí que vem, de graça, o fallback para +# o que o config/initializers/smtp.rb montou a partir do .env. class ApplicationMailer < ActionMailer::Base - default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') layout 'mailer' + + default from: proc { ConfiguracaoNotificacao.remetente }, + delivery_method_options: proc { ConfiguracaoNotificacao.opcoes_de_entrega } end diff --git a/app/mailers/consolidacao_mailer.rb b/app/mailers/consolidacao_mailer.rb index 6b07434..481be47 100644 --- a/app/mailers/consolidacao_mailer.rb +++ b/app/mailers/consolidacao_mailer.rb @@ -1,6 +1,7 @@ # app/mailers/consolidacao_mailer.rb class ConsolidacaoMailer < ApplicationMailer - default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + # Sem `default from:` aqui de propósito: redeclarar sobrescreveria o proc do + # ApplicationMailer e congelaria o remetente no valor que o .env tinha no boot. def pagamento_fechado(user, consolidacao, consolidacao_motorista) @user = user diff --git a/app/mailers/teste_mailer.rb b/app/mailers/teste_mailer.rb new file mode 100644 index 0000000..b36cff3 --- /dev/null +++ b/app/mailers/teste_mailer.rb @@ -0,0 +1,14 @@ +# app/mailers/teste_mailer.rb +# +# E-mail disparado pelo botão "Salvar e enviar e-mail de teste" da tela +# /admin/configuracao_notificacao. Serve só para provar que as credenciais SMTP +# gravadas funcionam de verdade. +class TesteMailer < ApplicationMailer + def teste(destinatario, config, usuario = nil) + @config = config + @usuario = usuario + @quando = Time.current + + mail to: destinatario, subject: '[Reem Notas] E-mail de teste' + end +end diff --git a/app/models/concerns/atributo_cifrado.rb b/app/models/concerns/atributo_cifrado.rb new file mode 100644 index 0000000..f65d18c --- /dev/null +++ b/app/models/concerns/atributo_cifrado.rb @@ -0,0 +1,71 @@ +# app/models/concerns/atributo_cifrado.rb +# +# Cifra atributos sensíveis (senha SMTP, token do Twilio) em colunas +# `_cifrado` usando ActiveSupport::MessageEncryptor (AES-256-GCM). +# +# POR QUE NÃO ActiveRecord Encryption (`encrypts :campo`): este projeto não usa +# `config/credentials.yml.enc` nem `config/master.key` — tudo vem do .env. Ligar +# o AR Encryption exigiria configurar 3 chaves novas e depender da ordem de +# execução dos initializers do framework. Aqui a chave é DERIVADA do +# secret_key_base, sem nada novo para o operador gerar. +# +# ⚠️ ROTAÇÃO: se o SECRET_KEY_BASE do servidor for trocado, os valores já +# gravados viram ilegíveis. O reader devolve nil (nunca estoura 500), o app +# cai no fallback do .env e a tela mostra um aviso pedindo para redigitar. +# Para ficar imune a isso, defina NOTIFICACAO_SECRET no .env (qualquer string +# longa e fixa) — a derivação passa a usar ela em vez do secret_key_base. +module AtributoCifrado + extend ActiveSupport::Concern + + SALT = 'reem-notas/notificacao/v1' + + class_methods do + def atributo_cifrado(*nomes) + nomes.each do |nome| + coluna = :"#{nome}_cifrado" + + define_method(nome) { AtributoCifrado.decifrar(self[coluna]) } + + define_method(:"#{nome}=") do |valor| + texto = valor.to_s + # Sem isso, salvar a tela sem mexer no campo geraria um criptograma + # novo (o IV é aleatório) e o dirty tracking acusaria mudança à toa. + next if texto == send(nome) + + self[coluna] = texto.blank? ? nil : AtributoCifrado.cifrar(texto) + end + + define_method(:"#{nome}?") { send(nome).present? } + end + end + end + + def self.cifrar(texto) + cofre.encrypt_and_sign(texto) + end + + def self.decifrar(blob) + return nil if blob.blank? + + cofre.decrypt_and_verify(blob) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, + ActiveSupport::MessageVerifier::InvalidSignature => e + Rails.logger.error("[AtributoCifrado] valor ilegível (SECRET_KEY_BASE mudou?): #{e.class}") + nil + end + + # O default do MessageEncryptor no Rails 7.1 é aes-256-gcm → chave de 32 bytes. + def self.cofre + @cofre ||= ActiveSupport::MessageEncryptor.new(chave) + end + + def self.chave + if (segredo = ENV['NOTIFICACAO_SECRET'].presence) + ActiveSupport::KeyGenerator + .new(segredo, hash_digest_class: OpenSSL::Digest::SHA256) + .generate_key(SALT, 32) + else + Rails.application.key_generator.generate_key(SALT, 32) + end + end +end diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index d42932a..2143321 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -11,6 +11,10 @@ class Configuracao < ApplicationRecord preco_extraordinaria preco_termo preco_termo_especial + # ⚠️ OBSOLETAS: quem liga/desliga notificação agora é ConfiguracaoNotificacao + # (tela /admin/configuracao_notificacao). Nenhum código lê estas duas chaves + # desde a migration CreateConfiguracaoNotificacoes, que copiou os valores. + # Continuam aqui só para não quebrar as linhas já existentes no banco. notificacao_whatsapp notificacao_email empresa_nome diff --git a/app/models/configuracao_notificacao.rb b/app/models/configuracao_notificacao.rb new file mode 100644 index 0000000..64eb8df --- /dev/null +++ b/app/models/configuracao_notificacao.rb @@ -0,0 +1,192 @@ +# app/models/configuracao_notificacao.rb +# +# Configuração de envio (SMTP + WhatsApp/Twilio) editável pelo ADM na tela +# /admin/configuracao_notificacao. Tabela SINGLETON: sempre uma linha só. +# +# HIERARQUIA: banco > .env. Enquanto a tela não estiver preenchida e marcada +# como ativa, `smtp_settings` devolve nil e `credenciais_whatsapp` cai no ENV — +# ou seja, o comportamento é idêntico ao que existia antes desta tela. +class ConfiguracaoNotificacao < ApplicationRecord + include AtributoCifrado + + self.table_name = 'configuracao_notificacoes' + + atributo_cifrado :smtp_password, :twilio_auth_token + + AUTENTICACOES = %w[plain login cram_md5].freeze + EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/ + REMETENTE_PADRAO = 'noreply@gade.com.br' + + before_validation :normalizar + + validates :smtp_address, presence: true, if: :smtp_ativo? + validates :smtp_username, presence: true, if: :smtp_ativo? + validates :smtp_port, numericality: { only_integer: true, + greater_than: 0, less_than_or_equal_to: 65_535 } + validates :smtp_autenticacao, inclusion: { in: AUTENTICACOES } + validates :remetente_email, format: { with: EMAIL_RE }, allow_blank: true + validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true + validate :senha_smtp_presente, if: :smtp_ativo? + + validates :twilio_account_sid, presence: true, if: :whatsapp_ativo? + validates :twilio_from, presence: true, if: :whatsapp_ativo? + validate :token_twilio_presente, if: :whatsapp_ativo? + + # ── Singleton ─────────────────────────────────────────────── + def self.instancia + first || create! + rescue ActiveRecord::RecordNotUnique + # Dois workers Puma criando a primeira linha ao mesmo tempo. + first + rescue ActiveRecord::StatementInvalid, + ActiveRecord::NoDatabaseError, + ActiveRecord::ConnectionNotEstablished => e + # Boot do container antes do db:migrate, assets:precompile, console sem banco… + Rails.logger.warn("[ConfiguracaoNotificacao] tabela indisponível (#{e.class}) — usando .env") + new + end + + # ── Usados pelo ApplicationMailer: NUNCA podem estourar ───── + # (um erro aqui derrubaria todo envio de e-mail, inclusive o reset de senha) + + def self.remetente + instancia.remetente_formatado + rescue StandardError => e + Rails.logger.error("[ConfiguracaoNotificacao.remetente] #{e.class}: #{e.message}") + ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO) + end + + # nil => o ActionMailer faz `.merge({})` e mantém o que o + # config/initializers/smtp.rb montou a partir do .env no boot. + def self.opcoes_de_entrega + instancia.smtp_settings + rescue StandardError => e + Rails.logger.error("[ConfiguracaoNotificacao.opcoes_de_entrega] #{e.class}: #{e.message}") + nil + end + + # ── SMTP ──────────────────────────────────────────────────── + def smtp_pronto? + smtp_ativo? && smtp_address.present? && smtp_port.to_i.positive? && + smtp_username.present? && smtp_password.present? + end + + def smtp_settings + return nil unless smtp_pronto? + + porta = smtp_port.to_i + base = { + address: smtp_address, + port: porta, + user_name: smtp_username, + password: smtp_password, + domain: smtp_domain.presence || dominio_padrao, + authentication: (smtp_autenticacao.presence || 'plain').to_sym, + # Sem timeout, uma porta filtrada pelo firewall do VPS pendura o worker + # Puma até o Nginx cortar — o botão "enviar teste" ficaria girando. + open_timeout: 10, + read_timeout: 10 + } + # 465 = SSL implícito; 587/25 = STARTTLS. + base.merge(porta == 465 ? { ssl: true, tls: true } : { enable_starttls_auto: true }) + end + + def remetente_formatado + endereco = remetente_email.presence || + smtp_username.presence || + ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO) + remetente_nome.present? ? %("#{remetente_nome}" <#{endereco}>) : endereco + end + + def email_habilitado? + email_notificacoes_ativo? + end + + # ── WhatsApp / Twilio ─────────────────────────────────────── + def whatsapp_pronto? + whatsapp_ativo? && twilio_account_sid.present? && + twilio_auth_token.present? && twilio_from.present? + end + + def whatsapp_habilitado? + whatsapp_ativo? && credenciais_whatsapp.present? + end + + # Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta + # seria outra e o Twilio devolveria 20003 sem explicação óbvia). + def credenciais_whatsapp + if whatsapp_pronto? + { sid: twilio_account_sid, + token: twilio_auth_token, + from: self.class.canal(twilio_from) } + elsif ENV['TWILIO_ACCOUNT_SID'].present? && ENV['TWILIO_AUTH_TOKEN'].present? + { sid: ENV['TWILIO_ACCOUNT_SID'], + token: ENV['TWILIO_AUTH_TOKEN'], + from: self.class.canal(ENV['TWILIO_WHATSAPP_FROM']) } + end + end + + # ── Telefone ──────────────────────────────────────────────── + # "11 920051157", "(11) 92005-1157", "011 92005-1157", "+55 11 92005-1157" + # e "whatsapp:+5511920051157" → "+5511920051157". + def self.normalizar_telefone(valor) + return nil if valor.blank? + + d = valor.to_s.gsub(/\D/, '').sub(/\A0+/, '') + # Só tira o 55 quando sobra número demais — senão quebraria o DDD 55 + # (Santa Maria/RS), onde "55991234567" já é o número completo. + d = d.sub(/\A55/, '') if d.length > 11 && d.start_with?('55') + return nil unless d.length.between?(10, 11) + + "+55#{d}" + end + + def self.canal(numero) + e164 = normalizar_telefone(numero) + return nil if e164.blank? + + "whatsapp:#{e164}" + end + + private + + def dominio_padrao + (remetente_email.presence || smtp_username).to_s.split('@').last.presence || 'localhost' + end + + def normalizar + self.smtp_address = smtp_address.to_s.strip.presence + self.smtp_username = smtp_username.to_s.strip.presence + self.smtp_domain = smtp_domain.to_s.strip.presence + self.remetente_email = remetente_email.to_s.strip.downcase.presence + self.remetente_nome = remetente_nome.to_s.strip.presence + self.email_admin = email_admin.to_s.strip.downcase.presence + self.twilio_account_sid = twilio_account_sid.to_s.strip.presence + # Mantém o que o admin digitou se não der para normalizar — a validação de + # presença acusa, e ele vê o próprio valor de volta no campo para corrigir. + self.twilio_from = self.class.normalizar_telefone(twilio_from) || twilio_from.presence + self.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence + end + + def senha_smtp_presente + return if smtp_password.present? + + errors.add(:smtp_password, + if smtp_password_cifrado.present? + 'gravada está ilegível (o SECRET_KEY_BASE do servidor mudou?). Digite a senha novamente.' + else + 'é obrigatória quando o envio de e-mail está ativo' + end) + end + + def token_twilio_presente + return if twilio_auth_token.present? + + errors.add(:twilio_auth_token, + if twilio_auth_token_cifrado.present? + 'gravado está ilegível (o SECRET_KEY_BASE do servidor mudou?). Cole o token novamente.' + else + 'é obrigatório quando o WhatsApp está ativo' + end) + end +end diff --git a/app/policies/configuracao_notificacao_policy.rb b/app/policies/configuracao_notificacao_policy.rb new file mode 100644 index 0000000..04b85c6 --- /dev/null +++ b/app/policies/configuracao_notificacao_policy.rb @@ -0,0 +1,22 @@ +# app/policies/configuracao_notificacao_policy.rb +# +# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas +# são credenciais de envio — senha de e-mail e token do Twilio —, então só ADM. +# +# ⚠️ Cada action nova do controller precisa de um método `?` aqui, +# senão o `authorize` estoura. +class ConfiguracaoNotificacaoPolicy < ApplicationPolicy + def show? + admin? + end + + def update? + admin? + end + + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/services/notificacao/cliente_twilio.rb b/app/services/notificacao/cliente_twilio.rb new file mode 100644 index 0000000..7e9868e --- /dev/null +++ b/app/services/notificacao/cliente_twilio.rb @@ -0,0 +1,26 @@ +# app/services/notificacao/cliente_twilio.rb +# +# Monta o Twilio::REST::Client com timeout. Sem timeout, uma saída de internet +# bloqueada no servidor pendura o worker Puma até o proxy cortar a requisição — +# e o botão "enviar teste" da tela ficaria girando sem resposta. +# +# O construtor com http_client é posicional na twilio-ruby 7.x +# (username, password, account_sid, region, http_client). Se a assinatura mudar +# numa atualização da gem, cai no construtor simples em vez de quebrar o envio. +module Notificacao + module ClienteTwilio + TIMEOUT = 15 + + def self.montar(sid, token) + require 'twilio-ruby' + + begin + http = Twilio::HTTP::Client.new(timeout: TIMEOUT) + Twilio::REST::Client.new(sid, token, nil, nil, http) + rescue ArgumentError, NameError => e + Rails.logger.warn("[ClienteTwilio] sem timeout customizado (#{e.class}: #{e.message})") + Twilio::REST::Client.new(sid, token) + end + end + end +end diff --git a/app/services/notificacao/resultado.rb b/app/services/notificacao/resultado.rb new file mode 100644 index 0000000..e259850 --- /dev/null +++ b/app/services/notificacao/resultado.rb @@ -0,0 +1,27 @@ +# app/services/notificacao/resultado.rb +# +# Retorno dos testes de envio. Existe para o controller não precisar saber nada +# sobre SMTP nem sobre a API do Twilio — só perguntar `ok?` e jogar no flash. +module Notificacao + Resultado = Struct.new(:ok, :mensagem, :detalhe, keyword_init: true) do + def self.sucesso(mensagem) + new(ok: true, mensagem: mensagem) + end + + def self.falha(mensagem, detalhe = nil) + new(ok: false, mensagem: mensagem, detalhe: detalhe) + end + + def ok? + !!ok + end + + # O detalhe técnico (mensagem crua do servidor/Twilio) só aparece no erro — + # é o que permite ao admin resolver sozinho sem abrir o log do container. + def para_flash + texto = mensagem.to_s + texto += " [detalhe técnico: #{detalhe.to_s.truncate(180)}]" if !ok? && detalhe.present? + { ok? ? :notice : :alert => texto } + end + end +end diff --git a/app/services/notificacao/teste_email.rb b/app/services/notificacao/teste_email.rb new file mode 100644 index 0000000..81e2fca --- /dev/null +++ b/app/services/notificacao/teste_email.rb @@ -0,0 +1,69 @@ +# app/services/notificacao/teste_email.rb +# +# Dispara um e-mail de teste com as credenciais que ACABARAM de ser salvas e +# devolve o erro REAL traduzido para português. É o oposto do NotificacaoService, +# que engole as falhas de propósito para não derrubar o fechamento — aqui o +# objetivo é justamente mostrar o problema. +module Notificacao + class TesteEmail + def initialize(config = ConfiguracaoNotificacao.instancia, destinatario: nil, usuario: nil) + @config = config + @destinatario = destinatario.presence || config.email_admin.presence + @usuario = usuario + end + + def executar + if @destinatario.blank? + return Resultado.falha('Preencha o "E-mail do administrador" antes de enviar o teste.') + end + + unless @config.smtp_pronto? + return Resultado.falha('Marque "Ativar envio de e-mail" e preencha servidor, porta, ' \ + 'usuário e senha antes de testar.') + end + + entrega = TesteMailer.teste(@destinatario, @config, @usuario) + # development.rb define raise_delivery_errors = false e o mail() copia isso + # para a mensagem — sem estas duas linhas o teste "passaria" em silêncio. + entrega.message.raise_delivery_errors = true + entrega.message.perform_deliveries = true + # NUNCA deliver_later: o adapter é :async (thread in-process) e a exceção + # nunca voltaria para o flash. + entrega.deliver_now + + Resultado.sucesso("E-mail de teste enviado para #{@destinatario}. " \ + 'Confira a caixa de entrada e também o spam.') + rescue Net::SMTPAuthenticationError => e + Resultado.falha('Usuário ou senha do SMTP recusados pelo servidor. No Gmail/Google ' \ + 'Workspace é preciso usar uma "Senha de app" de 16 caracteres, ' \ + 'não a senha da conta.', e.message) + rescue Net::SMTPSyntaxError, Net::SMTPFatalError => e + Resultado.falha("O servidor recusou a mensagem. Normalmente o remetente " \ + "(#{@config.remetente_email.presence || @config.smtp_username}) precisa ser " \ + 'o mesmo usuário que autenticou.', e.message) + rescue Net::SMTPServerBusy => e + Resultado.falha('Servidor SMTP ocupado ou aplicando limite de envio. ' \ + 'Tente de novo em alguns minutos.', e.message) + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e + Resultado.falha("Tempo esgotado ao conectar em #{@config.smtp_address}:#{@config.smtp_port}. " \ + 'A porta pode estar bloqueada pelo provedor ou pelo firewall — ' \ + 'tente 587 (STARTTLS) ou 465 (SSL).', e.message) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH => e + Resultado.falha("Conexão recusada por #{@config.smtp_address}:#{@config.smtp_port}. " \ + 'Servidor ou porta incorretos.', e.message) + rescue SocketError => e + Resultado.falha("Não foi possível resolver o endereço \"#{@config.smtp_address}\". " \ + 'Confira a grafia do servidor.', e.message) + rescue OpenSSL::SSL::SSLError => e + Resultado.falha('Erro de TLS/SSL. A porta 465 exige SSL direto e a 587 exige STARTTLS — ' \ + 'confira se a porta escolhida bate com o servidor.', e.message) + rescue Net::SMTPError, EOFError, IOError => e + # Net::SMTPError é um módulo incluído nas classes acima; por isso vem + # depois delas, como rede de segurança. + Resultado.falha("O servidor SMTP interrompeu a conexão: #{e.message}", e.message) + rescue StandardError => e + Rails.logger.error("[TesteEmail] #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message) + end + end +end diff --git a/app/services/notificacao/teste_whatsapp.rb b/app/services/notificacao/teste_whatsapp.rb new file mode 100644 index 0000000..9f1b93f --- /dev/null +++ b/app/services/notificacao/teste_whatsapp.rb @@ -0,0 +1,74 @@ +# app/services/notificacao/teste_whatsapp.rb +# +# Dispara um WhatsApp de teste pelo Twilio para o número administrativo e +# traduz o código de erro da API para uma instrução acionável em português — +# os erros de Sandbox (opt-in) são de longe os mais comuns e o código cru +# ("63003") não diz nada para quem está configurando. +module Notificacao + class TesteWhatsapp + MENSAGENS = { + 20003 => 'Account SID ou Auth Token inválidos: o Twilio recusou a autenticação.', + 20404 => 'Account SID não encontrado no Twilio. Confira se copiou o SID certo (começa com "AC").', + 21211 => 'Número de destino inválido. Informe com DDD, ex.: 11 92005-1157.', + 21606 => 'O número remetente não é um número Twilio habilitado para envio.', + 21608 => 'Conta trial do Twilio: só é possível enviar para números verificados no console da Twilio.', + 21610 => 'Este número pediu descadastro (enviou STOP). Ele precisa enviar START para voltar a receber.', + 63003 => 'O Twilio não encontrou o destinatário nesse canal. No Sandbox do WhatsApp o número ' \ + 'precisa enviar "join " para o número do sandbox ANTES de conseguir receber.', + 63007 => 'O número remetente não está habilitado como canal de WhatsApp. Use exatamente o ' \ + 'número do Sandbox (ou do Sender aprovado) do Twilio.', + 63015 => 'No Sandbox só dá para enviar a números que fizeram o opt-in ("join ").', + 63016 => 'Passou da janela de 24h desde a última mensagem do destinatário — nesse caso o Twilio ' \ + 'só aceita template aprovado. Peça para ele mandar qualquer mensagem e tente de novo.', + 63018 => 'Limite de mensagens do Twilio atingido. Aguarde e tente novamente.', + 63038 => 'Limite diário de mensagens da conta Twilio atingido.' + }.freeze + + def initialize(config = ConfiguracaoNotificacao.instancia, destino: nil) + @config = config + @destino = ConfiguracaoNotificacao.canal(destino.presence || config.whatsapp_admin) + end + + def executar + if @destino.blank? + return Resultado.falha('Preencha o "WhatsApp do administrador" com DDD ' \ + '(ex.: 11 92005-1157) antes de testar.') + end + + credenciais = @config.credenciais_whatsapp + if credenciais.blank? + return Resultado.falha('Marque "Ativar WhatsApp" e preencha Account SID, Auth Token e o ' \ + 'número remetente do Twilio.') + end + if credenciais[:from].blank? + return Resultado.falha('O número remetente do Twilio está vazio ou em formato inválido.') + end + + msg = ClienteTwilio + .montar(credenciais[:sid], credenciais[:token]) + .messages.create(from: credenciais[:from], to: @destino, body: corpo) + + Resultado.sucesso("WhatsApp de teste aceito pelo Twilio para " \ + "#{@destino.sub('whatsapp:', '')} (SID #{msg.sid}, status \"#{msg.status}\"). " \ + 'Atenção: "queued"/"accepted" significa que a Twilio recebeu — ' \ + 'a entrega final ainda pode falhar depois.') + rescue Twilio::REST::RestError => e + Resultado.falha(MENSAGENS[e.code] || "O Twilio recusou o envio (código #{e.code}).", + "[#{e.code}] #{e.message}") + rescue Twilio::REST::TwilioError => e + Resultado.falha('Não foi possível falar com a API do Twilio (rede ou timeout). ' \ + 'Verifique a saída de internet do servidor.', e.message) + rescue StandardError => e + Rails.logger.error("[TesteWhatsapp] #{e.class}: #{e.message}") + Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message) + end + + private + + def corpo + "✅ Teste do Reem Notas\n" \ + "Se você recebeu esta mensagem, a integração com o Twilio está funcionando.\n" \ + "Enviado em #{Time.current.strftime('%d/%m/%Y às %H:%M')}." + end + end +end diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 53fee92..6f30929 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -1,10 +1,27 @@ # app/services/notificacao_service.rb # -# Envia notificações aos motoristas quando uma consolidação é finalizada. -# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email. -# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam). +# Envia notificações aos motoristas quando uma consolidação é finalizada ou +# quando o pagamento é efetuado. # +# Canais e credenciais vêm de ConfiguracaoNotificacao (tela /admin/ +# configuracao_notificacao), com fallback para o .env. +# +# Falhas de envio NUNCA quebram o fluxo de finalização — apenas logam. Isso é +# intencional: um SMTP fora do ar não pode impedir o fechamento de um pagamento. +# Para DIAGNOSTICAR o envio, use os botões de teste da tela, que usam +# Notificacao::TesteEmail / Notificacao::TesteWhatsapp e mostram o erro real. class NotificacaoService + EVENTOS = { + fechado: { mailer: :pagamento_fechado, + emoji: '🚚', + titulo: 'Seu pagamento de entregas foi fechado.', + cta: 'Acesse seu painel para baixar o extrato:' }, + pago: { mailer: :pagamento_efetuado, + emoji: '✅', + titulo: 'Seu pagamento foi efetuado.', + cta: 'Acesse seu painel para conferir:' } + }.freeze + def self.notificar_finalizacao(consolidacao) new(consolidacao).notificar_todos end @@ -16,106 +33,73 @@ class NotificacaoService def initialize(consolidacao) @consolidacao = consolidacao + @config = ConfiguracaoNotificacao.instancia end def notificar_todos - @consolidacao.consolidacao_motoristas.ativos.each do |cm| - user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) - next unless user - - enviar_whatsapp(user, cm) if whatsapp_ativo? - enviar_email(user, cm) if email_ativo? && user.email.present? - end + @consolidacao.consolidacao_motoristas.ativos.each { |cm| notificar(cm, :fechado) } end - def enviar_aviso_pagamento(cm) - user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) - return unless user - - enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo? - enviar_email_pagamento(user, cm) if email_ativo? && user.email.present? + def enviar_aviso_pagamento(consolidacao_motorista) + notificar(consolidacao_motorista, :pago) end private - def whatsapp_ativo? - Configuracao.valor('notificacao_whatsapp') == 'true' && - ENV['TWILIO_ACCOUNT_SID'].present? + def notificar(cm, evento) + user = motorista_de(cm) + return unless user + + enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado? + enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present? end - def email_ativo? - Configuracao.valor('notificacao_email') == 'true' + def motorista_de(cm) + User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) + end + + def enviar_whatsapp(user, cm, evento) + # O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse + # texto cru para o Twilio devolve erro 21211 (destino inválido), que o + # rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio + # realmente funcionar. + destino = ConfiguracaoNotificacao.canal(user.telefone) + return if destino.blank? + + credenciais = @config.credenciais_whatsapp + return if credenciais.blank? || credenciais[:from].blank? + + Notificacao::ClienteTwilio + .montar(credenciais[:sid], credenciais[:token]) + .messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento)) + + Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}") + rescue StandardError => e + Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}") + end + + def enviar_email(user, cm, evento) + ConsolidacaoMailer + .public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm) + .deliver_later + Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}") + rescue StandardError => e + Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}") + end + + def mensagem(user, cm, evento) + evt = EVENTOS.fetch(evento) + "Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \ + "#{evt[:titulo]}\n" \ + "📋 #{@consolidacao.nome}\n" \ + "💰 Valor: #{moeda(cm.valor_total)}\n" \ + "#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" end # Moeda BR com separador de milhar: R$ 1.234,56 - def moeda(v) - inteiro, decimais = format('%.2f', v.to_f).split('.') + def moeda(valor) + inteiro, decimais = format('%.2f', valor.to_f).split('.') inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse "R$ #{inteiro},#{decimais}" end - - def mensagem(user, cm) - valor = moeda(cm.valor_total) - "Olá #{user.nome.split.first}! 🚚\n" \ - "Seu pagamento de entregas foi fechado.\n" \ - "📋 #{@consolidacao.nome}\n" \ - "💰 Valor: #{valor}\n" \ - "Acesse seu painel para baixar o extrato: " \ - "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" - end - - def enviar_whatsapp(user, cm) - return if user.telefone.blank? - - require 'twilio-ruby' - cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN']) - cliente.messages.create( - from: ENV['TWILIO_WHATSAPP_FROM'], - to: "whatsapp:#{user.telefone}", - body: mensagem(user, cm) - ) - Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}") - rescue => e - Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}") - end - - def enviar_email(user, cm) - ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later - Rails.logger.info("[Notificacao] Email agendado para #{user.email}") - rescue => e - Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}") - end - - # ── Aviso de pagamento efetuado ───────────────────────────── - def mensagem_pagamento(user, cm) - valor = moeda(cm.valor_total) - "Olá #{user.nome.split.first}! ✅\n" \ - "Seu pagamento foi efetuado.\n" \ - "📋 #{@consolidacao.nome}\n" \ - "💰 Valor: #{valor}\n" \ - "Acesse seu painel para conferir: " \ - "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" - end - - def enviar_whatsapp_pagamento(user, cm) - return if user.telefone.blank? - - require 'twilio-ruby' - cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN']) - cliente.messages.create( - from: ENV['TWILIO_WHATSAPP_FROM'], - to: "whatsapp:#{user.telefone}", - body: mensagem_pagamento(user, cm) - ) - Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}") - rescue => e - Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}") - end - - def enviar_email_pagamento(user, cm) - ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later - Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}") - rescue => e - Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}") - end end diff --git a/app/views/admin/configuracao_notificacoes/show.html.erb b/app/views/admin/configuracao_notificacoes/show.html.erb new file mode 100644 index 0000000..62fdf5f --- /dev/null +++ b/app/views/admin/configuracao_notificacoes/show.html.erb @@ -0,0 +1,262 @@ +<%# app/views/admin/configuracao_notificacoes/show.html.erb %> +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + card = 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" + ajuda = 'text-gray-500 text-xs mt-1.5' + botao_2 = 'px-6 py-3 border border-white/10 hover:border-white/20 text-gray-300 + hover:text-white rounded-xl transition-colors cursor-pointer min-h-[48px]' +%> + +
+ +
+
+

+ <%= icone :email, espaco: false %> Notificações e E-mail +

+

+ Servidor de e-mail (SMTP) e WhatsApp (Twilio) usados pelo sistema +

+
+ <%= link_to 'Voltar', admin_configuracoes_path, + class: 'px-4 py-2 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors text-sm whitespace-nowrap' %> +
+ + <%= render 'shared/flash' %> + + <%# Status atual — o que o sistema REALMENTE vai usar agora %> +
+
+ + E-mail: + + <%= @config.smtp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + WhatsApp: + + <%= @config.whatsapp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + <%# Segredo gravado que não pôde ser decifrado (SECRET_KEY_BASE rotacionado) %> + <% ilegiveis = [] %> + <% ilegiveis << 'senha do SMTP' if @config.smtp_password_cifrado.present? && @config.smtp_password.blank? %> + <% ilegiveis << 'token do Twilio' if @config.twilio_auth_token_cifrado.present? && @config.twilio_auth_token.blank? %> + <% if ilegiveis.any? %> +
+ Não foi possível ler <%= ilegiveis.to_sentence %> que estava gravada no banco + (o SECRET_KEY_BASE do servidor mudou). + Digite novamente e salve. Enquanto isso o sistema usa o que está no arquivo .env. +
+ <% end %> + + <%= form_with(model: @config, url: admin_configuracao_notificacao_path, method: :patch, + class: 'space-y-6') do |f| %> + + <% if @config.errors.any? %> +
+

+ <%= pluralize(@config.errors.count, 'erro', 'erros') %> encontrado<%= @config.errors.count > 1 ? 's' : '' %>: +

+
    + <% @config.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + + <%# ─────────────────────────── E-MAIL / SMTP ─────────────────────────── %> +
+
+
+

+ <%= icone :email, espaco: false %> Servidor de e-mail (SMTP) +

+

+ Usado no envio de extratos, avisos de pagamento e recuperação de senha +

+
+ +
+ +
+
+ <%= f.label :smtp_address, 'Servidor', class: rotulo %> + <%= f.text_field :smtp_address, class: input, placeholder: 'smtp.gmail.com' %> +
+ +
+ <%= f.label :smtp_port, 'Porta', class: rotulo %> + <%= f.number_field :smtp_port, min: 1, max: 65535, class: input, placeholder: '587' %> +

587 para STARTTLS (padrão) ou 465 para SSL direto.

+
+ +
+ <%= f.label :smtp_username, 'Usuário', class: rotulo %> + <%= f.text_field :smtp_username, autocomplete: 'off', class: input, + placeholder: 'sistema@suaempresa.com.br' %> +
+ +
+ <%= f.label :smtp_password, 'Senha', class: rotulo %> + <%# value: nil — a senha gravada NUNCA volta para o HTML. %> + <%= f.password_field :smtp_password, value: nil, autocomplete: 'new-password', class: input, + placeholder: @config.smtp_password? ? '•••••••• (em branco mantém a atual)' : 'senha ou senha de app' %> +

+ No Gmail/Google Workspace use uma Senha de app de 16 caracteres, + não a senha da conta. +

+
+ +
+ <%= f.label :smtp_autenticacao, 'Autenticação', class: rotulo %> + <%= f.select :smtp_autenticacao, + options_for_select(ConfiguracaoNotificacao::AUTENTICACOES, @config.smtp_autenticacao), + {}, class: "#{input} cursor-pointer" %> +
+ +
+ <%= f.label :smtp_domain, 'Domínio (opcional)', class: rotulo %> + <%= f.text_field :smtp_domain, class: input, placeholder: 'suaempresa.com.br' %> +
+ +
+ <%= f.label :remetente_email, 'E-mail remetente', class: rotulo %> + <%= f.email_field :remetente_email, class: input, placeholder: 'nao-responda@suaempresa.com.br' %> +

Em branco, usa o próprio usuário do SMTP.

+
+ +
+ <%= f.label :remetente_nome, 'Nome do remetente', class: rotulo %> + <%= f.text_field :remetente_nome, class: input, placeholder: 'Reem Transporte' %> +
+
+ +
+
+ <%= f.label :email_notificacoes_ativo, 'Avisar motoristas por e-mail', class: 'block text-sm font-medium text-gray-300' %> +

+ Envia o aviso de pagamento fechado e de pagamento efetuado para o motorista +

+
+ +
+
+ + <%# ────────────────────────── WHATSAPP / TWILIO ───────────────────────── %> +
+
+
+

+ <%= icone :telefone, espaco: false %> WhatsApp (Twilio) +

+

+ Credenciais em Twilio Console → Account Info +

+
+ +
+ +
+
+ <%= f.label :twilio_account_sid, 'Account SID', class: rotulo %> + <%= f.text_field :twilio_account_sid, autocomplete: 'off', class: "#{input} font-mono text-sm", + placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' %> +
+ +
+ <%= f.label :twilio_auth_token, 'Auth Token', class: rotulo %> + <%= f.password_field :twilio_auth_token, value: nil, autocomplete: 'new-password', + class: "#{input} font-mono text-sm", + placeholder: @config.twilio_auth_token? ? '•••••••• (em branco mantém o atual)' : 'token da conta Twilio' %> +
+ +
+ <%= f.label :twilio_from, 'Número remetente (o que envia)', class: rotulo %> + <%= f.text_field :twilio_from, class: input, placeholder: '+1 415 523 8886 (sandbox) ou seu número aprovado' %> +

+ Precisa ser o número do Sandbox ou um Sender aprovado no Twilio — + não dá para usar um número de WhatsApp comum. +

+
+
+ +
+

+ Sandbox do Twilio: antes do primeiro envio, o + número que vai receber precisa mandar join <sua-palavra> + no WhatsApp para o número do sandbox. Sem esse opt-in o Twilio recusa com o erro 63003/63015. +

+
+
+ + <%# ───────────────────── DESTINATÁRIOS ADMINISTRATIVOS ────────────────── %> +
+
+

+ <%= icone :pessoa, espaco: false %> Destinatários administrativos +

+

+ Para onde vão os testes abaixo e os avisos destinados à administração +

+
+ +
+
+ <%= f.label :email_admin, 'E-mail do administrador', class: rotulo %> + <%= f.email_field :email_admin, class: input, placeholder: 'voce@suaempresa.com.br' %> +
+ +
+ <%= f.label :whatsapp_admin, 'WhatsApp do administrador', class: rotulo %> + <%= f.text_field :whatsapp_admin, class: input, placeholder: '11 92005-1157' %> +

Com DDD. É gravado no formato internacional (+55…).

+
+
+
+ + <%# ──────────────────────────────── AÇÕES ────────────────────────────── %> + <%# Os três botões submetem ESTE form; o controller decide pelo name="acao". + Assim o teste roda sobre o que está na tela, já salvo. %> +
+
+ <%= f.button 'Salvar', type: :submit, name: 'acao', value: 'salvar', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> + + <%= f.button 'Salvar e enviar e-mail de teste', type: :submit, + name: 'acao', value: 'testar_email', class: botao_2 %> + + <%= f.button 'Salvar e enviar WhatsApp de teste', type: :submit, + name: 'acao', value: 'testar_whatsapp', class: botao_2 %> +
+

+ Os testes salvam as alterações antes de enviar e mostram o erro exato retornado + pelo servidor de e-mail ou pelo Twilio. +

+
+ <% end %> +
diff --git a/app/views/admin/configuracoes/index.html.erb b/app/views/admin/configuracoes/index.html.erb index 8fbfee7..846710a 100644 --- a/app/views/admin/configuracoes/index.html.erb +++ b/app/views/admin/configuracoes/index.html.erb @@ -8,6 +8,26 @@ <%= render 'shared/flash' %> + <%# Configurações sensíveis (credenciais de envio) — só ADM %> + <% if current_user.admin? %> +
+

<%= icone :travado, espaco: false %> Configurações do Administrador

+
+ <%= link_to admin_configuracao_notificacao_path, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 hover:border-[#f97316]/50 + p-6 flex items-start gap-4 transition-colors group' do %> +
✉️
+
+

Notificações e E-mail

+

+ Servidor de e-mail (SMTP), WhatsApp via Twilio e os contatos que recebem os avisos da administração +

+
+ <% end %> +
+
+ <% end %> + <%# Pilares de preço %>

<%= icone :dinheiro, espaco: false %> Pilares de Preço

diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 197d730..e07e198 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -61,6 +61,7 @@ <%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %> <% if current_user.admin? %> <%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %> + <%= nav_link_to 'Notificações', admin_configuracao_notificacao_path, icon: :email %> <% end %>
<% end %> diff --git a/app/views/teste_mailer/teste.html.erb b/app/views/teste_mailer/teste.html.erb new file mode 100644 index 0000000..6c300d3 --- /dev/null +++ b/app/views/teste_mailer/teste.html.erb @@ -0,0 +1,42 @@ +<%# app/views/teste_mailer/teste.html.erb %> +
+ +
+

REEM TRANSPORTE

+

Sistema de Logística

+
+ +
+

✅ Configuração de e-mail funcionando

+ +

+ Se você está lendo esta mensagem, o servidor de e-mail cadastrado na tela + Configurações → Notificações e E-mail está enviando corretamente. +

+ + + + + + + + + + + <% if @usuario %> + + + + + <% end %> + + + + +
Servidor<%= @config.smtp_address %>:<%= @config.smtp_port %>
Remetente<%= @config.remetente_formatado %>
Disparado por<%= @usuario.nome_display %>
Data/hora<%= @quando.strftime('%d/%m/%Y às %H:%M') %>
+ +

+ Mensagem automática de teste — não é necessário responder. +

+
+
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 93e0ff2..28162db 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,5 +1,12 @@ Devise.setup do |config| + # Fallback: só é consultado se o `default from:` do mailer sumir. config.mailer_sender = ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + + # Faz Devise::Mailer herdar de ApplicationMailer para que o "esqueci minha + # senha" use o SMTP e o remetente gravados no banco (tela /admin/ + # configuracao_notificacao). Efeito colateral: os e-mails do Devise passam a + # usar o layout app/views/layouts/mailer.html.erb. + config.parent_mailer = 'ApplicationMailer' require 'devise/orm/active_record' config.case_insensitive_keys = [:email] config.strip_whitespace_keys = [:email] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index c9f330a..f6cc57e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -3,4 +3,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.irregular 'consolidacao', 'consolidacoes' inflect.irregular 'configuracao', 'configuracoes' + # Sem esta linha, ConfiguracaoNotificacao procuraria a tabela + # "configuracao_notificacaos" (o irregular acima só casa no FIM da palavra). + inflect.irregular 'notificacao', 'notificacoes' end diff --git a/config/initializers/smtp.rb b/config/initializers/smtp.rb index 13bf6f5..6c8a132 100644 --- a/config/initializers/smtp.rb +++ b/config/initializers/smtp.rb @@ -1,5 +1,12 @@ # config/initializers/smtp.rb -# Configuração de e-mail 100% via ENV — só ativa se SMTP_USERNAME existir +# +# FALLBACK de e-mail via ENV — só ativa se SMTP_USERNAME existir. +# +# A fonte de verdade preferencial é o BANCO (ConfiguracaoNotificacao, editável +# em /admin/configuracao_notificacao). O ApplicationMailer passa as settings do +# banco em `delivery_method_options`, que o ActionMailer MERGEIA por cima do que +# está configurado aqui. Ou seja: banco > .env, e o que estiver só aqui continua +# valendo enquanto a tela não for preenchida. if ENV['SMTP_USERNAME'].present? Rails.application.config.action_mailer.delivery_method = :smtp Rails.application.config.action_mailer.smtp_settings = { diff --git a/config/routes.rb b/config/routes.rb index 2175d64..4cbb931 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,12 @@ Rails.application.routes.draw do end resources :auditoria_logs, only: [:index] + # SMTP e WhatsApp (Twilio) gravados no banco — só admin. + # Sem rotas `testar_*`: os botões de teste são submits do próprio form + # (params[:acao]), para o teste usar exatamente o que está na tela. + resource :configuracao_notificacao, only: [:show, :update], + controller: 'configuracao_notificacoes' + # Edição de lançamento do SimpliRoute (correção de status/motivo/comentário) # — só admin. Grava direto na API do SimpliRoute + AuditoriaLog. resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do diff --git a/db/migrate/20260811000001_create_configuracao_notificacoes.rb b/db/migrate/20260811000001_create_configuracao_notificacoes.rb new file mode 100644 index 0000000..1314192 --- /dev/null +++ b/db/migrate/20260811000001_create_configuracao_notificacoes.rb @@ -0,0 +1,62 @@ +# Configuração de SMTP e WhatsApp (Twilio) gravada no BANCO, editável pelo ADM +# em /admin/configuracao_notificacao — antes disso tudo vinha só do .env e mudar +# um servidor de e-mail exigia editar o arquivo no servidor e reiniciar. +# +# Tabela SINGLETON: uma linha só. `singleton_guard` com índice único é o que +# impede dois workers Puma criarem linhas concorrentes no primeiro acesso. +# +# As credenciais NÃO são importadas do ENV aqui de propósito: os campos nascem +# vazios e `smtp_ativo`/`whatsapp_ativo` respeitam as flags antigas, então +# `smtp_pronto?`/`whatsapp_pronto?` dão false e o app segue usando o .env +# exatamente como hoje até o admin preencher a tela. +class CreateConfiguracaoNotificacoes < ActiveRecord::Migration[7.1] + def up + create_table :configuracao_notificacoes do |t| + t.integer :singleton_guard, null: false, default: 0 + + # ── E-mail / SMTP ─────────────────────────────────────── + t.boolean :smtp_ativo, null: false, default: false + t.string :smtp_address + t.integer :smtp_port, null: false, default: 587 + t.string :smtp_username + t.text :smtp_password_cifrado # AES-256-GCM (concern AtributoCifrado) + t.string :smtp_domain + t.string :smtp_autenticacao, null: false, default: 'plain' + t.string :remetente_email + t.string :remetente_nome + + # ── Destinatários administrativos ─────────────────────── + t.string :email_admin + t.string :whatsapp_admin + + # ── Notificação aos motoristas ────────────────────────── + t.boolean :email_notificacoes_ativo, null: false, default: false + + # ── WhatsApp / Twilio ─────────────────────────────────── + t.boolean :whatsapp_ativo, null: false, default: false + t.string :twilio_account_sid + t.text :twilio_auth_token_cifrado # AES-256-GCM + t.string :twilio_from + + t.timestamps + end + + add_index :configuracao_notificacoes, :singleton_guard, unique: true + + # Herda as flags que hoje moram em `configuracoes` (chave/valor) e que nunca + # tiveram UI. SQL cru de propósito: migration não deve depender de model. + whats = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_whatsapp'") + email = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_email'") + + execute(<<~SQL.squish) + INSERT INTO configuracao_notificacoes + (singleton_guard, whatsapp_ativo, email_notificacoes_ativo, smtp_ativo, + smtp_port, smtp_autenticacao, created_at, updated_at) + VALUES (0, #{whats == 'true'}, #{email == 'true'}, false, 587, 'plain', NOW(), NOW()) + SQL + end + + def down + drop_table :configuracao_notificacoes + end +end diff --git a/spec/models/configuracao_notificacao_spec.rb b/spec/models/configuracao_notificacao_spec.rb new file mode 100644 index 0000000..5bce1ca --- /dev/null +++ b/spec/models/configuracao_notificacao_spec.rb @@ -0,0 +1,189 @@ +require 'rails_helper' + +RSpec.describe ConfiguracaoNotificacao do + # Preenchimento mínimo para os toggles passarem nas validações condicionais. + def config_smtp(extras = {}) + described_class.new({ + smtp_ativo: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, + smtp_username: 'sistema@reem.com.br', smtp_password: 'segredo123' + }.merge(extras)) + end + + def config_whats(extras = {}) + described_class.new({ + whatsapp_ativo: true, twilio_account_sid: 'AC123', + twilio_auth_token: 'tok123', twilio_from: '+14155238886' + }.merge(extras)) + end + + describe '.instancia' do + it 'devolve sempre a mesma linha' do + primeira = described_class.instancia + expect(described_class.instancia.id).to eq(primeira.id) + expect(described_class.count).to eq(1) + end + end + + describe '.normalizar_telefone' do + { + '11 920051157' => '+5511920051157', + '(11) 92005-1157' => '+5511920051157', + '011 92005-1157' => '+5511920051157', + '+55 11 92005-1157' => '+5511920051157', + 'whatsapp:+5511920051157' => '+5511920051157', + '1132224444' => '+551132224444', + # DDD 55 (Santa Maria/RS): o "55" inicial é o DDD, não o país. + '55991234567' => '+5555991234567' + }.each do |entrada, esperado| + it "converte #{entrada.inspect} em #{esperado}" do + expect(described_class.normalizar_telefone(entrada)).to eq(esperado) + end + end + + it 'devolve nil para vazio ou número curto demais' do + expect(described_class.normalizar_telefone(nil)).to be_nil + expect(described_class.normalizar_telefone('')).to be_nil + expect(described_class.normalizar_telefone('920051157')).to be_nil + end + end + + describe '.canal' do + it 'prefixa whatsapp:' do + expect(described_class.canal('11 920051157')).to eq('whatsapp:+5511920051157') + end + + it 'devolve nil quando o número é inválido' do + expect(described_class.canal('abc')).to be_nil + end + end + + describe 'atributos cifrados' do + # A tabela é singleton (índice único em singleton_guard) e a migration já + # cria a linha — por isso os testes que persistem usam `.instancia`. + it 'grava criptografado e lê de volta em claro' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'minha-senha', twilio_auth_token: 'meu-token') + cfg.reload + + expect(cfg.smtp_password).to eq('minha-senha') + expect(cfg.twilio_auth_token).to eq('meu-token') + expect(cfg.smtp_password_cifrado).to be_present + expect(cfg.smtp_password_cifrado).not_to include('minha-senha') + end + + it 'não marca o registro como alterado ao reatribuir o mesmo valor' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'igual') + + cfg.smtp_password = 'igual' + expect(cfg).not_to be_changed + end + + it 'trata segredo ilegível como ausente, sem estourar' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'x') + cfg.update_column(:smtp_password_cifrado, 'lixo-que-nao-decifra') + + expect(cfg.reload.smtp_password).to be_nil + expect(cfg.smtp_password?).to be(false) + end + end + + describe 'validações' do + it 'não exige SMTP enquanto o envio de e-mail está desligado' do + expect(described_class.new).to be_valid + end + + it 'exige servidor, usuário e senha quando o e-mail é ativado' do + cfg = described_class.new(smtp_ativo: true) + expect(cfg).not_to be_valid + expect(cfg.errors.attribute_names).to include(:smtp_address, :smtp_username, :smtp_password) + end + + it 'exige credenciais do Twilio quando o WhatsApp é ativado' do + cfg = described_class.new(whatsapp_ativo: true) + expect(cfg).not_to be_valid + expect(cfg.errors.attribute_names).to include(:twilio_account_sid, :twilio_auth_token, :twilio_from) + end + + it 'rejeita e-mail administrativo malformado' do + expect(described_class.new(email_admin: 'sem-arroba')).not_to be_valid + end + + it 'rejeita porta fora da faixa' do + expect(described_class.new(smtp_port: 0)).not_to be_valid + expect(described_class.new(smtp_port: 70_000)).not_to be_valid + end + + it 'normaliza os telefones antes de salvar' do + cfg = described_class.instancia + cfg.update!(whatsapp_admin: '(11) 92005-1157') + expect(cfg.reload.whatsapp_admin).to eq('+5511920051157') + end + end + + describe '#smtp_settings' do + it 'devolve nil quando não está pronto, para o .env continuar valendo' do + expect(described_class.new.smtp_settings).to be_nil + expect(config_smtp(smtp_ativo: false).smtp_settings).to be_nil + end + + it 'usa STARTTLS na 587' do + settings = config_smtp.smtp_settings + expect(settings[:enable_starttls_auto]).to be(true) + expect(settings).not_to have_key(:ssl) + expect(settings[:password]).to eq('segredo123') + expect(settings[:authentication]).to eq(:plain) + end + + it 'usa SSL direto na 465' do + settings = config_smtp(smtp_port: 465).smtp_settings + expect(settings[:ssl]).to be(true) + expect(settings).not_to have_key(:enable_starttls_auto) + end + + it 'deriva o domínio do remetente quando não informado' do + expect(config_smtp.smtp_settings[:domain]).to eq('reem.com.br') + end + end + + describe '#remetente_formatado' do + it 'monta "Nome" quando há nome' do + cfg = config_smtp(remetente_email: 'nao-responda@reem.com.br', remetente_nome: 'Reem Transporte') + expect(cfg.remetente_formatado).to eq('"Reem Transporte" ') + end + + it 'cai no usuário do SMTP quando não há remetente próprio' do + expect(config_smtp.remetente_formatado).to eq('sistema@reem.com.br') + end + end + + describe '#credenciais_whatsapp' do + it 'usa o banco quando está completo' do + creds = config_whats.credenciais_whatsapp + expect(creds[:sid]).to eq('AC123') + expect(creds[:from]).to eq('whatsapp:+14155238886') + end + + it 'cai no .env quando o banco está incompleto' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env') + allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env') + allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886') + + expect(described_class.new.credenciais_whatsapp[:sid]).to eq('AC_env') + end + + it 'não mistura SID do banco com token do .env' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env') + allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env') + allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886') + + # SID no banco mas sem token → whatsapp_pronto? é false → vai inteiro pro ENV + cfg = described_class.new(whatsapp_ativo: true, twilio_account_sid: 'AC_banco') + expect(cfg.credenciais_whatsapp[:sid]).to eq('AC_env') + expect(cfg.credenciais_whatsapp[:token]).to eq('tok_env') + end + end +end diff --git a/spec/models/table_names_spec.rb b/spec/models/table_names_spec.rb index b0b9642..fb4faa5 100644 --- a/spec/models/table_names_spec.rb +++ b/spec/models/table_names_spec.rb @@ -8,7 +8,8 @@ RSpec.describe 'Nomes de tabela dos models' do Consolidacao => 'consolidacoes', ConsolidacaoMotorista => 'consolidacao_motoristas', ConsolidacaoEntrega => 'consolidacao_entregas', - Configuracao => 'configuracoes' + Configuracao => 'configuracoes', + ConfiguracaoNotificacao => 'configuracao_notificacoes' }.each do |model, tabela| it "#{model} aponta para #{tabela}" do expect(model.table_name).to eq(tabela) diff --git a/spec/policies/configuracao_notificacao_policy_spec.rb b/spec/policies/configuracao_notificacao_policy_spec.rb new file mode 100644 index 0000000..5f930fe --- /dev/null +++ b/spec/policies/configuracao_notificacao_policy_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe ConfiguracaoNotificacaoPolicy do + subject { described_class } + + # Mais restrito que ConfiguracaoPolicy de propósito: aqui ficam a senha do + # e-mail e o token do Twilio, então gerente não entra. + permissions :show?, :update? do + it 'libera só para admin' do + expect(subject).to permit(build(:admin), ConfiguracaoNotificacao) + expect(subject).not_to permit(build(:gerente), ConfiguracaoNotificacao) + expect(subject).not_to permit(build(:operador), ConfiguracaoNotificacao) + end + end +end diff --git a/spec/requests/admin/configuracao_notificacoes_spec.rb b/spec/requests/admin/configuracao_notificacoes_spec.rb new file mode 100644 index 0000000..ed88aab --- /dev/null +++ b/spec/requests/admin/configuracao_notificacoes_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe 'Admin::ConfiguracaoNotificacoes', type: :request do + let(:config) { ConfiguracaoNotificacao.instancia } + + def patch_config(atributos, acao: 'salvar') + patch admin_configuracao_notificacao_path, + params: { acao: acao, configuracao_notificacao: atributos } + end + + describe 'permissão' do + it 'exige login' do + get admin_configuracao_notificacao_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia gerente (só admin mexe em credenciais de envio)' do + sign_in create(:gerente) + get admin_configuracao_notificacao_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to match(/permissão/i) + end + + it 'bloqueia gerente também no update' do + sign_in create(:gerente) + patch_config(smtp_address: 'smtp.invasor.com') + + expect(response).to redirect_to(root_path) + expect(config.reload.smtp_address).not_to eq('smtp.invasor.com') + end + + it 'libera admin' do + sign_in create(:admin) + get admin_configuracao_notificacao_path + expect(response).to have_http_status(:ok) + end + end + + context 'como admin' do + let(:admin) { create(:admin) } + + before { sign_in admin } + + it 'salva as configurações' do + patch_config(smtp_address: 'smtp.gmail.com', smtp_port: 465, + email_admin: 'Chefe@Reem.com.br', whatsapp_admin: '(11) 92005-1157') + + expect(response).to redirect_to(admin_configuracao_notificacao_path) + config.reload + expect(config.smtp_address).to eq('smtp.gmail.com') + expect(config.smtp_port).to eq(465) + expect(config.email_admin).to eq('chefe@reem.com.br') + expect(config.whatsapp_admin).to eq('+5511920051157') + end + + it 'mantém a senha gravada quando o campo vem em branco' do + config.update!(smtp_password: 'senha-original') + + patch_config(smtp_address: 'smtp.outro.com', smtp_password: '') + + expect(config.reload.smtp_password).to eq('senha-original') + end + + it 'substitui a senha quando o campo é preenchido' do + config.update!(smtp_password: 'senha-original') + + patch_config(smtp_password: 'senha-nova') + + expect(config.reload.smtp_password).to eq('senha-nova') + end + + it 'reexibe o formulário quando ativa o e-mail sem credenciais' do + patch_config(smtp_ativo: '1', smtp_address: '', smtp_username: '') + + expect(response).to have_http_status(:unprocessable_entity) + expect(config.reload.smtp_ativo).to be(false) + end + + it 'registra auditoria sem vazar a senha nem o token' do + patch_config(smtp_address: 'smtp.gmail.com', smtp_password: 'super-secreta', + twilio_auth_token: 'token-secreto') + + log = AuditoriaLog.where(entidade: 'ConfiguracaoNotificacao').last + expect(log.acao).to eq('editar_notificacoes') + expect(log.user_id).to eq(admin.id) + + serializado = [log.dados_anteriores, log.dados_novos].to_json + expect(serializado).not_to include('super-secreta') + expect(serializado).not_to include('token-secreto') + expect(log.dados_novos['smtp_password_definida']).to be(true) + end + + describe 'botões de teste' do + it 'avisa quando o e-mail administrativo não foi preenchido' do + patch_config({ email_admin: '' }, acao: 'testar_email') + + expect(response).to redirect_to(admin_configuracao_notificacao_path) + expect(flash[:alert]).to match(/E-mail do administrador/i) + end + + it 'avisa quando o SMTP não está pronto' do + patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email') + + expect(flash[:alert]).to match(/Ativar envio de e-mail/i) + end + + it 'envia o e-mail de teste quando o SMTP está configurado' do + resultado = Notificacao::Resultado.sucesso('E-mail de teste enviado.') + expect(Notificacao::TesteEmail).to receive(:new).and_return(double(executar: resultado)) + + patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email') + + expect(flash[:notice]).to eq('E-mail de teste enviado.') + end + + it 'avisa quando o WhatsApp administrativo não foi preenchido' do + patch_config({ whatsapp_admin: '' }, acao: 'testar_whatsapp') + + expect(flash[:alert]).to match(/WhatsApp do administrador/i) + end + + it 'reporta o erro traduzido do Twilio no flash' do + resultado = Notificacao::Resultado.falha('Sandbox exige opt-in.', '[63015] detalhe') + expect(Notificacao::TesteWhatsapp).to receive(:new).and_return(double(executar: resultado)) + + patch_config({ whatsapp_admin: '11 920051157' }, acao: 'testar_whatsapp') + + expect(flash[:alert]).to include('Sandbox exige opt-in.') + expect(flash[:alert]).to include('63015') + end + end + end +end From c86bae66a463cd7655ce0483f382f7fbca9a8e956657f0ea56ffa306ebf5ae33 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 11 Aug 2026 17:02:45 -0300 Subject: [PATCH 2/7] =?UTF-8?q?Adi=C3=A7=C3=A3o=20da=20fun=C3=A7=C3=A3o=20?= =?UTF-8?q?de=20configura=C3=A7=C3=A3o=20de=20email=20e=20whatts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/layouts/_navbar.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index e07e198..197d730 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -61,7 +61,6 @@ <%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %> <% if current_user.admin? %> <%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %> - <%= nav_link_to 'Notificações', admin_configuracao_notificacao_path, icon: :email %> <% end %> <% end %> From e3d4546c3c28e33e16a39bc7571b9ff7e84012d7f41cb8cbacada2a7beba94f8 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 11 Aug 2026 23:42:00 -0300 Subject: [PATCH 3/7] =?UTF-8?q?Adi=C3=A7=C3=A3o=20da=20fun=C3=A7=C3=A3o=20?= =?UTF-8?q?de=20configura=C3=A7=C3=A3o=20de=20email=20e=20whatts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/README.md b/README.md index f877f1d..2607905 100644 --- a/README.md +++ b/README.md @@ -1786,3 +1786,161 @@ app/views/admin/configuracoes/index.html.erb # idem (arquivo distint +--- + +
+✉️ Notificações e E-mail configuráveis pela tela — SMTP + WhatsApp (11/08/2026) + +> ⚠️ **STATUS: implementado, ainda NÃO executado.** Nada aqui foi rodado contra um banco nem +> contra os servidores reais (Gmail/Twilio) — não há Ruby nem Postgres na máquina de +> desenvolvimento. Foi conferida a sintaxe de todos os arquivos `.rb` e `.erb` e validado o +> algoritmo de normalização de telefone em Ruby puro. **A migration, a suíte e o envio real +> continuam pendentes** — roteiro no fim desta seção. + +### 🎯 O problema +Servidor de e-mail e credenciais do Twilio viviam **só no `.env`**: trocar a senha de app do Gmail +ou o número remetente exigia editar o arquivo no servidor e **reiniciar o container**. O ADM não +tinha como fazer nada disso pela interface. + +Pior: as chaves `notificacao_whatsapp` e `notificacao_email` existiam em `configuracoes` mas +**nunca tiveram UI** — `Admin::ConfiguracoesController#index` filtra por `CHAVES_MOEDA`. Ligar +notificação só era possível pelo `rails console`. + +### 🆕 A tela +**Configurações → card "Notificações e E-mail"** (`/admin/configuracao_notificacao`). +**Só `admin`** — `ConfiguracaoNotificacaoPolicy` é mais restrita que `ConfiguracaoPolicy`, que +libera `index?` para gerente: aqui ficam senha de e-mail e token do Twilio. + +O acesso é **exclusivamente pelo card dentro de Configurações** — de propósito não há item no menu +lateral, para não expor um atalho de credenciais na navegação de todo dia. + +| Bloco | Campos | +|---|---| +| **Servidor de e-mail (SMTP)** | ativo, servidor, porta, usuário, senha, autenticação, domínio, e-mail e nome do remetente + toggle "avisar motoristas por e-mail" | +| **WhatsApp (Twilio)** | ativo, Account SID, Auth Token, número remetente | +| **Destinatários administrativos** | e-mail do admin, WhatsApp do admin | + +Três botões: **Salvar**, **Salvar e enviar e-mail de teste**, **Salvar e enviar WhatsApp de teste**. + +### ⚙️ Como funciona (técnico) — os 6 pontos não-óbvios + +**1. Hierarquia banco > `.env`, sem quebrar nada.** `ConfiguracaoNotificacao#smtp_settings` devolve +**`nil`** quando não está pronto. O ActionMailer faz `.merge(options || {})` por cima do que o +`config/initializers/smtp.rb` montou no boot — então o fallback para o `.env` é **automático**. +Enquanto os toggles estiverem desligados, o comportamento é **idêntico ao de antes desta tela**. + +**2. `default delivery_method_options:`, NÃO um `before_action`.** Um callback que mexesse em +`message.delivery_method` seria **descartado**: `ActionMailer::Base#mail` roda *depois* dos +callbacks e chama `wrap_delivery_behavior!`, que reconfigura a mensagem. O +`delivery_method_options` é lido *dentro* do próprio `mail()`. + +**3. `proc`, NÃO lambda.** O Devise avalia o `default from:` com `instance_eval(&proc)`, que passa +1 argumento. Um `-> { }` de aridade 0 estouraria **`ArgumentError` em todo "esqueci minha senha"**. + +**4. `config.parent_mailer = 'ApplicationMailer'`** no `devise.rb` — sem isso o reset de senha +continuaria preso ao `.env`. Efeito colateral aceito: os e-mails do Devise passam a usar +`app/views/layouts/mailer.html.erb`. + +**5. Segredos cifrados sem `master.key`.** Senha SMTP e Auth Token vão para colunas +`*_cifrado` (AES-256-GCM) via `AtributoCifrado`, com chave derivada do `secret_key_base`. **Não** se +usou ActiveRecord Encryption: exigiria 3 chaves novas, dependeria da ordem dos initializers e +estouraria `Errors::Decryption` na leitura. Aqui o reader faz `rescue → nil`, o app **degrada para +o `.env`** e a tela mostra um banner amarelo pedindo para redigitar. + +**6. Testes com `deliver_now` e `raise_delivery_errors = true` forçado.** O `development.rb` define +`raise_delivery_errors = false` e o adapter do ActiveJob é `:async` (thread in-process) — com +`deliver_later` **o teste "passaria" em silêncio mesmo com a senha errada**. + +### 🩹 Bug pré-existente corrigido de passagem +`NotificacaoService` mandava `to: "whatsapp:#{user.telefone}"` com o telefone **cru do cadastro**. +Um telefone gravado como `(11) 92005-1157` vira `whatsapp:(11) 92005-1157`, o Twilio devolve +**21211** — e o `rescue` engolia. **Provavelmente nenhum WhatsApp a motorista jamais chegou.** +Agora passa por `ConfiguracaoNotificacao.canal`, que normaliza para E.164. + +> Casos cobertos: `11 920051157`, `(11) 92005-1157`, `011 …`, `+55 11 …`, `whatsapp:+55…` → +> `+5511920051157`. O prefixo `55` só é removido quando sobra número demais — senão quebraria o +> **DDD 55** (Santa Maria/RS), onde `55991234567` já é o número completo. + +### 🔐 Segurança +- A senha gravada **nunca volta para o HTML** (`password_field value: nil`). Campo em branco + significa "mantenha a atual" — salvar sem redigitar não apaga o que está lá. +- `AuditoriaLog` registra a mudança com `acao: 'editar_notificacoes'`, mas grava apenas + `smtp_password_definida: true/false`. **Nunca a senha nem o token** — `dados_novos` é exibido em + `/admin/auditoria_logs`, que **gerente também acessa**. +- Os erros do Twilio vêm traduzidos (63003/63015 = falta o `join ` do sandbox, 21608 = + conta trial só envia a número verificado, 20003 = SID/token inválidos…), para o ADM resolver + sozinho sem abrir o log do container. + +### 🆕 Migration adicionada (rodar `db:migrate`) +``` +20260811000001_create_configuracao_notificacoes.rb +``` +Tabela **singleton** (índice único em `singleton_guard` — impede dois workers Puma criarem linhas +concorrentes). Copia as flags antigas de `configuracoes` e **não** importa credenciais do `.env`: +os campos nascem vazios e o fallback segue mandando até alguém preencher a tela. + +### 📂 Arquivos +``` +db/migrate/20260811000001_create_configuracao_notificacoes.rb # NOVO — tabela singleton +app/models/configuracao_notificacao.rb # NOVO — smtp_settings, credenciais, E.164 +app/models/concerns/atributo_cifrado.rb # NOVO — AES-256-GCM sem master.key +app/policies/configuracao_notificacao_policy.rb # NOVO — show?/update? = admin +app/controllers/admin/configuracao_notificacoes_controller.rb # NOVO — show/update + params[:acao] +app/views/admin/configuracao_notificacoes/show.html.erb # NOVO — a tela +app/services/notificacao/resultado.rb # NOVO — ok?/mensagem/detalhe → flash +app/services/notificacao/teste_email.rb # NOVO — erros SMTP em português +app/services/notificacao/teste_whatsapp.rb # NOVO — códigos Twilio em português +app/services/notificacao/cliente_twilio.rb # NOVO — client com timeout de 15s +app/mailers/teste_mailer.rb · app/views/teste_mailer/teste.html.erb # NOVO +app/services/notificacao_service.rb # lê do banco; unifica os 2 pares duplicados +app/mailers/application_mailer.rb # default from: / delivery_method_options: proc +app/mailers/consolidacao_mailer.rb # removido o `default from:` que anulava o proc +config/initializers/devise.rb # + parent_mailer +config/initializers/smtp.rb # vira fallback (só comentário) +config/initializers/inflections.rb # + irregular 'notificacao' +config/routes.rb # + resource :configuracao_notificacao +app/views/admin/configuracoes/index.html.erb # + card "Notificações e E-mail" (só admin) +app/models/configuracao.rb # marca notificacao_* como obsoletas +.env.example # hierarquia banco > .env + NOTIFICACAO_SECRET +spec/models/configuracao_notificacao_spec.rb # NOVO +spec/policies/configuracao_notificacao_policy_spec.rb # NOVO +spec/requests/admin/configuracao_notificacoes_spec.rb # NOVO +spec/models/table_names_spec.rb # + ConfiguracaoNotificacao +``` + +### ⏳ Pendente de execução — roteiro +```bash +# 1. Migrar +docker-compose exec app bundle exec rails db:migrate + +# 2. Suíte +docker-compose exec app bundle exec rspec spec/models spec/policies spec/requests + +# 3. Confirmar a assinatura da gem (não pôde ser verificada fora do container) +docker-compose exec app bundle exec rails runner \ + 'p Twilio::HTTP::Client.instance_method(:initialize).parameters' +``` +4. **Permissão:** logar como **gerente** → o card não aparece e `/admin/configuracao_notificacao` + redireciona com "Você não tem permissão". Como **admin** → a tela abre. +5. **E-mail:** `smtp.gmail.com`, porta 587, **Senha de app de 16 caracteres** (não a senha da + conta), marcar "Ativar envio de e-mail" → **Salvar e enviar e-mail de teste**. Conferir o spam. +6. **WhatsApp:** SID / Auth Token / número remetente do Twilio, marcar "Ativar WhatsApp" → + **Salvar e enviar WhatsApp de teste**. ⚠️ **No sandbox, o número que vai RECEBER precisa antes + mandar `join `** para o número do sandbox — sem esse opt-in volta 63003/63015. +7. **Não regressão:** com os toggles desligados, finalizar uma consolidação e conferir que nada + mudou; e testar o **"Esqueci minha senha"**, que trocou de mailer pai. + +### ⚠️ Alertas +- **Rotação do `SECRET_KEY_BASE` torna senha e token ilegíveis.** O sistema não quebra (volta ao + `.env` e avisa na tela), mas os dois campos precisam ser redigitados. Para desacoplar, defina + **`NOTIFICACAO_SECRET`** no `.env` com uma string longa e **fixa**. +- **Não cachear a config em `Rails.cache`:** o `production.rb` usa `:memory_store`, que é por + processo — a tela pareceria "não salvar" para os outros workers. É 1 `SELECT` por e-mail. +- `app/views/configuracoes/index.html.erb` (fora do `admin/`) **não recebeu o card**: não tem rota + e é código morto — o vivo é `app/views/admin/configuracoes/index.html.erb`. + +> **Migration nova** (`db:migrate` obrigatório) e **sem gem nova** — `twilio-ruby` já estava no +> Gemfile. ⚠️ Reiniciar o Puma após o deploy. + +
+ From cebd6e2dcd0354f7671fe742b78f7980dc5a968b3004f606275602d19443f4ec Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 20 Aug 2026 15:48:47 -0300 Subject: [PATCH 4/7] =?UTF-8?q?corre=C3=A7=C3=A3o=20de=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/dashboard_controller.rb | 65 +++--- app/models/entrega.rb | 10 +- app/views/dashboard/index.html.erb | 8 +- bin/entregas_do_motorista | 252 ++++++++++++++++++++++++ lib/tasks/entregas_motorista.rake | 118 +++++++++++ spec/models/entrega_spec.rb | 137 +++++++++++++ spec/requests/dashboard_spec.rb | 108 ++++++++++ spec/support/espelho_rastreio.rb | 102 ++++++++++ 8 files changed, 770 insertions(+), 30 deletions(-) create mode 100755 bin/entregas_do_motorista create mode 100644 lib/tasks/entregas_motorista.rake create mode 100644 spec/models/entrega_spec.rb create mode 100644 spec/support/espelho_rastreio.rb diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e1f349c..d530fe9 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -76,35 +76,43 @@ class DashboardController < ApplicationController @operacoes_agrupadas = Operacao.agrupadas_por_mes @operacao_filtro = Operacao.sanitizar(params[:operacoes]) - # PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data. - pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim) - pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? + # ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira. + # + # Atendida = o motorista FOI ao local e fechou a visita, com sucesso + # (`completed`) OU insucesso (`failed`). É exatamente o conjunto que a + # consolidação considera elegível e paga (ver Entrega::STATUS_ATENDIDO, + # Entrega.contar_atendidas e Consolidacao#veiculos_do_motorista), por isso o + # dashboard usa o MESMO recorte — usava só `pagas` (concluídas) e mostrava + # um valor menor que o do fechamento. + atendidas = Entrega.da_conta_gade.atendidas.no_periodo_checkout(@periodo_inicio, @periodo_fim) + atendidas = atendidas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada. + # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada — + # é o único recorte sem checkout, então planned_date é o eixo correto aqui. pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - # FALHADAS (insucesso) pela data planejada — também não têm checkout. - falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim) - falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present? - - # Totais gerais - @entregas_pagas = pagas.count + # Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`). + # ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com + # motivo de insucesso. Por isso o eixo é checkout, igual às concluídas — antes + # as falhas eram filtradas por planned_date e caíam no período errado. + @entregas_pagas = atendidas.concluidas.count + @entregas_falhadas = atendidas.falhadas.count + @entregas_atendidas = @entregas_pagas + @entregas_falhadas @entregas_pendentes = pendentes.count - @entregas_falhadas = falhadas.count - @total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas + @total_entregas = @entregas_atendidas + @entregas_pendentes # Configurações de preço config = Configuracao.mapa_de_precos # Valor estimado total - @valor_estimado = (@entregas_pagas * config[:entrega]).round(2) + @valor_estimado = (@entregas_atendidas * config[:entrega]).round(2) # Por motorista (todos) - @motoristas = pagas.group(:driver) - .count - .sort_by { |_, v| -v } - .map do |driver, qtd| + @motoristas = atendidas.group(:driver) + .count + .sort_by { |_, v| -v } + .map do |driver, qtd| { nome: driver, entregas: qtd, @@ -113,14 +121,14 @@ class DashboardController < ApplicationController end # Por local de entrega (contact_name) - @por_operacao = pagas.group(:contact_name) - .count - .sort_by { |_, v| -v } - .first(6) - .to_h + @por_operacao = atendidas.group(:contact_name) + .count + .sort_by { |_, v| -v } + .first(6) + .to_h # Evolução diária no período (para Chart.js) — por data real (checkout) - @grafico_diario = build_grafico_diario(pagas, config[:entrega]) + @grafico_diario = build_grafico_diario(atendidas, config[:entrega]) # Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard # — independe de quando foram criadas (created_at). @@ -221,20 +229,21 @@ class DashboardController < ApplicationController .sort_by { |_, v| -v } end - # `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL - # (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso. - def build_grafico_diario(pagas, preco_entrega) + # `atendidas` já vem filtrado por checkout no período (concluídas + falhadas). + # Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz + # conversão de fuso. + def build_grafico_diario(atendidas, preco_entrega) dias = (@periodo_inicio..@periodo_fim).to_a if dias.size <= MAX_DIAS_GRAFICO # Granularidade diária (períodos curtos) - contagem = pagas.group("DATE(checkout)").count.transform_keys(&:to_s) + contagem = atendidas.group("DATE(checkout)").count.transform_keys(&:to_s) labels = dias.map { |d| d.strftime('%d/%m') } valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) } qtds = dias.map { |d| contagem[d.to_s] || 0 } else # Período longo: agrega por mês para o gráfico não ficar vazio nem poluído. - contagem = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count + contagem = atendidas.group("TO_CHAR(checkout, 'YYYY-MM')").count meses = meses_no_periodo labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') } valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) } diff --git a/app/models/entrega.rb b/app/models/entrega.rb index fe76fa2..3882316 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -121,12 +121,20 @@ class Entrega < ApplicationRecord # Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o # filtro dinâmico de veículos na criação de consolidação. + # + # ⚠️ Precisa usar EXATAMENTE o mesmo recorte da elegibilidade + # (`atendidas` + `no_periodo_checkout`, ver contar_atendidas e + # Consolidacao#veiculos_do_motorista). Usava `no_periodo` (planned_date), e o + # descasamento sumia com veículo do filtro: entrega planejada em 31/07 e + # entregue em 01/08 é elegível numa consolidação 01/08–14/08 (checkout dentro), + # mas o veículo dela não aparecia (planned_date fora) — sem opção de marcar, + # a entrega ficava de fora do fechamento SEM aviso, e o financeiro não batia. def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil) nomes = Array(motoristas).reject(&:blank?) return [] if nomes.empty? base = da_conta_gade.where(driver: nomes) - base = base.no_periodo(inicio, fim) if inicio.present? && fim.present? + base = base.atendidas.no_periodo_checkout(inicio, fim) if inicio.present? && fim.present? base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?) end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 167d030..84dc3cd 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -134,8 +134,14 @@

<%= moeda(@valor_estimado) %>

+ <%# "Atendidas" = concluídas + falhadas (motorista foi ao local e fechou + a visita). É a mesma base que a consolidação paga, por isso o rótulo + NÃO pode voltar a dizer "concluídas": o valor acima inclui as falhas. %>

- <%= @entregas_pagas %> entregas concluídas + <%= @entregas_atendidas %> entregas atendidas +

+

+ <%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas

diff --git a/bin/entregas_do_motorista b/bin/entregas_do_motorista new file mode 100755 index 0000000..5e79b1b --- /dev/null +++ b/bin/entregas_do_motorista @@ -0,0 +1,252 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Lista, DIRETO NA API do SimpliRoute, todas as visitas de um motorista dentro +# de um período. +# +# ⚠️ POR QUE É DIA A DIA (e não uma chamada só): +# A API NÃO tem filtro de intervalo. Testado contra a API real em 21/07/2026 +# (bin/sondar_busca_nf): `planned_date_from/to`, `planned_date_after/before`, +# `__gte/__lte`, `since/until`, `start_date/end_date`, `date_from/to` — TODOS +# ignorados em silêncio (200 + o conjunto padrão inteiro). E SEM `planned_date` +# a API devolve um conjunto padrão (~2469 visitas) que NÃO cobre o histórico. +# Logo: `planned_date` é obrigatório e o período vira N requisições, uma por dia, +# disparadas em paralelo. +# +# ⚠️ Não filtramos motorista no servidor: `search=` só foi comprovado para NF, e +# parâmetro que a API não registra é IGNORADO em silêncio (devolve o dia inteiro +# com HTTP 200). Então baixamos o dia inteiro e filtramos AQUI — é lento +# (~3,8 MB / ~9 s por dia) mas é a única forma correta. +# +# SÓ LEITURA: o script faz apenas GET. Não altera nada. +# +# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN): +# +# SIMPLIROUTE_TOKEN=xxx bin/entregas_do_motorista \ +# --motorista "JOSE DA SILVA" --de 2026-08-01 --ate 2026-08-14 +# +# ... --csv /tmp/entregas.csv grava o resultado em CSV +# ... --listar-motoristas só mostra quem aparece no período (sem filtrar) +# ... --campos mostra as chaves cruas de uma visita (diagnóstico) +# +# Alternativa MUITO mais barata quando não precisa do dado ao vivo: o espelho +# local (model Entrega, tabela db_reem_simplerout_2026) já tem a coluna `driver` +# e responde isso numa query só: +# +# Entrega.no_periodo('2026-08-01', '2026-08-14').do_motorista('JOSE DA SILVA') +# +# O espelho sincroniza depois da API, então só use este script quando precisar +# do estado ATUAL ou desconfiar da sync. + +require 'net/http' +require 'json' +require 'uri' +require 'date' +require 'csv' + +BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com') +TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s +THREADS = 6 # mesmo lote usado no sondar_busca_nf; acima disso a API estrangula + +def sair(msg) + warn msg + exit 1 +end + +sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty? + +motorista = nil +de = nil +ate = nil +csv_path = nil +listar = false +campos = false +ARGV.each_with_index do |a, i| + case a + when '--motorista' then motorista = ARGV[i + 1] + when '--de' then de = ARGV[i + 1] + when '--ate', '--até' then ate = ARGV[i + 1] + when '--csv' then csv_path = ARGV[i + 1] + when '--listar-motoristas' then listar = true + when '--campos' then campos = true + end +end + +sair('Uso: bin/entregas_do_motorista --motorista "NOME" --de YYYY-MM-DD --ate YYYY-MM-DD') if de.to_s.empty? || ate.to_s.empty? +sair('Informe --motorista, ou use --listar-motoristas.') if motorista.to_s.empty? && !listar && !campos + +inicio = begin + Date.parse(de) +rescue ArgumentError + sair("Data inválida em --de: #{de}") +end +fim = begin + Date.parse(ate) +rescue ArgumentError + sair("Data inválida em --ate: #{ate}") +end +sair('--de é depois de --ate.') if inicio > fim + +DIAS = (inicio..fim).to_a + +# Normaliza nome para comparar: sem acento, sem espaço duplo, maiúsculo. +# O nome no SimpliRoute é digitado por gente — "José da Silva" e "JOSE DA SILVA" +# são o mesmo motorista. +def normalizar(nome) + nome.to_s + .unicode_normalize(:nfd).gsub(/\p{Mn}/, '') + .upcase.gsub(/\s+/, ' ').strip +end + +def requisicao(caminho) + uri = URI.join(BASE, caminho) + req = Net::HTTP::Get.new(uri) + req['Authorization'] = "Token #{TOKEN}" + req['Accept'] = 'application/json' + + res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: 10, read_timeout: 180) { |h| h.request(req) } + return [res.code.to_i, nil] unless res.code.to_i == 200 + + [200, (res.body.to_s.empty? ? nil : JSON.parse(res.body))] +rescue StandardError => e + [0, "ERRO: #{e.class}: #{e.message}"] +end + +# A resposta pode ser lista crua OU paginada ({count, next, results}). +def itens(corpo) + return corpo if corpo.is_a?(Array) + return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results') + + [] +end + +# Onde está o nome do motorista numa visita. A lista-por-data às vezes traz o +# campo achatado ('driver'), às vezes só o id, às vezes um hash aninhado — por +# isso procuramos em várias formas em vez de fixar uma. Se nada casar, o script +# avisa e o --campos mostra o formato real. +CHAVES_MOTORISTA = %w[driver driver_name route_driver driver_full_name].freeze + +def nome_do_motorista(visita) + CHAVES_MOTORISTA.each do |k| + v = visita[k] + next if v.nil? || v == '' || v.is_a?(Numeric) + + return v if v.is_a?(String) + return (v['name'] || v['full_name'] || + [v['first_name'], v['last_name']].compact.join(' ')) if v.is_a?(Hash) + end + nil +end + +puts "Base: #{BASE}" +puts "Período: #{inicio.strftime('%d/%m/%Y')} a #{fim.strftime('%d/%m/%Y')} (#{DIAS.size} dia(s), #{THREADS} em paralelo)" +puts "Motorista: #{motorista}" if motorista +puts '(a API não filtra por intervalo nem por motorista — baixando dia a dia; pode levar ~1 min)' +puts + +# ── Varredura dia a dia, em lotes ──────────────────────────────────────────── +t0 = Time.now +visitas = [] +falhas = [] +mutex = Mutex.new + +DIAS.each_slice(THREADS) do |lote| + lote.map do |dia| + Thread.new do + cod, corpo = requisicao("/v1/routes/visits/?planned_date=#{dia.iso8601}") + lista = cod == 200 ? itens(corpo) : [] + mutex.synchronize do + if cod == 200 + # planned_date às vezes não volta na visita — garantimos o dia pedido. + lista.each { |v| v['_dia'] = dia.iso8601 } + visitas.concat(lista) + print '.' + else + falhas << [dia, cod, corpo] + print 'x' + end + end + end + end.each(&:join) +end +puts "\nBaixadas #{visitas.size} visita(s) em #{(Time.now - t0).round(1)}s" +falhas.each { |d, c, e| warn " ⚠️ #{d}: HTTP #{c} #{e.inspect[0, 120]}" } +sair('Nenhuma visita voltou — período sem operação ou token sem acesso.') if visitas.empty? + +# ── Diagnóstico do formato ─────────────────────────────────────────────────── +if campos + puts "\nChaves de uma visita crua:" + puts visitas.first.keys.sort.inspect + puts "\nExemplo (recortado):" + puts JSON.pretty_generate(visitas.first)[0, 2000] + exit 0 +end + +com_nome = visitas.count { |v| nome_do_motorista(v) } +if com_nome.zero? + warn "\n⚠️ NENHUMA visita trouxe nome de motorista nas chaves #{CHAVES_MOTORISTA.inspect}." + warn ' A lista-por-data pode estar devolvendo só o id da rota. Rode com --campos' + warn ' para ver o formato real, ou use o espelho local (Entrega#driver).' + exit 1 +end +warn "\n⚠️ #{visitas.size - com_nome} visita(s) sem motorista preenchido (ficam de fora do filtro)." if com_nome < visitas.size + +# ── Só listar quem apareceu ────────────────────────────────────────────────── +if listar + puts "\nMotoristas no período:" + visitas.group_by { |v| nome_do_motorista(v) } + .reject { |k, _| k.nil? } + .sort_by { |_, vs| -vs.size } + .each { |nome, vs| puts " #{vs.size.to_s.rjust(5)} #{nome}" } + exit 0 +end + +# ── Filtro pelo motorista ──────────────────────────────────────────────────── +alvo = normalizar(motorista) +dele = visitas.select { |v| normalizar(nome_do_motorista(v)) == alvo } + +if dele.empty? + puts "\nNenhuma visita de #{motorista.inspect} no período." + parecidos = visitas.map { |v| nome_do_motorista(v) }.compact.uniq + .select { |n| normalizar(n).include?(alvo) || alvo.include?(normalizar(n)) } + puts "Nomes parecidos que existem: #{parecidos.inspect}" if parecidos.any? + puts 'Use --listar-motoristas para ver a grafia exata.' + exit 0 +end + +dele.sort_by! { |v| [v['_dia'].to_s, v['checkout_time'].to_s] } + +puts "\n#{'─' * 100}" +puts "#{dele.size} visita(s) de #{motorista}" +puts '─' * 100 +printf("%-11s %-10s %-9s %-19s %-40s\n", 'DATA', 'NF', 'STATUS', 'CHECKOUT', 'DESTINO') +dele.each do |v| + printf("%-11s %-10s %-9s %-19s %-40s\n", + Date.parse(v['_dia']).strftime('%d/%m/%Y'), + v['reference'].to_s[0, 10], + v['status'].to_s[0, 9], + v['checkout_time'].to_s[0, 19].tr('T', ' '), + v['title'].to_s[0, 40]) +end + +puts '─' * 100 +por_status = dele.group_by { |v| v['status'].to_s }.transform_values(&:size) +puts "Total: #{dele.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ') +atendidas = dele.count { |v| %w[completed failed].include?(v['status'].to_s) && v['checkout_time'].to_s != '' } +puts "Atendidas (foi ao local, com checkout): #{atendidas}" +puts "Dias com entrega: #{dele.map { |v| v['_dia'] }.uniq.size}" + +# ── CSV ────────────────────────────────────────────────────────────────────── +if csv_path + CSV.open(csv_path, 'w') do |csv| + csv << ['Data', 'NF', 'Tracking', 'ID visita', 'Motorista', 'Status', + 'Checkout', 'Título', 'Endereço', 'Motivo (UUID)', 'Comentário'] + dele.each do |v| + csv << [Date.parse(v['_dia']).strftime('%d/%m/%Y'), v['reference'], v['tracking_id'], v['id'], + nome_do_motorista(v), v['status'], v['checkout_time'], v['title'], v['address'], + v['checkout_observation'], v['checkout_comment']] + end + end + puts "\nCSV gravado em #{csv_path}" +end diff --git a/lib/tasks/entregas_motorista.rake b/lib/tasks/entregas_motorista.rake new file mode 100644 index 0000000..593620c --- /dev/null +++ b/lib/tasks/entregas_motorista.rake @@ -0,0 +1,118 @@ +# lib/tasks/entregas_motorista.rake +# +# Lista as entregas de um motorista num período lendo o ESPELHO LOCAL +# (model Entrega / tabela db_reem_simplerout_2026) — sem tocar na API do +# SimpliRoute, logo sem precisar de SIMPLIROUTE_TOKEN. +# +# Por que aqui e não na API: a API não tem filtro de intervalo nem de motorista +# (ver bin/entregas_do_motorista), então um período vira N requisições de vários +# MB. No espelho é uma query só. O preço é a defasagem da sync — se precisar do +# estado AO VIVO, use bin/entregas_do_motorista. +# +# SÓ LEITURA: Entrega#readonly? é true; nada aqui escreve. +# +# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14]" +# rake "entregas:motorista[JOSE DA SILVA,2026-08-01,2026-08-14,/tmp/saida.csv]" +# rake "entregas:motoristas[2026-08-01,2026-08-14]" # quem operou no período +# +# ⚠️ O nome tem que ser a grafia EXATA da coluna `driver` (do_motorista usa +# igualdade). Rode `entregas:motoristas` primeiro para ver a lista; se o nome +# passado não bater exatamente, a task sugere os parecidos em vez de devolver +# vazio silenciosamente. +# +# ⚠️ Período pelo `planned_date` (dia planejado da visita), que é o mesmo eixo +# do dashboard. As análises FINANCEIRAS usam `checkout` (data real da entrega) — +# são recortes diferentes; passe FONTE=checkout para usar esse outro eixo. + +namespace :entregas do + desc 'Entregas de um motorista no período, lidas do espelho local (sem API)' + task :motorista, %i[nome inicio fim csv] => :environment do |_t, args| + nome = args[:nome].to_s.strip + inicio = args[:inicio].to_s.strip + fim = args[:fim].to_s.strip + + if nome.empty? || inicio.empty? || fim.empty? + abort 'Uso: rake "entregas:motorista[NOME,YYYY-MM-DD,YYYY-MM-DD,/caminho/opcional.csv]"' + end + + d1 = Date.parse(inicio) + d2 = Date.parse(fim) + abort 'Início é depois do fim.' if d1 > d2 + + # planned_date = dia planejado; checkout = dia em que a entrega ocorreu. + por_checkout = ENV['FONTE'].to_s.casecmp('checkout').zero? + base = Entrega.da_conta_gade + base = por_checkout ? base.no_periodo_checkout(d1, d2) : base.no_periodo(d1, d2) + + entregas = base.do_motorista(nome).order(:planned_date, :checkout).to_a + + if entregas.empty? + puts "Nenhuma entrega de #{nome.inspect} entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}." + # Nome quase certo é o erro mais comum aqui — mostrar o vizinho evita + # concluir "o motorista não rodou" quando na verdade a grafia divergiu. + alvo = nome.upcase + parecidos = Entrega.motoristas_ativos(inicio: d1, fim: d2) + .select { |n| n.upcase.include?(alvo) || alvo.include?(n.upcase) } + puts "Nomes parecidos no período: #{parecidos.inspect}" if parecidos.any? + puts 'Rode: rake "entregas:motoristas[' + "#{d1},#{d2}" + ']" para ver a grafia exata.' + next + end + + eixo = por_checkout ? 'checkout (data real)' : 'planned_date (dia planejado)' + puts "#{'─' * 104}" + puts "#{entregas.size} entrega(s) de #{nome} · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} · eixo: #{eixo}" + puts '─' * 104 + printf("%-11s %-10s %-10s %-10s %-17s %-38s\n", 'DATA', 'NF', 'VEÍCULO', 'STATUS', 'CHECKOUT', 'DESTINO') + entregas.each do |e| + printf("%-11s %-10s %-10s %-10s %-17s %-38s\n", + e.planned_date&.strftime('%d/%m/%Y').to_s, + e.reference_id.to_s[0, 10], + e.vehicle.to_s[0, 10], + e.status.to_s[0, 10], + e.checkout&.strftime('%d/%m %H:%M').to_s, + e.local.to_s[0, 38]) + end + puts '─' * 104 + + por_status = entregas.group_by { |e| e.status.to_s }.transform_values(&:size) + puts "Total: #{entregas.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ') + # "Atendidas" = foi ao local (sucesso OU insucesso) com checkout — é o + # conjunto elegível da consolidação, e não o mesmo que "entregues". + puts "Entregues (completed + checkout) ..... #{entregas.count { |e| e.concluida? && e.checkout_registrado? }}" + puts "Insucessos (failed) .................. #{entregas.count(&:falhada?)}" + puts "Atendidas (foi ao local c/ checkout) . #{entregas.count { |e| Entrega::STATUS_ATENDIDO.include?(e.status) && e.checkout_registrado? }}" + puts "Dias com entrega ..................... #{entregas.map { |e| e.planned_date&.to_date }.compact.uniq.size}" + puts "Veículos ............................. #{entregas.map(&:vehicle).compact.reject(&:empty?).uniq.join(', ')}" + + if args[:csv].present? + require 'csv' + CSV.open(args[:csv], 'w') do |csv| + csv << ['Data planejada', 'NF', 'Tracking', 'Motorista', 'Veículo', 'Status', 'Checkout', 'Local', 'Endereço', 'Motivo'] + entregas.each do |e| + csv << [e.planned_date, e.reference_id, e.tracking_id, e.driver, e.vehicle, + e.status, e.checkout, e.local, e.try(:address), e.try(:observation)] + end + end + puts "\nCSV gravado em #{args[:csv]}" + end + end + + desc 'Motoristas que aparecem no espelho dentro do período (grafia exata + volume)' + task :motoristas, %i[inicio fim] => :environment do |_t, args| + abort 'Uso: rake "entregas:motoristas[YYYY-MM-DD,YYYY-MM-DD]"' if args[:inicio].blank? || args[:fim].blank? + + d1 = Date.parse(args[:inicio]) + d2 = Date.parse(args[:fim]) + + contagem = Entrega.da_conta_gade.no_periodo(d1, d2) + .where.not(driver: [nil, '']).group(:driver).count + + if contagem.empty? + puts "Nenhuma entrega no espelho entre #{d1.strftime('%d/%m/%Y')} e #{d2.strftime('%d/%m/%Y')}." + next + end + + puts "Motoristas · #{d1.strftime('%d/%m/%Y')} a #{d2.strftime('%d/%m/%Y')} (#{contagem.values.sum} entregas)" + contagem.sort_by { |_, n| -n }.each { |nome, n| puts " #{n.to_s.rjust(5)} #{nome}" } + end +end diff --git a/spec/models/entrega_spec.rb b/spec/models/entrega_spec.rb new file mode 100644 index 0000000..ed509ee --- /dev/null +++ b/spec/models/entrega_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' + +# Trava o EIXO DE DATA do espelho de rastreio. +# +# O sistema tem dois eixos e eles NÃO são intercambiáveis: +# planned_date → dia planejado da visita (scope :no_periodo) +# checkout → dia REAL da entrega (scope :no_periodo_checkout) +# +# A elegibilidade da consolidação — o que vira dinheiro — é sempre pelo +# `checkout`. Um filtro que use `planned_date` some com entrega elegível na +# virada de mês e o fechamento não bate. Foi exatamente o bug do seletor de +# veículos da nova consolidação (Entrega.veiculos_de_motoristas usava +# :no_periodo), por isso o caso da virada está fixado aqui. +# +# A tabela do espelho é EXTERNA (sem migration, sem factory) — quem a cria e +# derruba no banco de teste é o harness em spec/support/espelho_rastreio.rb, que +# também expõe o helper `semear`. +RSpec.describe Entrega, type: :model do + include_context 'espelho de rastreio' + + describe '.veiculos_de_motoristas' do + # Período de fechamento usado em todos os exemplos. + let(:inicio) { Date.new(2026, 8, 1) } + let(:fim) { Date.new(2026, 8, 14) } + + it 'inclui o veículo de entrega PLANEJADA fora do período mas ENTREGUE dentro' do + # A regressão: planejada em 31/07, entregue em 01/08. É elegível (checkout + # dentro), logo o veículo TEM que aparecer — senão não há como marcá-lo no + # filtro e a entrega sai do fechamento sem aviso. + semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed', + planned_date: Time.zone.local(2026, 7, 31, 8, 0), + checkout: Time.zone.local(2026, 8, 1, 9, 30) }]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to eq(['GADE_001']) + end + + it 'exclui o veículo de entrega PLANEJADA dentro do período mas ENTREGUE fora' do + # O espelho do caso acima: planejada em 14/08, entregue em 20/08. Não é + # elegível neste fechamento, então o veículo não pode aparecer. + semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'completed', + planned_date: Time.zone.local(2026, 8, 14, 8, 0), + checkout: Time.zone.local(2026, 8, 20, 9, 30) }]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to be_empty + end + + it 'inclui o último dia do período inteiro (checkout às 23h de 14/08)' do + # no_periodo_checkout usa `< fim + 1`; um `<= fim` cortaria o último dia. + semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'completed', + planned_date: Time.zone.local(2026, 8, 14, 7, 0), + checkout: Time.zone.local(2026, 8, 14, 23, 40) }]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to eq(['GADE_003']) + end + + it 'inclui veículo de INSUCESSO (failed conta como atendida)' do + # Atendida = o motorista foi ao local, com sucesso OU insucesso. Insucesso + # é elegível e precisa ser classificado no wizard. + semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'failed', + planned_date: Time.zone.local(2026, 8, 5, 8, 0), + checkout: Time.zone.local(2026, 8, 5, 16, 0) }]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to eq(['GADE_004']) + end + + it 'exclui veículo que só tem entrega PENDENTE (sem checkout) no período' do + # Pendente não é elegível; listar o veículo só sujaria o filtro com um + # carro de zero elegíveis. + semear([{ driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'pending', + planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: nil }]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to be_empty + end + + it 'não vaza veículo de outro motorista' do + semear([ + { driver: 'JOSE DA SILVA', vehicle: 'GADE_006', status: 'completed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) }, + { driver: 'MARIA SOUZA', vehicle: 'GADE_007', status: 'completed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 11, 0) } + ]) + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim)) + .to eq(['GADE_006']) + end + + # A invariante que realmente protege o financeiro: o seletor de veículos do + # formulário e o conjunto elegível da consolidação têm que enxergar o MESMO + # recorte. Se alguém mexer no eixo de data de um dos dois, este exemplo cai. + it 'lista exatamente os veículos que têm entrega elegível no período' do + semear([ + # elegíveis (checkout dentro) + { driver: 'JOSE DA SILVA', vehicle: 'GADE_010', status: 'completed', + planned_date: Time.zone.local(2026, 7, 30, 8, 0), checkout: Time.zone.local(2026, 8, 2, 10, 0) }, + { driver: 'JOSE DA SILVA', vehicle: 'GADE_011', status: 'failed', + planned_date: Time.zone.local(2026, 8, 9, 8, 0), checkout: Time.zone.local(2026, 8, 9, 10, 0) }, + # não elegíveis + { driver: 'JOSE DA SILVA', vehicle: 'GADE_012', status: 'completed', + planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 10, 0) }, + { driver: 'JOSE DA SILVA', vehicle: 'GADE_013', status: 'pending', + planned_date: Time.zone.local(2026, 8, 11, 8, 0), checkout: nil } + ]) + + elegiveis = described_class.atendidas.da_conta_gade + .no_periodo_checkout(inicio, fim) + .do_motorista('JOSE DA SILVA') + .distinct.pluck(:vehicle).compact.sort + + expect(described_class.veiculos_de_motoristas(['JOSE DA SILVA'], inicio: inicio, fim: fim).sort) + .to eq(elegiveis) + expect(elegiveis).to eq(%w[GADE_010 GADE_011]) + end + + it 'devolve vazio sem motorista informado (não toca o banco)' do + expect(described_class.veiculos_de_motoristas([], inicio: inicio, fim: fim)).to eq([]) + expect(described_class.veiculos_de_motoristas(nil, inicio: inicio, fim: fim)).to eq([]) + end + end + + # Os dois scopes são a origem da confusão — vale fixar que cada um olha para a + # coluna que promete, para nenhum refactor trocar um pelo outro em silêncio. + describe 'eixos de período' do + it ':no_periodo filtra por planned_date e :no_periodo_checkout por checkout' do + expect(described_class.no_periodo(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql) + .to include('planned_date') + + sql_checkout = described_class.no_periodo_checkout(Date.new(2026, 8, 1), Date.new(2026, 8, 14)).to_sql + expect(sql_checkout).to include('checkout') + expect(sql_checkout).not_to include('planned_date') + end + end +end diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index cc4518a..7ddb2ab 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -12,3 +12,111 @@ RSpec.describe 'Dashboard', type: :request do expect(response).to redirect_to(motorista_dashboard_path) end end + +# Trava a CONTABILIZAÇÃO DAS FALHAS no dashboard principal. +# +# A base financeira do dashboard é `atendidas` (concluídas + falhadas, com +# checkout) — o MESMO conjunto que a consolidação considera elegível e paga. +# Antes usava só `pagas` (concluídas) e o valor da tela ficava menor que o do +# fechamento; e as falhas eram filtradas por `planned_date`, caindo no período +# errado. Os dois casos estão fixados aqui. +# +# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com motivo +# de insucesso. É o que torna `checkout` o eixo correto também para elas. +RSpec.describe 'Dashboard — contabilização de falhas', type: :request do + include_context 'espelho de rastreio' + + # Preço redondo para os valores esperados serem óbvios na leitura do spec. + let(:preco) { 10.0 } + let(:inicio) { '2026-08-01' } + let(:fim) { '2026-08-14' } + + before do + create(:configuracao, chave: 'preco_entrega', valor: preco.to_s) + sign_in create(:gerente) + end + + def abrir_dashboard + get dashboard_path, params: { inicio: inicio, fim: fim } + expect(response).to have_http_status(:ok) + response.body + end + + it 'soma a falhada ao valor estimado e ao total de atendidas' do + semear([ + { driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'completed', + planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) }, + { driver: 'JOSE DA SILVA', vehicle: 'GADE_001', status: 'failed', + planned_date: Time.zone.local(2026, 8, 6, 8, 0), checkout: Time.zone.local(2026, 8, 6, 11, 0) } + ]) + + corpo = abrir_dashboard + + # 2 atendidas × R$ 10,00 — com a base antiga (`pagas`) seria 1 e R$ 10,00. + expect(corpo).to include('2 entregas atendidas') + expect(corpo).to include('1 concluídas · 1 falhadas') + expect(corpo).to include('R$ 20,00') + end + + it 'conta a falhada pela data do CHECKOUT, não pela planejada' do + # Planejada em 31/07 (fora), entregue em 01/08 (dentro) → entra. + semear([ + { driver: 'JOSE DA SILVA', vehicle: 'GADE_002', status: 'failed', + planned_date: Time.zone.local(2026, 7, 31, 8, 0), checkout: Time.zone.local(2026, 8, 1, 9, 0) } + ]) + + corpo = abrir_dashboard + + expect(corpo).to include('1 entregas atendidas') + expect(corpo).to include('0 concluídas · 1 falhadas') + end + + it 'exclui a falhada planejada dentro do período mas com checkout fora' do + # O espelho do caso acima: planejada em 10/08 (dentro), entregue em 01/09 + # (fora) → não entra. Com o eixo antigo (planned_date) entrava. + semear([ + { driver: 'JOSE DA SILVA', vehicle: 'GADE_003', status: 'failed', + planned_date: Time.zone.local(2026, 8, 10, 8, 0), checkout: Time.zone.local(2026, 9, 1, 9, 0) } + ]) + + corpo = abrir_dashboard + + expect(corpo).to include('0 entregas atendidas') + expect(corpo).to include('0 concluídas · 0 falhadas') + end + + it 'mantém a pendente fora do valor, contando-a só como pendente' do + # Pendente não tem checkout: continua no eixo planned_date e não vira R$. + semear([ + { driver: 'JOSE DA SILVA', vehicle: 'GADE_004', status: 'pending', + planned_date: Time.zone.local(2026, 8, 7, 8, 0), checkout: nil } + ]) + + corpo = abrir_dashboard + + expect(corpo).to include('0 entregas atendidas') + expect(corpo).to include('1 pendentes') + end + + it 'inclui a falhada no ranking por motorista' do + semear([ + # JOSE: 1 concluída + 1 falhada = 2 atendidas → R$ 20,00 + { driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'completed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) }, + { driver: 'JOSE DA SILVA', vehicle: 'GADE_005', status: 'failed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 15, 0) }, + # MARIA: 1 concluída = 1 atendida → R$ 10,00 + { driver: 'MARIA SOUZA', vehicle: 'GADE_006', status: 'completed', + planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) } + ]) + + corpo = abrir_dashboard + + expect(corpo).to include('3 entregas atendidas') + expect(corpo).to include('JOSE DA SILVA') + # R$ 20,00 do JOSE só existe se a falhada dele entrou na conta do ranking + # (com a base antiga ele apareceria com R$ 10,00, igual à MARIA). + expect(corpo).to include('R$ 20,00') + expect(corpo).to include('R$ 30,00') # total estimado do período + end +end diff --git a/spec/support/espelho_rastreio.rb b/spec/support/espelho_rastreio.rb new file mode 100644 index 0000000..08d112d --- /dev/null +++ b/spec/support/espelho_rastreio.rb @@ -0,0 +1,102 @@ +# Harness da tabela do espelho de rastreio (db_reem_simplerout_2026) para os +# specs. +# +# A tabela é EXTERNA: não tem migration (o model Entrega proíbe explicitamente) +# e não tem factory. Sem este harness, qualquer teste que dependa dela só +# poderia mockar o método sob teste — o que não pega regressão de SQL, que é +# justamente onde moram os bugs de eixo de data (planned_date × checkout). +# +# Uso: +# +# RSpec.describe Entrega do +# include_context 'espelho de rastreio' +# +# it '...' do +# semear([{ driver: 'FULANO', vehicle: 'GADE_001', status: 'completed', +# planned_date: ..., checkout: ... }]) +# end +# end +module EspelhoRastreio + TABELA = 'db_reem_simplerout_2026'.freeze + + # `Entrega.da_conta_gade` filtra por DB_EXISTING_ACCOUNT_ID (default '95907'). + # Gravamos nas linhas o primeiro valor configurado: se estiver 'all' o scope + # não filtra e o valor é indiferente; se estiver '95907' as linhas casam. + # Assim o harness não depende do ENV da máquina nem precisa stubar ENV. + CONTA = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').split(',').first.to_s.strip.presence || '95907' + + class << self + def criar! + # ⚠️ GUARDA OBRIGATÓRIA. O model do espelho proíbe DROP porque a tabela + # real é a base de rastreio da operação. Aqui só criamos uma tabela de + # mesmo nome no banco de TESTE; se ela já existir COM DADOS, estamos + # apontados para dados de verdade e o spec tem que abortar em vez de + # destruí-los. + raise "Espelho de teste exige RAILS_ENV=test (está em #{Rails.env})" unless Rails.env.test? + return if @criada + + if conexao.table_exists?(TABELA) + linhas = conexao.select_value("SELECT COUNT(*) FROM #{nome_citado}").to_i + if linhas.positive? + raise "#{TABELA} já existe no banco de teste com #{linhas} linha(s) — " \ + 'o spec NÃO vai derrubá-la. Aponte RAILS_ENV=test para um banco descartável.' + end + + conexao.drop_table(TABELA) + end + + conexao.create_table(TABELA, id: false) do |t| + t.string :tracking_id, null: false + t.string :account_id + t.bigint :reference_id + t.string :driver + t.string :vehicle + t.string :status + t.datetime :planned_date + t.datetime :checkout + t.string :contact_name + t.string :address + end + conexao.execute("ALTER TABLE #{nome_citado} ADD PRIMARY KEY (tracking_id)") + @criada = true + end + + # Só derruba o que este harness criou. + def derrubar! + return unless @criada + + conexao.drop_table(TABELA, if_exists: true) + @criada = false + end + + def limpar! + conexao.execute("DELETE FROM #{nome_citado}") if @criada + end + + private + + def conexao + ActiveRecord::Base.connection + end + + def nome_citado + conexao.quote_table_name(TABELA) + end + end +end + +RSpec.shared_context 'espelho de rastreio' do + before(:all) { EspelhoRastreio.criar! } + after(:all) { EspelhoRastreio.derrubar! } + before { EspelhoRastreio.limpar! } + + # O model é readonly? = true, então insert_all (que não instancia registro) é + # a única via de gravação — e vale só para o banco de teste. + def semear(linhas) + Entrega.insert_all( + linhas.map.with_index do |l, i| + { tracking_id: "TRK-#{i}-#{SecureRandom.hex(3)}", account_id: EspelhoRastreio::CONTA }.merge(l) + end + ) + end +end From 4965931c1320e73f74c5d823bf3541f38ebd388bdf0efe329c6a576399fc5ca9 Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 21 Aug 2026 01:12:47 -0300 Subject: [PATCH 5/7] =?UTF-8?q?corre=C3=A7=C3=A3o=20de=20bugs=20na=20area?= =?UTF-8?q?=20de=20Dashbord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/dashboard_controller.rb | 16 +++ .../motorista/dashboard_controller.rb | 26 +++-- .../dashboard/_ranking_motoristas.html.erb | 42 +++++++ app/views/dashboard/index.html.erb | 104 +++++++++++------ app/views/motorista/dashboard/index.html.erb | 9 +- spec/requests/dashboard_spec.rb | 98 ++++++++++++++++ spec/requests/motorista_dashboard_spec.rb | 107 ++++++++++++++++++ 7 files changed, 361 insertions(+), 41 deletions(-) create mode 100644 app/views/dashboard/_ranking_motoristas.html.erb create mode 100644 spec/requests/motorista_dashboard_spec.rb diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d530fe9..8cba40d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -227,6 +227,22 @@ class DashboardController < ApplicationController # Custo por motorista (todos) @fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total) .sort_by { |_, v| -v } + + # Ranking CONSOLIDADO (aba "Consolidado" do card Motoristas): o valor REAL + # fechado por motorista + quantas entregas entraram nesse fechamento. + # + # ⚠️ A quantidade é DISTINCT tracking_id, NÃO a contagem de linhas: a mesma + # entrega pode ter vários pilares (Normal + Bônus + Retirada = 3 linhas em + # consolidacao_entregas), e contar linhas inflaria o número de entregas. + # + # Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos + # a quantidade: assim a aba lista exatamente os motoristas do fechamento — + # arquivado não aparece — e a soma bate com o KPI "Custo total" do topo. + qtd_por_motorista = ces.group(:motorista_nome).distinct.count(:tracking_id) + + @ranking_consolidado = @fin_por_motorista.map do |nome, valor| + { nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i } + end end # `atendidas` já vem filtrado por checkout no período (concluídas + falhadas). diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 85f42ff..6a1e903 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -7,15 +7,27 @@ module Motorista def index nome = current_user.nome - # Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega + # Card 1 — Valor ESTIMADO (mês atual): entregas ATENDIDAS × preco_entrega. + # + # Atendida = o motorista FOI ao local e fechou a visita, com sucesso + # ('completed') OU insucesso ('failed') — no insucesso o deslocamento + # aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do + # dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e + # Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via + # um valor MENOR que o do fechamento, sem entender de onde vinha a diferença. + # + # ⚠️ O eixo é o CHECKOUT (data real da visita), não planned_date: falhada TEM + # checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a + # agosto — igual ao que a consolidação considera elegível. @estimado_inicio = Date.current.beginning_of_month @estimado_fim = Date.current - entregas_mes = Entrega.pagas.da_conta_gade - .do_motorista(nome) - .no_periodo(@estimado_inicio, @estimado_fim) - .count - @valor_estimado = entregas_mes * Configuracao.preco_entrega - @entregas_mes = entregas_mes + atendidas = Entrega.atendidas.da_conta_gade + .do_motorista(nome) + .no_periodo_checkout(@estimado_inicio, @estimado_fim) + @entregas_concluidas = atendidas.concluidas.count + @entregas_falhadas = atendidas.falhadas.count + @entregas_mes = @entregas_concluidas + @entregas_falhadas + @valor_estimado = @entregas_mes * Configuracao.preco_entrega # Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas @minhas_consolidacoes = Consolidacao.ativas diff --git a/app/views/dashboard/_ranking_motoristas.html.erb b/app/views/dashboard/_ranking_motoristas.html.erb new file mode 100644 index 0000000..f673ab0 --- /dev/null +++ b/app/views/dashboard/_ranking_motoristas.html.erb @@ -0,0 +1,42 @@ +<%# Lista do ranking de motoristas — markup COMPARTILHADO pelas abas "Estimado" e + "Consolidado" do dashboard. Os dois conjuntos têm a mesma forma + (:nome, :valor, :entregas); só muda a origem dos números, então duplicar o + markup faria as duas abas divergirem visualmente na primeira alteração. + + Locais: itens (array de hashes), vazio (texto do estado sem dados). %> +<% if itens.any? %> +
+ <%# Base da barra: o MAIOR nº de entregas da aba, não o primeiro item — a aba + consolidada é ordenada por VALOR, então o primeiro pode não ser o maior em + quantidade e a barra passaria de 100%. Sem entregas (só bônus/desconto, + por exemplo) a barra fica vazia em vez de dividir por zero. %> + <% max = itens.map { |x| x[:entregas].to_i }.max.to_f %> + <% itens.each_with_index do |m, i| %> +
+ + <%= i + 1 %> + +
+
+ <%= m[:nome] %> + + <%= moeda(m[:valor]) %> + +
+ <%# Barra de progresso %> +
+
+
+ <%= m[:entregas] %> entregas +
+
+ <% end %> +
+<% else %> +
+
<%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

<%= vazio %>

+
+<% end %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 84dc3cd..5256fb3 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -262,42 +262,48 @@ <% end %> - <%# Top motoristas %> + <%# Top motoristas — DUAS ABAS sobre o mesmo ranking: + • Estimado = entregas atendidas × preço da entrega (mesma base do card + laranja do topo). + • Consolidado = o que foi REALMENTE fechado nas consolidações do período, + já com bônus/desconto/retirada aplicados, e a quantidade + exata de entregas que entrou no fechamento. + Os dois 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. Por isso cada aba diz de onde vem o seu número. %>
-

<%= icone :ranking, espaco: false %> Motoristas

+
+

<%= icone :ranking, espaco: false %> Motoristas

- <% if @motoristas.any? %> -
- <% @motoristas.each_with_index do |m, i| %> -
- - <%= i + 1 %> - -
-
- <%= m[:nome] %> - - <%= moeda(m[:valor]) %> - -
- <%# Barra de progresso %> -
- <% max = @motoristas.first[:entregas].to_f %> -
-
- <%= m[:entregas] %> entregas -
-
- <% end %> + <%# Mesmas pílulas da paginação de pagamentos (as classes estão duplicadas + no JS que troca a aba — mexeu aqui, mexa lá). %> +
+ +
- <% else %> -
-
<%= icone :caminhao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
-

Nenhuma entrega concluída neste período

-
- <% end %> +
+ +
+

Entregas atendidas × preço da entrega

+ <%= render 'ranking_motoristas', + itens: @motoristas, + vazio: 'Nenhuma entrega atendida neste período' %> +
+ + <%# Sem JS a aba fica escondida — aceitável: o mesmo dado consolidado + também aparece no gráfico "Custo por motorista" logo abaixo. %> +
@@ -577,6 +583,38 @@ })(); +<%# Abas do ranking de motoristas (Estimado × Consolidado) %> + + <%# Filtro de operação — submete o form ao marcar/desmarcar %>