# app/services/simpli_route/client.rb # # Cliente HTTP da API do SimpliRoute (base de ESCRITA). A tabela de rastreio # local (model Entrega) é só leitura e sincroniza a partir daqui; para corrigir # um lançamento o ADM grava direto por este cliente. # # Usa Net::HTTP (stdlib) — o projeto não tem Faraday/HTTParty. # require 'net/http' require 'json' module SimpliRoute class Error < StandardError; end # Erro esperado/apresentável ao usuário (NF não achada, ambiguidade, etc.). class NotFound < Error; end class Client # Motivos de insucesso são fixos (todos type=failed). Cache curto porque # muda raríssimo, mas evita bater na API a cada abertura de tela. OBSERVATIONS_CACHE_KEY = 'simpliroute/observations'.freeze OBSERVATIONS_TTL = 1.hour def initialize(token: SimpliRoute.token, base_url: SimpliRoute.base_url) raise Error, 'SIMPLIROUTE_TOKEN não configurado no ambiente.' if token.blank? @token = token @base_uri = URI.parse(base_url) end # Lista de motivos [{ 'id' => uuid, 'type' => 'failed', 'label' => 'ÓBITO' }, ...] def observations Rails.cache.fetch(OBSERVATIONS_CACHE_KEY, expires_in: OBSERVATIONS_TTL) do Array(get('/v1/routes/observations/')) end end # Visita única (hash) pelo id numérico. def visita(id) get("/v1/routes/visits/#{id}/") end # Todas as visitas de uma data (Array de hashes). # # `busca` vira `&search=` — parâmetro NÃO documentado, mas verificado contra # a API real em 21/07/2026: filtra de verdade e derruba a resposta de um dia # de ~3,8 MB / ~9 s (1879 visitas) para ~1 KB / ~0,6 s. Sem ele, varrer um # mês é inviável. # # ⚠️ NÃO confiar só nele: `reference`, `reference_id`, `q` e `title` são # ignorados em SILÊNCIO pela API (devolvem 200 com o dia inteiro). Se o # `search` mudar de nome, cai no mesmo comportamento — por isso quem chama # continua filtrando por `reference`, e a perda é só de desempenho. # # ⚠️ SEM `planned_date` a API responde um conjunto padrão (~2469 visitas, # medido) que NÃO cobre o histórico: a NF 82891 voltava só com a visita de # 21/07, escondendo a de 17/07. Todo filtro de intervalo testado # (planned_date_from/to, since/until, __gte/__lte, date_from/to) é ignorado. # Por isso a data continua obrigatória e a varredura é dia a dia. def visitas_da_data(data, busca: nil) caminho = "/v1/routes/visits/?planned_date=#{data.to_date.iso8601}" caminho += "&search=#{URI.encode_www_form_component(busca.to_s)}" if busca.present? Array(get(caminho)) end # Rotas PLANEJADAS da data (Array de hashes). É aqui que costuma morar o par # veículo/motorista, que a visita talvez não carregue — e o romaneio precisa do # veículo para saber quantas folhas imprimir. # # ⚠️ NÃO VERIFICADO contra a API real: não há token na máquina de # desenvolvimento. Por isso é best-effort (endpoint inexistente devolve [], não # explode) e por isso Romaneios::PlanoDoDia tem uma escada de queda até o # espelho local. Rode `bin/sondar_plano_do_dia` no servidor antes de confiar # nesta chamada — e anote o resultado no cabeçalho daquele script, como foi # feito em bin/sondar_busca_nf. def rotas_da_data(data) Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}")) rescue Error [] end # Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega # do espelho local, que só tem tracking_id (SR...) + reference_id (NF) + # planned_date. Casa pelo tracking_id; se não achar, tenta pela NF. # Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id). # # ⚠️ Ambiguidade por NF é REAL (plano duplicado gera duas visitas com a mesma # NF). Quem precisa lidar com isso — a tela de edição — usa # `visitas_da_data` + `casa_entrega?` e deixa o ADM escolher; este método # continua servindo os fluxos que exigem resposta única. def resolver_id(entrega) data = entrega.planned_date&.to_date raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank? visitas = visitas_da_data(data) if entrega.tracking_id.present? achada = visitas.find { |v| v['tracking_id'].to_s == entrega.tracking_id.to_s } return achada['id'] if achada end nf = entrega.reference_id.to_s por_nf = visitas.select { |v| v['reference'].to_s == nf } raise NotFound, "Visita da NF #{nf} não encontrada na API na data #{data.strftime('%d/%m/%Y')}." if por_nf.empty? if por_nf.size > 1 raise Error, "Mais de uma visita para a NF #{nf} em #{data.strftime('%d/%m/%Y')} — não é possível resolver com segurança." end por_nf.first['id'] end # Atualiza campos da visita (PATCH — só o que vier em `attrs`). Retorna a # visita atualizada (hash). Preferimos PATCH ao checkout para não sobrescrever # assinatura/geo originais sem intenção. def atualizar_visita(id, attrs) patch("/v1/routes/visits/#{id}/", attrs) end # Histórico da visita (best-effort — a API pode devolver []). def historico(id) Array(get("/v1/routes/visits/#{id}/history/")) rescue Error [] end # Detalhe do comprovante de entrega (POD): traz foto, assinatura, hora e GPS. # É uma fonte MAIS COMPLETA de imagens que `visita(id)` — a visita crua às # vezes vem com `pictures` vazio mesmo havendo foto registrada no checkout. # Best-effort: se o endpoint não existir/responder, devolve {} e a tela segue # com o que a visita tiver. def detalhe_visita(id) get("/v1/plans/visits/#{id}/detail/") || {} rescue Error {} end private def get(path) requisicao(Net::HTTP::Get.new(caminho(path))) end def patch(path, body) req = Net::HTTP::Patch.new(caminho(path)) req.body = body.to_json requisicao(req) end def caminho(path) URI.join(@base_uri.to_s, path) end def requisicao(req) req['Authorization'] = "Token #{@token}" req['Content-Type'] = 'application/json' req['Accept'] = 'application/json' res = Net::HTTP.start(@base_uri.host, @base_uri.port, use_ssl: @base_uri.scheme == 'https', open_timeout: 10, read_timeout: 30) do |http| http.request(req) end unless res.is_a?(Net::HTTPSuccess) raise Error, "SimpliRoute respondeu #{res.code}: #{res.body.to_s.truncate(300)}" end res.body.present? ? JSON.parse(res.body) : nil rescue JSON::ParserError => e raise Error, "Resposta inválida do SimpliRoute: #{e.message}" rescue Net::OpenTimeout, Net::ReadTimeout raise Error, 'Tempo esgotado ao falar com o SimpliRoute. Tente de novo.' end end end