solidus_import_products 2.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +19 -0
- data/README.md +71 -0
- data/app/controllers/spree/admin/product_imports_controller.rb +35 -0
- data/app/jobs/import_products_job.rb +14 -0
- data/app/models/solidus_import_products/parser.rb +15 -0
- data/app/models/solidus_import_products/parser/base.rb +24 -0
- data/app/models/solidus_import_products/parser/csv.rb +98 -0
- data/app/models/spree/product_import.rb +82 -0
- data/app/overrides/add_import_to_admin_sidebar_menu.rb +6 -0
- data/app/services/solidus_import_products/create_variant.rb +109 -0
- data/app/services/solidus_import_products/import.rb +38 -0
- data/app/services/solidus_import_products/process_row.rb +93 -0
- data/app/services/solidus_import_products/save_product.rb +41 -0
- data/app/services/solidus_import_products/save_properties.rb +23 -0
- data/app/services/solidus_import_products/update_product.rb +45 -0
- data/app/views/spree/admin/product_imports/index.html.erb +71 -0
- data/app/views/spree/admin/product_imports/show.html.erb +37 -0
- data/app/views/spree/admin/shared/_import_sidebar_menu.erb +3 -0
- data/app/views/spree/user_mailer/product_import_results.text.erb +23 -0
- data/lib/generators/solidus_import_products/install/install_generator.rb +41 -0
- data/lib/generators/solidus_import_products/install/templates/config/initializers/solidus_import_product_settings.rb +10 -0
- data/lib/solidus_import_products.rb +8 -0
- data/lib/solidus_import_products/engine.rb +26 -0
- data/lib/solidus_import_products/exception.rb +19 -0
- data/lib/solidus_import_products/import_helper.rb +118 -0
- data/lib/solidus_import_products/logger.rb +15 -0
- data/lib/solidus_import_products/user_mailer_ext.rb +15 -0
- data/lib/tasks/solidus_import_products.rake +1 -0
- metadata +255 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class Import
|
3
|
+
attr_accessor :product_imports, :logger
|
4
|
+
|
5
|
+
def initialize(args = { product_imports: nil })
|
6
|
+
self.product_imports = args[:product_imports]
|
7
|
+
self.logger = SolidusImportProducts::Logger.instance
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.call(options = {})
|
11
|
+
new(options).call
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
skus_of_products_before_import = Spree::Product.all.map(&:sku)
|
16
|
+
parser = product_imports.parse
|
17
|
+
col = parser.column_mappings
|
18
|
+
|
19
|
+
product_imports.start
|
20
|
+
ActiveRecord::Base.transaction do
|
21
|
+
parser.data_rows.each do |row|
|
22
|
+
SolidusImportProducts::ProcessRow.call(
|
23
|
+
parser: parser,
|
24
|
+
product_imports: product_imports,
|
25
|
+
row: row,
|
26
|
+
col: col,
|
27
|
+
skus_of_products_before_import: skus_of_products_before_import
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
product_imports.complete
|
33
|
+
rescue SolidusImportProducts::Exception::Base => e
|
34
|
+
product_imports.failure!
|
35
|
+
raise e
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class ProcessRow
|
3
|
+
attr_accessor :parser, :product_imports, :logger, :row, :col, :product_information, :variant_field, :skus_of_products_before_import
|
4
|
+
|
5
|
+
VARIANT_FIELD_NAME = :name
|
6
|
+
|
7
|
+
def initialize(args = { parser: nil, product_imports: nil, row: nil, col: nil, skus_of_products_before_import: nil })
|
8
|
+
self.parser = args[:parser]
|
9
|
+
self.product_imports = args[:product_imports]
|
10
|
+
self.row = args[:row]
|
11
|
+
self.col = args[:col]
|
12
|
+
self.variant_field = VARIANT_FIELD_NAME
|
13
|
+
self.skus_of_products_before_import = args[:skus_of_products_before_import]
|
14
|
+
self.logger = SolidusImportProducts::Logger.instance
|
15
|
+
self.product_information = { variant_options: {}, images: [], variant_images: [], product_properties: {}, attributes: {} }
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.call(options = {})
|
19
|
+
new(options).call
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
extract_product_information
|
24
|
+
product_information_default_values
|
25
|
+
logger.log(product_information.to_s, :debug)
|
26
|
+
|
27
|
+
variant_column = col[variant_field]
|
28
|
+
product = Spree::Product.find_by(variant_field.to_s => row[variant_column])
|
29
|
+
|
30
|
+
unless product
|
31
|
+
if skus_of_products_before_import.include?(product_information[:attributes][:sku])
|
32
|
+
raise SolidusImportProducts::Exception::ProductError, "SKU #{product_information[:attributes][:sku]} exists, but #{variant_field}: #{row[variant_column]} not exists!! "
|
33
|
+
end
|
34
|
+
product = Spree::Product.new
|
35
|
+
end
|
36
|
+
|
37
|
+
unless product_imports.product?(product)
|
38
|
+
create_or_update_product(product)
|
39
|
+
product_imports.add_product(product)
|
40
|
+
end
|
41
|
+
|
42
|
+
SolidusImportProducts::CreateVariant.call(product: product, product_information: product_information)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def create_or_update_product(product)
|
48
|
+
properties_hash = SolidusImportProducts::UpdateProduct.call(product: product, product_information: product_information)
|
49
|
+
SolidusImportProducts::SaveProduct.call(product: product, product_information: product_information)
|
50
|
+
SolidusImportProducts::SaveProperties.call(product: product, properties_hash: properties_hash)
|
51
|
+
end
|
52
|
+
|
53
|
+
def extract_product_information
|
54
|
+
col.each do |key, value|
|
55
|
+
row[value].try :strip!
|
56
|
+
if parser.variant_option_field?(key)
|
57
|
+
product_information[:variant_options][key] = row[value]
|
58
|
+
elsif parser.property_field?(key)
|
59
|
+
product_information[:product_properties][key] = row[value]
|
60
|
+
elsif parser.image_field?(key)
|
61
|
+
product_information[:images].push(row[value])
|
62
|
+
elsif parser.variant_image_field?(key)
|
63
|
+
product_information[:variant_images].push(row[value])
|
64
|
+
else
|
65
|
+
product_information[:attributes][key] = key.to_s.eql?('price') ? convert_to_price(row[value]) : row[value]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def product_information_default_values
|
71
|
+
product_information[:attributes][:available_on] = Time.zone.today - 1.day if product_information[:attributes][:available_on].nil?
|
72
|
+
|
73
|
+
if product_information[:attributes][:shipping_category_id].nil?
|
74
|
+
sc = Spree::ShippingCategory.first
|
75
|
+
product_information[:attributes][:shipping_category_id] = sc.id if sc
|
76
|
+
end
|
77
|
+
|
78
|
+
product_information[:attributes][:retail_only] = 0 if product_information[:attributes][:retail_only].nil?
|
79
|
+
end
|
80
|
+
|
81
|
+
# Special process of prices because of locales and different decimal separator characters.
|
82
|
+
# We want to get a format with dot as decimal separator and without thousand separator
|
83
|
+
def convert_to_price(price_str)
|
84
|
+
raise SolidusImportProducts::Exception::InvalidPrice unless price_str
|
85
|
+
punt = price_str.index('.')
|
86
|
+
coma = price_str.index(',')
|
87
|
+
if !coma.nil? && !punt.nil?
|
88
|
+
price_str.gsub!(punt < coma ? '.' : ',', '')
|
89
|
+
end
|
90
|
+
price_str.tr(',', '.').to_f
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class SaveProduct
|
3
|
+
attr_accessor :product, :product_information, :logger
|
4
|
+
|
5
|
+
include SolidusImportProducts::ImportHelper
|
6
|
+
|
7
|
+
def self.call(options = {})
|
8
|
+
new.call(options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(args = { product: nil, product_information: nil })
|
12
|
+
self.logger = SolidusImportProducts::Logger.instance
|
13
|
+
self.product_information = args[:product_information]
|
14
|
+
self.product = args[:product]
|
15
|
+
|
16
|
+
logger.log("SAVE PRODUCT: #{product.inspect}", :debug)
|
17
|
+
|
18
|
+
unless product.valid?
|
19
|
+
msg = "A product could not be imported - here is the information we have:\n" \
|
20
|
+
"#{product_information}, #{product.inspect} #{product.errors.full_messages.join(', ')}"
|
21
|
+
logger.log(msg, :error)
|
22
|
+
raise SolidusImportProducts::Exception::ProductError, msg
|
23
|
+
end
|
24
|
+
|
25
|
+
product.save
|
26
|
+
|
27
|
+
# Associate our new product with any taxonomies that we need to worry about
|
28
|
+
if product_information[:attributes].key?(:taxonomies) && product_information[:attributes][:taxonomies]
|
29
|
+
associate_product_with_taxon(product, product_information[:attributes][:taxonomies], true)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Finally, attach any images that have been specified
|
33
|
+
product_information[:images].each do |filename|
|
34
|
+
find_and_attach_image_to(product, filename)
|
35
|
+
end
|
36
|
+
|
37
|
+
logger.log("#{product.name} successfully imported.\n")
|
38
|
+
true
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class SaveProperties
|
3
|
+
attr_accessor :product, :properties_hash, :logger
|
4
|
+
|
5
|
+
def self.call(options = {})
|
6
|
+
new.call(options)
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(args = { product: nil, properties_hash: nil })
|
10
|
+
self.logger = SolidusImportProducts::Logger.instance
|
11
|
+
self.properties_hash = args[:properties_hash]
|
12
|
+
self.product = args[:product]
|
13
|
+
|
14
|
+
properties_hash.each do |field, value|
|
15
|
+
property = Spree::Property.where('lower(name) = ?', field).first
|
16
|
+
next unless property
|
17
|
+
product_property = Spree::ProductProperty.where(product_id: product.id, property_id: property.id).first_or_initialize
|
18
|
+
product_property.value = value
|
19
|
+
product_property.save!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
class UpdateProduct
|
3
|
+
attr_accessor :product, :product_information, :logger
|
4
|
+
|
5
|
+
include SolidusImportProducts::ImportHelper
|
6
|
+
|
7
|
+
def self.call(options = {})
|
8
|
+
new.call(options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(args = { product: nil, product_information: nil })
|
12
|
+
self.logger = SolidusImportProducts::Logger.instance
|
13
|
+
self.product_information = args[:product_information]
|
14
|
+
self.product = args[:product]
|
15
|
+
|
16
|
+
logger.log("UPDATE PRODUCT: #{product.inspect} #{product_information.inspect}", :debug)
|
17
|
+
|
18
|
+
product.update_attribute(:deleted_at, nil) if product.deleted_at
|
19
|
+
product.variants.each { |variant| variant.update_attribute(:deleted_at, nil) }
|
20
|
+
|
21
|
+
properties_hash = {}
|
22
|
+
|
23
|
+
product_information.each do |field, value|
|
24
|
+
if field == :product_properties
|
25
|
+
value.each { |prop_field, prop_value| properties_hash[prop_field] = prop_value }
|
26
|
+
elsif field == :attributes
|
27
|
+
value.each { |attr_field, attr_value| product.send("#{attr_field}=", attr_value) if product.respond_to?("#{attr_field}=") }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
setup_shipping_category(product) unless product.shipping_category
|
32
|
+
|
33
|
+
properties_hash
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def setup_shipping_category(product)
|
39
|
+
unless Spree::ShippingCategory.first
|
40
|
+
Spree::ShippingCategory.find_or_create_by(name: 'Default')
|
41
|
+
end
|
42
|
+
product.shipping_category = Spree::ShippingCategory.first
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
<% content_for :page_title do %>
|
2
|
+
<%= t('form.product_import.heading') %>
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<div class="row">
|
6
|
+
<div class="col-sm-6">
|
7
|
+
<%= form_for([:admin, @product_import], :method => :post, :html => { :multipart => true }) do |f| %>
|
8
|
+
<fieldset class="no-border-top">
|
9
|
+
<%= f.field_container :data_file, class: ['form-group'] do %>
|
10
|
+
<%= f.label :data_file, t('form.product_import.new.data_file') %><br />
|
11
|
+
<%= f.file_field :data_file %>
|
12
|
+
<%= f.error_message_on :data_file %>
|
13
|
+
<% end %>
|
14
|
+
<div class="field">
|
15
|
+
<%= f.label :separatorChar, t('form.product_import.separator_char') %><br />
|
16
|
+
<%= f.text_field :separatorChar, class: 'form-control required',:id => "separatorChar",:value => ";" ,:size => 1, :maxlength => 1 %>
|
17
|
+
</div>
|
18
|
+
<div class="field">
|
19
|
+
<%= f.label :encoding_csv, t('form.product_import.encoding') %><br />
|
20
|
+
<%= f.select(:encoding_csv,
|
21
|
+
options_from_collection_for_select(Spree::ProductImport::ENCODINGS, :to_s, :to_s, 'UTF-8'),
|
22
|
+
{:class => 'select2', :prompt => false}) %>
|
23
|
+
</div>
|
24
|
+
<div data-hook="buttons" class="form-actions">
|
25
|
+
<%= button Spree.t('actions.import'), 'ok', 'submit', {class: 'btn-success'} %>
|
26
|
+
</div>
|
27
|
+
</fieldset>
|
28
|
+
|
29
|
+
<% end %>
|
30
|
+
</div>
|
31
|
+
</div>
|
32
|
+
<table class="table">
|
33
|
+
<colgroup>
|
34
|
+
<col style="width: 15%">
|
35
|
+
<col style="width: 20%">
|
36
|
+
<col style="width: 10%">
|
37
|
+
<col style="width: 15%">
|
38
|
+
<col style="width: 10%">
|
39
|
+
<col style="width: 20%">
|
40
|
+
<col style="width: 5%">
|
41
|
+
</colgroup>
|
42
|
+
<thead>
|
43
|
+
<tr>
|
44
|
+
<th>Date creation</th>
|
45
|
+
<th>CSV Name</th>
|
46
|
+
<th>Status</th>
|
47
|
+
<th>Date Status</th>
|
48
|
+
<th>Imported</th>
|
49
|
+
<th>Error Message</th>
|
50
|
+
<th class="actions">Actions</th>
|
51
|
+
</tr>
|
52
|
+
</thead>
|
53
|
+
<tbody>
|
54
|
+
<% Spree::ProductImport.order("created_at DESC").all.each do |import| %>
|
55
|
+
<tr class="<%= cycle('odd', 'even') %>" id="<%= dom_id import %>">
|
56
|
+
<td><%= I18n.l(import.created_at.to_datetime,format: :short)%></td>
|
57
|
+
<td><%= link_to import.data_file_file_name, import.data_file.url -%></td>
|
58
|
+
<td><span class="label-<%= import.state.downcase %>"><%= t(import.state, :scope => "product_import.state")%></span></td>
|
59
|
+
<td><%= I18n.l(import.state_datetime.to_datetime,format: :short) %></td>
|
60
|
+
<td><%= import.product_ids.size -%></td>
|
61
|
+
<td><%= import.error_message%></td>
|
62
|
+
<td class="actions text-center">
|
63
|
+
<%= link_to_edit import, :url => admin_product_import_path(import), :no_text => true -%>
|
64
|
+
</td>
|
65
|
+
<td class="actions text-center">
|
66
|
+
<%= link_to_delete import, :url => admin_product_import_path(import), :no_text => true -%>
|
67
|
+
</td>
|
68
|
+
</tr>
|
69
|
+
<% end %>
|
70
|
+
</tbody>
|
71
|
+
</table>
|
@@ -0,0 +1,37 @@
|
|
1
|
+
<% content_for :page_title do %>
|
2
|
+
<%= t(:show_product_import) %>
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<% content_for :page_actions do %>
|
6
|
+
<%= button_link_to t(:back_to_product_imports), admin_product_imports_path, :icon => 'icon-arrow-left' %>
|
7
|
+
<%= button_link_to t(:delete_product_import), admin_product_import_path(@product_import), {:icon => 'icon-remove', :method => :delete, :confirm => t("confirm_delete_product_import")} %>
|
8
|
+
<% end %>
|
9
|
+
|
10
|
+
<table class="table" id="listing_products">
|
11
|
+
<colgroup>
|
12
|
+
<col style="width: 20%">
|
13
|
+
<col style="width: 50%">
|
14
|
+
<col style="width: 20%">
|
15
|
+
<col style="width: 10%">
|
16
|
+
</colgroup>
|
17
|
+
<thead>
|
18
|
+
<tr data-hook="admin_products_index_headers">
|
19
|
+
<th><%= t(:sku) %></th>
|
20
|
+
<th><%= t(:name) %></th>
|
21
|
+
<th><%= t(:master_price) %></th>
|
22
|
+
<th class="actions" data-hook="admin_products_index_header_actions"></th>
|
23
|
+
</tr>
|
24
|
+
</thead>
|
25
|
+
<tbody>
|
26
|
+
<% @products.each do |product| %>
|
27
|
+
<tr class="<%= cycle('odd', 'even') %>" <%= "style='color: red;'" if product.deleted? %> id="<%= dom_id product %>" data-hook="admin_products_index_rows">
|
28
|
+
<td><%= product.sku rescue '' %></td>
|
29
|
+
<td><%= product.name rescue '' %></td>
|
30
|
+
<td><%= number_to_currency product.price rescue '' %></td>
|
31
|
+
<td class="actions" data-hook="admin_products_index_row_actions">
|
32
|
+
<%= link_to_edit_url edit_admin_product_path(product), :no_text => true unless product.deleted? %>
|
33
|
+
</td>
|
34
|
+
</tr>
|
35
|
+
<% end %>
|
36
|
+
</tbody>
|
37
|
+
</table>
|
@@ -0,0 +1,23 @@
|
|
1
|
+
Hello,
|
2
|
+
|
3
|
+
<% if @error_message %>
|
4
|
+
Something went wrong during the product import.
|
5
|
+
Common causes for this include:
|
6
|
+
- Badly formatted CSV
|
7
|
+
- Special characters within the file
|
8
|
+
- Incorrect data
|
9
|
+
- Application or database error
|
10
|
+
|
11
|
+
Please check your file and try again.
|
12
|
+
To help diagnose the problem, we have attached the log file for the imports.
|
13
|
+
|
14
|
+
--
|
15
|
+
|
16
|
+
<%= @error_message %>
|
17
|
+
|
18
|
+
<% else %>
|
19
|
+
This is a quick email to let you know that the product import you submitted has been completed.
|
20
|
+
You can now see the products you added visible in your store.
|
21
|
+
<% end %>
|
22
|
+
|
23
|
+
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module SolidusImportProducts
|
2
|
+
module Generators
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
4
|
+
class_option :auto_run_migrations, type: :boolean, default: false
|
5
|
+
class_option :auto_skip_migrations, type: :boolean, default: false
|
6
|
+
|
7
|
+
def self.source_paths
|
8
|
+
paths = superclass.source_paths
|
9
|
+
paths << File.expand_path('../templates', "../../#{__FILE__}")
|
10
|
+
paths << File.expand_path('../templates', "../#{__FILE__}")
|
11
|
+
paths << File.expand_path('../templates', __FILE__)
|
12
|
+
paths.flatten
|
13
|
+
end
|
14
|
+
|
15
|
+
def add_migrations
|
16
|
+
if !options[:auto_skip_migrations]
|
17
|
+
run 'bundle exec rake solidus_import_products:install:migrations'
|
18
|
+
else
|
19
|
+
puts 'Skipping rake solidus_import_products:install:migrations, don\'t forget to run it!'
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_files
|
25
|
+
template 'config/initializers/solidus_import_product_settings.rb', 'config/initializers/solidus_import_product_settings.rb'
|
26
|
+
end
|
27
|
+
|
28
|
+
def run_migrations
|
29
|
+
run_migrations = options[:auto_skip_migrations] ||
|
30
|
+
options[:auto_run_migrations] ||
|
31
|
+
['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
|
32
|
+
|
33
|
+
if run_migrations && !options[:auto_skip_migrations]
|
34
|
+
run 'bundle exec rake db:migrate'
|
35
|
+
else
|
36
|
+
puts 'Skipping rake db:migrate, don\'t forget to run it!'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
Spree::ProductImport.settings = {
|
2
|
+
num_prods_for_delayed: 20, # From this number of products, the process is executed in delayed_job. Under it is processed immediately.
|
3
|
+
create_missing_taxonomies: true,
|
4
|
+
product_image_path: "#{Rails.root}/lib/etc/product_data/product-images/", # The location of images on disk
|
5
|
+
log_to: File.join(Rails.root, '/log/', "import_products_#{Rails.env}.log"), # Where to log to
|
6
|
+
destroy_original_products: false, # Disabled #Delete the products originally in the database after the import?
|
7
|
+
create_variants: true, # Compares products and creates a variant if that product already exists.
|
8
|
+
store_field: :store_code, # Which field of the column mappings contains either the store id or store code?
|
9
|
+
transaction: true # import product in a sql transaction so we can rollback when an exception is raised
|
10
|
+
}
|
@@ -0,0 +1,8 @@
|
|
1
|
+
require 'solidus_core'
|
2
|
+
require 'solidus_support'
|
3
|
+
require 'solidus_auth_devise'
|
4
|
+
require 'deface'
|
5
|
+
require 'solidus_import_products/exception'
|
6
|
+
require 'solidus_import_products/logger'
|
7
|
+
require 'solidus_import_products/import_helper'
|
8
|
+
require 'solidus_import_products/engine'
|