Imṕlantação do dash board de acompanhamento diario das entregas
This commit is contained in:
221
app/services/analytics/operacao_metricas.rb
Normal file
221
app/services/analytics/operacao_metricas.rb
Normal file
@@ -0,0 +1,221 @@
|
||||
# app/services/analytics/operacao_metricas.rb
|
||||
#
|
||||
# Núcleo de dados do "Dashboard de Operações".
|
||||
#
|
||||
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
|
||||
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
||||
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
||||
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
||||
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
|
||||
# e o mapa de calor).
|
||||
#
|
||||
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
|
||||
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||
# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA.
|
||||
module Analytics
|
||||
class OperacaoMetricas
|
||||
# Centro padrão do mapa quando não há coordenadas (Grande São Paulo).
|
||||
SP_CENTRO = [-23.55, -46.63].freeze
|
||||
|
||||
def initialize(tabelas:, inicio:, fim:)
|
||||
@tabelas = Operacao.sanitizar(Array(tabelas))
|
||||
@inicio = inicio.to_date
|
||||
@fim = fim.to_date
|
||||
end
|
||||
|
||||
def operacoes_label
|
||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||
end
|
||||
|
||||
# Linhas cruas (Array de Hash com chave string) — base de todas as agregações.
|
||||
def registros
|
||||
@registros ||= carregar
|
||||
end
|
||||
|
||||
def vazio?
|
||||
registros.empty?
|
||||
end
|
||||
|
||||
# ── KPIs ─────────────────────────────────────────────────────
|
||||
def total
|
||||
registros.size
|
||||
end
|
||||
|
||||
def sucesso
|
||||
registros.count { |r| r['status'] == 'completed' }
|
||||
end
|
||||
|
||||
def recusas
|
||||
registros.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
# Em aberto: nem concluídas nem falhadas.
|
||||
def pendentes
|
||||
total - sucesso - recusas
|
||||
end
|
||||
|
||||
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||
def insucessos_pct
|
||||
{
|
||||
completed: sucesso,
|
||||
failed: recusas,
|
||||
pct_completed: pct(sucesso, total),
|
||||
pct_failed: pct(recusas, total)
|
||||
}
|
||||
end
|
||||
|
||||
# Falhas agrupadas por observação (motivo), % sobre o total de falhas.
|
||||
def indices_falha
|
||||
falhas = registros.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
total_falhas = falhas.size
|
||||
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
|
||||
.map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
# Contagem por status da tabela gade (RECORRENTE/NOVO/...).
|
||||
def por_status_gade
|
||||
registros.group_by { |r| r['status_gade'].presence || '—' }
|
||||
.map { |st, rows| { status: st, total: rows.size } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
# NFs por motorista (todas as entregas), desc.
|
||||
def por_motorista
|
||||
contagem(registros, 'driver')
|
||||
end
|
||||
|
||||
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
|
||||
def por_sts
|
||||
contagem(registros.select { |r| r['status'] == 'completed' }, 'contact_name')
|
||||
end
|
||||
|
||||
# Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
|
||||
def por_dia
|
||||
por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
|
||||
registros.each do |r|
|
||||
d = data_de(r['checkout'])
|
||||
next unless d
|
||||
if r['status'] == 'completed'
|
||||
por_data[d][:completed] += 1
|
||||
elsif Entrega::STATUS_FALHA.include?(r['status'])
|
||||
por_data[d][:failed] += 1
|
||||
end
|
||||
end
|
||||
datas = por_data.keys.sort
|
||||
{
|
||||
labels: datas.map { |d| d.strftime('%d/%m/%Y') },
|
||||
completed: datas.map { |d| por_data[d][:completed] },
|
||||
failed: datas.map { |d| por_data[d][:failed] }
|
||||
}
|
||||
end
|
||||
|
||||
# Pontos [lat, lng] das entregas concluídas para o heatmap.
|
||||
# Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
|
||||
def pontos_mapa
|
||||
@pontos_mapa ||= registros.filter_map do |r|
|
||||
next unless r['status'] == 'completed'
|
||||
lat = num(r['checkout_latitude']) || num(r['latitude'])
|
||||
lng = num(r['checkout_longitude']) || num(r['longitude'])
|
||||
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||
[lat, lng]
|
||||
end
|
||||
end
|
||||
|
||||
def centro_mapa
|
||||
pontos_mapa.first || SP_CENTRO
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contagem(rows, coluna)
|
||||
rows.group_by { |r| r[coluna].presence || '—' }
|
||||
.map { |nome, grupo| { nome: nome, total: grupo.size } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
def pct(parte, total)
|
||||
return 0.0 if total.to_i.zero?
|
||||
(parte.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def num(valor)
|
||||
return nil if valor.nil? || valor.to_s.strip.empty?
|
||||
Float(valor)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def data_de(valor)
|
||||
return nil if valor.nil?
|
||||
return valor.to_date if valor.respond_to?(:to_date)
|
||||
Date.parse(valor.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def carregar
|
||||
return [] if @tabelas.empty?
|
||||
|
||||
conn = ActiveRecord::Base.connection
|
||||
|
||||
unions = @tabelas.map do |tabela|
|
||||
gade = conn.quote_table_name(tabela)
|
||||
label = conn.quote(Operacao.label(tabela))
|
||||
<<~SQL.strip
|
||||
SELECT #{label} AS operacao,
|
||||
r.driver, r.status, r.observation, r.contact_name,
|
||||
r.checkout, r.planned_date,
|
||||
r.latitude, r.longitude,
|
||||
r.checkout_latitude, r.checkout_longitude,
|
||||
g.status AS status_gade
|
||||
FROM ultimo r
|
||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||
WHERE r.rn = 1 AND (#{periodo_sql(conn)})
|
||||
SQL
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
||||
WHERE reference_id IS NOT NULL #{conta_sql(conn)}
|
||||
)
|
||||
#{unions.join("\nUNION ALL\n")}
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
|
||||
# data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
|
||||
def periodo_sql(conn)
|
||||
ini = conn.quote(@inicio)
|
||||
fim_excl = conn.quote(@fim + 1)
|
||||
fim_dia = conn.quote(@fim.end_of_day)
|
||||
<<~SQL.strip
|
||||
(r.checkout >= #{ini} AND r.checkout < #{fim_excl})
|
||||
OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia})
|
||||
SQL
|
||||
end
|
||||
|
||||
# Replica Entrega.da_conta_gade como fragmento "AND (account_id ...)".
|
||||
# Lista separada por vírgula em DB_EXISTING_ACCOUNT_ID; "all"/vazio = sem filtro.
|
||||
def conta_sql(conn)
|
||||
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
|
||||
return '' if contas.empty? || contas.casecmp?('all')
|
||||
|
||||
valores = contas.split(',', -1).map(&:strip)
|
||||
inclui_null = valores.any?(&:empty?)
|
||||
lista = valores.reject(&:empty?).map { |v| conn.quote(v) }
|
||||
|
||||
condicoes = []
|
||||
condicoes << "account_id IN (#{lista.join(', ')})" if lista.any?
|
||||
condicoes << 'account_id IS NULL' if inclui_null
|
||||
return '' if condicoes.empty?
|
||||
|
||||
"AND (#{condicoes.join(' OR ')})"
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user