Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
@@ -11,9 +11,15 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
|
||||
self.table_name = 'configuracao_notificacoes'
|
||||
|
||||
atributo_cifrado :smtp_password, :twilio_auth_token
|
||||
atributo_cifrado :smtp_password, :twilio_auth_token, :baileys_token
|
||||
|
||||
AUTENTICACOES = %w[plain login cram_md5].freeze
|
||||
|
||||
# Quem entrega o WhatsApp. `baileys` é a ponte por QR code (container
|
||||
# `whatsapp`), gratuita e ilimitada, porém NÃO OFICIAL — a Meta pode banir o
|
||||
# número. `twilio` é o canal oficial pago que existia antes.
|
||||
PROVEDORES_WHATSAPP = %w[baileys twilio].freeze
|
||||
BAILEYS_URL_PADRAO = 'http://whatsapp:3001'.freeze
|
||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||
REMETENTE_PADRAO = 'noreply@gade.com.br'
|
||||
|
||||
@@ -28,9 +34,16 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
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?
|
||||
validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP }
|
||||
validates :whatsapp_intervalo_segundos,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 300 }
|
||||
|
||||
# As credenciais do Twilio só são exigidas quando ele É o provedor escolhido.
|
||||
validates :twilio_account_sid, presence: true, if: :twilio_exigido?
|
||||
validates :twilio_from, presence: true, if: :twilio_exigido?
|
||||
validate :token_twilio_presente, if: :twilio_exigido?
|
||||
validate :baileys_configuravel, if: :baileys_exigido?
|
||||
|
||||
# ── Singleton ───────────────────────────────────────────────
|
||||
def self.instancia
|
||||
@@ -102,6 +115,32 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
email_notificacoes_ativo?
|
||||
end
|
||||
|
||||
# ── WhatsApp: escolha de provedor ───────────────────────────
|
||||
def baileys? = whatsapp_provedor.to_s == 'baileys'
|
||||
def twilio? = whatsapp_provedor.to_s == 'twilio'
|
||||
|
||||
def twilio_exigido? = whatsapp_ativo? && twilio?
|
||||
def baileys_exigido? = whatsapp_ativo? && baileys?
|
||||
|
||||
# URL e token da ponte: banco > .env, mesma hierarquia do resto da tela.
|
||||
def baileys_url_efetiva
|
||||
baileys_url.presence || ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO
|
||||
end
|
||||
|
||||
def baileys_token_efetivo
|
||||
baileys_token.presence || ENV['WHATSAPP_TOKEN'].presence
|
||||
end
|
||||
|
||||
def baileys_pronto?
|
||||
whatsapp_ativo? && baileys? && baileys_token_efetivo.present?
|
||||
end
|
||||
|
||||
# Segundos entre uma mensagem e a seguinte. Disparo em rajada para dezenas de
|
||||
# contatos é a forma mais rápida de o número ser banido no canal não oficial.
|
||||
def intervalo_envio
|
||||
whatsapp_intervalo_segundos.to_i.clamp(0, 300)
|
||||
end
|
||||
|
||||
# ── WhatsApp / Twilio ───────────────────────────────────────
|
||||
def whatsapp_pronto?
|
||||
whatsapp_ativo? && twilio_account_sid.present? &&
|
||||
@@ -109,7 +148,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
end
|
||||
|
||||
def whatsapp_habilitado?
|
||||
whatsapp_ativo? && credenciais_whatsapp.present?
|
||||
return false unless whatsapp_ativo?
|
||||
|
||||
baileys? ? baileys_pronto? : credenciais_whatsapp.present?
|
||||
end
|
||||
|
||||
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
|
||||
@@ -162,12 +203,22 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
||||
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
|
||||
self.baileys_url = baileys_url.to_s.strip.chomp('/').presence
|
||||
self.whatsapp_provedor = whatsapp_provedor.presence || 'baileys'
|
||||
# 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 baileys_configuravel
|
||||
return if baileys_token_efetivo.present?
|
||||
|
||||
errors.add(:baileys_token,
|
||||
'é obrigatório: defina WHATSAPP_TOKEN no .env (o mesmo do container `whatsapp`) ' \
|
||||
'ou preencha aqui.')
|
||||
end
|
||||
|
||||
def senha_smtp_presente
|
||||
return if smtp_password.present?
|
||||
|
||||
|
||||
@@ -377,12 +377,19 @@ class Consolidacao < ApplicationRecord
|
||||
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
|
||||
return unless cm
|
||||
|
||||
anterior = cm.valor_total
|
||||
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
||||
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
|
||||
end
|
||||
cm.update_column(:valor_total, total)
|
||||
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
||||
sincronizar_veiculos!(nome)
|
||||
|
||||
# Funil ÚNICO de mudança de valor do motorista — todo caminho que altera
|
||||
# pilar, desconto ou lançamento termina aqui. O gatilho só vale depois do
|
||||
# fechamento: em rascunho o valor muda a cada clique do wizard e avisar ali
|
||||
# seria spam, não informação. Nunca levanta (ver Notificacao::Gatilhos).
|
||||
Notificacao::Gatilhos.valor_alterado(self, cm, anterior: anterior)
|
||||
end
|
||||
|
||||
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
||||
|
||||
68
app/models/contato.rb
Normal file
68
app/models/contato.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# app/models/contato.rb
|
||||
#
|
||||
# Destinatário das notificações, cadastrado à mão pelo ADM: nome, número e/ou
|
||||
# e-mail, e o grupo a que pertence.
|
||||
#
|
||||
# Telefone é gravado SEMPRE em E.164 (+5511920051157). O ADM digita como quiser
|
||||
# — "(11) 92005-1157", "011 92005 1157" — e a normalização acontece aqui, uma
|
||||
# vez, na gravação. Mandar o texto cru para o provedor devolve erro de destino
|
||||
# inválido que o rescue do envio esconderia.
|
||||
class Contato < ApplicationRecord
|
||||
belongs_to :grupo_contato, optional: true
|
||||
belongs_to :user, optional: true
|
||||
has_many :notificacao_envios, dependent: :nullify
|
||||
|
||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||
|
||||
validates :nome, presence: true
|
||||
validates :email, format: { with: EMAIL_RE }, allow_blank: true
|
||||
validate :telefone_ou_email
|
||||
validate :telefone_valido
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
scope :com_whatsapp, -> { where.not(telefone: [nil, '']) }
|
||||
scope :com_email, -> { where.not(email: [nil, '']) }
|
||||
|
||||
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
|
||||
scope :com_canal, ->(canal) {
|
||||
case canal.to_s
|
||||
when 'whatsapp' then com_whatsapp
|
||||
when 'email' then com_email
|
||||
else where("(telefone IS NOT NULL AND telefone <> '') OR (email IS NOT NULL AND email <> '')")
|
||||
end
|
||||
}
|
||||
|
||||
def whatsapp? = telefone.present?
|
||||
def email? = email.present?
|
||||
|
||||
def primeiro_nome
|
||||
nome.to_s.split.first.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar
|
||||
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||
self.email = email.to_s.strip.downcase.presence
|
||||
# Mantém o que foi digitado se não der para normalizar — a validação acusa e
|
||||
# o ADM vê o próprio valor de volta no campo para corrigir.
|
||||
bruto = telefone.to_s.strip.presence
|
||||
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
|
||||
end
|
||||
|
||||
def telefone_ou_email
|
||||
return if telefone.present? || email.present?
|
||||
|
||||
errors.add(:base, 'Informe ao menos um WhatsApp ou um e-mail.')
|
||||
end
|
||||
|
||||
def telefone_valido
|
||||
return if telefone.blank?
|
||||
return if ConfiguracaoNotificacao.normalizar_telefone(telefone).present?
|
||||
|
||||
errors.add(:telefone, 'não parece um número brasileiro válido (DDD + número)')
|
||||
end
|
||||
end
|
||||
119
app/models/evento_notificacao.rb
Normal file
119
app/models/evento_notificacao.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
# app/models/evento_notificacao.rb
|
||||
#
|
||||
# Um evento que dispara mensagem. O ADM cadastra quantos quiser e liga cada um a
|
||||
# grupos de contato — mas o GATILHO sai de GATILHOS, porque gatilho é código.
|
||||
#
|
||||
# manual → botão "disparar agora" (aviso pontual)
|
||||
# consolidacao_finalizada → NotificacaoService.notificar_finalizacao
|
||||
# pagamento_efetuado → NotificacaoService.notificar_pagamento
|
||||
# valor_alterado → edição de lançamento / desconto após o fechamento
|
||||
# operacao_alterada → NF que entrou/saiu, entrega que mudou de status
|
||||
# agendado → resumo periódico (frequencia + hora)
|
||||
#
|
||||
# `sistema: true` marca os que nascem com o app: editáveis e desligáveis, mas
|
||||
# não apagáveis — apagar deixaria o código disparando no vazio.
|
||||
class EventoNotificacao < ApplicationRecord
|
||||
self.table_name = 'eventos_notificacao'
|
||||
|
||||
GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado
|
||||
valor_alterado operacao_alterada agendado].freeze
|
||||
|
||||
GATILHO_LABEL = {
|
||||
'manual' => 'Disparo manual (botão)',
|
||||
'consolidacao_finalizada' => 'Consolidação finalizada',
|
||||
'pagamento_efetuado' => 'Pagamento efetuado',
|
||||
'valor_alterado' => 'Valor alterado no fechamento',
|
||||
'operacao_alterada' => 'Dados da operação mudaram',
|
||||
'agendado' => 'Agendado (resumo periódico)'
|
||||
}.freeze
|
||||
|
||||
FREQUENCIAS = %w[diaria semanal].freeze
|
||||
|
||||
# Gatilhos que se referem a UMA pessoa específica (o motorista do pagamento),
|
||||
# e não só a grupos. Só neles `notificar_envolvido` faz sentido.
|
||||
GATILHOS_COM_ENVOLVIDO = %w[consolidacao_finalizada pagamento_efetuado valor_alterado].freeze
|
||||
|
||||
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||
has_many :grupos_contato, through: :assinaturas, source: :grupo_contato
|
||||
has_many :notificacao_envios, dependent: :nullify
|
||||
has_many :mensagem_templates, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :assinaturas, allow_destroy: true
|
||||
|
||||
validates :nome, presence: true
|
||||
validates :chave, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :gatilho, inclusion: { in: GATILHOS }
|
||||
validates :frequencia, inclusion: { in: FREQUENCIAS }, if: :agendado?
|
||||
validates :hora, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 23 }, allow_nil: true
|
||||
validates :dia_semana, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||
less_than_or_equal_to: 6 }, allow_nil: true
|
||||
validate :semanal_tem_dia
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
scope :do_gatilho, ->(g) { ativos.where(gatilho: g) }
|
||||
|
||||
def agendado? = gatilho == 'agendado'
|
||||
def manual? = gatilho == 'manual'
|
||||
def envolvido_aplicavel? = GATILHOS_COM_ENVOLVIDO.include?(gatilho)
|
||||
def gatilho_label = GATILHO_LABEL[gatilho] || gatilho
|
||||
|
||||
# Só pode apagar o que o ADM criou.
|
||||
def apagavel? = !sistema?
|
||||
|
||||
# Template do canal, criando um vazio em memória quando ainda não existe —
|
||||
# assim a tela do editor abre igual para evento novo e evento já configurado.
|
||||
def template(canal)
|
||||
mensagem_templates.find { |t| t.canal == canal.to_s } ||
|
||||
mensagem_templates.build(canal: canal.to_s, blocos: [])
|
||||
end
|
||||
|
||||
# O corpo configurado no editor vence o texto padrão do código. Um template
|
||||
# ativo porém VAZIO não conta (mandaria mensagem em branco).
|
||||
def template_utilizavel(canal)
|
||||
t = mensagem_templates.detect { |x| x.canal == canal.to_s }
|
||||
t&.utilizavel? ? t : nil
|
||||
end
|
||||
|
||||
def variaveis = Notificacao::Variaveis.para(gatilho)
|
||||
|
||||
# Contatos que devem receber este evento, sem repetição — a mesma pessoa pode
|
||||
# estar em dois grupos assinantes.
|
||||
def destinatarios(canal)
|
||||
ids = assinaturas.where(ativo: true)
|
||||
.select { |a| a.cobre_canal?(canal) }
|
||||
.map(&:grupo_contato_id)
|
||||
return Contato.none if ids.empty?
|
||||
|
||||
Contato.ativos.where(grupo_contato_id: ids).com_canal(canal).distinct
|
||||
end
|
||||
|
||||
# Já passou da hora de disparar hoje/esta semana?
|
||||
def vencido?(agora = Time.current)
|
||||
return false unless agendado? && ativo?
|
||||
return false unless agora.hour >= hora.to_i
|
||||
return false if frequencia == 'semanal' && agora.wday != dia_semana.to_i
|
||||
return true if ultimo_disparo_em.blank?
|
||||
|
||||
ultimo_disparo_em < agora.beginning_of_day
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar
|
||||
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||
self.chave = chave.presence || nome.to_s.parameterize(separator: '_').presence
|
||||
self.chave = chave.to_s.strip.downcase.presence
|
||||
self.dia_semana = nil unless agendado? && frequencia == 'semanal'
|
||||
self.frequencia = nil unless agendado?
|
||||
end
|
||||
|
||||
def semanal_tem_dia
|
||||
return unless agendado? && frequencia == 'semanal' && dia_semana.blank?
|
||||
|
||||
errors.add(:dia_semana, 'é obrigatório na frequência semanal')
|
||||
end
|
||||
end
|
||||
38
app/models/grupo_contato.rb
Normal file
38
app/models/grupo_contato.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
# app/models/grupo_contato.rb
|
||||
#
|
||||
# Grupo de destinatários (Diretoria, Operação, Motoristas, Cliente...). É o
|
||||
# GRUPO que assina os eventos: cadastrar alguém novo é escolher o grupo, não
|
||||
# repetir a configuração pessoa por pessoa.
|
||||
class GrupoContato < ApplicationRecord
|
||||
self.table_name = 'grupos_contato'
|
||||
|
||||
has_many :contatos, dependent: :nullify
|
||||
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||
has_many :eventos, through: :assinaturas, source: :evento_notificacao
|
||||
|
||||
validates :nome, presence: true
|
||||
validate :nome_unico
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||
|
||||
before_validation { self.nome = nome.to_s.strip.squeeze(' ').presence }
|
||||
|
||||
# Contatos que devem receber ESTE evento por ESTE canal.
|
||||
def destinatarios(canal)
|
||||
return GrupoContato.none unless ativo?
|
||||
|
||||
contatos.ativos.com_canal(canal)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Índice único é sobre LOWER(nome); validar aqui dá a mensagem amigável em vez
|
||||
# de estourar RecordNotUnique na tela.
|
||||
def nome_unico
|
||||
return if nome.blank?
|
||||
|
||||
existe = GrupoContato.where.not(id: id).where('LOWER(nome) = ?', nome.downcase).exists?
|
||||
errors.add(:nome, 'já existe') if existe
|
||||
end
|
||||
end
|
||||
22
app/models/grupo_evento_assinatura.rb
Normal file
22
app/models/grupo_evento_assinatura.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# app/models/grupo_evento_assinatura.rb
|
||||
#
|
||||
# "O grupo X recebe o evento Y por WhatsApp/e-mail/ambos."
|
||||
class GrupoEventoAssinatura < ApplicationRecord
|
||||
CANAIS = %w[ambos whatsapp email].freeze
|
||||
CANAL_LABEL = { 'ambos' => 'WhatsApp e e-mail', 'whatsapp' => 'Só WhatsApp', 'email' => 'Só e-mail' }.freeze
|
||||
|
||||
belongs_to :grupo_contato
|
||||
belongs_to :evento_notificacao
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :grupo_contato_id, uniqueness: { scope: :evento_notificacao_id,
|
||||
message: 'já assina este evento' }
|
||||
|
||||
scope :ativas, -> { where(ativo: true) }
|
||||
|
||||
def cobre_canal?(canal)
|
||||
self.canal == 'ambos' || self.canal == canal.to_s
|
||||
end
|
||||
|
||||
def canal_label = CANAL_LABEL[canal] || canal
|
||||
end
|
||||
85
app/models/mensagem_template.rb
Normal file
85
app/models/mensagem_template.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# app/models/mensagem_template.rb
|
||||
#
|
||||
# Corpo de um evento em UM canal, montado com blocos no editor.
|
||||
#
|
||||
# `blocos` vem de um campo hidden preenchido por JavaScript — ou seja, de fora.
|
||||
# Por isso #normalizar_blocos DESCARTA o que não está no catálogo: tipo
|
||||
# desconhecido, campo que aquele tipo não tem, e qualquer coisa que não seja
|
||||
# Hash. Sem isso, um JSON adulterado no form viraria conteúdo renderizado sem
|
||||
# passar por validação nenhuma.
|
||||
class MensagemTemplate < ApplicationRecord
|
||||
CANAIS = %w[whatsapp email].freeze
|
||||
# Teto de segurança: um template com milhares de blocos só serviria para
|
||||
# travar o render do e-mail.
|
||||
MAX_BLOCOS = 40
|
||||
MAX_LINHAS_TABELA = 20
|
||||
|
||||
belongs_to :evento_notificacao
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :canal, uniqueness: { scope: :evento_notificacao_id }
|
||||
validate :assunto_no_email
|
||||
|
||||
before_validation :normalizar_blocos
|
||||
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :do_canal, ->(canal) { where(canal: canal.to_s) }
|
||||
|
||||
def email? = canal == 'email'
|
||||
def whatsapp? = canal == 'whatsapp'
|
||||
|
||||
# Um template só entra em uso quando tem conteúdo — template ativo e vazio
|
||||
# mandaria mensagem em branco, o que é pior do que cair no texto padrão.
|
||||
def utilizavel?
|
||||
ativo? && blocos.present?
|
||||
end
|
||||
|
||||
def renderizador(dados = {})
|
||||
Notificacao::Renderizador.new(blocos, dados)
|
||||
end
|
||||
|
||||
def preview(dados = nil)
|
||||
dados ||= Notificacao::Variaveis.amostra(evento_notificacao&.gatilho)
|
||||
r = renderizador(dados)
|
||||
email? ? r.html : r.texto
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar_blocos
|
||||
lista = blocos.is_a?(Array) ? blocos : []
|
||||
self.blocos = lista.filter_map { |bruto| limpar(bruto) }.first(MAX_BLOCOS)
|
||||
end
|
||||
|
||||
def limpar(bruto)
|
||||
return nil unless bruto.is_a?(Hash)
|
||||
|
||||
tipo = bruto['tipo'] || bruto[:tipo]
|
||||
return nil unless Notificacao::Blocos.valido?(tipo)
|
||||
|
||||
limpo = { 'tipo' => tipo.to_s }
|
||||
Notificacao::Blocos.campos(tipo).each do |campo, especie|
|
||||
valor = bruto[campo] || bruto[campo.to_sym]
|
||||
limpo[campo] = especie == :lista ? limpar_linhas(valor) : valor.to_s
|
||||
end
|
||||
limpo
|
||||
end
|
||||
|
||||
def limpar_linhas(valor)
|
||||
Array(valor).filter_map do |linha|
|
||||
next unless linha.is_a?(Hash)
|
||||
|
||||
rotulo = (linha['rotulo'] || linha[:rotulo]).to_s
|
||||
conteudo = (linha['valor'] || linha[:valor]).to_s
|
||||
next if rotulo.blank? && conteudo.blank?
|
||||
|
||||
{ 'rotulo' => rotulo, 'valor' => conteudo }
|
||||
end.first(MAX_LINHAS_TABELA)
|
||||
end
|
||||
|
||||
def assunto_no_email
|
||||
return unless email? && ativo? && blocos.present? && assunto.to_s.strip.blank?
|
||||
|
||||
errors.add(:assunto, 'é obrigatório no e-mail')
|
||||
end
|
||||
end
|
||||
41
app/models/notificacao_envio.rb
Normal file
41
app/models/notificacao_envio.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# app/models/notificacao_envio.rb
|
||||
#
|
||||
# Registro de UMA mensagem disparada. Existe para responder "o motorista
|
||||
# recebeu?" — pergunta que hoje só tem resposta no log do Rails, porque o
|
||||
# NotificacaoService engole a exceção de propósito (um SMTP fora do ar não pode
|
||||
# travar o fechamento de um pagamento).
|
||||
#
|
||||
# Com o WhatsApp por sessão QR, que cai sozinha e exige repareamento, essa
|
||||
# pergunta vira rotina — daí o log ser tabela, não linha de arquivo.
|
||||
class NotificacaoEnvio < ApplicationRecord
|
||||
STATUS = %w[pendente enviado falhou].freeze
|
||||
CANAIS = %w[whatsapp email].freeze
|
||||
|
||||
belongs_to :evento_notificacao, optional: true
|
||||
belongs_to :contato, optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
validates :canal, inclusion: { in: CANAIS }
|
||||
validates :status, inclusion: { in: STATUS }
|
||||
validates :destino, presence: true
|
||||
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :falhados, -> { where(status: 'falhou') }
|
||||
scope :enviados, -> { where(status: 'enviado') }
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(created_at: inicio.to_date.beginning_of_day..fim.to_date.end_of_day)
|
||||
}
|
||||
|
||||
def marcar_enviado!
|
||||
update!(status: 'enviado', enviado_em: Time.current, erro: nil,
|
||||
tentativas: tentativas + 1)
|
||||
end
|
||||
|
||||
def marcar_falha!(erro)
|
||||
update!(status: 'falhou', erro: erro.to_s.truncate(2000),
|
||||
tentativas: tentativas + 1)
|
||||
end
|
||||
|
||||
def enviado? = status == 'enviado'
|
||||
def falhou? = status == 'falhou'
|
||||
end
|
||||
41
app/models/operacao_snapshot.rb
Normal file
41
app/models/operacao_snapshot.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# app/models/operacao_snapshot.rb
|
||||
#
|
||||
# Últimos números conhecidos de uma operação. Comparar com os de agora é o que
|
||||
# permite dizer O QUE mudou — e não só como está.
|
||||
class OperacaoSnapshot < ApplicationRecord
|
||||
CONTADORES = %i[total_notas entregues nao_entregues pendentes].freeze
|
||||
|
||||
ROTULOS = {
|
||||
total_notas: 'notas na operação',
|
||||
entregues: 'entregues',
|
||||
nao_entregues: 'não entregues',
|
||||
pendentes: 'em aberto'
|
||||
}.freeze
|
||||
|
||||
validates :tabela, presence: true, uniqueness: true
|
||||
|
||||
def self.para(tabela) = find_or_initialize_by(tabela: tabela.to_s)
|
||||
|
||||
def numeros = CONTADORES.index_with { |c| send(c).to_i }
|
||||
|
||||
# Diferença legível entre o retrato guardado e os números de agora:
|
||||
# { total_notas: +3, pendentes: -1 } => "3 notas na operação a mais, 1 em aberto a menos"
|
||||
def diferenca(atuais)
|
||||
CONTADORES.filter_map do |contador|
|
||||
delta = atuais[contador].to_i - send(contador).to_i
|
||||
next if delta.zero?
|
||||
|
||||
[contador, delta]
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def self.descrever(diferenca)
|
||||
diferenca.map do |contador, delta|
|
||||
"#{delta.abs} #{ROTULOS.fetch(contador, contador)} #{delta.positive? ? 'a mais' : 'a menos'}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
def registrar!(atuais)
|
||||
update!(atuais.merge(capturado_em: Time.current))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user