diff --git a/Gemfile b/Gemfile index 45b260c4c8..2b8f6bdddc 100644 --- a/Gemfile +++ b/Gemfile @@ -63,6 +63,7 @@ gem 'wkhtmltopdf-binary' gem 'foreigner' gem 'immigrant' +gem 'roo', '~> 2.7.0' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e03e350866..20366a9107 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -558,6 +558,9 @@ GEM roadie-rails (1.0.3) rails (>= 3.0, < 4.2) roadie (~> 3.0) + roo (2.7.1) + nokogiri (~> 1) + rubyzip (~> 1.1, < 2.0.0) rspec (2.14.1) rspec-core (~> 2.14.0) rspec-expectations (~> 2.14.0) @@ -576,6 +579,7 @@ GEM rspec-retry (0.4.2) rspec-core ruby-progressbar (1.7.1) + rubyzip (1.2.0) safe_yaml (0.9.5) sass (3.3.14) sass-rails (3.2.6) @@ -717,6 +721,7 @@ DEPENDENCIES redcarpet representative_view roadie-rails (~> 1.0.3) + roo (~> 2.7.0) rspec-rails rspec-retry sass (~> 3.3) diff --git a/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee new file mode 100644 index 0000000000..1403611168 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/dropdown_panels.js.coffee @@ -0,0 +1,5 @@ +angular.module("ofn.admin").controller "DropdownPanelsCtrl", ($scope) -> + $scope.active = false + + $scope.togglePanel = -> + $scope.active = !$scope.active diff --git a/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee new file mode 100644 index 0000000000..8cc4de5202 --- /dev/null +++ b/app/assets/javascripts/admin/product_import/controllers/import_options_form.js.coffee @@ -0,0 +1,15 @@ +angular.module("ofn.admin").controller "ImportOptionsFormCtrl", ($scope, $rootScope, ProductImportService) -> + + $scope.toggleResetAbsent = () -> + confirmed = confirm 'This will set stock level to zero on all products for this \n' + + 'enterprise that are not present in the uploaded file.' if $scope.resetAbsent + + if confirmed or !$scope.resetAbsent + ProductImportService.updateResetAbsent($scope.supplierId, $scope.resetCount, $scope.resetAbsent) + else + $scope.resetAbsent = false + + $scope.resetTotal = ProductImportService.resetTotal + + $rootScope.$watch 'resetTotal', (newValue) -> + $scope.resetTotal = newValue if newValue || newValue == 0 diff --git a/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee new file mode 100644 index 0000000000..330e7b6cad --- /dev/null +++ b/app/assets/javascripts/admin/product_import/services/product_import_service.js.coffee @@ -0,0 +1,15 @@ +angular.module("ofn.admin").factory "ProductImportService", ($rootScope) -> + new class ProductImportService + suppliers: {} + resetTotal: 0 + + updateResetAbsent: (supplierId, resetCount, resetAbsent) -> + if resetAbsent + @suppliers[supplierId] = resetCount + @resetTotal += resetCount + else + @suppliers[supplierId] = null + @resetTotal -= resetCount + + $rootScope.resetTotal = @resetTotal + diff --git a/app/assets/stylesheets/admin/product_import.css.scss b/app/assets/stylesheets/admin/product_import.css.scss new file mode 100644 index 0000000000..907e8b1a5c --- /dev/null +++ b/app/assets/stylesheets/admin/product_import.css.scss @@ -0,0 +1,225 @@ +div.panel-section { + + .neutral { + color: #bfbfbf; + } + .warning { + color: #da5354; + } + .success { + color: #86d83a; + } + .info { + color: #68b7c0; + } + + div.panel-header { + width: 100%; + //font-size: 1.5em; + clear: both; + //border: 1px solid #ccc; + float: left; + padding: 0.5em; + + div { + font-size: 1.25em; + float: left; + } + + div.header-caret { + width: 2em; + text-align: center; + min-height: 0.1em; //Empty div fix + } + + div.header-icon { + width: 2.5em; + text-align: center; + padding-top: 0.18em; + + i { + font-size: 1.5em; + line-height: 0.9em; + } + } + + div.header-count { + min-width: 2em; + text-align: right; + padding-right: 0.5em; + } + + div.header-description { + width: auto; + } + + } + + div.panel-header:hover { + cursor: pointer; + background-color: #f7f7f7; + } + + div.panel-header.active { + background-color: #efefef; + text-shadow: 1px 1px 0px rgba(255,255,255,0.75); + } + + div.panel-content { + width: 100%; + clear: both; + //border: 1px solid #ccc; + margin-bottom: 0.5em; + background-color: #f9f9f9; + padding: 1.5em; + + div.table-wrap { + width: 100%; + overflow: auto; + border-right: 1px solid #ceede3; + max-height: 23em; + } + + table { + background-color: white; + margin-bottom: 0; + td, th { + white-space: nowrap; + } + tr.error { + //background-color: #ffe6e4; + //color: #ee4728; + color: #c84C4c; + } + tr:hover td.invalid { + background-color: #ed5135; + } + tr i { + display: block; + margin-bottom: -0.2em; + font-size: 1.4em !important; + } + td.invalid { + background-color: #f05c51; + box-shadow: inset 0px 0px 1px red; + color: white; + } + } + + div.import-errors { + margin-bottom: 0.85em; + + p.line { + font-size: 1.15em; + margin-bottom: 0.2em; + color: #577084; + } + p.error { + color: #cb1b1b; + margin-bottom: 0.2em; + } + } + + } + +} + +br.panels.clearfix { + clear: both; +} + +table.import-settings { + background-color: transparent !important; + width: auto; + + //select { + // width: 100%; + //} + tr { + + } + tbody tr:hover td { + background-color: #f3f3f3; + } + td { + border: 0; + border-bottom: 1px solid #eee; + text-align: left; + + input { + width: 15em; + } + input[type="checkbox"] { + width: auto; + } + + } + td.description { + font-weight: bold; + padding-right: 2.5em; + } + tr:first-child td { + //border-top: 1px solid #eee; + border-top: 0; + } + tr:last-child td { + //border-top: 1px solid #eee; + border-bottom: 0; + } + +} + +.panel-section.import-settings { + + .header-description { + padding-left: 1em; + } + + span.header-error { + font-size: 0.85em; + color: #da5354; + } + + .select2-search { + display: none; + } + + .select2-results { + margin: 0; + } +} + + + +.post-save-results { + p { + font-size: 1.25em; + margin-bottom: 0.5em; + + strong { + margin-right: 0.2em; + min-width: 1.8em; + display: inline-block; + text-align: right; + } + + i { + font-size: 1.4em; + vertical-align: middle; + position: relative; + } + + i.fa-check-circle { + color: #86d83a; + } + i.fa-info-circle { + color: #68b7c0; + } + } + + p.save-error { + color: #ee4728; + font-size: 1.05em; + margin-top: 0.4em; + } +} \ No newline at end of file diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb new file mode 100644 index 0000000000..819fd3823b --- /dev/null +++ b/app/controllers/admin/product_import_controller.rb @@ -0,0 +1,62 @@ +require 'roo' + +class Admin::ProductImportController < Spree::Admin::BaseController + + before_filter :validate_upload_presence, except: :index + + def import + # Save uploaded file to tmp directory + @filepath = save_uploaded_file(params[:file]) + @importer = ProductImporter.new(File.new(@filepath), editable_enterprises) + + check_file_errors @importer + check_spreadsheet_has_data @importer + + @tax_categories = Spree::TaxCategory.order('is_default DESC, name ASC') + @shipping_categories = Spree::ShippingCategory.order('name ASC') + end + + def save + @importer = ProductImporter.new(File.new(params[:filepath]), editable_enterprises, params[:settings]) + @importer.save_all if @importer.has_valid_entries? + end + + private + + def validate_upload_presence + unless params[:file] || (params[:filepath] && File.exist?(params[:filepath])) + redirect_to '/admin/product_import', notice: 'File not found or could not be opened' + return + end + end + + def check_file_errors(importer) + if importer.errors.present? + redirect_to '/admin/product_import', notice: @importer.errors.full_messages.to_sentence + return + end + end + + def check_spreadsheet_has_data(importer) + unless importer.item_count + redirect_to '/admin/product_import', notice: 'No data found in spreadsheet' + return + end + end + + def save_uploaded_file(upload) + filename = 'import' + Time.now.strftime('%d-%m-%Y-%H-%M-%S') + extension = '.' + upload.original_filename.split('.').last + directory = 'tmp/product_import' + Dir.mkdir(directory) unless File.exists?(directory) + File.open(Rails.root.join(directory, filename+extension), 'wb') do |f| + f.write(upload.read) + f.path + end + end + + # Define custom model class for Cancan permissions + def model_class + ProductImporter + end +end \ No newline at end of file diff --git a/app/models/product_importer.rb b/app/models/product_importer.rb new file mode 100644 index 0000000000..58c0def237 --- /dev/null +++ b/app/models/product_importer.rb @@ -0,0 +1,464 @@ +require 'roo' + +class ProductImporter + extend ActiveModel::Naming + include ActiveModel::Conversion + include ActiveModel::Validations + + attr_reader :total_supplier_products + + def initialize(file, editable_enterprises, import_settings={}) + if file.is_a?(File) + @file = file + @sheet = open_spreadsheet + @entries = [] + @valid_entries = {} + @invalid_entries = {} + + @products_to_create = {} + @variants_to_create = {} + @variants_to_update = {} + + @products_created = 0 + @variants_created = 0 + @variants_updated = 0 + + @import_settings = import_settings + @editable_enterprises = {} + editable_enterprises.map { |e| @editable_enterprises[e.name] = e.id } + + @total_supplier_products = 0 + @products_to_reset = {} + @updated_ids = [] + + init_product_importer if @sheet + else + self.errors.add(:importer, 'error: no file uploaded') + end + end + + def persisted? + false #ActiveModel, not ActiveRecord + end + + def has_valid_entries? + valid_count and valid_count > 0 + end + + def item_count + @sheet ? @sheet.last_row - 1 : 0 + end + + def products_to_reset + # Return indexed data about existing product count, reset count, and updates count per supplier + @products_to_reset.each do |supplier_id, values| + values[:updates_count] = 0 if values[:updates_count].blank? + + if values[:updates_count] and values[:existing_products] + @products_to_reset[supplier_id][:reset_count] = values[:existing_products] - values[:updates_count] + end + end + @products_to_reset + end + + def valid_count + @valid_entries.count + end + + def invalid_count + @invalid_entries.count + end + + def products_create_count + @products_to_create.count + @variants_to_create.count + end + + def products_update_count + @variants_to_update.count + end + + def suppliers_index + index = @suppliers_index || build_suppliers_index + index.sort_by{ |k,v| v.to_i }.reverse.to_h + end + + def all_entries + invalid_entries.merge(products_to_create).merge(products_to_update).sort.to_h + end + + def invalid_entries + @invalid_entries + end + + def products_to_create + @products_to_create.merge(@variants_to_create) + end + + def products_to_update + @variants_to_update + end + + def products_created_count + @products_created + @variants_created + end + + def products_updated_count + @variants_updated + end + + def products_reset_count + @products_reset_count || 0 + end + + def total_saved_count + @products_created + @variants_created + @variants_updated + end + + def save_all + save_all_valid + delete_uploaded_file + end + + def permission_by_name?(supplier_name) + @editable_enterprises.has_key?(supplier_name) + end + + def permission_by_id?(supplier_id) + @editable_enterprises.has_value?(Integer(supplier_id)) + end + + private + + def init_product_importer + build_entries + build_categories_index + build_suppliers_index + validate_all + end + + def open_spreadsheet + if accepted_mimetype + Roo::Spreadsheet.open(@file, extension: accepted_mimetype) + else + self.errors.add(:importer, 'could not process file: invalid filetype') + delete_uploaded_file + nil + end + end + + def accepted_mimetype + File.extname(@file.path).in?('.csv', '.xls', '.xlsx', '.ods') ? @file.path.split('.').last.to_sym : false + end + + def headers + @sheet.row(1) + end + + def rows + return [] unless @sheet and @sheet.last_row + (2..@sheet.last_row).map do |i| + @sheet.row(i) + end + end + + def build_entries + rows.each_with_index do |row, i| + row_data = Hash[[headers, row].transpose] + entry = SpreadsheetEntry.new(row_data) + entry.line_number = i+2 + @entries.push entry + end + @entries + end + + def validate_all + @entries.each do |entry| + supplier_validation(entry) + category_validation(entry) + set_update_status(entry) + + mark_as_valid(entry) unless entry_invalid?(entry.line_number) + end + + count_existing_products + delete_uploaded_file if item_count.zero? or valid_count.zero? + end + + def count_existing_products + @suppliers_index.each do |supplier_name, supplier_id| + if supplier_id and permission_by_id?(supplier_id) + products_count = Spree::Variant.joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.is_master = false + AND spree_variants.deleted_at IS NULL', supplier_id). + count + + if @products_to_reset[supplier_id] + @products_to_reset[supplier_id][:existing_products] = products_count + else + @products_to_reset[supplier_id] = {existing_products: products_count} + end + + @total_supplier_products += products_count + end + end + end + + def entry_invalid?(line_number) + !!@invalid_entries[line_number] + end + + def supplier_validation(entry) + supplier_name = entry.supplier + + if supplier_name.blank? + mark_as_invalid(entry, attribute: "supplier", error: "can't be blank") + return + end + + unless supplier_exists?(supplier_name) + mark_as_invalid(entry, attribute: "supplier", error: "\"#{supplier_name}\" not found in database") + return + end + + unless permission_by_name?(supplier_name) + mark_as_invalid(entry, attribute: "supplier", error: "\"#{supplier_name}\": you do not have permission to manage products for this enterprise") + return + end + + entry.supplier_id = @suppliers_index[supplier_name] + end + + def supplier_exists?(supplier_name) + @suppliers_index[supplier_name] + end + + def category_validation(entry) + category_name = entry.category + + if category_name.blank? + mark_as_invalid(entry, attribute: "category", error: "can't be blank") + return + end + + if category_exists?(category_name) + entry.primary_taxon_id = @categories_index[category_name] + else + mark_as_invalid(entry, attribute: "category", error: "\"#{category_name}\" not found in database") + end + end + + def category_exists?(category_name) + @categories_index[category_name] + end + + def mark_as_valid(entry) + @valid_entries[entry.line_number] = entry + end + + def mark_as_invalid(entry, options={}) + entry.errors.add(options[:attribute], options[:error]) if options[:attribute] and options[:error] + entry.product_validations = options[:product_validations] if options[:product_validations] + + @invalid_entries[entry.line_number] = entry + end + + # Minimise db queries by getting a list of suppliers to look + # up, instead of doing a query for each entry in the spreadsheet + def build_suppliers_index + @suppliers_index = {} + @entries.each do |entry| + supplier_name = entry.supplier + supplier_id = @suppliers_index[supplier_name] || + Enterprise.find_by_name(supplier_name, :select => 'id, name').try(:id) + @suppliers_index[supplier_name] = supplier_id + end + @suppliers_index + end + + def build_categories_index + @categories_index = {} + @entries.each do |entry| + category_name = entry.category + category_id = @categories_index[category_name] || + Spree::Taxon.find_by_name(category_name, :select => 'id, name').try(:id) + @categories_index[category_name] = category_id + end + @categories_index + end + + def save_all_valid + already_created = {} + @products_to_create.each do |line_number, entry| + # If we've already added a new product with these attributes + # from this spreadsheet, mark this entry as a new variant with + # the new product id, as this is a now variant of that product... + if already_created[entry.supplier_id] and already_created[entry.supplier_id][entry.name] + product_id = already_created[entry.supplier_id][entry.name] + mark_as_new_variant(entry, product_id) + next + end + + product = Spree::Product.new() + product.assign_attributes(entry.attributes.except('id')) + assign_defaults(product, entry.attributes) + if product.save + ensure_variant_updated(product, entry) + @products_created += 1 + @updated_ids.push product.variants.first.id + else + self.errors.add("Line #{line_number}:", product.errors.full_messages) #TODO: change + end + + already_created[entry.supplier_id] = {entry.name => product.id} + end + + @variants_to_update.each do |line_number, entry| + variant = entry.product_object + assign_defaults(variant, entry.attributes) + if variant.valid? and variant.save + @variants_updated += 1 + @updated_ids.push variant.id + else + self.errors.add("Line #{line_number}:", variant.errors.full_messages) #TODO: change + end + end + + @variants_to_create.each do |line_number, entry| + new_variant = entry.product_object + assign_defaults(new_variant, entry.attributes) + if new_variant.valid? and new_variant.save + @variants_created += 1 + @updated_ids.push new_variant.id + else + self.errors.add("Line #{line_number}:", new_variant.errors.full_messages) + end + end + + self.errors.add(:importer, "did not save any products successfully") if total_saved_count.zero? + + reset_absent_products + total_saved_count + end + + def reset_absent_products + return if total_saved_count.zero? + + enterprises_to_reset = [] + @import_settings.each do |enterprise_id, settings| + enterprises_to_reset.push enterprise_id if settings['reset_all_absent'] and permission_by_id?(enterprise_id) + end + + unless enterprises_to_reset.empty? or @updated_ids.empty? + # For selected enterprises; set stock to zero for all products + # that were not present in the uploaded spreadsheet + @products_reset_count = Spree::Variant.joins(:product). + where('spree_products.supplier_id IN (?) + AND spree_variants.id NOT IN (?) + AND spree_variants.is_master = false + AND spree_variants.deleted_at IS NULL', enterprises_to_reset, @updated_ids). + update_all(count_on_hand: 0) + end + end + + def assign_defaults(object, entry) + @import_settings[entry['supplier_id'].to_s]['defaults'].each do |attribute, setting| + case setting['mode'] + when 'overwrite_all' + object.assign_attributes(attribute => setting['value']) + when 'overwrite_empty' + if object.send(attribute).blank? or (attribute == 'on_hand' and entry['on_hand_nil']) + object.assign_attributes(attribute => setting['value']) + end + end + end + end + + def ensure_variant_updated(product, entry) + # Ensure display_name and on_demand are copied to new product's variant + if entry.display_name || entry.on_demand + variant = product.variants.first + variant.display_name = entry.display_name if entry.display_name + variant.on_demand = entry.on_demand if entry.on_demand + variant.save + end + end + + def set_update_status(entry) + # Find product with matching supplier and name + match = Spree::Product.where(supplier_id: entry.supplier_id, name: entry.name, deleted_at: nil).first + + # If no matching product was found, create a new product + if match.nil? + mark_as_new_product(entry) + return + end + + # Otherwise, if a variant exists with matching display_name and unit_value, update it + match.variants.each do |existing_variant| + if existing_variant.display_name == entry.display_name && existing_variant.unit_value == Float(entry.unit_value) + mark_as_existing_variant(entry, existing_variant) + return + end + end + + # Otherwise, a variant with sufficiently matching attributes doesn't exist; create a new one + mark_as_new_variant(entry, match.id) + end + + def mark_as_new_product(entry) + new_product = Spree::Product.new() + new_product.assign_attributes(entry.attributes.except('id')) + if new_product.valid? + @products_to_create[entry.line_number] = entry unless entry_invalid?(entry.line_number) + else + mark_as_invalid(entry, product_validations: new_product.errors) + end + end + + def mark_as_existing_variant(entry, existing_variant) + existing_variant.assign_attributes(entry.attributes.except('id', 'product_id')) + check_on_hand_nil(entry, existing_variant) + if existing_variant.valid? + entry.product_object = existing_variant + @variants_to_update[entry.line_number] = entry unless entry_invalid?(entry.line_number) + updates_count_per_supplier(entry.supplier_id) unless entry_invalid?(entry.line_number) + else + mark_as_invalid(entry, product_validations: existing_variant.errors) + end + end + + def mark_as_new_variant(entry, product_id) + new_variant = Spree::Variant.new(entry.attributes.except('id', 'product_id')) + new_variant.product_id = product_id + check_on_hand_nil(entry, new_variant) + if new_variant.valid? + entry.product_object = new_variant + @variants_to_create[entry.line_number] = entry unless entry_invalid?(entry.line_number) + else + mark_as_invalid(entry, product_validations: new_variant.errors) + end + end + + def updates_count_per_supplier(supplier_id) + if @products_to_reset[supplier_id] and @products_to_reset[supplier_id][:updates_count] + @products_to_reset[supplier_id][:updates_count] += 1 + else + @products_to_reset[supplier_id] = {updates_count: 1} + end + end + + def check_on_hand_nil(entry, variant) + if entry.on_hand.blank? + variant.on_hand = 0 + entry.on_hand_nil = true + end + end + + def delete_uploaded_file + # Only delete if file is in '/tmp/product_import' directory + if @file.path == Rails.root.join('tmp', 'product_import').to_s + File.delete(@file) + end + end +end diff --git a/app/models/spreadsheet_entry.rb b/app/models/spreadsheet_entry.rb new file mode 100644 index 0000000000..1db6d43879 --- /dev/null +++ b/app/models/spreadsheet_entry.rb @@ -0,0 +1,68 @@ +# Class for defining spreadsheet entry objects for use in ProductImporter +class SpreadsheetEntry + extend ActiveModel::Naming + include ActiveModel::Conversion + include ActiveModel::Validations + + attr_accessor :line_number, :valid, :product_object, :product_validations, :save_type, :on_hand_nil + + attr_accessor :id, :product_id, :supplier, :supplier_id, :name, :display_name, :sku, + :unit_value, :unit_description, :variant_unit, :variant_unit_scale, :variant_unit_name, + :display_as, :category, :primary_taxon_id, :price, :on_hand, :on_demand, + :tax_category_id, :shipping_category_id, :description + + def initialize(attrs) + @product_validations = {} + + attrs.each do |k, v| + if self.respond_to?("#{k}=") + send("#{k}=", v) unless non_product_attributes.include?(k) + else + # Trying to assign unknown attribute. Record this and give feedback or just ignore silently? + end + end + end + + def persisted? + false #ActiveModel + end + + def has_errors? + self.errors.count > 0 or @product_validations.count > 0 + end + + def attributes + attrs = {} + self.instance_variables.each do |var| + attrs[var.to_s.delete("@")] = self.instance_variable_get(var) + end + attrs.except(*non_product_attributes) + end + + def displayable_attributes + # Modified attributes list for displaying in user feedback + attrs = {} + self.instance_variables.each do |var| + attrs[var.to_s.delete("@")] = self.instance_variable_get(var) + end + attrs.except(*non_product_attributes, *non_display_attributes) + end + + def invalid_attributes + invalid_attrs = {} + @product_validations.messages.merge(self.errors.messages).each do |attr, message| + invalid_attrs[attr.to_s] = "#{attr.to_s.capitalize} #{message.first}" + end + invalid_attrs.except(*non_product_attributes, *non_display_attributes) + end + + private + + def non_display_attributes + ['id', 'product_id', 'variant_id', 'supplier_id', 'primary_taxon', 'primary_taxon_id', 'category_id', 'shipping_category_id', 'tax_category_id'] + end + + def non_product_attributes + ['line_number', 'valid', 'errors', 'product_object', 'product_validations', 'save_type', 'on_hand_nil'] + end +end diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 13efc7e109..e49db570cb 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -155,6 +155,8 @@ class AbilityDecorator can [:admin, :index, :read, :search], Spree::Taxon can [:admin, :index, :read, :create, :edit], Spree::Classification + can [:admin, :index, :import, :save], 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 end diff --git a/app/models/spree/payment_decorator.rb b/app/models/spree/payment_decorator.rb index dbee3cfd0f..98b996cce7 100644 --- a/app/models/spree/payment_decorator.rb +++ b/app/models/spree/payment_decorator.rb @@ -16,7 +16,7 @@ module Spree adjustment.save else payment_method.create_adjustment(adjustment_label, order, self, true) - reload + association(:adjustment).reload end end diff --git a/app/models/spree/tax_rate_decorator.rb b/app/models/spree/tax_rate_decorator.rb index 121d379584..7126e8ac29 100644 --- a/app/models/spree/tax_rate_decorator.rb +++ b/app/models/spree/tax_rate_decorator.rb @@ -12,7 +12,8 @@ module Spree def adjust_with_included_tax(order) adjust_without_included_tax(order) - order.reload + order.adjustments(:reload) + order.line_items(:reload) (order.adjustments.tax + order.price_adjustments).each do |a| a.set_absolute_included_tax! a.amount end diff --git a/app/overrides/spree/admin/shared/_product_sub_menu/add_product_import_tab.html.haml.deface b/app/overrides/spree/admin/shared/_product_sub_menu/add_product_import_tab.html.haml.deface new file mode 100644 index 0000000000..f3029e55cf --- /dev/null +++ b/app/overrides/spree/admin/shared/_product_sub_menu/add_product_import_tab.html.haml.deface @@ -0,0 +1,4 @@ +/ insert_bottom "[data-hook='admin_product_sub_tabs']" + +-# Commenting out for now, until product import is finished +-# = tab :product_import, label: "Import", url: main_app.admin_product_import_path, match_path: '/product_import' diff --git a/app/views/admin/product_import/_entries_table.html.haml b/app/views/admin/product_import/_entries_table.html.haml new file mode 100644 index 0000000000..cd293eea05 --- /dev/null +++ b/app/views/admin/product_import/_entries_table.html.haml @@ -0,0 +1,15 @@ +- if entries && entries.count > 0 + %div.table-wrap + %table + %thead + %th + %th Line + - entries.values.first.displayable_attributes.each do |key, value| + %th= key + - entries.each do |line_number, entry| + %tr{class: ('error' if entry.has_errors?)} + %td + %i{class: (entry.has_errors? ? 'fa fa-warning warning' : 'fa fa-check-circle success')} + %td= line_number + - entry.displayable_attributes.each do |key, value| + %td{class: ('invalid' if entry.has_errors? and entry.invalid_attributes[key])}= value diff --git a/app/views/admin/product_import/_errors_list.html.haml b/app/views/admin/product_import/_errors_list.html.haml new file mode 100644 index 0000000000..e7203a7ddd --- /dev/null +++ b/app/views/admin/product_import/_errors_list.html.haml @@ -0,0 +1,11 @@ +- @importer.invalid_entries.each do |line_number, entry| + %div.import-errors + %p.line + %strong + Item line #{line_number}: + %span= entry.name + - if entry.display_name + ( #{entry.display_name} ) + - entry.invalid_attributes.each do |attr, error| + %p.error +  -  #{error} \ No newline at end of file diff --git a/app/views/admin/product_import/_import_options.html.haml b/app/views/admin/product_import/_import_options.html.haml new file mode 100644 index 0000000000..f5733c4f80 --- /dev/null +++ b/app/views/admin/product_import/_import_options.html.haml @@ -0,0 +1,49 @@ +%h5 Import options and defaults +%br + +- @importer.suppliers_index.each do |name, supplier_id| + - if name and supplier_id and @importer.permission_by_id?(supplier_id) + %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} + %div.header-icon.neutral + %i.fa.fa-edit + -#%div.header-count + -# %strong.invalid-count= @importer.invalid_count + %div.header-description + = name + %div.panel-content{ng: {hide: '!active'}} + = render 'options_form', supplier_id: supplier_id, name: name + - elsif name and supplier_id + %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header + %div.header-caret + -#%i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} + %div.header-icon.error + %i.fa.fa-warning + -#%div.header-count + -# %strong.invalid-count= @importer.invalid_count + %div.header-description + = name + %span.header-error= " - you do not have permission to manage this enterprise" + -#%div.panel-content{ng: {hide: '!active'}} + -# = render 'options_form', supplier_id: supplier_id, name: name + - elsif name + %div.panel-section.import-settings{ng: {controller: 'DropdownPanelsCtrl'}} + %div.panel-header + %div.header-caret + -#%i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}"}} + %div.header-icon.error + %i.fa.fa-warning + -#%div.header-count + -# %strong.invalid-count= @importer.invalid_count + %div.header-description + = name + %span.header-error= " - enterprise could not be found in database" + -#%div.panel-content{ng: {hide: '!active'}} + -# = render 'options_form', supplier_id: supplier_id, name: name + + +%br.panels.clearfix +%br diff --git a/app/views/admin/product_import/_import_review.html.haml b/app/views/admin/product_import/_import_review.html.haml new file mode 100644 index 0000000000..20cb48b769 --- /dev/null +++ b/app/views/admin/product_import/_import_review.html.haml @@ -0,0 +1,83 @@ +%h5 Import validation overview +%br + +-#%div.panel-section +-# %div.panel-header +-# %div.header-caret +-# %div.header-icon.info +-# %i.fa.fa-info-circle +-# %div.header-count +-# %strong.existing-count= @importer.total_supplier_products +-# %div.header-description +-# Existing products in referenced enterprise(s) +-# -#%div.panel-content{ng: {hide: '!active'}} +-# -# Content goes here + +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.item_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} + %div.header-icon.success + %i.fa.fa-info-circle.info + %div.header-count + %strong.item-count= @importer.item_count + %div.header-description + Entries found in imported file + %div.panel-content{ng: {hide: '!active || count == 0'}} + = render 'entries_table', entries: @importer.all_entries + +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.invalid_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} + %div.header-icon.warning + %i.fa.fa-warning + %div.header-count + %strong.invalid-count= @importer.invalid_count + %div.header-description + Items contain errors and will not be imported + %div.panel-content{ng: {hide: '!active || count == 0'}} + = render 'errors_list' + %br + = render 'entries_table', entries: @importer.invalid_entries + +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_create_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.create-count= @importer.products_create_count + %div.header-description + Products will be created + %div.panel-content{ng: {hide: '!active || count == 0'}} + = render 'entries_table', entries: @importer.products_to_create + +%div.panel-section{ng: {controller: 'DropdownPanelsCtrl', init: "count = #{@importer.products_update_count}"}} + %div.panel-header{ng: {click: 'togglePanel()', class: '{active: active && count}'}} + %div.header-caret + %i{ng: {class: "{'icon-chevron-down': active, 'icon-chevron-right': !active}", hide: 'count == 0'}} + %div.header-icon.success + %i.fa.fa-check-circle + %div.header-count + %strong.update-count= @importer.products_update_count + %div.header-description + Products will be updated + %div.panel-content{ng: {hide: '!active || count == 0'}} + = render 'entries_table', entries: @importer.products_to_update + +%div.panel-section{ng: {controller: 'ImportOptionsFormCtrl', hide: 'resetTotal == 0'}} + %div.panel-header + %div.header-caret + %div.header-icon.info + %i.fa.fa-info-circle + %div.header-count + %strong.reset-count + {{resetTotal}} + %div.header-description + Existing products will have their stock reset to zero + -#%div.panel-content{ng: {hide: '!active'}} + -# Content goes here + +%br.panels.clearfix \ No newline at end of file diff --git a/app/views/admin/product_import/_options_form.html.haml b/app/views/admin/product_import/_options_form.html.haml new file mode 100644 index 0000000000..da0e2df055 --- /dev/null +++ b/app/views/admin/product_import/_options_form.html.haml @@ -0,0 +1,35 @@ +%table.import-settings{ng: {controller: 'ImportOptionsFormCtrl', init: "supplierId = #{supplier_id}; resetCount = #{@importer.products_to_reset[supplier_id][:reset_count]}"}} + %tr + %td.description + Remove absent products? + %td + = check_box_tag "settings[#{supplier_id}][reset_all_absent]", 1, false, :'ng-model' => 'resetAbsent', :'ng-change' => 'toggleResetAbsent()' + %td + %tr + %td.description + Set default stock level + %td + = select_tag "settings[#{supplier_id}][defaults][on_hand][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = number_field_tag "settings[#{supplier_id}][defaults][on_hand][value]", 0 + %tr + %td.description + Set default tax category + %td + = select_tag "settings[#{supplier_id}][defaults][tax_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %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'} + %tr + %td.description + Set default shipping category + %td + = select_tag "settings[#{supplier_id}][defaults][shipping_category_id][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %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'} + %tr + %td.description + Set default available date + %td + = select_tag "settings[#{supplier_id}][defaults][available_on][mode]", options_for_select({"Don't overwrite" => :overwrite_none, "Overwrite all" => :overwrite_all, "Overwrite if empty" => :overwrite_empty}), {class: 'select2 fullwidth'} + %td + = text_field_tag "settings[#{supplier_id}][defaults][available_on][value]", nil, {class: 'datepicker', placeholder: 'Today'} diff --git a/app/views/admin/product_import/_upload_form.html.haml b/app/views/admin/product_import/_upload_form.html.haml new file mode 100644 index 0000000000..46e05adfdb --- /dev/null +++ b/app/views/admin/product_import/_upload_form.html.haml @@ -0,0 +1,9 @@ +%h5 Select a spreadsheet to upload +%br += form_tag main_app.admin_product_import_path, multipart: true do + = file_field_tag :file + %br + %br + = submit_tag "Import" + %br + %br \ No newline at end of file diff --git a/app/views/admin/product_import/import.html.haml b/app/views/admin/product_import/import.html.haml new file mode 100644 index 0000000000..74422c162f --- /dev/null +++ b/app/views/admin/product_import/import.html.haml @@ -0,0 +1,34 @@ +- content_for :page_title do + Product Import + += render partial: 'spree/admin/shared/product_sub_menu' + += form_tag main_app.admin_product_import_save_path, {'ng-app' => 'ofn.admin'} do + + - if @importer.invalid_count && !@importer.has_valid_entries? + %h5 No valid entries found + %p There are no entries that can be saved + %br + + = render 'import_options' if @importer.has_valid_entries? + + = render 'import_review' + + - if @importer.has_valid_entries? + - if @importer.invalid_count > 0 + %br + %h5 Imported file contains some invalid entries + %p Save valid entries for now and discard the others? + - else + %h5 No errors detected! + %p Save all imported products? + %br + = hidden_field_tag :filepath, @filepath + = submit_tag "Save" + %a.button{href: main_app.admin_product_import_path} Cancel + + - else + %br + %a.button{href: main_app.admin_product_import_path} Cancel + + diff --git a/app/views/admin/product_import/index.html.haml b/app/views/admin/product_import/index.html.haml new file mode 100644 index 0000000000..c904b6a70f --- /dev/null +++ b/app/views/admin/product_import/index.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + Product Import + += render :partial => 'spree/admin/shared/product_sub_menu' + += render 'upload_form' diff --git a/app/views/admin/product_import/save.html.haml b/app/views/admin/product_import/save.html.haml new file mode 100644 index 0000000000..06d11e5f35 --- /dev/null +++ b/app/views/admin/product_import/save.html.haml @@ -0,0 +1,38 @@ +- content_for :page_title do + Product Import + += render :partial => 'spree/admin/shared/product_sub_menu' + +%h5 Import final results +%br + +%div.post-save-results{ng: {app: 'ofn.admin'}} + + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_created_count} == 0, 'fa-check-circle': #{@importer.products_created_count} != 0}"}} + %strong.created-count= @importer.products_created_count + Products created + + %p + %i.fa{ng: {class: "{'fa-info-circle': #{@importer.products_updated_count} == 0, 'fa-check-circle': #{@importer.products_updated_count} != 0}"}} + %strong.updated-count= @importer.products_updated_count + Products updated + + - if @importer.products_reset_count > 0 + %p + %i.fa.fa-check-circle + %strong.reset-count= @importer.products_reset_count + Products had stock level reset to zero + + %br + + - if @importer.errors.count == 0 + %p All #{@importer.total_saved_count} items saved successfully + - else + %h5 Save errors + - @importer.errors.full_messages.each do |error| + %p.save-error +  -  #{error} + + %br + %a.button{href: main_app.admin_product_import_path} Back diff --git a/app/views/checkout/_summary.html.haml b/app/views/checkout/_summary.html.haml index 0fec419556..a6bb5ca651 100644 --- a/app/views/checkout/_summary.html.haml +++ b/app/views/checkout/_summary.html.haml @@ -4,30 +4,30 @@ %legend = t :checkout_your_order %table - %tr + %tr.subtotal %th = t :checkout_cart_total %td.cart-total.text-right= display_checkout_subtotal(@order) - checkout_adjustments_for(current_order, exclude: [:shipping, :payment, :line_item]).reject{ |a| a.amount == 0 }.each do |adjustment| - %tr + %tr.adjustment %th= adjustment.label %td.text-right= adjustment.display_amount.to_html - %tr + %tr.shipping %th = t :checkout_shipping_price - %td.shipping.text-right {{ Checkout.shippingPrice() | localizeCurrency }} + %td.text-right {{ Checkout.shippingPrice() | localizeCurrency }} - %tr + %tr.transaction-fee %th = t :payment_method_fee %td.text-right {{ Checkout.paymentPrice() | localizeCurrency }} - %tr + %tr.total %th = t :checkout_total_price - %td.total.text-right {{ Checkout.cartTotal() | localizeCurrency }} + %td.text-right {{ Checkout.cartTotal() | localizeCurrency }} //= f.submit "Purchase", class: "button", "ofn-focus" => "accordion['payment']" %a.button.secondary{href: cart_url} diff --git a/config/routes.rb b/config/routes.rb index 04ad87568e..90700d89b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,6 +114,10 @@ Openfoodnetwork::Application.routes.draw do get '/inventory', to: 'variant_overrides#index' + 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' + resources :variant_overrides do post :bulk_update, on: :collection post :bulk_reset, on: :collection @@ -237,7 +241,6 @@ Spree::Core::Engine.routes.prepend do namespace :admin do get '/search/known_users' => "search#known_users", :as => :search_known_users - get '/search/customers' => 'search#customers', :as => :search_customers resources :products do diff --git a/spec/features/admin/enterprises_spec.rb b/spec/features/admin/enterprises_spec.rb index c051c9eade..ce414ed99f 100644 --- a/spec/features/admin/enterprises_spec.rb +++ b/spec/features/admin/enterprises_spec.rb @@ -94,6 +94,7 @@ feature %q{ within (".side_menu") { click_link "Users" } select2_search user.email, from: 'Owner' + expect(page).to have_no_selector '.select2-drop-mask' # Ensure select2 has finished click_link "About" fill_in 'enterprise_description', :with => 'Connecting farmers and eaters' diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb new file mode 100644 index 0000000000..5843330a7f --- /dev/null +++ b/spec/features/admin/product_import_spec.rb @@ -0,0 +1,343 @@ +require 'spec_helper' +require 'open_food_network/permissions' + +feature "Product Import", js: true do + include AuthenticationWorkflow + include WebHelper + + let!(:admin) { create(:admin_user) } + let!(:user) { create_enterprise_user } + let!(:enterprise) { create(:supplier_enterprise, owner: user, name: "User Enterprise") } + let!(:enterprise2) { create(:supplier_enterprise, owner: admin, name: "Another Enterprise") } + 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') } + let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage') } + let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce') } + + + describe "when importing products from uploaded file" do + before { quick_login_as_admin } + after { File.delete('/tmp/test.csv') } + + it "validates entries and saves them if they are all valid" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["Potatoes", "User Enterprise", "Vegetables", "6", "6.50", "1000", "weight", "1000"] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + + 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: "0" + expect(page).to have_selector '.create-count', text: "2" + expect(page).to have_selector '.update-count', text: "0" + + click_button 'Save' + expect(page).to have_selector '.created-count', text: '2' + expect(page).to have_selector '.updated-count', text: '0' + + potatoes = Spree::Product.find_by_name('Potatoes') + potatoes.supplier.should == enterprise + potatoes.on_hand.should == 6 + potatoes.price.should == 6.50 + 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 '.update-count', text: "0" + + expect(page).to have_selector 'input[type=submit][value="Save"]' + click_button 'Save' + + expect(page).to have_selector '.created-count', text: '1' + expect(page).to have_selector '.updated-count', text: '0' + + 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"] + csv << ["Bad Carrots", "Unkown Enterprise", "Mouldy vegetables", "666", "3.20", "", "weight", ""] + csv << ["Bad Potatoes", "", "Vegetables", "6", "6", "6", "", "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: "2" + expect(page).to have_selector '.create-count', text: "0" + expect(page).to have_selector '.update-count', text: "0" + + 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 have_selector '.invalid-count', text: "0" + 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 + + 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 + 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 have_selector '.invalid-count', text: "0" + expect(page).to have_selector '.create-count', text: "2" + expect(page).to have_selector '.update-count', text: "0" + + click_button 'Save' + expect(page).to have_selector '.created-count', text: '2' + expect(page).to have_selector '.updated-count', text: '0' + + 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 "when dealing with uploaded files" do + before { quick_login_as_admin } + + it "checks filetype on upload" do + File.write('/tmp/test.txt', "Wrong filetype!") + + visit main_app.admin_product_import_path + attach_file 'file', '/tmp/test.txt' + click_button 'Import' + + expect(page).to have_content "Importer could not process file: invalid filetype" + expect(page).to_not have_selector 'input[type=submit][value="Save"]' + expect(page).to have_content "Select a spreadsheet to upload" + File.delete('/tmp/test.txt') + end + + it "returns and error if nothing was uploaded" do + visit main_app.admin_product_import_path + click_button 'Import' + + expect(page).to have_content "File not found or could not be opened" + end + + it "handles cases where no meaningful data can be read from the file" do + File.write('/tmp/test.csv', "A22££S\\\\\n**VA,,,AF..D") + + visit main_app.admin_product_import_path + attach_file 'file', '/tmp/test.csv' + click_button 'Import' + + expect(page).to have_selector '.create-count', text: "0" + expect(page).to have_selector '.update-count', text: "0" + expect(page).to_not have_selector 'input[type=submit][value="Save"]' + File.delete('/tmp/test.csv') + end + end + + describe "handling enterprise permissions" do + before { quick_login_as user } + + it "only allows 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.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 have_selector '.invalid-count', text: "1" + expect(page).to have_selector '.create-count', text: "1" + expect(page).to have_selector '.update-count', text: "0" + + expect(page.body).to have_content 'you do not have permission' + + click_button 'Save' + + expect(page).to have_selector '.created-count', text: '1' + expect(page).to have_selector '.updated-count', text: '0' + + Spree::Product.find_by_name('My Carrots').should be_a Spree::Product + Spree::Product.find_by_name('Your Potatoes').should == nil + end + end + + describe "applying settings and defaults on import" do + before { quick_login_as_admin } + + it "can set all products for an enterprise that are not present in the uploaded file to zero stock" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale"] + csv << ["Carrots", "User Enterprise", "Vegetables", "5", "3.20", "500", "weight", "1"] + csv << ["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 have_selector '.invalid-count', text: "0" + 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 "overwrites fields with selected defaults" do + csv_data = CSV.generate do |csv| + csv << ["name", "supplier", "category", "on_hand", "price", "unit_value", "variant_unit", "variant_unit_scale", "tax_category_id", "available_on"] + 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 + 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 + 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) + 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 + 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' + expect(page).to have_selector '.updated-count', text: '0' + + 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 + end +end \ No newline at end of file diff --git a/spec/features/consumer/shopping/checkout_spec.rb b/spec/features/consumer/shopping/checkout_spec.rb index 16bdb27ec1..d9c9e8bf8f 100644 --- a/spec/features/consumer/shopping/checkout_spec.rb +++ b/spec/features/consumer/shopping/checkout_spec.rb @@ -1,6 +1,5 @@ require 'spec_helper' - feature "As a consumer I want to check out my cart", js: true, retry: 3 do include AuthenticationWorkflow include ShopWorkflow @@ -31,7 +30,7 @@ feature "As a consumer I want to check out my cart", js: true, retry: 3 do let(:sm2) { create(:shipping_method, require_ship_address: false, name: "Donkeys", description: "blue", calculator: Spree::Calculator::FlatRate.new(preferred_amount: 4.56)) } let(:sm3) { create(:shipping_method, require_ship_address: false, name: "Local", tag_list: "local") } let!(:pm1) { create(:payment_method, distributors: [distributor], name: "Roger rabbit", type: "Spree::PaymentMethod::Check") } - let!(:pm2) { create(:payment_method, distributors: [distributor]) } + let!(:pm2) { create(:payment_method, distributors: [distributor], calculator: Spree::Calculator::FlatRate.new(preferred_amount: 5.67)) } let!(:pm3) do Spree::Gateway::PayPalExpress.create!(name: "Paypal", environment: 'test', distributor_ids: [distributor.id]).tap do |pm| pm.preferred_login = 'devnull-facilitator_api1.rohanmitchell.com' @@ -40,6 +39,7 @@ feature "As a consumer I want to check out my cart", js: true, retry: 3 do end end + before do distributor.shipping_methods << sm1 distributor.shipping_methods << sm2 @@ -328,37 +328,63 @@ feature "As a consumer I want to check out my cart", js: true, retry: 3 do end end - context "with a credit card payment method" do - let!(:pm1) { create(:payment_method, distributors: [distributor], name: "Roger rabbit", type: "Spree::Gateway::Bogus") } + context "when we are charged a payment method fee (transaction fee)" do + it "creates a payment including the transaction fee" do + # Selecting the transaction fee, it is displayed + expect(page).to have_selector ".transaction-fee td", text: "$0.00" + expect(page).to have_selector ".total", text: "$11.23" - it "takes us to the order confirmation page when submitted with a valid credit card" do toggle_payment - fill_in 'Card Number', with: "4111111111111111" - select 'February', from: 'secrets.card_month' - select (Date.current.year+1).to_s, from: 'secrets.card_year' - fill_in 'Security Code', with: '123' + choose "#{pm2.name} ($5.67)" + + expect(page).to have_selector ".transaction-fee td", text: "$5.67" + expect(page).to have_selector ".total", text: "$16.90" place_order - page.should have_content "Your order has been processed successfully" + expect(page).to have_content "Your order has been processed successfully" - # Order should have a payment with the correct amount + # There are two orders - our order and our new cart o = Spree::Order.complete.first - o.payments.first.amount.should == 11.23 + expect(o.adjustments.payment_fee.first.amount).to eq 5.67 + expect(o.payments.first.amount).to eq(10 + 1.23 + 5.67) # items + fees + transaction end + end - it "shows the payment processing failed message when submitted with an invalid credit card" do - toggle_payment - fill_in 'Card Number', with: "9999999988887777" - select 'February', from: 'secrets.card_month' - select (Date.current.year+1).to_s, from: 'secrets.card_year' - fill_in 'Security Code', with: '123' + describe "credit card payments" do + ["Spree::Gateway::Bogus", "Spree::Gateway::BogusSimple"].each do |gateway_type| + context "with a credit card payment method using #{gateway_type}" do + let!(:pm1) { create(:payment_method, distributors: [distributor], name: "Roger rabbit", type: gateway_type) } - place_order - page.should have_content "Payment could not be processed, please check the details you entered" + it "takes us to the order confirmation page when submitted with a valid credit card" do + toggle_payment + fill_in 'Card Number', with: "4111111111111111" + select 'February', from: 'secrets.card_month' + select (Date.current.year+1).to_s, from: 'secrets.card_year' + fill_in 'Security Code', with: '123' - # Does not show duplicate shipping fee - visit checkout_path - page.should have_selector "th", text: "Shipping", count: 1 + place_order + page.should have_content "Your order has been processed successfully" + + # Order should have a payment with the correct amount + o = Spree::Order.complete.first + o.payments.first.amount.should == 11.23 + end + + it "shows the payment processing failed message when submitted with an invalid credit card" do + toggle_payment + fill_in 'Card Number', with: "9999999988887777" + select 'February', from: 'secrets.card_month' + select (Date.current.year+1).to_s, from: 'secrets.card_year' + fill_in 'Security Code', with: '123' + + place_order + page.should have_content "Payment could not be processed, please check the details you entered" + + # Does not show duplicate shipping fee + visit checkout_path + page.should have_selector "th", text: "Shipping", count: 1 + end + end end end end diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb new file mode 100644 index 0000000000..e51bf91cea --- /dev/null +++ b/spec/models/product_importer_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'open_food_network/permissions' + +describe ProductImporter do + include AuthenticationWorkflow + + let!(:admin) { create(:admin_user) } + let!(:user) { create_enterprise_user } + let!(:enterprise) { create(:enterprise, owner: user, name: "Test Enterprise") } + let!(:category) { create(:taxon, name: 'Vegetables') } + 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 + 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"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + + importer = ProductImporter.new(file, permissions.editable_enterprises) + + expect(importer.valid_count).to eq(2) + expect(importer.invalid_count).to eq(0) + end + end + + # Test handling of filetypes +end