Implantação da geração de mensagem de forma livre e mudança na engine de mensagem

This commit is contained in:
2026-08-24 17:32:16 -03:00
parent 94bbe7556f
commit 88f19dd8f5
77 changed files with 4650 additions and 25 deletions

View File

@@ -0,0 +1,50 @@
# app/services/analytics/resumo_operacao.rb
#
# Os números da operação num período, na forma que a mensagem agendada usa —
# as MESMAS chaves que Notificacao::Variaveis anuncia no editor para o gatilho
# `agendado`.
#
# Reusa OperacaoMetricas (visão Global) e NotasForaOperacao em vez de refazer as
# queries: se a regra de contagem mudar num lugar, o resumo enviado por WhatsApp
# não pode continuar dizendo outra coisa que o dashboard.
module Analytics
class ResumoOperacao
def initialize(inicio:, fim:)
@inicio = inicio.to_date
@fim = fim.to_date
end
# Valores como TEXTO: eles vão direto para dentro de uma mensagem.
def numeros
{
'operacao' => rotulo_operacao,
'entregues' => metricas.sucesso.to_s,
'recusas' => metricas.recusas.to_s,
'pendentes' => metricas.pendentes.to_s,
'retentativas' => metricas.retentativas.to_s,
'fora_operacao' => fora.total.to_s
}
end
# Nada mudou no período? Evita mandar resumo zerado num feriado.
def vazio?
metricas.total.zero? && fora.total.zero?
end
private
def tabelas = @tabelas ||= Operacao.nomes_validos
def rotulo_operacao
tabelas.size == 1 ? Operacao.label(tabelas.first) : "Todas as operações (#{tabelas.size})"
end
def metricas
@metricas ||= OperacaoMetricas.new(tabelas: tabelas, inicio: @inicio, fim: @fim)
end
def fora
@fora ||= NotasForaOperacao.new(inicio: @inicio, fim: @fim)
end
end
end

View File

@@ -0,0 +1,65 @@
# app/services/notificacao/blocos.rb
#
# Catálogo dos blocos que o ADM pode empilhar no editor. É a ÚNICA fonte da
# verdade sobre quais tipos existem e quais campos cada um tem — o editor monta
# a paleta a partir daqui, o model valida a partir daqui e o renderizador
# desenha a partir daqui.
#
# Bloco desconhecido é DESCARTADO na gravação (ver MensagemTemplate): sem isso,
# um JSON adulterado no form viraria conteúdo renderizado sem passar por
# validação nenhuma.
module Notificacao
module Blocos
# tipo => { rotulo:, campos: { chave => tipo_do_campo } }
# tipo_do_campo: :texto (uma linha) | :multilinha | :lista (tabela)
CATALOGO = {
'cabecalho' => {
rotulo: 'Cabeçalho',
dica: 'Título da mensagem, em destaque.',
campos: { 'titulo' => :texto, 'subtitulo' => :texto }
},
'texto' => {
rotulo: 'Texto',
dica: 'Parágrafo livre. Use as variáveis para personalizar.',
campos: { 'texto' => :multilinha }
},
'tabela' => {
rotulo: 'Tabela de valores',
dica: 'Pares rótulo/valor — entregas, valor, período…',
campos: { 'linhas' => :lista }
},
'aviso' => {
rotulo: 'Aviso de mudança',
dica: 'Destaque em amarelo. Bom para "o que foi alterado".',
campos: { 'texto' => :multilinha }
},
'botao' => {
rotulo: 'Botão / link',
dica: 'No e-mail vira botão; no WhatsApp, o link em texto.',
campos: { 'rotulo' => :texto, 'url' => :texto }
},
'divisor' => {
rotulo: 'Divisor',
dica: 'Linha separando seções.',
campos: {}
},
'rodape' => {
rotulo: 'Rodapé',
dica: 'Texto pequeno no fim (assinatura, aviso legal).',
campos: { 'texto' => :multilinha }
}
}.freeze
TIPOS = CATALOGO.keys.freeze
def self.valido?(tipo) = TIPOS.include?(tipo.to_s)
def self.campos(tipo) = CATALOGO.dig(tipo.to_s, :campos) || {}
def self.rotulo(tipo) = CATALOGO.dig(tipo.to_s, :rotulo) || tipo.to_s
# Bloco novo com os campos vazios — usado pelo editor ao arrastar da paleta.
def self.vazio(tipo)
campos(tipo).to_h { |chave, especie| [chave, especie == :lista ? [] : ''] }
.merge('tipo' => tipo.to_s)
end
end
end

View File

@@ -0,0 +1,98 @@
# app/services/notificacao/cliente_whatsapp.rb
#
# Cliente da ponte Baileys (container `whatsapp`). Substitui o Twilio como canal
# padrão de WhatsApp.
#
# ⚠️ Canal NÃO OFICIAL: a ponte fala o protocolo do WhatsApp Web por engenharia
# reversa. É gratuito e ilimitado, mas está fora dos Termos do WhatsApp e a
# Meta pode banir o número. Por isso: chip dedicado, e o intervalo entre
# mensagens é respeitado por quem chama (Notificacao::Despachante), não aqui.
#
# TIMEOUT em tudo: sem ele, uma ponte pendurada trava o worker Puma até o proxy
# cortar — o mesmo motivo que levou o ClienteTwilio a configurar timeout.
module Notificacao
class ClienteWhatsapp
TIMEOUT_ABERTURA = 5
TIMEOUT_LEITURA = 20
Resposta = Struct.new(:ok, :dados, :erro, keyword_init: true) do
def ok? = ok
end
def self.padrao
config = ConfiguracaoNotificacao.instancia
new(url: config.baileys_url_efetiva, token: config.baileys_token_efetivo)
end
def initialize(url:, token:)
@url = url.to_s.strip.chomp('/')
@token = token.to_s
end
def configurado?
@url.present? && @token.present?
end
# { conectado:, numero:, qr:, desde:, ultimo_erro: }
def status
get('/status')
end
def enviar(para:, texto:)
post('/enviar', para: para, texto: texto)
end
def desconectar
post('/logout')
end
private
def get(caminho)
requisitar(Net::HTTP::Get, caminho)
end
def post(caminho, **corpo)
requisitar(Net::HTTP::Post, caminho, corpo)
end
def requisitar(classe, caminho, corpo = nil)
return Resposta.new(ok: false, erro: 'Ponte do WhatsApp não configurada.') unless configurado?
uri = URI.join("#{@url}/", caminho.delete_prefix('/'))
req = classe.new(uri)
req['Authorization'] = "Bearer #{@token}"
req['Content-Type'] = 'application/json'
req.body = corpo.to_json if corpo
resposta = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.scheme == 'https',
open_timeout: TIMEOUT_ABERTURA,
read_timeout: TIMEOUT_LEITURA) { |http| http.request(req) }
dados = parse(resposta.body)
if resposta.is_a?(Net::HTTPSuccess)
Resposta.new(ok: true, dados: dados)
else
Resposta.new(ok: false, dados: dados, erro: mensagem_de_erro(resposta, dados))
end
rescue StandardError => e
# A ponte fora do ar não pode virar 500 na tela nem quebrar um fechamento.
Rails.logger.error("[ClienteWhatsapp] #{e.class}: #{e.message}")
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
end
def parse(corpo)
JSON.parse(corpo.to_s)
rescue JSON::ParserError
{}
end
def mensagem_de_erro(resposta, dados)
return 'Token da ponte do WhatsApp inválido.' if resposta.code == '401'
return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503'
dados['erro'].presence || "HTTP #{resposta.code}"
end
end
end

View File

@@ -0,0 +1,155 @@
# app/services/notificacao/despachante.rb
#
# Entrega UMA mensagem já montada aos destinatários de um evento.
#
# Separação proposital: o Despachante resolve QUEM recebe, por qual canal,
# respeita o intervalo entre envios e registra o resultado. O TEXTO vem do
# template de blocos do evento (MensagemTemplate), renderizado por canal — HTML
# no e-mail, texto puro no WhatsApp, do mesmo template.
#
# `corpo:`/`assunto:` são o FALLBACK: valem quando o evento ainda não tem
# template montado (o texto padrão do código) ou quando a mensagem é digitada na
# hora (disparo manual). Sem esse fallback, ligar o editor apagaria as
# notificações dos eventos que já funcionavam.
#
# Destinatários = contatos dos grupos que assinam o evento
# + (opcional) a pessoa diretamente envolvida no fato, que é o
# comportamento que já existia: o motorista dono do pagamento.
#
# ⚠️ INTERVALO: no WhatsApp por sessão QR (canal não oficial), disparar em
# rajada para dezenas de números é a forma mais rápida de perder o número.
# Por isso o envio roda em job, com pausa configurável entre mensagens —
# nunca dentro da requisição web.
module Notificacao
class Despachante
# Dispara em background. Nunca levanta: uma notificação não pode derrubar o
# fechamento de um pagamento (mesma regra do NotificacaoService).
def self.disparar(chave:, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
evento = EventoNotificacao.ativos.find_by(chave: chave.to_s)
return if evento.nil?
NotificacaoJob.perform_later(evento.id, (dados || {}).stringify_keys,
assunto.to_s, corpo.to_s, envolvido&.id)
rescue StandardError => e
Rails.logger.error("[Despachante] falha ao agendar #{chave}: #{e.class}: #{e.message}")
end
def initialize(evento, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
@evento = evento
@dados = (dados || {}).stringify_keys
@assunto = assunto.presence || evento.nome
@corpo = corpo.to_s
@envolvido = envolvido
@config = ConfiguracaoNotificacao.instancia
end
def executar
enviar_whatsapp if @config.whatsapp_habilitado?
enviar_email if @config.email_habilitado?
end
private
def enviar_whatsapp
intervalo = @config.intervalo_envio
destinos_whatsapp.each_with_index do |(destino, contato, user), i|
# Pausa ENTRE mensagens, não antes da primeira.
sleep(intervalo) if i.positive? && intervalo.positive?
corpo = corpo_whatsapp(contato, user)
envio = registrar(canal: 'whatsapp', destino: destino, contato: contato,
user: user, corpo: corpo)
resposta = Whatsapp.enviar(para: destino, texto: corpo, config: @config)
resposta.ok? ? envio.marcar_enviado! : envio.marcar_falha!(resposta.erro)
end
end
# ── Corpo por canal ─────────────────────────────────────────
# A renderização acontece POR DESTINATÁRIO, não uma vez só: {{contato}} é o
# primeiro nome de quem recebe, então um render compartilhado mandaria o
# nome da primeira pessoa para todo mundo. Renderizar é manipulação de
# string — o custo por destinatário é irrelevante perto do envio em si.
def dados_para(contato, user)
nome = contato&.primeiro_nome.presence || user&.nome.to_s.split.first
@dados.merge('contato' => nome.to_s)
end
# O template do editor vence; sem template utilizável, cai no texto que o
# chamador passou (texto padrão do código ou mensagem digitada na hora).
def corpo_whatsapp(contato, user)
template = @evento.template_utilizavel('whatsapp')
return @corpo unless template
template.renderizador(dados_para(contato, user)).texto.presence || @corpo
end
# [assunto, corpo, html?] — com template o e-mail sai em HTML montado; sem
# template, texto puro que o mailer formata.
def email_montado(contato, user)
template = @evento.template_utilizavel('email')
return [@assunto, @corpo, false] unless template
r = template.renderizador(dados_para(contato, user))
[r.interpolar(template.assunto).presence || @assunto, r.html, true]
end
def enviar_email
destinos_email.each do |destino, contato, user|
assunto, corpo, html = email_montado(contato, user)
envio = registrar(canal: 'email', destino: destino, contato: contato,
user: user, corpo: corpo, assunto: assunto)
begin
NotificacaoMailer.mensagem(destino, assunto, corpo, html: html).deliver_now
envio.marcar_enviado!
rescue StandardError => e
envio.marcar_falha!("#{e.class}: #{e.message}")
end
end
end
# [[destino, contato, user], ...] sem repetir o mesmo número/e-mail — a mesma
# pessoa pode ser contato de um grupo E o envolvido no fato.
def destinos_whatsapp
lista = @evento.destinatarios('whatsapp').map { |c| [c.telefone, c, c.user] }
lista += envolvido_whatsapp
dedup(lista)
end
def destinos_email
lista = @evento.destinatarios('email').map { |c| [c.email, c, c.user] }
lista += envolvido_email
dedup(lista)
end
def envolvido_whatsapp
return [] unless notificar_envolvido?
numero = ConfiguracaoNotificacao.normalizar_telefone(@envolvido.telefone)
numero.present? ? [[numero, nil, @envolvido]] : []
end
def envolvido_email
return [] unless notificar_envolvido? && @envolvido.email.present?
[[@envolvido.email, nil, @envolvido]]
end
def notificar_envolvido?
@envolvido.present? && @evento.notificar_envolvido?
end
def dedup(lista)
lista.reject { |destino, _, _| destino.blank? }
.uniq { |destino, _, _| destino.to_s.downcase }
end
def registrar(canal:, destino:, contato:, user:, corpo:, assunto: nil)
NotificacaoEnvio.create!(
evento_notificacao: @evento, contato: contato, user: user,
canal: canal, destino: destino, assunto: assunto,
corpo: corpo, status: 'pendente'
)
end
end
end

View File

@@ -0,0 +1,133 @@
# app/services/notificacao/gatilhos.rb
#
# Os pontos do sistema que DISPARAM um evento de notificação. Cada método monta
# o contexto das {{variaveis}} daquele gatilho e entrega ao Despachante.
#
# Existe para que o código de negócio (consolidação, edição de lançamento, jobs)
# tenha UMA linha de chamada, sem saber de template, canal ou destinatário — e
# para que a lista de variáveis que o editor oferece (Notificacao::Variaveis)
# tenha um par exato aqui. Variável anunciada na tela e nunca preenchida sai como
# vazio na mensagem, o que é pior do que não existir.
#
# NADA aqui levanta: uma notificação não pode derrubar um fechamento nem uma
# edição de lançamento. O Despachante já engole, e os métodos que montam o
# contexto também.
module Notificacao
module Gatilhos
module_function
# Valor do motorista mudou DEPOIS do fechamento (lançamento editado,
# desconto aplicado, pilar remarcado). Em rascunho o valor muda a cada
# clique do wizard — avisar ali seria spam, não informação.
def valor_alterado(consolidacao, consolidacao_motorista, anterior:)
return unless consolidacao.finalizada?
atual = consolidacao_motorista.valor_total.to_f
return if anterior.to_f == atual
Despachante.disparar(
chave: 'valor_alterado',
dados: Variaveis.comuns_reais.merge(
'motorista' => consolidacao_motorista.motorista_nome,
'consolidacao' => consolidacao.nome,
'periodo' => periodo(consolidacao.data_inicio, consolidacao.data_fim),
'valor' => moeda(atual),
'entregas' => contar_entregas(consolidacao, consolidacao_motorista).to_s,
'link_painel' => link_painel,
'o_que_mudou' => "Valor passou de #{moeda(anterior)} para #{moeda(atual)}"
),
assunto: "Valor alterado — #{consolidacao_motorista.motorista_nome}",
corpo: "⚠️ O valor de #{consolidacao_motorista.motorista_nome} em #{consolidacao.nome} " \
"passou de #{moeda(anterior)} para #{moeda(atual)}.",
# O próprio motorista é parte interessada: o valor DELE mudou depois de
# fechado. Fica sob o `notificar_envolvido` do evento, então o ADM pode
# desligar sem perder o aviso à diretoria.
envolvido: motorista_usuario(consolidacao_motorista)
)
rescue StandardError => e
Rails.logger.error("[Gatilhos] valor_alterado: #{e.class}: #{e.message}")
end
# Dados da operação mudaram. Duas origens, os dois caminham para cá:
# - edição de lançamento no SimpliRoute feita por dentro do sistema;
# - varredura periódica que compara o espelho com o retrato anterior
# (DetectarMudancasOperacaoJob) — é por onde chega a maioria, já que o
# espelho sincroniza mudanças feitas fora daqui.
def operacao_alterada(operacao:, o_que_mudou:, nf: nil)
Despachante.disparar(
chave: 'operacao_alterada',
dados: Variaveis.comuns_reais.merge(
'operacao' => operacao.to_s,
'o_que_mudou' => o_que_mudou.to_s,
'nf' => nf.to_s
),
assunto: "Alteração na operação #{operacao}",
corpo: "🔄 #{operacao}: #{o_que_mudou}#{nf.present? ? " (NF #{nf})" : ''}"
)
rescue StandardError => e
Rails.logger.error("[Gatilhos] operacao_alterada: #{e.class}: #{e.message}")
end
# Resumo periódico de um evento `agendado`.
def resumo_agendado(evento, inicio:, fim:)
numeros = Analytics::ResumoOperacao.new(inicio: inicio, fim: fim).numeros
Despachante.disparar(
chave: evento.chave,
dados: Variaveis.comuns_reais.merge(numeros).merge(
'periodo' => periodo(inicio, fim)
),
assunto: "Resumo da operação — #{periodo(inicio, fim)}",
corpo: corpo_resumo(numeros, inicio, fim)
)
rescue StandardError => e
Rails.logger.error("[Gatilhos] resumo_agendado #{evento.chave}: #{e.class}: #{e.message}")
end
# ── Auxiliares ─────────────────────────────────────────────
def corpo_resumo(numeros, inicio, fim)
"📊 Resumo da operação — #{periodo(inicio, fim)}\n" \
"• Entregues: #{numeros['entregues']}\n" \
"• Não entregues: #{numeros['recusas']}\n" \
"• Em aberto: #{numeros['pendentes']}\n" \
"• Retentativas: #{numeros['retentativas']}\n" \
"• Notas fora da operação: #{numeros['fora_operacao']}"
end
def periodo(inicio, fim)
"#{inicio&.strftime('%d/%m/%Y')} a #{fim&.strftime('%d/%m/%Y')}"
end
def link_painel
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
# Mesma busca do NotificacaoService (casa pelo nome, que é o que a
# consolidação guarda). Só roda em consolidação finalizada, então o custo
# por recálculo é irrelevante.
def motorista_usuario(consolidacao_motorista)
User.motorista.ativos.find_by('LOWER(nome) = ?', consolidacao_motorista.motorista_nome.to_s.downcase)
rescue StandardError
nil
end
def contar_entregas(consolidacao, consolidacao_motorista)
consolidacao.consolidacao_entregas
.where(motorista_nome: consolidacao_motorista.motorista_nome)
.where.not(tipo: :desconto)
.count
rescue StandardError
nil
end
# Moeda BR com separador de milhar. Duplica o helper do NotificacaoService de
# propósito: este módulo é chamado de dentro de models e jobs, onde puxar o
# service inteiro só para formatar número seria acoplamento à toa.
def moeda(valor)
inteiro, decimais = format('%.2f', valor.to_f).split('.')
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
"R$ #{inteiro},#{decimais}"
end
end
end

View File

@@ -0,0 +1,159 @@
# app/services/notificacao/renderizador.rb
#
# Blocos + dados => mensagem pronta. Duas saídas do MESMO template:
#
# #texto → WhatsApp (texto puro, com o *negrito* do WhatsApp)
# #html → e-mail (tabelas com estilo inline, que é o que cliente de e-mail
# renderiza de forma previsível)
#
# ⚠️ TODO texto vindo do editor e TODO valor de variável passa por escape no
# caminho HTML. O corpo é digitado numa tela e viraria injeção de HTML no
# e-mail — e o preview usa este mesmo renderizador, então um XSS aqui
# atingiria primeiro o próprio admin.
#
# O preview da tela chama exatamente estes métodos: uma segunda implementação em
# JavaScript inevitavelmente divergiria do que é enviado de verdade.
module Notificacao
class Renderizador
# Cores fixas: cliente de e-mail não lê CSS externo nem variável de tema.
LARANJA = '#f97316'.freeze
ESCURO = '#111111'.freeze
CINZA = '#666666'.freeze
def initialize(blocos, dados = {})
@blocos = Array(blocos).select { |b| b.is_a?(Hash) && Blocos.valido?(b['tipo']) }
@dados = (dados || {}).transform_keys(&:to_s)
end
# ── WhatsApp ────────────────────────────────────────────────
def texto
@blocos.filter_map { |bloco| texto_do(bloco) }.join("\n\n").strip
end
# ── E-mail ──────────────────────────────────────────────────
def html
corpo = @blocos.filter_map { |bloco| html_do(bloco) }.join("\n")
<<~HTML
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#{ESCURO};line-height:1.6;max-width:600px;">
#{corpo}
</div>
HTML
end
# Substitui {{variavel}} pelos dados. Variável sem valor vira string vazia —
# deixar "{{valor}}" cru numa mensagem enviada é pior do que deixar o espaço.
def interpolar(texto)
texto.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { @dados[Regexp.last_match(1)].to_s }
end
private
def escapar(valor) = ERB::Util.html_escape(interpolar(valor))
def texto_do(bloco)
case bloco['tipo']
when 'cabecalho'
[negrito(bloco['titulo']), interpolar(bloco['subtitulo'])].reject(&:blank?).join("\n").presence
when 'texto', 'rodape'
interpolar(bloco['texto']).presence
when 'aviso'
conteudo = interpolar(bloco['texto'])
conteudo.present? ? "⚠️ #{conteudo}" : nil
when 'tabela'
linhas = Array(bloco['linhas']).filter_map do |linha|
next unless linha.is_a?(Hash)
rotulo = interpolar(linha['rotulo'])
valor = interpolar(linha['valor'])
next if rotulo.blank? && valor.blank?
"#{rotulo}: #{valor}".strip
end
linhas.any? ? linhas.join("\n") : nil
when 'botao'
url = interpolar(bloco['url'])
return nil if url.blank?
[interpolar(bloco['rotulo']).presence, url].compact.join(': ')
when 'divisor'
'——————————'
end
end
def negrito(valor)
conteudo = interpolar(valor)
conteudo.present? ? "*#{conteudo}*" : ''
end
def html_do(bloco)
case bloco['tipo']
when 'cabecalho' then html_cabecalho(bloco)
when 'texto' then html_paragrafo(bloco['texto'])
when 'aviso' then html_aviso(bloco)
when 'tabela' then html_tabela(bloco)
when 'botao' then html_botao(bloco)
when 'divisor' then %(<hr style="border:none;border-top:1px solid #e5e5e5;margin:20px 0;">)
when 'rodape' then html_rodape(bloco)
end
end
def html_cabecalho(bloco)
titulo = escapar(bloco['titulo'])
sub = escapar(bloco['subtitulo'])
return nil if titulo.blank? && sub.blank?
partes = []
partes << %(<h1 style="margin:0 0 4px;font-size:20px;color:#{ESCURO};">#{titulo}</h1>) if titulo.present?
partes << %(<p style="margin:0 0 16px;font-size:14px;color:#{CINZA};">#{sub}</p>) if sub.present?
partes.join("\n")
end
# `simple_format` não serve aqui: ele produz HTML sem os estilos inline que o
# cliente de e-mail precisa. As quebras de linha viram <br>, já escapadas.
def html_paragrafo(valor)
conteudo = escapar(valor)
return nil if conteudo.blank?
%(<p style="margin:0 0 14px;">#{conteudo.gsub("\n", '<br>')}</p>)
end
def html_aviso(bloco)
conteudo = escapar(bloco['texto'])
return nil if conteudo.blank?
%(<div style="margin:0 0 16px;padding:12px 14px;background:#fff8e1;border-left:4px solid #f0ad4e;) +
%(font-size:14px;color:#5c4400;">#{conteudo.gsub("\n", '<br>')}</div>)
end
def html_tabela(bloco)
linhas = Array(bloco['linhas']).filter_map do |linha|
next unless linha.is_a?(Hash)
rotulo = escapar(linha['rotulo'])
valor = escapar(linha['valor'])
next if rotulo.blank? && valor.blank?
%(<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;color:#{CINZA};">#{rotulo}</td>) +
%(<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold;">#{valor}</td></tr>)
end
return nil if linhas.empty?
%(<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:0 0 16px;font-size:14px;">) +
linhas.join + '</table>'
end
def html_botao(bloco)
url = interpolar(bloco['url']).to_s.strip
# Só http(s): `javascript:` num href montado na tela seria clique armado.
return nil unless url.match?(%r{\Ahttps?://}i)
rotulo = escapar(bloco['rotulo']).presence || 'Abrir'
%(<p style="margin:0 0 18px;"><a href="#{ERB::Util.html_escape(url)}" ) +
%(style="display:inline-block;padding:11px 20px;background:#{LARANJA};color:#000;) +
%(text-decoration:none;border-radius:8px;font-weight:bold;font-size:14px;">#{rotulo}</a></p>)
end
def html_rodape(bloco)
conteudo = escapar(bloco['texto'])
return nil if conteudo.blank?
%(<p style="margin:24px 0 0;font-size:12px;color:#999;">#{conteudo.gsub("\n", '<br>')}</p>)
end
end
end

View File

@@ -0,0 +1,78 @@
# app/services/notificacao/variaveis.rb
#
# Quais {{variaveis}} existem em cada gatilho, o que elas significam e um valor
# de AMOSTRA para o preview do editor.
#
# A amostra é o que faz o preview valer alguma coisa: mostrar "{{valor}}" cru na
# tela não diz se a mensagem ficou boa. Aqui ela é um exemplo plausível, não um
# dado real do banco — o preview não deve depender de haver consolidação
# fechada no ambiente para funcionar.
module Notificacao
module Variaveis
# Disponíveis em qualquer evento.
COMUNS = {
'empresa' => { descricao: 'Nome da empresa', amostra: 'Reem Transportes' },
'data' => { descricao: 'Data de hoje', amostra: '24/08/2026' },
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos' }
}.freeze
# Gatilhos que falam de um fechamento/pagamento de motorista.
FECHAMENTO = {
'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' },
'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 0114/08' },
'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' },
'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' },
'entregas' => { descricao: 'Entregas atendidas', amostra: '280' },
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' }
}.freeze
POR_GATILHO = {
'manual' => {},
'consolidacao_finalizada' => FECHAMENTO,
'pagamento_efetuado' => FECHAMENTO,
'valor_alterado' => FECHAMENTO.merge(
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 aplicado (avaria)' }
),
'operacao_alterada' => {
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: '3 NFs entraram, 1 saiu' },
'nf' => { descricao: 'Nota fiscal', amostra: '85382' }
},
'agendado' => {
'operacao' => { descricao: 'Operação', amostra: 'UBS NORTE AGO 2026' },
'periodo' => { descricao: 'Período do resumo', amostra: '01/08/2026 a 24/08/2026' },
'entregues' => { descricao: 'Notas entregues', amostra: '4.851' },
'recusas' => { descricao: 'Notas não entregues', amostra: '122' },
'pendentes' => { descricao: 'Notas em aberto', amostra: '0' },
'retentativas' => { descricao: 'Visitas além da primeira', amostra: '4' },
'fora_operacao' => { descricao: 'Notas fora da operação', amostra: '2' }
}
}.freeze
def self.para(gatilho)
COMUNS.merge(POR_GATILHO.fetch(gatilho.to_s, {}))
end
def self.nomes(gatilho) = para(gatilho).keys
# { 'valor' => 'R$ 5.060,00', ... } — o contexto do PREVIEW.
#
# ⚠️ Só para preview. Num envio de verdade a amostra colocaria uma data fixa
# e um valor inventado dentro da mensagem que o contato recebe.
def self.amostra(gatilho)
para(gatilho).transform_values { |meta| meta[:amostra] }
end
# Valores REAIS das variáveis comuns — o que um envio de verdade deve usar
# quando o gatilho não traz contexto próprio (ex.: disparo manual).
def self.comuns_reais
{
'empresa' => Configuracao.valor('empresa_nome').presence || 'Reem Transportes',
'data' => Date.current.strftime('%d/%m/%Y')
}
rescue StandardError
# Tabela de configuração indisponível não pode impedir um disparo.
{ 'data' => Date.current.strftime('%d/%m/%Y') }
end
end
end

View File

@@ -0,0 +1,41 @@
# app/services/notificacao/whatsapp.rb
#
# Ponto ÚNICO de envio de WhatsApp: escolhe o provedor configurado e devolve
# sempre a mesma resposta, venha de onde vier.
#
# Existe para que trocar Twilio ↔ Baileys seja um campo na tela, e não um `if`
# espalhado por cada chamador. O NotificacaoService (aviso ao motorista) e o
# Despachante (grupos) passam os dois por aqui.
module Notificacao
module Whatsapp
Resposta = ClienteWhatsapp::Resposta
def self.enviar(para:, texto:, config: ConfiguracaoNotificacao.instancia)
destino = ConfiguracaoNotificacao.normalizar_telefone(para)
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?
config.baileys? ? via_baileys(destino, texto) : via_twilio(destino, texto, config)
end
def self.via_baileys(destino, texto)
ClienteWhatsapp.padrao.enviar(para: destino, texto: texto)
end
def self.via_twilio(destino, texto, config)
credenciais = config.credenciais_whatsapp
if credenciais.blank? || credenciais[:from].blank?
return Resposta.new(ok: false, erro: 'Twilio sem credenciais configuradas.')
end
ClienteTwilio.montar(credenciais[:sid], credenciais[:token])
.messages.create(from: credenciais[:from],
to: ConfiguracaoNotificacao.canal(destino),
body: texto)
Resposta.new(ok: true)
rescue StandardError => e
# Falha de envio nunca pode subir: quem chama está no meio de um
# fechamento de pagamento.
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
end
end
end

View File

@@ -13,10 +13,12 @@
class NotificacaoService
EVENTOS = {
fechado: { mailer: :pagamento_fechado,
chave: 'consolidacao_finalizada',
emoji: '🚚',
titulo: 'Seu pagamento de entregas foi fechado.',
cta: 'Acesse seu painel para baixar o extrato:' },
pago: { mailer: :pagamento_efetuado,
chave: 'pagamento_efetuado',
emoji: '✅',
titulo: 'Seu pagamento foi efetuado.',
cta: 'Acesse seu painel para conferir:' }
@@ -48,10 +50,70 @@ class NotificacaoService
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?
# Aviso pessoal ao motorista: continua com o e-mail formatado do
# ConsolidacaoMailer (não vira texto puro) e passa a usar o provedor de
# WhatsApp escolhido na tela — Baileys por QR ou Twilio.
if user
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
end
# Cópia para os grupos que assinam este evento (diretoria, operação...).
# `envolvido: nil` de propósito: o motorista já foi avisado acima e receberia
# a mensagem duas vezes.
avisar_grupos(cm, evento, user)
end
# O corpo aqui é o mesmo texto do WhatsApp — na etapa do editor de blocos ele
# passa a vir do template configurado pelo ADM.
def avisar_grupos(cm, evento, user)
evt = EVENTOS.fetch(evento)
Notificacao::Despachante.disparar(
chave: evt[:chave],
dados: variaveis(cm, user),
assunto: "#{evt[:titulo]}#{@consolidacao.nome}",
corpo: mensagem_para_grupos(cm, evento, user)
)
end
# Contexto das {{variaveis}} do editor de blocos. As chaves são as mesmas de
# Notificacao::Variaveis::FECHAMENTO — é o que o ADM vê na paleta da tela.
def variaveis(cm, user)
Notificacao::Variaveis.comuns_reais.merge(
'motorista' => user&.nome.presence || cm.motorista_nome,
'consolidacao' => @consolidacao.nome,
'periodo' => "#{l_data(@consolidacao.data_inicio)} a #{l_data(@consolidacao.data_fim)}",
'valor' => moeda(cm.valor_total),
# ConsolidacaoMotorista não guarda contagem — só valor_total. As entregas
# do motorista são as linhas classificadas na consolidação (mesma fonte do
# #recalcular_valor), descontando os lançamentos de desconto, que não são
# entregas.
'entregas' => entregas_do_motorista(cm).to_s,
'link_painel' => "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
)
end
def l_data(data)
data&.strftime('%d/%m/%Y').to_s
end
def entregas_do_motorista(cm)
@consolidacao.consolidacao_entregas
.where(motorista_nome: cm.motorista_nome)
.where.not(tipo: :desconto)
.count
rescue StandardError => e
Rails.logger.warn("[Notificacao] contagem de entregas indisponível: #{e.class}: #{e.message}")
nil
end
def mensagem_para_grupos(cm, evento, user)
evt = EVENTOS.fetch(evento)
"#{evt[:emoji]} #{evt[:titulo]}\n" \
"👤 #{user&.nome || cm.motorista_nome}\n" \
"📋 #{@consolidacao.nome}\n" \
"💰 Valor: #{moeda(cm.valor_total)}"
end
def motorista_de(cm)
@@ -59,34 +121,57 @@ class NotificacaoService
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)
# O telefone no cadastro pode estar como "(11) 92005-1157"; a normalização
# para E.164 acontece dentro de Notificacao::Whatsapp — mandar o texto cru
# devolve erro de destino inválido que o rescue esconderia.
destino = ConfiguracaoNotificacao.normalizar_telefone(user.telefone)
return if destino.blank?
credenciais = @config.credenciais_whatsapp
return if credenciais.blank? || credenciais[:from].blank?
texto = mensagem(user, cm, evento)
envio = registrar_envio(evento, canal: 'whatsapp', destino: destino, user: user, corpo: texto)
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}")
resposta = Notificacao::Whatsapp.enviar(para: destino, texto: texto, config: @config)
if resposta.ok?
envio&.marcar_enviado!
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
else
envio&.marcar_falha!(resposta.erro)
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{resposta.erro}")
end
end
def enviar_email(user, cm, evento)
ConsolidacaoMailer
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
.deliver_later
evt = EVENTOS.fetch(evento)
envio = registrar_envio(evento, canal: 'email', destino: user.email, user: user,
corpo: mensagem(user, cm, evento), assunto: evt[:titulo])
ConsolidacaoMailer.public_send(evt[:mailer], user, @consolidacao, cm).deliver_later
# `deliver_later` só ENFILEIRA: marcar como enviado aqui significa "saiu do
# nosso lado". Uma falha do SMTP aparece no log do Rails, não aqui.
envio&.marcar_enviado!
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
rescue StandardError => e
envio&.marcar_falha!("#{e.class}: #{e.message}")
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
end
# Log do aviso pessoal. Nunca levanta: se a tabela ainda não existir (deploy
# antes do migrate), o envio precisa seguir mesmo sem registro.
def registrar_envio(evento, canal:, destino:, user:, corpo:, assunto: nil)
NotificacaoEnvio.create!(
evento_notificacao: evento_registrado(evento), user: user, canal: canal,
destino: destino, assunto: assunto, corpo: corpo, status: 'pendente'
)
rescue StandardError => e
Rails.logger.warn("[Notificacao] sem log de envio (#{e.class}: #{e.message})")
nil
end
def evento_registrado(evento)
@eventos_cache ||= {}
@eventos_cache[evento] ||= EventoNotificacao.find_by(chave: EVENTOS.fetch(evento)[:chave])
end
def mensagem(user, cm, evento)
evt = EVENTOS.fetch(evento)
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \