From 34aa563934367d1770180eb0a3ac9e736fcdb22ee2d0c9d678ae6b78103b78da Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 21 Jul 2026 11:36:29 -0300 Subject: [PATCH 01/10] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20do=20fun=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20editar=20lan=C3=A7amento=20para=20quer=20consiga?= =?UTF-8?q?=20listar=20todas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/edicao_lancamentos_controller.rb | 167 ++++++++++++++---- app/services/simpli_route/client.rb | 5 + .../admin/edicao_lancamentos/show.html.erb | 119 ++++++++++++- config/routes.rb | 3 +- 4 files changed, 257 insertions(+), 37 deletions(-) diff --git a/app/controllers/admin/edicao_lancamentos_controller.rb b/app/controllers/admin/edicao_lancamentos_controller.rb index 8cefe0e..1135c16 100644 --- a/app/controllers/admin/edicao_lancamentos_controller.rb +++ b/app/controllers/admin/edicao_lancamentos_controller.rb @@ -22,45 +22,34 @@ class Admin::EdicaoLancamentosController < ApplicationController @motivos = motivos_seguros end - # GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com o estado atual. + # GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos + # daquela NF. + # + # ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no + # SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a + # antiga — a que tem o motorista/checkout — continua existindo. Por isso a + # busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1 + # lançamento" escondia justamente o lançamento antigo. + # + # Com um único lançamento já vem o card montado, para não custar um segundo + # round-trip no caso comum. def buscar authorize :edicao_lancamento nf = params[:nf].to_s.strip return render_erro('Informe o número da NF.') if nf.blank? - entrega = Entrega.por_nf(nf).first - return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil? + entregas = Entrega.por_nf(nf).to_a + return render_erro("NF #{nf} não encontrada na base de rastreio.") if entregas.empty? - id = client.resolver_id(entrega) - visita = client.visita(id) - # Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}. - detalhe = client.detalhe_visita(id) + ocorrencias = ocorrencias_da_nf(nf, entregas) + unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present? render json: { - ok: true, - visita: { - id: id, - nf: visita['reference'], - titulo: visita['title'], - endereco: visita['address'], - status: visita['status'], - checkout_observation: visita['checkout_observation'], - checkout_comment: visita['checkout_comment'], - notes: visita['notes'], - checkout_time: visita['checkout_time'], - checkout_latitude: visita['checkout_latitude'], - checkout_longitude: visita['checkout_longitude'], - planned_date: visita['planned_date'], - contato: visita['contact_name'], - telefone: visita['contact_phone'], - # Galeria unificada — TODAS as fotos do lançamento, de todas as fontes, - # cada uma etiquetada para o ADM saber o que está conferindo. - fotos: fotos_do_lancamento(entrega, visita, detalhe), - motorista: entrega.driver, - veiculo: entrega.vehicle - }, - motivos: motivos_seguros + ok: true, + ocorrencias: ocorrencias, + visita: (unica && payload_visita(unica[:id], entrega_de(entregas, unica[:tracking_id]))), + motivos: motivos_seguros } rescue SimpliRoute::NotFound => e render_erro(e.message) @@ -68,6 +57,23 @@ class Admin::EdicaoLancamentosController < ApplicationController render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway) end + # GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR... + # Monta o card de UM lançamento escolhido na lista de ocorrências. + def carregar + authorize :edicao_lancamento + + id = params[:visit_id].to_s.strip + return render_erro('Lançamento não identificado.') if id.blank? + + entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present? + + render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros } + rescue SimpliRoute::NotFound => e + render_erro(e.message) + rescue SimpliRoute::Error => e + render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway) + end + # PATCH /admin/edicao_lancamento/atualizar # HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card # (click-to-edit) — o fetch manda visit_id + só o campo editado. @@ -146,6 +152,107 @@ class Admin::EdicaoLancamentosController < ApplicationController @client ||= SimpliRoute::Client.new end + # Todos os lançamentos da NF, cruzando as DUAS fontes: + # • espelho de rastreio (Entrega) — traz motorista/veículo; + # • API do SimpliRoute, nas datas desses lançamentos — pega a visita + # duplicada que a sincronização ainda não trouxe para o espelho. + # + # A API não tem busca global por NF (só por data), então as datas vêm do + # espelho: 1 chamada por data distinta (plano duplicado costuma cair no mesmo + # dia, e aí é 1 chamada só). + def ocorrencias_da_nf(nf, entregas) + datas = entregas.filter_map { |e| e.planned_date&.to_date }.uniq + trackings = entregas.map { |e| e.tracking_id.to_s } + do_dia = datas.flat_map { |data| client.visitas_da_data(data) } + + visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) } + .uniq { |v| v['id'] } + + achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) } + + # Lançamento que existe no espelho mas não apareceu na API (data sem plano, + # visita removida): entra na lista como não editável, para o ADM ver que + # ele existe em vez de sumir silenciosamente — que é o bug que estamos + # corrigindo. + vistos = visitas.map { |v| v['tracking_id'].to_s } + orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }.map { |e| ocorrencia_do_espelho(e) } + + # Mais recente primeiro (a data vem como texto ISO da API e como timestamp + # do espelho — os 10 primeiros caracteres normalizam as duas). + (achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse + end + + def entrega_de(entregas, tracking_id) + entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s } + end + + # Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe — + # isso só acontece quando o ADM abre um). + def ocorrencia_da_api(visita, entrega) + { + id: visita['id'], + tracking_id: visita['tracking_id'], + titulo: visita['title'], + endereco: visita['address'], + status: visita['status'], + data: visita['planned_date'], + checkout_time: visita['checkout_time'], + motorista: entrega&.driver, + veiculo: entrega&.vehicle, + editavel: true, + # Está na API mas ainda não no espelho — daí não sabermos motorista/veículo. + aviso: entrega ? nil : 'Ainda não sincronizado no painel' + } + end + + def ocorrencia_do_espelho(entrega) + { + id: nil, + tracking_id: entrega.tracking_id, + titulo: entrega.local, + endereco: entrega.address, + status: entrega.status, + data: entrega.planned_date, + checkout_time: entrega.checkout, + motorista: entrega.driver, + veiculo: entrega.vehicle, + editavel: false, + aviso: 'Não localizado na API do SimpliRoute — não é editável' + } + end + + # Estado completo de UMA visita para o card (inclui a galeria de fotos, que + # custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita + # ainda não sincronizou no espelho. + def payload_visita(id, entrega) + visita = client.visita(id) + # Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}. + detalhe = client.detalhe_visita(id) + + { + id: id, + tracking_id: visita['tracking_id'], + nf: visita['reference'], + titulo: visita['title'], + endereco: visita['address'], + status: visita['status'], + checkout_observation: visita['checkout_observation'], + checkout_comment: visita['checkout_comment'], + notes: visita['notes'], + checkout_time: visita['checkout_time'], + checkout_latitude: visita['checkout_latitude'], + checkout_longitude: visita['checkout_longitude'], + planned_date: visita['planned_date'], + contato: visita['contact_name'], + telefone: visita['contact_phone'], + # Galeria unificada — TODAS as fotos do lançamento, de todas as fontes, + # cada uma etiquetada para o ADM saber o que está conferindo. + fotos: fotos_do_lancamento(entrega, visita, detalhe), + motorista: entrega&.driver, + veiculo: entrega&.vehicle + } + end + # Monta o hash de PATCH só com os campos que vieram no form E que realmente # mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa. def mudancas(anterior) diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 8936ad0..49c87a7 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -49,6 +49,11 @@ module SimpliRoute # 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? diff --git a/app/views/admin/edicao_lancamentos/show.html.erb b/app/views/admin/edicao_lancamentos/show.html.erb index 2f4eccf..82cbbbe 100644 --- a/app/views/admin/edicao_lancamentos/show.html.erb +++ b/app/views/admin/edicao_lancamentos/show.html.erb @@ -4,6 +4,7 @@ /history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
Consultando SimpliRoute…

+ <%# Lançamentos da NF — só aparece quando há mais de um. Plano duplicado no + SimpliRoute cria uma visita nova com a MESMA NF, e a antiga (a que tem o + motorista) continua valendo: aqui o ADM vê as duas e escolhe qual editar. %> + + <%# Card do lançamento (preenchido via JS após a busca) %> ' + '

' + - (o.motorista ? esc(o.motorista) : 'sem motorista') + ' · ' + - (o.veiculo ? esc(o.veiculo) : 'sem veículo') + + // "sem motorista" (o painel sabe e está vazio) é diferente de + // "não identificado" (a visita nem está no painel ainda). + (o.motorista ? esc(o.motorista) + : '' + (o.no_painel ? 'sem motorista' : 'motorista não identificado') + '') + + ' · ' + + (o.veiculo ? esc(o.veiculo) : '' + (o.no_painel ? 'sem veículo' : 'veículo não identificado') + '') + + (fmtHora(o.checkout_time) ? ' · checkout ' + esc(fmtHora(o.checkout_time)) + '' : '') + '

' + (o.aviso ? '

' + esc(o.aviso) + '

' : '') + '' + From faa5226e9598d150c062f9c67c719acffc24297f1a455a3ae984a021e186df4a Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 21 Jul 2026 16:23:25 -0300 Subject: [PATCH 03/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20do=20fun=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20editar=20lan=C3=A7amento=20para=20quer=20consiga=20lis?= =?UTF-8?q?tar=20todas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/edicao_lancamentos_controller.rb | 113 ++++++++-- app/services/simpli_route/client.rb | 23 +- .../admin/edicao_lancamentos/show.html.erb | 75 ++++++- bin/sondar_busca_nf | 198 ++++++++++++++++++ 4 files changed, 390 insertions(+), 19 deletions(-) create mode 100755 bin/sondar_busca_nf diff --git a/app/controllers/admin/edicao_lancamentos_controller.rb b/app/controllers/admin/edicao_lancamentos_controller.rb index 9877618..83e9c1b 100644 --- a/app/controllers/admin/edicao_lancamentos_controller.rb +++ b/app/controllers/admin/edicao_lancamentos_controller.rb @@ -33,6 +33,9 @@ class Admin::EdicaoLancamentosController < ApplicationController # # Com um único lançamento já vem o card montado, para não custar um segundo # round-trip no caso comum. + # + # Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então + # varremos dia a dia). Sem período, usa as datas que o espelho conhece. def buscar authorize :edicao_lancamento @@ -40,15 +43,21 @@ class Admin::EdicaoLancamentosController < ApplicationController return render_erro('Informe o número da NF.') if nf.blank? entregas = Entrega.por_nf(nf).to_a - return render_erro("NF #{nf} não encontrada na base de rastreio.") if entregas.empty? + datas = datas_de_busca(entregas) + return render_erro(sem_datas_msg(nf, entregas)) if datas.empty? - ocorrencias = ocorrencias_da_nf(nf, entregas) + falhas = [] + ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas) unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present? render json: { ok: true, ocorrencias: ocorrencias, - visita: (unica && payload_visita(unica[:id], entrega_de(entregas, unica[:tracking_id]))), + visita: (unica && payload_seguro(unica, entregas, falhas)), + periodo: { de: datas.first, ate: datas.last, dias: datas.size }, + # Dias que a API não respondeu. Vão para a tela: uma busca que devolve + # menos do que existe TEM que dizer que devolveu menos. + falhas: falhas, motivos: motivos_seguros } rescue SimpliRoute::NotFound => e @@ -152,18 +161,53 @@ class Admin::EdicaoLancamentosController < ApplicationController @client ||= SimpliRoute::Client.new end + # Máximo de dias varridos numa busca por período. A API só filtra por UM dia, + # então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão + # em lotes paralelos, o que torna dois meses viável; o teto existe para a tela + # não virar uma varredura sem fim. + MAX_DIAS_VARREDURA = 62 + + # Quais dias consultar na API: + # • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para + # outra data, e aí o espelho não tem como saber qual é); + # • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas). + def datas_de_busca(entregas) + de = data_param(:de) + ate = data_param(:ate) + + if de || ate + inicio, fim = [de || ate, ate || de].minmax + (inicio..fim).first(MAX_DIAS_VARREDURA) + else + entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort + end + end + + def data_param(chave) + valor = params[chave].to_s.strip + return nil if valor.blank? + + Date.parse(valor) + rescue Date::Error + nil + end + + # Sem datas não há o que consultar — e o motivo muda a saída para o ADM. + def sem_datas_msg(nf, entregas) + if entregas.empty? + "NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute." + else + "NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API." + end + end + # Todos os lançamentos da NF, cruzando as DUAS fontes: # • espelho de rastreio (Entrega) — traz motorista/veículo; - # • API do SimpliRoute, nas datas desses lançamentos — pega a visita - # duplicada que a sincronização ainda não trouxe para o espelho. - # - # A API não tem busca global por NF (só por data), então as datas vêm do - # espelho: 1 chamada por data distinta (plano duplicado costuma cair no mesmo - # dia, e aí é 1 chamada só). - def ocorrencias_da_nf(nf, entregas) - datas = entregas.filter_map { |e| e.planned_date&.to_date }.uniq + # • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a + # sincronização ainda não trouxe para o espelho. + def ocorrencias_da_nf(nf, entregas, datas, falhas) trackings = entregas.map { |e| e.tracking_id.to_s } - do_dia = datas.flat_map { |data| client.visitas_da_data(data) } + do_dia = visitas_dos_dias(datas, nf, falhas) visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) } .uniq { |v| v['id'] } @@ -176,13 +220,48 @@ class Admin::EdicaoLancamentosController < ApplicationController # ele existe em vez de sumir silenciosamente — que é o bug que estamos # corrigindo. vistos = visitas.map { |v| v['tracking_id'].to_s } - orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }.map { |e| ocorrencia_do_espelho(e) } + orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) } + .map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) } # Mais recente primeiro (a data vem como texto ISO da API e como timestamp # do espelho — os 10 primeiros caracteres normalizam as duas). (achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse end + # Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver + # SimpliRoute::Client#requisicao) e não toca o banco, então dá para + # paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute. + MAX_PARALELO_VARREDURA = 6 + + # Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro) + # e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data. + def visitas_dos_dias(datas, nf, falhas) + mutex = Mutex.new + + datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote| + lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value) + end + end + + # Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a + # falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas + # datas passou a não devolver nada em vez de devolver o que deu certo.) + def visitas_do_dia(data, nf, falhas, mutex) + client.visitas_da_data(data, busca: nf) + rescue SimpliRoute::Error => e + mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } } + [] + end + + # Abrir o card do lançamento único é conveniência: se falhar, a lista ainda + # tem que aparecer. + def payload_seguro(ocorrencia, entregas, falhas) + payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id])) + rescue SimpliRoute::Error => e + falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" } + nil + end + def entrega_de(entregas, tracking_id) return nil if tracking_id.blank? @@ -233,7 +312,7 @@ class Admin::EdicaoLancamentosController < ApplicationController } end - def ocorrencia_do_espelho(entrega) + def ocorrencia_do_espelho(entrega, data_varrida) { id: nil, tracking_id: entrega.tracking_id, @@ -246,7 +325,11 @@ class Admin::EdicaoLancamentosController < ApplicationController veiculo: entrega.vehicle, editavel: false, no_painel: true, - aviso: 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' + aviso: (if data_varrida + 'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar' + else + 'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo' + end) } end diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 49c87a7..5f59e78 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -41,8 +41,27 @@ module SimpliRoute end # Todas as visitas de uma data (Array de hashes). - def visitas_da_data(data) - Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}")) + # + # `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 # Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega diff --git a/app/views/admin/edicao_lancamentos/show.html.erb b/app/views/admin/edicao_lancamentos/show.html.erb index 41336fb..eb3b20a 100644 --- a/app/views/admin/edicao_lancamentos/show.html.erb +++ b/app/views/admin/edicao_lancamentos/show.html.erb @@ -40,8 +40,40 @@ Buscar + <%# Período — a API do SimpliRoute só filtra visita por UM dia, então achar + uma NF exige saber em quais dias procurar. Sem período, procuramos nas + datas que o painel já conhece; quando o plano foi duplicado para outra + data, o painel não sabe dela e é preciso ampliar aqui. %> +
+
+ + +
+
+ + +
+ + +
+

+ Sem período, procuramos só nas datas que o painel conhece. Se o plano foi duplicado para outra + data, amplie o período — a API do SimpliRoute não busca NF por intervalo, então varremos dia a + dia (máx. 62 dias). +

+ + <%# Lançamentos da NF — só aparece quando há mais de um. Plano duplicado no @@ -199,9 +231,16 @@ const loadEl = document.getElementById('busca-loading'); const cardEl = document.getElementById('card'); + const campoDe = document.getElementById('campo-de'); + const campoAte = document.getElementById('campo-ate'); + const periodoEl = document.getElementById('busca-periodo'); + const falhasEl = document.getElementById('busca-falhas'); + async function buscar() { const nf = campoNf.value.trim(); erroEl.classList.add('hidden'); + periodoEl.classList.add('hidden'); + falhasEl.classList.add('hidden'); if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; } loadEl.classList.remove('hidden'); @@ -209,14 +248,18 @@ ocorrencias = []; selecionado = null; try { - const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } }); + let url = URLS.buscar + '?nf=' + encodeURIComponent(nf); + if (campoDe.value) url += '&de=' + encodeURIComponent(campoDe.value); + if (campoAte.value) url += '&ate=' + encodeURIComponent(campoAte.value); + const resp = await fetch(url, { headers: { 'Accept': 'application/json' } }); const data = await resp.json(); if (!data.ok) { renderOcorrencias(); mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; } ocorrencias = data.ocorrencias || []; + renderPeriodo(data.periodo, data.falhas); // Só veio o card pronto quando a NF tem um lançamento único; com mais de // um, o ADM escolhe na lista antes de abrir qualquer coisa. if (data.visita) { abrirCard(data.visita); } else { visita = null; renderOcorrencias(); } - if (!data.visita && !ocorrencias.length) mostrarErroBusca('Nenhum lançamento localizado para esta NF.'); + if (!data.visita && !ocorrencias.length) mostrarErroBusca('Nenhum lançamento localizado para esta NF no período consultado.'); } catch (e) { renderOcorrencias(); mostrarErroBusca('Erro de conexão ao buscar a NF.'); @@ -226,9 +269,37 @@ } function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); } + // Dizer o que foi consultado e o que falhou. Uma busca que devolve menos do + // que existe precisa admitir isso — senão parece que a NF não existe. + function renderPeriodo(periodo, falhas) { + if (periodo && periodo.dias) { + periodoEl.textContent = 'Consultado: ' + fmtData(periodo.de) + + (periodo.dias > 1 ? ' a ' + fmtData(periodo.ate) + ' (' + periodo.dias + ' dias)' : '') + '.'; + periodoEl.classList.remove('hidden'); + } + if (falhas && falhas.length) { + falhasEl.innerHTML = '

A API do SimpliRoute falhou em ' + falhas.length + + ' dia(s) — pode haver lançamento faltando na lista:

' + + falhas.map(f => '

' + esc(f.data) + ' — ' + esc(f.erro) + '

').join(''); + falhasEl.classList.remove('hidden'); + } + } + document.getElementById('btn-buscar').addEventListener('click', buscar); campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } }); + document.getElementById('btn-30dias').addEventListener('click', () => { + const hoje = new Date(); + const trinta = new Date(hoje.getTime() - 30 * 86400000); + const iso = d => d.toISOString().slice(0, 10); + campoDe.value = iso(trinta); + campoAte.value = iso(hoje); + if (campoNf.value.trim()) buscar(); + }); + document.getElementById('btn-limpar-periodo').addEventListener('click', () => { + campoDe.value = ''; campoAte.value = ''; + }); + // ── Lista de lançamentos da NF ────────────────────────────── const ocEl = document.getElementById('ocorrencias'); const ocLista = document.getElementById('oc-lista'); diff --git a/bin/sondar_busca_nf b/bin/sondar_busca_nf new file mode 100755 index 0000000..774435c --- /dev/null +++ b/bin/sondar_busca_nf @@ -0,0 +1,198 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Descobre se a API do SimpliRoute aceita buscar visita por NF e/ou por período +# — a doc oficial só documenta `?planned_date=` de UM dia. +# +# ── RESULTADO DA RODADA DE 21/07/2026 (contra a API real) ─────────────────── +# ✔ `&search=` FUNCIONA (não documentado): dia inteiro cai de ~3,8 MB/9 s +# para ~1 KB/0,6 s. É o que a tela usa hoje. +# ✘ `reference`, `reference_id`, `q`, `title`, `reference__*` — IGNORADOS em +# silêncio (200 + dia inteiro). +# ✘ Intervalo (`planned_date_from/to`, `since/until`, `__gte/__lte`, +# `date_from/to`) — TODOS ignorados: devolveram 2469, igual ao controle sem +# parâmetro nenhum. +# ✘ Sem `planned_date` a API devolve um conjunto padrão (~2469) que NÃO cobre +# o histórico — `?search=` sozinho escondeu a visita mais antiga da NF. +# ⇒ `planned_date` continua obrigatório; a busca por NF é dia a dia + search. +# +# Rode de novo se desconfiar que a API mudou. +# +# SÓ LEITURA: o script faz apenas GET/OPTIONS. Não altera nada. +# +# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN): +# +# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 +# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 --sem-data +# +# --data deve ser um dia em que a NF EXISTE (é a referência de comparação). +# --sem-data testa a consulta sem `planned_date` nenhum; pode ser lenta ou vir +# gigante, por isso fica de fora por padrão. +# +# ⚠️ Por que comparar contagens: a API é Django REST. Parâmetro que o backend +# não registra é IGNORADO em silêncio — devolve 200 e a lista inteira. Então +# "voltou 200 com resultados" NÃO prova que filtrou. A prova é o filtro DIMINUIR +# o resultado do dia e sobrar só a NF pedida. + +require 'net/http' +require 'json' +require 'uri' +require 'date' + +BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com') +TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s + +def sair(msg) + warn msg + exit 1 +end + +sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty? + +nf = nil +data = nil +sem_data = false +ARGV.each_with_index do |a, i| + nf = ARGV[i + 1] if a == '--nf' + data = ARGV[i + 1] if a == '--data' + sem_data = true if a == '--sem-data' +end +sair('Uso: bin/sondar_busca_nf --nf --data [--sem-data]') if nf.to_s.empty? || data.to_s.empty? + +def requisicao(caminho, metodo: :get) + uri = URI.join(BASE, caminho) + req = metodo == :options ? Net::HTTP::Options.new(uri) : Net::HTTP::Get.new(uri) + req['Authorization'] = "Token #{TOKEN}" + req['Accept'] = 'application/json' + + t0 = Time.now + res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: 10, read_timeout: 120) { |h| h.request(req) } + corpo = begin + res.body.to_s.empty? ? nil : JSON.parse(res.body) + rescue JSON::ParserError + res.body.to_s[0, 200] + end + [res.code.to_i, corpo, res.body.to_s.bytesize, Time.now - t0] +rescue StandardError => e + [0, "ERRO: #{e.class}: #{e.message}", 0, Time.now - t0] +end + +# A resposta pode ser lista crua OU paginada ({count, next, results}). No caso +# paginado o `count` já é o total — é a informação mais barata que existe aqui. +def itens(corpo) + return corpo if corpo.is_a?(Array) + return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results') + + [] +end + +def total(corpo, lista) + corpo.is_a?(Hash) && corpo['count'] ? corpo['count'] : lista.size +end + +def titulo(t) + puts "\n#{'─' * 70}\n#{t}\n#{'─' * 70}" +end + +puts "Base: #{BASE} NF: #{nf} Data de referência: #{data}" + +# ── 1. Linha de base: o dia inteiro, do jeito que a tela faz hoje ──────────── +titulo("1. LINHA DE BASE · GET /v1/routes/visits/?planned_date=#{data}") +cod, corpo, bytes, seg = requisicao("/v1/routes/visits/?planned_date=#{data}") +sair("Falhou (HTTP #{cod}): #{corpo.inspect}") unless cod == 200 + +base_lista = itens(corpo) +base_total = total(corpo, base_lista) +base_ids = base_lista.map { |v| v['id'] } +da_nf = base_lista.select { |v| v['reference'].to_s == nf.to_s } + +puts " HTTP 200 em #{seg.round(2)}s — #{bytes} bytes" +puts " visitas no dia .......... #{base_total}" +puts " paginado? ............... #{corpo.is_a?(Hash) ? "sim (count=#{corpo['count']}, next=#{corpo['next'].inspect})" : 'não (lista crua)'}" +puts " visitas com a NF #{nf} ... #{da_nf.size}" +da_nf.each { |v| puts " id=#{v['id']} tracking=#{v['tracking_id'].inspect} status=#{v['status']} data=#{v['planned_date']}" } +puts " ⚠️ tracking_id vem NULO na lista-por-data" if da_nf.any? && da_nf.all? { |v| v['tracking_id'].nil? } +sair("A NF #{nf} não aparece em #{data}. Passe uma data em que ela exista — sem isso não há como comparar.") if da_nf.empty? + +# ── 2. Filtro por NF: o parâmetro DIMINUI o resultado do dia? ──────────────── +# Se a contagem não mudar, o parâmetro foi ignorado (mesmo com HTTP 200). +titulo('2. FILTRO POR NF · + =NF (diminuiu = filtro real)') +%w[reference reference_id search q title reference__exact reference__icontains].each do |param| + cod, corpo, _b, seg = requisicao("/v1/routes/visits/?planned_date=#{data}&#{param}=#{nf}") + lista = itens(corpo) + qtd = total(corpo, lista) + + veredito = + if cod != 200 then "HTTP #{cod} — não aceito" + elsif qtd == base_total then 'IGNORADO (mesma contagem do dia inteiro)' + elsif qtd.zero? then 'aceito porém vazio — filtrou demais' + elsif lista.all? { |v| v['reference'].to_s == nf.to_s } then "★ FILTRA DE VERDADE — #{qtd} visita(s), todas da NF" + else "reduziu p/ #{qtd} mas veio NF de fora" + end + puts " #{param.ljust(22)} #{veredito} (#{seg.round(2)}s)" +end + +# ── 3. Filtro por período ──────────────────────────────────────────────────── +# Comparação: o intervalo .. tem que devolver MAIS que só . +dia2 = (Date.parse(data) + 1).to_s +titulo("3. FILTRO POR PERÍODO · #{data} a #{dia2} (> #{base_total} = intervalo respeitado)") +[ + %w[planned_date_from planned_date_to], + %w[planned_date_after planned_date_before], + %w[planned_date__gte planned_date__lte], + %w[since until], + %w[start_date end_date], + %w[date_from date_to] +].each do |de, ate| + cod, corpo, _b, seg = requisicao("/v1/routes/visits/?#{de}=#{data}&#{ate}=#{dia2}") + lista = itens(corpo) + qtd = total(corpo, lista) + + veredito = + if cod != 200 then "HTTP #{cod} — não aceito" + elsif qtd > base_total then "★ RESPEITA O INTERVALO — #{qtd} visitas (dia sozinho: #{base_total})" + elsif qtd == base_total then "mesma contagem do dia — inconclusivo/ignorado" + else "#{qtd} visitas — menos que o dia sozinho, estranho" + end + puts " #{"#{de}/#{ate}".ljust(38)} #{veredito} (#{seg.round(2)}s)" +end + +# ── 4. OPTIONS: o DRF às vezes lista os filtros aceitos ────────────────────── +titulo('4. OPTIONS /v1/routes/visits/ (metadados do endpoint)') +cod, corpo, = requisicao('/v1/routes/visits/', metodo: :options) +if cod == 200 && corpo.is_a?(Hash) + puts " métodos ... #{corpo['renders'] ? corpo.slice('name', 'description', 'parses').inspect[0, 300] : corpo.keys.inspect}" + filtros = corpo['filters'] || corpo['filter_fields'] || corpo.dig('actions', 'GET') + puts " filtros ... #{filtros ? filtros.inspect[0, 500] : '(não declarados)'}" +else + puts " HTTP #{cod} — #{corpo.inspect[0, 200]}" +end + +# ── 5. Sem planned_date nenhum — a pergunta direta ─────────────────────────── +unless sem_data + puts "\n(Pulei o teste SEM planned_date. Rode com --sem-data para incluir — pode demorar ou vir gigante.)" + exit 0 +end + +titulo('5. SEM planned_date · a API devolve a NF inteira de uma vez?') +[ + "/v1/routes/visits/?reference=#{nf}", + "/v1/routes/visits/?search=#{nf}", + '/v1/routes/visits/' +].each do |caminho| + cod, corpo, bytes, seg = requisicao(caminho) + lista = itens(corpo) + qtd = total(corpo, lista) + casam = lista.count { |v| v['reference'].to_s == nf.to_s } + + puts " GET #{caminho}" + if cod == 200 + puts " #{qtd} visita(s), #{casam} com a NF #{nf} — #{bytes} bytes em #{seg.round(2)}s" + datas = lista.select { |v| v['reference'].to_s == nf.to_s }.map { |v| v['planned_date'] }.uniq + puts " datas da NF: #{datas.inspect}" if datas.any? + puts ' ★ RESOLVE TUDO: a NF veio inteira, sem varrer dia a dia' if casam > 1 && casam == qtd + else + puts " HTTP #{cod} — #{corpo.inspect[0, 200]}" + end +end From 033e083ec55cb0399850f6182e2705b6a86aa8c28d15bbf21b1ba50728be4cf7 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 21 Jul 2026 16:33:21 -0300 Subject: [PATCH 04/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20do=20fun=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20editar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/edicao_lancamentos/show.html.erb | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/app/views/admin/edicao_lancamentos/show.html.erb b/app/views/admin/edicao_lancamentos/show.html.erb index eb3b20a..82eda37 100644 --- a/app/views/admin/edicao_lancamentos/show.html.erb +++ b/app/views/admin/edicao_lancamentos/show.html.erb @@ -258,11 +258,25 @@ renderPeriodo(data.periodo, data.falhas); // Só veio o card pronto quando a NF tem um lançamento único; com mais de // um, o ADM escolhe na lista antes de abrir qualquer coisa. - if (data.visita) { abrirCard(data.visita); } else { visita = null; renderOcorrencias(); } - if (!data.visita && !ocorrencias.length) mostrarErroBusca('Nenhum lançamento localizado para esta NF no período consultado.'); + if (data.visita) { + abrirCard(data.visita); + } else { + visita = null; + renderOcorrencias(); + // Tela em branco é pior que erro: se não há card E a lista ficou + // escondida, o ADM precisa saber por quê em vez de achar que a NF + // não existe. + if (ocEl.classList.contains('hidden')) { + mostrarErroBusca(ocorrencias.length + ? 'O lançamento foi localizado mas não pôde ser aberto — veja os avisos acima.' + : 'Nenhum lançamento localizado para esta NF no período consultado.'); + } + } } catch (e) { - renderOcorrencias(); - mostrarErroBusca('Erro de conexão ao buscar a NF.'); + // Mostra o erro real: "Erro de conexão" genérico escondeu uma exceção de + // renderização e deixou a tela muda. + console.error('[edicao-lancamento] falha na busca', e); + mostrarErroBusca('Falha ao processar a busca: ' + ((e && e.message) || e)); } finally { loadEl.classList.add('hidden'); } @@ -306,10 +320,16 @@ const ocCont = document.getElementById('oc-contador'); const ocDica = document.getElementById('oc-dica'); + // ⚠️ NÃO usar new Date('2026-07-15') para data pura: o JS lê como meia-noite + // UTC e o toLocale em UTC-3 devolve o DIA ANTERIOR. Data planejada não é um + // instante — formata direto do texto ISO. const fmtData = v => { if (!v) return '—'; - const d = new Date(v); - return isNaN(d) ? String(v).slice(0, 10) : d.toLocaleDateString('pt-BR'); + const s = String(v); + const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) return iso[3] + '/' + iso[2] + '/' + iso[1]; + const d = new Date(s); + return isNaN(d) ? s.slice(0, 10) : d.toLocaleDateString('pt-BR'); }; const fmtHora = v => { if (!v) return null; @@ -325,6 +345,9 @@ ocEl.classList.add('hidden'); ocLista.innerHTML = ''; return; } + // O bloco nasce com `hidden` no ERB — sem tirar a classe aqui, a lista é + // montada e fica invisível (a tela parecia não ter achado nada). + ocEl.classList.remove('hidden'); ocCont.textContent = n + (n === 1 ? ' lançamento' : ' lançamentos'); ocDica.textContent = n > 1 ? 'para esta NF — escolha qual quer editar.' : 'para esta NF.'; ocLista.innerHTML = ocorrencias.map((o, i) => { From 84d6607d63392f20b05f94bd33e3954477c213d1e333b529dffe9c853fd88a73 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 21 Jul 2026 16:41:12 -0300 Subject: [PATCH 05/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20da=20edi=C3=A7=C3=A3?= =?UTF-8?q?o=20da=20fun=C3=A7=C3=A3o=20de=20editar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/policies/edicao_lancamento_policy.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/policies/edicao_lancamento_policy.rb b/app/policies/edicao_lancamento_policy.rb index 481ec53..5ab7ed7 100644 --- a/app/policies/edicao_lancamento_policy.rb +++ b/app/policies/edicao_lancamento_policy.rb @@ -4,8 +4,13 @@ # operação sensível e irreversível. Restrita a admin. # Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model). class EdicaoLancamentoPolicy < ApplicationPolicy + # ⚠️ Uma entrada por action do controller: o Pundit chama "?" e, se o + # método não existir, levanta NoMethodError (500) — foi o que aconteceu ao + # adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha + # nova aqui. def show? = admin? def buscar? = admin? + def carregar? = admin? def atualizar? = admin? def historico? = admin? end From dc1b5c1b4114b69a7e0ed91070fa36ee0c564ca0a20e6f80198354f1fcd08d6d Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 01:04:22 -0300 Subject: [PATCH 06/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20responsividade?= =?UTF-8?q?=20geral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/validacao_controller.js | 20 ++- .../consolidacao_entregas/validar.html.erb | 129 +++++++++++------- 2 files changed, 94 insertions(+), 55 deletions(-) diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js index 3b1245c..87708b1 100644 --- a/app/javascript/controllers/validacao_controller.js +++ b/app/javascript/controllers/validacao_controller.js @@ -7,6 +7,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "linha", "checkbox", "selecionarTodos", "busca", "buscaVazia", + "barraSelecao", "contadorSelecao", "resumo", "listaCol", "btnResumoLabel", "toggles", "barra", "barraManuais", "percentual", "chipVeiculo", "consolidarBtn", "consolidarHint", @@ -98,6 +99,22 @@ export default class extends Controller { if (linha && linha.classList.contains("hidden")) return c.checked = marcar }) + this.atualizarSelecao() + } + + // ── Barra de ações em massa: só existe quando há seleção ── + // Sem isto os 5 botões de pilar ficavam fixos no topo o tempo todo, comendo + // a altura da lista para não fazer nada (clicar sem seleção só alertava). + atualizarSelecao() { + const n = this.checkboxTargets.filter(c => c.checked).length + if (this.hasContadorSelecaoTarget) this.contadorSelecaoTarget.textContent = n + if (this.hasBarraSelecaoTarget) this.barraSelecaoTarget.classList.toggle("hidden", n === 0) + } + + limparSelecao() { + this.checkboxTargets.forEach(c => (c.checked = false)) + if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false + this.atualizarSelecao() } // O pilar está ativo (ring-2) na entrega indicada? @@ -137,8 +154,7 @@ export default class extends Controller { valor: valor }) ids.forEach(id => this.setToggle(id, tipo, !remover)) - this.checkboxTargets.forEach(c => c.checked = false) - if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false + this.limparSelecao() // some a barra de ações junto com a seleção this.atualizarResumo(resp) } diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index d5a7407..9b7253e 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -12,38 +12,43 @@ <%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
- <%# Header %> -
- <%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao), - class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %> -
-
-

<%= icone :caminhao, espaco: false %> <%= @motorista %>

-

<%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>

-
- <%= link_to safe_join(['Revisar (Passo 3)', icone(:avancar, cor: nil)], ' '), - revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista), - class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %> + <%# Cabeçalho compacto: voltar, identificação e progresso numa faixa só. + Espaço vertical aqui é CARO — tudo isto fica fixo e é subtraído da lista, + que é o que o ADM realmente precisa ver. Antes eram 178px em dois cards. %> +
+ <%= link_to wizard_consolidacao_path(@consolidacao), title: 'Voltar (Passo 1)', + class: 'shrink-0 inline-flex items-center justify-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-3 h-11 rounded-lg text-sm' do %> + ← + <% end %> + + <%# basis-40: o nome não pode ser espremido até virar "P…" — se não couber + com o resto, a linha quebra em vez de esmagar a identificação. %> +
+

<%= icone :caminhao, espaco: false %> <%= @motorista %>

+

<%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>

+ + <%# Progresso ao lado do título (em vez de um card de 70px só p/ ele). No + celular vai para uma linha própria, no fim, para não brigar pelo espaço. %> +

+ <% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %> + <%= @classificadas %>/<%= @total_entregas %> + + · + +<%= @manuais %> manuais + +

+ + <%= link_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista), + class: 'shrink-0 bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 h-11 rounded-lg flex items-center gap-1 text-sm' do %> + Revisar<%= icone :avancar, cor: nil %> + <% end %>
- <%# Barra de progresso (reflete o veículo selecionado, se houver) %> -
-
- - <% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %> - <%= @classificadas %> - de <%= @total_entregas %> entregas classificadas - · - +<%= @manuais %> manuais - - -
- <%# Barra com 2 segmentos: laranja/verde = elegíveis classificadas · azul = lançamentos manuais %> -
-
-
-
+ <%# Barra com 2 segmentos: laranja = elegíveis classificadas · azul = manuais %> +
+
+
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/ @@ -136,47 +141,36 @@
<% end %> - <%# Ações em massa + filtro %> -
-
+ <%# Fim da barra de ações %> + + <%# Ações em massa — SÓ aparecem quando há entregas selecionadas. Ocupavam + 160px fixos para botões que, sem seleção, respondiam com um alerta + pedindo para selecionar algo. %> + +
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %> @@ -375,6 +397,7 @@ <%# Checkbox seleção em massa %> <%# Dados da entrega %> From 3dbb65efd2bffc12aa0bee66e401eea12d85bf62f4f47390f18daeb6a3e78961 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 01:21:32 -0300 Subject: [PATCH 07/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20responsividade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidacao_entregas/validar.html.erb | 24 +++++++++-------- app/views/consolidacoes/index.html.erb | 27 ++++++++++++------- app/views/dashboard/index.html.erb | 20 +++++++++----- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 9b7253e..5440890 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -24,19 +24,21 @@ <%# basis-40: o nome não pode ser espremido até virar "P…" — se não couber com o resto, a linha quebra em vez de esmagar a identificação. %>
-

<%= icone :caminhao, espaco: false %> <%= @motorista %>

-

<%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>

+

<%= icone :caminhao, espaco: false %> <%= @motorista %>

+

<%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>

<%# Progresso ao lado do título (em vez de um card de 70px só p/ ele). No - celular vai para uma linha própria, no fim, para não brigar pelo espaço. %> -

- <% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %> - <%= @classificadas %>/<%= @total_entregas %> - + celular vai para uma linha própria, no fim, para não brigar pelo espaço. + Números em branco/negrito: esta é a informação que o ADM confere o tempo + todo enquanto classifica — compactar não pode custar a leitura dela. %> +

+ <% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %> + <%= @classificadas %>/<%= @total_entregas %> + classificadas · +<%= @manuais %> manuais - +

<%= link_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista), @@ -46,9 +48,9 @@
<%# Barra com 2 segmentos: laranja = elegíveis classificadas · azul = manuais %> -
-
-
+
+
+
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/ diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 64df335..47b5202 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -71,28 +71,37 @@
<%# ── Filtros ──────────────────────────────────────────── %> +<%# Filtros em flex-wrap, NÃO em grade de 6 colunas fixas. + Motivo: os breakpoints do Tailwind (md:, lg:…) enxergam a JANELA, mas o + conteúdo perde 256px para a sidebar (`main.md:ml-64`). A mesma janela de + 1280px tem duas larguras conforme o menu esteja aberto ou recolhido, então + `md:grid-cols-6` espremia os campos a ~150px e o texto do botão "Filtrar" + vazava para fora do fundo. Com flex-wrap + largura mínima, cada campo mantém + tamanho legível e a linha quebra sozinha conforme o espaço REAL. %> <%= form_with url: consolidacoes_path, method: :get, - class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 gap-3' do |f| %> + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %> <%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…', - class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %> + class: 'flex-1 basis-48 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %> <%= f.select :status, options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), - {}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + {}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> <%= f.select :pagamento, options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]), - {}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + {}, class: 'flex-1 basis-44 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> <%= f.date_field :inicio, value: params[:inicio], - class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> <%= f.date_field :fim, value: params[:fim], - class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> -
- <%= f.submit 'Filtrar', class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer' %> - <%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg' %> + <%# shrink-0 + whitespace-nowrap: os botões nunca são espremidos a ponto de + cortar o texto — se faltar espaço, a linha quebra e eles descem inteiros. %> +
+ <%= f.submit 'Filtrar', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer px-5 py-2.5 whitespace-nowrap' %> + <%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %>
<% end %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index b95e0a8..167d030 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -117,15 +117,21 @@
- <%# CARDS GRANDES — KPIs %> -
+ <%# CARDS GRANDES — KPIs + auto-fit + minmax em vez de `xl:grid-cols-5`: o breakpoint xl olha a JANELA + (1280px), mas com a sidebar aberta sobram ~960px de conteúdo — 5 colunas de + ~176px, e "R$ 82.692,00" em text-4xl era cortado pelo overflow-hidden do + card. Com auto-fit o número de colunas cai sozinho quando o menu está + aberto e sobe quando ele é recolhido, sem depender de breakpoint. %> +
- <%# Card 1: Valor estimado %> -
+ <%# Card 1: Valor estimado — sempre 2 faixas: é o maior número da tela %> +

<%= icone :dinheiro, cor: nil, espaco: false %> Valor Estimado

-

+ <%# break-words: se ainda faltar espaço, quebra a linha em vez de cortar %> +

<%= moeda(@valor_estimado) %>

@@ -178,7 +184,7 @@

<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %> -
+

<%= icone :dinheiro, espaco: false %> Custo total

<%= moeda(@fin_custo_total) %>

@@ -445,7 +451,7 @@ <% if @por_operacao.any? %>

<%= icone :operacao, espaco: false %> Entregas por Local

-
+
<% total_op = @por_operacao.values.sum.to_f %> <% @por_operacao.each do |local, qtd| %>
From def8a8314cfee3660a67d45786270b6117806fe41d680f6a6042e77dac0df1dd Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 01:37:20 -0300 Subject: [PATCH 08/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20responsividade?= =?UTF-8?q?=20=20e=20atualiza=C3=A7=C3=A3o=20do=20read.me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 114 ++++++++++++++++++ .../controllers/validacao_controller.js | 20 ++- app/views/admin/configuracoes/index.html.erb | 5 +- app/views/configuracoes/index.html.erb | 5 +- .../consolidacao_entregas/revisar.html.erb | 7 +- .../consolidacao_entregas/validar.html.erb | 58 ++++----- 6 files changed, 171 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8f66115..945be02 100644 --- a/README.md +++ b/README.md @@ -1638,3 +1638,117 @@ app/javascript/controllers/validacao_controller.js (chip usa SVG, não emoji) +--- + +
+🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026) + +## Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas + +### 🎯 O problema real +Quando um plano é **duplicado** no SimpliRoute, nasce uma **visita nova** (outro `tracking_id`) com a +**mesma NF**, e a antiga continua existindo. Caso que motivou tudo: **NF 82891** com visita em +**17/07** (pendente, motorista Thiago Rabello Bittencourt) e outra em **21/07** (sucesso, sem +motorista). A tela mostrava **uma só** — e depois, por regressão, **nenhuma**. + +### 🔴 Causas corrigidas (foram 7, em camadas) +1. **`Entrega.por_nf(nf).first`** — pegava uma linha só, sem `ORDER BY`. Agora carrega todas e a + tela lista as ocorrências para o ADM escolher qual editar. +2. **`SimpliRoute::Client#resolver_id` abortava na ambiguidade** (`"Mais de uma visita para a NF…"`) + em vez de deixar escolher. A tela de edição não passa mais por ele. +3. **As datas vinham só do espelho local.** Como a API **não busca NF por intervalo**, a visita de + 17/07 era inalcançável se o painel só conhecia a de 21/07. Entraram campos **De/Até** + botão + **"Últimos 30 dias"** (varredura dia a dia, teto de 62 dias). +4. **Um dia com falha derrubava a busca inteira** → agora é *fail-soft* por dia: registra em + `falhas` e segue. A tela informa o período consultado e quais dias falharam. +5. **`carregar?` faltando em `EdicaoLancamentoPolicy`** → o Pundit levantava `NoMethodError` (500), + o `fetch` recebia HTML e o JS reportava "erro de conexão". Falhou **fechado** (negou acesso), + sem brecha de segurança. +6. **Tela em branco:** `renderOcorrencias()` montava a lista inteira mas **nunca removia a classe + `hidden`** do container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível. +7. **Fuso horário:** `new Date('2026-07-15')` é meia-noite **UTC** e, em UTC−3, o `toLocale` + devolvia **14/07**. Datas puras agora são formatadas direto do texto ISO; horários de checkout + continuam convertendo (correto para instante). + +### 🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026) +| Parâmetro | Resultado | +|---|---| +| **`&search=`** | ✅ **Funciona e não é documentado.** Um dia cai de **3,8 MB / ~9 s** (1879 visitas) para **~1 KB / ~0,6 s**. Varredura de 31 dias em lotes paralelos: **3,4 s / 3,2 KB** | +| `reference`, `reference_id`, `q`, `title`, `reference__*` | ❌ **Ignorados em silêncio** — devolvem 200 com o dia inteiro | +| `planned_date_from/to`, `since/until`, `__gte/__lte`, `date_from/to` | ❌ **Não existe filtro de intervalo** — todos devolveram 2469, idêntico ao controle **sem parâmetro nenhum** | +| `GET /v1/routes/visits/` sem `planned_date` | ⚠️ Devolve um **conjunto padrão (~2469)** que **não cobre o histórico** — para a NF 82891 voltava só a visita de 21/07 e escondia a de 17/07 | + +> ⚠️ **Lição:** parâmetro não registrado no backend Django é **ignorado sem erro**. "Voltou 200 com +> resultados" **não** prova que filtrou — a prova é a **contagem diminuir** em relação ao dia sem o +> filtro. Ferramenta: **`bin/sondar_busca_nf --nf --data [--sem-data]`** (só GET). + +--- + +## Parte 2 — Responsividade + +### 🎯 A raiz comum +Os breakpoints do Tailwind (`md:`, `lg:`, `xl:`) enxergam a **janela**, mas o conteúdo perde +**256px** para a sidebar (`main.md:ml-64`). **A mesma janela de 1280px tem duas larguras** conforme +o menu esteja aberto ou recolhido — e grades de colunas fixas não ficam sabendo. Daí "quando a barra +de menu é acionada, quebra o layout". Solução: **`flex-wrap` / `auto-fit + minmax`**, que reagem ao +espaço **real**. + +### 🔧 Corrigido +| Tela | Antes | Sintoma | Depois | +|---|---|---|---| +| `consolidacoes/index` | `md:grid-cols-6` | Campos a ~150px; texto do botão **"Filtrar" vazava** para fora do fundo | `flex-wrap` + `basis-*` | +| `dashboard/index` | `xl:grid-cols-5` | **"R$ 82.692,"** — valor cortado pelo `overflow-hidden` do card | `auto-fit,minmax(13rem,1fr)` | +| `consolidacao_entregas/revisar` | `md:grid-cols-7` | 7 cards de **~55px**, destruindo "Extraordinária"/"Termo Especial" | `auto-fit,minmax(9rem,1fr)` | +| `configuracoes` + `admin/configuracoes` | `lg:grid-cols-4` | ~128px úteis para valores em moeda | `auto-fit,minmax(14rem,1fr)` | + +**Mantidos de propósito:** `validar.html.erb` (`lg:grid-cols-4` é a divisão 3:1 lista/Resumo, não +grade de cards) e os `xl:grid-cols-3` de gráficos — nesses o conteúdo encolhe sem cortar. + +### 📐 Passo 2 (validar) — cabeçalho fixo +Medido a 1280×577: **442px de cabeçalho contra 67px de lista** (menos que um card). + +- Voltar + título + progresso passaram a **uma faixa só**; o card de progresso (70px de moldura para + uma barra de 12px) virou linha de 8px. +- **"Adicionar lançamento"** entrou na barra de ações via `order` do flex — sem mover os ~170 linhas + de modais que vivem dentro do `data-controller`. +- **Pilares (Normal/Retirada/Bônus/Desconto/Extra) continuam SEMPRE visíveis**, apenas **esmaecidos** + (`opacity-40`) enquanto não há seleção, com contador e "limpar" aparecendo ao selecionar. + ⚠️ Uma versão intermediária os **escondia** até haver seleção — revertido: são a ação principal da + tela e sumir com eles esconde o que dá para fazer de quem ainda não sabe que precisa selecionar. +- Legibilidade preservada: título `text-2xl`, subtítulo e progresso `text-sm`, números em branco/negrito. + +**Resultado:** cabeçalho **442px → 210px**, lista **67px → 299px**. + +### 📂 Arquivos +``` +app/controllers/admin/edicao_lancamentos_controller.rb # lista ocorrências, período, fail-soft, carregar +app/policies/edicao_lancamento_policy.rb # + carregar? (era o 500) +app/services/simpli_route/client.rb # visitas_da_data(data, busca:) → &search= +app/views/admin/edicao_lancamentos/show.html.erb # lista de ocorrências, De/Até, avisos, fuso +config/routes.rb # + get :carregar +bin/sondar_busca_nf # NOVO — sondagem de filtros da API (só GET) +app/views/consolidacao_entregas/validar.html.erb # cabeçalho compacto + barra de ações +app/javascript/controllers/validacao_controller.js # atualizarSelecao / limparSelecao +app/views/consolidacao_entregas/revisar.html.erb # grid-cols-7 → auto-fit +app/views/consolidacoes/index.html.erb # filtros → flex-wrap +app/views/dashboard/index.html.erb # KPIs → auto-fit +app/views/configuracoes/index.html.erb # preços → auto-fit +app/views/admin/configuracoes/index.html.erb # idem (arquivo distinto, também vivo) +``` + +### ⚠️ Pendências e alertas +- **Tailwind vem do Play CDN** (`cdn.tailwindcss.com`, em `application.html.erb:15`), que gera CSS no + navegador em tempo real. A documentação oficial **desaconselha em produção** (~380KB bloqueando a + renderização, recompilação a cada carregamento). O **`tailwind.config.js` do repositório não está + sendo usado** — a config real está embutida no layout (linha 17), e a gem `tailwindcss-rails` está + no Gemfile sem servir CSS. +- **Não existe teste para `Admin::EdicaoLancamentosController`** (`spec/` não tem nada de + `edicao_lancamento`). Duas das quebras acima — policy faltando e `hidden` não removido — seriam + pegas por um teste de request/sistema em segundos, sem custar deploy. +- **Token do SimpliRoute:** se passou por chat/e-mail, **rotacione** em `app2.simpliroute.com`. + +> **Sem migration e sem gem nova** — controller, policy, service, views e JS. +> ⚠️ Reiniciar o Puma após o deploy (cache de classes/views). + +
+ diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js index 87708b1..2f54e20 100644 --- a/app/javascript/controllers/validacao_controller.js +++ b/app/javascript/controllers/validacao_controller.js @@ -7,7 +7,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "linha", "checkbox", "selecionarTodos", "busca", "buscaVazia", - "barraSelecao", "contadorSelecao", + "grupoPilares", "chipSelecao", "contadorSelecao", "btnLimpar", "resumo", "listaCol", "btnResumoLabel", "toggles", "barra", "barraManuais", "percentual", "chipVeiculo", "consolidarBtn", "consolidarHint", @@ -102,13 +102,23 @@ export default class extends Controller { this.atualizarSelecao() } - // ── Barra de ações em massa: só existe quando há seleção ── - // Sem isto os 5 botões de pilar ficavam fixos no topo o tempo todo, comendo - // a altura da lista para não fazer nada (clicar sem seleção só alertava). + // ── Estado da seleção ── + // Os pilares ficam SEMPRE na tela; sem seleção apenas esmaecem. Esconder e + // remostrar mexia na altura do cabeçalho a cada clique e escondia a ação + // principal justamente de quem ainda não sabe que precisa selecionar antes. atualizarSelecao() { const n = this.checkboxTargets.filter(c => c.checked).length + if (this.hasContadorSelecaoTarget) this.contadorSelecaoTarget.textContent = n - if (this.hasBarraSelecaoTarget) this.barraSelecaoTarget.classList.toggle("hidden", n === 0) + if (this.hasChipSelecaoTarget) this.chipSelecaoTarget.classList.toggle("hidden", n === 0) + if (this.hasBtnLimparTarget) this.btnLimparTarget.classList.toggle("hidden", n === 0) + + if (this.hasGrupoPilaresTarget) { + this.grupoPilaresTarget.classList.toggle("opacity-40", n === 0) + this.grupoPilaresTarget.title = n === 0 + ? "Marque ao menos uma entrega para aplicar em massa" + : `Aplicar em ${n} entrega(s) — clique de novo no pilar para remover` + } } limparSelecao() { diff --git a/app/views/admin/configuracoes/index.html.erb b/app/views/admin/configuracoes/index.html.erb index f90d925..8fbfee7 100644 --- a/app/views/admin/configuracoes/index.html.erb +++ b/app/views/admin/configuracoes/index.html.erb @@ -11,7 +11,10 @@ <%# Pilares de preço %>

<%= icone :dinheiro, espaco: false %> Pilares de Preço

-
+ <%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do + conteúdo — os cards de preço ficavam com ~128px úteis e o valor em + moeda espremia. Aqui o número de colunas segue a largura REAL. %> +
<% @configuracoes.each do |cfg| %>
diff --git a/app/views/configuracoes/index.html.erb b/app/views/configuracoes/index.html.erb index f10cf9b..1567874 100644 --- a/app/views/configuracoes/index.html.erb +++ b/app/views/configuracoes/index.html.erb @@ -11,7 +11,10 @@ <%# Pilares de preço %>

<%= icone :dinheiro, espaco: false %> Pilares de Preço

-
+ <%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do + conteúdo — os cards de preço ficavam com ~128px úteis e o valor em + moeda espremia. Aqui o número de colunas segue a largura REAL. %> +
<% @configuracoes.each do |cfg| %>
diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index 011077b..b4c93d9 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -11,8 +11,11 @@

<%= @consolidacao.nome %>

- <%# Resumo por tipo %> -
+ <%# Resumo por tipo — auto-fit em vez de `md:grid-cols-7`. + Sete colunas fixas a partir de 768px dão ~55px cada quando a sidebar está + aberta (768 − 256 de menu − padding), destruindo rótulos como + "Extraordinária" e "Termo Especial". O mínimo de 9rem cabe o maior rótulo. %> +
<% [ ['entrega_normal', 'Normal', 'bg-orange-500 text-black'], ['retirada', 'Retirada', 'bg-orange-800 text-white'], diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 5440890..31eb1b9 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -147,14 +147,39 @@ abaixo, que carrega os modais) entra NESTA mesma faixa via flex `order`, sem precisar mover os modais de lugar. %>
-
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %> From d3667ac48a96a41fc93419db058e04cdb600eb9337d50a86182e15163229b5e3 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 10:01:41 -0300 Subject: [PATCH 09/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20do=20relat=C3=B3rio?= =?UTF-8?q?=20que=20=C3=A9=20gerado=20para=20envio=20gade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/analytics/planilha_entregas.rb | 50 ++++++++++++++----- .../analytics/planilha_entregas_xlsx.rb | 26 +++++++++- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/services/analytics/planilha_entregas.rb b/app/services/analytics/planilha_entregas.rb index 45f25cc..ef2f979 100644 --- a/app/services/analytics/planilha_entregas.rb +++ b/app/services/analytics/planilha_entregas.rb @@ -42,17 +42,35 @@ module Analytics 'nota_fiscal' => 'NOTA FISCAL' }.freeze + # Colunas da gade cujo nome real difere da chave de COLUNAS_GADE (a chave é + # o alias usado no SELECT e na leitura da linha). As tabelas do cliente + # gravam a geo como `lat`/`long`, não `latitude`/`longitude` — sem isso as + # duas colunas saíam vazias. Ordem = preferência; a 1ª que existir é usada. + ALIAS_GADE = { + 'latitude' => %w[latitude lat], + 'longitude' => %w[longitude long] + }.freeze + # Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio). CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze # Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela # de rastreio (a primeira que existir é usada; nenhuma existente = vazia). + # + # O 1º candidato de cada linha é o nome REAL conferido na + # db_reem_simplerout_2026 (o espelho usa os nomes do export do SimpliRoute: + # `codrivers`, `responsible_person`, `advance`, `start_of_time_window_1`, + # `required_skills`...). Os demais candidatos são só rede de segurança para + # o dia em que o espelho for recriado com outra grafia. + # + # 'Load 4' fica sem candidato de propósito: essa coluna NÃO existe no + # espelho (só load, load_2 e load_3) — sai vazia, preservando o layout. RASTREIO_COLUNAS = { 'Tracking ID' => %w[tracking_id], 'Referência ID' => %w[reference_id], 'Data prevista' => %w[planned_date], 'Motorista' => %w[driver], - 'Co-pilotos' => %w[copilots co_pilotos copilotos], + 'Co-pilotos' => %w[codrivers copilots co_pilotos copilotos], 'Veículo' => %w[vehicle], 'Título' => %w[title titulo], 'Endereço' => %w[address], @@ -60,10 +78,10 @@ module Analytics 'ETD' => %w[etd], 'Checkin' => %w[checkin], 'Checkout' => %w[checkout], - 'Responsável' => %w[receiver responsavel responsible], + 'Responsável' => %w[responsible_person receiver responsavel responsible], 'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration], 'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real], - 'Antecipação' => %w[early antecipacao anticipation], + 'Antecipação' => %w[advance early antecipacao anticipation], 'Atraso' => %w[delay atraso], 'Latitude' => %w[latitude], 'Longitude' => %w[longitude], @@ -72,16 +90,16 @@ module Analytics 'Load' => %w[load load_1 carga], 'Load 2' => %w[load_2], 'Load 3' => %w[load_3], - 'Load 4' => %w[load_4], + 'Load 4' => %w[], 'Estado' => %w[status], - 'Comentários' => %w[checkout_comment comments comentarios comment], + 'Comentários' => %w[comments checkout_comment comentarios comment], 'Observações' => %w[observation observacoes], - 'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1], - 'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1], - 'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2], - 'Janela de horário Final 2' => %w[window_end_2 janela_final_2], - 'Habilidades necessárias' => %w[skills_required habilidades_necessarias], - 'Habilidades adicionais' => %w[skills_optional habilidades_adicionais], + 'Janela de horário Inicial 1' => %w[start_of_time_window_1 window_start janela_inicial_1], + 'Janela de horário Final 1' => %w[end_of_time_window_1 window_end janela_final_1], + 'Janela de horário Inicial 2' => %w[start_of_time_window_2 window_start_2 janela_inicial_2], + 'Janela de horário Final 2' => %w[end_of_time_window_2 window_end_2 janela_final_2], + 'Habilidades necessárias' => %w[required_skills skills_required habilidades_necessarias], + 'Habilidades adicionais' => %w[optional_skills skills_optional habilidades_adicionais], 'Notas' => %w[notes notas], 'Nome de contato' => %w[contact_name], 'Telefone de contato' => %w[contact_phone telefone_de_contato], @@ -93,7 +111,10 @@ module Analytics 'Termo de Recebimento' => %w[termo_de_recebimento], 'Foto da Fachada' => %w[foto_da_fachada], 'Relatório de visita' => %w[relatorio_de_visita visit_report], - 'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base] + 'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base], + # Campo do espelho que o modelo antigo do cliente ainda não tinha. Entra + # DEPOIS das colunas do modelo para não deslocar o layout A..AT dele. + 'Protocolo de Entrega' => %w[protocolo_de_entrega] }.freeze # Quadro do RESUMO — linhas fixas do modelo. @@ -204,10 +225,13 @@ module Analytics 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. def selects_gade existentes = conn.columns(@tabela).map(&:name) COLUNAS_GADE.keys.map do |c| - existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}" + real = (ALIAS_GADE[c] || [c]).find { |cand| existentes.include?(cand) } + real ? "g.#{conn.quote_column_name(real)} AS #{c}" : "CAST(NULL AS text) AS #{c}" end.join(', ') end diff --git a/app/services/analytics/planilha_entregas_xlsx.rb b/app/services/analytics/planilha_entregas_xlsx.rb index dba9da2..1a84dc1 100644 --- a/app/services/analytics/planilha_entregas_xlsx.rb +++ b/app/services/analytics/planilha_entregas_xlsx.rb @@ -66,6 +66,9 @@ module Analytics # Cabeçalho da ENTREGAS @cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true) @cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true) + + # Hiperlink (fotos/comprovantes do SimpliRoute) — azul sublinhado padrão. + @link = st.add_style(fg_color: '0563C1', u: true) end # ── RESUMO ─────────────────────────────────────────────────── @@ -183,19 +186,38 @@ module Analytics # Dump cru do rastreio (todas as visitas das NFs da operação), com o # cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem # coluna correspondente sai vazio. + # + # As colunas de foto (Nota Fiscal, Termo de Recebimento, Foto da Fachada, + # Relatório de visita) guardam URLs do S3 do SimpliRoute: viram HIPERLINK + # clicável (azul sublinhado), para o cliente abrir o comprovante direto do + # Excel em vez de copiar e colar a URL. def aba_simpliroute(wb) colunas = @planilha.colunas_rastreio linhas = @planilha.linhas_rastreio wb.add_worksheet(name: 'SimpliRoute') do |sheet| sheet.add_row(colunas.keys) linhas.each do |r| - sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' }, - types: Array.new(colunas.size, :string)) + valores = colunas.values.map { |col| col ? texto(r[col]) : '' } + sheet.add_row(valores, types: Array.new(colunas.size, :string)) + linkar_urls(sheet, valores) end sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}" end end + # Transforma em hiperlink toda célula da linha recém-adicionada cujo valor + # seja uma URL http(s) — mesma checagem do foto_url do OperacaoMetricas, + # para não jogar lixo dentro do do arquivo. + def linkar_urls(sheet, valores) + linha = sheet.rows.size + valores.each_with_index do |valor, i| + next unless valor.to_s.match?(%r{\Ahttps?://\S+\z}i) + + sheet.add_hyperlink(location: valor, ref: "#{Axlsx.col_ref(i)}#{linha}") + sheet.rows.last.cells[i].style = @link + end + end + def texto(valor) valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s end From e8f470f878b16b48f3799bc670fc0c64fbd5f56afea9b2633de1f72fed1d14da Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 22 Jul 2026 11:35:59 -0300 Subject: [PATCH 10/10] =?UTF-8?q?Corre=C3=A7=C3=A3o=20do=20relat=C3=B3rio?= =?UTF-8?q?=20que=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