From e8f470f878b16b48f3799bc670fc0c64fbd5f56afea9b2633de1f72fed1d14da Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 11:35:59 -0300 Subject: [PATCH] =?UTF-8?q?Corre=C3=A7=C3=A3o=20do=20relat=C3=B3rio=20que?= =?UTF-8?q?=20=C3=A9=20gerado=20para=20completo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 44 +++++- app/jobs/sincronizar_series_aparelho_job.rb | 149 ++++++++++++++++++ app/models/serie_aparelho.rb | 23 +++ app/services/analytics/planilha_entregas.rb | 41 +++-- config/schedule.rb | 8 + .../20260722000001_create_series_aparelho.rb | 26 +++ lib/tasks/series_aparelho.rake | 42 +++++ 7 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 app/jobs/sincronizar_series_aparelho_job.rb create mode 100644 app/models/serie_aparelho.rb create mode 100644 db/migrate/20260722000001_create_series_aparelho.rb create mode 100644 lib/tasks/series_aparelho.rake diff --git a/README.md b/README.md index 945be02..f877f1d 100644 --- a/README.md +++ b/README.md @@ -1452,13 +1452,47 @@ feito manualmente para entregar ao cliente. ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em `002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice. +- **Links das fotos são hiperlinks clicáveis** (azul sublinhado): Nota Fiscal, Termo de + Recebimento, Foto da Fachada e Relatório de visita abrem o comprovante direto do Excel. + Qualquer célula que seja URL `http(s)` vira link (`#linkar_urls`). - Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário, caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`. -- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com - fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro - download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2 - **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle - manual (sai "A iniciar"). + +#### Nomes de coluna — conferidos no banco real (22/07/2026) +Os nomes eram **deduzidos** e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes +reais foram conferidos com `Entrega.column_names` e estão como 1º candidato em +`RASTREIO_COLUNAS`; os chutes antigos continuam na lista como rede de segurança. +- Espelho usa o padrão do export pt-BR: `codrivers`, `responsible_person`, `advance`, + `start_of_time_window_1..2`, `end_of_time_window_1..2`, `required_skills`, `optional_skills`, + `comments`. Corrigidos — antes eram `copilots`, `receiver`, `early`, `window_start`, etc. +- Tabelas `gade_entregas_*` gravam a geo como **`lat`/`long`** (não `latitude`/`longitude`) — + as duas colunas da aba ENTREGAS saíam vazias. Ver `ALIAS_GADE`. +- **`Load 4` não existe** no espelho (só `load`, `load_2`, `load_3`) — sai vazia de propósito. +- Coluna **`protocolo_de_entrega`** existe no espelho e não estava em lugar nenhum: entrou como + última coluna (AU), **depois** do layout A–AT do modelo, para não deslocar nada do cliente. +- ⚠️ Vazias por **falta de dado na origem**, não por bug: `contact_phone`, `account_id`, + `account_name` (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute + TEM o valor, conferido na NF 79093) e `codrivers`/`required_skills`/`optional_skills`/ + `contact_email`/`load_2`/`load_3` (vazios também no relatório baixado direto do SimpliRoute). +- ⚠️ Os 2 **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é + controle manual (sai "A iniciar"). + +#### 🔄 Número de série da base (aparelho) — job em background +A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho **nunca** traz (0 de +~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da `gade_entregas_*`: os seriais +de `num_serie_base` são de NFs **disjuntas** das que têm série no SimpliRoute (0 de 203 batem). +O dado só existe na API, em `extra_field_values` — o mesmo hash das fotos do motorista. +- `SincronizarSeriesAparelhoJob` varre a API por DATA (a API não filtra intervalo; cada dia são + ~4 MB / ~9 s) em lotes de 6 threads e grava em **`series_aparelho`** (tabela nossa — o espelho + é read-only). Um dia que não responde vira aviso, não derruba a rodada. +- `Analytics::PlanilhaEntregas#completar_serie` completa **só** as linhas em que o espelho veio + vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando. +- Agendado em `config/schedule.rb` às **01h** (últimos 45 dias). Backfill de meses antigos: + `rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"` ou + `rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"`. +- Se a rodada varrer visitas e não achar **nenhuma** série, o log lista as chaves de + `extra_field_values` que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo + em `CAMPOS_SERIE` (foi chute de nome de campo que causou o bug acima). ### 📊 Dashboard financeiro - **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com diff --git a/app/jobs/sincronizar_series_aparelho_job.rb b/app/jobs/sincronizar_series_aparelho_job.rb new file mode 100644 index 0000000..ceb3c35 --- /dev/null +++ b/app/jobs/sincronizar_series_aparelho_job.rb @@ -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 diff --git a/app/models/serie_aparelho.rb b/app/models/serie_aparelho.rb new file mode 100644 index 0000000..98ba212 --- /dev/null +++ b/app/models/serie_aparelho.rb @@ -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 diff --git a/app/services/analytics/planilha_entregas.rb b/app/services/analytics/planilha_entregas.rb index ef2f979..9b10915 100644 --- a/app/services/analytics/planilha_entregas.rb +++ b/app/services/analytics/planilha_entregas.rb @@ -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. diff --git a/config/schedule.rb b/config/schedule.rb index 426c4c3..67a603a 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -11,3 +11,11 @@ set :environment, ENV.fetch("RAILS_ENV", "production") every "*/30 8-18 * * *" do rake "historico:atualizar" end + +# 01h — busca na API do SimpliRoute o número de série da base (aparelho) das +# visitas dos últimos 45 dias. É o único campo da planilha do cliente que o +# espelho não traz, e a API cobra ~9 s por DIA varrido: fora do horário +# comercial não atrapalha ninguém. Ver SincronizarSeriesAparelhoJob. +every "0 1 * * *" do + rake "simpli_route:series" +end diff --git a/db/migrate/20260722000001_create_series_aparelho.rb b/db/migrate/20260722000001_create_series_aparelho.rb new file mode 100644 index 0000000..d311d7d --- /dev/null +++ b/db/migrate/20260722000001_create_series_aparelho.rb @@ -0,0 +1,26 @@ +# Guarda o "Número de Série da Base (Aparelho)" que o motorista registra no +# checkout do SimpliRoute. +# +# POR QUE UMA TABELA NOSSA: o campo existe como coluna no espelho +# (db_reem_simplerout_2026.numero_de_serie_da_base_aparelho), mas o processo que +# alimenta o espelho NUNCA o preenche — conferido em fev/mar/mai/jul/2026, 0 de +# ~6.000 linhas. O espelho é READ-ONLY (jamais escrever nele), então o job que +# busca o dado na API do SimpliRoute grava aqui, e a planilha do cliente faz o +# LEFT JOIN por tracking_id. +class CreateSeriesAparelho < ActiveRecord::Migration[7.1] + def change + create_table :series_aparelho do |t| + # tracking_id (SR...) é a chave do espelho — é por ele que a planilha casa. + t.string :tracking_id, null: false + t.string :reference_id # NF, só para conferência/depuração + t.string :numero_serie, null: false + t.date :planned_date # data varrida na API + t.datetime :sincronizado_em, null: false + + t.timestamps + end + + add_index :series_aparelho, :tracking_id, unique: true + add_index :series_aparelho, :planned_date + end +end diff --git a/lib/tasks/series_aparelho.rake b/lib/tasks/series_aparelho.rake new file mode 100644 index 0000000..1abcdfc --- /dev/null +++ b/lib/tasks/series_aparelho.rake @@ -0,0 +1,42 @@ +# lib/tasks/series_aparelho.rake +# +# Sincroniza o "Número de Série da Base (Aparelho)" da API do SimpliRoute para a +# tabela series_aparelho (ver SincronizarSeriesAparelhoJob). + +# Log padrão das tarefas do projeto (ver historico.rake): uma linha de início, +# uma de resultado, e o erro sobe para o cron registrar. É lambda (e não método) +# para não pendurar um `executar` global no Object. +executar = lambda do |&bloco| + puts "[#{Time.current}] Sincronizando número de série da base (SimpliRoute)..." + r = bloco.call + puts "[#{Time.current}] OK — #{r[:datas]} data(s) varrida(s), #{r[:visitas]} visita(s), #{r[:series]} série(s) gravada(s)." + puts "[#{Time.current}] AVISO — #{r[:falhas].size} data(s) sem resposta da API: #{r[:falhas].map { |f| f[:data] }.join(', ')}" if r[:falhas].present? +rescue StandardError => e + puts "[#{Time.current}] ERRO: #{e.message}" + Rails.logger.error("[simpli_route:series] #{e.message}\n#{e.backtrace.first(5).join("\n")}") + raise +end + +namespace :simpli_route do + desc 'Sincroniza o número de série da base dos últimos 45 dias (cron diário)' + task series: :environment do + executar.call { SincronizarSeriesAparelhoJob.perform_now } + end + + desc 'Backfill de UMA operação inteira — rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"' + task :series_operacao, [:tabela] => :environment do |_t, args| + tabela = args[:tabela].to_s + if Operacao.valida?(tabela) + puts "[#{Time.current}] Backfill da operação #{tabela}..." + executar.call { SincronizarSeriesAparelhoJob.perform_now(operacao: tabela) } + else + puts "[#{Time.current}] ERRO: operação inválida (#{tabela.inspect})." + puts "Válidas: #{Operacao.nomes_validos.join(', ')}" + end + end + + desc 'Backfill por período — rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"' + task :series_periodo, %i[de ate] => :environment do |_t, args| + executar.call { SincronizarSeriesAparelhoJob.perform_now(de: Date.parse(args[:de]), ate: Date.parse(args[:ate])) } + end +end