Import multiple spreadsheet formats. Make upload work like sync.

This commit is contained in:
wvengen 2015-01-18 02:04:57 +01:00
parent 08c8d25a9d
commit 07ba6f0535
8 changed files with 157 additions and 191 deletions

View file

@ -42,6 +42,8 @@ gem 'ruby-units'
gem 'attribute_normalizer' gem 'attribute_normalizer'
gem 'ice_cube', github: 'wvengen/ice_cube', branch: 'issues/50-from_ical-rebased' # fork until merged gem 'ice_cube', github: 'wvengen/ice_cube', branch: 'issues/50-from_ical-rebased' # fork until merged
gem 'recurring_select' gem 'recurring_select'
gem 'roo', '~> 1.13.2'
gem 'spreadsheet'
# we use the git version of acts_as_versioned, and need to include it in this Gemfile # we use the git version of acts_as_versioned, and need to include it in this Gemfile
gem 'acts_as_versioned', github: 'technoweenie/acts_as_versioned' gem 'acts_as_versioned', github: 'technoweenie/acts_as_versioned'

View file

@ -333,6 +333,10 @@ GEM
http-cookie (>= 1.0.2, < 2.0) http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 3.0) mime-types (>= 1.16, < 3.0)
netrc (~> 0.7) netrc (~> 0.7)
roo (1.13.2)
nokogiri
rubyzip
spreadsheet (> 0.6.4)
rspec (2.99.0) rspec (2.99.0)
rspec-core (~> 2.99.0) rspec-core (~> 2.99.0)
rspec-expectations (~> 2.99.0) rspec-expectations (~> 2.99.0)
@ -354,6 +358,7 @@ GEM
rspec-mocks (~> 2.99.0) rspec-mocks (~> 2.99.0)
rspec-rerun (0.3.0) rspec-rerun (0.3.0)
rspec rspec
ruby-ole (1.2.11.8)
ruby-prof (0.15.6) ruby-prof (0.15.6)
ruby-units (1.4.5) ruby-units (1.4.5)
ruby_parser (3.6.5) ruby_parser (3.6.5)
@ -395,6 +400,8 @@ GEM
eventmachine (~> 1.0.0) eventmachine (~> 1.0.0)
thin (~> 1.5.0) thin (~> 1.5.0)
slop (3.6.0) slop (3.6.0)
spreadsheet (1.0.0)
ruby-ole (>= 1.0)
sprockets (2.12.3) sprockets (2.12.3)
hike (~> 1.2) hike (~> 1.2)
multi_json (~> 1.0) multi_json (~> 1.0)
@ -510,6 +517,7 @@ DEPENDENCIES
ransack ransack
recurring_select recurring_select
resque resque
roo (~> 1.13.2)
rspec-core (~> 2.99) rspec-core (~> 2.99)
rspec-rails rspec-rails
rspec-rerun rspec-rerun
@ -522,6 +530,7 @@ DEPENDENCIES
simple-navigation-bootstrap simple-navigation-bootstrap
simple_form simple_form
simplecov simplecov
spreadsheet
sqlite3 sqlite3
therubyracer therubyracer
thin thin

View file

@ -135,89 +135,37 @@ class ArticlesController < ApplicationController
def upload def upload
end end
# parses the articles from a csv and creates a form-table with the parsed data. # Update articles from a spreadsheet
# the csv must have the following format:
# status | number | name | note | manufacturer | origin | unit | clear price | unit_quantity | tax | deposit | scale quantity | scale price | category
# the first line will be ignored.
# field-seperator: ";"
# text-seperator: ""
def parse_upload def parse_upload
begin uploaded_file = params[:articles]['file']
@articles = Array.new options = {filename: uploaded_file.original_filename}
articles, outlisted_articles = FoodsoftFile::parse(params[:articles]["file"]) @updated_article_pairs, @outlisted_articles, @new_articles = [], [], []
no_category = ArticleCategory.new FoodsoftFile::parse uploaded_file.tempfile, options do |status, new_attrs, line|
articles.each do |row| article = @supplier.articles.where(order_number: new_attrs[:order_number]).first
# fallback to Others category new_attrs[:article_category] = ArticleCategory.find_match(new_attrs[:article_category])
category = (ArticleCategory.find_match(row[:category]) || no_category) new_attrs[:tax] ||= FoodsoftConfig[:tax_default]
# creates a new article and price new_article = @supplier.articles.build(new_attrs)
article = @supplier.articles.build(:name => row[:name],
:note => row[:note], if status.nil? && article.nil?
:manufacturer => row[:manufacturer], @new_articles << new_article
:origin => row[:origin], elsif status.nil? && article.present?
:unit => row[:unit], unequal_attributes = article.unequal_attributes(new_article)
:article_category => category, article.attributes = unequal_attributes
:price => row[:price], @updated_article_pairs << [article, unequal_attributes]
:unit_quantity => row[:unit_quantity], elsif status == :outlisted && article.present?
:order_number => row[:number], @outlisted_articles << article
:deposit => row[:deposit],
:tax => (row[:tax] || FoodsoftConfig[:tax_default])) # stop when there is a parsing error
# stop parsing, when an article isn't valid elsif status.is_a? String
unless article.valid? raise I18n.t('articles.controller.error_parse', :msg => status, :line => line.to_s)
raise I18n.t('articles.controller.error_parse', :msg => article.errors.full_messages.join(", "), :line => (articles.index(row) + 2).to_s)
end end
@articles << article
end end
flash.now[:notice] = I18n.t('articles.controller.parse_upload.notice', :count => @articles.size) @ignored_article_count = 0
render :sync
rescue => error rescue => error
redirect_to upload_supplier_articles_path(@supplier), :alert => I18n.t('errors.general_msg', :msg => error.message) redirect_to upload_supplier_articles_path(@supplier), :alert => I18n.t('errors.general_msg', :msg => error.message)
end end
end
# creates articles from form
def create_from_upload
begin
Article.transaction do
invalid_articles = false
@articles = []
params[:articles].each do |_key, article_attributes|
@articles << (article = @supplier.articles.build(article_attributes))
invalid_articles = true unless article.save
end
raise I18n.t('articles.controller.error_invalid') if invalid_articles
end
# Successfully done.
redirect_to supplier_articles_path(@supplier), notice: I18n.t('articles.controller.create_from_upload.notice', :count => @articles.size)
rescue => error
# An error has occurred, transaction has been rolled back.
flash.now[:error] = I18n.t('errors.general_msg', :msg => error.message)
render :parse_upload
end
end
# renders a view to import articles in local database
#
def shared
# build array of keywords, required for ransack _all suffix
params[:q][:name_cont_all] = params[:q][:name_cont_all].split(' ') if params[:q]
# Build search with meta search plugin
@search = @supplier.shared_supplier.shared_articles.search(params[:q])
@articles = @search.result.page(params[:page]).per(10)
render :layout => false
end
# fills a form whith values of the selected shared_article
# when the direct parameter is set and the article is valid, it is imported directly
def import
@article = SharedArticle.find(params[:shared_article_id]).build_new_article(@supplier)
@article.article_category_id = params[:article_category_id] unless params[:article_category_id].blank?
if params[:direct] && !params[:article_category_id].blank? && @article.valid? && @article.save
render :action => 'create', :layout => false
else
render :action => 'new', :layout => false
end
end
# sync all articles with the external database # sync all articles with the external database
# renders a form with articles, which should be updated # renders a form with articles, which should be updated
@ -227,16 +175,14 @@ class ArticlesController < ApplicationController
redirect_to supplier_articles_url(@supplier), :alert => I18n.t('articles.controller.sync.shared_alert', :supplier => @supplier.name) redirect_to supplier_articles_url(@supplier), :alert => I18n.t('articles.controller.sync.shared_alert', :supplier => @supplier.name)
end end
# sync articles against external database # sync articles against external database
@updated_articles, @outlisted_articles, @new_articles = @supplier.sync_all @updated_article_pairs, @outlisted_articles, @new_articles = @supplier.sync_all
# convert to db-compatible-string if @updated_article_pairs.empty? && @outlisted_articles.empty? && @new_articles.empty?
@updated_articles.each {|a, b| a.shared_updated_on = a.shared_updated_on.to_formatted_s(:db)}
if @updated_articles.empty? && @outlisted_articles.empty? && @new_articles.empty?
redirect_to supplier_articles_path(@supplier), :notice => I18n.t('articles.controller.sync.notice') redirect_to supplier_articles_path(@supplier), :notice => I18n.t('articles.controller.sync.notice')
end end
@ignored_article_count = @supplier.articles.where(order_number: [nil, '']).count @ignored_article_count = @supplier.articles.where(order_number: [nil, '']).count
end end
# Updates, deletes articles when sync form is submitted # Updates, deletes articles when upload or sync form is submitted
def update_synchronized def update_synchronized
begin begin
Article.transaction do Article.transaction do
@ -277,4 +223,27 @@ class ArticlesController < ApplicationController
alert: I18n.t('errors.general_msg', :msg => error.message) alert: I18n.t('errors.general_msg', :msg => error.message)
end end
end end
# renders a view to import articles in local database
#
def shared
# build array of keywords, required for ransack _all suffix
params[:q][:name_cont_all] = params[:q][:name_cont_all].split(' ') if params[:q]
# Build search with meta search plugin
@search = @supplier.shared_supplier.shared_articles.search(params[:q])
@articles = @search.result.page(params[:page]).per(10)
render :layout => false
end
# fills a form whith values of the selected shared_article
# when the direct parameter is set and the article is valid, it is imported directly
def import
@article = SharedArticle.find(params[:shared_article_id]).build_new_article(@supplier)
@article.article_category_id = params[:article_category_id] unless params[:article_category_id].blank?
if params[:direct] && !params[:article_category_id].blank? && @article.valid? && @article.save
render :action => 'create', :layout => false
else
render :action => 'new', :layout => false
end
end
end end

View file

@ -3,7 +3,7 @@ module ArticlesHelper
# useful for highlighting attributes, when synchronizing articles # useful for highlighting attributes, when synchronizing articles
def highlight_new(unequal_attributes, attribute) def highlight_new(unequal_attributes, attribute)
return unless unequal_attributes return unless unequal_attributes
unequal_attributes.detect {|a| a == attribute} ? "background-color: yellow" : "" unequal_attributes.has_key?(attribute) ? "background-color: yellow" : ""
end end
def row_classes(article) def row_classes(article)

View file

@ -104,46 +104,55 @@ class Article < ActiveRecord::Base
# skip early if the timestamp hasn't changed # skip early if the timestamp hasn't changed
shared_article = self.shared_article(supplier) shared_article = self.shared_article(supplier)
unless shared_article.nil? || self.shared_updated_on == shared_article.updated_on unless shared_article.nil? || self.shared_updated_on == shared_article.updated_on
attrs = unequal_attributes(shared_article)
# try to convert units if attrs.empty?
# convert supplier's price and unit_quantity into fc-size
new_price, new_unit_quantity = self.convert_units
new_unit = self.unit
unless new_price && new_unit_quantity
# if convertion isn't possible, take shared_article-price/unit_quantity
new_price, new_unit_quantity, new_unit = shared_article.price, shared_article.unit_quantity, shared_article.unit
end
# check if all attributes differ
unequal_attributes = Article.compare_attributes(
{
:name => [self.name, shared_article.name],
:manufacturer => [self.manufacturer, shared_article.manufacturer.to_s],
:origin => [self.origin, shared_article.origin],
:unit => [self.unit, new_unit],
:price => [self.price.to_f.round(2), new_price.to_f.round(2)],
:tax => [self.tax, shared_article.tax],
:deposit => [self.deposit.to_f.round(2), shared_article.deposit.to_f.round(2)],
# take care of different num-objects.
:unit_quantity => [self.unit_quantity.to_s.to_f, new_unit_quantity.to_s.to_f],
:note => [self.note.to_s, shared_article.note.to_s]
}
)
if unequal_attributes.empty?
# when attributes not changed, update timestamp of article # when attributes not changed, update timestamp of article
self.update_attribute(:shared_updated_on, shared_article.updated_on) self.update_attribute(:shared_updated_on, shared_article.updated_on)
false false
else else
unequal_attributes attrs
end end
end end
end end
# compare attributes from different articles. used for auto-synchronization # Return article attributes that were changed (incl. unit conversion)
# returns array of symbolized unequal attributes # @param [Article] New article to update self
# @return [Hash<Symbol, Object>] Attributes with new values
def unequal_attributes(new_article)
# try to convert different units
new_price, new_unit_quantity = convert_units(new_article)
if new_price && new_unit_quantity
new_unit = self.unit
else
new_price = new_article.price
new_unit_quantity = new_article.unit_quantity
new_unit = new_article.unit
end
return Article.compare_attributes(
{
:name => [self.name, new_article.name],
:manufacturer => [self.manufacturer, new_article.manufacturer.to_s],
:origin => [self.origin, new_article.origin],
:unit => [self.unit, new_unit],
:price => [self.price.to_f.round(2), new_price.to_f.round(2)],
:tax => [self.tax, new_article.tax],
:deposit => [self.deposit.to_f.round(2), new_article.deposit.to_f.round(2)],
# take care of different num-objects.
:unit_quantity => [self.unit_quantity.to_s.to_f, new_unit_quantity.to_s.to_f],
:note => [self.note.to_s, new_article.note.to_s]
}
)
end
# Compare attributes from two different articles.
#
# This is used for auto-synchronization
# @param attributes [Hash<Symbol, Array>] Attributes with old and new values
# @return [Hash<Symbol, Object>] Changed attributes with new values
def self.compare_attributes(attributes) def self.compare_attributes(attributes)
unequal_attributes = attributes.select { |name, values| values[0] != values[1] && !(values[0].blank? && values[1].blank?) } unequal_attributes = attributes.select { |name, values| values[0] != values[1] && !(values[0].blank? && values[1].blank?) }
unequal_attributes.collect { |pair| pair[0] } Hash[unequal_attributes.to_a.map {|a| [a[0], a[1].last]}]
end end
# to get the correspondent shared article # to get the correspondent shared article
@ -157,25 +166,25 @@ class Article < ActiveRecord::Base
# returns new price and unit_quantity in array, when calc is possible => [price, unit_quanity] # returns new price and unit_quantity in array, when calc is possible => [price, unit_quanity]
# returns false if units aren't foodsoft-compatible # returns false if units aren't foodsoft-compatible
# returns nil if units are eqal # returns nil if units are eqal
def convert_units def convert_units(new_article = shared_article)
if unit != shared_article.unit if unit != new_article.unit
# legacy, used by foodcoops in Germany # legacy, used by foodcoops in Germany
if shared_article.unit == "KI" && unit == "ST" # 'KI' means a box, with a different amount of items in it if new_article.unit == "KI" && unit == "ST" # 'KI' means a box, with a different amount of items in it
# try to match the size out of its name, e.g. "banana 10-12 St" => 10 # try to match the size out of its name, e.g. "banana 10-12 St" => 10
new_unit_quantity = /[0-9\-\s]+(St)/.match(shared_article.name).to_s.to_i new_unit_quantity = /[0-9\-\s]+(St)/.match(new_article.name).to_s.to_i
if new_unit_quantity && new_unit_quantity > 0 if new_unit_quantity && new_unit_quantity > 0
new_price = (shared_article.price/new_unit_quantity.to_f).round(2) new_price = (new_article.price/new_unit_quantity.to_f).round(2)
[new_price, new_unit_quantity] [new_price, new_unit_quantity]
else else
false false
end end
else # use ruby-units to convert else # use ruby-units to convert
fc_unit = (::Unit.new(unit) rescue nil) fc_unit = (::Unit.new(unit) rescue nil)
supplier_unit = (::Unit.new(shared_article.unit) rescue nil) supplier_unit = (::Unit.new(new_article.unit) rescue nil)
if fc_unit && supplier_unit && fc_unit =~ supplier_unit if fc_unit && supplier_unit && fc_unit =~ supplier_unit
conversion_factor = (supplier_unit / fc_unit).to_base.to_r conversion_factor = (supplier_unit / fc_unit).to_base.to_r
new_price = shared_article.price / conversion_factor new_price = new_article.price / conversion_factor
new_unit_quantity = shared_article.unit_quantity * conversion_factor new_unit_quantity = new_article.unit_quantity * conversion_factor
[new_price, new_unit_quantity] [new_price, new_unit_quantity]
else else
false false

View file

@ -34,30 +34,9 @@ class Supplier < ActiveRecord::Base
shared_article = article.shared_article(self) shared_article = article.shared_article(self)
if shared_article # article will be updated if shared_article # article will be updated
unequal_attributes = article.shared_article_changed?(self) unequal_attributes = article.shared_article_changed?(self)
unless unequal_attributes.blank? # skip if shared_article has not been changed unless unequal_attributes.blank? # skip if shared_article has not been changed
article.attributes = unequal_attributes
# try to convert different units
new_price, new_unit_quantity = article.convert_units
if new_price && new_unit_quantity
article.price = new_price
article.unit_quantity = new_unit_quantity
else
article.price = shared_article.price
article.unit_quantity = shared_article.unit_quantity
article.unit = shared_article.unit
end
# update other attributes
article.attributes = {
:name => shared_article.name,
:manufacturer => shared_article.manufacturer,
:origin => shared_article.origin,
:shared_updated_on => shared_article.updated_on,
:tax => shared_article.tax,
:deposit => shared_article.deposit,
:note => shared_article.note
}
updated_articles << [article, unequal_attributes] updated_articles << [article, unequal_attributes]
end end
# Articles with no order number can be used to put non-shared articles # Articles with no order number can be used to put non-shared articles

View file

@ -14,13 +14,13 @@
.alert= t '.outlist.alert_used', article: article.name .alert= t '.outlist.alert_used', article: article.name
%hr/ %hr/
- if @updated_articles.any? - if @updated_article_pairs.any?
%h2= t '.update.title' %h2= t '.update.title'
%p %p
%i %i
= t '.update.update_msg', count: @updated_articles.size = t '.update.update_msg', count: @updated_article_pairs.size
= t '.update.body' = t '.update.body'
= render 'sync_table', articles: @updated_articles, field: 'articles', hidden_fields: %w(shared_updated_on) = render 'sync_table', articles: @updated_article_pairs, field: 'articles', hidden_fields: %w(shared_updated_on)
%hr/ %hr/
- if @new_articles.any? - if @new_articles.any?

View file

@ -1,20 +1,25 @@
# Module for Foodsoft-File import require 'roo'
# The Foodsoft-File is a cvs-file, with columns separated by semicolons
require 'csv' # Foodsoft-file import
class FoodsoftFile
module FoodsoftFile
# parses a string from a foodsoft-file # parses a string from a foodsoft-file
# returns two arrays with articles and outlisted_articles # returns two arrays with articles and outlisted_articles
# the parsed article is a simple hash # the parsed article is a simple hash
def self.parse(file) def self.parse(file, options = {})
articles, outlisted_articles = Array.new, Array.new filepath = file.is_a?(String) ? file : file.to_path
row_index = 2 filename = options[:filename] || filepath
::CSV.parse(file.read.force_encoding('utf-8'), {:col_sep => ";", :headers => true}) do |row| fileext = ::File.extname(filename)
# check if the line is empty options = {col_sep: ';', encoding: 'utf-8'}.merge(options)
unless row[2] == "" || row[2].nil? s = Roo::Spreadsheet.open filepath, extension: fileext, csv_options: options
article = {:number => row[1],
row_index = 1
s.each do |row|
if row_index == 1
# @todo try to detect headers; for now using the index is ok
elsif !row[2].blank?
article = {:order_number => row[1],
:name => row[2], :name => row[2],
:note => row[3], :note => row[3],
:manufacturer => row[4], :manufacturer => row[4],
@ -24,20 +29,13 @@ module FoodsoftFile
:tax => row[8], :tax => row[8],
:deposit => (row[9].nil? ? "0" : row[9]), :deposit => (row[9].nil? ? "0" : row[9]),
:unit_quantity => row[10], :unit_quantity => row[10],
:scale_quantity => row[11], :article_category => row[13]}
:scale_price => row[12], status = row[0] && row[0].strip.downcase == 'x' ? :outlisted : nil
:category => row[13]} yield status, article, row_index
case row[0]
when "x"
# check if the article is outlisted
outlisted_articles << article
else
articles << article
end
end end
row_index += 1 row_index += 1
end end
return [articles, outlisted_articles] row_index
end end
end end