# app/controllers/admin/romaneios_controller.rb # # Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a # tabela com a prévia do PDF real ao lado, e imprime uma folha A4 paisagem por # veículo. # # As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE # LEITURA — o que gravamos são as tabelas `romaneio*`, nossas. # FileUtils chegava aqui de carona no que o Rails carrega. Explícito porque o # upload do logo depende dele: sem o require, o dia em que o Rails parar de # carregá-lo o erro é um NameError no meio da gravação. require 'fileutils' class Admin::RomaneiosController < ApplicationController include Auditavel # ⚠️ TODA ACTION DE COLEÇÃO PRECISA ENTRAR NESTE `except`. `carregar_romaneio` # faz `Romaneio.find(params[:id])`, e numa rota de coleção não existe `:id` — # o find levanta RecordNotFound, que o Rails traduz em **404**. Não é erro de # rota: o roteador casa certinho e a action simplesmente nunca roda. # # Foi o bug de 28/08/2026: `atualizar_logo` foi adicionada ao `collection` do # routes.rb mas esquecida aqui, e clicar em "Trocar logo" devolvia # `POST /admin/romaneios/atualizar_logo 404 (Not Found)`. Como o 404 parece # "rota errada", a busca foi para todo lado menos para cá — e o sintoma que o # operador via era outro ("subi o logo e o PDF não mudou"), porque o upload # nunca chegava a acontecer. before_action :carregar_romaneio, except: %i[index create importar_planilha atualizar_logo] def index authorize Romaneio # includes: a listagem mostra a contagem de veículos de cada romaneio. @romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50) @operacoes_agrupadas = Operacao.agrupadas_por_mes @data_padrao = Date.current # 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 end def show authorize @romaneio @veiculos = @romaneio.romaneio_veiculos.ordenados.to_a @veiculo = veiculo_selecionado @linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo @removidas = @romaneio.linhas_removidas.to_a @colunas_ausentes = colunas_ausentes_da_operacao # Mesma lista da tela de criação: o vínculo manual da operação acontece aqui # também, porque é aqui que se descobre que APARELHO saiu vazio. @operacoes_agrupadas = Operacao.agrupadas_por_mes # A prévia aberta é ESTADO NA URL (?previa=1), não estado de navegador. Ver o # comentário em _previa.html.erb: hash (#previa) não sobrevive ao Turbo e # checkbox não sobrevive à troca de folha. @previa = params[:previa].present? end # Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho). def create authorize Romaneio @romaneio = preparar_romaneio return if performed? # `@plano_escolhido` vem de #preparar_romaneio quando o operador clicou num # plano da lista; sem ele o caminho continua sendo por data. # Os DOIS: a data já foi resolvida em #preparar_romaneio (não paga a chamada # de novo) e ainda destrava a queda para o espelho local se a API cair no # meio; o plano é quem delimita as rotas. plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date, plano: @plano_escolhido) importar(plano.linhas, origem: plano.origem) rescue SimpliRoute::Error => e # NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx. descartar_romaneio_vazio redirect_to admin_romaneios_path, alert: e.message end # Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda não foi # publicado ou a API não responde. def importar_planilha authorize Romaneio @romaneio = preparar_romaneio return if performed? arquivo = params[:arquivo] if arquivo.blank? descartar_romaneio_vazio return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).') end importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha') rescue Romaneios::PlanoPlanilha::Erro => e descartar_romaneio_vazio redirect_to admin_romaneios_path, alert: e.message end # ── Logo do cabeçalho ────────────────────────────────────────────────────── # O romaneio é documento do CLIENTE, não da Reem: quando a operação muda de # contratante, trocar o logo não pode exigir deploy. # # 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. # ⚠️ 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], [' for multipart. Sem isto, um # form mal montado manda uma String e a linha seguinte estoura com # NoMethodError — página 500 em vez de "escolha o arquivo". unless arquivo.respond_to?(:read) && arquivo.respond_to?(:size) return redirect_to(admin_romaneios_path, alert: 'O arquivo não chegou. Escolha o logo e envie de novo.') end if arquivo.size > 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 # ⚠️ A GRAVAÇÃO PODE FALHAR, E FALHAR AQUI NÃO É "bug do Rails": `storage/` # mora no share SMB do NAS, onde permissão de pasta se perde na cópia (o # mesmo motivo que fez o bin/docker-boot ser chamado com `bash` explícito). # Sem este rescue, um EACCES vira a página 500 crua na cara do operador, sem # dizer o que houve nem o que fazer — foi o que aconteceu em 28/08/2026 ao # clicar em "Trocar logo". begin FileUtils.mkdir_p(destino) File.binwrite(destino.join(nome), conteudo) rescue SystemCallError => e Rails.logger.error("[romaneio] falha ao gravar logo em #{destino}: #{e.class}: #{e.message}") return redirect_to(admin_romaneios_path, alert: "Não consegui gravar o logo em #{destino} (#{e.class}). " \ 'A pasta existe e o container tem permissão de escrita nela? ' \ 'O erro completo está no log do container.') end config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo') config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120), updated_by: current_user&.id) redirect_to admin_romaneios_path, notice: "Logo atualizado: #{arquivo.original_filename}. Vale para os próximos PDFs." end # Rebusca o plano e reconcilia com o que já está editado (a regra está em # Romaneios::Importador). def reimportar authorize @romaneio # Reimportar com arquivo em mãos usa o arquivo; sem arquivo, volta ao # SimpliRoute — inclusive para um romaneio que nasceu de planilha (é justamente # assim que o operador troca o plano B pelo A quando a API volta). if params[:arquivo].present? plano = Romaneios::PlanoPlanilha.new(params[:arquivo]) linhas = plano.linhas origem = 'planilha' else plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date) linhas = plano.linhas origem = plano.origem end resultado = aplicar(linhas, origem: origem) auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h) redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), notice: "Plano reimportado — #{resultado.resumo}." rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message end # Vincula a operação do mês à mão e preenche APARELHO/TELEFONES/NOME a partir # dela. É a saída para quando o casamento pelo nome do plano não acontece — sem # isso o único caminho era reimportar, que depende da API responder ou de o # operador ainda ter o .xlsx do plano. def vincular_operacao authorize @romaneio resultado = Romaneios::VinculadorDeOperacao.new(romaneio: @romaneio, tabela: params[:operacao_tabela]).aplicar! auditar!(:vincular_operacao, @romaneio, dados_novos: resultado.to_h.merge(operacao_tabela: @romaneio.operacao_tabela)) # Sem a coluna STATUS a operação não sabe quem é NOVO: o vínculo deu certo e # mesmo assim ninguém recebe aparelho. Dizer isso aqui evita a leitura de que # "vinculei e continua vazio, logo o sistema está errado". aviso = if resultado.colunas_ausentes.include?('status') " Atenção: esta operação não tem a coluna STATUS, então APARELHO continua vazio e precisa ser preenchido à mão." else '' end redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), notice: "Operação #{@romaneio.operacao_label} vinculada — #{resultado.resumo}.#{aviso}" rescue ArgumentError redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: 'Selecione uma operação válida.' rescue Romaneios::VinculadorDeOperacao::Conflito => e redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: "#{e.message} Abra o romaneio de " \ "#{l e.existente.planned_date, format: '%d/%m/%Y'} (#{e.existente.rotulo_plano.presence || 'sem rótulo'}) " \ 'em vez de duplicar o documento do dia.' end # Com ?veiculo= sai INLINE (é a prévia do editor, uma folha só, sub-100ms); # sem veiculo sai o romaneio inteiro como download. def pdf authorize @romaneio veiculo = params[:veiculo].to_s binario = Pdf::RomaneioPdf.new(romaneio: @romaneio, veiculos: veiculo.presence && [veiculo]).render # A prévia é regerada a cada edição: cache do navegador aqui devolveria o PDF # anterior e o operador acharia que a alteração não gravou. expires_now if veiculo.present? send_data binario, type: 'application/pdf', disposition: veiculo.present? ? 'inline' : 'attachment', filename: nome_do_arquivo(veiculo) end # JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano # trouxe (e destrava o campo para a próxima reimportação). def atualizar_linha authorize @romaneio linha = @romaneio.romaneio_linhas.find(params[:linha_id]) campo = params[:campo].to_s unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo) return render json: { ok: false, erro: 'Campo não editável.' }, status: :unprocessable_entity end if params[:valor].nil? linha[campo] = linha.valor_do_plano(campo) linha.desmarcar_editado!(campo) else linha[campo] = params[:valor].to_s linha.marcar_editado!(campo) end linha.save! # Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação é # last-write-wins, e assim uma sobreposição de duas abas fica visível em vez # de silenciosa. render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) } end def atualizar_veiculo authorize @romaneio registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s) registro.update!(motorista: params[:motorista].to_s) render json: { ok: true, valor: registro.motorista } end def atualizar_rotulo authorize @romaneio @romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s) render json: { ok: true, valor: @romaneio.rotulo_plano } end def destroy authorize @romaneio # Auditar ANTES: depois do destroy não há mais atributos para registrar. auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes) @romaneio.destroy! redirect_to admin_romaneios_path, notice: 'Romaneio excluído.' end private def carregar_romaneio @romaneio = Romaneio.find(params[:id]) end # Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de # "a coluna APARELHO saiu toda vazia": vazio também é o resultado legítimo de uma # operação só com pacientes recorrentes, e um aviso que erra o motivo é pior do # que nenhum. def colunas_ausentes_da_operacao return [] if @romaneio.operacao_tabela.blank? Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes rescue ArgumentError # Operação removida do banco depois que o romaneio foi criado. Romaneios::EnriquecimentoGade::COLUNAS.keys end # Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` é o que faz # "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo # romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso. def preparar_romaneio # ── Plano escolhido na lista (caminho normal desde 28/08/2026) ────────── # O plano resolve de uma vez os três campos que antes eram digitados: a # DATA (perguntada à API, porque a janela do plano não a revela), o NOME do # plano (que ia colado à mão e é o que descobre a operação do mês) e as # rotas, que delimitam o romaneio quando dois planos caem no mesmo dia. @plano_escolhido = nil data = params[:planned_date].presence if params[:plano_id].present? @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.' return nil end # A data REAL do plano, não a janela dele. Sem isso o romaneio nasceria # com `planned_date` errado e o "Reimportar" (que é por data) buscaria o # dia errado depois. resolvida = Romaneios::PlanoDoDia.new(plano: @plano_escolhido).data_resolvida if resolvida.nil? redirect_to admin_romaneios_path, alert: "Não consegui descobrir o dia do plano \"#{@plano_escolhido['name']}\". " \ 'Informe a data ou envie a planilha do plano (.xlsx).' return nil end data = resolvida.to_s params[:rotulo_plano] = @plano_escolhido['name'] if params[:rotulo_plano].blank? end if data.blank? redirect_to admin_romaneios_path, alert: 'Escolha um plano ou informe a data.' return nil end tabela = params[:operacao_tabela].to_s if tabela.present? && !Operacao.valida?(tabela) redirect_to admin_romaneios_path, alert: 'Selecione uma operação válida.' return nil end # Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD # 09.2026" → gade_entregas_emad_set_2026). # # POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é # paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a # coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho # não entregue. A operação não é campo opcional na prática; era só difícil de # acertar, porque o nome do plano e o nome da tabela nunca são iguais. # # Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada # preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém # desconfiar. A tela avisa quando ficou sem. @operacao_deduzida = nil if tabela.blank? && params[:rotulo_plano].present? tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s @operacao_deduzida = tabela.presence end data = Date.parse(data) Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r| r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data) r.importado_por = current_user end rescue Date::Error redirect_to admin_romaneios_path, alert: 'Data do plano inválida.' 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) # Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é # invisível, mas dedução silenciosa que erra é indefensável — o operador # precisa ver de qual mês vieram os dados de APARELHO. aviso = if @operacao_deduzida " Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano." else '' end redirect_to admin_romaneio_path(@romaneio), notice: "Plano importado — #{resultado.resumo}.#{aviso}" end def aplicar(linhas, origem:) Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas, usuario: current_user, origem: origem).aplicar! end # Um romaneio recém-criado que não chegou a receber nenhuma parada é lixo: some # da lista para o operador não achar que a importação funcionou. def descartar_romaneio_vazio @romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none? end def veiculo_selecionado pedido = params[:veiculo].to_s return pedido if @veiculos.any? { |v| v.veiculo == pedido } @veiculos.first&.veiculo end def nome_do_arquivo(veiculo) partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo] "#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf" end end