# 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. class Admin::RomaneiosController < ApplicationController include Auditavel before_action :carregar_romaneio, except: %i[index create importar_planilha] 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 # 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 = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date) 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. LOGO_TIPOS = { 'image/png' => '.png', 'image/jpeg' => '.jpg' }.freeze LOGO_TAMANHO_MAX = 2.megabytes def atualizar_logo authorize Romaneio arquivo = params[:logo] return redirect_to(admin_romaneios_path, alert: 'Escolha o arquivo do logo (PNG ou JPG).') if arquivo.blank? extensao = LOGO_TIPOS[arquivo.content_type.to_s] if extensao.nil? return redirect_to(admin_romaneios_path, alert: 'O logo precisa ser PNG ou JPG — é o que o PDF consegue embutir.') 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 # 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) 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 data = params[:planned_date].presence if data.blank? redirect_to admin_romaneios_path, alert: 'Informe a data do plano.' 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 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