Imṕlantação do dash board de acompanhamento diario das entregas
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 698 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 478 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (2).png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (3).png
Normal file
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 2.5 MiB |
53
app/controllers/operacoes_dashboard_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# app/controllers/operacoes_dashboard_controller.rb
|
||||
#
|
||||
# Dashboard de análise de ENTREGAS por operação (separado do dashboard
|
||||
# financeiro). O operador escolhe uma operação (UBS Norte, EMAD, ...) e vê os
|
||||
# KPIs/insucessos/STS/mapa; também há os modos "global" (todas no período) e
|
||||
# "comparar" (uma operação vs outra). Os números vêm de Analytics::OperacaoMetricas.
|
||||
class OperacoesDashboardController < ApplicationController
|
||||
MODOS = %w[operacao global comparar].freeze
|
||||
|
||||
def index
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
|
||||
case @modo
|
||||
when 'global'
|
||||
@metricas = montar(@tabelas_validas)
|
||||
when 'comparar'
|
||||
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
||||
@op_b = Operacao.sanitizar([params[:op_b]]).first || @tabelas_validas[1]
|
||||
@metricas_a = montar([@op_a]) if @op_a
|
||||
@metricas_b = montar([@op_b]) if @op_b
|
||||
else
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao]) if @operacao
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def montar(tabelas)
|
||||
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: @periodo_inicio, fim: @periodo_fim)
|
||||
end
|
||||
|
||||
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.
|
||||
# Espelha DashboardController#periodo_selecionado.
|
||||
def periodo_selecionado
|
||||
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
|
||||
fim = parse_data(params[:fim]) || Date.current
|
||||
inicio, fim = fim, inicio if fim < inicio
|
||||
[inicio, fim]
|
||||
end
|
||||
|
||||
def parse_data(str)
|
||||
return nil if str.blank?
|
||||
Date.parse(str)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard)
|
||||
def index?
|
||||
user.admin? || user.gerente?
|
||||
end
|
||||
|
||||
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
|
||||
def operacoes?
|
||||
!user.motorista?
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
@@ -37,6 +37,7 @@
|
||||
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
|
||||
<% unless current_user.motorista? %>
|
||||
<%= nav_link_to '📊 Dashboard', dashboard_path %>
|
||||
<%= nav_link_to '📈 Operações', operacoes_dashboard_path %>
|
||||
<% end %>
|
||||
|
||||
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
|
||||
|
||||
15
app/views/operacoes_dashboard/_mapa.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<%# locals: metricas (Analytics::OperacaoMetricas) %>
|
||||
<% pontos = metricas.pontos_mapa %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-gray-400 text-sm font-medium">Mapa de Calor de Entregas</p>
|
||||
<span class="text-xs text-gray-500"><%= pontos.size %> pontos</span>
|
||||
</div>
|
||||
<% if pontos.empty? %>
|
||||
<p class="text-center py-8 text-gray-500 text-sm">Sem coordenadas para exibir no período.</p>
|
||||
<% else %>
|
||||
<div class="mapa-calor h-96 rounded-xl overflow-hidden"
|
||||
data-pontos="<%= pontos.to_json %>"
|
||||
data-centro="<%= metricas.centro_mapa.to_json %>"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
110
app/views/operacoes_dashboard/_painel.html.erb
Normal file
@@ -0,0 +1,110 @@
|
||||
<%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional) %>
|
||||
<% titulo = local_assigns[:titulo] %>
|
||||
<% ins = metricas.insucessos_pct %>
|
||||
<% pd = metricas.por_dia %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if titulo.present? %>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-white"><%= titulo %></h2>
|
||||
<span class="text-xs text-gray-500"><%= metricas.total %> entregas</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# ── KPIs ─────────────────────────────────────────────── %>
|
||||
<div class="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">📦 Total de Entregas</p>
|
||||
<p class="text-4xl font-black text-white"><%= metricas.total %></p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">✅ Total de Sucesso</p>
|
||||
<p class="text-4xl font-black text-green-400"><%= metricas.sucesso %></p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">❌ Total de Recusas</p>
|
||||
<p class="text-4xl font-black text-red-400"><%= metricas.recusas %></p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">⏳ Entregas Pendentes</p>
|
||||
<p class="text-4xl font-black text-yellow-400"><%= metricas.pendentes %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Insucessos % + Índices de falha + Status ─────────── %>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Insucessos %</p>
|
||||
<% if metricas.total.zero? %>
|
||||
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
|
||||
<% else %>
|
||||
<div class="relative h-56">
|
||||
<canvas class="dash-donut"
|
||||
data-completed="<%= ins[:completed] %>"
|
||||
data-failed="<%= ins[:failed] %>"></canvas>
|
||||
</div>
|
||||
<div class="flex justify-center gap-4 mt-3 text-xs">
|
||||
<span class="text-cyan-400">● Completas <%= ins[:pct_completed] %>%</span>
|
||||
<span class="text-indigo-400">● Falhas <%= ins[:pct_failed] %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Índices de Falha</p>
|
||||
<%= render 'tabela_simples',
|
||||
cabecalhos: ['Observação', 'Total', '%'],
|
||||
linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] },
|
||||
vazio: 'Nenhuma falha no período.' %>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Por Status</p>
|
||||
<%= render 'tabela_simples',
|
||||
cabecalhos: ['Status', 'Quantidade'],
|
||||
linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.total %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Motoristas + STS ─────────────────────────────────── %>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Notas fiscais por Motorista</p>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<%= render 'tabela_simples',
|
||||
cabecalhos: ['Motorista', 'Notas fiscais'],
|
||||
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.total %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por STS (Unidade)</p>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<%= render 'tabela_simples',
|
||||
cabecalhos: ['Unidade', 'NFs entregues'],
|
||||
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.sucesso %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Entregas por dia ─────────────────────────────────── %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por Dia</p>
|
||||
<% if pd[:labels].empty? %>
|
||||
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
|
||||
<% else %>
|
||||
<div class="relative h-72">
|
||||
<canvas class="dash-bars"
|
||||
data-labels="<%= pd[:labels].to_json %>"
|
||||
data-completed="<%= pd[:completed].to_json %>"
|
||||
data-failed="<%= pd[:failed].to_json %>"></canvas>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
32
app/views/operacoes_dashboard/_tabela_simples.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<%# locals: cabecalhos (Array), linhas (Array de Array), vazio (String), total (opcional) %>
|
||||
<% total_rodape = local_assigns[:total] %>
|
||||
<% if linhas.blank? %>
|
||||
<p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p>
|
||||
<% else %>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||
<% cabecalhos.each_with_index do |c, i| %>
|
||||
<th class="pb-2 font-medium <%= 'text-right' if i.positive? %>"><%= c %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% linhas.each do |celulas| %>
|
||||
<tr class="border-b border-white/5">
|
||||
<% celulas.each_with_index do |cel, i| %>
|
||||
<td class="py-2 <%= i.positive? ? 'text-right font-semibold text-white' : 'text-gray-300' %>"><%= cel %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<% if total_rodape %>
|
||||
<tfoot>
|
||||
<tr class="text-white font-bold">
|
||||
<td class="pt-2">Total</td>
|
||||
<td class="pt-2 text-right" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<% end %>
|
||||
</table>
|
||||
<% end %>
|
||||
286
app/views/operacoes_dashboard/index.html.erb
Normal file
@@ -0,0 +1,286 @@
|
||||
<%# app/views/operacoes_dashboard/index.html.erb %>
|
||||
<%# Dashboard de análise de entregas por operação (KPIs, insucessos, STS, mapa). %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<meta name="turbo-cache-control" content="no-cache">
|
||||
|
||||
<%# Flatpickr (date range) — CSS no <head> %>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
|
||||
<%# Leaflet (mapa de calor) %>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.flatpickr-calendar { background: #1a1a1a; border: 1px solid rgba(255,255,255,.1); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
|
||||
.flatpickr-months, .flatpickr-weekdays, .flatpickr-weekday { background: #1a1a1a; color: #9ca3af; }
|
||||
.flatpickr-current-month, .flatpickr-monthDropdown-months, .numInput { color: #fff; }
|
||||
.flatpickr-day { color: #e5e7eb; }
|
||||
.flatpickr-day:hover { background: #2a2a2a; border-color: #2a2a2a; }
|
||||
.flatpickr-day.today { border-color: #f97316; }
|
||||
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange,
|
||||
.flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover {
|
||||
background: #f97316; border-color: #f97316; color: #0a0a0a;
|
||||
}
|
||||
.flatpickr-day.inRange { background: rgba(249,115,22,.18); border-color: rgba(249,115,22,.18); box-shadow: -5px 0 0 rgba(249,115,22,.18), 5px 0 0 rgba(249,115,22,.18); }
|
||||
.flatpickr-months .flatpickr-prev-month svg, .flatpickr-months .flatpickr-next-month svg { fill: #f97316; }
|
||||
/* O mapa do Leaflet usa fundo claro — moldura escura ao redor */
|
||||
.leaflet-container { background: #1a1a1a; }
|
||||
</style>
|
||||
<% end %>
|
||||
|
||||
<% hoje = Date.current %>
|
||||
<% atalhos = {
|
||||
'Hoje' => [hoje, hoje],
|
||||
'7 dias' => [hoje - 6, hoje],
|
||||
'Este mês' => [hoje.beginning_of_month, hoje],
|
||||
'30 dias' => [hoje - 29, hoje]
|
||||
} %>
|
||||
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
|
||||
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
|
||||
<%# Parâmetros de operação a preservar ao trocar de modo/período %>
|
||||
<% ctx_ops = { operacao: @operacao, op_a: @op_a, op_b: @op_b }.compact %>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
<%# ── Cabeçalho ─────────────────────────────────────────── %>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Dashboard de Operações</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
<%= @periodo_inicio.strftime('%d/%m/%Y') %> – <%= @periodo_fim.strftime('%d/%m/%Y') %>
|
||||
· Atualizado às <%= Time.now.strftime('%H:%M') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# Filtro de período (flatpickr) + atalhos %>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
|
||||
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
|
||||
value="<%= @periodo_inicio.strftime('%d/%m/%Y') %> até <%= @periodo_fim.strftime('%d/%m/%Y') %>"
|
||||
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
|
||||
text-white text-sm w-full sm:w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<% atalhos.each do |label, (ini, fim)| %>
|
||||
<% ativo = @periodo_inicio == ini && @periodo_fim == fim %>
|
||||
<%= link_to label,
|
||||
operacoes_dashboard_path(ctx_ops.merge(modo: @modo, inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'))),
|
||||
data: { turbo: false },
|
||||
class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Abas de modo ──────────────────────────────────────── %>
|
||||
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3">
|
||||
<% modos.each do |m, label| %>
|
||||
<% ativo = @modo == m %>
|
||||
<%= link_to label,
|
||||
operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)),
|
||||
data: { turbo: false },
|
||||
class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @tabelas_validas.blank? %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
|
||||
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
|
||||
</div>
|
||||
<% else %>
|
||||
|
||||
<%# ── Seletor de operação por modo ───────────────────── %>
|
||||
<% if @modo == 'operacao' %>
|
||||
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="modo" value="operacao">
|
||||
<input type="hidden" name="inicio" value="<%= ini_iso %>">
|
||||
<input type="hidden" name="fim" value="<%= fim_iso %>">
|
||||
<label class="text-gray-400 text-sm">Operação:</label>
|
||||
<select name="operacao" class="auto-submit bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[240px]">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @operacao %>><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<% if @metricas %>
|
||||
<%= render 'painel', metricas: @metricas %>
|
||||
<%= render 'mapa', metricas: @metricas %>
|
||||
<% end %>
|
||||
|
||||
<% elsif @modo == 'global' %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
|
||||
🌐 Visão <strong class="text-white">Global</strong> — todas as
|
||||
<%= @tabelas_validas.size %> operações agregadas no período.
|
||||
</div>
|
||||
<% if @metricas %>
|
||||
<%= render 'painel', metricas: @metricas %>
|
||||
<%= render 'mapa', metricas: @metricas %>
|
||||
<% end %>
|
||||
|
||||
<% else # comparar %>
|
||||
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<input type="hidden" name="modo" value="comparar">
|
||||
<input type="hidden" name="inicio" value="<%= ini_iso %>">
|
||||
<input type="hidden" name="fim" value="<%= fim_iso %>">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-cyan-400 text-sm font-semibold">A:</label>
|
||||
<select name="op_a" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-cyan-500">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_a %>><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-indigo-400 text-sm font-semibold">B:</label>
|
||||
<select name="op_b" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-indigo-500">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_b %>><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<% if @metricas_a %>
|
||||
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm">Selecione a operação A.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<% if @metricas_b %>
|
||||
<%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm">Selecione a operação B.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const ISO = '<%= ini_iso %>';
|
||||
const ISO_FIM = '<%= fim_iso %>';
|
||||
|
||||
// ── Flatpickr (período) ──
|
||||
function initPeriodo() {
|
||||
const input = document.getElementById('periodo-range');
|
||||
if (!input || typeof flatpickr === 'undefined' || input._flatpickr) return;
|
||||
const toISO = (d) =>
|
||||
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
flatpickr(input, {
|
||||
mode: 'range',
|
||||
locale: Object.assign({}, flatpickr.l10ns.pt, { rangeSeparator: ' até ' }),
|
||||
dateFormat: 'd/m/Y',
|
||||
defaultDate: [parseISO(ISO), parseISO(ISO_FIM)],
|
||||
maxDate: 'today',
|
||||
onClose: function (selectedDates) {
|
||||
if (selectedDates.length === 0) return;
|
||||
const ini = toISO(selectedDates[0]);
|
||||
const fim = toISO(selectedDates[selectedDates.length - 1]);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('inicio', ini);
|
||||
url.searchParams.set('fim', fim);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Selects que submetem o form ao mudar ──
|
||||
function initAutoSubmit() {
|
||||
document.querySelectorAll('select.auto-submit').forEach(function (s) {
|
||||
if (s.dataset.bound) return;
|
||||
s.dataset.bound = '1';
|
||||
s.addEventListener('change', function () { s.form.requestSubmit(); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Charts (donut + barras) ──
|
||||
function initCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Completas', 'Falhas'],
|
||||
datasets: [{
|
||||
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
|
||||
backgroundColor: ['#06b6d4', '#6366f1'],
|
||||
borderColor: '#1a1a1a', borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { display: false } } }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) {
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: JSON.parse(ctx.dataset.labels),
|
||||
datasets: [
|
||||
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: '#06b6d4' },
|
||||
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: '#6366f1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#9ca3af' } } }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mapa de calor (Leaflet) ──
|
||||
function initMapas() {
|
||||
if (typeof L === 'undefined') return;
|
||||
document.querySelectorAll('.mapa-calor').forEach(function (el) {
|
||||
if (el._leaflet_id) return;
|
||||
const pontos = JSON.parse(el.dataset.pontos || '[]');
|
||||
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
|
||||
const mapa = L.map(el).setView(centro, 11);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap', maxZoom: 19
|
||||
}).addTo(mapa);
|
||||
if (pontos.length && L.heatLayer) {
|
||||
L.heatLayer(pontos, { radius: 18, blur: 22, maxZoom: 14 }).addTo(mapa);
|
||||
}
|
||||
setTimeout(function () { mapa.invalidateSize(); }, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function initTudo() { initPeriodo(); initAutoSubmit(); initCharts(); initMapas(); }
|
||||
initTudo();
|
||||
document.addEventListener('turbo:load', initTudo);
|
||||
})();
|
||||
</script>
|
||||
@@ -16,6 +16,9 @@ Rails.application.routes.draw do
|
||||
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
||||
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
||||
|
||||
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
|
||||
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
|
||||
|
||||
# Painel motorista
|
||||
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
|
||||
|
||||
|
||||
26
spec/requests/operacoes_dashboard_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Dashboard de Operações', type: :request do
|
||||
it 'redireciona usuário não autenticado para o login' do
|
||||
get operacoes_dashboard_path
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
|
||||
it 'bloqueia motorista (Pundit redireciona para a raiz)' do
|
||||
sign_in create(:motorista)
|
||||
get operacoes_dashboard_path
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
|
||||
it 'permite operador (renderiza a página)' do
|
||||
sign_in create(:operador)
|
||||
get operacoes_dashboard_path
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'permite gerente' do
|
||||
sign_in create(:gerente)
|
||||
get operacoes_dashboard_path(modo: 'global')
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
96
spec/services/analytics/operacao_metricas_spec.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Analytics::OperacaoMetricas do
|
||||
# Linhas simuladas (mesma forma do retorno de select_all) — evita depender das
|
||||
# tabelas externas no teste. Stubamos #registros para focar nas agregações.
|
||||
let(:rows) do
|
||||
[
|
||||
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'),
|
||||
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'),
|
||||
row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'),
|
||||
row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'),
|
||||
row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'),
|
||||
row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24')
|
||||
]
|
||||
end
|
||||
|
||||
subject(:metricas) do
|
||||
described_class.new(tabelas: [], inicio: Date.new(2026, 6, 1), fim: Date.new(2026, 6, 30))
|
||||
end
|
||||
|
||||
before do
|
||||
# Isola das tabelas externas: não toca o catálogo nem db_reem_simplerout_2026.
|
||||
allow(Operacao).to receive(:sanitizar).and_return([])
|
||||
allow(metricas).to receive(:registros).and_return(rows)
|
||||
end
|
||||
|
||||
it 'conta KPIs (total/sucesso/recusas/pendentes)' do
|
||||
expect(metricas.total).to eq(6)
|
||||
expect(metricas.sucesso).to eq(3)
|
||||
expect(metricas.recusas).to eq(2)
|
||||
expect(metricas.pendentes).to eq(1)
|
||||
end
|
||||
|
||||
it 'calcula insucessos %' do
|
||||
ins = metricas.insucessos_pct
|
||||
expect(ins[:completed]).to eq(3)
|
||||
expect(ins[:failed]).to eq(2)
|
||||
expect(ins[:pct_completed]).to eq(50.0)
|
||||
expect(ins[:pct_failed]).to eq(33.33)
|
||||
end
|
||||
|
||||
it 'agrupa índices de falha por observação' do
|
||||
expect(metricas.indices_falha).to eq([{ observation: 'ÓBITO', total: 2, pct: 100.0 }])
|
||||
end
|
||||
|
||||
it 'agrupa por status_gade' do
|
||||
expect(metricas.por_status_gade).to contain_exactly(
|
||||
{ status: 'RECORRENTE', total: 3 },
|
||||
{ status: 'NOVO', total: 3 }
|
||||
)
|
||||
end
|
||||
|
||||
it 'conta NFs por motorista (todas), desc' do
|
||||
expect(metricas.por_motorista.first).to eq({ nome: 'Carlos', total: 3 })
|
||||
expect(metricas.por_motorista.map { |h| h[:nome] }).to eq(%w[Carlos Pedro Marcos])
|
||||
end
|
||||
|
||||
it 'conta entregas concluídas por STS (contact_name)' do
|
||||
expect(metricas.por_sts).to eq([
|
||||
{ nome: 'STS PERUS', total: 2 },
|
||||
{ nome: 'STS PIRITUBA', total: 1 }
|
||||
])
|
||||
end
|
||||
|
||||
it 'agrupa por dia (completas x falhas)' do
|
||||
pd = metricas.por_dia
|
||||
expect(pd[:labels]).to eq(['22/06/2026', '23/06/2026'])
|
||||
expect(pd[:completed]).to eq([2, 1])
|
||||
expect(pd[:failed]).to eq([1, 1])
|
||||
end
|
||||
|
||||
it 'monta pontos do mapa (prefere checkout, cai p/ checkin)' do
|
||||
expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]])
|
||||
end
|
||||
|
||||
context 'sem registros' do
|
||||
before { allow(metricas).to receive(:registros).and_return([]) }
|
||||
|
||||
it 'não divide por zero' do
|
||||
expect(metricas.total).to eq(0)
|
||||
expect(metricas.insucessos_pct[:pct_failed]).to eq(0.0)
|
||||
expect(metricas.indices_falha).to eq([])
|
||||
expect(metricas.pontos_mapa).to eq([])
|
||||
expect(metricas.centro_mapa).to eq(described_class::SP_CENTRO)
|
||||
end
|
||||
end
|
||||
|
||||
def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil)
|
||||
{
|
||||
'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade,
|
||||
'observation' => obs, 'checkout' => checkout, 'planned_date' => planned,
|
||||
'latitude' => lat, 'longitude' => lng,
|
||||
'checkout_latitude' => clat, 'checkout_longitude' => clng
|
||||
}
|
||||
end
|
||||
end
|
||||