spree_product_assembly 0.60.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. data/README.markdown +9 -0
  2. data/Rakefile +75 -0
  3. data/Versionfile +3 -0
  4. data/app/controllers/admin/parts_controller.rb +47 -0
  5. data/app/helpers/admin/parts_helper.rb +2 -0
  6. data/app/models/assemblies_part.rb +19 -0
  7. data/app/models/inventory_unit_decorator.rb +73 -0
  8. data/app/models/line_item_decorator.rb +32 -0
  9. data/app/models/product_decorator.rb +91 -0
  10. data/app/views/admin/parts/_parts_table.html.erb +35 -0
  11. data/app/views/admin/parts/available.js.erb +55 -0
  12. data/app/views/admin/parts/index.html.erb +30 -0
  13. data/app/views/admin/parts/update_parts_table.js.erb +2 -0
  14. data/app/views/admin/products/_product_assembly_fields.html.erb +18 -0
  15. data/app/views/admin/shared/_product_assembly_product_tabs.html.erb +14 -0
  16. data/app/views/orders/_cart_description.html.erb +8 -0
  17. data/config/locales/en.yml +6 -0
  18. data/config/locales/fr-FR.yml +6 -0
  19. data/config/locales/ru.yml +6 -0
  20. data/config/routes.rb +19 -0
  21. data/lib/generators/spree_product_assembly/install_generator.rb +19 -0
  22. data/lib/generators/templates/db/migrate/20091028152124_add_many_to_many_relation_to_products.rb +13 -0
  23. data/lib/generators/templates/db/migrate/20091029165620_add_parts_fields_to_products.rb +15 -0
  24. data/lib/generators/templates/public/javascripts/product_assembly.js +37 -0
  25. data/lib/spree_product_assembly.rb +15 -0
  26. data/lib/spree_product_assembly_hooks.rb +18 -0
  27. data/spree_product_assembly.gemspec +20 -0
  28. data/test/test_helper.rb +17 -0
  29. data/test/unit/inventory_unit_test.rb +41 -0
  30. data/test/unit/product_test.rb +313 -0
  31. metadata +106 -0
@@ -0,0 +1,9 @@
1
+ = Product Assembly
2
+
3
+ == Installation
4
+
5
+ 1. Add `gem "spree_product_assembly"` to your Gemfile
6
+ 1. Add `gem "jrails"` to your Gemfile
7
+ 1. Run `bundle install`
8
+ 1. Run `rails g spree_static_content:install`
9
+ 1. Run `rake db:migrate`
@@ -0,0 +1,75 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rake/packagetask'
5
+ require 'rake/gempackagetask'
6
+
7
+ gemfile = File.expand_path('../spec/test_app/Gemfile', __FILE__)
8
+ if File.exists?(gemfile) && (%w(spec cucumber).include?(ARGV.first.to_s) || ARGV.size == 0)
9
+ require 'bundler'
10
+ ENV['BUNDLE_GEMFILE'] = gemfile
11
+ Bundler.setup
12
+
13
+ require 'rspec'
14
+ require 'rspec/core/rake_task'
15
+ RSpec::Core::RakeTask.new
16
+
17
+ require 'cucumber/rake/task'
18
+ Cucumber::Rake::Task.new do |t|
19
+ t.cucumber_opts = %w{--format progress}
20
+ end
21
+ end
22
+
23
+ desc "Default Task"
24
+ task :default => [:spec, :cucumber ]
25
+
26
+ spec = eval(File.read('spree_openbravo.gemspec'))
27
+
28
+ Rake::GemPackageTask.new(spec) do |p|
29
+ p.gem_spec = spec
30
+ end
31
+
32
+ desc "Release to gemcutter"
33
+ task :release => :package do
34
+ require 'rake/gemcutter'
35
+ Rake::Gemcutter::Tasks.new(spec).define
36
+ Rake::Task['gem:push'].invoke
37
+ end
38
+
39
+ desc "Default Task"
40
+ task :default => [ :spec ]
41
+
42
+ desc "Regenerates a rails 3 app for testing"
43
+ task :test_app do
44
+ require '../spree/lib/generators/spree/test_app_generator'
45
+ class SpreeProductAssemblyTestAppGenerator < Spree::Generators::TestAppGenerator
46
+
47
+ def install_gems
48
+ inside "test_app" do
49
+ run 'rake spree_core:install'
50
+ run 'rails g spree_product_assembly:install'
51
+ end
52
+ end
53
+
54
+ def migrate_db
55
+ run_migrations
56
+ end
57
+
58
+ protected
59
+ def full_path_for_local_gems
60
+ <<-gems
61
+ gem 'spree_core', :path => \'#{File.join(File.dirname(__FILE__), "../spree/", "core")}\'
62
+ gem 'spree_product_assembly', :path => \'#{File.dirname(__FILE__)}\'
63
+ gems
64
+ end
65
+
66
+ end
67
+ SpreeProductAssemblyTestAppGenerator.start
68
+ end
69
+
70
+ namespace :test_app do
71
+ desc 'Rebuild test and cucumber databases'
72
+ task :rebuild_dbs do
73
+ system("cd spec/test_app && rake db:drop db:migrate RAILS_ENV=test && rake db:drop db:migrate RAILS_ENV=cucumber")
74
+ end
75
+ end
@@ -0,0 +1,3 @@
1
+ "0.70.x" => { :branch => "master" }
2
+ "0.60.x" => { :branch => "0-60-stable" }
3
+ "0.50.x" => { :branch => "0-60-stable" }
@@ -0,0 +1,47 @@
1
+ class Admin::PartsController < Admin::BaseController
2
+ helper :products
3
+ before_filter :find_product
4
+
5
+ def index
6
+ @parts = @product.parts
7
+ end
8
+
9
+ def remove
10
+ @part = Variant.find(params[:id])
11
+ @product.remove_part(@part)
12
+ render :template => 'admin/parts/update_parts_table'
13
+ end
14
+
15
+ def set_count
16
+ @part = Variant.find(params[:id])
17
+ @product.set_part_count(@part, params[:count].to_i)
18
+ render :template => 'admin/parts/update_parts_table'
19
+ end
20
+
21
+ def available
22
+ if params[:q].blank?
23
+ @available_products = []
24
+ else
25
+ query = "%#{params[:q]}%"
26
+ @available_products = Product.not_deleted.available.joins(:master).where("(products.name #{LIKE} ? OR variants.sku #{LIKE} ?) AND can_be_part = ?", query, query, true).limit(30)
27
+
28
+ @available_products.uniq!
29
+ end
30
+ respond_to do |format|
31
+ format.html
32
+ format.js {render :layout => false}
33
+ end
34
+ end
35
+
36
+ def create
37
+ @part = Variant.find(params[:part_id])
38
+ qty = params[:part_count].to_i
39
+ @product.add_part(@part, qty) if qty > 0
40
+ render :template => 'admin/parts/update_parts_table'
41
+ end
42
+
43
+ private
44
+ def find_product
45
+ @product = Product.find_by_permalink(params[:product_id])
46
+ end
47
+ end
@@ -0,0 +1,2 @@
1
+ module Admin::PartsHelper
2
+ end
@@ -0,0 +1,19 @@
1
+ class AssembliesPart < ActiveRecord::Base
2
+ #set_primary_keys :assembly_id, :part_id
3
+ belongs_to :assembly, :class_name => "Product", :foreign_key => "assembly_id"
4
+ belongs_to :part, :class_name => "Variant", :foreign_key => "part_id"
5
+
6
+ def self.get(assembly_id, part_id)
7
+ AssembliesPart.find_by_assembly_id_and_part_id(assembly_id, part_id)
8
+ end
9
+
10
+ def save
11
+ AssembliesPart.update_all("count = #{count}",
12
+ ["assembly_id = ? AND part_id = ?", assembly_id, part_id])
13
+ end
14
+
15
+ def destroy
16
+ AssembliesPart.delete_all(["assembly_id = ? AND part_id = ?", assembly_id, part_id])
17
+ end
18
+
19
+ end
@@ -0,0 +1,73 @@
1
+ InventoryUnit.class_eval do
2
+ def self.sell_units(order)
3
+ # we should not already have inventory associated with the order at this point but we should clear to be safe (#1394)
4
+ order.inventory_units.destroy_all
5
+
6
+ out_of_stock_items = []
7
+ order.line_items.each do |line_item|
8
+ variant = line_item.variant
9
+ quantity = line_item.quantity
10
+ product = variant.product
11
+
12
+ if product.assembly?
13
+ product.parts.each do |v|
14
+ out_of_stock_items += create_units(order, v, quantity * product.count_of(v))
15
+ end
16
+ else
17
+ out_of_stock_items += create_units(order, variant, quantity)
18
+ end
19
+ end
20
+ out_of_stock_items.flatten
21
+ end
22
+
23
+ def self.adjust_units(order)
24
+ units_by_variant = order.inventory_units.group_by(&:variant_id)
25
+ out_of_stock_items = []
26
+
27
+ #check line items quantities match
28
+ order.line_items.each do |line_item|
29
+ if line_item.variant.product.assembly?
30
+
31
+ line_item.variant.product.parts.each do |variant|
32
+ quantity = line_item.quantity
33
+ unit_count = units_by_variant.key?(variant.id) ? units_by_variant[variant.id].size : 0
34
+
35
+ adjust_line(variant, quantity, unit_count, order, out_of_stock_items, units_by_variant)
36
+
37
+ #remove it from hash as it's up-to-date
38
+ units_by_variant.delete(variant.id)
39
+ end
40
+
41
+ else
42
+ variant = line_item.variant
43
+ quantity = line_item.quantity
44
+ unit_count = units_by_variant.key?(variant.id) ? units_by_variant[variant.id].size : 0
45
+
46
+ adjust_line(variant, quantity, unit_count, order, out_of_stock_items, units_by_variant)
47
+
48
+ #remove it from hash as it's up-to-date
49
+ units_by_variant.delete(variant.id)
50
+ end
51
+
52
+ end
53
+
54
+ #check for deleted line items (if theres anything left in units_by_variant its' extra)
55
+ units_by_variant.each do |variant_id, units|
56
+ units.each {|unit| unit.restock!}
57
+ end
58
+
59
+ out_of_stock_items
60
+ end
61
+
62
+ private
63
+ def self.adjust_line(variant, quantity, unit_count, order, out_of_stock_items, units_by_variant)
64
+ if unit_count < quantity
65
+ out_of_stock_items.concat create_units(order, variant, (quantity - unit_count))
66
+ elsif unit_count > quantity
67
+ (unit_count - quantity).times do
68
+ inventory_unit = units_by_variant[variant.id].pop
69
+ inventory_unit.restock!
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,32 @@
1
+ LineItem.class_eval do
2
+ def validate
3
+ unless quantity && quantity >= 0
4
+ errors.add(:quantity, I18n.t("validation.must_be_non_negative"))
5
+ end
6
+ # avoid reload of order.inventory_units by using direct lookup
7
+ unless !Spree::Config[:track_inventory_levels] ||
8
+ Spree::Config[:allow_backorders] ||
9
+ order && InventoryUnit.order_id_equals(order).first.present? ||
10
+ variant && quantity <= variant.on_hand
11
+ errors.add(:quantity, I18n.t("validation.is_too_large") + " (#{self.variant.name})")
12
+ end
13
+
14
+ return unless variant
15
+
16
+ =begin # Commented out until Order#shipped_units not restored in the Core
17
+ if variant.product.assembly?
18
+ variant.product.parts.each do |part|
19
+ if shipped_count = order.shipped_units.nil? ? nil : order.shipped_units[part]
20
+ errors.add(:quantity, I18n.t("validation.cannot_be_less_than_shipped_units") ) if quantity < shipped_count
21
+ end
22
+ end
23
+ else
24
+ if shipped_count = order.shipped_units.nil? ? nil : order.shipped_units[variant]
25
+ errors.add(:quantity, I18n.t("validation.cannot_be_less_than_shipped_units") ) if quantity < shipped_count
26
+ end
27
+ end
28
+ =end
29
+
30
+ end
31
+
32
+ end
@@ -0,0 +1,91 @@
1
+ Product.class_eval do
2
+
3
+ has_and_belongs_to_many :assemblies, :class_name => "Product",
4
+ :join_table => "assemblies_parts",
5
+ :foreign_key => "part_id", :association_foreign_key => "assembly_id"
6
+
7
+ has_and_belongs_to_many :parts, :class_name => "Variant",
8
+ :join_table => "assemblies_parts",
9
+ :foreign_key => "assembly_id", :association_foreign_key => "part_id"
10
+
11
+
12
+ scope :individual_saled, where(["products.individual_sale = ?", true])
13
+
14
+ scope :active, lambda { |*args|
15
+ not_deleted.individual_saled.available(args.first)
16
+ }
17
+
18
+
19
+ # returns the number of inventory units "on_hand" for this product
20
+ def on_hand_with_assembly
21
+ if self.assembly? && Spree::Config[:track_inventory_levels]
22
+ parts.map{|v| v.on_hand / self.count_of(v) }.min
23
+ else
24
+ on_hand_without_assembly
25
+ end
26
+ end
27
+ alias_method_chain :on_hand, :assembly
28
+
29
+ alias_method :orig_on_hand=, :on_hand=
30
+ def on_hand=(new_level)
31
+ self.orig_on_hand=(new_level) unless self.assembly?
32
+ end
33
+
34
+ alias_method :orig_has_stock?, :has_stock?
35
+ def has_stock?
36
+ if self.assembly? && Spree::Config[:track_inventory_levels]
37
+ !parts.detect{|v| self.count_of(v) > v.on_hand}
38
+ else
39
+ self.orig_has_stock?
40
+ end
41
+ end
42
+
43
+ def add_part(variant, count = 1)
44
+ ap = AssembliesPart.get(self.id, variant.id)
45
+ unless ap.nil?
46
+ ap.count += count
47
+ ap.save
48
+ else
49
+ self.parts << variant
50
+ set_part_count(variant, count) if count > 1
51
+ end
52
+ end
53
+
54
+ def remove_part(variant)
55
+ ap = AssembliesPart.get(self.id, variant.id)
56
+ unless ap.nil?
57
+ ap.count -= 1
58
+ if ap.count > 0
59
+ ap.save
60
+ else
61
+ ap.destroy
62
+ end
63
+ end
64
+ end
65
+
66
+ def set_part_count(variant, count)
67
+ ap = AssembliesPart.get(self.id, variant.id)
68
+ unless ap.nil?
69
+ if count > 0
70
+ ap.count = count
71
+ ap.save
72
+ else
73
+ ap.destroy
74
+ end
75
+ end
76
+ end
77
+
78
+ def assembly?
79
+ parts.present?
80
+ end
81
+
82
+ def part?
83
+ assemblies.present?
84
+ end
85
+
86
+ def count_of(variant)
87
+ ap = AssembliesPart.get(self.id, variant.id)
88
+ ap ? ap.count : 0
89
+ end
90
+
91
+ end
@@ -0,0 +1,35 @@
1
+ <table class="index">
2
+ <thead>
3
+ <tr>
4
+ <th><%= t("sku") %></th>
5
+ <th><%= t("name") %></th>
6
+ <th><%= t("options") %></th>
7
+ <th><%= t("qty") %></th>
8
+ <th></th>
9
+ </tr>
10
+ </thead>
11
+ <tbody>
12
+ <% parts.each do |part| %>
13
+ <tr id="<%= dom_id(part, :sel)%>">
14
+ <td><%= part.sku %></td>
15
+ <td><%= part.product.name %></td>
16
+ <td><%= variant_options part %></td>
17
+ <td><%= text_field_tag :count, @product.count_of(part) %></td>
18
+ <td class="actions">
19
+ <%= image_tag "spinner.gif", :style => "display:none", :class => "spinner" %>
20
+ <%= link_to(icon('edit') + ' ' + t('update'),
21
+ set_count_admin_product_part_url(@product, part),
22
+ :class => "set_count_admin_product_part_link") %>
23
+
24
+ <%= link_to(icon('delete') + ' ' + t('remove'),
25
+ remove_admin_product_part_url(@product, part),
26
+ :class => "remove_admin_product_part_link") %>
27
+ </td>
28
+ </tr>
29
+ <% end %>
30
+ <% if parts.empty? %>
31
+ <tr><td colspan="5"><%= t('none') %>.</td></tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <%= javascript_tag("subscribe_product_part_links();") if request.xhr? %>
@@ -0,0 +1,55 @@
1
+ <script type="text/javascript">
2
+ function displayRow(){
3
+ var row = document.getElementById("captionRow");
4
+ if (row.style.display == '') row.style.display = 'none';
5
+ else row.style.display = '';
6
+ }
7
+ </script>
8
+ <h4><%= t('available_parts') %></h4>
9
+ <table class="index">
10
+ <thead>
11
+ <tr>
12
+ <th><%= t("name") %></th>
13
+ <th><%= t("options") %></th>
14
+ <th><%= t("qty") %></th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @available_products.each do |product| %>
20
+ <tr id="<%= dom_id(product) %>">
21
+
22
+ <td><%= product.name %></td>
23
+ <td>
24
+ <% if product.has_variants? %>
25
+ <%= select_tag "part[id]",
26
+ options_for_select(product.variants.map { |v| [variant_options(v), v.id] }) %>
27
+ <% else %>
28
+ <%= hidden_field_tag "part[id]", product.master.id %>
29
+ <%= t :no_variants %>
30
+ <% end %>
31
+ </td>
32
+ <td><%= text_field_tag "part[count]", 1 %></td>
33
+ <td class="actions">
34
+ <%= image_tag "spinner.gif", :style => "display:none", :class => "spinner" %>
35
+ <%= link_to(icon('add') + ' ' + t('select'),
36
+ admin_product_parts_path(@product),
37
+ :class => "add_product_part_link") %>
38
+ </td>
39
+ </tr>
40
+ <% end %>
41
+ <% if @available_products.empty? %>
42
+ <tr><td colspan="3"><%= t('no_match_found') %>.</td></tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+
47
+ <%= javascript_tag do %>
48
+ $("a.add_product_part_link").click(function(){
49
+ part_id_val = $('select option:selected', $(this).parent().parent()).val() ||
50
+ $('input:first', $(this).parent().parent()).val();
51
+ params = { part_count : $('input:last', $(this).parent().parent()).val(),
52
+ part_id : part_id_val};
53
+ return make_post_request($(this), params);
54
+ });
55
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <%= render :partial => 'admin/shared/product_sub_menu' %>
2
+
3
+ <%= render :partial => 'admin/shared/product_tabs', :locals => {:current => "Parts"} %>
4
+ <div id="product_parts">
5
+ <%= render :partial => "parts_table", :locals => {:parts => @parts} %>
6
+ </div>
7
+
8
+ <% form_tag('#') do %>
9
+ <label><%= t('search') %>:</label>
10
+ <input id="searchtext" size="25">
11
+ <% end %>
12
+
13
+ <br/>
14
+ <div id="search_hits"></div>
15
+ <% javascript_tag do %>
16
+ function search_for_parts(){
17
+ $.ajax({
18
+ data: {q: $("#searchtext").val() },
19
+ success: function(request){
20
+ $('#search_hits').html(request);
21
+ $('#search_hits').show();
22
+ },
23
+ type: 'post',
24
+ dataType: 'script',
25
+ url: '<%= available_admin_product_parts_url(@product) %>'
26
+ });
27
+ }
28
+ <% end -%>
29
+ <%= javascript_include_tag 'product_assembly' %>
30
+
@@ -0,0 +1,2 @@
1
+ $("#product_parts").html("<%= escape_javascript(render(:partial => "parts_table", :locals => {:parts => @product.parts})) %>");
2
+ $("#search_hits").hide();
@@ -0,0 +1,18 @@
1
+ <p>
2
+ <%= f.label :can_be_part, t("can_be_part")%><br />
3
+ <%= f.check_box(:can_be_part) %>
4
+ </p>
5
+ <p>
6
+ <%= f.label :individual_sale, t("individual_sale")%><br />
7
+ <%= f.check_box(:individual_sale) %>
8
+ </p>
9
+
10
+ <% if @product.assembly? %>
11
+ <% content_for :head do %>
12
+ <script type="text/javascript">
13
+ jQuery(document).ready(function(){
14
+ $("input[id='product_on_hand']").disable().parent().hide();
15
+ });
16
+ </script>
17
+ <% end %>
18
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <li<%= ' class="active"' if current == "Parts" %>>
2
+ <%= link_to t("parts"), admin_product_parts_url(@product) %>
3
+ </li>
4
+
5
+ <% if @product.assembly? %>
6
+ <% content_for :head do %>
7
+ <script type="text/javascript">
8
+ jQuery(document).ready(function(){
9
+ $("ul.product-menu a:contains('<%= t("variants") %>')").parent().hide();
10
+ $("ul.product-menu a:contains('<%= t("option_types") %>')").parent().hide();
11
+ });
12
+ </script>
13
+ <% end %>
14
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <% product = variant.product
2
+ if product.assembly? %>
3
+ <ul class='assembly_parts'>
4
+ <% product.parts.each do |v| %>
5
+ <li>(<%= product.count_of(v) %>) <%= v.name %> (<%= v.sku %>)</li>
6
+ <% end %>
7
+ </ul>
8
+ <% end %>
@@ -0,0 +1,6 @@
1
+ en:
2
+ available_parts: Available parts
3
+ can_be_part: Can be part
4
+ individual_sale: Individual sale
5
+ no_variants: No variants
6
+ parts: Parts
@@ -0,0 +1,6 @@
1
+ fr-FR:
2
+ available_parts: Parties disponibles
3
+ can_be_part: Peut faire partie d'un package
4
+ individual_sale: Vente individuelle
5
+ no_variants: Pas de variants
6
+ parts: Parties
@@ -0,0 +1,6 @@
1
+ ru:
2
+ available_parts: "Доступные составные части"
3
+ can_be_part: "Может входить в состав других продуктов"
4
+ individual_sale: "Может продаваться отдельно"
5
+ no_variants: "Нет вариантов"
6
+ parts: "Составные части"
@@ -0,0 +1,19 @@
1
+ Rails.application.routes.draw do
2
+
3
+ namespace :admin do
4
+ resources :products do
5
+ resources :parts do
6
+ member do
7
+ post :select
8
+ post :remove
9
+ post :set_count
10
+ end
11
+ collection do
12
+ post :available
13
+ get :selected
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ end
@@ -0,0 +1,19 @@
1
+ module SpreeProductAssembly
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("../../templates", __FILE__)
5
+
6
+ desc "Configures your Rails application for use with spree_product_assembly"
7
+
8
+ def copy_migrations
9
+ directory "db"
10
+ end
11
+
12
+ def copy_public
13
+ directory "public"
14
+ end
15
+
16
+ end
17
+ end
18
+ end
19
+
@@ -0,0 +1,13 @@
1
+ class AddManyToManyRelationToProducts < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :assemblies_parts, :id => false do |t|
4
+ t.integer "assembly_id", :null => false
5
+ t.integer "part_id", :null => false
6
+ t.integer "count", :null => false, :default => 1
7
+ end
8
+ end
9
+
10
+ def self.down
11
+ drop_table :assemblies_parts
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class AddPartsFieldsToProducts < ActiveRecord::Migration
2
+ def self.up
3
+ change_table(:products) do |t|
4
+ t.column :can_be_part, :boolean, :default => false, :null => false
5
+ t.column :individual_sale, :boolean, :default => true, :null => false
6
+ end
7
+ end
8
+
9
+ def self.down
10
+ change_table(:products) do |t|
11
+ t.remove :can_be_part
12
+ t.remove :individual_sale
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ $("#searchtext").keypress(function (e) {
2
+ if ((e.which && e.which == 13) || (e.keyCode && e.keyCode == 13)) {
3
+ search_for_parts();
4
+ return false;
5
+ } else {
6
+ return true;
7
+ }
8
+ });
9
+
10
+ $("#searchtext").delayedObserver(function(element, value) {
11
+ search_for_parts();
12
+ }, 0.75)
13
+
14
+ function subscribe_product_part_links()
15
+ {
16
+ $("a.set_count_admin_product_part_link").click(function(){
17
+ params = { count : $("input", $(this).parent().parent()).val() };
18
+ return make_post_request($(this), params);
19
+ });
20
+
21
+ $("a.remove_admin_product_part_link").click(function(){
22
+ return make_post_request($(this), {});
23
+ });
24
+ }
25
+
26
+ function make_post_request(link, post_params)
27
+ {
28
+ spinner = $("img.spinner", link.parent())
29
+ spinner.show();
30
+ $.post(link.attr("href"), post_params,
31
+ function (data, textStatus) { spinner.hide(); },
32
+ "script");
33
+
34
+ return false;
35
+ }
36
+
37
+ subscribe_product_part_links();
@@ -0,0 +1,15 @@
1
+ require 'spree_core'
2
+ require 'spree_product_assembly_hooks'
3
+
4
+ module SpreeProductAssembly
5
+ class Engine < Rails::Engine
6
+ def self.activate
7
+ Dir.glob(File.join(File.dirname(__FILE__), "../app/**/*_decorator*.rb")) do |c|
8
+ Rails.env == "production" ? require(c) : load(c)
9
+ end
10
+ end
11
+ config.to_prepare &method(:activate).to_proc
12
+ config.autoload_paths += %W(#{config.root}/lib)
13
+ end
14
+ end
15
+
@@ -0,0 +1,18 @@
1
+ Deface::Override.new(:virtual_path => "admin/shared/_product_tabs",
2
+ :name => "product_assembly_admin_product_tabs",
3
+ :insert_bottom => "[data-hook='admin_product_tabs']",
4
+ :partial => "admin/shared/product_assembly_product_tabs",
5
+ :disabled => false)
6
+
7
+ Deface::Override.new(:virtual_path => "orders/_line_item",
8
+ :name => "product_assembly_cart_item_description",
9
+ :insert_bottom => "[data-hook='cart_item_description']",
10
+ :partial => "orders/cart_description",
11
+ :disabled => false)
12
+
13
+ Deface::Override.new(:virtual_path => "admin/products/_form",
14
+ :name => "product_assembly_admin_product_form_right",
15
+ :insert_after => "[data-hook='admin_product_form_right'], #admin_product_form_right[data-hook]",
16
+ :partial => "admin/products/product_assembly_fields",
17
+ :disabled => false)
18
+
@@ -0,0 +1,20 @@
1
+ Gem::Specification.new do |s|
2
+ s.platform = Gem::Platform::RUBY
3
+ s.name = 'spree_product_assembly'
4
+ s.version = '0.60.0'
5
+ s.summary = 'Adds oportunity to make bundle of products to your Spree store'
6
+ s.description = 'Adds oportunity to make bundle of products to your Spree store'
7
+ s.required_ruby_version = '>= 1.8.7'
8
+
9
+ s.author = 'Roman Smirnov'
10
+ s.email = 'roman@railsdog.com'
11
+ s.homepage = 'https://github.com/spree/spree-product-assembly'
12
+ # s.rubyforge_project = 'actionmailer'
13
+
14
+ s.files = `git ls-files`.split("\n")
15
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
+ s.require_path = 'lib'
17
+ s.requirements << 'none'
18
+
19
+ s.add_dependency('spree_core', '>= 0.60.0')
20
+ end
@@ -0,0 +1,17 @@
1
+ require 'test/unit'
2
+ # Load the environment
3
+ unless defined? SPREE_ROOT
4
+ ENV["RAILS_ENV"] = "test"
5
+ case
6
+ when ENV["SPREE_ENV_FILE"]
7
+ require File.dirname(ENV["SPREE_ENV_FILE"]) + "/boot"
8
+ when File.dirname(__FILE__) =~ %r{vendor/spree/vendor/extensions}
9
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../../../")}/config/boot"
10
+ else
11
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../")}/config/boot"
12
+ end
13
+ end
14
+ #require "#{SPREE_ROOT}/test/test_helper"
15
+ #Dir["#{SPREE_ROOT}/test/factories/*.rb"].each {|f| require f }
16
+
17
+
@@ -0,0 +1,41 @@
1
+ require 'test/test_helper'
2
+
3
+ class InventoryUnitTest < ActiveSupport::TestCase
4
+ context InventoryUnit do
5
+ setup do
6
+ @product = Factory(:product)
7
+
8
+ @part_product_1 = Factory(:product, :can_be_part => true)
9
+ @part1 = @part_product_1.master
10
+ @part1.on_hand = 5
11
+
12
+ @part_product_2 = Factory(:product, :can_be_part => true)
13
+ @part2 = @part_product_2.master
14
+ @part2.on_hand = 16
15
+
16
+ @product.add_part @part1, 1
17
+ @product.add_part @part2, 4
18
+
19
+ line_item = Factory(:line_item, :variant => @product.master)
20
+
21
+ @order = line_item.order.reload
22
+ end
23
+ context "when sold" do
24
+ setup do
25
+ InventoryUnit.sell_units(@order)
26
+ end
27
+ should "selling parts" do
28
+ assert_equal 4, @part1.on_hand
29
+ assert_equal 12, @part2.on_hand
30
+ end
31
+ should "associate the inventory units with the order" do
32
+ @part1.inventory_units.select{|iu| iu.state == 'sold'}.each do |inv_unit|
33
+ assert_equal @order, inv_unit.order
34
+ end
35
+ @part2.inventory_units.select{|iu| iu.state == 'sold'}.each do |inv_unit|
36
+ assert_equal @order, inv_unit.order
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,313 @@
1
+ # These tests are organized into four product configuration quadrants as follows:
2
+ #
3
+ # product
4
+ # w/o variants
5
+ # w/o inventory
6
+ # w/inventory
7
+ #
8
+ # product
9
+ # w/variants
10
+ # w/o inventory
11
+ # w/inventory
12
+ #
13
+ # Reusable context taken from:
14
+ # http://www.viget.com/extend/reusing-contexts-in-shoulda-with-context-macros/
15
+ #
16
+ # Additionally, some basic tests for Product.new vs. Product.create are defined to test
17
+ # that Product creation vs instantiated behave the normal "rails way"
18
+ #
19
+ require 'test/test_helper'
20
+
21
+ class ProductTest < Test::Unit::TestCase
22
+
23
+ def self.should_pass_basic_tests
24
+ subject { @product }
25
+ should "have a product" do
26
+ assert @product.is_a?(Product)
27
+ end
28
+ should_validate_presence_of :name
29
+ should "have 'Foo Bar' as name" do
30
+ assert_equal @product.name, "Foo Bar"
31
+ end
32
+ should "have 'foo-bar' as permalink" do
33
+ assert_equal "foo-bar", @product.permalink
34
+ end
35
+ should "not change permalink when name changes" do
36
+ @product.update_attributes :name => 'Foo BaZ'
37
+ assert_equal @product.name, 'Foo BaZ'
38
+ assert_equal 'foo-bar', @product.permalink
39
+ end
40
+ should "not obscure deleted_at" do
41
+ assert true, @product.deleted_at.nil?
42
+ end
43
+ should "have a price" do
44
+ assert_equal 19.99, @product.price
45
+ end
46
+ should "have a master price" do
47
+ assert_equal @product.price, @product.master.price
48
+ assert_equal @product.price, @product.price # deprecated, to be removed
49
+ end
50
+ should "change master price when price changes" do
51
+ @product.update_attributes(:price => 30.0)
52
+ assert_equal @product.price, @product.master.price
53
+ assert_equal 30.0, @product.price
54
+ end
55
+ should "change price when master price changes" do
56
+ @product.master.update_attributes(:price => 50.0)
57
+ assert_equal @product.price, @product.master.price
58
+ assert_equal 50.0, @product.price
59
+ end
60
+ should "persist a master variant record" do
61
+ assert_equal @master_variant, @product.master
62
+ end
63
+ should "have a sku" do
64
+ assert_equal 'ABC', @product.sku
65
+ end
66
+ context "when sku is changed" do
67
+ setup { @product.sku = "NEWSKU" }
68
+ should_change("@product.sku", :from => "ABC", :to => "NEWSKU") { @product.sku }
69
+ should_change("@product.master.sku", :from => "ABC", :to => "NEWSKU") { @product.master.sku }
70
+ end
71
+ end
72
+
73
+ def self.context_created_product(&block)
74
+ context "Created Product" do
75
+ setup do
76
+ @product = Factory(:product, :name => "Foo Bar")
77
+ @master_variant = Variant.find_by_product_id(@product.id, :conditions => ["is_master = ?", true])
78
+ end
79
+ teardown do
80
+ @product.destroy
81
+ end
82
+
83
+ merge_block(&block) if block_given?
84
+ end
85
+ end
86
+
87
+ def self.context_without_variants(&block)
88
+ context "without variants" do
89
+ should_pass_basic_tests
90
+ should "return false for has_variants?" do
91
+ assert !@product.has_variants?
92
+ end
93
+
94
+ merge_block(&block) if block_given?
95
+ end
96
+ end
97
+
98
+ def self.context_with_variants(&block)
99
+ context "with variants" do
100
+ setup do
101
+ @product.variants << Factory(:variant)
102
+ @first_variant = @product.variants.first
103
+ end
104
+ teardown { @first_variant.destroy }
105
+ should_pass_basic_tests
106
+ should "have variants" do
107
+ assert @product.has_variants?
108
+ assert @first_variant.is_a?(Variant)
109
+ end
110
+ should "return true for has_variants?" do
111
+ assert @product.has_variants?
112
+ end
113
+
114
+ merge_block(&block) if block_given?
115
+ end
116
+ end
117
+
118
+ def self.context_without_inventory_units(&block)
119
+ context "without inventory units" do
120
+ should_pass_basic_tests
121
+ should "return zero on_hand value" do
122
+ assert_equal 0, @product.on_hand
123
+ end
124
+ should "return true for master.has_stock?" do
125
+ assert !@product.master.in_stock?
126
+ end
127
+ should "return false for has_stock?" do
128
+ assert !@product.has_stock?
129
+ end
130
+
131
+ merge_block(&block) if block_given?
132
+ end
133
+ end
134
+
135
+ def self.should_pass_inventory_tests
136
+ should "return true for has_stock?" do
137
+ assert @product.has_stock?
138
+ end
139
+ should "have on_hand greater than zero" do
140
+ assert @product.on_hand > 0
141
+ end
142
+ end
143
+
144
+ context "New Product" do
145
+ setup do
146
+ @product = Factory.build(:product)
147
+ end
148
+
149
+ should_not_change("Product.count") { Product.count }
150
+ should_not_change("Variant.count") { Variant.count }
151
+ should "not have a product id" do
152
+ assert @product.id.nil?
153
+ end
154
+ end
155
+
156
+ context "New Product instantiated with on_hand" do
157
+ setup do
158
+ @product = Product.new(:name => "fubaz", :price => "10.0", :on_hand => 5)
159
+ end
160
+ should "not have a product id" do
161
+ assert @product.id.nil?
162
+ end
163
+ should_not_change("Product.count") { Product.count }
164
+ should_not_change("Variant.count") { Variant.count }
165
+ should_not_change("InventoryUnit.count") { InventoryUnit.count }
166
+ should "have a Product class" do
167
+ assert @product.is_a?(Product)
168
+ end
169
+ should "have specified on_hand" do
170
+ assert_equal 5, @product.on_hand
171
+ end
172
+ end
173
+
174
+ context "Product created with on_hand" do
175
+ setup do
176
+ @product = Product.create(:name => "fubaz", :price => "10.0", :on_hand => 7)
177
+ end
178
+ teardown do
179
+ @product.master.inventory_units.destroy_all
180
+ @product.destroy
181
+ end
182
+ should_change("InventoryUnit.count", :by => 7) { InventoryUnit.count }
183
+ end
184
+
185
+ context_created_product do
186
+ context_without_variants do
187
+ context_without_inventory_units do
188
+
189
+ end
190
+ context "with inventory units" do
191
+ setup { @product.master.inventory_units << Factory(:inventory_unit) }
192
+ teardown { @product.master.inventory_units.destroy_all }
193
+ should_pass_inventory_tests
194
+ should "be true for has_stock?" do
195
+ assert @product.has_stock?
196
+ assert @product.master.in_stock?
197
+ end
198
+ context "when on_hand is increased" do
199
+ setup { @product.update_attribute("on_hand", 5) }
200
+ should_change("InventoryUnit.count", :by => 4) { InventoryUnit.count }
201
+ should "have the specified on_hand" do
202
+ assert_equal 5, @product.on_hand
203
+ end
204
+ end
205
+ context "when on_hand is decreased" do
206
+ setup { @product.on_hand = 3 }
207
+ should_change("InventoryUnit.count", :by => 2) { InventoryUnit.count }
208
+ should "have the specified on_hand" do
209
+ assert_equal 3, @product.on_hand
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ context_created_product do
217
+ context_with_variants do
218
+ context_without_inventory_units
219
+ context "with inventory units" do
220
+ setup do
221
+ @first_variant.inventory_units << Factory(:inventory_unit)
222
+ end
223
+ teardown { @first_variant.inventory_units.destroy_all }
224
+ should_pass_inventory_tests
225
+ should "be true for has_stock?" do
226
+ assert !@product.master.in_stock?
227
+ assert @first_variant.in_stock?
228
+ assert @product.has_stock?
229
+ end
230
+ should "have one inventory unit initially" do
231
+ assert 1, @first_variant.inventory_units.count
232
+ end
233
+ context "when on_hand is increased" do
234
+ setup { @first_variant.on_hand = 5 }
235
+ should_change("InventoryUnit.count", :by => 4) { InventoryUnit.count }
236
+ should "have the specified on_hand" do
237
+ assert_equal 5, @product.on_hand
238
+ end
239
+ end
240
+ context "when on_hand is decreased" do
241
+ setup { @first_variant.on_hand = 3 }
242
+ should_change("InventoryUnit.count", :by => 2) { InventoryUnit.count }
243
+ should "have the specified on_hand" do
244
+ assert_equal 3, @product.on_hand
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ context "Product.available" do
252
+ setup do
253
+ 5.times { Factory(:product, :available_on => Time.now - 1.day) }
254
+ Factory(:product, :available_on => Time.now - 15.minutes)
255
+ @future_product = Factory.create(:product, :available_on => Time.now + 2.weeks)
256
+ end
257
+ teardown do
258
+ Product.available.destroy_all
259
+ @future_product.destroy
260
+ end
261
+ should "only include available products" do
262
+ assert_equal 6, Product.available.size
263
+ assert !Product.available.include?(@future_product)
264
+ end
265
+ end
266
+
267
+ context "instance" do
268
+ setup { @product = Factory(:product, :price => 19.99) }
269
+ context "with a change in price" do
270
+ setup do
271
+ @product.price = 1.11
272
+ @product.save
273
+ end
274
+ should "change the save the new master price" do
275
+ assert_equal BigDecimal.new("1.11"), @product.reload.price
276
+ end
277
+ end
278
+ end
279
+
280
+ context "Product Assembly" do
281
+ setup do
282
+ @product = Factory(:product)
283
+
284
+ @part_product_1 = Factory(:product, :can_be_part => true)
285
+ @part1 = @part_product_1.master
286
+ @part1.on_hand = 5
287
+
288
+ @part_product_2 = Factory(:product, :can_be_part => true)
289
+ @part2 = @part_product_2.master
290
+ @part2.on_hand = 16
291
+
292
+ @product.add_part @part1, 1
293
+ @product.add_part @part2, 4
294
+ end
295
+ should "be assembly" do
296
+ assert @product.assembly?
297
+ assert_equal @product.on_hand, 4 # min_of(5/1, 16/4)
298
+ end
299
+ context "setting on_hand" do
300
+ setup { @product.on_hand = 100 }
301
+ should_not_change("InventoryUnit.count") { InventoryUnit.count }
302
+ should_not_change("count on_hand") { @product.on_hand }
303
+ end
304
+ context "change count of part" do
305
+ setup { @product.set_part_count(@part2, 2) }
306
+ should "be equal 2" do
307
+ assert_equal @product.count_of(@part2), 2
308
+ end
309
+ should_change("count on_hand", :by => 1) { @product.on_hand }
310
+ end
311
+ end
312
+
313
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_product_assembly
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 60
8
+ - 0
9
+ version: 0.60.0
10
+ platform: ruby
11
+ authors:
12
+ - Roman Smirnov
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2011-05-25 00:00:00 +04:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: spree_core
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 0
29
+ - 60
30
+ - 0
31
+ version: 0.60.0
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ description: Adds oportunity to make bundle of products to your Spree store
35
+ email: roman@railsdog.com
36
+ executables: []
37
+
38
+ extensions: []
39
+
40
+ extra_rdoc_files: []
41
+
42
+ files:
43
+ - README.markdown
44
+ - Rakefile
45
+ - Versionfile
46
+ - app/controllers/admin/parts_controller.rb
47
+ - app/helpers/admin/parts_helper.rb
48
+ - app/models/assemblies_part.rb
49
+ - app/models/inventory_unit_decorator.rb
50
+ - app/models/line_item_decorator.rb
51
+ - app/models/product_decorator.rb
52
+ - app/views/admin/parts/_parts_table.html.erb
53
+ - app/views/admin/parts/available.js.erb
54
+ - app/views/admin/parts/index.html.erb
55
+ - app/views/admin/parts/update_parts_table.js.erb
56
+ - app/views/admin/products/_product_assembly_fields.html.erb
57
+ - app/views/admin/shared/_product_assembly_product_tabs.html.erb
58
+ - app/views/orders/_cart_description.html.erb
59
+ - config/locales/en.yml
60
+ - config/locales/fr-FR.yml
61
+ - config/locales/ru.yml
62
+ - config/routes.rb
63
+ - lib/generators/spree_product_assembly/install_generator.rb
64
+ - lib/generators/templates/db/migrate/20091028152124_add_many_to_many_relation_to_products.rb
65
+ - lib/generators/templates/db/migrate/20091029165620_add_parts_fields_to_products.rb
66
+ - lib/generators/templates/public/javascripts/product_assembly.js
67
+ - lib/spree_product_assembly.rb
68
+ - lib/spree_product_assembly_hooks.rb
69
+ - spree_product_assembly.gemspec
70
+ - test/test_helper.rb
71
+ - test/unit/inventory_unit_test.rb
72
+ - test/unit/product_test.rb
73
+ has_rdoc: true
74
+ homepage: https://github.com/spree/spree-product-assembly
75
+ licenses: []
76
+
77
+ post_install_message:
78
+ rdoc_options: []
79
+
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ segments:
87
+ - 1
88
+ - 8
89
+ - 7
90
+ version: 1.8.7
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ segments:
96
+ - 0
97
+ version: "0"
98
+ requirements:
99
+ - none
100
+ rubyforge_project:
101
+ rubygems_version: 1.3.6
102
+ signing_key:
103
+ specification_version: 3
104
+ summary: Adds oportunity to make bundle of products to your Spree store
105
+ test_files: []
106
+