From b3c906b3a478fa665c987cb991c55568a5e03f3e Mon Sep 17 00:00:00 2001 From: Matt-Yorkley Date: Fri, 21 Apr 2017 20:11:12 +0100 Subject: [PATCH] Product Import v3 with asynchronous processing Fixed spec Quick spec tweak --- .../controllers/import_feedback.js.coffee | 5 +- .../import_form_controller.js.coffee | 159 ++++++ .../controllers/import_options_form.js.coffee | 31 +- .../services/product_import_service.js.coffee | 6 + .../stylesheets/admin/product_import.css.scss | 59 +- .../admin/product_import_controller.rb | 54 +- .../admin/variant_overrides_controller.rb | 4 +- .../admin/products_controller_decorator.rb | 4 +- app/models/product_importer.rb | 91 ++- app/models/spree/ability_decorator.rb | 2 +- .../product_import/_entries_table.html.haml | 2 +- .../product_import/_errors_list.html.haml | 2 +- .../product_import/_import_review.html.haml | 72 +-- .../_inventory_options_form.html.haml | 13 +- .../_product_options_form.html.haml | 28 +- .../product_import/_save_results.html.haml | 60 ++ .../product_import/_upload_form.html.haml | 2 +- .../admin/product_import/import.html.haml | 68 ++- config/locales/en.yml | 10 +- config/routes.rb | 5 +- spec/features/admin/product_import_spec.rb | 396 ++----------- spec/models/product_importer_spec.rb | 525 +++++++++++++++++- 22 files changed, 1124 insertions(+), 474 deletions(-) create mode 100644 app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee create mode 100644 app/views/admin/product_import/_save_results.html.haml diff --git a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee index 3c9891dec0..990d3598bf 100644 --- a/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee +++ b/app/assets/javascripts/admin/product_import/controllers/import_feedback.js.coffee @@ -1,5 +1,4 @@ -angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope, productImportData) -> - $scope.entries = productImportData +angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope) -> $scope.count = (items) -> total = 0 @@ -8,4 +7,4 @@ angular.module("ofn.admin").controller "ImportFeedbackCtrl", ($scope, productImp total $scope.attribute_invalid = (attribute, line_number) -> - $scope.entries[line_number]['errors'][attribute] != undefined \ No newline at end of file + $scope.entries[line_number]['errors'][attribute] != undefined diff --git a/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee new file mode 100644 index 0000000000..c2de32cac3 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/import_form_controller.js.coffee @@ -0,0 +1,159 @@ +angular.module("ofn.admin").controller "ImportFormCtrl", ($scope, $http, $filter, ProductImportService, $timeout) -> + + $scope.entries = {} + $scope.update_counts = {} + $scope.reset_counts = {} + + #$scope.import_options = {} + + $scope.updates = {} + $scope.updated_total = 0 + $scope.updated_ids = [] + $scope.update_errors = [] + + $scope.chunks = 0 + $scope.completed = 0 + $scope.percentage = "0%" + $scope.started = false + $scope.finished = false + + $scope.countResettable = () -> + angular.forEach $scope.supplier_product_counts, (value, key) -> + $scope.reset_counts[key] = value + if $scope.update_counts[key] + $scope.reset_counts[key] -= $scope.update_counts[key] + + $scope.resetProgress = () -> + $scope.chunks = 0 + $scope.completed = 0 + $scope.percentage = "0%" + $scope.started = false + $scope.finished = false + + $scope.step = 'import' + + $scope.viewResults = () -> + $scope.countResettable() + $scope.step = 'results' + $scope.resetProgress() + + $scope.acceptResults = () -> + $scope.step = 'save' + + $scope.finalResults = () -> + $scope.step = 'complete' + + $scope.start = () -> + $scope.started = true + $scope.percentage = "1%" + total = $scope.item_count + size = 100 + $scope.chunks = Math.ceil(total / size) + + i = 0 + + while i < $scope.chunks + start = (i*size)+1 + end = (i+1)*size + if $scope.step == 'import' + $scope.processImport(start, end) + if $scope.step == 'save' + $scope.processSave(start, end) + i++ + + $scope.processImport = (start, end) -> + $http( + url: $scope.import_url + method: 'POST' + data: + 'start': start + 'end': end + 'filepath': $scope.filepath + 'import_into': $scope.import_into + ).success((data, status, headers, config) -> + angular.merge($scope.entries, angular.fromJson(data['entries'])) + $scope.sortUpdates(data['reset_counts']) + + $scope.updateProgress() + ).error((data, status, headers, config) -> + console.log('Error: '+status) + ) + + $scope.importSettings = null + + $scope.getSettings = () -> + $scope.importSettings = ProductImportService.getSettings() + + $scope.sortUpdates = (data) -> + angular.forEach data, (value, key) -> + if (key in $scope.update_counts) + $scope.update_counts[key] += value['updates_count'] + else + $scope.update_counts[key] = value['updates_count'] + + $scope.processSave = (start, end) -> + $scope.getSettings() if $scope.importSettings == null + $http( + url: $scope.save_url + method: 'POST' + data: + 'start': start + 'end': end + 'filepath': $scope.filepath + 'import_into': $scope.import_into, + 'settings': $scope.importSettings + ).success((data, status, headers, config) -> + $scope.sortResults(data['results']) + + angular.forEach data['updated_ids'], (id) -> + $scope.updated_ids.push(id) + + angular.forEach data['errors'], (error) -> + $scope.update_errors.push(error) + + $scope.updateProgress() + ).error((data, status, headers, config) -> + console.log('Error: '+status) + ) + + $scope.sortResults = (results) -> + angular.forEach results, (value, key) -> + if ($scope.updates[key] != undefined) + $scope.updates[key] += value + else + $scope.updates[key] = value + + $scope.updated_total += value + + $scope.resetAbsent = () -> + enterprises_to_reset = [] + angular.forEach $scope.importSettings, (settings, enterprise) -> + if settings['reset_all_absent'] + enterprises_to_reset.push(enterprise) + + if enterprises_to_reset.length && $scope.updated_ids.length + $http( + url: $scope.reset_url + method: 'POST' + data: + 'filepath': $scope.filepath + 'import_into': $scope.import_into, + 'settings': $scope.importSettings + 'reset_absent': true, + 'updated_ids': $scope.updated_ids, + 'enterprises_to_reset': enterprises_to_reset + ).success((data, status, headers, config) -> + console.log(data) + $scope.updates.products_reset = data + + ).error((data, status, headers, config) -> + console.log('Error: '+status) + ) + + $scope.updateProgress = () -> + $scope.completed++ + $scope.percentage = String(Math.round(($scope.completed / $scope.chunks) * 100)) + '%' + + if $scope.completed == $scope.chunks + $scope.finished = true + $scope.resetAbsent() if $scope.step == 'save' 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 index 21c08b9ae1..41a1c318fa 100644 --- 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 @@ -1,12 +1,31 @@ angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) -> - $scope.toggleResetAbsent = () -> - confirmed = confirm t('js.product_import.confirmation') if $scope.resetAbsent + $scope.initForm = () -> + $scope.settings = {} if $scope.settings == undefined + $scope.settings[$scope.supplierId] = { + defaults: + count_on_hand: + mode: 'overwrite_all' + on_hand: + mode: 'overwrite_all' + tax_category_id: + mode: 'overwrite_all' + shipping_category_id: + mode: 'overwrite_all' + available_on: + mode: 'overwrite_all' + } - if confirmed or !$scope.resetAbsent - ProductImportService.updateResetAbsent($scope.supplierId, $scope.resetCount, $scope.resetAbsent) - else - $scope.resetAbsent = false + $scope.$watch 'settings', (updated) -> + ProductImportService.updateSettings(updated) + , true + + $scope.toggleResetAbsent = (id) -> + resetAbsent = $scope.settings[id]['reset_all_absent'] + confirmed = confirm t('js.product_import.confirmation') if resetAbsent + + if confirmed or !resetAbsent + ProductImportService.updateResetAbsent($scope.supplierId, $scope.reset_counts[$scope.supplierId], resetAbsent) $scope.resetTotal = ProductImportService.resetTotal 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 index 330e7b6cad..51b57c8735 100644 --- 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 @@ -2,6 +2,7 @@ angular.module("ofn.admin").factory "ProductImportService", ($rootScope) -> new class ProductImportService suppliers: {} resetTotal: 0 + settings: {} updateResetAbsent: (supplierId, resetCount, resetAbsent) -> if resetAbsent @@ -13,3 +14,8 @@ angular.module("ofn.admin").factory "ProductImportService", ($rootScope) -> $rootScope.resetTotal = @resetTotal + updateSettings: (updated) -> + angular.merge(@settings, updated) + + getSettings: () -> + @settings \ No newline at end of file diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss index ae30cac119..ebd58fc468 100644 --- a/app/assets/stylesheets/admin/product_import.css.scss +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -238,7 +238,7 @@ table.import-settings { } } -form.product-import, div.post-save-results { +form.product-import, div.post-save-results, div.import-wrapper { input[type="submit"] { margin-right: 0.5em; } @@ -246,4 +246,59 @@ form.product-import, div.post-save-results { min-width: 8em; text-align: center; } -} \ No newline at end of file +} + +form.product-import, div.save-results { + transition: all linear 0.25s; +} + +form.product-import.ng-hide, div.save-results.ng-hide { + opacity: 0; +} + +div.import-wrapper { + div.progress-interface { + text-align: center; + transition: all linear 0.25s; + + button { + + } + + button:disabled { + background: #ccc !important; + } + + } + div.progress-interface.ng-hide { + position: absolute; + width: 100%; + opacity: 0; + } + .post-save-results { + a.button{ + float: left; + margin-right: 0.5em; + } + } +} + +div.progress-bar { + height: 25px; + width: 30em; + max-width: 90%; + margin: 1em auto; + background: #f7f7f7; + padding: 3px; + border-radius: 0.3em; + border: 1px solid #eee; + + span.progress-track{ + display: block; + background: #b7ea53; + height: 100%; + border-radius: 0.3em; + box-shadow: inset 0 0 3px rgba(0,0,0,0.3); + transition: width 0.5s ease-in-out; + } +} diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index b808875aff..08868cbaaf 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -2,12 +2,13 @@ require 'roo' class Admin::ProductImportController < Spree::Admin::BaseController - before_filter :validate_upload_presence, except: :index + before_filter :validate_upload_presence, except: [:index, :process_data] def import # Save uploaded file to tmp directory @filepath = save_uploaded_file(params[:file]) @importer = ProductImporter.new(File.new(@filepath), spree_current_user, params[:settings]) + @original_filename = params[:file].try(:original_filename) @import_into = params[:settings][:import_into] check_file_errors @importer @@ -17,10 +18,53 @@ class Admin::ProductImportController < Spree::Admin::BaseController @shipping_categories = Spree::ShippingCategory.order('name ASC') end - def save - @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) - @importer.save_all if @importer.has_valid_entries? - @import_into = params[:settings][:import_into] + # def save + # @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, params[:settings]) + # @importer.save_all if @importer.has_valid_entries? + # @import_into = params[:settings][:import_into] + # end + + def process_data + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into]}) + + @importer.validate_entries + + import_results = { + entries: @importer.entries_json, + reset_counts: @importer.reset_counts + } + + render json: import_results + end + + def save_data + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {start: params[:start], end: params[:end], import_into: params[:import_into], settings: params[:settings]}) + + @importer.save_entries + + save_results = { + results: { + products_created: @importer.products_created_count, + products_updated: @importer.products_updated_count, + inventory_created: @importer.inventory_created_count, + inventory_updated: @importer.inventory_updated_count, + products_reset: @importer.products_reset_count, + }, + updated_ids: @importer.updated_ids, + errors: @importer.errors.full_messages + } + + render json: save_results + end + + def reset_absent_products + @importer = ProductImporter.new(File.new(params[:filepath]), spree_current_user, {import_into: params[:import_into], enterprises_to_reset: params[:enterprises_to_reset], updated_ids: params[:updated_ids], 'settings' => params[:settings]}) + + if params.has_key?(:enterprises_to_reset) and params.has_key?(:updated_ids) + @importer.reset_absent(params[:updated_ids]) + end + + render json: @importer.products_reset_count end private diff --git a/app/controllers/admin/variant_overrides_controller.rb b/app/controllers/admin/variant_overrides_controller.rb index cf978881a3..072a1f1eeb 100644 --- a/app/controllers/admin/variant_overrides_controller.rb +++ b/app/controllers/admin/variant_overrides_controller.rb @@ -58,8 +58,8 @@ module Admin @inventory_items = InventoryItem.where(enterprise_id: @hubs) import_dates = [{id: '0', name: 'All'}] - inventory_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } - @import_dates = import_dates.to_json + inventory_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + @import_dates = import_dates.uniq.to_json end def load_collection diff --git a/app/controllers/spree/admin/products_controller_decorator.rb b/app/controllers/spree/admin/products_controller_decorator.rb index b6a71273f3..bd6257d2a0 100644 --- a/app/controllers/spree/admin/products_controller_decorator.rb +++ b/app/controllers/spree/admin/products_controller_decorator.rb @@ -102,8 +102,8 @@ Spree::Admin::ProductsController.class_eval do @producers = OpenFoodNetwork::Permissions.new(spree_current_user).managed_product_enterprises.is_primary_producer.by_name @taxons = Spree::Taxon.order(:name) import_dates = [{id: '0', name: ''}] - product_import_dates.map {|i| import_dates.push({id: i, name: i.to_formatted_s(:long)}) } - @import_dates = import_dates.to_json + product_import_dates.map {|i| import_dates.push({id: i.to_date, name: i.to_date.to_formatted_s(:long)}) } + @import_dates = import_dates.uniq.to_json end def product_import_dates diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb index 28ad0ffc29..1eb178cfdd 100644 --- a/app/models/product_importer.rb +++ b/app/models/product_importer.rb @@ -5,7 +5,7 @@ class ProductImporter include ActiveModel::Conversion include ActiveModel::Validations - attr_reader :total_supplier_products + attr_reader :total_supplier_products, :supplier_products, :updated_ids def initialize(file, current_user, import_settings={}) if file.is_a?(File) @@ -33,6 +33,7 @@ class ProductImporter @inventory_permissions = {} @total_supplier_products = 0 + @supplier_products = {} @reset_counts = {} @updated_ids = [] @@ -42,16 +43,6 @@ class ProductImporter end end - def init_permissions - permissions = OpenFoodNetwork::Permissions.new(@current_user) - - permissions.editable_enterprises. - order('is_primary_producer ASC, name'). - map { |e| @editable_enterprises[e.name] = e.id } - - @inventory_permissions = permissions.variant_override_enterprises_per_hub - end - def persisted? false # ActiveModel end @@ -148,15 +139,55 @@ class ProductImporter @current_user.admin? or ( @inventory_permissions[supplier_id] and @inventory_permissions[supplier_id].include? producer_id ) end + def validate_entries + @entries.each do |entry| + supplier_validation(entry) + + if importing_into_inventory? + producer_validation(entry) + inventory_validation(entry) + else + category_validation(entry) + product_validation(entry) + end + end + end + + def save_entries + validate_entries + save_all_valid + end + + def reset_absent(updated_ids) + @products_created = updated_ids.count + @updated_ids = updated_ids + reset_absent_items + end + private def init_product_importer init_permissions - build_entries + if @import_settings.has_key?(:start) and @import_settings.has_key?(:end) + build_entries_in_range + else + build_entries + end build_categories_index build_suppliers_index build_producers_index if importing_into_inventory? - validate_all + #validate_all + count_existing_items unless @import_settings.has_key?(:start) + end + + def init_permissions + permissions = OpenFoodNetwork::Permissions.new(@current_user) + + permissions.editable_enterprises. + order('is_primary_producer ASC, name'). + map { |e| @editable_enterprises[e.name] = e.id } + + @inventory_permissions = permissions.variant_override_enterprises_per_hub end def open_spreadsheet @@ -184,6 +215,21 @@ class ProductImporter end end + def build_entries_in_range + start_line = @import_settings[:start] + end_line = @import_settings[:end] + + (start_line..end_line).each do |i| + line_number = i + 1 + row = @sheet.row(line_number) + row_data = Hash[[headers, row].transpose] + entry = SpreadsheetEntry.new(row_data) + entry.line_number = line_number + @entries.push entry + return if @sheet.last_row == line_number # TODO: test + end + end + def build_entries rows.each_with_index do |row, i| row_data = Hash[[headers, row].transpose] @@ -212,7 +258,7 @@ class ProductImporter end def importing_into_inventory? - @import_settings['import_into'] == 'inventories' + @import_settings[:import_into] == 'inventories' end def inventory_validation(entry) @@ -282,12 +328,7 @@ class ProductImporter count end - if @reset_counts[supplier_id] - @reset_counts[supplier_id][:existing_products] = products_count - else - @reset_counts[supplier_id] = {existing_products: products_count} - end - + @supplier_products[supplier_id] = products_count @total_supplier_products += products_count end end @@ -415,7 +456,7 @@ class ProductImporter self.errors.add(:importer, I18n.t(:product_importer_products_save_error)) if total_saved_count.zero? - reset_absent_items + reset_absent_items unless @import_settings.has_key?(:start) total_saved_count end @@ -521,10 +562,10 @@ class ProductImporter end def reset_absent_items - return if total_saved_count.zero? or @updated_ids.empty? + return if total_saved_count.zero? or @updated_ids.empty? or !@import_settings.has_key?('settings') enterprises_to_reset = [] - @import_settings.each do |enterprise_id, settings| + @import_settings['settings'].each do |enterprise_id, settings| enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) end @@ -548,9 +589,9 @@ class ProductImporter end def assign_defaults(object, entry) - return unless @import_settings[entry.supplier_id.to_s] and @import_settings[entry.supplier_id.to_s]['defaults'] + return unless @import_settings.has_key?(:settings) and @import_settings[:settings][entry.supplier_id.to_s] and @import_settings[:settings][entry.supplier_id.to_s]['defaults'] - @import_settings[entry.supplier_id.to_s]['defaults'].each do |attribute, setting| + @import_settings[:settings][entry.supplier_id.to_s]['defaults'].each do |attribute, setting| next unless setting['active'] case setting['mode'] diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 0c9492b4cf..b12124b067 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -177,7 +177,7 @@ class AbilityDecorator can [:admin, :index, :read, :search], Spree::Taxon can [:admin, :index, :read, :create, :edit], Spree::Classification - can [:admin, :index, :import, :save], ProductImporter + can [:admin, :index, :import, :save, :save_data, :process_data, :reset_absent_products], ProductImporter # Reports page can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :bulk_coop, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing], :report diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml index 425cd04159..4d19f19b21 100644 --- a/app/views/admin/product_import/_entries_table.html.haml +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -5,7 +5,7 @@ %th #{t('admin.product_import.import.line')} - @importer.table_headings.each do |heading| %th= heading - %tr{ng: {repeat: "(line_number, entry ) in entries | entriesFilterValid:'#{filter}' "}} + %tr{ng: {repeat: "(line_number, entry) in (entries | entriesFilterValid:'#{entries}')"}} %td %i{ng: {class: "{'fa fa-warning warning': (count(entry.errors) > 0), 'fa fa-check-circle success': (count(entry.errors) == 0)}"}} %td diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml index ab907344d8..260ad46196 100644 --- a/app/views/admin/product_import/_errors_list.html.haml +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -1,4 +1,4 @@ -%div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in entries | entriesFilterValid:'invalid' "}} +%div.import-errors{ng: {controller: 'ImportFeedbackCtrl', repeat: "(line_number, entry ) in (entries | entriesFilterValid:'invalid')"}} %p.line %strong #{t('admin.product_import.import.item_line')} {{line_number}}: diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml index 18f2238056..0e65bc6135 100644 --- a/app/views/admin/product_import/_import_review.html.haml +++ b/app/views/admin/product_import/_import_review.html.haml @@ -3,91 +3,91 @@ %div{ng: {controller: 'ImportFeedbackCtrl'}} - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "all.count = count((entries | entriesFilterValid:'all')) "}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && all.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"all"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'all.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"all")) == 0'}} %div.header-icon.success %i.fa.fa-info-circle.info %div.header-count %strong.item-count - {{all.count}} + {{count((entries | entriesFilterValid:"all"))}} %div.header-description #{t('admin.product_import.import.entries_found')} - %div.panel-content{ng: {hide: '!active || all.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'all' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"all")) == 0'}} + = render 'entries_table', entries: 'all' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "invalid.count = count((entries | entriesFilterValid:'invalid')) ", hide: 'invalid.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && invalid.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"invalid")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"invalid"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'invalid.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"invalid")) == 0'}} %div.header-icon.warning %i.fa.fa-warning %div.header-count %strong.invalid-count - {{invalid.count}} + {{count((entries | entriesFilterValid:"invalid"))}} %div.header-description #{t('admin.product_import.import.entries_with_errors')} - %div.panel-content{ng: {hide: '!active || invalid.count == 0'}} + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"invalid")) == 0'}} = render 'errors_list' %br - = render 'entries_table', entries: @importer.all_entries, filter: 'invalid' + = render 'entries_table', entries: 'invalid' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_product.count = count((entries | entriesFilterValid:'create_product')) ", hide: 'create_product.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_product.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"create_product")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"create_product"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_product.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"create_product")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.create-count - {{create_product.count}} + {{count((entries | entriesFilterValid:"create_product"))}} %div.header-description #{t('admin.product_import.import.products_to_create')} - %div.panel-content{ng: {hide: '!active || create_product.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'create_product' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_product")) == 0'}} + = render 'entries_table', entries: 'create_product' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_product.count = count((entries | entriesFilterValid:'update_product')) ", hide: 'update_product.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_product.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"update_product")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"update_product"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_product.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"update_product")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.update-count - {{update_product.count}} + {{count((entries | entriesFilterValid:"update_product"))}} %div.header-description #{t('admin.product_import.import.products_to_update')} - %div.panel-content{ng: {hide: '!active || update_product.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'update_product' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_product")) == 0'}} + = render 'entries_table', entries: 'update_product' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "create_inventory.count = count((entries | entriesFilterValid:'create_inventory')) ", hide: 'create_inventory.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && create_inventory.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"create_inventory")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"create_inventory"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'create_inventory.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"create_inventory")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.inv-create-count - {{create_inventory.count}} + {{count((entries | entriesFilterValid:"create_inventory"))}} %div.header-description #{t('admin.product_import.import.inventory_to_create')} - %div.panel-content{ng: {hide: '!active || create_inventory.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'create_inventory' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"create_inventory")) == 0'}} + = render 'entries_table', entries: 'create_inventory' - %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "update_inventory.count = count((entries | entriesFilterValid:'update_inventory')) ", hide: 'update_inventory.count == 0'}} - %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && update_inventory.count}'}} + %div.panel-section{ng: {controller: 'DropdownPanelsCtrl', hide: 'count((entries | entriesFilterValid:"update_inventory")) == 0'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count((entries | entriesFilterValid:"update_inventory"))}'}} %div.header-caret - %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'update_inventory.count == 0'}} + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count((entries | entriesFilterValid:"update_inventory")) == 0'}} %div.header-icon.success %i.fa.fa-check-circle %div.header-count %strong.inv-update-count - {{update_inventory.count}} + {{count((entries | entriesFilterValid:"update_inventory"))}} %div.header-description #{t('admin.product_import.import.inventory_to_update')} - %div.panel-content{ng: {hide: '!active || update_inventory.count == 0'}} - = render 'entries_table', entries: @importer.all_entries, filter: 'update_inventory' + %div.panel-content{ng: {hide: '!active || count((entries | entriesFilterValid:"update_inventory")) == 0'}} + = render 'entries_table', entries: 'update_inventory' %div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetTotal == 0'}} %div.panel-header diff --git a/app/views/admin/product_import/_inventory_options_form.html.haml b/app/views/admin/product_import/_inventory_options_form.html.haml index 087dd5f54d..57974c016b 100644 --- a/app/views/admin/product_import/_inventory_options_form.html.haml +++ b/app/views/admin/product_import/_inventory_options_form.html.haml @@ -1,16 +1,19 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} %tr %td.description #{t('admin.product_import.import.reset_absent?')} %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, 'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", 'ng-change' => "toggleResetAbsent('#{supplier_id}')" + %td + %td %td %tr %td.description #{t('admin.product_import.import.default_stock')} %td - = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, :'ng-model' => "count_on_hand_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][count_on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!count_on_hand_#{supplier_id}"} %td - = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!count_on_hand_#{supplier_id}" \ No newline at end of file + = select_tag "settings[#{supplier_id}][defaults][count_on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']"} + %td + = number_field_tag "settings[#{supplier_id}][defaults][count_on_hand][value]", 0, 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['count_on_hand']['active']", 'ng-model' => "settings[#{supplier_id}]['defaults']['count_on_hand']['value']" diff --git a/app/views/admin/product_import/_product_options_form.html.haml b/app/views/admin/product_import/_product_options_form.html.haml index b7d0316322..f1bbeb392c 100644 --- a/app/views/admin/product_import/_product_options_form.html.haml +++ b/app/views/admin/product_import/_product_options_form.html.haml @@ -1,44 +1,44 @@ -%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.reset_counts[supplier_id][:reset_count]}"}} +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; initForm()"}} %tr %td.description #{t('admin.product_import.import.reset_absent?')} %td - = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => "settings[#{supplier_id}]['reset_all_absent']", :'ng-change' => "toggleResetAbsent('#{supplier_id}')" %td %td %tr %td.description #{t('admin.product_import.import.default_stock')} %td - = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, :'ng-model' => "on_hand_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][on_hand][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!on_hand_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']"} %td - = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-disabled' => "!on_hand_#{supplier_id}" + = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0, 'ng-model' => "settings[#{supplier_id}]['defaults']['on_hand']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['on_hand']['active']" %tr %td.description #{t('admin.product_import.import.default_tax_cat')} %td - = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, :'ng-model' => "tax_category_id_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][tax_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %td - = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!tax_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][value]", options_for_select(@tax_categories.map {|tc| [tc.name, tc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['tax_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['tax_category_id']['active']"} %tr %td.description #{t('admin.product_import.import.default_shipping_cat')} %td - = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, :'ng-model' => "shipping_category_id_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][shipping_category_id][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %td - = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!shipping_category_id_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][value]", options_for_select(@shipping_categories.map {|sc| [sc.name, sc.id]}), {prompt: 'None', class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['shipping_category_id']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['shipping_category_id']['active']"} %tr %td.description #{t('admin.product_import.import.default_available_date')} %td - = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, :'ng-model' => "available_on_#{supplier_id}" + = check_box_tag "settings[#{supplier_id}][defaults][available_on][active]", 1, false, 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['active']" %td - = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-disabled' => "!available_on_#{supplier_id}"} + = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"#{t('admin.product_import.import.overwrite_all')}" => :overwrite_all, "#{t('admin.product_import.import.overwrite_empty')}" => :overwrite_empty}), {class: 'select2 fullwidth select2-no-search', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['mode']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} %td - = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-disabled' => "!available_on_#{supplier_id}"} + = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today', 'ng-model' => "settings[#{supplier_id}]['defaults']['available_on']['value']", 'ng-disabled' => "!settings[#{supplier_id}]['defaults']['available_on']['active']"} diff --git a/app/views/admin/product_import/_save_results.html.haml b/app/views/admin/product_import/_save_results.html.haml new file mode 100644 index 0000000000..190b301f48 --- /dev/null +++ b/app/views/admin/product_import/_save_results.html.haml @@ -0,0 +1,60 @@ + +%h5 #{t('admin.product_import.save.final_results')} +%br + +%div.post-save-results + + %p{ng: {show: 'updates.products_created'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.products_created == 0, 'fa-check-circle': updates.products_created > 0}"}} + %strong.created-count + {{updates.products_created}} + #{t('admin.product_import.save.products_created')} + + %p{ng: {show: 'updates.products_updated'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.products_updated == 0, 'fa-check-circle': updates.products_updated > 0}"}} + %strong.updated-count + {{updates.products_updated}} + #{t('admin.product_import.save.products_updated')} + + %p{ng: {show: 'updates.inventory_created'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_created == 0, 'fa-check-circle': updates.inventory_created > 0}"}} + %strong.inv-created-count + {{updates.inventory_created}} + #{t('admin.product_import.save.inventory_created')} + + %p{ng: {show: 'updates.inventory_updated'}} + %i.fa{ng: {class: "{'fa-info-circle': updates.inventory_updated == 0, 'fa-check-circle': updates.inventory_updated > 0}"}} + %strong.inv-updated-count + {{updates.inventory_updated}} + #{t('admin.product_import.save.inventory_updated')} + + %p{ng: {show: 'updates.products_reset'}} + %i.fa.fa-info-circle + %strong.reset-count + {{updates.products_reset}} + - if @import_into == 'inventories' + #{t('admin.product_import.save.inventory_reset')} + - else + #{t('admin.product_import.save.products_reset')} + + %br + + %p{ng: {show: 'update_errors.length == 0'}} + #{t('admin.product_import.save.all_saved')} + + %div{ng: {show: 'update_errors.length > 0'}} + %p {{updated_total}} #{t('admin.product_import.save.some_saved')} + %br + %h5 #{t('admin.product_import.save.save_errors')} + + %p.save-error{ng: {repeat: 'error in update_errors'}} +  -  {{error}} + + %br + %div{ng: {show: 'updated_total > 0'}} + - if @import_into == 'inventories' + %a.button.view{href: main_app.admin_inventory_path} #{t('admin.product_import.save.view_inventory')} + - else + %a.button.view{href: bulk_edit_admin_products_path + '?latest_import=true'} #{t('admin.product_import.save.view_products')} + + %a.button{href: main_app.admin_product_import_path} #{t('admin.back')} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml index ae5360598e..4b752a2548 100644 --- a/app/views/admin/product_import/_upload_form.html.haml +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -14,4 +14,4 @@ %br %br %br - = submit_tag "#{t('admin.product_import.index.import')}" + = submit_tag "#{t('admin.product_import.index.upload')}" diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml index 2ed7a2d6b2..d62ef3c37f 100644 --- a/app/views/admin/product_import/import.html.haml +++ b/app/views/admin/product_import/import.html.haml @@ -3,36 +3,62 @@ = render partial: 'spree/admin/shared/product_sub_menu' -= form_tag main_app.admin_product_import_save_path, {class: 'product-import', 'ng-app' => 'ofn.admin'} do +.import-wrapper{ng: {app: 'ofn.admin', controller: 'ImportFormCtrl', init: "supplier_product_counts = #{@importer.supplier_products.to_json}"}} - - if !@importer.has_valid_entries? #and @importer.invalid_count + - if @importer.item_count == 0 #and @importer.invalid_count %h5 #{t('admin.product_import.import.no_valid_entries')} %p #{t('admin.product_import.import.none_to_save')} %br + - else + .progress-interface{ng: {show: 'step == "import"'}} + %span.filename + #{@original_filename} + %span.percentage + ({{percentage}}) + .progress-bar + %span.progress-track{class: 'ng-binding', style: "width:{{percentage}}"} + %button.start_import{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; import_url = '#{main_app.admin_product_import_process_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} + #{t('admin.product_import.index.import')} + %button.review{ng: {click: 'viewResults()', disabled: '!finished'}} + #{t('admin.product_import.import.review')} - = render partial: "admin/json/injection_ams", locals: {ngModule: 'ofn.admin', name: 'productImportData', json: @importer.entries_json} + = form_tag false, {class: 'product-import', name: 'importForm', 'ng-show' => 'step == "results"'} do - = render 'import_options' if @importer.has_valid_entries? + = render 'import_options' if @importer.table_headings - = render 'import_review' if @importer.has_entries? + = render 'import_review' if @importer.table_headings - %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) > 0"}} - %div{ng: {if: "count((entries | entriesFilterValid:'invalid')) > 0"}} + %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) > 0'}} + %div{ng: {if: 'count((entries | entriesFilterValid:"invalid")) > 0'}} + %br + %h5 #{t('admin.product_import.import.some_invalid_entries')} + %p #{t('admin.product_import.import.save_valid?')} + %div{ng: {show: 'count((entries | entriesFilterValid:"invalid")) == 0'}} + %br + %h5 #{t('admin.product_import.import.no_errors')} + %p #{t('admin.product_import.import.save_all_imported?')} %br - %h5 #{t('admin.product_import.import.some_invalid_entries')} - %p #{t('admin.product_import.import.save_valid?')} - %div{ng: {show: "count((entries | entriesFilterValid:'invalid')) == 0"}} + = hidden_field_tag :filepath, @filepath + = hidden_field_tag "settings[import_into]", @import_into + + %a.button{href: '', ng: {click: 'acceptResults()'}} + #{t('admin.product_import.import.proceed')} + + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + + %div{ng: {controller: 'ImportFeedbackCtrl', show: 'count((entries | entriesFilterValid:"valid")) == 0'}} %br - %h5 #{t('admin.product_import.import.no_errors')} - %p #{t('admin.product_import.import.save_all_imported?')} - %br - = hidden_field_tag :filepath, @filepath - = hidden_field_tag "settings[import_into]", @import_into - = submit_tag t('admin.save') - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} - - %div{ng: {controller: 'ImportFeedbackCtrl', show: "count((entries | entriesFilterValid:'valid')) == 0"}} - %br - %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + %a.button{href: main_app.admin_product_import_path} #{t('admin.cancel')} + .progress-interface{ng: {show: 'step == "save"'}} + %span.filename + #{t('admin.product_import.import.save_imported')} ({{percentage}}) + .progress-bar{} + %span.progress-track{ng: {style: "{'width':percentage}"}} + %button.start_save{ng: {click: 'start()', disabled: 'started', init: "item_count = #{@importer.item_count}; save_url = '#{main_app.admin_product_import_save_async_path}'; reset_url = '#{main_app.admin_product_import_reset_async_path}'; filepath = '#{@filepath}'; import_into = '#{@import_into}'"}} + #{t('admin.product_import.import.save')} + %button.view_results{ng: {click: 'finalResults()', disabled: '!finished'}} + #{t('admin.product_import.import.results')} + .save-results{ng: {show: 'step == "complete"'}} + = render 'save_results' diff --git a/config/locales/en.yml b/config/locales/en.yml index 3959453aae..60d6c08ab6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -500,7 +500,13 @@ en: product_list: Product list inventories: Inventories import: Import + upload: Upload import: + review: Review + proceed: Proceed + save: Save + results: Results + save_imported: Save imported products no_valid_entries: No valid entries found none_to_save: There are no entries that can be saved some_invalid_entries: Imported file contains some invalid entries @@ -538,8 +544,8 @@ en: inventory_updated: Inventory items updated products_reset: Products had stock level reset to zero inventory_reset: Inventory items had stock level reset to zero - all_saved: "All %{num} items saved successfully" - total_saved: "%{num} items saved successfully" + all_saved: "All items saved successfully" + some_saved: "items saved successfully" save_errors: Save errors view_products: View Products view_inventory: View Inventory diff --git a/config/routes.rb b/config/routes.rb index fb4b652bac..58e74ae5d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,7 +135,10 @@ Openfoodnetwork::Application.routes.draw do get '/product_import', to: 'product_import#index' post '/product_import', to: 'product_import#import' - post '/product_import/save', to: 'product_import#save', as: 'product_import_save' + post '/product_import/process_data', to: 'product_import#process_data', as: 'product_import_process_async' + post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' + post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' + #post '/product_import/save', to: 'product_import#save', as: 'product_import_save' resources :variant_overrides do post :bulk_update, on: :collection diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 401abd872c..8ec649f7e1 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -43,14 +43,31 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + + expect(page).to have_selector 'button.start_import' + expect(page).to have_selector "button.review[disabled='disabled']" + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to_not have_selector '.invalid-count' expect(page).to have_selector '.create-count', text: "2" expect(page).to_not have_selector '.update-count' + click_link 'Proceed' + + expect(page).to have_selector 'button.start_save' + expect(page).to have_selector "button.view_results[disabled='disabled']" + + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + + click_button 'Results' + expect(page).to have_selector '.created-count', text: '2' expect(page).to_not have_selector '.updated-count' @@ -61,6 +78,8 @@ feature "Product Import", js: true do potatoes.price.should == 6.50 potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + wait_until { page.find("a.button.view").present? } + click_link 'View Products' expect(page).to have_content 'Bulk Edit Products' @@ -69,36 +88,6 @@ feature "Product Import", js: true do expect(page).to have_field "product_name", with: potatoes.name end - it "displays info about invalid entries but still allows saving of valid entries" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1000", "", "1000"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - expect(page).to have_content "Select a spreadsheet to upload" - attach_file('file', '/tmp/test.csv') - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "1" - expect(page).to have_selector '.create-count', text: "1" - - expect(page).to have_selector 'input[type=submit][value="Save"]' - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '1' - - Spree::Product.find_by_name('Bad Potatoes').should == nil - carrots = Spree::Product.find_by_name('Good Carrots') - carrots.supplier.should == enterprise - carrots.on_hand.should == 5 - carrots.price.should == 3.20 - end - it "displays info about invalid entries but no save button if all items are invalid" do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] @@ -111,7 +100,11 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "2" @@ -121,75 +114,11 @@ feature "Product Import", js: true do expect(page).to_not have_selector 'input[type=submit][value="Save"]' end - 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", "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) - - visit main_app.admin_product_import_path - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '1' - - added_coffee = Spree::Variant.find_by_display_name('Emergent Coffee') - added_coffee.product.name.should == 'Hypothetical Cake' - added_coffee.price.should == 3.50 - added_coffee.on_hand.should == 6 - added_coffee.import_date.should be_within(1.minute).of DateTime.now - - updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') - updated_banana.product.name.should == 'Hypothetical Cake' - updated_banana.price.should == 5.50 - updated_banana.on_hand.should == 5 - updated_banana.import_date.should be_within(1.minute).of DateTime.now - end - - it "can add a new product and sub-variants of that product at the same time" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] - csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "2" - - click_button 'Save' - expect(page).to have_selector '.created-count', text: '2' - - small_bag = Spree::Variant.find_by_display_name('Small Bag') - small_bag.product.name.should == 'Potatoes' - small_bag.price.should == 3.50 - small_bag.on_hand.should == 5 - - big_bag = Spree::Variant.find_by_display_name('Big Bag') - big_bag.product.name.should == 'Potatoes' - big_bag.price.should == 5.50 - big_bag.on_hand.should == 6 - end - it "records a timestamp on import that can be viewed and filtered under Bulk Edit Products" 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) @@ -197,17 +126,29 @@ feature "Product Import", js: true do expect(page).to have_content "Select a spreadsheet to upload" attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' + + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' carrots = Spree::Product.find_by_name('Carrots') carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now click_link 'View Products' wait_until { page.find("#p_#{carrots.id}").present? } expect(page).to have_field "product_name", with: carrots.name + expect(page).to have_field "product_name", with: potatoes.name find("div#columns-dropdown", :text => "COLUMNS").click find("div#columns-dropdown div.menu div.menu_item", text: "Import").click find("div#columns-dropdown", :text => "COLUMNS").click @@ -217,10 +158,11 @@ feature "Product Import", js: true do end expect(page).to have_selector 'div#s2id_import_date_filter' - import_time = carrots.import_date.to_formatted_s(:long) + import_time = carrots.import_date.to_date.to_formatted_s(:long) select import_time, from: "import_date_filter", visible: false expect(page).to have_field "product_name", with: carrots.name + expect(page).to have_field "product_name", with: potatoes.name expect(page).to_not have_field "product_name", with: product.name expect(page).to_not have_field "product_name", with: product2.name end @@ -238,7 +180,11 @@ feature "Product Import", js: true do attach_file 'file', '/tmp/test.csv' select 'Inventories', from: "settings_import_into", visible: false + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "3" expect(page).to_not have_selector '.invalid-count' @@ -247,7 +193,11 @@ feature "Product Import", js: true do expect(page).to have_selector '.inv-create-count', text: "2" expect(page).to have_selector '.inv-update-count', text: "1" + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' expect(page).to_not have_selector '.created-count' expect(page).to_not have_selector '.updated-count' @@ -288,7 +238,7 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.txt' - click_button 'Import' + click_button 'Upload' expect(page).to have_content "Importer could not process file: invalid filetype" expect(page).to_not have_selector 'input[type=submit][value="Save"]' @@ -296,10 +246,9 @@ feature "Product Import", js: true do File.delete('/tmp/test.txt') end - it "returns and error if nothing was uploaded" do + it "returns an error if nothing was uploaded" do visit main_app.admin_product_import_path - expect(page).to have_content 'Select a spreadsheet to upload' - click_button 'Import' + click_button 'Upload' expect(flash_message).to eq I18n.t(:product_import_file_not_found_notice) end @@ -309,7 +258,7 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.csv' - click_button 'Import' + click_button 'Upload' expect(page).to_not have_selector '.create-count' expect(page).to_not have_selector '.update-count' @@ -333,7 +282,11 @@ feature "Product Import", js: true do visit main_app.admin_product_import_path attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + click_button 'Import' + wait_until { page.find("button.review:not([disabled='disabled'])").present? } + click_button 'Review' expect(page).to have_selector '.item-count', text: "2" expect(page).to have_selector '.invalid-count', text: "1" @@ -341,249 +294,16 @@ feature "Product Import", js: true do expect(page.body).to have_content 'you do not have permission' + click_link 'Proceed' + sleep 0.5 click_button 'Save' + wait_until { page.find("button.view_results:not([disabled='disabled'])").present? } + click_button 'Results' expect(page).to have_selector '.created-count', text: '1' Spree::Product.find_by_name('My Carrots').should be_a Spree::Product Spree::Product.find_by_name('Your Potatoes').should == nil end - - it "allows creating inventories for producers that a user's hub has permission for" do - csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] - end - File.write('/tmp/test.csv', csv_data) - - quick_login_as user2 - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "1" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.inv-create-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.inv-created-count', text: '1' - - beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - beans.count_on_hand.should == 777 - end - - it "does not allow creating inventories for producers that a user's hubs don't have permission for" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] - csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - quick_login_as user2 - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to have_selector '.invalid-count', text: "2" - expect(page).to_not have_selector '.inv-create-count' - - expect(page.body).to have_content 'you do not have permission' - expect(page).to_not have_selector 'input[type=submit][value="Save"]' - end - end - - describe "applying settings and defaults on import" do - before { quick_login_as_admin } - after { File.delete('/tmp/test.csv') } - - it "can reset 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 << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "weight", "1"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.create-count', text: "1" - expect(page).to have_selector '.update-count', text: "1" - - expect(page).to_not have_selector '.reset-count' - - 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_selector '.created-count', text: '1' - expect(page).to have_selector '.updated-count', text: '1' - expect(page).to have_selector '.reset-count', text: '2' - - Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file, added - Spree::Product.find_by_name('Beans').on_hand.should == 6 # Present in file, updated - Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file - Spree::Product.find_by_name('Cabbage').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 "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do - csv_data = CSV.generate do |csv| - csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] - csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - expect(page).to have_selector '.item-count', text: "2" - expect(page).to_not have_selector '.invalid-count' - expect(page).to have_selector '.inv-create-count', text: "2" - - expect(page).to_not have_selector '.reset-count' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - check "settings_#{enterprise2.id}_reset_all_absent" - end - - expect(page).to have_selector '.reset-count', text: "1" - - click_button 'Save' - - expect(page).to have_selector '.inv-created-count', text: '2' - expect(page).to have_selector '.reset-count', text: '1' - - beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first - cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first - - beans.count_on_hand.should == 6 # Present in file, created - sprouts.count_on_hand.should == 7 # Present in file, created - cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) - lettuce.count_on_hand.should == 96 # In different enterprise; unchanged - end - - it "can overwrite fields with selected defaults when importing to product list" 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"] - csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] - 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 - expect(page).to have_selector "#settings_#{enterprise.id}_defaults_on_hand_mode", visible: false - - # Overwrite stock level of all items to 9000 - check "settings_#{enterprise.id}_defaults_on_hand_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_on_hand_mode", visible: false - fill_in "settings_#{enterprise.id}_defaults_on_hand_value", with: '9000' - - # Overwrite default tax category, but only where field is empty - check "settings_#{enterprise.id}_defaults_tax_category_id_active" - select 'Overwrite if empty', from: "settings_#{enterprise.id}_defaults_tax_category_id_mode", visible: false - select tax_category2.name, from: "settings_#{enterprise.id}_defaults_tax_category_id_value", visible: false - - # Set default shipping category (field not present in file) - check "settings_#{enterprise.id}_defaults_shipping_category_id_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_shipping_category_id_mode", visible: false - select shipping_category.name, from: "settings_#{enterprise.id}_defaults_shipping_category_id_value", visible: false - - # Set available_on date - check "settings_#{enterprise.id}_defaults_available_on_active" - select 'Overwrite all', from: "settings_#{enterprise.id}_defaults_available_on_mode", visible: false - find("input#settings_#{enterprise.id}_defaults_available_on_value").set '2020-01-01' - end - - click_button 'Save' - - expect(page).to have_selector '.created-count', text: '2' - - carrots = Spree::Product.find_by_name('Carrots') - carrots.on_hand.should == 9000 - carrots.tax_category_id.should == tax_category.id - carrots.shipping_category_id.should == shipping_category.id - carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) - - potatoes = Spree::Product.find_by_name('Potatoes') - potatoes.on_hand.should == 9000 - potatoes.tax_category_id.should == tax_category2.id - potatoes.shipping_category_id.should == shipping_category.id - potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) - end - - it "can overwrite fields with selected defaults when importing to inventory" do - csv_data = CSV.generate do |csv| - csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] - csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] - csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] - csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] - end - File.write('/tmp/test.csv', csv_data) - - visit main_app.admin_product_import_path - - attach_file 'file', '/tmp/test.csv' - select 'Inventories', from: "settings_import_into", visible: false - click_button 'Import' - - within 'div.import-settings' do - find('div.header-description').click # Import settings tab - check "settings_#{enterprise2.id}_defaults_count_on_hand_active" - select 'Overwrite if empty', from: "settings_#{enterprise2.id}_defaults_count_on_hand_mode", visible: false - fill_in "settings_#{enterprise2.id}_defaults_count_on_hand_value", with: '9000' - end - - expect(page).to have_selector '.item-count', text: "3" - expect(page).to_not have_selector '.invalid-count' - expect(page).to_not have_selector '.create-count' - expect(page).to_not have_selector '.update-count' - expect(page).to have_selector '.inv-create-count', text: "2" - expect(page).to have_selector '.inv-update-count', text: "1" - - click_button 'Save' - - expect(page).to_not have_selector '.created-count' - expect(page).to_not have_selector '.updated-count' - expect(page).to have_selector '.inv-created-count', text: '2' - expect(page).to have_selector '.inv-updated-count', text: '1' - - beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first - cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first - - beans_override.count_on_hand.should == 9000 - sprouts_override.count_on_hand.should == 7 - cabbage_override.count_on_hand.should == 9000 - end end end diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 5d8aef2ce5..00f20dfe5f 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -6,25 +6,534 @@ describe ProductImporter do let!(:admin) { create(:admin_user) } let!(:user) { create_enterprise_user } - let!(:enterprise) { create(:enterprise, owner: user, name: "Test Enterprise") } + let!(:user2) { create_enterprise_user } + let!(:enterprise) { create(:enterprise, owner: user, name: "User Enterprise") } + let!(:enterprise2) { create(:distributor_enterprise, owner: user2, name: "Another Enterprise") } + let!(:relationship) { create(:enterprise_relationship, parent: enterprise, child: enterprise2, permissions_list: [:create_variant_overrides]) } + let!(:category) { create(:taxon, name: 'Vegetables') } + let!(:category2) { create(:taxon, name: 'Cake') } + let!(:tax_category) { create(:tax_category) } + let!(:tax_category2) { create(:tax_category) } + let!(:shipping_category) { create(:shipping_category) } + + 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', unit_value: '500') } + let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts', unit_value: '500') } + let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '500') } + let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce', unit_value: '500') } + let!(:variant_override) { create(:variant_override, variant_id: product4.variants.first.id, hub: enterprise2, count_on_hand: 42) } + let!(:variant_override2) { create(:variant_override, variant_id: product5.variants.first.id, hub: enterprise, count_on_hand: 96) } + let(:permissions) { OpenFoodNetwork::Permissions.new(user) } describe "importing products from a spreadsheet" do - after { File.delete('/tmp/test-m.csv') } - - it "validates the entries" do + before do csv_data = CSV.generate do |csv| csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] - csv << ["Carrots", "Test Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] - csv << ["Potatoes", "Test Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + 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-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "returns the number of entries" do + expect(@importer.item_count).to eq(2) + end + + it "validates entries and returns the results as json" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + expect(filter('update_product', entries)).to eq 0 + end + + it "saves the results and returns info on updated products" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + carrots = Spree::Product.find_by_name('Carrots') + carrots.supplier.should == enterprise + carrots.on_hand.should == 5 + carrots.price.should == 3.20 + carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.supplier.should == enterprise + potatoes.on_hand.should == 6 + potatoes.price.should == 6.50 + potatoes.variants.first.import_date.should be_within(1.minute).of DateTime.now + end + end + + describe "when uploading a spreadsheet with some invalid entries" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Good Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Bad Potatoes", "", "Vegetables", "6", "6.50", "1000", "", "1000"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 1 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 0 + end + + it "allows saving of the valid entries" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + carrots = Spree::Product.find_by_name('Good Carrots') + carrots.supplier.should == enterprise + carrots.on_hand.should == 5 + carrots.price.should == 3.20 + carrots.variants.first.import_date.should be_within(1.minute).of DateTime.now + + Spree::Product.find_by_name('Bad Potatoes').should == nil + end + end + + describe "adding new variants to existing products and updating exiting products" do + before 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", "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-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 1 + end + + it "saves and updates" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.products_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + added_coffee = Spree::Variant.find_by_display_name('Emergent Coffee') + added_coffee.product.name.should == 'Hypothetical Cake' + added_coffee.price.should == 3.50 + added_coffee.on_hand.should == 6 + added_coffee.import_date.should be_within(1.minute).of DateTime.now + + updated_banana = Spree::Variant.find_by_display_name('Preexisting Banana') + updated_banana.product.name.should == 'Hypothetical Cake' + updated_banana.price.should == 5.50 + updated_banana.on_hand.should == 5 + updated_banana.import_date.should be_within(1.minute).of DateTime.now + end + + end + + describe "adding new product and sub-variant at the same time" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "display_name"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "5", "3.50", "500", "weight", "1000", "Small Bag"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "5.50", "2000", "weight", "1000", "Big Bag"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + end + + it "saves and updates" do + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + small_bag = Spree::Variant.find_by_display_name('Small Bag') + small_bag.product.name.should == 'Potatoes' + small_bag.price.should == 3.50 + small_bag.on_hand.should == 5 + + big_bag = Spree::Variant.find_by_display_name('Big Bag') + big_bag.product.name.should == 'Potatoes' + big_bag.price.should == 5.50 + big_bag.on_hand.should == 6 + end + end + + describe "importing items into inventory" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500"] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", "2001", "1.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories'}) + end + after { File.delete('/tmp/test-m.csv') } + + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('update_inventory', entries)).to eq 1 + end + + it "saves and updates inventory" do + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.inventory_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + Float(beans_override.price).should == 3.20 + beans_override.count_on_hand.should == 5 + + Float(sprouts_override.price).should == 6.50 + sprouts_override.count_on_hand.should == 6 + + Float(cabbage_override.price).should == 1.50 + cabbage_override.count_on_hand.should == 2001 + end + end + + describe "handling enterprise permissions" do + after { File.delete('/tmp/test-m.csv') } + + it "only allows product import into enterprises the user is permitted to manage" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["My Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Your Potatoes", "Another Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user, {start: 1, end: 100, import_into: 'product_list'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 1 + expect(filter('create_product', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + Spree::Product.find_by_name('My Carrots').should be_a Spree::Product + Spree::Product.find_by_name('Your Potatoes').should == nil + end + + it "allows creating inventories for producers that a user's hub has permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "777", "3.20", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 1 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 1 + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + beans.count_on_hand.should == 777 + end + + it "does not allow creating inventories for producers that a user's hubs don't have permission for" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Vegetables", "5", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Vegetables", "6", "6.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, user2, {start: 1, end: 100, import_into: 'inventories'}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 0 + expect(filter('invalid', entries)).to eq 2 + expect(filter('create_inventory', entries)).to eq 0 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 0 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 0 + end + end + + describe "applying settings and defaults on import" do + after { File.delete('/tmp/test-m.csv') } + + it "can reset 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 << ["Beans", "User Enterprise", "Vegetables", "6", "6.50", "500", "weight", "1"] end File.write('/tmp/test-m.csv', csv_data) file = File.new('/tmp/test-m.csv') - importer = ProductImporter.new(file, admin) + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', 'settings' => {enterprise.id => {'reset_all_absent' => true}}}) - expect(importer.item_count).to eq(2) + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 1 + expect(filter('update_product', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 1 + expect(@importer.products_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + @importer.reset_absent(@importer.updated_ids) + + expect(@importer.products_reset_count).to eq 2 + + Spree::Product.find_by_name('Carrots').on_hand.should == 5 # Present in file, added + Spree::Product.find_by_name('Beans').on_hand.should == 6 # Present in file, updated + Spree::Product.find_by_name('Sprouts').on_hand.should == 0 # In enterprise, not in file + Spree::Product.find_by_name('Cabbage').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 "can reset all inventory items for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "producer", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", "6", "3.20", "500"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "7", "6.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', 'settings' => {enterprise2.id => {'reset_all_absent' => true}}}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + @importer.reset_absent(@importer.updated_ids) + + expect(@importer.products_reset_count).to eq 1 + + beans = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + lettuce = VariantOverride.where(variant_id: product5.variants.first.id, hub_id: enterprise.id).first + + beans.count_on_hand.should == 6 # Present in file, created + sprouts.count_on_hand.should == 7 # Present in file, created + cabbage.count_on_hand.should == 0 # In enterprise, not in file (reset) + lettuce.count_on_hand.should == 96 # In different enterprise; unchanged + end + + it "can overwrite fields with selected defaults when importing to product list" 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"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1", tax_category.id, ""] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000", "", ""] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + + import_settings = {enterprise.id.to_s => { + 'defaults' => { + 'on_hand' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '9000' + }, + 'tax_category_id' => { + 'active' => true, + 'mode' => 'overwrite_empty', + 'value' => tax_category2.id + }, + 'shipping_category_id' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => shipping_category.id + }, + 'available_on' => { + 'active' => true, + 'mode' => 'overwrite_all', + 'value' => '2020-01-01' + } + } + }} + + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'product_list', settings: import_settings}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 2 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_product', entries)).to eq 2 + + @importer.save_entries + + expect(@importer.products_created_count).to eq 2 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 2 + + carrots = Spree::Product.find_by_name('Carrots') + carrots.on_hand.should == 9000 + carrots.tax_category_id.should == tax_category.id + carrots.shipping_category_id.should == shipping_category.id + carrots.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.on_hand.should == 9000 + potatoes.tax_category_id.should == tax_category2.id + potatoes.shipping_category_id.should == shipping_category.id + potatoes.available_on.should be_within(1.day).of(Time.zone.local(2020, 1, 1)) + end + + it "can overwrite fields with selected defaults when importing to inventory" do + csv_data = CSV.generate do |csv| + csv << ["name", "producer", "supplier", "category", "on_hand", "price", "unit_value"] + csv << ["Beans", "User Enterprise", "Another Enterprise", "Vegetables", "", "3.20", "500"] + csv << ["Sprouts", "User Enterprise", "Another Enterprise", "Vegetables", "7", "6.50", "500"] + csv << ["Cabbage", "User Enterprise", "Another Enterprise", "Vegetables", "", "1.50", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + + import_settings = {enterprise2.id.to_s => { + 'defaults' => { + 'count_on_hand' => { + 'active' => true, + 'mode' => 'overwrite_empty', + 'value' => '9000' + } + } + }} + + @importer = ProductImporter.new(file, admin, {start: 1, end: 100, import_into: 'inventories', settings: import_settings}) + + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) + + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('update_inventory', entries)).to eq 1 + + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.inventory_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + beans_override.count_on_hand.should == 9000 + sprouts_override.count_on_hand.should == 7 + cabbage_override.count_on_hand.should == 9000 + end + end + end + +private + +def filter(type, entries) + valid_count = 0 + entries.each do |line_number, entry| + validates_as = entry['validates_as'] + + valid_count += 1 if type == 'valid' and (validates_as != '') + valid_count += 1 if type == 'invalid' and (validates_as == '') + valid_count += 1 if type == 'create_product' and (validates_as == 'new_product' or validates_as == 'new_variant') + valid_count += 1 if type == 'update_product' and validates_as == 'existing_variant' + valid_count += 1 if type == 'create_inventory' and validates_as == 'new_inventory_item' + valid_count += 1 if type == 'update_inventory' and validates_as == 'existing_inventory_item' + end + valid_count +end \ No newline at end of file