diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb index 612137f..5c1253c 100644 --- a/app/controllers/admin/romaneios_controller.rb +++ b/app/controllers/admin/romaneios_controller.rb @@ -18,10 +18,22 @@ class Admin::RomaneiosController < ApplicationController @operacoes_agrupadas = Operacao.agrupadas_por_mes @data_padrao = Date.current - # Os 5 planos mais recentes, para o operador ESCOLHER em vez de adivinhar a - # data. Best-effort de propósito: API fora do ar devolve [] e a tela cai no - # campo de data de sempre — a importação nunca fica bloqueada por isso. - @planos = SimpliRoute::Client.new.planos(limite: 5) + # Os planos para o operador ESCOLHER em vez de adivinhar a data. + # + # Sem período: os 5 mais recentes — a ferramenta é usada logo depois de + # lançar uma operação, então o que ele quer está sempre entre os últimos, e + # o caso comum fica em zero clique. + # Com período: TODOS os planos da faixa. É assim que se alcança um plano + # antigo sem saber o dia dele — e é o mesmo recorte da tela do SimpliRoute, + # para o operador não ter que traduzir nada entre as duas. + @plano_inicio = params[:plano_inicio].presence + @plano_fim = params[:plano_fim].presence + @planos = SimpliRoute::Client.new.planos(limite: 5, inicio: @plano_inicio, fim: @plano_fim) + @planos_filtrados = @plano_inicio.present? && @plano_fim.present? + + # O logo escolhido sumiu do disco? A tela precisa dizer — foi exatamente + # assim que o PDF saiu com o logo antigo sem ninguém entender (28/08/2026). + _, @logo_problema = Pdf::RomaneioPdf.logo_configurado # Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo # que o operador enviou; nil = ainda usando o logo da GADE do repositório. @logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao @@ -87,7 +99,28 @@ class Admin::RomaneiosController < ApplicationController # # PNG/JPG apenas porque é o que o Prawn embute — um SVG aceito aqui sairia como # folha SEM logo, sem erro nenhum, e ninguém descobriria antes de imprimir. - LOGO_TIPOS = { 'image/png' => '.png', 'image/jpeg' => '.jpg' }.freeze + # ⚠️ O FORMATO É DECIDIDO PELO CONTEÚDO DO ARQUIVO, não pelo `content_type` + # que o navegador manda. O content_type é entrada do usuário: Windows e alguns + # navegadores mandam `application/octet-stream` para um PNG perfeitamente + # válido, e a versão antiga (`LOGO_TIPOS[arquivo.content_type]`) RECUSAVA esse + # arquivo dizendo "precisa ser PNG ou JPG" — para um PNG. Assinatura de arquivo + # não mente. + # + # A lista é curta porque quem manda aqui é o Prawn: ele embute **PNG e JPEG**, + # e só. Formato que ele não embute não pode ser aceito "para ver no que dá" — + # viraria um PDF quebrado na hora de imprimir, ou um logo que some sem aviso. + # Os outros formatos são RECONHECIDOS mesmo assim, para a mensagem poder dizer + # o que a pessoa mandou e o que fazer, em vez de repetir "PNG ou JPG". + ASSINATURAS = [ + ["\x89PNG\r\n\x1a\n".b, '.png', 'PNG', true], + ["\xFF\xD8\xFF".b, '.jpg', 'JPEG', true], + ['RIFF'.b, nil, 'WebP', false], # confirmado abaixo pelo 'WEBP' + ['GIF8'.b, nil, 'GIF', false], + ['BM'.b, nil, 'BMP', false], + ['%PDF'.b, nil, 'PDF', false], + [' LOGO_TAMANHO_MAX return redirect_to(admin_romaneios_path, alert: 'Logo acima de 2 MB. Reduza a imagem antes de enviar.') end + # Lê tudo UMA vez: o mesmo conteúdo serve para identificar o formato e para + # gravar. Reler o upload depois de `read` devolveria vazio. + conteudo = arquivo.read.to_s.b + extensao, formato, aceito = identificar_imagem(conteudo) + + if extensao.nil? + recado = if formato + "O arquivo é #{formato}, e o PDF só embute PNG ou JPG. " \ + 'Salve o logo como PNG (qualquer editor exporta) e envie de novo.' + else + 'Não reconheci esse arquivo como imagem. O logo precisa ser PNG ou JPG.' + end + return redirect_to(admin_romaneios_path, alert: recado) + end + # `aceito` existe para o dia em que alguém adicionar conversão: hoje só + # chega aqui o que o Prawn embute. + return redirect_to(admin_romaneios_path, alert: 'Formato não suportado.') unless aceito + # Nome GERADO por nós: o nome que vem do navegador é entrada do usuário e não # entra em caminho de arquivo. O original fica só na descrição, para a tela # mostrar "Logo: LOGOGADE.png" como o operador reconhece. nome = "logo-#{Time.current.strftime('%Y%m%d%H%M%S')}#{extensao}" destino = Pdf::RomaneioPdf.pasta_logos FileUtils.mkdir_p(destino) - File.binwrite(destino.join(nome), arquivo.read) + File.binwrite(destino.join(nome), conteudo) config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo') config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120), @@ -281,8 +327,7 @@ class Admin::RomaneiosController < ApplicationController data = params[:planned_date].presence if params[:plano_id].present? - @plano_escolhido = SimpliRoute::Client.new.planos(limite: 20) - .find { |pl| pl['id'].to_s == params[:plano_id].to_s } + @plano_escolhido = SimpliRoute::Client.new.plano(params[:plano_id]) if @plano_escolhido.nil? redirect_to admin_romaneios_path, alert: 'Plano não encontrado no SimpliRoute — recarregue a página e escolha de novo.' @@ -342,6 +387,20 @@ class Admin::RomaneiosController < ApplicationController nil end + # Formato pela ASSINATURA do arquivo. Devolve [extensao, nome_do_formato, + # aceito?] — com nome preenchido mesmo para o que não serve, para a mensagem + # poder dizer "é WebP" em vez de "não é PNG ou JPG". + def identificar_imagem(conteudo) + achado = ASSINATURAS.find { |assinatura, _, _, _| conteudo.start_with?(assinatura) } + return [nil, nil, false] if achado.nil? + + _, extensao, formato, aceito = achado + # RIFF é contêiner (WebP, WAV, AVI): o que diz que é WebP são os bytes 8..11. + return [nil, nil, false] if formato == 'WebP' && conteudo[8, 4] != 'WEBP' + + [extensao, formato, aceito] + end + def importar(linhas, origem:) resultado = aplicar(linhas, origem: origem) auditar!(:criar, @romaneio, dados_novos: resultado.to_h) diff --git a/app/services/pdf/romaneio_pdf.rb b/app/services/pdf/romaneio_pdf.rb index 17d6be0..988ed94 100644 --- a/app/services/pdf/romaneio_pdf.rb +++ b/app/services/pdf/romaneio_pdf.rb @@ -126,15 +126,47 @@ module Pdf end def self.logo_escolhido + caminho, = logo_configurado + caminho + end + + # O logo escolhido E o motivo de ele não estar valendo — os dois juntos, + # porque quem chama precisa saber a diferença entre "ninguém escolheu logo" + # e "o logo que o operador subiu SUMIU do disco". + # + # ⚠️ ISTO ERA UM `File.exist?(x) ? x : nil` MUDO, e foi um bug real + # (relatado em 28/08/2026): o operador subiu outro logo, a tela disse "Logo + # atualizado", e o PDF continuou saindo com o logo antigo — porque o arquivo + # não estava mais na pasta e o método caía no padrão sem dizer nada a + # ninguém. Falha silenciosa em cima de documento que vai IMPRESSO para o + # cliente é o pior tipo: ninguém procura o que não reclama. + # + # A causa mais provável do arquivo sumir: `storage/` é gitignorado + # (`/storage/*`) e o deploy é cópia de pasta pelo share SMB do NAS — a cópia + # leva junto a pasta vazia da máquina de quem publicou. Se acontecer de + # novo, o log e a tela agora dizem. + # + # Devolve [caminho, problema]: + # [nil, nil] -> ninguém escolheu logo (usa o da GADE, normal) + # [caminho, nil] -> logo escolhido e presente + # [nil, :sumiu] -> escolhido MAS o arquivo não está lá + # [nil, :sem_banco] -> sem Rails/tabela (o container pelado do layout) + def self.logo_configurado nome = Configuracao.valor('romaneio_logo').to_s - return nil if nome.strip.empty? + return [nil, nil] if nome.strip.empty? caminho = pasta_logos.join(nome).to_s - File.exist?(caminho) ? caminho : nil + return [caminho, nil] if File.exist?(caminho) + + if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + Rails.logger.error("[romaneio] logo '#{nome}' configurado mas AUSENTE em #{pasta_logos} — " \ + 'o PDF saiu com o logo padrão') + end + [nil, :sumiu] rescue StandardError # Sem banco (a conferência de layout roda num container Ruby pelado) ou # tabela ainda não migrada: o PDF continua saindo, com o logo padrão. - nil + [nil, :sem_banco] end def render diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index f3d47f8..191f55b 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -64,6 +64,16 @@ module SimpliRoute Array(get(caminho)) end + # UM plano pelo uuid. Existe separado de `planos` porque a busca do plano + # ESCOLHIDO não pode depender de recorte nenhum: o operador pode ter filtrado + # por período e escolhido um plano fora dos mais recentes, e procurá-lo numa + # lista cortada devolveria "plano não encontrado" para um plano que existe. + def plano(id) + Array(get('/v1/routes/plans/')).find { |pl| pl['id'].to_s == id.to_s } + rescue Error + nil + end + # UMA rota pelo uuid. Serve para descobrir o DIA REAL de um plano: o registro # do plano só traz a JANELA (`start_date`/`end_date`), e o dia das rotas nem # sempre é o começo dela — medido em 28/08/2026: EMAD SETEMBRO 2026 (31/08 a @@ -113,12 +123,22 @@ module SimpliRoute # (o EMAD SETEMBRO 2026 vai de 31/08 a 08/09), e é justamente essa janela que # tornava impossível adivinhar "a data do plano". Quem publicou por último é # quem o operador quer — ele abre a ferramenta logo depois de lançar. - def planos(limite: 5) - Array(get('/v1/routes/plans/')) - .sort_by { |p| p['created'].to_s } - .reverse - .first(limite) - rescue Error + # + # COM PERÍODO (`inicio`/`fim`) o corte muda de propósito: em vez dos N mais + # recentes, devolve TODOS os planos da faixa — é assim que se alcança um + # plano antigo sem saber o dia dele, e é o mesmo recorte que a tela do + # SimpliRoute usa. O critério é a JANELA DO PLANO CRUZAR o período + # (`start_date <= fim AND end_date >= inicio`), a mesma semântica de + # `Consolidacao.cruzando_periodo`: um plano de 31/08 a 08/09 pertence a + # agosto E a setembro, e some da lista se a regra for "está dentro". + def planos(limite: 5, inicio: nil, fim: nil) + lista = Array(get('/v1/routes/plans/')).sort_by { |p| p['created'].to_s }.reverse + return lista.first(limite) if inicio.blank? || fim.blank? + + i = inicio.to_date + f = fim.to_date + lista.select { |p| cruza_periodo?(p, i, f) } + rescue Error, Date::Error [] end @@ -192,6 +212,18 @@ module SimpliRoute private + # Plano sem data em um dos lados não some da lista por isso: usa a que tem. + # Sumir em silêncio é o pior resultado possível numa lista de escolha. + def cruza_periodo?(plano, inicio, fim) + comeco = Date.parse(plano['start_date'].to_s) rescue nil + termino = Date.parse(plano['end_date'].to_s) rescue nil + comeco ||= termino + termino ||= comeco + return false if comeco.nil? + + comeco <= fim && termino >= inicio + end + def get(path) requisicao(Net::HTTP::Get.new(caminho(path))) end diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index 0c8d7fc..d9150a5 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -36,6 +36,16 @@ Em uso: <%= @logo_atual.presence || 'logo-gade.png (padrão)' %>

+ + <%# O logo está configurado mas o ARQUIVO sumiu do disco. Antes isto era + mudo: a tela dizia o nome do logo novo e o PDF saía com o antigo. + Agora aparece aqui, que é onde a pessoa está olhando quando troca. %> + <% if @logo_problema == :sumiu %> +

+ O arquivo deste logo não está mais no servidor — os PDFs estão + saindo com o logo padrão. Envie o arquivo de novo. +

+ <% end %> @@ -66,6 +76,42 @@ tela "A ação foi recusada por uma verificação de segurança" e volta ao início — enquanto o botão de buscar, na action original, passava. O helper sem argumentos devolve o token GLOBAL, válido para as duas actions. %> + <%# ── Período dos planos ──────────────────────────────────────────────── + Form SEPARADO e em GET: filtra a LISTA de planos, não importa nada. + Fica antes do formulário de importação porque é o que se mexe primeiro + quando o plano procurado não está entre os 5. + + Por que período e não data: período é aproximado por natureza — "agosto" + a pessoa sabe; "20/08" ela não sabia, e era esse chute que a tela veio + eliminar. É também o recorte que a própria tela do SimpliRoute usa, então + não há tradução a fazer entre as duas. %> + <%= form_with url: admin_romaneios_path, method: :get, + class: 'flex flex-wrap items-end gap-3 mb-4 pb-4 border-b border-white/5' do %> +
+ + +
+
+ + +
+ + <%# Rótulo que diz o que o controle FAZ (diretriz 2): limpa o período e + devolve os 5 mais recentes — não "limpa a tela". %> + <% if @planos_filtrados %> + <%= link_to 'Voltar aos 5 mais recentes', admin_romaneios_path, + class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-xl min-h-[48px] inline-flex items-center whitespace-nowrap' %> + <% end %> + <% end %> + <%= form_with url: admin_romaneios_path, method: :post, multipart: true, authenticity_token: form_authenticity_token, data: { turbo: false }, class: 'space-y-4' do %> @@ -96,26 +142,33 @@ <%= pl['name'] %> · <%= Array(pl['routes']).size %> veículos <% end %> - <%# Escape para plano mais antigo que os 5, ou para refazer um - romaneio de um dia específico: valor vazio faz o controller - usar o campo de data abaixo, como sempre fez. %> -

- Os 5 planos mais recentes do SimpliRoute. O dia é descoberto pelo plano. + <% if @planos_filtrados %> + <%= pluralize(@planos.size, 'plano', plural: 'planos') %> no período. + <% else %> + Os 5 mais recentes. O dia é descoberto pelo plano. + <% end %>

<% else %>

- Não consegui listar os planos do SimpliRoute agora — informe a data. + <% if @planos_filtrados %> + Nenhum plano nesse período — amplie a faixa ou limpe o filtro. + <% else %> + Não consegui listar os planos do SimpliRoute agora — informe a data. + <% end %>

<% end %> - <%# Continua existindo sempre: é o caminho do "— usar uma data - específica —" e a rede quando a API não responde. %> - - class="w-full mt-2 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white - focus:outline-none focus:border-orange-500 min-h-[48px]"> + <%# A data solta só aparece quando NÃO há lista de planos: ela é a rede + para a API fora do ar, e voltar a oferecê-la de graça reintroduziria + justamente o chute que esta tela existe para eliminar. Para alcançar + um plano antigo o caminho é o filtro de período, logo abaixo. %> + <% if @planos.blank? %> + + <% end %>