├── .rspec ├── .github_changelog_generator ├── app ├── assets │ ├── javascripts │ │ └── spree │ │ │ └── backend │ │ │ └── solidus_product_assembly.js │ ├── stylesheets │ │ └── spree │ │ │ └── backend │ │ │ └── solidus_product_assembly.css │ └── images │ │ └── spinner.gif ├── overrides │ └── spree │ │ ├── checkout │ │ └── _delivery │ │ │ ├── remove_unshippable_markup.html.erb.deface │ │ │ └── render_line_item_manifest.html.erb.deface │ │ ├── orders │ │ └── _line_item │ │ │ └── product_assembly_cart_item_description.deface │ │ ├── admin │ │ ├── shared │ │ │ └── _product_tabs │ │ │ │ └── product_assembly_admin_product_tabs.deface │ │ ├── orders │ │ │ ├── _form │ │ │ │ └── inject_product_assemblies.html.erb.deface │ │ │ ├── _carton_manifest │ │ │ │ ├── _assembly_parts_price.html.erb.deface │ │ │ │ └── _assembly_parts_total_price.html.erb.deface │ │ │ └── _shipment │ │ │ │ └── stock_contents.html.erb.deface │ │ └── products │ │ │ └── _form │ │ │ └── product_assembly_product_form_right.deface │ │ ├── products │ │ └── show │ │ │ ├── remove_add_to_cart_button_for_non_individual_sale_products.html.erb.deface │ │ │ └── add_links_to_parts.html.erb.deface │ │ └── shared │ │ └── _order_details │ │ └── part_description.html.erb.deface ├── views │ └── spree │ │ ├── admin │ │ ├── parts │ │ │ ├── update_parts_table.js.erb │ │ │ ├── _parts_table.html.erb │ │ │ ├── available.html.erb │ │ │ ├── available.js.erb │ │ │ └── index.html.erb │ │ ├── shared │ │ │ └── _product_assembly_product_tabs.html.erb │ │ ├── orders │ │ │ ├── _stock_contents.html.erb │ │ │ ├── _assemblies.html.erb │ │ │ ├── _stock_item.html.erb │ │ │ ├── _stock_contents_2_3.html.erb │ │ │ └── _stock_contents_2_4.html.erb │ │ └── products │ │ │ └── _product_assembly_fields.html.erb │ │ ├── orders │ │ └── _cart_description.html.erb │ │ ├── api │ │ └── line_items │ │ │ └── show.v1.rabl │ │ └── checkout │ │ └── _line_item_manifest.html.erb ├── decorators │ ├── models │ │ └── solidus_product_assembly │ │ │ └── spree │ │ │ ├── return_item_decorator.rb │ │ │ ├── stock │ │ │ ├── differentiator_decorator.rb │ │ │ ├── inventory_validator_decorator.rb │ │ │ ├── inventory_units_finalizer_decorator.rb │ │ │ ├── inventory_unit_builder_decorator.rb │ │ │ └── availability_validator_decorator.rb │ │ │ ├── inventory_unit_decorator.rb │ │ │ ├── variant_decorator.rb │ │ │ ├── line_item_decorator.rb │ │ │ ├── shipment_decorator.rb │ │ │ └── product_decorator.rb │ └── helpers │ │ └── solidus_product_assembly │ │ └── spree │ │ └── admin │ │ └── orders_helper_decorator.rb ├── models │ └── spree │ │ ├── assemblies_part.rb │ │ ├── calculator │ │ └── returns │ │ │ └── assemblies_default_refund_amount.rb │ │ └── order_inventory_assembly.rb └── controllers │ └── spree │ └── admin │ └── parts_controller.rb ├── lib ├── solidus_product_assembly │ ├── testing_support │ │ └── factories.rb │ ├── version.rb │ ├── configuration.rb │ └── engine.rb ├── solidus_product_assembly.rb ├── generators │ └── solidus_product_assembly │ │ └── install │ │ ├── templates │ │ └── initializer.rb │ │ └── install_generator.rb └── tasks │ └── spree2_upgrade.rake ├── .rubocop.yml ├── bin ├── rake ├── setup ├── rails ├── rails-sandbox ├── console ├── rails-engine └── sandbox ├── CHANGELOG.md ├── .gem_release.yml ├── .github ├── dependabot.yml └── stale.yml ├── Rakefile ├── .gitignore ├── db └── migrate │ ├── 20140620223938_add_id_to_spree_assemblies_parts.rb │ ├── 20120316141830_namespace_product_assembly_for_spree_one.rb │ ├── 20091028152124_add_many_to_many_relation_to_products.rb │ └── 20091029165620_add_parts_fields_to_products.rb ├── config ├── locales │ ├── sv.yml │ ├── en.yml │ ├── fr.yml │ └── ru.yml └── routes.rb ├── spec ├── support │ └── shared_contexts │ │ └── order_with_bundle.rb ├── models │ └── spree │ │ ├── assemblies_part_spec.rb │ │ ├── variant_spec.rb │ │ ├── order_inventory_spec.rb │ │ ├── order_contents_spec.rb │ │ ├── stock │ │ ├── inventory_unit_builder_spec.rb │ │ ├── coordinator_spec.rb │ │ └── availability_validator_spec.rb │ │ ├── inventory_unit_spec.rb │ │ ├── product_spec.rb │ │ ├── order_inventory_assembly_spec.rb │ │ ├── line_item_spec.rb │ │ └── shipment_spec.rb ├── features │ ├── admin │ │ ├── parts_spec.rb │ │ ├── orders_spec.rb │ │ └── return_items_spec.rb │ └── checkout_spec.rb └── spec_helper.rb ├── LICENSE ├── solidus_product_assembly.gemspec ├── Gemfile ├── .circleci └── config.yml ├── README.md ├── .rubocop_todo.yml └── OLD_CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | issues=false 2 | exclude-labels=infrastructure 3 | -------------------------------------------------------------------------------- /app/assets/javascripts/spree/backend/solidus_product_assembly.js: -------------------------------------------------------------------------------- 1 | //= require_tree . 2 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spree/backend/solidus_product_assembly.css: -------------------------------------------------------------------------------- 1 | /* 2 | *= require spree/backend 3 | */ 4 | -------------------------------------------------------------------------------- /app/overrides/spree/checkout/_delivery/remove_unshippable_markup.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/solidus_product_assembly/testing_support/factories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | end 5 | -------------------------------------------------------------------------------- /app/assets/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solidusio-contrib/solidus_product_assembly/HEAD/app/assets/images/spinner.gif -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - solidus_dev_support/rubocop 5 | 6 | AllCops: 7 | NewCops: disable 8 | -------------------------------------------------------------------------------- /lib/solidus_product_assembly/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | VERSION = '1.4.0' 5 | end 6 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "rubygems" 5 | require "bundler/setup" 6 | 7 | load Gem.bin_path("rake", "rake") 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | gem install bundler --conservative 7 | bundle update 8 | bin/rake clobber 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | See https://github.com/solidusio-contrib/solidus_product_assembly/releases or [OLD_CHANGELOG.md](OLD_CHANGELOG.md) for older versions. 4 | -------------------------------------------------------------------------------- /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | recurse: false 3 | file: 'lib/solidus_product_assembly/version.rb' 4 | message: Bump SolidusProductAssembly to %{version} 5 | tag: true 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /app/overrides/spree/orders/_line_item/product_assembly_cart_item_description.deface: -------------------------------------------------------------------------------- 1 | insert_bottom "[data-hook='cart_item_description']" 2 | partial "spree/orders/cart_description" 3 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | if %w[g generate].include? ARGV.first 4 | exec "#{__dir__}/rails-engine", *ARGV 5 | else 6 | exec "#{__dir__}/rails-sandbox", *ARGV 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'solidus_dev_support/rake_tasks' 5 | SolidusDevSupport::RakeTasks.install 6 | 7 | task default: 'extension:specs' 8 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/shared/_product_tabs/product_assembly_admin_product_tabs.deface: -------------------------------------------------------------------------------- 1 | insert_bottom "[data-hook='admin_product_tabs']" 2 | partial "spree/admin/shared/product_assembly_product_tabs" 3 | -------------------------------------------------------------------------------- /app/overrides/spree/checkout/_delivery/render_line_item_manifest.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= render 'line_item_manifest', ship_form: ship_form %> 4 | -------------------------------------------------------------------------------- /app/views/spree/admin/parts/update_parts_table.js.erb: -------------------------------------------------------------------------------- 1 | $("#product_parts").html("<%= escape_javascript(render(:partial => "parts_table", :locals => {:parts => @product.parts})) %>"); 2 | $("#search_hits").hide(); 3 | -------------------------------------------------------------------------------- /lib/solidus_product_assembly.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'solidus_product_assembly/configuration' 4 | require 'solidus_product_assembly/version' 5 | require 'solidus_product_assembly/engine' 6 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/orders/_form/inject_product_assemblies.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= render partial: 'spree/admin/orders/assemblies', locals: { order: order } %> 4 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/products/_form/product_assembly_product_form_right.deface: -------------------------------------------------------------------------------- 1 | insert_after "[data-hook='admin_product_form_right'], #admin_product_form_right[data-hook]" 2 | partial "spree/admin/products/product_assembly_fields" 3 | -------------------------------------------------------------------------------- /app/overrides/spree/products/show/remove_add_to_cart_button_for_non_individual_sale_products.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | <% if @product.individual_sale? %> 3 | <%= render_original %> 4 | <% end %> 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | \#* 3 | *~ 4 | .#* 5 | .DS_Store 6 | .idea 7 | .project 8 | .sass-cache 9 | coverage 10 | Gemfile.lock 11 | tmp 12 | nbproject 13 | pkg 14 | *.swp 15 | spec/dummy 16 | spec/examples.txt 17 | /sandbox 18 | .rvmrc 19 | .ruby-version 20 | .ruby-gemset 21 | -------------------------------------------------------------------------------- /app/views/spree/admin/shared/_product_assembly_product_tabs.html.erb: -------------------------------------------------------------------------------- 1 | <%= content_tag :li, class: ('active' if current == 'Parts') do %> 2 | <%= link_to t('spree.parts'), admin_product_parts_url(@product) %> 3 | <% end if can?(:admin, Spree::AssembliesPart) && !@product.deleted? %> 4 | -------------------------------------------------------------------------------- /app/views/spree/admin/orders/_stock_contents.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'stock_item', shipment: shipment %> 2 | 3 | <% unless shipment.shipped? %> 4 | 5 | 6 | <% end %> 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/generators/solidus_product_assembly/install/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SolidusProductAssembly.configure do |config| 4 | # TODO: Remember to change this with the actual preferences you have implemented! 5 | # config.sample_preference = 'sample_value' 6 | end 7 | -------------------------------------------------------------------------------- /app/views/spree/orders/_cart_description.html.erb: -------------------------------------------------------------------------------- 1 | <% product = variant.product 2 | if product.assembly? %> 3 | 8 | <% end %> 9 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/orders/_carton_manifest/_assembly_parts_price.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% if item.variant.part? %> 4 | --- 5 | <% else %> 6 | 7 | <%= line_item_shipment_price(item.line_item, item.quantity) %> 8 | 9 | <% end %> 10 | -------------------------------------------------------------------------------- /db/migrate/20140620223938_add_id_to_spree_assemblies_parts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIdToSpreeAssembliesParts < SolidusSupport::Migration[4.2] 4 | def up 5 | add_column :spree_assemblies_parts, :id, :primary_key 6 | end 7 | 8 | def down 9 | remove_column :spree_assemblies_parts, :id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/orders/_carton_manifest/_assembly_parts_total_price.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% if item.variant.part? %> 4 | --- 5 | <% else %> 6 | 7 | <%= line_item_shipment_price(item.line_item, item.quantity) %> 8 | 9 | <% end %> 10 | -------------------------------------------------------------------------------- /db/migrate/20120316141830_namespace_product_assembly_for_spree_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NamespaceProductAssemblyForSpreeOne < SolidusSupport::Migration[4.2] 4 | def up 5 | rename_table :assemblies_parts, :spree_assemblies_parts 6 | end 7 | 8 | def down 9 | rename_table :spree_assemblies_parts, :assemblies_parts 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/overrides/spree/shared/_order_details/part_description.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | <% if item.product.assembly? %> 3 | 8 | <% end %> 9 | -------------------------------------------------------------------------------- /config/locales/sv.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sv: 3 | spree: 4 | available_parts: Tillgängliga delar 5 | can_be_part: Kan vara del 6 | individual_sale: Individuell försäljning 7 | no_variants: Inga varianter 8 | parts: Delar 9 | assembly_cannot_be_part: enheten kan inte vara en del 10 | product_bundles: Produktpaket 11 | parts_included: Delar som ingår 12 | part_of_bundle: 'Del av %{sku}' -------------------------------------------------------------------------------- /bin/rails-sandbox: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | app_root = 'sandbox' 4 | 5 | unless File.exist? "#{app_root}/bin/rails" 6 | warn 'Creating the sandbox app...' 7 | Dir.chdir "#{__dir__}/.." do 8 | system "#{__dir__}/sandbox" or begin 9 | warn 'Automatic creation of the sandbox app failed' 10 | exit 1 11 | end 12 | end 13 | end 14 | 15 | Dir.chdir app_root 16 | exec 'bin/rails', *ARGV 17 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | spree: 4 | available_parts: Available parts 5 | can_be_part: Can be part 6 | individual_sale: Individual sale 7 | no_variants: No variants 8 | parts: Parts 9 | assembly_cannot_be_part: assembly can't be part 10 | product_bundles: Product Bundles 11 | parts_included: Parts included 12 | part_of_bundle: 'Part of bundle %{sku}' 13 | actions: 14 | delete: 'Delete' 15 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | fr: 3 | spree: 4 | available_parts: Parties disponibles 5 | can_be_part: Peut faire partie d'un package 6 | individual_sale: Vente individuelle 7 | no_variants: Pas de variants 8 | parts: Parties 9 | assembly_cannot_be_part: ensemble ne peut pas être partie 10 | product_bundles: Ensembles du produit 11 | parts_included: Les pièces comprises 12 | part_of_bundle: 'Une partie du %{sku}' 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spree::Core::Engine.routes.draw do 4 | namespace :admin do 5 | resources :products do 6 | resources :parts do 7 | member do 8 | post :select 9 | post :remove 10 | post :set_count 11 | end 12 | collection do 13 | post :available 14 | get :selected 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/overrides/spree/admin/orders/_shipment/stock_contents.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | <% if Spree.solidus_gem_version < Gem::Version.new('2.4') %> 3 | <%= render "stock_contents_2_3", shipment: shipment %> 4 | <% elsif Spree.solidus_gem_version < Gem::Version.new('2.5') %> 5 | <%= render "stock_contents_2_4", shipment: shipment %> 6 | <% else %> 7 | <%= render "stock_contents", shipment: shipment %> 8 | <% end %> 9 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ru: 3 | spree: 4 | available_parts: Доступные составные части 5 | can_be_part: Может входить в состав других продуктов 6 | individual_sale: Может продаваться отдельно 7 | no_variants: Нет вариантов 8 | parts: Составные части 9 | assembly_cannot_be_part: сборка не может быть частью 10 | product_bundles: Продукт cвязки 11 | parts_included: 'Детали, входящие' 12 | part_of_bundle: 'Часть пучка %{sku}' -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/return_item_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module ReturnItemDecorator 6 | def self.prepended(base) 7 | base.class_eval do 8 | self.refund_amount_calculator = ::Spree::Calculator::Returns::AssembliesDefaultRefundAmount 9 | end 10 | end 11 | 12 | ::Spree::ReturnItem.prepend self 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20091028152124_add_many_to_many_relation_to_products.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddManyToManyRelationToProducts < SolidusSupport::Migration[4.2] 4 | def self.up 5 | create_table :assemblies_parts, id: false do |t| 6 | t.integer "assembly_id", null: false 7 | t.integer "part_id", null: false 8 | t.integer "count", null: false, default: 1 9 | end 10 | end 11 | 12 | def self.down 13 | drop_table :assemblies_parts 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/stock/differentiator_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Stock 6 | module DifferentiatorDecorator 7 | def build_missing 8 | super.tap do 9 | @missing.delete_if { |k, _v| k.product.assembly? } 10 | end 11 | end 12 | 13 | ::Spree::Stock::Differentiator.prepend self 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/spree/assemblies_part.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | class AssembliesPart < ApplicationRecord 5 | belongs_to :assembly, class_name: "Spree::Product", 6 | foreign_key: "assembly_id", touch: true 7 | 8 | belongs_to :part, class_name: "Spree::Variant", foreign_key: "part_id" 9 | 10 | def self.get(assembly_id, part_id) 11 | find_or_initialize_by(assembly_id: assembly_id, part_id: part_id) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/solidus_product_assembly/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | class Configuration 5 | # Define here the settings for this extension, e.g.: 6 | # 7 | # attr_accessor :my_setting 8 | end 9 | 10 | class << self 11 | def configuration 12 | @configuration ||= Configuration.new 13 | end 14 | 15 | alias config configuration 16 | 17 | def configure 18 | yield configuration 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/solidus_product_assembly/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'solidus_core' 4 | require 'solidus_support' 5 | require 'deface' 6 | 7 | module SolidusProductAssembly 8 | class Engine < Rails::Engine 9 | include SolidusSupport::EngineExtensions 10 | 11 | isolate_namespace ::Spree 12 | 13 | engine_name 'solidus_product_assembly' 14 | 15 | # use rspec for tests 16 | config.generators do |g| 17 | g.test_framework :rspec 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | require "bundler/setup" 6 | require "solidus_product_assembly" 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | $LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"]) 11 | 12 | # (If you use this, don't forget to add pry to your Gemfile!) 13 | # require "pry" 14 | # Pry.start 15 | 16 | require "irb" 17 | IRB.start(__FILE__) 18 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/order_with_bundle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context "product is ordered as individual and within a bundle" do 4 | let(:order) { create(:order_with_line_items) } 5 | let(:parts) { (1..3).map { create(:variant) } } 6 | 7 | let(:bundle_variant) { order.variants.first } 8 | let(:bundle) { bundle_variant.product } 9 | 10 | let(:common_product) { order.variants.last } 11 | 12 | before do 13 | bundle.parts << [parts, common_product] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/models/spree/assemblies_part_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe AssembliesPart do 7 | let(:product) { create(:product) } 8 | let(:variant) { create(:variant) } 9 | 10 | before do 11 | product.parts.push variant 12 | end 13 | 14 | context "get" do 15 | it "brings part by product and variant id" do 16 | expect(subject.class.get(product.id, variant.id).part).to eq variant 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /bin/rails-engine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path('..', __dir__) 6 | ENGINE_PATH = File.expand_path('../lib/solidus_product_assembly/engine', __dir__) 7 | 8 | # Set up gems listed in the Gemfile. 9 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 10 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 11 | 12 | require 'rails/all' 13 | require 'rails/engine/commands' 14 | -------------------------------------------------------------------------------- /app/models/spree/calculator/returns/assemblies_default_refund_amount.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | module Calculator::Returns 5 | class AssembliesDefaultRefundAmount < DefaultRefundAmount 6 | def compute(return_item) 7 | percentage = return_item.inventory_unit.percentage_of_line_item 8 | if percentage < 1 && return_item.variant.part? 9 | line_item = return_item.inventory_unit.line_item 10 | (super * percentage * line_item.quantity).round 4, :up 11 | else 12 | super 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/decorators/helpers/solidus_product_assembly/spree/admin/orders_helper_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Admin 6 | module OrdersHelperDecorator 7 | def self.prepended(base) 8 | base.module_eval do 9 | def line_item_shipment_price(line_item, quantity) 10 | ::Spree::Money.new(line_item.price * quantity, currency: line_item.currency) 11 | end 12 | end 13 | end 14 | 15 | ::Spree::Admin::OrdersHelper.prepend self 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/inventory_unit_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module InventoryUnitDecorator 6 | def percentage_of_line_item 7 | product = line_item.product 8 | if product.assembly? 9 | total_value = line_item.quantity_by_variant.map { |part, quantity| part.price * quantity }.sum 10 | variant.price / total_value 11 | else 12 | 1 / BigDecimal(line_item.quantity) 13 | end 14 | end 15 | 16 | ::Spree::InventoryUnit.prepend self 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/stock/inventory_validator_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Stock 6 | module InventoryValidatorDecorator 7 | def validate(line_item) 8 | total_quantity = line_item.quantity_by_variant.values.sum 9 | 10 | if line_item.inventory_units.count != total_quantity 11 | line_item.errors[:inventory] << I18n.t( 12 | 'spree.inventory_not_available', 13 | item: line_item.variant.name 14 | ) 15 | end 16 | end 17 | 18 | ::Spree::Stock::InventoryValidator.prepend self 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: false 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It might be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/variant_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module VariantDecorator 6 | def self.prepended(base) 7 | base.class_eval do 8 | has_and_belongs_to_many :assemblies, 9 | class_name: "Spree::Product", 10 | join_table: "spree_assemblies_parts", 11 | foreign_key: "part_id", association_foreign_key: "assembly_id" 12 | end 13 | end 14 | 15 | def assemblies_for(products) 16 | assemblies.where(id: products) 17 | end 18 | 19 | def part? 20 | assemblies.exists? 21 | end 22 | 23 | ::Spree::Variant.prepend self 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/views/spree/admin/products/_product_assembly_fields.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= f.label :can_be_part, t('spree.can_be_part')%> 4 | <%= f.check_box(:can_be_part) %> 5 |
6 |
7 |
8 |
9 | <%= f.label :individual_sale, t('spree.individual_sale')%> 10 | <%= f.check_box(:individual_sale) %> 11 |
12 |
13 | 14 | <% if @product.assembly? %> 15 | <% content_for :head do %> 16 | 22 | <% end %> 23 | <% end %> 24 | -------------------------------------------------------------------------------- /app/views/spree/api/line_items/show.v1.rabl: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | object @line_item 4 | cache [I18n.locale, root_object] 5 | attributes *line_item_attributes 6 | node(:single_display_amount) { |li| li.single_display_amount.to_s } 7 | node(:display_amount) { |li| li.display_amount.to_s } 8 | node(:total, &:total) 9 | glue variant: :parts do 10 | glue :product do 11 | child parts: :parts do 12 | extends "spree/api/variants/small" 13 | attributes :product_id 14 | 15 | child(images: :images) { extends "spree/api/images/show" } 16 | end 17 | end 18 | end 19 | child :variant do 20 | extends "spree/api/variants/small" 21 | attributes :product_id 22 | child(images: :images) { extends "spree/api/images/show" } 23 | end 24 | 25 | child adjustments: :adjustments do 26 | extends "spree/api/adjustments/show" 27 | end 28 | -------------------------------------------------------------------------------- /app/views/spree/checkout/_line_item_manifest.html.erb: -------------------------------------------------------------------------------- 1 | <% ship_form.object.line_item_manifest.each do |item| %> 2 | 3 | <%= render 'spree/shared/image', 4 | image: (item.variant.gallery.images.first || item.variant.product.gallery.images.first), 5 | size: :mini %> 6 | 7 | <%= item.variant.name %> 8 | <%= render 'spree/orders/cart_description', variant: item.variant, line_item: item.line_item %> 9 | 10 | 11 | <% if item.line_item.product.assembly? %> 12 | <%= item.line_item.quantity %> 13 | <% else %> 14 | <%= item.quantity %> 15 | <% end %> 16 | 17 | <%= item.line_item.single_money %> 18 | 19 | <% end %> 20 | -------------------------------------------------------------------------------- /spec/features/admin/parts_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe "Parts", type: :feature, js: true do 6 | stub_authorization! 7 | 8 | let!(:tshirt) { create(:product, name: "T-Shirt") } 9 | let!(:mug) { create(:product, name: "Mug") } 10 | 11 | before do 12 | visit spree.admin_product_path(mug) 13 | check "product_can_be_part" 14 | click_on "Update" 15 | end 16 | 17 | it "can add and remove parts" do 18 | visit spree.admin_product_path(tshirt) 19 | click_on "Parts" 20 | fill_in "searchtext", with: mug.name 21 | click_on "Search" 22 | click_on "Select" 23 | expect(page).to have_link('Delete') 24 | 25 | expect(tshirt.reload.parts).to eq([mug.master]) 26 | 27 | click_on 'Delete', wait: 30 28 | expect(page).not_to have_link('Delete') 29 | expect(tshirt.reload.parts).to eq([]) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20091029165620_add_parts_fields_to_products.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPartsFieldsToProducts < SolidusSupport::Migration[4.2] 4 | def self.up 5 | table = if data_source_exists?(:products) 6 | 'products' 7 | elsif data_source_exists?(:spree_products) 8 | 'spree_products' 9 | end 10 | 11 | change_table(table) do |t| 12 | t.column :can_be_part, :boolean, default: false, null: false 13 | t.column :individual_sale, :boolean, default: true, null: false 14 | end 15 | end 16 | 17 | def self.down 18 | table = if data_source_exists?(:products) 19 | 'products' 20 | elsif data_source_exists?(:spree_products) 21 | 'spree_products' 22 | end 23 | 24 | change_table(table) do |t| 25 | t.remove :can_be_part 26 | t.remove :individual_sale 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/models/spree/variant_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe Variant do 7 | context "filter assemblies" do 8 | let(:mug) { create(:product) } 9 | let(:tshirt) { create(:product) } 10 | let(:variant) { create(:variant) } 11 | 12 | context "variant has more than one assembly" do 13 | before { variant.assemblies.push [mug, tshirt] } 14 | 15 | it "returns both products" do 16 | expect(variant.assemblies_for([mug, tshirt])).to include mug 17 | expect(variant.assemblies_for([mug, tshirt])).to include tshirt 18 | end 19 | 20 | it { expect(variant).to be_a_part } 21 | end 22 | 23 | context "variant no assembly" do 24 | it "returns both products" do 25 | expect(variant.assemblies_for([mug, tshirt])).to be_empty 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/overrides/spree/products/show/add_links_to_parts.html.erb.deface: -------------------------------------------------------------------------------- 1 | 2 | <% if @product.parts.any?(&:in_stock?) %> 3 | 4 |
<%= t('spree.parts_included') %>
5 | 6 | 21 | <% end %> 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure Rails Environment 4 | ENV['RAILS_ENV'] = 'test' 5 | 6 | # Run Coverage report 7 | require 'solidus_dev_support/rspec/coverage' 8 | 9 | # Create the dummy app if it's still missing. 10 | dummy_env = "#{__dir__}/dummy/config/environment.rb" 11 | system 'bin/rake extension:test_app' unless File.exist? dummy_env 12 | require dummy_env 13 | 14 | # Requires factories and other useful helpers defined in spree_core. 15 | require 'solidus_dev_support/rspec/feature_helper' 16 | 17 | # Requires supporting ruby files with custom matchers and macros, etc, 18 | # in spec/support/ and its subdirectories. 19 | Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } 20 | 21 | # Requires factories defined in lib/solidus_product_assembly/testing_support/factories.rb 22 | SolidusDevSupport::TestingSupport::Factories.load_for(SolidusProductAssembly::Engine) 23 | 24 | RSpec.configure do |config| 25 | config.infer_spec_type_from_file_location! 26 | config.use_transactional_fixtures = false 27 | 28 | if Spree.solidus_gem_version < Gem::Version.new('2.11') 29 | config.extend Spree::TestingSupport::AuthorizationHelpers::Request, type: :system 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/models/spree/order_inventory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe OrderInventory do 7 | subject { described_class.new(order, order.line_items.first) } 8 | 9 | let!(:store) { create :store } 10 | let(:order) { Order.create } 11 | 12 | context "same variant within bundle and as regular product" do 13 | let(:contents) { OrderContents.new(order) } 14 | let(:guitar) { create(:variant) } 15 | let(:bass) { create(:variant) } 16 | 17 | let(:bundle) { create(:product) } 18 | 19 | before { bundle.parts.push [guitar, bass] } 20 | 21 | let!(:bundle_item) { contents.add(bundle.master, 5) } 22 | let!(:guitar_item) { contents.add(guitar, 3) } 23 | 24 | let!(:shipment) { order.create_proposed_shipments.first } 25 | 26 | context "completed order" do 27 | before do 28 | order.touch :completed_at 29 | end 30 | 31 | it "removes only units associated with provided line item" do 32 | expect { 33 | subject.send(:remove_from_shipment, shipment, 5) 34 | }.not_to change { bundle_item.inventory_units.count } 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/models/spree/order_contents_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe OrderContents do 7 | subject { described_class.new(order) } 8 | 9 | let!(:store) { create :store } 10 | let(:order) { Order.create } 11 | 12 | let(:guitar) { create(:variant) } 13 | let(:bass) { create(:variant) } 14 | 15 | let(:bundle) { create(:product) } 16 | 17 | before { bundle.parts.push [guitar, bass] } 18 | 19 | context "same variant within bundle and as regular product" do 20 | let!(:guitar_item) { subject.add(guitar, 3) } 21 | let!(:bundle_item) { subject.add(bundle.master, 5) } 22 | 23 | it "destroys the variant as regular product only" do 24 | subject.remove(guitar, 3) 25 | expect(order.reload.line_items.to_a).to eq [bundle_item] 26 | end 27 | 28 | context "completed order" do 29 | before do 30 | order.create_proposed_shipments 31 | order.touch :completed_at 32 | end 33 | 34 | it "destroys accurate number of inventory units" do 35 | expect { subject.remove(guitar, 3) }. 36 | to change(InventoryUnit, :count).by(-3) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tasks/spree2_upgrade.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :solidus_product_assembly do 4 | desc 'Link legacy inventory units to an order line item' 5 | task upgrade: :environment do 6 | shipments = Spree::Shipment.includes(:inventory_units).where("spree_inventory_units.line_item_id IS NULL") 7 | 8 | shipments.each do |shipment| 9 | shipment.inventory_units.includes(:variant).group_by(&:variant).each do |variant, units| 10 | line_item = shipment.order.line_items.detect { |line_item| line_item.variant_id == variant.id } 11 | 12 | next if line_item 13 | 14 | begin 15 | master = shipment.order.products.detect { |p| variant.assemblies.include? p }.master 16 | supposed_line_item = shipment.order.line_items.detect { |line_item| line_item.variant_id == master.id } 17 | 18 | if supposed_line_item 19 | Spree::InventoryUnit.where(id: units.map(&:id)).update_all "line_item_id = #{supposed_line_item.id}" 20 | else 21 | puts "Couldn't find a matching line item for #{variant.name}" 22 | end 23 | rescue StandardError 24 | puts "Couldn't find a matching line item for #{variant.name}" 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/stock/inventory_units_finalizer_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Stock 6 | module InventoryUnitsFinalizerDecorator 7 | private 8 | 9 | def unstock_inventory_units 10 | inventory_units.group_by(&:shipment_id).each_value do |inventory_units_for_shipment| 11 | inventory_units_for_shipment.group_by(&:line_item_id).each_value do |units| 12 | shipment = units.first.shipment 13 | line_item = units.first.line_item 14 | 15 | if line_item.product.assembly? 16 | units.group_by(&:variant_id).each_value do |units_for_part| 17 | part = units_for_part.first.variant 18 | shipment.stock_location.unstock part, units_for_part.count, shipment 19 | end 20 | else 21 | shipment.stock_location.unstock line_item.variant, units.count, shipment 22 | end 23 | end 24 | end 25 | end 26 | 27 | if ::Spree.solidus_gem_version >= Gem::Version.new('2.8') 28 | ::Spree::Stock::InventoryUnitsFinalizer.prepend self 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/models/spree/stock/inventory_unit_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | module Stock 7 | describe InventoryUnitBuilder, type: :model do 8 | subject { described_class.new(order) } 9 | 10 | context "order shares variant as individual and within bundle" do 11 | include_context "product is ordered as individual and within a bundle" do 12 | let(:bundle_item_quantity) { order.find_line_item_by_variant(bundle_variant).quantity } 13 | 14 | describe "#units" do 15 | it "returns an inventory unit for each part of each quantity for the order's line items" do 16 | units = subject.units 17 | expect(units.count).to eq 4 18 | expect(units[0].line_item.quantity).to eq order.line_items.first.quantity 19 | expect(units[0].line_item.quantity).to eq bundle_item_quantity 20 | 21 | line_item = order.line_items.first 22 | 23 | expect(units.map(&:variant)).to match_array line_item.parts 24 | end 25 | 26 | it "builds the inventory units as pending" do 27 | expect(subject.units.map(&:pending).uniq).to eq [true] 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/stock/inventory_unit_builder_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Stock 6 | module InventoryUnitBuilderDecorator 7 | def units 8 | @order.line_items.flat_map do |line_item| 9 | line_item.quantity_by_variant.flat_map do |variant, quantity| 10 | quantity.times.map { build_inventory_unit(variant, line_item) } 11 | end 12 | end 13 | end 14 | 15 | def build_inventory_unit(variant, line_item) 16 | inventory_unit_attributes = { 17 | pending: true, 18 | variant: variant, 19 | line_item: line_item, 20 | } 21 | if ::Spree.solidus_gem_version < Gem::Version.new('2.5.x') 22 | inventory_unit_attributes[:order] = @order 23 | end 24 | @order.inventory_units.includes( 25 | variant: { 26 | product: { 27 | shipping_category: { 28 | shipping_methods: [:calculator, { zones: :zone_members }] 29 | } 30 | } 31 | } 32 | ).build(inventory_unit_attributes) 33 | end 34 | 35 | ::Spree::Stock::InventoryUnitBuilder.prepend self 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/stock/availability_validator_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module Stock 6 | # Overridden from spree core to make it also check for assembly parts stock 7 | module AvailabilityValidatorDecorator 8 | def validate(line_item) 9 | if line_item.product.assembly? 10 | line_item.quantity_by_variant.each do |variant, variant_quantity| 11 | inventory_units = line_item.inventory_units.where(variant: variant).count 12 | quantity = variant_quantity - inventory_units 13 | next if quantity <= 0 14 | next unless variant 15 | 16 | quantifier = ::Spree::Stock::Quantifier.new(variant) 17 | 18 | next if quantifier.can_supply? quantity 19 | 20 | display_name = variant.name.to_s 21 | display_name += %{ (#{variant.options_text})} if variant.options_text.present? 22 | 23 | line_item.errors[:quantity] << I18n.t( 24 | 'spree.selected_quantity_not_available', 25 | item: display_name.inspect 26 | ) 27 | end 28 | else 29 | super 30 | end 31 | end 32 | 33 | ::Spree::Stock::AvailabilityValidator.prepend self 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/models/spree/inventory_unit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe InventoryUnit do 7 | subject { described_class.create!(attributes) } 8 | 9 | let!(:order) { create(:order_with_line_items) } 10 | let(:line_item) { order.line_items.first } 11 | let(:product) { line_item.product } 12 | let(:shipment) { create(:shipment, order: order) } 13 | 14 | if Spree.solidus_gem_version < Gem::Version.new('2.5.x') 15 | let(:attributes) { { shipment: shipment, line_item: line_item, variant: line_item.variant, order: order } } 16 | else 17 | let(:attributes) { { shipment: shipment, line_item: line_item, variant: line_item.variant } } 18 | end 19 | 20 | context 'if the unit is not part of an assembly' do 21 | it 'will return the percentage of a line item' do 22 | expect(subject.percentage_of_line_item).to eql(BigDecimal(1)) 23 | end 24 | end 25 | 26 | context 'if part of an assembly' do 27 | let(:parts) { (1..2).map { create(:variant) } } 28 | 29 | before do 30 | product.parts << parts 31 | order.create_proposed_shipments 32 | end 33 | 34 | it 'will return the percentage of a line item' do 35 | subject.line_item = line_item 36 | expect(subject.percentage_of_line_item).to eql(BigDecimal(0.5, 2)) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/controllers/spree/admin/parts_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Spree::Admin::PartsController < Spree::Admin::BaseController 4 | before_action :find_product 5 | 6 | def index 7 | @parts = @product.parts 8 | end 9 | 10 | def remove 11 | @part = Spree::Variant.find(params[:id]) 12 | @product.remove_part(@part) 13 | render 'spree/admin/parts/update_parts_table' 14 | end 15 | 16 | def set_count 17 | @part = Spree::Variant.find(params[:id]) 18 | @product.set_part_count(@part, params[:count].to_i) 19 | render 'spree/admin/parts/update_parts_table' 20 | end 21 | 22 | def available 23 | if params[:q].blank? 24 | @available_products = [] 25 | else 26 | query = "%#{params[:q]}%" 27 | @available_products = Spree::Product.search_can_be_part(query).distinct 28 | end 29 | respond_to do |format| 30 | format.html { render layout: false } 31 | format.js { render layout: false } 32 | end 33 | end 34 | 35 | def create 36 | @part = Spree::Variant.find(params[:part_id]) 37 | qty = params[:part_count].to_i 38 | @product.add_part(@part, qty) if qty > 0 39 | render 'spree/admin/parts/update_parts_table' 40 | end 41 | 42 | private 43 | 44 | def find_product 45 | @product = Spree::Product.find_by(slug: params[:product_id]) 46 | end 47 | 48 | def model_class 49 | Spree::AssembliesPart 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/views/spree/admin/parts/_parts_table.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% parts.each do |part| %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 32 | 33 | <% end %> 34 | <% if parts.empty? %> 35 | 36 | <% end %> 37 | 38 |
<%= t('spree.sku') %><%= t('spree.name') %><%= t('spree.available_on') %><%= t('spree.options') %><%= t('spree.qty') %>
<%= part.sku %><%= part.product.name %><%= part.product.available_on %><%= variant_options part %><%= text_field_tag :count, @product.count_of(part) %> 20 | <%= link_to_with_icon( 21 | 'edit', 22 | t('spree.actions.edit'), 23 | set_count_admin_product_part_url(@product, part), 24 | :class => "set_count_admin_product_part_link save-line-item") %> 25 | 26 | <%= link_to_with_icon( 27 | 'trash', 28 | t('spree.actions.delete'), 29 | remove_admin_product_part_url(@product, part), 30 | class: "remove_admin_product_part_link delete-line-item") %> 31 |
<%= t('spree.none') %>.
39 | <%= javascript_tag("subscribe_product_part_links();") if request.xhr? %> 40 | -------------------------------------------------------------------------------- /lib/generators/solidus_product_assembly/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | class_option :auto_run_migrations, type: :boolean, default: false 7 | source_root File.expand_path('templates', __dir__) 8 | 9 | def copy_initializer 10 | template 'initializer.rb', 'config/initializers/solidus_product_assembly.rb' 11 | end 12 | 13 | def add_javascripts 14 | append_file 'vendor/assets/javascripts/spree/backend/all.js', "//= require spree/backend/solidus_product_assembly\n" 15 | end 16 | 17 | def add_stylesheets 18 | inject_into_file 'vendor/assets/stylesheets/spree/backend/all.css', " *= require spree/backend/solidus_product_assembly\n", before: %r{\*/}, verbose: true # rubocop:disable Layout/LineLength 19 | end 20 | 21 | def add_migrations 22 | run 'bin/rails railties:install:migrations FROM=solidus_product_assembly' 23 | end 24 | 25 | def run_migrations 26 | run_migrations = options[:auto_run_migrations] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]')) # rubocop:disable Layout/LineLength 27 | if run_migrations 28 | run 'bin/rails db:migrate' 29 | else 30 | puts 'Skipping bin/rails db:migrate, don\'t forget to run it!' # rubocop:disable Rails/Output 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Spree Commerce Inc. and other contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name Solidus nor the names of its contributors may be used to 13 | endorse or promote products derived from this software without specific 14 | prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /solidus_product_assembly.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/solidus_product_assembly/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'solidus_product_assembly' 7 | spec.version = SolidusProductAssembly::VERSION 8 | spec.authors = ['Roman Smirnov'] 9 | spec.email = 'roman@railsdog.com' 10 | 11 | spec.summary = 'Make bundle of products to your Solidus store' 12 | spec.description = 'Make bundle of products to your Solidus store' 13 | spec.homepage = 'https://github.com/solidusio-contrib/solidus_product_assembly' 14 | spec.license = 'BSD-3-Clause' 15 | 16 | spec.metadata['homepage_uri'] = spec.homepage 17 | spec.metadata['source_code_uri'] = 'https://github.com/solidusio-contrib/solidus_product_assembly' 18 | spec.metadata['changelog_uri'] = 'https://github.com/solidusio-contrib/solidus_product_assembly/releases' 19 | 20 | spec.required_ruby_version = Gem::Requirement.new('>= 2.5', '< 4') 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") } 25 | 26 | spec.files = files.grep_v(%r{^(test|spec|features)/}) 27 | spec.test_files = files.grep(%r{^(test|spec|features)/}) 28 | spec.bindir = "exe" 29 | spec.executables = files.grep(%r{^exe/}) { |f| File.basename(f) } 30 | spec.require_paths = ["lib"] 31 | 32 | spec.add_dependency 'solidus_core', '>= 3.2' 33 | spec.add_dependency 'solidus_support', '~> 0.8' 34 | spec.add_dependency 'deface' 35 | 36 | spec.add_development_dependency 'solidus_dev_support', '~> 2.5' 37 | end 38 | -------------------------------------------------------------------------------- /app/models/spree/order_inventory_assembly.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | # This class has basically the same functionality of Spree core OrderInventory 5 | # except that it takes account of bundle parts and properly creates and removes 6 | # inventory unit for each parts of a bundle 7 | class OrderInventoryAssembly < OrderInventory 8 | attr_reader :product 9 | 10 | def initialize(line_item) 11 | @order = line_item.order 12 | @line_item = line_item 13 | @product = line_item.product 14 | end 15 | 16 | def verify(shipment = nil) 17 | if order.completed? || shipment.present? 18 | line_item.quantity_by_variant.each do |part, total_parts| 19 | existing_parts = line_item.inventory_units.where(variant: part).count 20 | 21 | self.variant = part 22 | 23 | if existing_parts < total_parts 24 | if method(:determine_target_shipment).arity == 1 25 | quantity = total_parts - existing_parts 26 | shipment = determine_target_shipment(quantity) unless shipment 27 | add_to_shipment(shipment, quantity) 28 | else 29 | shipment = determine_target_shipment unless shipment 30 | add_to_shipment(shipment, total_parts - existing_parts) 31 | end 32 | elsif existing_parts > total_parts 33 | quantity = existing_parts - total_parts 34 | if shipment.present? 35 | remove_from_shipment(shipment, quantity) 36 | else 37 | order.shipments.each do |shipment| 38 | break if quantity == 0 39 | 40 | quantity -= remove_from_shipment(shipment, quantity) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | branch = ENV.fetch('SOLIDUS_BRANCH', 'main') 7 | gem 'solidus', github: 'solidusio/solidus', branch: branch 8 | 9 | # The solidus_frontend gem has been pulled out since v3.2 10 | if branch >= 'v3.2' 11 | gem 'solidus_frontend' 12 | elsif branch == 'main' 13 | gem 'solidus_frontend', github: 'solidusio/solidus_frontend' 14 | else 15 | gem 'solidus_frontend', github: 'solidusio/solidus', branch: branch 16 | end 17 | 18 | # Needed to help Bundler figure out how to resolve dependencies, 19 | # otherwise it takes forever to resolve them. 20 | # See https://github.com/bundler/bundler/issues/6677 21 | gem 'rails', '>0.a' 22 | 23 | # Provides basic authentication functionality for testing parts of your engine 24 | gem 'solidus_auth_devise' 25 | 26 | gem 'friendly_id-globalize', github: 'norman/friendly_id-globalize', branch: "master" 27 | gem 'solidus_globalize', github: 'solidusio-contrib/solidus_globalize' 28 | 29 | case ENV.fetch('DB', nil) 30 | when 'mysql' 31 | gem 'mysql2' 32 | when 'postgresql' 33 | gem 'pg' 34 | else 35 | gem 'sqlite3' 36 | end 37 | 38 | # While we still support Ruby < 3 we need to workaround a limitation in 39 | # the 'async' gem that relies on the latest ruby, since RubyGems doesn't 40 | # resolve gems based on the required ruby version. 41 | gem 'async', '< 3' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3') 42 | 43 | gemspec 44 | 45 | # Use a local Gemfile to include development dependencies that might not be 46 | # relevant for the project or for other contributors, e.g. pry-byebug. 47 | # 48 | # We use `send` instead of calling `eval_gemfile` to work around an issue with 49 | # how Dependabot parses projects: https://github.com/dependabot/dependabot-core/issues/1658. 50 | send(:eval_gemfile, 'Gemfile-local') if File.exist? 'Gemfile-local' 51 | -------------------------------------------------------------------------------- /spec/models/spree/product_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Spree::Product do 6 | before do 7 | @product = FactoryBot.create(:product, name: "Foo Bar") 8 | @master_variant = Spree::Variant.where(is_master: true).find_by(product_id: @product.id) 9 | end 10 | 11 | describe ".search_can_be_part" do 12 | subject { described_class.search_can_be_part("matching") } 13 | 14 | let!(:name_matching_product) { create :product, can_be_part: true, name: "matching" } 15 | let!(:sku_matching_product) { create :product, can_be_part: true, master: variant } 16 | let(:variant) { create :master_variant, sku: "matching" } 17 | 18 | before do 19 | create :product, can_be_part: false, name: "matching" 20 | create :product, deleted_at: 1.day.ago, can_be_part: true, name: "matching" 21 | create :product, can_be_part: true, name: "Something else" 22 | end 23 | 24 | it "returns non-deleted products matching the search that can be parts" do 25 | expect(subject).to contain_exactly(name_matching_product, sku_matching_product) 26 | end 27 | end 28 | 29 | describe "Spree::Product Assembly" do 30 | before do 31 | @product = create(:product) 32 | @part1 = create(:product, can_be_part: true) 33 | @part2 = create(:product, can_be_part: true) 34 | @product.add_part @part1.master, 1 35 | @product.add_part @part2.master, 4 36 | end 37 | 38 | it "is an assembly" do 39 | expect(@product).to be_assembly 40 | end 41 | 42 | it "cannot be part" do 43 | expect(@product).to be_assembly 44 | @product.can_be_part = true 45 | expect(@product).not_to be_valid 46 | expect(@product.errors[:can_be_part]).to eq ["assembly can't be part"] 47 | end 48 | 49 | it 'changing part qty changes count on_hand' do 50 | @product.set_part_count(@part2.master, 2) 51 | expect(@product.count_of(@part2.master)).to eq 2 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/line_item_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module LineItemDecorator 6 | def self.prepended(base) 7 | base.class_eval do 8 | scope :assemblies, -> { joins(product: :parts).distinct } 9 | end 10 | end 11 | 12 | def any_units_shipped? 13 | inventory_units.any?(&:shipped?) 14 | end 15 | 16 | # The parts that apply to this particular LineItem. Usually `product#parts`, but 17 | # provided as a hook if you want to override and customize the parts for a specific 18 | # LineItem. 19 | delegate :parts, to: :product 20 | 21 | # The number of the specified variant that make up this LineItem. By default, calls 22 | # `product#count_of`, but provided as a hook if you want to override and customize 23 | # the parts available for a specific LineItem. Note that if you only customize whether 24 | # a variant is included in the LineItem, and don't customize the quantity of that part 25 | # per LineItem, you shouldn't need to override this method. 26 | delegate :count_of, to: :product 27 | 28 | def quantity_by_variant 29 | if product.assembly? 30 | {}.tap { |hash| product.assemblies_parts.each { |ap| hash[ap.part] = ap.count * quantity } } 31 | else 32 | { variant => quantity } 33 | end 34 | end 35 | 36 | private 37 | 38 | def update_inventory 39 | saved_changes = respond_to?(:saved_changes?) ? saved_changes? : changed? 40 | if (saved_changes || target_shipment.present?) && order.has_checkout_step?("delivery") 41 | if product.assembly? 42 | ::Spree::OrderInventoryAssembly.new(self).verify(target_shipment) 43 | else 44 | ::Spree::OrderInventory.new(order, self).verify(target_shipment) 45 | end 46 | end 47 | end 48 | 49 | ::Spree::LineItem.prepend self 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /app/views/spree/admin/parts/available.html.erb: -------------------------------------------------------------------------------- 1 | 8 | 9 |

<%= t('spree.available_parts') %>

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% @available_products.each do |product| %> 22 | 23 | 24 | 25 | 26 | 35 | 36 | 41 | 42 | <% end %> 43 | <% if @available_products.empty? %> 44 | 45 | <% end %> 46 | 47 |
<%= t('spree.name') %><%= t('spree.available_on') %><%= t('spree.options') %><%= t('spree.qty') %>
<%= product.name %><%= product.available_on %> 27 | <% if product.has_variants? %> 28 | <%= select_tag "part[id]", 29 | options_for_select(product.variants.map { |v| [variant_options(v), v.id] }) %> 30 | <% else %> 31 | <%= hidden_field_tag "part[id]", product.master.id %> 32 | <%= t(:no_variants) %> 33 | <% end %> 34 | <%= text_field_tag "part[count]", 1 %> 37 | <%= link_to(t('spree.select'), 38 | admin_product_parts_path(@product), 39 | :class => "add_product_part_link btn btn-primary") %> 40 |
<%= t('spree.no_match_found') %>.
48 | 49 | <%= javascript_tag do %> 50 | $("a.add_product_part_link").click(function(){ 51 | part_id_val = $('select option:selected', $(this).parent().parent()).val() || 52 | $('input:first', $(this).parent().parent()).val(); 53 | params = { part_count : $('input:last', $(this).parent().parent()).val(), 54 | part_id : part_id_val}; 55 | return make_post_request($(this), params); 56 | }); 57 | <% end %> 58 | -------------------------------------------------------------------------------- /app/views/spree/admin/parts/available.js.erb: -------------------------------------------------------------------------------- 1 | 8 |

<%= t('spree.available_parts') %>

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <% @available_products.each do |product| %> 21 | 22 | 23 | 24 | 25 | 34 | 35 | 40 | 41 | <% end %> 42 | <% if @available_products.empty? %> 43 | 44 | <% end %> 45 | 46 |
<%= t('spree.name') %><%= t('spree.available_on') %><%= t('spree.options') %><%= t('spree.qty') %>
<%= product.name %><%= product.available_on %> 26 | <% if product.has_variants? %> 27 | <%= select_tag "part[id]", 28 | options_for_select(product.variants.map { |v| [variant_options(v), v.id] }) %> 29 | <% else %> 30 | <%= hidden_field_tag "part[id]", product.master.id %> 31 | <%= t('spree.no_variants') %> 32 | <% end %> 33 | <%= text_field_tag "part[count]", 1 %> 36 | <%= link_to(icon('add') + ' ' + t('spree.select'), 37 | admin_product_parts_path(@product), 38 | :class => "add_product_part_link") %> 39 |
<%= t('spree.no_match_found') %>.
47 | 48 | <%= javascript_tag do %> 49 | $("a.add_product_part_link").click(function(){ 50 | part_id_val = $('select option:selected', $(this).parent().parent()).val() || 51 | $('input:first', $(this).parent().parent()).val(); 52 | params = { part_count : $('input:last', $(this).parent().parent()).val(), 53 | part_id : part_id_val}; 54 | return make_post_request($(this), params); 55 | }); 56 | <% end %> 57 | -------------------------------------------------------------------------------- /app/views/spree/admin/orders/_assemblies.html.erb: -------------------------------------------------------------------------------- 1 | <% if order.line_items.assemblies.any? %> 2 |
3 | 4 | <%= t('spree.product_bundles') %> 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | <% order.line_items.assemblies.each do |item| %> 28 | <%= content_tag :tr, class: 'line-item', id: "line-item-#{item.id}", data: { line_item_id: item.id } do %> 29 | 32 | 38 | 39 | 42 | 43 | <% end %> 44 | <% end %> 45 | 46 |
<%= Spree::Product.human_attribute_name(:name) %><%= Spree::LineItem.human_attribute_name(:price) %><%= Spree::LineItem.human_attribute_name(:quantity) %><%= Spree::LineItem.human_attribute_name(:total) %>
<%= render 'spree/shared/image', 30 | image: (item.variant.gallery.images.first || item.variant.product.gallery.images.first), 31 | size: :mini %> 33 | <%= item.variant.product.name %>
<%= "(" + variant_options(item.variant) + ")" unless item.variant.option_values.empty? %> 34 | <% if item.variant.sku.present? %> 35 | <%= Spree::Variant.human_attribute_name(:sku) %>: <%= item.variant.sku %> 36 | <% end %> 37 |
<%= item.single_money.to_html %> 40 | <%= item.quantity %> 41 | <%= line_item_shipment_price(item, item.quantity) %>
47 | <% end %> 48 | -------------------------------------------------------------------------------- /app/views/spree/admin/parts/index.html.erb: -------------------------------------------------------------------------------- 1 | <%# This partial was remove in solidus 1.2 but that is also when Spree.solidus_version was added %> 2 | <%= render :partial => 'spree/admin/shared/product_sub_menu' if !Spree.respond_to?(:solidus_version) %> 3 | 4 | <%= render :partial => 'spree/admin/shared/product_tabs', :locals => {:current => "Parts"} %> 5 |
6 | <%= render :partial => "parts_table", :locals => {:parts => @parts} %> 7 |
8 | 9 | <%= form_tag('#') do %> 10 | 11 | 12 | 13 | <% end %> 14 | 15 |
16 |
17 | <%= javascript_tag do %> 18 | /*! 19 | * Spree Product Assembly 20 | * https://github.com/spree/spree-product-assembly 21 | * 22 | */ 23 | 24 | function search_for_parts(){ 25 | $.ajax({ 26 | data: {q: $("#searchtext").val() }, 27 | dataType: 'html', 28 | success: function(request){ 29 | jQuery('#search_hits').html(request); 30 | $('#search_hits').show(); 31 | }, 32 | type: 'POST', 33 | url: '<%= available_admin_product_parts_url(@product) %>' 34 | }); 35 | } 36 | 37 | $("#searchtext").keypress(function (e) { 38 | if ((e.which && e.which == 13) || (e.keyCode && e.keyCode == 13)) { 39 | search_for_parts(); 40 | return false; 41 | } else { 42 | return true; 43 | } 44 | }); 45 | 46 | $("#search_parts_button").click(function(e) { 47 | e.preventDefault(); 48 | search_for_parts(); 49 | }); 50 | 51 | function subscribe_product_part_links() 52 | { 53 | $("a.set_count_admin_product_part_link").click(function(){ 54 | params = { count : $("input", $(this).parent().parent()).val() }; 55 | return make_post_request($(this), params); 56 | }); 57 | 58 | $("a.remove_admin_product_part_link").click(function(){ 59 | return make_post_request($(this), {}); 60 | }); 61 | } 62 | 63 | function make_post_request(link, post_params) 64 | { 65 | $.post(link.attr("href"), post_params, "script"); 66 | return false; 67 | } 68 | 69 | subscribe_product_part_links(); 70 | <% end -%> 71 | -------------------------------------------------------------------------------- /spec/models/spree/stock/coordinator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # Spree::Stock::Coordinator was refactored in Solidus to hide keep private 6 | # information about packages, we have to be sneaky to reach inside 7 | def inventory_units(coordinator) 8 | coordinator.shipments.flat_map(&:inventory_units) 9 | end 10 | 11 | module Spree 12 | module Stock 13 | coordinator_class = 14 | if Spree.solidus_gem_version < Gem::Version.new('2.4.x') 15 | Coordinator 16 | else 17 | SimpleCoordinator 18 | end 19 | describe coordinator_class do 20 | subject { described_class.new(order) } 21 | 22 | context "order shares variant as individual and within bundle" do 23 | include_context "product is ordered as individual and within a bundle" 24 | 25 | before { StockItem.update_all 'count_on_hand = 10' } 26 | 27 | context "bundle part requires more units than individual product" do 28 | before { order.contents.add(bundle_variant, 5) } 29 | 30 | let(:bundle_item_quantity) { order.find_line_item_by_variant(bundle_variant).quantity } 31 | 32 | it "calculates items quantity properly" do 33 | expected_units_on_package = order.line_items.to_a.sum(&:quantity) - bundle_item_quantity + (bundle.parts.count * bundle_item_quantity) 34 | 35 | expect(inventory_units(subject).size).to eql expected_units_on_package 36 | end 37 | end 38 | end 39 | 40 | context "multiple stock locations" do 41 | let!(:stock_locations) { (1..3).map { create(:stock_location) } } 42 | 43 | let(:order) { create(:order_with_line_items) } 44 | let(:parts) { (1..3).map { create(:variant) } } 45 | 46 | let(:bundle_variant) { order.variants.first } 47 | let(:bundle) { bundle_variant.product } 48 | 49 | let(:bundle_item_quantity) { order.find_line_item_by_variant(bundle_variant).quantity } 50 | 51 | before { bundle.parts << parts } 52 | 53 | it "haha" do 54 | expected_units_on_package = order.line_items.to_a.sum(&:quantity) - bundle_item_quantity + (bundle.parts.count * bundle_item_quantity) 55 | expect(inventory_units(subject).size).to eql expected_units_on_package 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | # Required for feature specs. 5 | browser-tools: circleci/browser-tools@1.1 6 | 7 | # Always take the latest version of the orb, this allows us to 8 | # run specs against Solidus supported versions only without the need 9 | # to change this configuration every time a Solidus version is released 10 | # or goes EOL. 11 | solidusio_extensions: solidusio/extensions@volatile 12 | 13 | jobs: 14 | run-specs: 15 | parameters: 16 | solidus: 17 | type: string 18 | default: main 19 | db: 20 | type: string 21 | default: "postgres" 22 | ruby: 23 | type: string 24 | default: "3.2" 25 | executor: 26 | name: solidusio_extensions/<< parameters.db >> 27 | ruby_version: << parameters.ruby >> 28 | steps: 29 | - checkout 30 | - browser-tools/install-chrome 31 | - solidusio_extensions/run-tests-solidus-<< parameters.solidus >> 32 | 33 | lint-code: 34 | executor: 35 | name: solidusio_extensions/sqlite 36 | ruby_version: "3.0" 37 | steps: 38 | - solidusio_extensions/lint-code 39 | 40 | workflows: 41 | "Run specs on supported Solidus versions": 42 | jobs: 43 | - run-specs: 44 | name: &name "run-specs-solidus-<< matrix.solidus >>-ruby-<< matrix.ruby >>-db-<< matrix.db >>" 45 | matrix: 46 | parameters: { solidus: ["main"], ruby: ["3.2"], db: ["postgres"] } 47 | - run-specs: 48 | name: *name 49 | matrix: 50 | parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] } 51 | - run-specs: 52 | name: *name 53 | matrix: 54 | parameters: { solidus: ["older"], ruby: ["3.0"], db: ["sqlite"] } 55 | - lint-code 56 | 57 | "Weekly run specs against master": 58 | triggers: 59 | - schedule: 60 | cron: "0 0 * * 4" # every Thursday 61 | filters: 62 | branches: 63 | only: 64 | - master 65 | jobs: 66 | - run-specs: 67 | name: *name 68 | matrix: 69 | parameters: { solidus: ["main"], ruby: ["3.2"], db: ["postgres"] } 70 | - run-specs: 71 | name: *name 72 | matrix: 73 | parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] } 74 | -------------------------------------------------------------------------------- /spec/models/spree/order_inventory_assembly_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe OrderInventoryAssembly do 7 | subject { described_class.new(line_item) } 8 | 9 | let(:order) { create(:order_with_line_items) } 10 | let(:line_item) { order.line_items.first } 11 | let(:bundle) { line_item.product } 12 | let(:parts) { (1..3).map { create(:variant) } } 13 | 14 | before do 15 | bundle.parts << [parts] 16 | bundle.set_part_count(parts.first, 3) 17 | 18 | line_item.update!(quantity: 3) 19 | order.reload.create_proposed_shipments 20 | allow(order).to receive(:completed?).and_return(true) 21 | end 22 | 23 | context "inventory units count" do 24 | it "calculates the proper value for the bundle" do 25 | expected_units_count = line_item.quantity * bundle.assemblies_parts.to_a.sum(&:count) 26 | expect(subject.inventory_units.count).to eql(expected_units_count) 27 | end 28 | end 29 | 30 | context "verify line item units" do 31 | let!(:original_units_count) { subject.inventory_units.count } 32 | 33 | context "quantity increases" do 34 | before { subject.line_item.quantity += 1 } 35 | 36 | it "inserts new inventory units for every bundle part" do 37 | expected_units_count = original_units_count + bundle.assemblies_parts.to_a.sum(&:count) 38 | subject.verify 39 | 40 | # needs to reload so that inventory units are fetched from the updated order.shipments 41 | updated_units_count = described_class.new(line_item.reload).inventory_units.count 42 | expect(updated_units_count).to eql(expected_units_count) 43 | end 44 | end 45 | 46 | context "quantity decreases" do 47 | before { subject.line_item.quantity -= 1 } 48 | 49 | it "remove inventory units for every bundle part" do 50 | expected_units_count = original_units_count - bundle.assemblies_parts.to_a.sum(&:count) 51 | subject.verify 52 | 53 | # needs to reload so that inventory units are fetched from the updated order.shipments 54 | updated_units_count = described_class.new(line_item.reload).inventory_units.count 55 | expect(updated_units_count).to eql(expected_units_count) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/models/spree/line_item_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe LineItem do 7 | let!(:order) { create(:order_with_line_items) } 8 | let(:line_item) { order.line_items.first } 9 | let(:product) { line_item.product } 10 | let(:variant) { line_item.variant } 11 | let(:inventory) { double('order_inventory') } 12 | 13 | context "bundle parts stock" do 14 | let(:parts) { (1..2).map { create(:variant) } } 15 | 16 | before { product.parts << parts } 17 | 18 | context "one of them not in stock" do 19 | before do 20 | part = product.parts.first 21 | part.stock_items.update_all backorderable: false 22 | 23 | expect(part).not_to be_in_stock 24 | end 25 | 26 | it "doesn't save line item quantity" do 27 | order.contents.add(variant, 10) 28 | expect { order.contents.advance }.to raise_error Spree::Order::InsufficientStock 29 | end 30 | end 31 | 32 | context "in stock" do 33 | before do 34 | parts.each do |part| 35 | part.stock_items.first.set_count_on_hand(10) 36 | end 37 | expect(parts[0]).to be_in_stock 38 | expect(parts[1]).to be_in_stock 39 | end 40 | 41 | it "saves line item quantity" do 42 | line_item = order.contents.add(variant, 10) 43 | expect(line_item).to be_valid 44 | end 45 | end 46 | end 47 | 48 | context "updates bundle product line item" do 49 | let(:parts) { (1..2).map { create(:variant) } } 50 | 51 | before do 52 | product.parts << parts 53 | order.create_proposed_shipments 54 | end 55 | 56 | it "verifies inventory units via OrderInventoryAssembly" do 57 | expect(OrderInventoryAssembly).to receive(:new).with(line_item).and_return(inventory) 58 | expect(inventory).to receive(:verify).with(line_item.target_shipment) 59 | line_item.quantity = 2 60 | line_item.save 61 | end 62 | end 63 | 64 | context "updates regular line item" do 65 | it "verifies inventory units via OrderInventory" do 66 | expect(OrderInventory).to receive(:new).with(line_item.order, line_item).and_return(inventory) 67 | expect(inventory).to receive(:verify).with(line_item.target_shipment) 68 | line_item.quantity = 2 69 | line_item.save 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/models/spree/stock/availability_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | module Stock 7 | describe AvailabilityValidator, type: :model do 8 | context "line item has no parts" do 9 | subject { described_class.new } 10 | 11 | let!(:line_item) { create(:line_item, quantity: 5) } 12 | let!(:product) { line_item.product } 13 | 14 | it 'delegates to the original Solidus implementation' do 15 | expect(subject).to receive(:is_valid?).with(line_item) 16 | subject.validate(line_item) 17 | end 18 | end 19 | 20 | context "line item has parts" do 21 | subject { described_class.new } 22 | 23 | let!(:order) { create(:order_with_line_items) } 24 | let(:line_item) { order.line_items.first } 25 | let(:product) { line_item.product } 26 | let(:variant) { line_item.variant } 27 | let(:parts) { (1..2).map { create(:variant) } } 28 | 29 | before { product.parts << parts } 30 | 31 | it 'is valid when supply of all parts is sufficient' do 32 | allow_any_instance_of(Stock::Quantifier).to receive(:can_supply?).and_return(true) 33 | expect(line_item).not_to receive(:errors) 34 | subject.validate(line_item) 35 | end 36 | 37 | it 'is invalid when supplies of all parts are insufficent' do 38 | allow_any_instance_of(Stock::Quantifier).to receive(:can_supply?).and_return(false) 39 | expect(line_item.errors).to receive(:[]).exactly(line_item.parts.size).times.with(:quantity).and_return([]) 40 | subject.validate(line_item) 41 | end 42 | 43 | it 'is invalid when supply of 1 part is insufficient' do 44 | allow_any_instance_of(Stock::Quantifier).to receive(:can_supply?).and_return(false) 45 | create_list(:inventory_unit, 5, line_item: line_item, variant: line_item.parts.first, order: order, shipment: order.shipments.first) 46 | expect(line_item.errors).to receive(:[]).once.with(:quantity).and_return([]) 47 | subject.validate(line_item) 48 | end 49 | 50 | it 'is valid when supply of each part is sufficient' do 51 | line_item.parts.each do |part| 52 | allow(part).to receive(:inventory_units).and_return([double(variant: part)] * 5) 53 | end 54 | expect(line_item).not_to receive(:errors) 55 | subject.validate(line_item) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/shipment_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module ShipmentDecorator 6 | # Overriden from Spree core as a product bundle part should not be put 7 | # together with an individual product purchased (even though they're the 8 | # very same variant) That is so we can tell the store admin which units 9 | # were purchased individually and which ones as parts of the bundle 10 | # 11 | # Account for situations where we can't track the line_item for a variant. 12 | # This should avoid exceptions when users upgrade from spree 1.3 13 | # 14 | # TODO Can possibly be removed as well. We already override the manifest 15 | # partial so we can get the product there 16 | def manifest 17 | items = [] 18 | inventory_units.joins(:variant).includes(:variant, :line_item).group_by(&:variant).each do |variant, units| 19 | units.group_by(&:line_item).each do |line_item, units| 20 | states = {} 21 | units.group_by(&:state).each { |state, iu| states[state] = iu.count } 22 | line_item ||= order.find_line_item_by_variant(variant) 23 | 24 | part = line_item ? line_item.product.assembly? : false 25 | items << OpenStruct.new(part: part, 26 | product: line_item.try(:product), 27 | line_item: line_item, 28 | variant: variant, 29 | quantity: units.length, 30 | states: states) 31 | end 32 | end 33 | items 34 | end 35 | 36 | # There might be scenarios where we don't want to display every single 37 | # variant on the shipment. e.g. when ordering a product bundle that includes 38 | # 5 other parts. Frontend users should only see the product bundle as a 39 | # single item to ship 40 | def line_item_manifest 41 | inventory_units.includes(:line_item, :variant).group_by(&:line_item).map do |line_item, units| 42 | states = {} 43 | units.group_by(&:state).each { |state, iu| states[state] = iu.count } 44 | OpenStruct.new(line_item: line_item, variant: line_item.variant, quantity: units.length, states: states) 45 | end 46 | end 47 | 48 | def inventory_units_for_item(line_item, variant) 49 | inventory_units.where(line_item_id: line_item.id, variant_id: variant.id) 50 | end 51 | 52 | ::Spree::Shipment.prepend self 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_product_assembly/spree/product_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusProductAssembly 4 | module Spree 5 | module ProductDecorator 6 | def self.prepended(base) 7 | base.class_eval do 8 | has_and_belongs_to_many :parts, class_name: "Spree::Variant", 9 | join_table: "spree_assemblies_parts", 10 | foreign_key: "assembly_id", association_foreign_key: "part_id" 11 | 12 | has_many :assemblies_parts, class_name: "Spree::AssembliesPart", 13 | foreign_key: "assembly_id" 14 | 15 | scope :individual_saled, -> { where(individual_sale: true) } 16 | 17 | if defined?(SolidusGlobalize) 18 | scope :search_can_be_part, ->(query){ 19 | not_deleted.available.joins(:master) 20 | .joins(:translations) 21 | .where( 22 | ::Spree::Product::Translation.arel_table["name"].matches(query) 23 | .or(::Spree::Variant.arel_table["sku"].matches(query)) 24 | ) 25 | .where(can_be_part: true) 26 | .limit(30) 27 | } 28 | else 29 | scope :search_can_be_part, ->(query){ 30 | not_deleted.available.joins(:master) 31 | .where(arel_table["name"].matches(query).or(::Spree::Variant.arel_table["sku"].matches(query))) 32 | .where(can_be_part: true) 33 | .limit(30) 34 | } 35 | end 36 | 37 | validate :assembly_cannot_be_part, if: :assembly? 38 | end 39 | end 40 | 41 | def add_part(variant, count = 1) 42 | set_part_count(variant, count_of(variant) + count) 43 | end 44 | 45 | def remove_part(variant) 46 | set_part_count(variant, 0) 47 | end 48 | 49 | def set_part_count(variant, count) 50 | ap = assemblies_part(variant) 51 | if count > 0 52 | ap.count = count 53 | ap.save 54 | else 55 | ap.destroy 56 | end 57 | reload 58 | end 59 | 60 | def assembly? 61 | parts.present? 62 | end 63 | 64 | def count_of(variant) 65 | ap = assemblies_part(variant) 66 | # This checks persisted because the default count is 1 67 | ap.persisted? ? ap.count : 0 68 | end 69 | 70 | def assembly_cannot_be_part 71 | errors.add(:can_be_part, I18n.t('spree.assembly_cannot_be_part')) if can_be_part 72 | end 73 | 74 | private 75 | 76 | def assemblies_part(variant) 77 | ::Spree::AssembliesPart.get(id, variant.id) 78 | end 79 | 80 | ::Spree::Product.prepend self 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /app/views/spree/admin/orders/_stock_item.html.erb: -------------------------------------------------------------------------------- 1 | <% shipment.manifest.each do |item| %> 2 | 3 | <%= render 'spree/shared/image', 4 | image: (item.variant.gallery.images.first || item.variant.product.gallery.images.first), 5 | size: :mini %> 6 | 7 | <%= link_to item.variant.product.name, edit_admin_product_path(item.variant.product) %>
<%= "(" + variant_options(item.variant) + ")" unless item.variant.option_values.empty? %> 8 | <% if item.part && item.line_item %> 9 | <%= t('spree.part_of_bundle', sku: item.product.sku) %> 10 | <% elsif item.variant.sku.present? %> 11 | <%= Spree::Variant.human_attribute_name(:sku) %>: <%= item.variant.sku %> 12 | <% end %> 13 | 14 | 15 | <% if item.part %> 16 | --- 17 | <% else %> 18 | <%= item.line_item.single_money.to_html if item.line_item %> 19 | <% end %> 20 | 21 | 22 | <% item.states.each do |state,count| %> 23 | <%= count %> x <%= state.humanize.downcase %> 24 | <% end %> 25 | 26 | <% unless shipment.shipped? %> 27 | 28 | <%= number_field_tag :quantity, item.quantity, :min => 0, :class => "line_item_quantity", :size => 5 %> 29 | 30 | <% end %> 31 | 32 | <% if item.part %> 33 | --- 34 | <% else %> 35 | <%= line_item_shipment_price(item.line_item, item.quantity) if item.line_item %> 36 | <% end %> 37 | 38 | 39 | <% if !shipment.shipped? %> 40 | <% if can? :update, item %> 41 | <%= button_tag '', :class => 'save-item fa fa-ok no-text with-tip', :data => {'shipment-number' => shipment.number, 'variant-id' => item.variant.id, :action => 'save'}, :title => t('spree.actions.save'), :style => 'display: none' %> 42 | <%= link_to '', :class => 'cancel-item fa fa-cancel no-text with-tip', :data => {:action => 'cancel'}, :title => t('spree.actions.cancel'), :style => 'display: none' %> 43 | <%= button_tag '', :class => 'split-item fa fa-arrows-h no-text with-tip', :data => {:action => 'split', 'variant-id' => item.variant.id}, :title => t('spree.actions.split') %> 44 | <%= button_tag '', :class => 'delete-item fa fa-trash no-text with-tip', :data => {'shipment-number' => shipment.number, 'variant-id' => item.variant.id, :action => 'remove'}, :title => t('spree.actions.delete') %> 45 | <% end %> 46 | <% end %> 47 | 48 | 49 | <% end %> 50 | -------------------------------------------------------------------------------- /bin/sandbox: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | if [ ! -z $DEBUG ] 5 | then 6 | set -x 7 | fi 8 | 9 | case "$DB" in 10 | postgres|postgresql) 11 | RAILSDB="postgresql" 12 | ;; 13 | mysql) 14 | RAILSDB="mysql" 15 | ;; 16 | sqlite3|sqlite) 17 | RAILSDB="sqlite3" 18 | ;; 19 | '') 20 | echo "~~> Use 'export DB=[postgres|mysql|sqlite]' to control the DB adapter" 21 | RAILSDB="sqlite3" 22 | ;; 23 | *) 24 | echo "Invalid value specified for the Solidus sandbox: DB=\"$DB\"." 25 | echo "Please use 'postgres', 'mysql', or 'sqlite' instead." 26 | exit 1 27 | ;; 28 | esac 29 | echo "~~> Using $RAILSDB as the database engine" 30 | 31 | if [ -n $SOLIDUS_BRANCH ] 32 | then 33 | BRANCH=$SOLIDUS_BRANCH 34 | else 35 | echo "~~> Use 'export SOLIDUS_BRANCH=[master|v3.2|...]' to control the Solidus branch" 36 | BRANCH="master" 37 | fi 38 | echo "~~> Using branch $BRANCH of solidus" 39 | 40 | if [ -z $SOLIDUS_FRONTEND ] 41 | then 42 | echo "~~> Use 'export SOLIDUS_FRONTEND=[solidus_frontend|solidus_starter_frontend]' to control the Solidus frontend" 43 | SOLIDUS_FRONTEND="solidus_frontend" 44 | fi 45 | echo "~~> Using branch $SOLIDUS_FRONTEND as the solidus frontend" 46 | 47 | extension_name="solidus_product_assembly" 48 | 49 | # Stay away from the bundler env of the containing extension. 50 | function unbundled { 51 | ruby -rbundler -e'b = proc {system *ARGV}; Bundler.respond_to?(:with_unbundled_env) ? Bundler.with_unbundled_env(&b) : Bundler.with_clean_env(&b)' -- $@ 52 | } 53 | 54 | rm -rf ./sandbox 55 | unbundled bundle exec rails new sandbox --database="$RAILSDB" \ 56 | --skip-bundle \ 57 | --skip-git \ 58 | --skip-keeps \ 59 | --skip-rc \ 60 | --skip-spring \ 61 | --skip-test \ 62 | --skip-javascript 63 | 64 | if [ ! -d "sandbox" ]; then 65 | echo 'sandbox rails application failed' 66 | exit 1 67 | fi 68 | 69 | cd ./sandbox 70 | cat <> Gemfile 71 | gem 'solidus', github: 'solidusio/solidus', branch: '$BRANCH' 72 | gem 'rails-i18n' 73 | gem 'solidus_i18n' 74 | 75 | gem '$extension_name', path: '..' 76 | 77 | group :test, :development do 78 | platforms :mri do 79 | gem 'pry-byebug' 80 | end 81 | end 82 | RUBY 83 | 84 | unbundled bundle install --gemfile Gemfile 85 | 86 | unbundled bundle exec rake db:drop db:create 87 | 88 | unbundled bundle exec rails generate solidus:install \ 89 | --auto-accept \ 90 | --user_class=Spree::User \ 91 | --enforce_available_locales=true \ 92 | --with-authentication=true \ 93 | --payment-method=none \ 94 | --frontend=${SOLIDUS_FRONTEND} \ 95 | $@ 96 | 97 | unbundled bundle exec rails generate solidus:auth:install --auto-run-migrations 98 | unbundled bundle exec rails generate ${extension_name}:install --auto-run-migrations 99 | 100 | echo 101 | echo "🚀 Sandbox app successfully created for $extension_name!" 102 | echo "🧪 This app is intended for test purposes." 103 | -------------------------------------------------------------------------------- /app/views/spree/admin/orders/_stock_contents_2_3.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'stock_item', shipment: shipment %> 2 | 3 | <% unless shipment.shipped? %> 4 | 5 | 6 |
7 | <%= label_tag 'selected_shipping_rate_id', t('spree.shipping_method') %> 8 | <%= select_tag :selected_shipping_rate_id, 9 | options_for_select(shipment.shipping_rates.map {|sr| ["#{sr.name} #{sr.display_price}", sr.id] }, shipment.selected_shipping_rate_id), 10 | {:class => 'select2 fullwidth', :data => {'shipment-number' => shipment.number } } %> 11 |
12 | 13 | 14 | <% if can? :update, shipment %> 15 | <%= link_to '', '#', :class => 'save-method fa fa-ok no-text with-tip', 16 | :data => {'shipment-number' => shipment.number, :action => 'save'}, title: t('spree.actions.save') %> 17 | <%= link_to '', '#', :class => 'cancel-method fa fa-cancel no-text with-tip', 18 | :data => {:action => 'cancel'}, :title => t('spree.actions.cancel') %> 19 | <% end %> 20 | 21 | 22 | <% end %> 23 | 24 | 25 | <% if shipment.shipping_method %> 26 | 27 | <%= shipment.shipping_method.name %> 28 | 29 | 30 | <%= shipment.display_cost %> 31 | 32 | <% else %> 33 | <%= t('spree.no_shipping_method_selected') %> 34 | <% end %> 35 | 36 | 37 | <% if can? :update, shipment %> 38 | <%= link_to '', '#', :class => 'edit-method fa fa-edit no-text with-tip', :data => {:action => 'edit'}, :title => t('spree.actions.edit') %> 39 | <% end %> 40 | 41 | 42 | 43 | 44 | 45 | 46 | <%= text_field_tag :tracking, shipment.tracking %> 47 | 48 | 49 | <% if can? :update, shipment %> 50 | <%= link_to '', '#', :class => 'save-tracking fa fa-ok no-text with-tip', :data => {'shipment-number' => shipment.number, :action => 'save'}, :title => t('spree.actions.save') %> 51 | <%= link_to '', '#', :class => 'cancel-tracking fa fa-cancel no-text with-tip', :data => {:action => 'cancel'}, :title => t('spree.actions.cancel') %> 52 | <% end %> 53 | 54 | 55 | 56 | 57 | 58 | <% if shipment.tracking.present? %> 59 | <%= shipment.tracking %> 60 | <% else %> 61 | <%= t('spree.no_tracking_present') %> 62 | <% end %> 63 | 64 | 65 | <% if can? :update, shipment %> 66 | <%= link_to '', '#', :class => 'edit-tracking fa fa-edit no-text with-tip', :data => {:action => 'edit'}, :title => t('spree.actions.edit') %> 67 | <% end %> 68 | 69 | 70 | -------------------------------------------------------------------------------- /app/views/spree/admin/orders/_stock_contents_2_4.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'stock_item', shipment: shipment %> 2 | 3 | <% unless shipment.shipped? %> 4 | 5 | 6 |
7 | <%= label_tag 'selected_shipping_rate_id', t('spree.shipping_method') %> 8 | <%= select_tag :selected_shipping_rate_id, 9 | options_for_select(shipment.shipping_rates.map {|sr| ["#{sr.name} #{sr.display_price}", sr.id] }, shipment.selected_shipping_rate_id), 10 | {:class => 'select2 fullwidth', :data => {'shipment-number' => shipment.number } } %> 11 |
12 | 13 | 14 | <% if can? :update, shipment %> 15 | <%= button_tag '', :class => 'save-method fa fa-ok no-text with-tip', 16 | :data => {'shipment-number' => shipment.number, :action => 'save'}, title: t('spree.actions.save') %> 17 | <%= button_tag '', :class => 'cancel-method fa fa-cancel no-text with-tip', 18 | :data => {:action => 'cancel'}, :title => t('spree.actions.cancel') %> 19 | <% end %> 20 | 21 | 22 | <% end %> 23 | 24 | 25 | <% if shipment.shipping_method %> 26 | 27 | <%= shipment.shipping_method.name %> 28 | 29 | 30 | <%= shipment.display_cost %> 31 | 32 | <% else %> 33 | <%= t('spree.no_shipping_method_selected') %> 34 | <% end %> 35 | 36 | 37 | <% if can? :update, shipment %> 38 | <%= button_tag '', :class => 'edit-method fa fa-edit no-text with-tip', :data => {:action => 'edit'}, :title => t('spree.actions.edit') %> 39 | <% end %> 40 | 41 | 42 | 43 | 44 | 45 | 46 | <%= text_field_tag :tracking, shipment.tracking %> 47 | 48 | 49 | <% if can? :update, shipment %> 50 | <%= button_tag '', :class => 'save-tracking fa fa-ok no-text with-tip', :data => {'shipment-number' => shipment.number, :action => 'save'}, :title => t('spree.actions.save') %> 51 | <%= button_tag '', :class => 'cancel-tracking fa fa-cancel no-text with-tip', :data => {:action => 'cancel'}, :title => t('spree.actions.cancel') %> 52 | <% end %> 53 | 54 | 55 | 56 | 57 | <%= Spree::Shipment.human_attribute_name(:tracking) %> 58 | 59 | <% if shipment.tracking.present? %> 60 | <%= shipment.tracking %> 61 | <% else %> 62 | <%= t('spree.no_tracking_present') %> 63 | <% end %> 64 | 65 | 66 | <% if can? :update, shipment %> 67 | <%= button_tag '', class: 'edit-tracking fa fa-edit no-text with-tip', data: {action: 'edit'}, title: t('spree.actions.edit'), type: :button %> 68 | <% end %> 69 | 70 | 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solidus Product Assembly 2 | 3 | [![CircleCI](https://circleci.com/gh/solidusio-contrib/solidus_product_assembly.svg?style=shield)](https://circleci.com/gh/solidusio-contrib/solidus_product_assembly) 4 | [![codecov](https://codecov.io/gh/solidusio-contrib/solidus_product_assembly/branch/master/graph/badge.svg)](https://codecov.io/gh/solidusio-contrib/solidus_product_assembly) 5 | 6 | Create a product which is composed of other products. 7 | 8 | ## Installation 9 | 10 | Add solidus_product_assembly to your Gemfile: 11 | 12 | ```ruby 13 | gem 'solidus_product_assembly' 14 | ``` 15 | 16 | Bundle your dependencies and run the installation generator: 17 | 18 | ```shell 19 | bin/rails generate solidus_product_assembly:install 20 | ``` 21 | 22 | ## Usage 23 | 24 | To build a bundle (assembly product) you'd need to first check the "Can be part" 25 | flag on each product you want to be part of the bundle. Then create a product 26 | and add parts to it. By doing that you're making that product an assembly. 27 | 28 | The store will treat assemblies a bit different than regular products on checkout. 29 | Spree will create and track inventory units for its parts rather than for the product itself. 30 | That means you essentially have a product composed of other products. From a 31 | customer perspective it's like they are paying a single amount for a collection 32 | of products. 33 | 34 | ## Development 35 | 36 | ### Testing the extension 37 | 38 | First bundle your dependencies, then run `bin/rake`. `bin/rake` will default to building the dummy 39 | app if it does not exist, then it will run specs. The dummy app can be regenerated by using 40 | `bin/rake extension:test_app`. 41 | 42 | ```shell 43 | bin/rake 44 | ``` 45 | 46 | To run [Rubocop](https://github.com/bbatsov/rubocop) static code analysis run 47 | 48 | ```shell 49 | bundle exec rubocop 50 | ``` 51 | 52 | When testing your application's integration with this extension you may use its factories. 53 | Simply add this require statement to your `spec/spec_helper.rb`: 54 | 55 | ```ruby 56 | require 'solidus_product_assembly/testing_support/factories' 57 | ``` 58 | 59 | Or, if you are using `FactoryBot.definition_file_paths`, you can load Solidus core 60 | factories along with this extension's factories using this statement: 61 | 62 | ```ruby 63 | SolidusDevSupport::TestingSupport::Factories.load_for(SolidusProductAssembly::Engine) 64 | ``` 65 | 66 | ### Running the sandbox 67 | 68 | To run this extension in a sandboxed Solidus application, you can run `bin/sandbox`. The path for 69 | the sandbox app is `./sandbox` and `bin/rails` will forward any Rails commands to 70 | `sandbox/bin/rails`. 71 | 72 | Here's an example: 73 | 74 | ``` 75 | $ bin/rails server 76 | => Booting Puma 77 | => Rails 6.0.2.1 application starting in development 78 | * Listening on tcp://127.0.0.1:3000 79 | Use Ctrl-C to stop 80 | ``` 81 | 82 | ### Updating the changelog 83 | 84 | Before and after releases the changelog should be updated to reflect the up-to-date status of 85 | the project: 86 | 87 | ```shell 88 | bin/rake changelog 89 | git add CHANGELOG.md 90 | git commit -m "Update the changelog" 91 | ``` 92 | 93 | ### Releasing new versions 94 | 95 | Please refer to the dedicated [page](https://github.com/solidusio/solidus/wiki/How-to-release-extensions) on Solidus wiki. 96 | 97 | ## Contributing 98 | 99 | Spree is an open source project and we encourage contributions. Please see the [Community Guidelines](https://solidus.io/community-guidelines/) before contributing. 100 | 101 | In the spirit of [free software](http://www.fsf.org/licensing/essays/free-sw.html), **everyone** is encouraged to help improve this project. 102 | 103 | ## License 104 | 105 | Copyright (c) 2014 [Spree Commerce Inc.](https://github.com/spree) and [contributors](https://github.com/spree/spree-product-assembly/graphs/contributors), released under the [New BSD License](LICENSE) 106 | -------------------------------------------------------------------------------- /spec/models/spree/shipment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Spree 6 | describe Shipment do 7 | context "order has one product assembly" do 8 | let!(:store) { create(:store) } 9 | let(:order) { Order.create } 10 | let(:bundle) { create(:variant) } 11 | let!(:parts) { (1..2).map { create(:variant) } } 12 | let!(:bundle_parts) { bundle.product.parts << parts } 13 | 14 | let!(:line_item) { order.contents.add(bundle, 1) } 15 | let!(:shipment) { order.create_proposed_shipments.first } 16 | 17 | before do 18 | order.update_column :state, 'complete' 19 | end 20 | 21 | it "shipment item cost equals line item amount" do 22 | expect(shipment.item_cost).to eq line_item.amount 23 | end 24 | end 25 | 26 | context "manifests" do 27 | include_context "product is ordered as individual and within a bundle" 28 | 29 | let(:shipments) { order.create_proposed_shipments } 30 | 31 | context "default" do 32 | let(:expected_variants) { order.variants - [bundle_variant] + bundle.parts } 33 | before {shipments.first.save } 34 | 35 | it "separates variant purchased individually from the bundle one" do 36 | expect(shipments.count).to be 1 37 | expect(shipments.first.manifest.map(&:variant)).to match_array expected_variants 38 | end 39 | end 40 | 41 | context "line items manifest" do 42 | let(:expected_variants) { order.variants } 43 | before { shipments.first.save } 44 | 45 | it "groups units by line_item only" do 46 | expect(shipments.count).to be 1 47 | expect(shipments.first.line_item_manifest.map(&:variant)).to match_array expected_variants 48 | end 49 | end 50 | 51 | context "units are not associated with a line item" do 52 | let(:order) { create(:shipped_order) } 53 | let(:shipment) { order.shipments.first } 54 | 55 | it "searches for line item if inventory unit doesn't have one" do 56 | expect(shipment.manifest.last.line_item).not_to be_blank 57 | end 58 | end 59 | end 60 | 61 | context "set up new inventory units" do 62 | let(:line_item) { create(:line_item) } 63 | let(:variant) { line_item.variant } 64 | let(:order) { line_item.order } 65 | let(:shipment) { create(:shipment, order: order) } 66 | 67 | it "assigns variant, order and line_item" do 68 | unit = shipment.set_up_inventory('on_hand', variant, order, line_item) 69 | 70 | expect(unit.line_item).to eq line_item 71 | expect(unit.variant).to eq variant 72 | expect(unit.order).to eq order 73 | expect(unit.state).to eq 'on_hand' 74 | end 75 | end 76 | 77 | context "unit states for variant sold as part of an assembly and separately" do 78 | let(:assembly_line_item) { create(:line_item) } 79 | let(:shirt) { create(:variant) } 80 | 81 | let(:assembly_shirts) do 82 | 5.times.map { 83 | create(:inventory_unit, 84 | variant: shirt, 85 | line_item: assembly_line_item, 86 | state: :on_hand) 87 | } 88 | end 89 | 90 | let(:standalone_line_item) { create(:line_item, variant: shirt) } 91 | 92 | let(:standalone_shirts) do 93 | 2.times.map { 94 | create(:inventory_unit, 95 | variant: shirt, 96 | line_item: standalone_line_item, 97 | state: :on_hand) 98 | } 99 | end 100 | 101 | let(:shipment) { create(:shipment) } 102 | 103 | before do 104 | shipment.inventory_units << assembly_shirts 105 | shipment.inventory_units << standalone_shirts 106 | end 107 | 108 | it "set states numbers properly for all items" do 109 | shipment.manifest.each do |item| 110 | if item.line_item.id == standalone_line_item.id 111 | expect(item.states["on_hand"]).to eq standalone_shirts.count 112 | else 113 | expect(item.states["on_hand"]).to eq assembly_shirts.count 114 | end 115 | end 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/features/admin/orders_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe "Orders", type: :feature, js: true do 6 | stub_authorization! 7 | 8 | context 'when the order product is a bundle' do 9 | let(:order) { create(:order_with_line_items) } 10 | let(:line_item) { order.line_items.first } 11 | let(:bundle) { line_item.product } 12 | let(:parts) { (1..3).map { create(:variant) } } 13 | 14 | before do 15 | bundle.parts << [parts] 16 | line_item.update!(quantity: 3) 17 | order.reload.create_proposed_shipments 18 | end 19 | 20 | if Spree.solidus_gem_version < Gem::Version.new('2.5') 21 | context 'Adding tracking number' do 22 | let(:tracking_number) { 'AA123' } 23 | 24 | before { visit spree.edit_admin_order_path(order) } 25 | 26 | it 'allows admin to edit tracking' do 27 | expect(page).not_to have_content tracking_number 28 | within '.show-tracking' do 29 | find('.fa-edit').click 30 | end 31 | within '.edit-tracking' do 32 | expect(page).to have_selector 'input[name="tracking"]' 33 | fill_in :tracking, with: tracking_number 34 | find('.fa-ok').click 35 | expect(page).not_to have_selector 'input[name="tracking"]' 36 | end 37 | expect(page).to have_content tracking_number 38 | end 39 | end 40 | 41 | context 'Changing carrier/shipping costs' do 42 | before do 43 | visit spree.edit_admin_order_path(order) 44 | end 45 | 46 | it 'allows admin to edit shipping costs' do 47 | expect(page).not_to have_content 'UPS Ground $100.00' 48 | within '.show-method' do 49 | find('.fa-edit').click 50 | end 51 | within '.edit-method' do 52 | find('.fa-ok').click 53 | end 54 | expect(page).to have_content 'UPS Ground $100.00' 55 | end 56 | end 57 | else 58 | context 'Adding tracking number' do 59 | let(:tracking_number) { 'AA123' } 60 | 61 | before { visit spree.edit_admin_order_path(order) } 62 | 63 | it 'allows admin to edit tracking' do 64 | within '.edit-tracking' do 65 | expect(page).not_to have_content tracking_number 66 | find('.js-edit').click 67 | expect(page).to have_selector 'input[name="tracking"]' 68 | fill_in :tracking, with: tracking_number 69 | find('.js-save').click 70 | expect(page).not_to have_selector 'input[name="tracking"]' 71 | expect(page).to have_content tracking_number 72 | end 73 | end 74 | end 75 | 76 | context 'Changing carrier/shipping costs' do 77 | let(:carrier_name) { 'Fedex' } 78 | let(:select_name) { 'selected_shipping_method_id' } 79 | 80 | before do 81 | create :shipping_method, name: carrier_name 82 | visit spree.edit_admin_order_path(order) 83 | end 84 | 85 | it 'allows admin to edit shipping costs' do 86 | within '.edit-shipping-method' do 87 | expect(page).not_to have_content carrier_name 88 | find('.js-edit').click 89 | expect(page).to have_selector "select[name='#{select_name}']" 90 | select "#{carrier_name} $10.00", from: select_name 91 | find('.js-save').click 92 | expect(page).not_to have_selector "select[name='#{select_name}']" 93 | expect(page).to have_content carrier_name 94 | end 95 | end 96 | end 97 | end 98 | end 99 | 100 | context 'when the order product is not a bundle' do 101 | let(:order) { create(:order_ready_to_ship, state: 'complete', line_items_count: 1) } 102 | let(:product) { order.products.first } 103 | let(:location_name) { 'Foobar warehouse' } 104 | 105 | before { create :stock_location, name: location_name } 106 | 107 | context 'Splitting items' do 108 | before { visit spree.edit_admin_order_path(order) } 109 | 110 | it 'allows admin to split items to another stock location' do 111 | find('.split-item').click 112 | within '.stock-item-split' do 113 | expect(page).to have_content "Move #{product.name} to" 114 | find('.save-split').click 115 | alert_text = page.driver.browser.switch_to.alert.text 116 | expect(alert_text).to include 'Please select the split destination' 117 | page.driver.browser.switch_to.alert.accept 118 | find('.select2-container').click 119 | find(:xpath, '//body').all('.select2-drop li.select2-result', text: "#{location_name} (0 on hand)")[0].click 120 | find('.save-split').click 121 | end 122 | expect(page).to have_content /Pending package from '#{location_name}'/i 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/features/admin/return_items_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe "Return Items", type: :feature, js: true do 6 | stub_authorization! 7 | 8 | let(:line_item) { order.line_items.first } 9 | 10 | before do 11 | allow_any_instance_of(Spree::Admin::ReimbursementsController).to receive(:try_spree_current_user) { Spree.user_class.new } 12 | end 13 | 14 | context 'when the order product is a bundle' do 15 | let(:bundle) { line_item.product } 16 | let(:parts) { (1..3).map { create(:variant) } } 17 | 18 | before do 19 | create :refund_reason, name: Spree::RefundReason::RETURN_PROCESSING_REASON, mutable: false 20 | bundle.parts << [parts] 21 | bundle.set_part_count(parts.first, 2) 22 | parts.each { |p| p.stock_items.first.set_count_on_hand 4 } 23 | 3.times { order.next } 24 | create :payment, order: order, amount: order.amount 25 | order.next 26 | order.complete! 27 | order.payments.each(&:capture!) 28 | order.shipments.each { |s| s.update state: :ready } 29 | order.shipments.each(&:ship!) 30 | end 31 | 32 | context 'with a single item cart' do 33 | let(:order) { create(:order_with_line_items) } 34 | 35 | it 'successfully generates a RMA, a reimbursement and a refund' do 36 | visit spree.edit_admin_order_path(order) 37 | 38 | expect(page).to have_selector '.line-item-total', text: '$10.00' 39 | 40 | click_link 'RMA' 41 | click_link 'New RMA' 42 | 43 | expect(page).to have_selector '.return-item-charged', text: '$2.50', count: 4 44 | 45 | find('#select-all').click 46 | select Spree::StockLocation.first.name, from: 'Stock Location' 47 | click_button 'Create' 48 | 49 | expect(page).to have_content 'Return Authorization has been successfully created!' 50 | 51 | click_link 'Customer Returns' 52 | click_link 'New Customer Return' 53 | find('#select-all').click 54 | page.execute_script <<~JS 55 | $('option[value="receive"]').attr('selected', 'selected'); 56 | JS 57 | select Spree::StockLocation.first.name, from: 'Stock Location' 58 | click_button 'Create' 59 | 60 | expect(page).to have_content 'Customer Return has been successfully created!' 61 | 62 | click_button 'Create reimbursement' 63 | click_button 'Reimburse' 64 | 65 | within 'table.reimbursement-refunds' do 66 | expect(page).to have_content '$10.00' 67 | end 68 | end 69 | end 70 | 71 | context 'with a multiple items cart' do 72 | let(:order) { create(:order_with_line_items, line_items_attributes: [{ quantity: 2 }]) } 73 | 74 | it 'successfully generates a RMA, a reimbursement and a refund' do 75 | visit spree.edit_admin_order_path(order) 76 | 77 | expect(page).to have_selector '.line-item-total', text: '$20.00' 78 | 79 | click_link 'RMA' 80 | click_link 'New RMA' 81 | 82 | expect(page).to have_selector '.return-item-charged', text: '$2.50', count: 8 83 | 84 | find('#select-all').click 85 | select Spree::StockLocation.first.name, from: 'Stock Location' 86 | click_button 'Create' 87 | 88 | expect(page).to have_content 'Return Authorization has been successfully created!' 89 | 90 | click_link 'Customer Returns' 91 | click_link 'New Customer Return' 92 | find('#select-all').click 93 | page.execute_script <<~JS 94 | $('option[value="receive"]').attr('selected', 'selected'); 95 | JS 96 | select Spree::StockLocation.first.name, from: 'Stock Location' 97 | click_button 'Create' 98 | 99 | expect(page).to have_content 'Customer Return has been successfully created!' 100 | 101 | click_button 'Create reimbursement' 102 | click_button 'Reimburse' 103 | 104 | within 'table.reimbursement-refunds' do 105 | expect(page).to have_content '$20.00' 106 | end 107 | end 108 | end 109 | end 110 | 111 | context 'when the product is not a bundle' do 112 | before do 113 | create :refund_reason, name: Spree::RefundReason::RETURN_PROCESSING_REASON, mutable: false 114 | order.line_items.each { |li| li.variant.stock_items.first.set_count_on_hand 4 } 115 | 3.times { order.next } 116 | create :payment, order: order, amount: order.amount 117 | order.next 118 | order.complete! 119 | order.payments.each(&:capture!) 120 | order.shipments.each { |s| s.update state: :ready } 121 | order.shipments.each(&:ship!) 122 | end 123 | 124 | context 'with a single item cart' do 125 | let(:order) { create(:order_with_line_items) } 126 | 127 | it 'builds one return item for each product' do 128 | visit spree.edit_admin_order_path(order) 129 | 130 | expect(page).to have_selector '.item-total', text: '$10.00' 131 | 132 | click_link 'RMA' 133 | click_link 'New RMA' 134 | 135 | within '.return-items-table tbody' do 136 | expect(page).to have_selector 'tr', count: 1 137 | expect(page).to have_selector :field, class: 'refund-amount-input', with: '10.0', count: 1 138 | end 139 | end 140 | end 141 | 142 | context 'with a multiple items cart' do 143 | let(:order) { create(:order_with_line_items, line_items_attributes: [{ quantity: 2 }]) } 144 | 145 | it 'builds one return item for each product' do 146 | visit spree.edit_admin_order_path(order) 147 | 148 | expect(page).to have_selector '.item-total', text: '$20.00' 149 | 150 | click_link 'RMA' 151 | click_link 'New RMA' 152 | 153 | within '.return-items-table tbody' do 154 | expect(page).to have_selector 'tr', count: 2 155 | expect(page).to have_selector :field, class: 'refund-amount-input', with: '10.0', count: 2 156 | end 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/features/checkout_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe "Checkout", type: :feature do 6 | let!(:store) { create :store, default: true } 7 | let!(:country) { create(:country, name: "United States", states_required: true) } 8 | let!(:state) { create(:state, name: "Ohio", country: country) } 9 | let!(:shipping_method) { create(:shipping_method) } 10 | let!(:stock_location) { create(:stock_location) } 11 | let!(:payment_method) { create(:check_payment_method) } 12 | let!(:zone) { create(:zone) } 13 | 14 | let(:product) { create(:product, name: "RoR Mug") } 15 | let(:variant) { create(:variant) } 16 | let(:other_variant) { create(:variant) } 17 | 18 | stub_authorization! 19 | 20 | before { product.parts << variant << other_variant } 21 | 22 | shared_context "purchases product with part included" do 23 | before do 24 | visit spree.root_path 25 | add_product_to_cart 26 | click_button "Checkout" 27 | 28 | fill_in "order_email", with: "ryan@spreecommerce.com" 29 | click_button "Continue" 30 | fill_in_address 31 | 32 | click_button "Save and Continue" 33 | expect(current_path).to eql(spree.checkout_state_path("delivery")) 34 | expect(page).to have_content(variant.product.name) 35 | 36 | click_button "Save and Continue" 37 | expect(current_path).to eql(spree.checkout_state_path("payment")) 38 | 39 | click_button "Save and Continue" 40 | expect(page).to have_current_path spree.checkout_state_path('confirm'), ignore_query: true 41 | expect(page).to have_content(variant.product.name) 42 | 43 | click_button "Place Order" 44 | expect(page).to have_content('Your order has been processed successfully') 45 | end 46 | end 47 | 48 | shared_examples 'purchasable assembly' do 49 | context "ordering only the product assembly" do 50 | include_context "purchases product with part included" 51 | 52 | it "views parts bundled as well" do 53 | visit spree.admin_orders_path 54 | click_on Spree::Order.last.number 55 | 56 | expect(page).to have_content(variant.product.name) 57 | end 58 | end 59 | 60 | context "ordering assembly and the part as individual sale" do 61 | before do 62 | visit spree.root_path 63 | click_link variant.product.name 64 | click_button "add-to-cart-button" 65 | end 66 | 67 | include_context "purchases product with part included" 68 | 69 | it "views parts bundled and not" do 70 | visit spree.admin_orders_path 71 | click_on Spree::Order.last.number 72 | 73 | expect(page).to have_content(variant.product.name) 74 | end 75 | end 76 | end 77 | 78 | context "backend order shipments UI", js: true do 79 | context 'when the product and parts are backorderable' do 80 | it_behaves_like 'purchasable assembly' 81 | end 82 | 83 | context 'when the product and parts are not backorderable' do 84 | context 'when there is enough quantity available' do 85 | before do 86 | Spree::StockItem.update_all backorderable: false 87 | variant.stock_items.each { |stock_item| stock_item.set_count_on_hand 2 } 88 | other_variant.stock_items.each { |stock_item| stock_item.set_count_on_hand 1 } 89 | product.stock_items.each { |stock_item| stock_item.set_count_on_hand 1 } 90 | end 91 | 92 | it_behaves_like 'purchasable assembly' 93 | 94 | context 'when the order can be processed' do 95 | include_context "purchases product with part included" 96 | 97 | before do 98 | visit spree.admin_orders_path 99 | click_on Spree::Order.last.number 100 | end 101 | 102 | it 'marks the order as paid and shipped showing proper parts information' do 103 | within '#order_tab_summary' do 104 | expect(page).to have_selector '#shipment_status', text: 'Pending' 105 | expect(page).to have_selector '#payment_status', text: 'Balance due' 106 | end 107 | 108 | click_link 'Payments' 109 | find('.fa-capture').click 110 | 111 | within '#order_tab_summary' do 112 | expect(page).to have_selector '#shipment_status', text: 'Ready' 113 | expect(page).to have_selector '#payment_status', text: 'Paid' 114 | end 115 | 116 | click_link 'Shipments' 117 | 118 | within '.stock-contents' do 119 | expect(page).to have_selector '.item-price', text: '---' 120 | expect(page).to have_selector '.item-total', text: '---' 121 | expect(page).to have_selector '.item-qty-show', count: 2, text: '1 x on hand' 122 | end 123 | 124 | expect(page).not_to have_selector '.stock-contents.carton' 125 | 126 | click_button 'Ship' 127 | 128 | within '.stock-contents' do 129 | expect(page).to have_selector '.item-qty-show', count: 2, text: '1 x Shipped' 130 | end 131 | 132 | within '.stock-contents.carton' do 133 | expect(page).to have_selector '.item-qty-show', count: 2, text: '1 x Shipped' 134 | expect(page).to have_selector '.item-price', text: '---' 135 | expect(page).to have_selector '.item-total', text: '---' 136 | end 137 | 138 | within '#order_tab_summary' do 139 | expect(page).to have_selector '#shipment_status', text: 'Shipped' 140 | expect(page).to have_selector '#payment_status', text: 'Paid' 141 | end 142 | end 143 | end 144 | end 145 | end 146 | end 147 | 148 | def fill_in_address 149 | address = "order_bill_address_attributes" 150 | 151 | if use_address_full_name? 152 | fill_in "#{address}_name", with: "Ryan Bigg" 153 | else 154 | fill_in "#{address}_firstname", with: "Ryan" 155 | fill_in "#{address}_lastname", with: "Bigg" 156 | end 157 | 158 | fill_in "#{address}_address1", with: "143 Swan Street" 159 | fill_in "#{address}_city", with: "Richmond" 160 | select "Ohio", from: "#{address}_state_id" 161 | fill_in "#{address}_zipcode", with: "12345" 162 | fill_in "#{address}_phone", with: "(555) 555-5555" 163 | end 164 | 165 | def use_address_full_name? 166 | SolidusSupport.combined_first_and_last_name_in_address? 167 | end 168 | 169 | def add_product_to_cart 170 | visit spree.root_path 171 | click_link product.name 172 | click_button "add-to-cart-button" 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-09-14 16:19:15 UTC using RuboCop version 1.36.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # This cop supports safe autocorrection (--autocorrect). 11 | # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. 12 | # Include: **/*.gemfile, **/Gemfile, **/gems.rb 13 | Bundler/OrderedGems: 14 | Exclude: 15 | - 'Gemfile' 16 | 17 | # Offense count: 2 18 | # This cop supports safe autocorrection (--autocorrect). 19 | # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. 20 | # Include: **/*.gemspec 21 | Gemspec/OrderedDependencies: 22 | Exclude: 23 | - 'solidus_product_assembly.gemspec' 24 | 25 | # Offense count: 1 26 | # Configuration parameters: Include. 27 | # Include: **/*.gemspec 28 | Gemspec/RequiredRubyVersion: 29 | Exclude: 30 | - 'solidus_product_assembly.gemspec' 31 | 32 | # Offense count: 21 33 | # This cop supports safe autocorrection (--autocorrect). 34 | # Configuration parameters: EnforcedStyle, IndentationWidth. 35 | # SupportedStyles: with_first_argument, with_fixed_indentation 36 | Layout/ArgumentAlignment: 37 | Exclude: 38 | - 'app/decorators/models/solidus_product_assembly/spree/product_decorator.rb' 39 | - 'app/decorators/models/solidus_product_assembly/spree/shipment_decorator.rb' 40 | - 'app/models/spree/assemblies_part.rb' 41 | - 'app/overrides/add_admin_product_form_fields.rb' 42 | - 'app/overrides/add_admin_tabs.rb' 43 | - 'app/overrides/add_line_item_description.rb' 44 | 45 | # Offense count: 2 46 | # This cop supports safe autocorrection (--autocorrect). 47 | # Configuration parameters: EnforcedStyleAlignWith, Severity. 48 | # SupportedStylesAlignWith: keyword, variable, start_of_line 49 | Layout/EndAlignment: 50 | Exclude: 51 | - 'db/migrate/20091029165620_add_parts_fields_to_products.rb' 52 | 53 | # Offense count: 1 54 | # This cop supports safe autocorrection (--autocorrect). 55 | # Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment. 56 | Layout/ExtraSpacing: 57 | Exclude: 58 | - 'solidus_product_assembly.gemspec' 59 | 60 | # Offense count: 1 61 | # This cop supports safe autocorrection (--autocorrect). 62 | # Configuration parameters: Width, AllowedPatterns, IgnoredPatterns. 63 | Layout/IndentationWidth: 64 | Exclude: 65 | - 'spec/models/spree/shipment_spec.rb' 66 | 67 | # Offense count: 8 68 | # This cop supports safe autocorrection (--autocorrect). 69 | # Configuration parameters: EnforcedStyle, IndentationWidth. 70 | # SupportedStyles: aligned, indented, indented_relative_to_receiver 71 | Layout/MultilineMethodCallIndentation: 72 | Exclude: 73 | - 'app/decorators/models/solidus_product_assembly/spree/product_decorator.rb' 74 | - 'spec/models/spree/order_contents_spec.rb' 75 | 76 | # Offense count: 1 77 | # This cop supports safe autocorrection (--autocorrect). 78 | # Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator. 79 | # SupportedStylesForExponentOperator: space, no_space 80 | Layout/SpaceAroundOperators: 81 | Exclude: 82 | - 'solidus_product_assembly.gemspec' 83 | 84 | # Offense count: 1 85 | # This cop supports safe autocorrection (--autocorrect). 86 | # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. 87 | # SupportedStyles: space, no_space 88 | # SupportedStylesForEmptyBraces: space, no_space 89 | Layout/SpaceInsideBlockBraces: 90 | Exclude: 91 | - 'spec/models/spree/shipment_spec.rb' 92 | 93 | # Offense count: 1 94 | # This cop supports safe autocorrection (--autocorrect). 95 | # Configuration parameters: AllowInHeredoc. 96 | Layout/TrailingWhitespace: 97 | Exclude: 98 | - 'spec/features/checkout_spec.rb' 99 | 100 | # Offense count: 1 101 | # Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. 102 | Lint/AmbiguousBlockAssociation: 103 | Exclude: 104 | - 'spec/models/spree/order_inventory_spec.rb' 105 | 106 | # Offense count: 1 107 | # This cop supports safe autocorrection (--autocorrect). 108 | Lint/AmbiguousOperator: 109 | Exclude: 110 | - 'app/views/spree/api/line_items/show.v1.rabl' 111 | 112 | # Offense count: 1 113 | Lint/MissingSuper: 114 | Exclude: 115 | - 'app/models/spree/order_inventory_assembly.rb' 116 | 117 | # Offense count: 1 118 | # This cop supports safe autocorrection (--autocorrect). 119 | Lint/RedundantCopDisableDirective: 120 | Exclude: 121 | - 'lib/generators/solidus_product_assembly/install/install_generator.rb' 122 | 123 | # Offense count: 3 124 | Lint/ShadowingOuterLocalVariable: 125 | Exclude: 126 | - 'app/decorators/models/solidus_product_assembly/spree/shipment_decorator.rb' 127 | - 'lib/tasks/spree2_upgrade.rake' 128 | 129 | # Offense count: 2 130 | # This cop supports safe autocorrection (--autocorrect). 131 | Performance/BigDecimalWithNumericArgument: 132 | Exclude: 133 | - 'spec/models/spree/inventory_unit_spec.rb' 134 | 135 | # Offense count: 3 136 | # This cop supports unsafe autocorrection (--autocorrect-all). 137 | Performance/TimesMap: 138 | Exclude: 139 | - 'app/decorators/models/solidus_product_assembly/spree/stock/inventory_unit_builder_decorator.rb' 140 | - 'spec/models/spree/shipment_spec.rb' 141 | 142 | # Offense count: 4 143 | RSpec/AnyInstance: 144 | Exclude: 145 | - 'spec/features/admin/return_items_spec.rb' 146 | - 'spec/models/spree/stock/availability_validator_spec.rb' 147 | 148 | # Offense count: 2 149 | # This cop supports safe autocorrection (--autocorrect). 150 | RSpec/Capybara/CurrentPathExpectation: 151 | Exclude: 152 | - 'spec/features/checkout_spec.rb' 153 | 154 | # Offense count: 42 155 | # Configuration parameters: Prefixes, AllowedPatterns. 156 | # Prefixes: when, with, without 157 | RSpec/ContextWording: 158 | Exclude: 159 | - 'spec/features/admin/orders_spec.rb' 160 | - 'spec/features/checkout_spec.rb' 161 | - 'spec/models/spree/assemblies_part_spec.rb' 162 | - 'spec/models/spree/inventory_unit_spec.rb' 163 | - 'spec/models/spree/line_item_spec.rb' 164 | - 'spec/models/spree/order_contents_spec.rb' 165 | - 'spec/models/spree/order_inventory_assembly_spec.rb' 166 | - 'spec/models/spree/order_inventory_spec.rb' 167 | - 'spec/models/spree/shipment_spec.rb' 168 | - 'spec/models/spree/stock/availability_validator_spec.rb' 169 | - 'spec/models/spree/stock/coordinator_spec.rb' 170 | - 'spec/models/spree/stock/inventory_unit_builder_spec.rb' 171 | - 'spec/models/spree/variant_spec.rb' 172 | - 'spec/support/shared_contexts/order_with_bundle.rb' 173 | 174 | # Offense count: 2 175 | # This cop supports safe autocorrection (--autocorrect). 176 | RSpec/EmptyLineAfterFinalLet: 177 | Exclude: 178 | - 'spec/models/spree/shipment_spec.rb' 179 | 180 | # Offense count: 9 181 | RSpec/ExpectInHook: 182 | Exclude: 183 | - 'spec/features/checkout_spec.rb' 184 | - 'spec/models/spree/line_item_spec.rb' 185 | 186 | # Offense count: 14 187 | # Configuration parameters: AssignmentOnly. 188 | RSpec/InstanceVariable: 189 | Exclude: 190 | - 'spec/models/spree/product_spec.rb' 191 | 192 | # Offense count: 14 193 | RSpec/LetSetup: 194 | Exclude: 195 | - 'spec/features/checkout_spec.rb' 196 | - 'spec/models/spree/order_contents_spec.rb' 197 | - 'spec/models/spree/order_inventory_spec.rb' 198 | - 'spec/models/spree/shipment_spec.rb' 199 | - 'spec/models/spree/stock/availability_validator_spec.rb' 200 | - 'spec/models/spree/stock/coordinator_spec.rb' 201 | 202 | # Offense count: 9 203 | # Configuration parameters: . 204 | # SupportedStyles: have_received, receive 205 | RSpec/MessageSpies: 206 | EnforcedStyle: receive 207 | 208 | # Offense count: 19 209 | RSpec/MultipleExpectations: 210 | Max: 14 211 | 212 | # Offense count: 23 213 | # Configuration parameters: AllowSubject. 214 | RSpec/MultipleMemoizedHelpers: 215 | Max: 10 216 | 217 | # Offense count: 23 218 | # Configuration parameters: IgnoreSharedExamples. 219 | RSpec/NamedSubject: 220 | Exclude: 221 | - 'spec/models/spree/assemblies_part_spec.rb' 222 | - 'spec/models/spree/inventory_unit_spec.rb' 223 | - 'spec/models/spree/order_contents_spec.rb' 224 | - 'spec/models/spree/order_inventory_assembly_spec.rb' 225 | - 'spec/models/spree/order_inventory_spec.rb' 226 | - 'spec/models/spree/product_spec.rb' 227 | - 'spec/models/spree/stock/availability_validator_spec.rb' 228 | - 'spec/models/spree/stock/coordinator_spec.rb' 229 | - 'spec/models/spree/stock/inventory_unit_builder_spec.rb' 230 | 231 | # Offense count: 2 232 | # Configuration parameters: AllowedGroups. 233 | RSpec/NestedGroups: 234 | Max: 5 235 | 236 | # Offense count: 3 237 | # This cop supports safe autocorrection (--autocorrect). 238 | RSpec/ScatteredLet: 239 | Exclude: 240 | - 'spec/models/spree/order_inventory_spec.rb' 241 | 242 | # Offense count: 2 243 | RSpec/StubbedMock: 244 | Exclude: 245 | - 'spec/models/spree/line_item_spec.rb' 246 | 247 | # Offense count: 2 248 | # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. 249 | RSpec/VerifiedDoubles: 250 | Exclude: 251 | - 'spec/models/spree/line_item_spec.rb' 252 | - 'spec/models/spree/stock/availability_validator_spec.rb' 253 | 254 | # Offense count: 1 255 | # Configuration parameters: Include. 256 | # Include: db/migrate/*.rb 257 | Rails/CreateTableWithTimestamps: 258 | Exclude: 259 | - 'db/migrate/20091028152124_add_many_to_many_relation_to_products.rb' 260 | 261 | # Offense count: 2 262 | # Configuration parameters: IgnoreScopes, Include. 263 | # Include: app/models/**/*.rb 264 | Rails/InverseOf: 265 | Exclude: 266 | - 'app/models/spree/assemblies_part.rb' 267 | 268 | # Offense count: 2 269 | # This cop supports safe autocorrection (--autocorrect). 270 | Rails/RedundantForeignKey: 271 | Exclude: 272 | - 'app/models/spree/assemblies_part.rb' 273 | 274 | # Offense count: 8 275 | # Configuration parameters: ForbiddenMethods, AllowedMethods. 276 | # ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all 277 | Rails/SkipsModelValidations: 278 | Exclude: 279 | - 'lib/tasks/spree2_upgrade.rake' 280 | - 'spec/features/admin/return_items_spec.rb' 281 | - 'spec/features/checkout_spec.rb' 282 | - 'spec/models/spree/line_item_spec.rb' 283 | - 'spec/models/spree/order_contents_spec.rb' 284 | - 'spec/models/spree/order_inventory_spec.rb' 285 | - 'spec/models/spree/shipment_spec.rb' 286 | - 'spec/models/spree/stock/coordinator_spec.rb' 287 | 288 | # Offense count: 2 289 | # This cop supports unsafe autocorrection (--autocorrect-all). 290 | # Configuration parameters: EnforcedStyle. 291 | # SupportedStyles: nested, compact 292 | Style/ClassAndModuleChildren: 293 | Exclude: 294 | - 'app/controllers/spree/admin/parts_controller.rb' 295 | - 'app/models/spree/calculator/returns/assemblies_default_refund_amount.rb' 296 | 297 | # Offense count: 3 298 | # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. 299 | Style/GuardClause: 300 | Exclude: 301 | - 'app/decorators/models/solidus_product_assembly/spree/line_item_decorator.rb' 302 | - 'app/decorators/models/solidus_product_assembly/spree/stock/inventory_validator_decorator.rb' 303 | - 'app/models/spree/order_inventory_assembly.rb' 304 | 305 | # Offense count: 2 306 | # This cop supports safe autocorrection (--autocorrect). 307 | Style/OrAssignment: 308 | Exclude: 309 | - 'app/models/spree/order_inventory_assembly.rb' 310 | 311 | # Offense count: 6 312 | # This cop supports safe autocorrection (--autocorrect). 313 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns. 314 | # URISchemes: http, https 315 | Layout/LineLength: 316 | Max: 146 317 | -------------------------------------------------------------------------------- /OLD_CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.2.0](https://github.com/solidusio-contrib/solidus_product_assembly/tree/v1.2.0) (2022-11-02) 4 | 5 | [Full Changelog](https://github.com/solidusio-contrib/solidus_product_assembly/compare/v1.1.0...v1.2.0) 6 | 7 | **Merged pull requests:** 8 | 9 | - Fix before payment override [\#112](https://github.com/solidusio-contrib/solidus_product_assembly/pull/112) ([cpfergus1](https://github.com/cpfergus1)) 10 | - Update to use forked solidus\_frontend when needed [\#111](https://github.com/solidusio-contrib/solidus_product_assembly/pull/111) ([waiting-for-dev](https://github.com/waiting-for-dev)) 11 | - Search part globalize [\#109](https://github.com/solidusio-contrib/solidus_product_assembly/pull/109) ([AlistairNorman](https://github.com/AlistairNorman)) 12 | 13 | ## [v1.1.0](https://github.com/solidusio-contrib/solidus_product_assembly/tree/v1.1.0) (2021-10-19) 14 | 15 | [Full Changelog](https://github.com/solidusio-contrib/solidus_product_assembly/compare/v1.0.0...v1.1.0) 16 | 17 | **Closed issues:** 18 | 19 | - Dependabot couldn't find a Gemfile-local for this project [\#107](https://github.com/solidusio-contrib/solidus_product_assembly/issues/107) 20 | - Prepare Solidus Product Assembly for Solidus 3.0 [\#102](https://github.com/solidusio-contrib/solidus_product_assembly/issues/102) 21 | - Error on running specs locally [\#91](https://github.com/solidusio-contrib/solidus_product_assembly/issues/91) 22 | - Dependabot can't resolve your Ruby dependency files [\#80](https://github.com/solidusio-contrib/solidus_product_assembly/issues/80) 23 | - Dependabot can't resolve your Ruby dependency files [\#79](https://github.com/solidusio-contrib/solidus_product_assembly/issues/79) 24 | - Dependabot can't resolve your Ruby dependency files [\#78](https://github.com/solidusio-contrib/solidus_product_assembly/issues/78) 25 | - Dependabot can't resolve your Ruby dependency files [\#77](https://github.com/solidusio-contrib/solidus_product_assembly/issues/77) 26 | - Dependabot can't resolve your Ruby dependency files [\#76](https://github.com/solidusio-contrib/solidus_product_assembly/issues/76) 27 | - Dependabot can't resolve your Ruby dependency files [\#75](https://github.com/solidusio-contrib/solidus_product_assembly/issues/75) 28 | - Dependabot can't resolve your Ruby dependency files [\#74](https://github.com/solidusio-contrib/solidus_product_assembly/issues/74) 29 | - Dependabot can't resolve your Ruby dependency files [\#73](https://github.com/solidusio-contrib/solidus_product_assembly/issues/73) 30 | - Action links for assembly don't work [\#62](https://github.com/solidusio-contrib/solidus_product_assembly/issues/62) 31 | - Wrong return item amounts when returning orders with line items with quantity \> 1 [\#46](https://github.com/solidusio-contrib/solidus_product_assembly/issues/46) 32 | - `method_missing': undefined method `add\_routes' [\#39](https://github.com/solidusio-contrib/solidus_product_assembly/issues/39) 33 | - Deface is required [\#37](https://github.com/solidusio-contrib/solidus_product_assembly/issues/37) 34 | - Javascript not registering Events [\#23](https://github.com/solidusio-contrib/solidus_product_assembly/issues/23) 35 | - Cannot select parts [\#18](https://github.com/solidusio-contrib/solidus_product_assembly/issues/18) 36 | 37 | **Merged pull requests:** 38 | 39 | - Solidus 3 preparation [\#106](https://github.com/solidusio-contrib/solidus_product_assembly/pull/106) ([cpfergus1](https://github.com/cpfergus1)) 40 | - Upgrade to GitHub-native Dependabot [\#104](https://github.com/solidusio-contrib/solidus_product_assembly/pull/104) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 41 | - Bump solidus\_support dependency to \(~\> 0.5\) [\#96](https://github.com/solidusio-contrib/solidus_product_assembly/pull/96) ([jcsanti](https://github.com/jcsanti)) 42 | - Display parts' available\_on in the admin's "parts" tab \(feature proposal\) [\#95](https://github.com/solidusio-contrib/solidus_product_assembly/pull/95) ([jcsanti](https://github.com/jcsanti)) 43 | - Fix Community Guidelines link in Readme [\#94](https://github.com/solidusio-contrib/solidus_product_assembly/pull/94) ([jcsanti](https://github.com/jcsanti)) 44 | - Fix failing test in shipment\_spec due to missing store [\#93](https://github.com/solidusio-contrib/solidus_product_assembly/pull/93) ([jcsanti](https://github.com/jcsanti)) 45 | - Fix deprecation warning about SolidusSupport.solidus\_gem\_version [\#84](https://github.com/solidusio-contrib/solidus_product_assembly/pull/84) ([stem](https://github.com/stem)) 46 | - Upgrade the extension using solidus\_dev\_support [\#81](https://github.com/solidusio-contrib/solidus_product_assembly/pull/81) ([blocknotes](https://github.com/blocknotes)) 47 | - Adopt solidus\_extension\_dev\_tools [\#71](https://github.com/solidusio-contrib/solidus_product_assembly/pull/71) ([aldesantis](https://github.com/aldesantis)) 48 | - Remove deprecation for Reimbursement\#simulate [\#69](https://github.com/solidusio-contrib/solidus_product_assembly/pull/69) ([spaghetticode](https://github.com/spaghetticode)) 49 | - Remove broken buttons in Shipments page [\#68](https://github.com/solidusio-contrib/solidus_product_assembly/pull/68) ([spaghetticode](https://github.com/spaghetticode)) 50 | - Update factory\_bot requirement from 5.1.0 to 5.1.1 [\#67](https://github.com/solidusio-contrib/solidus_product_assembly/pull/67) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 51 | - Update puma requirement from ~\> 3.12 to ~\> 4.2 [\#66](https://github.com/solidusio-contrib/solidus_product_assembly/pull/66) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 52 | - Update factory\_bot requirement from 4.10.0 to 5.1.0 [\#65](https://github.com/solidusio-contrib/solidus_product_assembly/pull/65) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 53 | - Update pg requirement from ~\> 0.21 to ~\> 1.1 [\#64](https://github.com/solidusio-contrib/solidus_product_assembly/pull/64) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 54 | - Update sqlite3 requirement from ~\> 1.3.6 to ~\> 1.4.1 [\#63](https://github.com/solidusio-contrib/solidus_product_assembly/pull/63) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 55 | - Use `ActionView::Helpers::TranslationHelper.t` [\#60](https://github.com/solidusio-contrib/solidus_product_assembly/pull/60) ([spaghetticode](https://github.com/spaghetticode)) 56 | - Releasing new gem version to RubyGems, changelog generation [\#57](https://github.com/solidusio-contrib/solidus_product_assembly/pull/57) ([spaghetticode](https://github.com/spaghetticode)) 57 | - Remove Travis CI [\#56](https://github.com/solidusio-contrib/solidus_product_assembly/pull/56) ([spaghetticode](https://github.com/spaghetticode)) 58 | - Add SKU to \_stock\_item [\#55](https://github.com/solidusio-contrib/solidus_product_assembly/pull/55) ([ericsaupe](https://github.com/ericsaupe)) 59 | - Add CircleCI build [\#54](https://github.com/solidusio-contrib/solidus_product_assembly/pull/54) ([spaghetticode](https://github.com/spaghetticode)) 60 | - Load Capybara config from `solidus_support` gem [\#53](https://github.com/solidusio-contrib/solidus_product_assembly/pull/53) ([spaghetticode](https://github.com/spaghetticode)) 61 | - Fix build on Travis [\#52](https://github.com/solidusio-contrib/solidus_product_assembly/pull/52) ([spaghetticode](https://github.com/spaghetticode)) 62 | - Update `#determine_target_shipment` after Solidus \#3197 [\#50](https://github.com/solidusio-contrib/solidus_product_assembly/pull/50) ([spaghetticode](https://github.com/spaghetticode)) 63 | - Show split/delete actions for all items \(admin/orders/shipment\) [\#49](https://github.com/solidusio-contrib/solidus_product_assembly/pull/49) ([MinasMazar](https://github.com/MinasMazar)) 64 | - Show correct RMA amounts when assembly has more than one piece for var… [\#48](https://github.com/solidusio-contrib/solidus_product_assembly/pull/48) ([spaghetticode](https://github.com/spaghetticode)) 65 | - Fix build on Travis [\#47](https://github.com/solidusio-contrib/solidus_product_assembly/pull/47) ([spaghetticode](https://github.com/spaghetticode)) 66 | - Fix return item amounts with multiple items [\#45](https://github.com/solidusio-contrib/solidus_product_assembly/pull/45) ([spaghetticode](https://github.com/spaghetticode)) 67 | - add deface as a development dependency in gemspec [\#44](https://github.com/solidusio-contrib/solidus_product_assembly/pull/44) ([kevinjbayer](https://github.com/kevinjbayer)) 68 | - Round up default refund amount for assembly parts [\#43](https://github.com/solidusio-contrib/solidus_product_assembly/pull/43) ([spaghetticode](https://github.com/spaghetticode)) 69 | - Remove assembly part prices from carton manifest [\#42](https://github.com/solidusio-contrib/solidus_product_assembly/pull/42) ([spaghetticode](https://github.com/spaghetticode)) 70 | - Split refund amount proportionally on each assembly part [\#41](https://github.com/solidusio-contrib/solidus_product_assembly/pull/41) ([spaghetticode](https://github.com/spaghetticode)) 71 | - Fix stock finalization with non-backorderable stock and Solidus \>= 2.8 [\#40](https://github.com/solidusio-contrib/solidus_product_assembly/pull/40) ([spaghetticode](https://github.com/spaghetticode)) 72 | - Replace `Spree.t` with `I18n.t` [\#38](https://github.com/solidusio-contrib/solidus_product_assembly/pull/38) ([spaghetticode](https://github.com/spaghetticode)) 73 | - Lock SQLite3 to version 1.3 [\#36](https://github.com/solidusio-contrib/solidus_product_assembly/pull/36) ([aitbw](https://github.com/aitbw)) 74 | - Add Solidus v2.8 to Travis config [\#35](https://github.com/solidusio-contrib/solidus_product_assembly/pull/35) ([aitbw](https://github.com/aitbw)) 75 | - Remove stale edit button [\#34](https://github.com/solidusio-contrib/solidus_product_assembly/pull/34) ([spaghetticode](https://github.com/spaghetticode)) 76 | - Use Selenium with Chrome headless instead of Poltergeist [\#33](https://github.com/solidusio-contrib/solidus_product_assembly/pull/33) ([spaghetticode](https://github.com/spaghetticode)) 77 | - Fix `stock_item` partial for splitting items [\#32](https://github.com/solidusio-contrib/solidus_product_assembly/pull/32) ([spaghetticode](https://github.com/spaghetticode)) 78 | - Fix admin edit for shipping costs and tracking [\#31](https://github.com/solidusio-contrib/solidus_product_assembly/pull/31) ([spaghetticode](https://github.com/spaghetticode)) 79 | - AvailabilityValidator delegates to Solidus implementation when product is not an assembly [\#30](https://github.com/solidusio-contrib/solidus_product_assembly/pull/30) ([spaghetticode](https://github.com/spaghetticode)) 80 | - Test suite maintenance [\#29](https://github.com/solidusio-contrib/solidus_product_assembly/pull/29) ([aitbw](https://github.com/aitbw)) 81 | - Remove 2.2 from CI \(EOL\) [\#28](https://github.com/solidusio-contrib/solidus_product_assembly/pull/28) ([jacobherrington](https://github.com/jacobherrington)) 82 | - Remove versions past EOL from .travis.yml [\#27](https://github.com/solidusio-contrib/solidus_product_assembly/pull/27) ([jacobherrington](https://github.com/jacobherrington)) 83 | - Add Solidus 2.7 to .travis.yml [\#26](https://github.com/solidusio-contrib/solidus_product_assembly/pull/26) ([jacobherrington](https://github.com/jacobherrington)) 84 | - Update for Solidus 2.5 [\#21](https://github.com/solidusio-contrib/solidus_product_assembly/pull/21) ([jhawthorn](https://github.com/jhawthorn)) 85 | - Fix warnings [\#20](https://github.com/solidusio-contrib/solidus_product_assembly/pull/20) ([jhawthorn](https://github.com/jhawthorn)) 86 | - 2.4 fixes [\#19](https://github.com/solidusio-contrib/solidus_product_assembly/pull/19) ([jhawthorn](https://github.com/jhawthorn)) 87 | - Avoid stubbing in spec [\#17](https://github.com/solidusio-contrib/solidus_product_assembly/pull/17) ([jhawthorn](https://github.com/jhawthorn)) 88 | - Remove old Spree information from README [\#16](https://github.com/solidusio-contrib/solidus_product_assembly/pull/16) ([brchristian](https://github.com/brchristian)) 89 | - Removing small\_image Method Call. Replacing It To Avoid Error. [\#15](https://github.com/solidusio-contrib/solidus_product_assembly/pull/15) ([hugohernani](https://github.com/hugohernani)) 90 | - Fix deprecations and removals [\#14](https://github.com/solidusio-contrib/solidus_product_assembly/pull/14) ([jhawthorn](https://github.com/jhawthorn)) 91 | - Remove add\_routes fo routes.draw [\#13](https://github.com/solidusio-contrib/solidus_product_assembly/pull/13) ([denissellu](https://github.com/denissellu)) 92 | - Fix migration to allow rollback [\#12](https://github.com/solidusio-contrib/solidus_product_assembly/pull/12) ([tudorpavel](https://github.com/tudorpavel)) 93 | - Parts informations in API [\#11](https://github.com/solidusio-contrib/solidus_product_assembly/pull/11) ([DanielePalombo](https://github.com/DanielePalombo)) 94 | 95 | ## [v1.0.0](https://github.com/solidusio-contrib/solidus_product_assembly/tree/v1.0.0) (2016-09-06) 96 | 97 | [Full Changelog](https://github.com/solidusio-contrib/solidus_product_assembly/compare/7a6d85258735c0035d675364fc9000cdd7355e0e...v1.0.0) 98 | 99 | **Closed issues:** 100 | 101 | - Inventory Validation fail upon checkout, for the assembly product [\#3](https://github.com/solidusio-contrib/solidus_product_assembly/issues/3) 102 | 103 | **Merged pull requests:** 104 | 105 | - Add support for Solidus 2.0 and Rails 5 [\#10](https://github.com/solidusio-contrib/solidus_product_assembly/pull/10) ([jhawthorn](https://github.com/jhawthorn)) 106 | - Fix installation instructions in README [\#9](https://github.com/solidusio-contrib/solidus_product_assembly/pull/9) ([alexblackie](https://github.com/alexblackie)) 107 | - Add missing translation for actions.delete [\#8](https://github.com/solidusio-contrib/solidus_product_assembly/pull/8) ([Sinetheta](https://github.com/Sinetheta)) 108 | - Fix a load order issue when redefining inventory validator. [\#7](https://github.com/solidusio-contrib/solidus_product_assembly/pull/7) ([Senjai](https://github.com/Senjai)) 109 | - Fix product sub\_menu display on new Solidus [\#6](https://github.com/solidusio-contrib/solidus_product_assembly/pull/6) ([Sinetheta](https://github.com/Sinetheta)) 110 | - Inventory validation [\#5](https://github.com/solidusio-contrib/solidus_product_assembly/pull/5) ([Sinetheta](https://github.com/Sinetheta)) 111 | - Rename to solidus\_product\_assembly [\#2](https://github.com/solidusio-contrib/solidus_product_assembly/pull/2) ([Sinetheta](https://github.com/Sinetheta)) 112 | - Upgrade from spree 2.4 to solidus 1.1-1.3 [\#1](https://github.com/solidusio-contrib/solidus_product_assembly/pull/1) ([Sinetheta](https://github.com/Sinetheta)) 113 | 114 | 115 | 116 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 117 | --------------------------------------------------------------------------------