Files
Reem-Notas/app/services/simpli_route/client.rb

189 lines
7.3 KiB
Ruby

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