Correção do relatório que é gerado para completo
This commit is contained in:
149
app/jobs/sincronizar_series_aparelho_job.rb
Normal file
149
app/jobs/sincronizar_series_aparelho_job.rb
Normal file
@@ -0,0 +1,149 @@
|
||||
# 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
|
||||
23
app/models/serie_aparelho.rb
Normal file
23
app/models/serie_aparelho.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# app/models/serie_aparelho.rb
|
||||
#
|
||||
# Número de série da base (aparelho) de UMA visita do SimpliRoute, buscado na
|
||||
# API pelo SincronizarSeriesAparelhoJob.
|
||||
#
|
||||
# Esta é uma tabela NOSSA (gravável) — existe porque o espelho read-only
|
||||
# db_reem_simplerout_2026 tem a coluna `numero_de_serie_da_base_aparelho` mas
|
||||
# nunca a preenche. Ver a migration para o histórico.
|
||||
class SerieAparelho < ApplicationRecord
|
||||
self.table_name = 'series_aparelho'
|
||||
|
||||
validates :tracking_id, presence: true, uniqueness: true
|
||||
validates :numero_serie, presence: true
|
||||
|
||||
# { tracking_id => numero_serie } das visitas informadas — uma consulta só,
|
||||
# para a planilha não fazer N+1 ao preencher a coluna.
|
||||
def self.por_tracking(tracking_ids)
|
||||
ids = Array(tracking_ids).compact.uniq
|
||||
return {} if ids.empty?
|
||||
|
||||
where(tracking_id: ids).pluck(:tracking_id, :numero_serie).to_h
|
||||
end
|
||||
end
|
||||
@@ -166,20 +166,24 @@ module Analytics
|
||||
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
|
||||
# como no dump original do SimpliRoute).
|
||||
def linhas_rastreio
|
||||
gade = conn.quote_table_name(@tabela)
|
||||
sql = <<~SQL
|
||||
SELECT r.*
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
||||
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
||||
ORDER BY r.reference_id, r.checkout NULLS LAST
|
||||
SQL
|
||||
conn.select_all(sql).to_a
|
||||
@linhas_rastreio ||= begin
|
||||
gade = conn.quote_table_name(@tabela)
|
||||
sql = <<~SQL
|
||||
SELECT r.*
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
||||
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
||||
ORDER BY r.reference_id, r.checkout NULLS LAST
|
||||
SQL
|
||||
completar_serie(conn.select_all(sql).to_a)
|
||||
end
|
||||
end
|
||||
|
||||
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
|
||||
def colunas_rastreio
|
||||
existentes = conn.columns(Entrega.table_name).map(&:name)
|
||||
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
||||
@colunas_rastreio ||= begin
|
||||
existentes = conn.columns(Entrega.table_name).map(&:name)
|
||||
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
||||
end
|
||||
end
|
||||
|
||||
# ── Aba RESUMO ───────────────────────────────────────────────
|
||||
@@ -224,6 +228,23 @@ module Analytics
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
|
||||
# O espelho tem a coluna do número de série mas o sync NUNCA a preenche
|
||||
# (0 de ~6.000 linhas em fev/mar/mai/jul/2026) e a tabela é read-only —
|
||||
# quem busca o dado na API é o SincronizarSeriesAparelhoJob, que grava em
|
||||
# series_aparelho. Aqui só completamos as linhas em que o espelho veio
|
||||
# vazio: se um dia o sync passar a preencher, o valor dele continua valendo.
|
||||
def completar_serie(linhas)
|
||||
coluna = colunas_rastreio['Número de Série da Base (Aparelho)']
|
||||
return linhas if coluna.nil?
|
||||
|
||||
pendentes = linhas.reject { |r| r[coluna].present? }
|
||||
return linhas if pendentes.empty?
|
||||
|
||||
series = SerieAparelho.por_tracking(pendentes.map { |r| r['tracking_id'] })
|
||||
pendentes.each { |r| r[coluna] = series[r['tracking_id']] if series[r['tracking_id']] }
|
||||
linhas
|
||||
end
|
||||
|
||||
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
|
||||
# O nome real pode diferir da chave (ver ALIAS_GADE) — o alias no SELECT é
|
||||
# sempre a chave, então quem lê a linha não precisa saber da diferença.
|
||||
|
||||
Reference in New Issue
Block a user