# app/jobs/sincronizar_series_aparelho_job.rb # # Busca na API do SimpliRoute o "Número de Série da Base (Aparelho)" das visitas # e grava em series_aparelho, para a planilha Entregas do cliente sair com essa # coluna preenchida. # # POR QUE EM BACKGROUND: o dado só existe na API (o espelho tem a coluna e nunca # a preenche — 0 de ~6.000 linhas em fev/mar/mai/jul/2026). A API não filtra por # intervalo: é uma chamada POR DATA, cada uma com o dia inteiro (~4 MB / ~9 s). # Um mês são ~25 chamadas — inviável dentro do request do download, tranquilo # num job noturno. # # Roda via Whenever → `rake simpli_route:series` (últimos DIAS_PADRAO dias). # Para meses antigos, o backfill é manual: `rake simpli_route:series_operacao[tabela]`. class SincronizarSeriesAparelhoJob < ApplicationJob queue_as :default # Mesmo limite da varredura do Admin::EdicaoLancamentosController — a API do # SimpliRoute é lenta por dia, mas aguenta alguns dias em paralelo. MAX_PARALELO = 6 # Janela padrão da rodada agendada. Cobre o mês corrente inteiro com folga, # que é o que o cliente pede em planilha no dia a dia. DIAS_PADRAO = 45 # Nomes conhecidos do campo em `extra_field_values`. O 1º que vier preenchido # ganha; se nenhum casar, cai no fallback por /seri/ (ver #serie_de) — foi # chutar nome de campo que deixou meia planilha vazia antes. CAMPOS_SERIE = %w[ numero_de_serie_da_base_aparelho numero_de_serie_da_base numero_serie_base num_serie_base serie_base numero_de_serie serie ].freeze # Devolve um resumo { datas:, visitas:, series:, falhas: } para o rake logar. def perform(de: nil, ate: nil, operacao: nil, dias: DIAS_PADRAO) return resumo_vazio('SIMPLIROUTE_TOKEN não configurado') unless SimpliRoute.configurado? datas = datas_alvo(de: de, ate: ate, operacao: operacao, dias: dias) return resumo_vazio('nenhuma data com visitas no período') if datas.empty? visitas = varrer(datas) gravadas = gravar(visitas) avisar_se_nada_casou(gravadas, visitas) { datas: datas.size, visitas: visitas.size, series: gravadas, falhas: @falhas } end private # Datas que valem uma chamada à API. Sempre saem do espelho (planned_date das # visitas que já conhecemos) — varrer dia sem visita é chamada jogada fora. def datas_alvo(de:, ate:, operacao:, dias:) escopo = Entrega.where.not(planned_date: nil) escopo = escopo.da_operacoes([operacao]) if operacao.present? inicio, fim = if de.present? && ate.present? [de.to_date, ate.to_date] elsif operacao.present? [nil, nil] # operação inteira, sem recorte de data else [Date.current - dias.to_i, Date.current] end escopo = escopo.no_periodo(inicio, fim) if inicio && fim escopo.distinct.pluck(Arel.sql('planned_date::date')).compact.sort end # Um dia que a API não responde não pode derrubar a rodada: registra e segue # (mesma postura da varredura de NF do Admin::EdicaoLancamentosController). def varrer(datas) @falhas = [] mutex = Mutex.new datas.each_slice(MAX_PARALELO).flat_map do |lote| lote.map { |data| Thread.new { visitas_do_dia(data, mutex) } }.flat_map(&:value) end end def visitas_do_dia(data, mutex) client.visitas_da_data(data) rescue SimpliRoute::Error => e mutex.synchronize { @falhas << { data: data.to_s, erro: e.message } } [] end # Grava só as visitas que TÊM série. upsert_all em vez de N inserts: uma # operação de um mês passa de 1.000 visitas. def gravar(visitas) agora = Time.current registros = visitas.filter_map do |visita| serie = serie_de(visita) next if serie.blank? || visita['tracking_id'].blank? { tracking_id: visita['tracking_id'], reference_id: visita['reference'].to_s.presence, numero_serie: serie, planned_date: visita['planned_date'], sincronizado_em: agora } end # A mesma visita pode vir em duas datas varridas; upsert_all rejeita chave # repetida no MESMO lote, então deduplica antes (fica a última). registros = registros.index_by { |r| r[:tracking_id] }.values return 0 if registros.empty? SerieAparelho.upsert_all(registros, unique_by: :tracking_id, record_timestamps: true) registros.size end # A série fica em `extra_field_values`, o mesmo hash das fotos do motorista. # Ignora valor que seja URL: ali dentro convivem os campos foto_*. def serie_de(visita) extras = visita['extra_field_values'] return nil unless extras.is_a?(Hash) chave = CAMPOS_SERIE.find { |c| texto_util?(extras[c]) } || extras.keys.find { |k| k.to_s.match?(/seri/i) && texto_util?(extras[k]) } chave && extras[chave].to_s.strip end def texto_util?(valor) v = valor.to_s.strip v.present? && !v.match?(%r{\Ahttps?://}i) end # Varreu visitas e não achou UMA série? Quase certo que o campo mudou de nome # na API. Loga as chaves que vieram para não virar falha silenciosa de novo. def avisar_se_nada_casou(gravadas, visitas) return if gravadas.positive? || visitas.empty? chaves = visitas.filter_map { |v| v['extra_field_values'] } .select { |e| e.is_a?(Hash) }.flat_map(&:keys).uniq.sort Rails.logger.warn( "[series_aparelho] #{visitas.size} visitas varridas e nenhuma série encontrada. " \ "Campos vistos em extra_field_values: #{chaves.inspect}. " \ 'Se o campo da série estiver nessa lista, acrescente o nome em CAMPOS_SERIE.' ) end def client @client ||= SimpliRoute::Client.new end def resumo_vazio(motivo) Rails.logger.info("[series_aparelho] nada a fazer — #{motivo}.") { datas: 0, visitas: 0, series: 0, falhas: [], motivo: motivo } end end