395 lines
15 KiB
Ruby
395 lines
15 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: casa db_reem_simplerout_2026 com a(s)
|
|
# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e
|
|
# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha,
|
|
# status, motoristas, STS, por dia e o mapa de calor).
|
|
#
|
|
# DUAS CAMADAS, de propósito:
|
|
# #visitas — uma linha por ida do motorista ao local. É o que o dashboard
|
|
# financeiro conta (Entrega.contar_atendidas) e o que se paga ao
|
|
# motorista.
|
|
# #linhas — uma linha por NOTA FISCAL (a última visita de cada uma). É o que
|
|
# o cliente paga, o que confere com os documentos físicos e com a
|
|
# aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui.
|
|
# Os dois números só coincidem quando nenhuma NF precisou de segunda ida; a
|
|
# diferença aparece na tela como "retentativas" em vez de ficar escondida.
|
|
#
|
|
# 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: {}, data: nil)
|
|
@tabelas = Operacao.sanitizar(Array(tabelas))
|
|
@inicio = inicio&.to_date
|
|
@fim = fim&.to_date
|
|
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
|
|
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
|
|
end
|
|
|
|
def operacoes_label
|
|
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
|
end
|
|
|
|
# VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma
|
|
# NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise
|
|
# operacional — quantas idas ao local foram necessárias.
|
|
def visitas
|
|
@visitas ||= begin
|
|
base = @filtros.empty? ? registros
|
|
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
|
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
|
end
|
|
end
|
|
|
|
# NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de
|
|
# TODOS os KPIs desta tela, porque é o que confere com os documentos físicos
|
|
# e com a aba ENTREGAS da planilha entregue ao cliente (Analytics::
|
|
# PlanilhaEntregas também usa "último status por reference_id").
|
|
#
|
|
# ⚠️ O dedup precisa acontecer AQUI — depois do período e do cross-filter — e
|
|
# não no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela
|
|
# INTEIRA, uma NF reentregue DEPOIS do fim do período perdia a visita que
|
|
# estava DENTRO dele e sumia da contagem do mês.
|
|
def linhas
|
|
@linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } }
|
|
end
|
|
|
|
# Campos pesquisáveis da tabela espelho (planilha da operação).
|
|
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
|
address nome_completo endereco_completo status_gade operacao].freeze
|
|
|
|
# Busca textual da tabela espelho: filtra as linhas (já pós cross-filter)
|
|
# por qualquer campo exibido na planilha.
|
|
def buscar(texto)
|
|
termo = texto.to_s.strip.downcase
|
|
return linhas if termo.empty?
|
|
linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } }
|
|
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
|
|
|
|
# NFs da operação cujo resultado caiu dentro de uma faixa de datas —
|
|
# REFERÊNCIA para o cabeçalho da tela de operação única, onde o total
|
|
# continua sendo a operação inteira (é o que confere com a planilha do
|
|
# cliente). Mesma regra do filtro SQL: concluídas/falhas pela data real
|
|
# (checkout), pendentes pela data planejada.
|
|
def total_no_periodo(inicio, fim)
|
|
return total unless inicio && fim
|
|
|
|
ini = inicio.to_date
|
|
fim = fim.to_date
|
|
linhas.count do |r|
|
|
d = data_de(r['checkout']) || data_de(r['planned_date'])
|
|
d && d >= ini && d <= fim
|
|
end
|
|
end
|
|
|
|
# ── Camada operacional (por VISITA, não por NF) ───────────────
|
|
# Os KPIs acima contam NOTAS porque é o que o cliente paga e confere. Estes
|
|
# contam IDAS AO LOCAL — é o que o motorista recebe e o que o dashboard
|
|
# financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF
|
|
# que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso
|
|
# e o insucesso sumia da tela.
|
|
def total_visitas
|
|
visitas.size
|
|
end
|
|
|
|
# Idas ao local além da primeira de cada NF.
|
|
def retentativas
|
|
total_visitas - total
|
|
end
|
|
|
|
def visitas_insucesso
|
|
visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
|
end
|
|
|
|
# NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a
|
|
# informação que o dedup escondia por completo.
|
|
def notas_reentregues
|
|
@notas_reentregues ||= por_nota.count do |_nf, vs|
|
|
vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } &&
|
|
vs.max_by { |r| ordem_visita(r) }['status'] == 'completed'
|
|
end
|
|
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 ATENDIDAS (sucesso + falhas) 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 Entrega::STATUS_ATENDIDO.include?(r['status'])
|
|
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
|
|
|
|
# Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares).
|
|
MAX_PONTOS = 2000
|
|
|
|
# Pontos detalhados (modo "Pontos" do mapa) — um por entrega ATENDIDA
|
|
# (sucesso OU insucesso: óbito e demais pilares de falha), com coordenada de
|
|
# CHECKOUT (ou planejada como fallback). Inclui dados para o popup (NF,
|
|
# destinatário, endereço, unidade, motorista, veículo, data, status, motivo).
|
|
def pontos_detalhados
|
|
@pontos_detalhados ||= linhas.filter_map do |r|
|
|
next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
|
|
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: lat, lng: lng,
|
|
nf: r['reference_id'],
|
|
nome: r['nome_completo'].presence || r['contact_name'].presence || '—',
|
|
endereco: r['endereco_completo'].presence || r['address'].presence,
|
|
unidade: r['contact_name'],
|
|
motorista: r['driver'],
|
|
veiculo: r['vehicle'],
|
|
checkout: data_de(r['checkout'])&.strftime('%d/%m/%Y'),
|
|
sucesso: r['status'] == 'completed',
|
|
motivo: r['observation'].presence,
|
|
foto: foto_url(r['foto_da_fachada'])
|
|
}
|
|
end.first(MAX_PONTOS)
|
|
end
|
|
|
|
private
|
|
|
|
# NF => visitas dela (já filtradas). Chave string: reference_id é numérico no
|
|
# espelho e texto na tabela da operação.
|
|
def por_nota
|
|
@por_nota ||= visitas.group_by { |r| r['reference_id'].to_s }
|
|
end
|
|
|
|
# Ordem de "última visita": quem tem checkout ganha de quem não tem e, entre
|
|
# as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a
|
|
# planilha do cliente usa (checkout DESC NULLS LAST).
|
|
def ordem_visita(registro)
|
|
t = tempo_de(registro['checkout'])
|
|
[t ? 1 : 0, t || Time.at(0)]
|
|
end
|
|
|
|
def tempo_de(valor)
|
|
return nil if valor.nil? || valor.to_s.strip.empty?
|
|
valor.to_time
|
|
rescue ArgumentError, TypeError, NoMethodError
|
|
nil
|
|
end
|
|
|
|
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
|
|
|
|
# Só aceita URL http(s) — evita injetar lixo no <img src> do popup.
|
|
def foto_url(valor)
|
|
url = valor.to_s.strip
|
|
url.match?(%r{\Ahttps?://}i) ? url : nil
|
|
end
|
|
|
|
def carregar
|
|
return [] if @tabelas.empty?
|
|
|
|
conn = ActiveRecord::Base.connection
|
|
|
|
rastreio = conn.quote_table_name(Entrega.table_name)
|
|
conta = Entrega.condicao_conta_sql('r')
|
|
|
|
unions = @tabelas.map do |tabela|
|
|
gade = conn.quote_table_name(tabela)
|
|
label = conn.quote(Operacao.label(tabela))
|
|
<<~SQL.strip
|
|
SELECT #{label} AS operacao,
|
|
r.tracking_id,
|
|
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
|
r.checkout, r.planned_date, r.foto_da_fachada,
|
|
r.latitude, r.longitude,
|
|
r.checkout_latitude, r.checkout_longitude,
|
|
#{selects_gade(conn, tabela)}
|
|
FROM #{rastreio} r
|
|
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
|
WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)}
|
|
SQL
|
|
end
|
|
|
|
# Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas,
|
|
# já depois do período e do cross-filter (ver o comentário lá).
|
|
#
|
|
# `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de
|
|
# uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN
|
|
# devolveria a MESMA visita mais de uma vez e inflaria a contagem.
|
|
conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] }
|
|
end
|
|
|
|
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
|
# ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
|
|
# o UNION entre operações continua consistente. (A única coluna garantida em
|
|
# toda tabela gade é nota_fiscal.) As chaves são uma whitelist fixa (não entram
|
|
# dados do usuário no SQL).
|
|
GADE_OPCIONAIS = { 'status' => 'status_gade', 'nome_completo' => 'nome_completo',
|
|
'endereco_completo' => 'endereco_completo' }.freeze
|
|
|
|
def selects_gade(conn, tabela)
|
|
colunas = conn.columns(tabela).map(&:name)
|
|
GADE_OPCIONAIS.map do |origem, apelido|
|
|
colunas.include?(origem) ? "g.#{origem} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
|
|
end.join(", ")
|
|
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
|