# app/services/romaneios/plano_planilha.rb # # Lê o .xlsx do PLANO exportado do SimpliRoute e devolve as linhas na mesma forma # que SimpliRoute::PlanoDoDia — é o caminho B do romaneio, para quando a API não # responde ou o plano do dia ainda não foi publicado. # # ⚠️ O arquivo NÃO é persistido em lugar nenhum. Ele tem nome, endereço e telefone # de paciente; o ActiveStorage aqui é configurado condicionalmente e não há política # de retenção para pendurar isso. Lemos do tempfile e descartamos. require 'roo' module Romaneios class PlanoPlanilha class Erro < StandardError; end # Cabeçalhos aceitos, já normalizados (sem acento, minúsculo, espaço colapsado). # # ⚠️ Casamos por NOME e nunca por posição: o export do cliente varia. Medido no # arquivo real (SimpliRoute_Plan_ 2026-08-31.xlsx, 2047 linhas / 72 veículos): # a coluna "Motorista" vem VAZIA em todas as linhas e NÃO existem as colunas # "Aparelho", "Pessoa de contato" nem "Telefone de contato" — que o programa # Python original lia. Faltar qualquer uma delas é o caso NORMAL, não erro. # # "id de referencua" é um typo do próprio SimpliRoute; aceitamos os dois porque # eles podem corrigir a qualquer momento e aí o romaneio pararia de achar a NF. COLUNAS = { veiculo: ['veiculo', 'vehicle'], motorista: ['motorista', 'driver'], indice: ['indice de rota', 'route index'], ordem: ['ordem de entrega', 'delivery order'], titulo: ['titulo', 'title'], endereco: ['endereco', 'address'], anotacoes: ['anotacoes', 'notes'], nota_fiscal: ['id de referencua', 'id de referencia', 'reference id', 'nota fiscal'] }.freeze TAMANHO_MAXIMO = 20 * 1024 * 1024 LINHAS_MAXIMO = 20_000 ASSINATURA_ZIP = "PK\x03\x04".b.freeze # `entrada` é o tempfile do upload (ou um caminho, nos specs). def initialize(entrada) @caminho = caminho_de(entrada) validar_arquivo! end def linhas @linhas ||= montar end private def caminho_de(entrada) return entrada.tempfile.path if entrada.respond_to?(:tempfile) return entrada.path if entrada.respond_to?(:path) entrada.to_s end # Guardas antes de entregar o arquivo para a roo: um .xlsx é um zip, e um zip # inesperado (ou gigante) vira consumo de memória sem limite dentro do processo # que atende a requisição. def validar_arquivo! raise Erro, 'Envie a planilha do plano (.xlsx).' unless File.exist?(@caminho) raise Erro, 'A planilha passa de 20 MB — não parece ser o plano do dia.' if File.size(@caminho) > TAMANHO_MAXIMO assinatura = File.binread(@caminho, 4) return if assinatura == ASSINATURA_ZIP raise Erro, 'O arquivo não é um .xlsx. Baixe o plano do SimpliRoute em Excel e envie de novo.' end def montar planilha = Roo::Excelx.new(@caminho) cabecalho = mapear_colunas(planilha.row(1)) if cabecalho[:veiculo].nil? raise Erro, 'A planilha não tem a coluna "Veículo" — confira se é o plano ' \ 'do SimpliRoute e não a planilha de carga.' end total = planilha.last_row.to_i raise Erro, "A planilha tem #{total} linhas — acima do limite de #{LINHAS_MAXIMO}." if total > LINHAS_MAXIMO registros = (2..total).filter_map { |i| montar_linha(planilha.row(i), cabecalho, i) } ordenar(registros) ensure planilha&.close if planilha.respond_to?(:close) end # { chave => índice da coluna }. Coluna ausente simplesmente não entra no hash. def mapear_colunas(cabecalho) normalizados = Array(cabecalho).map { |c| normalizar_cabecalho(c) } COLUNAS.each_with_object({}) do |(chave, aceitos), memo| idx = normalizados.index { |c| aceitos.include?(c) } memo[chave] = idx if idx end end # Sem acento, minúsculo, espaço colapsado — o export alterna maiúscula/minúscula # e o acento varia com a versão do arquivo. def normalizar_cabecalho(valor) I18n.transliterate(valor.to_s).downcase.strip.gsub(/\s+/, ' ') end def montar_linha(linha, cabecalho, numero_da_linha) valor = ->(chave) { cabecalho[chave] ? linha[cabecalho[chave]] : nil } veiculo = valor.call(:veiculo).to_s.strip titulo = valor.call(:titulo).to_s.strip ender = valor.call(:endereco).to_s.strip # Linha sem veículo, ou sem nada que identifique a parada, é lixo de rodapé # do próprio export — pular em silêncio é o certo. return nil if veiculo.empty? || (titulo.empty? && ender.empty?) Normalizador.linha( veiculo: veiculo, # A ordem definitiva é atribuída em #ordenar; aqui guardamos o que o # arquivo trouxer para poder ordenar depois. ordem: valor.call(:ordem) || valor.call(:indice) || numero_da_linha, titulo: titulo, endereco: ender, anotacoes: valor.call(:anotacoes), nota_fiscal: valor.call(:nota_fiscal), motorista: valor.call(:motorista) ) end # Ordem final: por veículo, pela ordem de entrega do plano. Renumeramos de 1 a N # dentro de cada veículo porque é isso que vira a coluna "#" do papel — e o # "Índice de rota" do export não é 1..N por veículo. def ordenar(registros) registros.group_by { |r| r[:veiculo] }.flat_map do |_veiculo, linhas| linhas.sort_by.with_index { |r, i| [r[:ordem].to_i, i] } .each_with_index.map { |r, i| r.merge(ordem: i + 1) } end end end end