diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee new file mode 100644 index 0000000000..68f12079c4 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -0,0 +1,9 @@ +angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $timeout, $rootScope, ProductImportService) -> + + $scope.toggleResetAbsent = () -> + ProductImportService.updateResetAbsent($scope.supplierId, $scope.nonUpdated, $scope.resetAbsent) + + $scope.resetCount = ProductImportService.resetCount + + $rootScope.$watch 'resetCount', (newValue) -> + $scope.resetCount = newValue if newValue || newValue == 0 diff --git a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee new file mode 100644 index 0000000000..d65da24e95 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee @@ -0,0 +1,15 @@ +angular.module("ofn.admin").factory "ProductImportService", ($rootScope, $timeout) -> + new class ProductImportService + suppliers: {} + resetCount: 0 + + updateResetAbsent: (supplierId, nonUpdated, resetAbsent) -> + if resetAbsent + @suppliers[supplierId] = nonUpdated + @resetCount += nonUpdated + else + @suppliers[supplierId] = null + @resetCount -= nonUpdated + + $rootScope.resetCount = @resetCount + diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index e99cc9beea..301a91c741 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -24,16 +24,23 @@ div.panel-section { text-align: center; padding-top: 0.18em; - .fa { + i { font-size: 1.5em; line-height: 0.9em; } - .fa-warning { - color: #ee4728; - } - .fa-check-circle { - color: #86d83a; - } + } + + .neutral { + color: #BFBFBF; + } + .warning { + color: #ee4728; + } + .success { + color: #86d83a; + } + .info { + color: #68b7c0; } div.header-count { @@ -145,9 +152,7 @@ table.import-settings { } .panel-section.import-settings { - .header-icon { - color: #BFBFBF; - } + .header-description { padding-left: 1em; } diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 7bc5d0ff06..dea26ed720 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -25,6 +25,8 @@ class ProductImporter editable_enterprises.map { |e| @editable_enterprises[e.name] = e.id } @non_display_attributes = 'id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id', 'on_hand_nil' + @supplier_products = {total: 0, by_supplier: {}} + @updated_ids = [] validate_all if @sheet else @@ -44,6 +46,18 @@ class ProductImporter @sheet ? @sheet.last_row - 1 : 0 end + def supplier_products + # Return indexed data about existing product count and update count per supplier + @supplier_products[:by_supplier].each do |supplier_id, supplier_data| + supplier_data[:updates_count] = 0 if supplier_data[:updates_count].blank? + + if supplier_data[:updates_count] and supplier_data[:existing_products] + @supplier_products[:by_supplier][supplier_id][:non_updated] = supplier_data[:existing_products] - supplier_data[:updates_count] + end + end + @supplier_products + end + def valid_count @valid_entries.count end @@ -140,7 +154,7 @@ class ProductImporter def validate_all entries.each_with_index do |entry, i| - line_number = i+2 + line_number = i+2 # Roo counts "line 2" as the first line of data supplier_validation(line_number, entry) category_validation(line_number, entry) @@ -149,9 +163,26 @@ class ProductImporter mark_as_valid(line_number, entry) unless entry_invalid?(line_number) end + count_existing_products + delete_uploaded_file if item_count.zero? or valid_count.zero? end + def count_existing_products + @suppliers_index.each do |supplier_name, supplier_id| + if supplier_id + products_count = Spree::Variant.joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.is_master = false + AND spree_variants.deleted_at IS NULL', supplier_id). + count + + @supplier_products[:by_supplier][supplier_id] = {existing_products: products_count} + @supplier_products[:total] += products_count + end + end + end + def entry_invalid?(line_number) !!@invalid_entries[line_number] end @@ -246,14 +277,14 @@ class ProductImporter end def save_all_valid - updated = {} + already_created = {} @products_to_create.each do |line_number, data| entry = data[:entry] # If we've already added a new product with these attributes # from this spreadsheet, mark this entry as a new variant with # the new product id, as this is a now variant of that product... - if updated[entry['supplier_id']] && updated[entry['supplier_id']][entry['name']] - product_id = updated[entry['supplier_id']][entry['name']] + if already_created[entry['supplier_id']] and already_created[entry['supplier_id']][entry['name']] + product_id = already_created[entry['supplier_id']][entry['name']] mark_as_new_variant(line_number, entry, product_id) next end @@ -264,11 +295,12 @@ class ProductImporter if product.save ensure_variant_updated(entry, product) @products_created += 1 + @updated_ids.push product.variants.first.id else self.errors.add("Line #{line_number}:", product.errors.full_messages) end - updated[entry['supplier_id']] = {entry['name'] => product.id} + already_created[entry['supplier_id']] = {entry['name'] => product.id} end @variants_to_update.each do |line_number, data| @@ -276,6 +308,7 @@ class ProductImporter assign_defaults(variant, data[:entry]) if variant.valid? and variant.save @variants_updated += 1 + @updated_ids.push variant.id else self.errors.add("Line #{line_number}:", variant.errors.full_messages) end @@ -286,16 +319,36 @@ class ProductImporter assign_defaults(new_variant, data[:entry]) if new_variant.valid? and new_variant.save @variants_created += 1 + @updated_ids.push new_variant.id else self.errors.add("Line #{line_number}:", new_variant.errors.full_messages) end end - self.errors.add(:importer, "did not save any products successfully") if total_saved_count == 0 + self.errors.add(:importer, "did not save any products successfully") if total_saved_count.zero? + reset_absent_products total_saved_count end + def reset_absent_products + return if total_saved_count.zero? + + enterprises_to_reset = [] + @import_settings.each do |enterprise_id, settings| + enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] + end + + unless enterprises_to_reset.empty? or @updated_ids.empty? + # Set stock to zero for all products in selected enterprises that were not + # present in the uploaded spreadsheet. + Spree::Variant.joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.id NOT IN (?)', enterprises_to_reset, @updated_ids). + update_all(count_on_hand: 0) + end + end + def assign_defaults(object, entry) @import_settings[entry['supplier_id'].to_s]['defaults'].each do |attribute, setting| case setting['mode'] @@ -356,6 +409,7 @@ class ProductImporter check_on_hand_nil(entry, existing_variant) if existing_variant.valid? @variants_to_update[line_number] = {entry: entry, variant: existing_variant} unless entry_invalid?(line_number) + updates_count_per_supplier(entry['supplier_id']) unless entry_invalid?(line_number) else mark_as_invalid(line_number, entry, existing_variant.errors.full_messages) end @@ -372,6 +426,14 @@ class ProductImporter end end + def updates_count_per_supplier(supplier_id) + if @supplier_products[:by_supplier][supplier_id] and @supplier_products[:by_supplier][supplier_id][:updates_count] + @supplier_products[:by_supplier][supplier_id][:updates_count] += 1 + else + @supplier_products[:by_supplier][supplier_id] = {updates_count: 1} + end + end + def check_on_hand_nil(entry, variant) if entry['on_hand'].blank? variant.on_hand = 0 diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml index 64f9b657ff..242c0d8638 100644 --- a/app/views/admin/product_import/_import_options.html.haml +++ b/app/views/admin/product_import/_import_options.html.haml @@ -7,7 +7,7 @@ %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active}'}} %div.header-caret %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} - %div.header-icon + %div.header-icon.neutral %i.fa.fa-edit -#%div.header-count -# %strong.invalid-count= @importer.invalid_count diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 52cc334ca2..4f547b0dbb 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -4,9 +4,32 @@ %div.panel-section %div.panel-header %div.header-caret - -#%i.icon-chevron-right{ng: {hide: 'active'}} - -#%i.icon-chevron-down{ng: {hide: '!active'}} - %div.header-icon + %div.header-icon.info + %i.fa.fa-info-circle + %div.header-count + %strong.item-count= @importer.supplier_products[:total] + %div.header-description + Existing products in referenced enterprise(s) + -#%div.panel-content{ng: {hide: '!active'}} + -# Content goes here + +%div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetCount == 0'}} + %div.panel-header + %div.header-caret + %div.header-icon.info + %i.fa.fa-info-circle + %div.header-count + %strong.reset-count + {{resetCount}} + %div.header-description + Existing products will have their stock reset to zero + -#%div.panel-content{ng: {hide: '!active'}} + -# Content goes here + +%div.panel-section + %div.panel-header + %div.header-caret + %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.item-count= @importer.item_count @@ -19,7 +42,7 @@ %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon + %div.header-icon.warning %i.fa.fa-warning %div.header-count %strong.invalid-count= @importer.invalid_count @@ -34,7 +57,7 @@ %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon + %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.create-count= @importer.products_create_count @@ -47,7 +70,7 @@ %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} %div.header-caret %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} - %div.header-icon + %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.update-count= @importer.products_update_count diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml index 040a6d4f44..c6a5c28030 100644 --- a/app/views/admin/product_import/_options_form.html.haml +++ b/app/views/admin/product_import/_options_form.html.haml @@ -1,9 +1,9 @@ -%table.import-settings +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{id}; nonUpdated = #{@importer.supplier_products[:by_supplier][id][:non_updated]}"}} %tr %td.description Remove absent products? %td - = check_box_tag "settings[#{id}][products_absent]", 1, false + = check_box_tag "settings[#{id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' %td %tr %td.description diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 28976fee43..23703b953c 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -14,8 +14,12 @@ feature "Product Import", js: true do let!(:tax_category) { create(:tax_category) } let!(:tax_category2) { create(:tax_category) } let!(:shipping_category) { create(:shipping_category) } - let!(:product) { create(:simple_product, supplier: enterprise, name: 'Hypothetical Cake') } - let!(:variant) { create(:variant, product_id: product.id, price: '8.50', count_on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } + let!(:product) { create(:simple_product, supplier: enterprise2, name: 'Hypothetical Cake') } + let!(:variant) { create(:variant, product_id: product.id, price: '8.50', on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } + let!(:product2) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Beans') } + let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts') } + let!(:product4) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce') } + describe "when importing products from uploaded file" do before { quick_login_as_admin } @@ -104,8 +108,8 @@ feature "Product Import", js: true do it "can add new variants to existing products and update price and stock level of existing products" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Hypothetical Cake", "User Enterprise", "Cake", "5", "5.50", "500", "weight", "1", "Preexisting Banana"] - csv << ["Hypothetical Cake", "User Enterprise", "Cake", "6", "3.50", "500", "weight", "1", "Emergent Coffee"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "5", "5.50", "500", "weight", "1", "Preexisting Banana"] + csv << ["Hypothetical Cake", "Another Enterprise", "Cake", "6", "3.50", "500", "weight", "1", "Emergent Coffee"] end File.write('/tmp/test.csv', csv_data) @@ -236,6 +240,36 @@ feature "Product Import", js: true do describe "applying settings and defaults on import" do before { quick_login_as_admin } + it "can set all products for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + attach_file 'file', '/tmp/test.csv' + click_button 'Import' + + within 'div.import-settings' do + find('div.header-description').click # Import settings tab + check "settings_#{enterprise.id}_reset_all_absent" + end + + expect(page).to have_selector '.reset-count', text: "2" + + click_button 'Save' + expect(page).to have_content "Products created: 2" + + Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file + Spree::Product.find_by_name('Potatoes').on_hand.should == 6 # Present in file + Spree::Product.find_by_name('Beans').on_hand.should == 0 # In enterprise, not in file + Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file + Spree::Product.find_by_name('Lettuce').on_hand.should == 100 # In different enterprise; unchanged + end + it "overwrites fields with selected defaults" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"]