Files
Reem-Notas/app/services/analytics/operacao_metricas.rb

248 lines
8.4 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.
def initialize(tabelas:, inicio: nil, fim: nil)
@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
# 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
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 datas_checkout
@datas_checkout ||= registros.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
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
# 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
# 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