# 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 # ── 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 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