# 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 # Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares). MAX_PONTOS = 2000 # Pontos detalhados (modo "Pontos" do mapa) — um por entrega CONCLUÍDA com # coordenada de CHECKOUT (onde a entrega foi registrada). Inclui dados para o # popup (NF, destinatário, endereço, unidade, motorista, data). def pontos_detalhados @pontos_detalhados ||= 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: 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'], checkout: data_de(r['checkout'])&.strftime('%d/%m/%Y'), foto: foto_url(r['foto_da_fachada']) } end.first(MAX_PONTOS) 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 # Só aceita URL http(s) — evita injetar lixo no 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 unions = @tabelas.map do |tabela| gade = conn.quote_table_name(tabela) label = conn.quote(Operacao.label(tabela)) <<~SQL.strip SELECT #{label} AS operacao, r.reference_id, r.driver, 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 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 # 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