255 lines
8.5 KiB
Ruby
255 lines
8.5 KiB
Ruby
# 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
|
|
|
|
# inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a
|
|
# própria operação inteira (visão de operação única). O filtro de período só é
|
|
# usado na visão Global.
|
|
#
|
|
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
|
|
# numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
|
|
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
|
|
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {})
|
|
@tabelas = Operacao.sanitizar(Array(tabelas))
|
|
@inicio = inicio&.to_date
|
|
@fim = fim&.to_date
|
|
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
|
|
end
|
|
|
|
def operacoes_label
|
|
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
|
end
|
|
|
|
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
|
def linhas
|
|
@linhas ||= if @filtros.empty?
|
|
registros
|
|
else
|
|
registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
|
end
|
|
end
|
|
|
|
# Taxas (úteis no comparativo).
|
|
def taxa_sucesso
|
|
pct(sucesso, total)
|
|
end
|
|
|
|
def taxa_insucesso
|
|
pct(recusas, total)
|
|
end
|
|
|
|
# Faixa real das entregas carregadas (pela data de checkout) — para exibir o
|
|
# período natural da operação no cabeçalho/painel.
|
|
def data_inicio
|
|
datas_checkout.min
|
|
end
|
|
|
|
def data_fim
|
|
datas_checkout.max
|
|
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
|
|
linhas.size
|
|
end
|
|
|
|
def sucesso
|
|
linhas.count { |r| r['status'] == 'completed' }
|
|
end
|
|
|
|
def recusas
|
|
linhas.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 = linhas.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
|
|
linhas.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(linhas, 'driver')
|
|
end
|
|
|
|
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
|
|
def por_sts
|
|
contagem(linhas.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 } }
|
|
linhas.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 ||= linhas.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 datas_checkout
|
|
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
|
end
|
|
|
|
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))
|
|
status = coluna_status_gade(conn, 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,
|
|
#{status} AS status_gade
|
|
FROM ultimo r
|
|
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
|
WHERE r.rn = 1#{filtro_periodo(conn)}
|
|
SQL
|
|
end
|
|
|
|
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
|
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
|
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
|
|
)
|
|
#{unions.join("\nUNION ALL\n")}
|
|
SQL
|
|
|
|
conn.select_all(sql).to_a
|
|
end
|
|
|
|
# g.status existe só em ALGUMAS tabelas de operação (ex.: ubs_norte). Quando a
|
|
# coluna não existe, devolve NULL — o painel "Por Status" cai no rótulo "—".
|
|
# (A única coluna garantida em toda tabela gade é nota_fiscal.)
|
|
def coluna_status_gade(conn, tabela)
|
|
colunas = conn.columns(tabela).map(&:name)
|
|
colunas.include?('status') ? 'g.status' : 'CAST(NULL AS text)'
|
|
end
|
|
|
|
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
|
|
# Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
|
|
# data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
|
|
def filtro_periodo(conn)
|
|
return '' unless @inicio && @fim
|
|
ini = conn.quote(@inicio)
|
|
fim_excl = conn.quote(@fim + 1)
|
|
fim_dia = conn.quote(@fim.end_of_day)
|
|
" AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
|
|
" OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
|
|
end
|
|
end
|
|
end
|