public-market/spree_batch_api

View on GitHub
app/actions/spree/inventory/providers/default_variant_provider.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'dry-validation'

module Spree
  module Inventory
    module Providers
      class DefaultVariantProvider < Spree::BaseAction # rubocop:disable Metrics/ClassLength
        KEYWORDS_DELIMITER = ' '.freeze
        VALIDATION_SCHEMA =
          ::Dry::Validation.Schema do
            required(:sku).filled(:str?)
            required(:quantity).filled(:int?)
            required(:price).filled(:decimal?)
            optional(:notes).str?
          end

        param :item_json
        option :options, optional: true, default: proc { {} }

        def call
          item_hash = validate_item(cast_values(item_json))
          Taxon.no_touching { process_item(item_hash) }
        end

        protected

        def cast_values(item_json)
          item_json[:quantity] = cast(item_json[:quantity]) { |v| v.to_i }
          item_json[:price] = cast(item_json[:price]) { |v| v.to_f.to_d }
          item_json
        end

        def cast(value)
          str = value.to_s
          str.empty? ? nil : yield(str)
        end

        def upload_item_schema
          self.class::VALIDATION_SCHEMA || VALIDATION_SCHEMA
        end

        def validate_item(item_json)
          result = upload_item_schema.with(validation_options).call(item_json)
          messages = result.messages
          raise ImportError.new(messages.to_s, messages) if result.failure?

          result.to_h
        end

        def validation_options
          {}
        end

        def process_item(hash)
          variant = find_variant(variant_sku(hash))
          if variant.present?
            update_variant(variant, hash)
          else
            variant = create_variant(hash)
          end

          create_product_upload(variant)
          variant
        end

        def create_variant(hash)
          identifier = product_identifier(hash)
          product = find_product(identifier) || create_product(identifier)

          variant = Variant.new(sku: variant_sku(hash), product_id: product.id, upload_id: upload_id, upload_index: upload_index)
          update_variant(variant, hash)
        end

        def upload_last?(variant)
          return true if upload_id.blank? || upload_index.blank?

          Variant.where(id: variant)
                 .where('coalesce(upload_id, 0) < :upload_id or (coalesce(upload_id, 0) = :upload_id and coalesce(upload_index, 0) <= :upload_index)',
                        upload_id: upload_id, upload_index: upload_index)
                 .update_all(upload_id: upload_id, upload_index: upload_index)
                 .positive?
        end

        def create_product_upload(variant)
          ProductUpload.create(product_id: variant.product_id, upload_id: upload_id)
        end

        def upload_id
          options[:upload_id]
        end

        def upload_index
          options[:index]
        end

        def product_identifier(_hash)
          raise NotImplementedError, 'product_identifier'
        end

        def find_product(identifier)
          Product.joins(:master).find_by(spree_variants: { sku: identifier })
        end

        def variant_sku(hash)
          hash[:sku]
        end

        # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
        def create_product(identifier)
          metadata = find_metadata(identifier)
          raise ImportError, t('metadata_not_found', default: I18n.t('actions.spree.inventory.providers.default_variant_provider.metadata_not_found')) if metadata.blank?

          create_stock_location

          product = build_new_product(metadata)
          build_product_master(product, metadata)

          product.master.sku = identifier
          build_option_types(product)

          # Double check existing product
          existing_product = find_product(identifier)
          return existing_product if existing_product.present?

          Product.transaction do
            product.save!

            set_properties(product, metadata[:properties])
            set_tags(product, metadata[:keywords])
            categorize(product, metadata[:taxons])
          end

          product
        end
        # rubocop:enable Metrics/AbcSize, Metrics/MethodLength

        def find_metadata(identifier)
          metadata_provider.call(identifier)
        end

        def metadata_provider
          Fake::MetadataProvider
        end

        def build_option_types(product)
          option_types.each do |type|
            product.product_option_types.build(option_type: option_type_attrs(type))
          end
        end

        def option_type_attrs(type)
          attrs = { name: type[:name], presentation: type[:presentation] }
          attrs[:option_values_attributes] = type[:values] if type[:values].present?

          OptionType.where(name: type[:name]).first_or_create(attrs)
        end

        def build_new_product(metadata)
          Product.new(product_attrs(metadata))
        end

        def product_attrs(metadata)
          {
            name: metadata[:title],
            price: metadata[:price],
            description: metadata[:description],
            meta_description: metadata[:description],
            meta_title: metadata[:title]&.truncate(255), # spree has validation: length maximum - 255
            available_on: metadata[:available_on].presence || Time.current,
            discontinue_on: metadata[:discontinue_on].presence
          }
        end

        def build_product_master(product, metadata)
          product.master.assign_attributes(master_variant_attributes(metadata))
          return if metadata[:images].blank?

          metadata[:images].each do |img|
            product.master.images.build(
              alt: img[:title],
              attachment: img[:file] || URI.parse(img[:url])
            )
          end
        end

        def set_properties(product, properties)
          properties.each do |property_name, property_value|
            if property_value.present?
              product.set_property(property_name, property_value, I18n.t("properties.#{property_name}", default: property_name.to_s.humanize))
            end
          end
        end

        def set_tags(product, keywords)
          return if keywords.blank?

          product.tag_list.add(*keywords.split(KEYWORDS_DELIMITER))
          product.save!
        end

        def categorize(product, taxons)
          taxonomy = Spree::Taxonomy.find_or_create_by!(name: taxonomy_name)

          parent_taxon = taxonomy.root
          taxons.each do |taxon|
            parent_taxon = parent_taxon.children.find_or_create_by!(name: taxon, taxonomy: taxonomy)
          end
          parent_taxon.products << product
        end

        def update_variant(variant, item)
          variant.price = variant.cost_price = item[:price]
          variant.notes = item[:notes] if variant.respond_to?(:notes)
          update_variant_hook(variant, item)
          variant.build_options(variant_options(item))

          if variant.persisted? && !upload_last?(variant)
            Rails.logger.warn("Wrong variant '#{variant.sku}'' update order: upload_id #{upload_id}, upload_index #{upload_index}")
            return
          end

          variant.save!

          process_variant_quantity(variant, item[:quantity])
        end

        def variant_options(item)
          option_types.map { |type| { name: type[:name], value: variant_option_value(item, type) } }
        end

        def variant_option_value(item, option_type)
          item.dig(option_type[:name].to_sym)
        end

        def find_variant(sku)
          Variant.unscoped.find_by(sku: sku)
        end

        def process_variant_quantity(variant, quantity)
          stock_item = variant.stock_items.first
          stock_item.set_count_on_hand(quantity)
          variant
        end

        def master_variant_attributes(_metadata)
          {}
        end

        def create_stock_location
          StockLocation.create_with(backorderable_default: false).first_or_create(name: 'default')
        end

        def taxonomy_name
          options&.dig(:taxonomy) || self.class.parent::TAXONOMY
        end

        def update_variant_hook(variant, item)
          # Hook for extending variants
        end
      end
    end
  end
end