# 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