Correção de horario e implantação da geração de Romaneio
This commit is contained in:
140
app/services/romaneios/plano_planilha.rb
Normal file
140
app/services/romaneios/plano_planilha.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user