Implandação da customização das permissões dos usuarios e das notificaçoes

This commit is contained in:
2026-08-26 17:24:25 -03:00
parent 808c2c7545
commit 5bb5d74e22
71 changed files with 2487 additions and 269 deletions

View File

@@ -0,0 +1,196 @@
# app/services/notificacao/catalogo_variaveis.rb
#
# TODAS as variáveis que o editor de mensagem oferece, de quatro origens:
#
# :contexto — vêm com o disparo (motorista, consolidação, valor…). Só têm
# valor nos gatilhos que as produzem; em outro gatilho saem
# vazias, e a tela avisa isso.
# :sistema — não dependem de contexto nenhum (empresa, data, hora, mês).
# :dados — CONSULTAM O BANCO na hora do envio (entregas do mês, total a
# pagar…). Resolvidas sob demanda: só a variável realmente
# escrita no template vira query.
# :personalizada — cadastradas pelo ADM (VariavelPersonalizada), valor fixo.
#
# Antes, `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho: o ADM
# via 6 variáveis e nada mais, e qualquer texto com número (“entregas do mês”)
# precisava de deploy. Aqui a lista é aberta — e o que não se aplica é MARCADO em
# vez de escondido.
#
# ⚠️ Um `resolver` NUNCA pode levantar: notificação não derruba fechamento. O
# ResolvedorVariaveis engole e devolve vazio.
module Notificacao
module CatalogoVariaveis
ORIGENS = {
contexto: 'Do que aconteceu',
sistema: 'Empresa e data',
dados: 'Números do sistema',
personalizada: 'Suas variáveis'
}.freeze
# ── Sistema: sempre disponíveis ───────────────────────────
SISTEMA = {
'empresa' => {
descricao: 'Nome da empresa', amostra: 'Reem Transportes',
resolver: -> { Configuracao.valor('empresa_nome').presence || 'Reem Transportes' }
},
'data' => {
descricao: 'Data de hoje (dd/mm/aaaa)', amostra: '26/08/2026',
resolver: -> { Date.current.strftime('%d/%m/%Y') }
},
'hora' => {
descricao: 'Hora do envio', amostra: '14:35',
resolver: -> { Time.current.strftime('%H:%M') }
},
'dia_semana' => {
descricao: 'Dia da semana por extenso', amostra: 'quarta-feira',
resolver: -> { I18n.l(Date.current, format: '%A') rescue Date::DAYNAMES[Date.current.wday] }
},
'mes' => {
descricao: 'Mês por extenso', amostra: 'agosto',
resolver: -> { I18n.l(Date.current, format: '%B') rescue Date::MONTHNAMES[Date.current.month] }
},
'mes_ano' => {
descricao: 'Mês/ano (mm/aaaa)', amostra: '08/2026',
resolver: -> { Date.current.strftime('%m/%Y') }
},
'link_sistema' => {
descricao: 'Endereço do sistema', amostra: 'https://app.reemtransportes.com.br',
resolver: -> { Configuracao.valor('url_sistema').presence || ENV.fetch('APP_URL', '') }
}
}.freeze
# ── Contexto: quem produz é o gatilho ─────────────────────
# `gatilhos:` diz em quais a variável tem valor — é o que a tela usa para
# avisar "esta sai vazia aqui".
CONTEXTO = {
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos', gatilhos: :todos },
'quem' => { descricao: 'Quem executou a ação no sistema', amostra: 'Ana Paula', gatilhos: :todos },
'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 no fechamento', amostra: '280' },
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' },
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 (avaria)' },
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
'nf' => { descricao: 'Nota fiscal', amostra: '85382' },
'entregues' => { descricao: 'Notas entregues (resumo)', amostra: '4.851' },
'recusas' => { descricao: 'Notas não entregues (resumo)', amostra: '122' },
'pendentes' => { descricao: 'Notas em aberto (resumo)', amostra: '0' },
'retentativas' => { descricao: 'Visitas além da primeira (resumo)', amostra: '4' },
'fora_operacao' => { descricao: 'Notas fora da operação (resumo)', amostra: '2' }
}.freeze
# Quais variáveis de contexto cada gatilho realmente preenche.
POR_GATILHO = {
'manual' => [],
'consolidacao_finalizada' => %w[motorista consolidacao periodo valor entregas link_painel],
'pagamento_efetuado' => %w[motorista consolidacao periodo valor entregas link_painel],
'valor_alterado' => %w[motorista consolidacao periodo valor entregas link_painel o_que_mudou],
'operacao_alterada' => %w[operacao o_que_mudou nf],
'agendado' => %w[operacao periodo entregues recusas pendentes retentativas fora_operacao]
}.freeze
# ── Dados: consultam o banco na hora do envio ─────────────
# Cada uma responde a uma pergunta que o ADM hoje só conseguiria escrevendo à
# mão — e que ficaria desatualizada na mensagem seguinte.
DADOS = {
'entregas_mes' => {
descricao: 'Entregas atendidas no mês corrente', amostra: '4.977',
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current.beginning_of_month, Date.current).count }
},
'entregas_hoje' => {
descricao: 'Entregas atendidas hoje', amostra: '213',
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current, Date.current).count }
},
'motoristas_ativos_mes' => {
descricao: 'Motoristas com entrega no mês', amostra: '17',
resolver: -> { Entrega.motoristas_ativos(inicio: Date.current.beginning_of_month, fim: Date.current).size }
},
'consolidacoes_abertas' => {
descricao: 'Consolidações em rascunho', amostra: '3',
resolver: -> { Consolidacao.ativas.rascunho.count }
},
'total_a_pagar_mes' => {
descricao: 'Total a pagar no mês (fechado e não pago)', amostra: 'R$ 72.508,00',
resolver: lambda {
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
fim: Date.current.end_of_month)
Formatador.moeda(totais.total_pendente)
}
},
'total_pago_mes' => {
descricao: 'Total já pago no mês', amostra: 'R$ 21.300,00',
resolver: lambda {
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
fim: Date.current.end_of_month)
Formatador.moeda(totais.total_pago)
}
},
'total_consolidado_mes' => {
descricao: 'Total consolidado no mês', amostra: 'R$ 93.808,00',
resolver: lambda {
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
fim: Date.current.end_of_month)
Formatador.moeda(totais.total_geral)
}
}
}.freeze
module_function
# Todas as variáveis conhecidas, incluindo as que o ADM cadastrou.
# { chave => { origem:, descricao:, amostra: } }
def todas
base = {}
SISTEMA.each { |c, m| base[c] = { origem: :sistema, descricao: m[:descricao], amostra: m[:amostra] } }
CONTEXTO.each { |c, m| base[c] = { origem: :contexto, descricao: m[:descricao], amostra: m[:amostra] } }
DADOS.each { |c, m| base[c] = { origem: :dados, descricao: m[:descricao], amostra: m[:amostra] } }
personalizadas.each do |v|
base[v.chave] = { origem: :personalizada, descricao: v.descricao.presence || 'Sua variável', amostra: v.valor }
end
base
end
# A variável tem valor NESTE gatilho? Contexto de outro gatilho sai vazio —
# a tela mostra, em vez de esconder a variável (foi o pedido: liberdade com
# aviso, não lista curta).
def aplicavel?(chave, gatilho)
meta = CONTEXTO[chave.to_s]
return true if meta.nil? # sistema/dados/personalizada: sempre
return true if meta[:gatilhos] == :todos
Array(POR_GATILHO[gatilho.to_s]).include?(chave.to_s)
end
def origem(chave)
todas.dig(chave.to_s, :origem)
end
def existe?(chave) = todas.key?(chave.to_s)
# Agrupadas para a tela do editor, na ordem de ORIGENS.
def por_origem(gatilho = nil)
agrupadas = ORIGENS.keys.index_with { |o| [] }
todas.each do |chave, meta|
agrupadas[meta[:origem]] << meta.merge(chave: chave, aplicavel: gatilho.nil? || aplicavel?(chave, gatilho))
end
agrupadas
end
# Contexto do PREVIEW: valores de amostra, nunca de envio real.
def amostra(gatilho = nil)
todas.filter_map { |chave, meta|
[chave, meta[:amostra].to_s] if gatilho.nil? || aplicavel?(chave, gatilho)
}.to_h
end
def personalizadas
return [] unless VariavelPersonalizada.table_exists?
VariavelPersonalizada.ativas.to_a
rescue StandardError
[]
end
end
end

View File

@@ -93,7 +93,25 @@ module Notificacao
# 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)
dados_completos.merge('contato' => nome.to_s)
end
# O contexto do gatilho + as variáveis que o TEMPLATE usa e o gatilho não
# conhece ({{entregas_mes}}, {{telefone_suporte}}, {{hora}}…).
#
# Resolvido UMA vez por disparo, não por destinatário: `{{entregas_mes}}` é
# uma query, e o valor é o mesmo para todo mundo — só `{{contato}}` muda.
def dados_completos
@dados_completos ||= ResolvedorVariaveis.completar(textos_do_disparo, @dados)
end
# Tudo que será interpolado: os blocos dos dois templates (o evento pode ter
# WhatsApp e e-mail com textos diferentes), o assunto e o corpo padrão.
def textos_do_disparo
[@assunto, @corpo,
@evento.template_utilizavel('whatsapp')&.blocos,
@evento.template_utilizavel('email')&.blocos,
@evento.template_utilizavel('email')&.assunto]
end
# O template do editor vence; sem template utilizável, cai no texto que o

View File

@@ -0,0 +1,31 @@
# app/services/notificacao/formatador.rb
#
# Formatação dos valores que entram numa mensagem. Existe porque o mesmo `moeda`
# estava copiado em `Notificacao::Gatilhos` e em `NotificacaoService` — e agora o
# catálogo de variáveis também precisa dele, o que faria três cópias.
#
# Métodos de módulo (sem estado): é chamado de model, job, service e view.
module Notificacao
module Formatador
module_function
# R$ 1.234,56 — separador de milhar incluído (o `number_to_currency` do Rails
# depende de locale carregado, e isto roda também dentro de job).
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 periodo(inicio, fim)
return '' if inicio.blank? || fim.blank?
"#{inicio.to_date.strftime('%d/%m/%Y')} a #{fim.to_date.strftime('%d/%m/%Y')}"
end
# Números grandes ficam ilegíveis sem separador numa mensagem de WhatsApp.
def numero(valor)
valor.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
end
end
end

View File

@@ -121,13 +121,8 @@ module Notificacao
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
# Formatação vive em Notificacao::Formatador (era copiada aqui e no
# NotificacaoService; com o catálogo de variáveis seriam três cópias).
def moeda(valor) = Formatador.moeda(valor)
end
end

View File

@@ -0,0 +1,90 @@
# app/services/notificacao/resolvedor_variaveis.rb
#
# Completa o contexto de um disparo com as variáveis que o template REALMENTE
# usa. É o que permite escrever {{entregas_mes}} ou {{telefone_suporte}} numa
# mensagem sem que o gatilho precise saber dessas variáveis.
#
# SOB DEMANDA, de propósito: `{{entregas_mes}}` é uma query no espelho de
# rastreio. Resolver o catálogo inteiro em todo disparo faria dezenas de
# consultas para preencher variáveis que ninguém escreveu.
#
# Precedência (a primeira que responde vence):
# 1. o que o gatilho já mandou (contexto real do acontecimento);
# 2. variável de sistema (empresa, data, hora…);
# 3. variável de dados (consulta ao banco);
# 4. variável personalizada do ADM.
#
# ⚠️ NADA aqui levanta. Uma variável que falha vira string vazia — a mesma regra
# do renderizador, e pelo mesmo motivo: mensagem com buraco é ruim, disparo
# derrubado por causa de um texto é pior.
module Notificacao
module ResolvedorVariaveis
MARCADOR = /\{\{\s*(\w+)\s*\}\}/
module_function
# textos: tudo que será interpolado (blocos do template, assunto, corpo).
# dados: o contexto que o gatilho produziu.
def completar(textos, dados = {})
contexto = (dados || {}).transform_keys(&:to_s)
faltantes = usadas(textos) - contexto.keys
faltantes.each_with_object(contexto.dup) do |chave, resultado|
valor = resolver(chave)
resultado[chave] = valor unless valor.nil?
end
end
# Chaves {{assim}} encontradas em qualquer texto (String, Array ou Hash de
# blocos — o template é jsonb aninhado).
def usadas(textos)
varrer(textos).scan(MARCADOR).flatten.uniq
end
def resolver(chave)
chave = chave.to_s
valor = valor_de_sistema(chave) || valor_de_dados(chave) || valor_personalizado(chave)
valor&.to_s
end
# ── privados de módulo ──────────────────────────────────────
def varrer(objeto)
case objeto
when String then objeto
when Hash then objeto.values.map { |v| varrer(v) }.join(' ')
when Array then objeto.map { |v| varrer(v) }.join(' ')
else ''
end
end
def valor_de_sistema(chave)
meta = CatalogoVariaveis::SISTEMA[chave]
meta && executar(meta[:resolver], chave)
end
def valor_de_dados(chave)
meta = CatalogoVariaveis::DADOS[chave]
meta && executar(meta[:resolver], chave)
end
def valor_personalizado(chave)
return nil unless VariavelPersonalizada.table_exists?
VariavelPersonalizada.ativas.find_by(chave: chave)&.valor
rescue StandardError => e
Rails.logger.error("[Variaveis] personalizada #{chave}: #{e.class}: #{e.message}")
nil
end
def executar(bloco, chave)
return nil if bloco.nil?
resultado = bloco.call
# Número cru numa mensagem fica sem separador de milhar ("4977").
resultado.is_a?(Numeric) ? Formatador.numero(resultado) : resultado.to_s
rescue StandardError => e
Rails.logger.error("[Variaveis] #{chave}: #{e.class}: #{e.message}")
nil
end
end
end

View File

@@ -181,10 +181,6 @@ class NotificacaoService
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
end
# Moeda BR com separador de milhar: R$ 1.234,56
def moeda(valor)
inteiro, decimais = format('%.2f', valor.to_f).split('.')
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
"R$ #{inteiro},#{decimais}"
end
# Ver Notificacao::Formatador — implementação única.
def moeda(valor) = Notificacao::Formatador.moeda(valor)
end