#!/usr/bin/env ruby # frozen_string_literal: true # # Lista, DIRETO NA API do SimpliRoute, todas as visitas de um motorista dentro # de um período. # # ⚠️ POR QUE É DIA A DIA (e não uma chamada só): # A API NÃO tem filtro de intervalo. Testado contra a API real em 21/07/2026 # (bin/sondar_busca_nf): `planned_date_from/to`, `planned_date_after/before`, # `__gte/__lte`, `since/until`, `start_date/end_date`, `date_from/to` — TODOS # ignorados em silêncio (200 + o conjunto padrão inteiro). E SEM `planned_date` # a API devolve um conjunto padrão (~2469 visitas) que NÃO cobre o histórico. # Logo: `planned_date` é obrigatório e o período vira N requisições, uma por dia, # disparadas em paralelo. # # ⚠️ Não filtramos motorista no servidor: `search=` só foi comprovado para NF, e # parâmetro que a API não registra é IGNORADO em silêncio (devolve o dia inteiro # com HTTP 200). Então baixamos o dia inteiro e filtramos AQUI — é lento # (~3,8 MB / ~9 s por dia) mas é a única forma correta. # # SÓ LEITURA: o script faz apenas GET. Não altera nada. # # Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN): # # SIMPLIROUTE_TOKEN=xxx bin/entregas_do_motorista \ # --motorista "JOSE DA SILVA" --de 2026-08-01 --ate 2026-08-14 # # ... --csv /tmp/entregas.csv grava o resultado em CSV # ... --listar-motoristas só mostra quem aparece no período (sem filtrar) # ... --campos mostra as chaves cruas de uma visita (diagnóstico) # # Alternativa MUITO mais barata quando não precisa do dado ao vivo: o espelho # local (model Entrega, tabela db_reem_simplerout_2026) já tem a coluna `driver` # e responde isso numa query só: # # Entrega.no_periodo('2026-08-01', '2026-08-14').do_motorista('JOSE DA SILVA') # # O espelho sincroniza depois da API, então só use este script quando precisar # do estado ATUAL ou desconfiar da sync. require 'net/http' require 'json' require 'uri' require 'date' require 'csv' BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com') TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s THREADS = 6 # mesmo lote usado no sondar_busca_nf; acima disso a API estrangula def sair(msg) warn msg exit 1 end sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty? motorista = nil de = nil ate = nil csv_path = nil listar = false campos = false ARGV.each_with_index do |a, i| case a when '--motorista' then motorista = ARGV[i + 1] when '--de' then de = ARGV[i + 1] when '--ate', '--até' then ate = ARGV[i + 1] when '--csv' then csv_path = ARGV[i + 1] when '--listar-motoristas' then listar = true when '--campos' then campos = true end end sair('Uso: bin/entregas_do_motorista --motorista "NOME" --de YYYY-MM-DD --ate YYYY-MM-DD') if de.to_s.empty? || ate.to_s.empty? sair('Informe --motorista, ou use --listar-motoristas.') if motorista.to_s.empty? && !listar && !campos inicio = begin Date.parse(de) rescue ArgumentError sair("Data inválida em --de: #{de}") end fim = begin Date.parse(ate) rescue ArgumentError sair("Data inválida em --ate: #{ate}") end sair('--de é depois de --ate.') if inicio > fim DIAS = (inicio..fim).to_a # Normaliza nome para comparar: sem acento, sem espaço duplo, maiúsculo. # O nome no SimpliRoute é digitado por gente — "José da Silva" e "JOSE DA SILVA" # são o mesmo motorista. def normalizar(nome) nome.to_s .unicode_normalize(:nfd).gsub(/\p{Mn}/, '') .upcase.gsub(/\s+/, ' ').strip end def requisicao(caminho) uri = URI.join(BASE, caminho) req = Net::HTTP::Get.new(uri) req['Authorization'] = "Token #{TOKEN}" req['Accept'] = 'application/json' res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 180) { |h| h.request(req) } return [res.code.to_i, nil] unless res.code.to_i == 200 [200, (res.body.to_s.empty? ? nil : JSON.parse(res.body))] rescue StandardError => e [0, "ERRO: #{e.class}: #{e.message}"] end # A resposta pode ser lista crua OU paginada ({count, next, results}). def itens(corpo) return corpo if corpo.is_a?(Array) return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results') [] end # Onde está o nome do motorista numa visita. A lista-por-data às vezes traz o # campo achatado ('driver'), às vezes só o id, às vezes um hash aninhado — por # isso procuramos em várias formas em vez de fixar uma. Se nada casar, o script # avisa e o --campos mostra o formato real. CHAVES_MOTORISTA = %w[driver driver_name route_driver driver_full_name].freeze def nome_do_motorista(visita) CHAVES_MOTORISTA.each do |k| v = visita[k] next if v.nil? || v == '' || v.is_a?(Numeric) return v if v.is_a?(String) return (v['name'] || v['full_name'] || [v['first_name'], v['last_name']].compact.join(' ')) if v.is_a?(Hash) end nil end puts "Base: #{BASE}" puts "Período: #{inicio.strftime('%d/%m/%Y')} a #{fim.strftime('%d/%m/%Y')} (#{DIAS.size} dia(s), #{THREADS} em paralelo)" puts "Motorista: #{motorista}" if motorista puts '(a API não filtra por intervalo nem por motorista — baixando dia a dia; pode levar ~1 min)' puts # ── Varredura dia a dia, em lotes ──────────────────────────────────────────── t0 = Time.now visitas = [] falhas = [] mutex = Mutex.new DIAS.each_slice(THREADS) do |lote| lote.map do |dia| Thread.new do cod, corpo = requisicao("/v1/routes/visits/?planned_date=#{dia.iso8601}") lista = cod == 200 ? itens(corpo) : [] mutex.synchronize do if cod == 200 # planned_date às vezes não volta na visita — garantimos o dia pedido. lista.each { |v| v['_dia'] = dia.iso8601 } visitas.concat(lista) print '.' else falhas << [dia, cod, corpo] print 'x' end end end end.each(&:join) end puts "\nBaixadas #{visitas.size} visita(s) em #{(Time.now - t0).round(1)}s" falhas.each { |d, c, e| warn " ⚠️ #{d}: HTTP #{c} #{e.inspect[0, 120]}" } sair('Nenhuma visita voltou — período sem operação ou token sem acesso.') if visitas.empty? # ── Diagnóstico do formato ─────────────────────────────────────────────────── if campos puts "\nChaves de uma visita crua:" puts visitas.first.keys.sort.inspect puts "\nExemplo (recortado):" puts JSON.pretty_generate(visitas.first)[0, 2000] exit 0 end com_nome = visitas.count { |v| nome_do_motorista(v) } if com_nome.zero? warn "\n⚠️ NENHUMA visita trouxe nome de motorista nas chaves #{CHAVES_MOTORISTA.inspect}." warn ' A lista-por-data pode estar devolvendo só o id da rota. Rode com --campos' warn ' para ver o formato real, ou use o espelho local (Entrega#driver).' exit 1 end warn "\n⚠️ #{visitas.size - com_nome} visita(s) sem motorista preenchido (ficam de fora do filtro)." if com_nome < visitas.size # ── Só listar quem apareceu ────────────────────────────────────────────────── if listar puts "\nMotoristas no período:" visitas.group_by { |v| nome_do_motorista(v) } .reject { |k, _| k.nil? } .sort_by { |_, vs| -vs.size } .each { |nome, vs| puts " #{vs.size.to_s.rjust(5)} #{nome}" } exit 0 end # ── Filtro pelo motorista ──────────────────────────────────────────────────── alvo = normalizar(motorista) dele = visitas.select { |v| normalizar(nome_do_motorista(v)) == alvo } if dele.empty? puts "\nNenhuma visita de #{motorista.inspect} no período." parecidos = visitas.map { |v| nome_do_motorista(v) }.compact.uniq .select { |n| normalizar(n).include?(alvo) || alvo.include?(normalizar(n)) } puts "Nomes parecidos que existem: #{parecidos.inspect}" if parecidos.any? puts 'Use --listar-motoristas para ver a grafia exata.' exit 0 end dele.sort_by! { |v| [v['_dia'].to_s, v['checkout_time'].to_s] } puts "\n#{'─' * 100}" puts "#{dele.size} visita(s) de #{motorista}" puts '─' * 100 printf("%-11s %-10s %-9s %-19s %-40s\n", 'DATA', 'NF', 'STATUS', 'CHECKOUT', 'DESTINO') dele.each do |v| printf("%-11s %-10s %-9s %-19s %-40s\n", Date.parse(v['_dia']).strftime('%d/%m/%Y'), v['reference'].to_s[0, 10], v['status'].to_s[0, 9], v['checkout_time'].to_s[0, 19].tr('T', ' '), v['title'].to_s[0, 40]) end puts '─' * 100 por_status = dele.group_by { |v| v['status'].to_s }.transform_values(&:size) puts "Total: #{dele.size} " + por_status.sort_by { |_, n| -n }.map { |s, n| "#{s}=#{n}" }.join(' ') atendidas = dele.count { |v| %w[completed failed].include?(v['status'].to_s) && v['checkout_time'].to_s != '' } puts "Atendidas (foi ao local, com checkout): #{atendidas}" puts "Dias com entrega: #{dele.map { |v| v['_dia'] }.uniq.size}" # ── CSV ────────────────────────────────────────────────────────────────────── if csv_path CSV.open(csv_path, 'w') do |csv| csv << ['Data', 'NF', 'Tracking', 'ID visita', 'Motorista', 'Status', 'Checkout', 'Título', 'Endereço', 'Motivo (UUID)', 'Comentário'] dele.each do |v| csv << [Date.parse(v['_dia']).strftime('%d/%m/%Y'), v['reference'], v['tracking_id'], v['id'], nome_do_motorista(v), v['status'], v['checkout_time'], v['title'], v['address'], v['checkout_observation'], v['checkout_comment']] end end puts "\nCSV gravado em #{csv_path}" end