141 lines
5.5 KiB
Ruby
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
|