Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
65
app/services/notificacao/blocos.rb
Normal file
65
app/services/notificacao/blocos.rb
Normal 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
|
||||
98
app/services/notificacao/cliente_whatsapp.rb
Normal file
98
app/services/notificacao/cliente_whatsapp.rb
Normal 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
|
||||
155
app/services/notificacao/despachante.rb
Normal file
155
app/services/notificacao/despachante.rb
Normal 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
|
||||
133
app/services/notificacao/gatilhos.rb
Normal file
133
app/services/notificacao/gatilhos.rb
Normal 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
|
||||
159
app/services/notificacao/renderizador.rb
Normal file
159
app/services/notificacao/renderizador.rb
Normal 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
|
||||
78
app/services/notificacao/variaveis.rb
Normal file
78
app/services/notificacao/variaveis.rb
Normal 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 01–14/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
|
||||
41
app/services/notificacao/whatsapp.rb
Normal file
41
app/services/notificacao/whatsapp.rb
Normal 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
|
||||
Reference in New Issue
Block a user