Files
Reem-Notas/app/services/romaneios/plano_planilha.rb

141 lines
5.5 KiB
Ruby

# 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