# 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 # UM plano pelo uuid. Existe separado de `planos` porque a busca do plano # ESCOLHIDO não pode depender de recorte nenhum: o operador pode ter filtrado # por período e escolhido um plano fora dos mais recentes, e procurá-lo numa # lista cortada devolveria "plano não encontrado" para um plano que existe. def plano(id) Array(get('/v1/routes/plans/')).find { |pl| pl['id'].to_s == id.to_s } rescue Error nil end # UMA rota pelo uuid. Serve para descobrir o DIA REAL de um plano: o registro # do plano só traz a JANELA (`start_date`/`end_date`), e o dia das rotas nem # sempre é o começo dela — medido em 28/08/2026: EMAD SETEMBRO 2026 (31/08 a # 08/09) tem rotas em 31/08 (= início), mas UBS OESTE AGOSTO 2026 (18/08 a # 20/08) e UBS NORTE AGOSTO 2026 (17/08 a 20/08) têm rotas em 20/08 (= FIM). # Era isto que tornava a data impossível de adivinhar de fora; agora se # pergunta à API. Todas as rotas de um plano dividem o mesmo dia (amostra de # 8 em 3 planos), então basta a primeira. def rota(id) get("/v1/routes/routes/#{id}/") rescue Error nil end # Rotas PLANEJADAS da data (Array de hashes). É aqui que mora o par # veículo/motorista, e o romaneio precisa do veículo para saber quantas folhas # imprimir. # # ✔ VERIFICADO em 28/08/2026 contra a API real: `?planned_date=2026-08-31` # devolve 73 rotas (83 KB, 0,59 s), cada uma com `vehicle` (id numérico), # `plan` (uuid) e `total_visits`. ⚠️ O parâmetro é OBRIGATÓRIO: sem ele a rota # responde 200 com **lista vazia**, não com tudo — então "veio vazio" aqui # pode ser data errada, nunca "a conta não tem rotas". A rota NÃO traz array # de visitas (`visits` não existe); quem liga visita→rota é o campo `route` # da própria visita. def rotas_da_data(data) Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}")) rescue Error [] end # Os N planos mais recentes da conta — nome, período e as rotas de cada um. # # ⚠️ A CONCLUSÃO ANTIGA ("a API não expõe listagem de planos, só dá para # buscar por data") ESTAVA ERRADA. `/v1/routes/plans/` existe, não é # documentado, e devolve TUDO com o nome que a operação usa — "EMAD SETEMBRO # 2026", "UBS OESTE AGOSTO 2026". Verificado em 28/08/2026 contra a API real: # 138 planos, 185 KB, 0,86 s. Ver bin/sondar_planos. # # POR QUE ORDENAR E CORTAR AQUI, e não na URL: a API IGNORA todo filtro nesta # rota. `ordering`, `limit`, `page`, `page_size`, `search`, `name`, # `start_date__gte` — os dez candidatos testados devolveram os mesmos 138 # itens do controle sem parâmetro. Então não existe "pedir 5": pede-se tudo # (uma chamada, menos de 1 s) e corta-se em Ruby. # # `created` é o critério: `start_date` de um plano é o PRIMEIRO dia da janela # (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09), e é justamente essa janela que # tornava impossível adivinhar "a data do plano". Quem publicou por último é # quem o operador quer — ele abre a ferramenta logo depois de lançar. # # COM PERÍODO (`inicio`/`fim`) o corte muda de propósito: em vez dos N mais # recentes, devolve TODOS os planos da faixa — é assim que se alcança um # plano antigo sem saber o dia dele, e é o mesmo recorte que a tela do # SimpliRoute usa. O critério é a JANELA DO PLANO CRUZAR o período # (`start_date <= fim AND end_date >= inicio`), a mesma semântica de # `Consolidacao.cruzando_periodo`: um plano de 31/08 a 08/09 pertence a # agosto E a setembro, e some da lista se a regra for "está dentro". def planos(limite: 5, inicio: nil, fim: nil) lista = Array(get('/v1/routes/plans/')).sort_by { |p| p['created'].to_s }.reverse return lista.first(limite) if inicio.blank? || fim.blank? i = inicio.to_date f = fim.to_date lista.select { |p| cruza_periodo?(p, i, f) } rescue Error, Date::Error [] end # Veículos da conta, para traduzir o id numérico que vem nas rotas # (`vehicle: 630011`) no nome que a operação usa (`GADE_038`). # # Best-effort pelo mesmo motivo de `rotas_da_data`: sem token na máquina de # desenvolvimento não dá para verificar o caminho, então endpoint ausente # devolve [] e Romaneios::PlanoDoDia segue com o id em vez de quebrar a # importação inteira. def veiculos Array(get('/v1/routes/vehicles/')) 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 # Plano sem data em um dos lados não some da lista por isso: usa a que tem. # Sumir em silêncio é o pior resultado possível numa lista de escolha. def cruza_periodo?(plano, inicio, fim) comeco = Date.parse(plano['start_date'].to_s) rescue nil termino = Date.parse(plano['end_date'].to_s) rescue nil comeco ||= termino termino ||= comeco return false if comeco.nil? comeco <= fim && termino >= inicio end 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