spree_core 5.4.0.rc6 → 5.4.0.rc7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/concerns/spree/default_price.rb +108 -6
  3. data/app/models/spree/exports/product_translations.rb +45 -0
  4. data/app/models/spree/import_schemas/product_translations.rb +14 -0
  5. data/app/models/spree/imports/product_translations.rb +17 -0
  6. data/app/models/spree/metafield.rb +1 -1
  7. data/app/models/spree/metafield_definition.rb +10 -0
  8. data/app/models/spree/option_type.rb +9 -1
  9. data/app/models/spree/option_value.rb +9 -1
  10. data/app/models/spree/order.rb +1 -1
  11. data/app/models/spree/price.rb +1 -1
  12. data/app/models/spree/product.rb +40 -13
  13. data/app/models/spree/stock_movement.rb +2 -1
  14. data/app/models/spree/variant.rb +13 -11
  15. data/app/presenters/spree/csv/product_translation_presenter.rb +31 -0
  16. data/app/presenters/spree/csv/product_variant_presenter.rb +16 -3
  17. data/app/services/spree/imports/row_processors/product_translation.rb +45 -0
  18. data/app/services/spree/imports/row_processors/product_variant.rb +11 -3
  19. data/app/services/spree/products/prepare_nested_attributes.rb +18 -1
  20. data/app/services/spree/sample_data/loader.rb +13 -0
  21. data/app/views/spree/shared/_mailer_line_item.html.erb +2 -2
  22. data/config/locales/en.yml +1 -0
  23. data/db/sample_data/markets.rb +30 -0
  24. data/db/sample_data/product_translations.csv +75 -0
  25. data/db/sample_data/products.csv +88 -1
  26. data/lib/spree/core/configuration.rb +1 -0
  27. data/lib/spree/core/controller_helpers/order.rb +12 -10
  28. data/lib/spree/core/engine.rb +2 -0
  29. data/lib/spree/core/permission_configuration.rb +12 -0
  30. data/lib/spree/core/pricing/resolver.rb +5 -3
  31. data/lib/spree/core/version.rb +1 -1
  32. data/lib/spree/testing_support/authorization_helpers.rb +16 -16
  33. data/lib/spree/testing_support/factories/import_factory.rb +12 -0
  34. data/lib/spree/testing_support/factories/product_factory.rb +12 -2
  35. data/lib/spree/testing_support/factories/variant_factory.rb +8 -2
  36. data/spec/fixtures/files/product_translations_import.csv +3 -0
  37. metadata +12 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10b8425a8850b155fa6d2a2d6d452e016fd142d62849ce757e00b4b2db1efcdc
4
- data.tar.gz: 38b6187ef6b92566261d617d65e3eb59d2e760eca6d3519390189a40858a62c3
3
+ metadata.gz: 07eb5e06c1dbe2e9d89b69a3768a376fcf5d23f0be28bb15bd4e9fac1ce0bcaf
4
+ data.tar.gz: 99e243c978fb95cfd04e4b8ee42cc42b63f3580493939b4271be7992006c2bbb
5
5
  SHA512:
6
- metadata.gz: 76058003b7c03e556c6f7d035fa6b1283688927ad6102cacd955932440e81054a8ace08032fc593545b897bdc321ca5d6ae2e720170468c9e64adee5b7083a53
7
- data.tar.gz: 1783fc54faf9da589237ac09af8f665a2d145382eb6246911eecdfc2abdb26fce3d780e47f180fa6644a534164236232b4b30e3d8392c4fb869843405dfd2eaa
6
+ metadata.gz: 2967ba8a3825d98180adb93f0a025370c6aa6aee76520cf5400be56bfb6328b63f121434065f2acf8b600099f53136b067becec17c0f1523078dedd95cc4ccd5
7
+ data.tar.gz: ca2d9d12300bd8a765b3130cf86fd857d8c8b0ad7cc413f895956a806a642d5ef44c20983b08d227de1fff80b9bcfd68995173061fc60849ebe5dab185631ffb
@@ -2,24 +2,126 @@ module Spree
2
2
  module DefaultPrice
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ DEPRECATION_MSG = 'Spree::DefaultPrice is deprecated and will be removed in Spree 6.0. ' \
6
+ 'Use variant.set_price(currency, amount) and variant.price_in(currency) instead.'
7
+
5
8
  included do
6
9
  has_one :default_price,
7
10
  -> { with_deleted.where(currency: Spree::Store.default.default_currency) },
8
11
  class_name: 'Spree::Price',
9
12
  dependent: :destroy
10
13
 
11
- delegate :display_price, :display_amount, :price, :currency, :price=,
12
- :price_including_vat_for, :currency=, :display_compare_at_price,
13
- :compare_at_price, :compare_at_price=, to: :find_or_build_default_price
14
+ after_save :save_default_price, if: -> { Spree::Config.enable_legacy_default_price }
15
+
16
+ def price
17
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
18
+ if Spree::Config.enable_legacy_default_price
19
+ find_or_build_default_price.price
20
+ else
21
+ price_in(Spree::Store.default.default_currency).amount
22
+ end
23
+ end
24
+
25
+ def price=(value)
26
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
27
+ if Spree::Config.enable_legacy_default_price
28
+ find_or_build_default_price.price = value
29
+ else
30
+ set_price(Spree::Store.default.default_currency, value)
31
+ end
32
+ end
33
+
34
+ def currency
35
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
36
+ if Spree::Config.enable_legacy_default_price
37
+ find_or_build_default_price.currency
38
+ else
39
+ Spree::Store.default.default_currency
40
+ end
41
+ end
42
+
43
+ def currency=(value)
44
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
45
+ if Spree::Config.enable_legacy_default_price
46
+ find_or_build_default_price.currency = value
47
+ end
48
+ # no-op when legacy is disabled — currency is determined by the store
49
+ end
50
+
51
+ def display_price
52
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
53
+ if Spree::Config.enable_legacy_default_price
54
+ find_or_build_default_price.display_price
55
+ else
56
+ price_in(Spree::Store.default.default_currency).display_amount
57
+ end
58
+ end
59
+
60
+ def display_amount
61
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
62
+ if Spree::Config.enable_legacy_default_price
63
+ find_or_build_default_price.display_amount
64
+ else
65
+ price_in(Spree::Store.default.default_currency).display_amount
66
+ end
67
+ end
68
+
69
+ def compare_at_price
70
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
71
+ if Spree::Config.enable_legacy_default_price
72
+ find_or_build_default_price.compare_at_price
73
+ else
74
+ price_in(Spree::Store.default.default_currency).compare_at_amount
75
+ end
76
+ end
14
77
 
15
- after_save :save_default_price
78
+ def compare_at_price=(value)
79
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
80
+ if Spree::Config.enable_legacy_default_price
81
+ find_or_build_default_price.compare_at_price = value
82
+ else
83
+ default_currency = Spree::Store.default.default_currency
84
+ price_record = price_in(default_currency)
85
+ price_record.compare_at_amount = value
86
+ price_record.save! if price_record.persisted?
87
+ end
88
+ end
89
+
90
+ def display_compare_at_price
91
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
92
+ if Spree::Config.enable_legacy_default_price
93
+ find_or_build_default_price.display_compare_at_price
94
+ else
95
+ price_in(Spree::Store.default.default_currency).display_compare_at_amount
96
+ end
97
+ end
98
+
99
+ def price_including_vat_for(price_options)
100
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
101
+ if Spree::Config.enable_legacy_default_price
102
+ find_or_build_default_price.price_including_vat_for(price_options)
103
+ else
104
+ price_in(Spree::Store.default.default_currency).price_including_vat_for(price_options)
105
+ end
106
+ end
16
107
 
17
108
  def has_default_price?
18
- !default_price.nil?
109
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
110
+ if Spree::Config.enable_legacy_default_price
111
+ !default_price.nil?
112
+ else
113
+ prices.base_prices.any? { |p| p.currency == Spree::Store.default.default_currency }
114
+ end
19
115
  end
20
116
 
21
117
  def find_or_build_default_price
22
- default_price || build_default_price
118
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
119
+ if Spree::Config.enable_legacy_default_price
120
+ default_price || build_default_price
121
+ else
122
+ prices.base_prices.find { |p| p.currency == Spree::Store.default.default_currency } ||
123
+ prices.build(currency: Spree::Store.default.default_currency)
124
+ end
23
125
  end
24
126
 
25
127
  private
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module Exports
3
+ class ProductTranslations < Spree::Export
4
+ def scope_includes
5
+ []
6
+ end
7
+
8
+ def multi_line_csv?
9
+ true
10
+ end
11
+
12
+ def scope
13
+ if search_params.nil?
14
+ super.where.not(status: 'archived')
15
+ else
16
+ super
17
+ end
18
+ end
19
+
20
+ def csv_headers
21
+ Spree::CSV::ProductTranslationPresenter::CSV_HEADERS
22
+ end
23
+
24
+ def generate_csv
25
+ locales = store.supported_locales_list - [store.default_locale]
26
+ return super if locales.empty?
27
+
28
+ ::CSV.open(export_tmp_file_path, 'wb', encoding: 'UTF-8', col_sep: ',', row_sep: "\r\n") do |csv|
29
+ csv << csv_headers
30
+ records_to_export.includes(scope_includes).find_in_batches do |batch|
31
+ batch.each do |product|
32
+ product.to_translation_csv(store, locales).each do |line|
33
+ csv << line
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def model_class
41
+ Spree::Product
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ module Spree
2
+ module ImportSchemas
3
+ class ProductTranslations < Spree::ImportSchema
4
+ FIELDS = [
5
+ { name: 'slug', label: 'Slug', required: true },
6
+ { name: 'locale', label: 'Locale', required: true },
7
+ { name: 'name', label: 'Name' },
8
+ { name: 'description', label: 'Description' },
9
+ { name: 'meta_title', label: 'Meta Title' },
10
+ { name: 'meta_description', label: 'Meta Description' }
11
+ ].freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Spree
2
+ module Imports
3
+ class ProductTranslations < Spree::Import
4
+ def row_processor_class
5
+ Spree::Imports::RowProcessors::ProductTranslation
6
+ end
7
+
8
+ def model_class
9
+ Spree::Product
10
+ end
11
+
12
+ def self.model_class
13
+ Spree::Product
14
+ end
15
+ end
16
+ end
17
+ end
@@ -11,7 +11,7 @@ module Spree
11
11
  #
12
12
  # Delegations
13
13
  #
14
- delegate :key, :full_key, :name, :display_on, to: :metafield_definition, allow_nil: true
14
+ delegate :key, :full_key, :name, :label, :display_on, to: :metafield_definition, allow_nil: true
15
15
 
16
16
  #
17
17
  # Callbacks
@@ -51,6 +51,16 @@ module Spree
51
51
  self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
52
52
  self.whitelisted_ransackable_scopes = %w[search multi_search]
53
53
 
54
+ # 5.5 API naming bridge (DB column rename in 6.0)
55
+ # Aligns with OptionType/OptionValue which also expose `label` for the display name.
56
+ def label
57
+ name
58
+ end
59
+
60
+ def label=(value)
61
+ self.name = value
62
+ end
63
+
54
64
  # Returns the full key with namespace
55
65
  # @return [String] eg. custom.id
56
66
  def full_key
@@ -35,7 +35,15 @@ module Spree
35
35
  has_many :prototypes, through: :option_type_prototypes, class_name: 'Spree::Prototype'
36
36
 
37
37
  # 5.5 API naming bridge (DB column rename in 6.0)
38
- alias_attribute :label, :presentation
38
+ # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
39
+ # so we define explicit delegating methods instead.
40
+ def label(*args)
41
+ presentation(*args)
42
+ end
43
+
44
+ def label=(value)
45
+ self.presentation = value
46
+ end
39
47
 
40
48
  #
41
49
  # Validations
@@ -34,7 +34,15 @@ module Spree
34
34
  has_many :products, through: :variants, class_name: 'Spree::Product'
35
35
 
36
36
  # 5.5 API naming bridge (DB column rename in 6.0)
37
- alias_attribute :label, :presentation
37
+ # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
38
+ # so we define explicit delegating methods instead.
39
+ def label(*args)
40
+ presentation(*args)
41
+ end
42
+
43
+ def label=(value)
44
+ self.presentation = value
45
+ end
38
46
 
39
47
  #
40
48
  # Validations
@@ -54,7 +54,7 @@ module Spree
54
54
  alias display_ship_total display_shipment_total
55
55
  alias_attribute :ship_total, :shipment_total
56
56
  def amount_due
57
- outstanding_balance
57
+ [outstanding_balance - total_applied_store_credit, 0].max
58
58
  end
59
59
 
60
60
  # Transient warnings populated by remove_out_of_stock_items!
@@ -135,7 +135,7 @@ module Spree
135
135
  #
136
136
  # @return [Boolean]
137
137
  def discounted?
138
- compare_at_amount.to_i.positive? && compare_at_amount > amount
138
+ compare_at_amount.to_i.positive? && amount.present? && compare_at_amount > amount
139
139
  end
140
140
 
141
141
  # returns true if the price was discounted
@@ -604,19 +604,40 @@ module Spree
604
604
  taxons_for_csv.fill(nil, taxons_for_csv.size...3)
605
605
 
606
606
  csv_lines = []
607
+ all_variants = has_variants? ? variants_including_master.to_a : [master]
608
+ default_currency = store.default_currency
609
+ additional_currencies = store.supported_currencies_list.map(&:iso_code) - [default_currency]
610
+
611
+ # Primary rows in the store's default currency
612
+ all_variants.each_with_index do |variant, index|
613
+ csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, index, properties_for_csv, taxons_for_csv, store,
614
+ metafields_for_csv).call
615
+ end
607
616
 
608
- if has_variants?
609
- variants_including_master.each_with_index do |variant, index|
610
- csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, index, properties_for_csv, taxons_for_csv, store,
611
- metafields_for_csv).call
617
+ # Price-only rows for each additional currency
618
+ additional_currencies.each do |currency|
619
+ all_variants.each do |variant|
620
+ next unless variant.amount_in(currency)
621
+
622
+ csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, 0, [], [], store,
623
+ [], currency).call
612
624
  end
613
- else
614
- csv_lines << Spree::CSV::ProductVariantPresenter.new(self, master, 0, properties_for_csv, taxons_for_csv, store, metafields_for_csv).call
615
625
  end
616
626
 
617
627
  csv_lines
618
628
  end
619
629
 
630
+ def to_translation_csv(store = nil, locales = [])
631
+ locales.filter_map do |locale|
632
+ # Only export if at least one field has a translation for this locale
633
+ has_translation = Spree::CSV::ProductTranslationPresenter::TRANSLATABLE_FIELDS.any? do |field|
634
+ get_field_with_locale(locale, field).present?
635
+ end
636
+
637
+ Spree::CSV::ProductTranslationPresenter.new(self, locale).call if has_translation
638
+ end
639
+ end
640
+
620
641
  private
621
642
 
622
643
  # Determines which variant should be used for displaying media.
@@ -652,11 +673,12 @@ module Spree
652
673
  values = option_values_hash.values
653
674
  values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
654
675
 
676
+ default_currency = stores.first&.default_currency || Spree::Store.default.default_currency
677
+ master_price = master.price_in(default_currency).amount
678
+
655
679
  values.each do |ids|
656
- variants.create(
657
- option_value_ids: ids,
658
- price: master.price
659
- )
680
+ variant = variants.create!(option_value_ids: ids)
681
+ variant.set_price(default_currency, master_price) if master_price.present?
660
682
  end
661
683
  save
662
684
  end
@@ -690,6 +712,7 @@ module Spree
690
712
  master.new_record? ||
691
713
  master.changed? ||
692
714
  (
715
+ Spree::Config.enable_legacy_default_price &&
693
716
  master.default_price &&
694
717
  (
695
718
  master.default_price.new_record? ||
@@ -712,9 +735,13 @@ module Spree
712
735
  # If the master cannot be saved, the Product object will get its errors
713
736
  # and will be destroyed
714
737
  def validate_master
715
- # We call master.default_price here to ensure price is initialized.
716
- # Required to avoid Variant#check_price validation failing on create.
717
- unless master.default_price && master.valid?
738
+ if Spree::Config.enable_legacy_default_price
739
+ # We call master.default_price here to ensure price is initialized.
740
+ # Required to avoid Variant#check_price validation failing on create.
741
+ master.default_price
742
+ end
743
+
744
+ unless master.valid?
718
745
  master.errors.map { |error| { field: error.attribute, message: error&.message } }.each do |err|
719
746
  next if err[:field].blank? || err[:message].blank?
720
747
 
@@ -31,7 +31,8 @@ module Spree
31
31
  delegate :variant, :variant_id, to: :stock_item, allow_nil: true
32
32
  delegate :product, to: :variant
33
33
 
34
- self.whitelisted_ransackable_attributes = ['quantity']
34
+ self.whitelisted_ransackable_attributes = %w[quantity action created_at stock_item_id originator_type]
35
+ self.whitelisted_ransackable_associations = %w[stock_item]
35
36
 
36
37
  def readonly?
37
38
  persisted?
@@ -62,14 +62,13 @@ module Spree
62
62
 
63
63
  before_validation :set_cost_currency
64
64
 
65
- validate :check_price
65
+ validate :check_price, if: -> { Spree::Config.enable_legacy_default_price }
66
66
 
67
67
  validates :option_value_variants, presence: true, unless: :is_master?
68
68
 
69
- with_options numericality: { greater_than_or_equal_to: 0, allow_nil: true } do
70
- validates :cost_price
71
- validates :price
72
- end
69
+ validates :cost_price, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
70
+ validates :price, numericality: { greater_than_or_equal_to: 0, allow_nil: true },
71
+ if: -> { Spree::Config.enable_legacy_default_price }
73
72
  validates :sku, uniqueness: { conditions: -> { where(deleted_at: nil) }, case_sensitive: false, scope: spree_base_uniqueness_scope },
74
73
  allow_blank: true, unless: :disable_sku_validation?
75
74
 
@@ -447,8 +446,8 @@ module Spree
447
446
  def set_price(currency, amount, compare_at_amount = nil)
448
447
  price = prices.base_prices.find_or_initialize_by(currency: currency)
449
448
  price.amount = amount
450
- price.compare_at_amount = compare_at_amount if compare_at_amount.present?
451
- price.save!
449
+ price.compare_at_amount = compare_at_amount
450
+ price.save! if persisted?
452
451
  end
453
452
 
454
453
  # Returns the price for the given context or options.
@@ -607,18 +606,21 @@ module Spree
607
606
 
608
607
  # Ensures a new variant takes the product master price when price is not supplied
609
608
  def check_price
610
- return if (has_default_price? && default_price.valid?) || prices.any?
609
+ return if prices.any?
611
610
 
612
611
  infer_price_from_default_variant_if_needed
613
- self.currency = Spree::Store.default.default_currency if price.present? && currency.nil?
614
612
  end
615
613
 
616
614
  def infer_price_from_default_variant_if_needed
617
- if price.nil?
615
+ default_currency = Spree::Store.default.default_currency
616
+ current_price = price_in(default_currency).amount
617
+
618
+ if current_price.nil?
618
619
  return errors.add(:base, :no_master_variant_found_to_infer_price) unless product&.master
619
620
 
620
621
  # At this point, master can have or have no price, so let's use price from the default variant
621
- self.price = product.default_variant.price
622
+ inferred_price = product.default_variant.price_in(default_currency).amount
623
+ set_price(default_currency, inferred_price) if inferred_price.present?
622
624
  end
623
625
  end
624
626
 
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module CSV
3
+ class ProductTranslationPresenter
4
+ CSV_HEADERS = %w[
5
+ slug
6
+ locale
7
+ name
8
+ description
9
+ meta_title
10
+ meta_description
11
+ ].freeze
12
+
13
+ TRANSLATABLE_FIELDS = %i[name description meta_title meta_description].freeze
14
+
15
+ def initialize(product, locale)
16
+ @product = product
17
+ @locale = locale.to_s
18
+ end
19
+
20
+ attr_reader :product, :locale
21
+
22
+ def call
23
+ [
24
+ product.slug,
25
+ locale,
26
+ *TRANSLATABLE_FIELDS.map { |field| product.get_field_with_locale(locale, field) }
27
+ ]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -47,18 +47,19 @@ module Spree
47
47
  'category3',
48
48
  ].freeze
49
49
 
50
- def initialize(product, variant, index = 0, properties = [], taxons = [], store = nil, metafields = [])
50
+ def initialize(product, variant, index = 0, properties = [], taxons = [], store = nil, metafields = [], currency = nil)
51
51
  @product = product
52
52
  @variant = variant
53
53
  @index = index
54
54
  @properties = properties
55
55
  @taxons = taxons
56
56
  @store = store || product.stores.first
57
- @currency = @store.default_currency
57
+ @currency = currency || @store.default_currency
58
+ @price_only = @currency != @store.default_currency
58
59
  @metafields = metafields
59
60
  end
60
61
 
61
- attr_accessor :product, :variant, :index, :properties, :taxons, :store, :currency, :metafields
62
+ attr_accessor :product, :variant, :index, :properties, :taxons, :store, :currency, :price_only, :metafields
62
63
 
63
64
  ##
64
65
  # Generates an array representing a CSV row of product variant data.
@@ -72,6 +73,8 @@ module Spree
72
73
  #
73
74
  # @return [Array] An array containing the combined product and variant CSV data.
74
75
  def call
76
+ return price_only_row if price_only
77
+
75
78
  total_on_hand = variant.total_on_hand
76
79
 
77
80
  csv = [
@@ -134,6 +137,16 @@ module Spree
134
137
 
135
138
  private
136
139
 
140
+ def price_only_row
141
+ csv = Array.new(CSV_HEADERS.size)
142
+ csv[CSV_HEADERS.index('sku')] = variant.sku
143
+ csv[CSV_HEADERS.index('slug')] = product.slug
144
+ csv[CSV_HEADERS.index('price')] = variant.amount_in(currency)&.to_f
145
+ csv[CSV_HEADERS.index('compare_at_price')] = variant.compare_at_amount_in(currency)&.to_f
146
+ csv[CSV_HEADERS.index('currency')] = currency
147
+ csv
148
+ end
149
+
137
150
  def image_url_options
138
151
  { variant: :xlarge }
139
152
  end
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module Imports
3
+ module RowProcessors
4
+ class ProductTranslation < Base
5
+ TRANSLATABLE_FIELDS = %w[name description meta_title meta_description].freeze
6
+
7
+ def initialize(row)
8
+ super
9
+ @store = row.store
10
+ end
11
+
12
+ attr_reader :store
13
+
14
+ def process!
15
+ locale = attributes['locale'].to_s.strip
16
+ raise ArgumentError, 'Locale is required' if locale.blank?
17
+
18
+ slug = attributes['slug'].to_s.strip
19
+ raise ArgumentError, 'Slug is required' if slug.blank?
20
+
21
+ product = product_scope.find_by!(slug: slug)
22
+
23
+ translation_attrs = TRANSLATABLE_FIELDS.each_with_object({}) do |field, hash|
24
+ value = attributes[field]
25
+ hash[field.to_sym] = value.to_s.strip if value.present?
26
+ end
27
+
28
+ return product if translation_attrs.empty?
29
+
30
+ Mobility.with_locale(locale) do
31
+ product.update!(translation_attrs)
32
+ end
33
+
34
+ product
35
+ end
36
+
37
+ private
38
+
39
+ def product_scope
40
+ Spree::Product.accessible_by(import.current_ability, :manage)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -32,7 +32,7 @@ module Spree
32
32
  variant.width = attributes['width'] if attributes['width'].present?
33
33
  variant.depth = attributes['depth'] if attributes['depth'].present?
34
34
  variant.track_inventory = attributes['track_inventory'] if attributes['track_inventory'].present?
35
- variant.option_value_variants = prepare_option_value_variants
35
+ variant.option_value_variants = prepare_option_value_variants if options.any?
36
36
 
37
37
  if attributes['tax_category'].present?
38
38
  tax_category = prepare_tax_category
@@ -68,7 +68,7 @@ module Spree
68
68
 
69
69
  product = assign_attributes_to_product(product)
70
70
  product.save!
71
- handle_metafields(product)
71
+ handle_metafields(product) if has_product_attributes?
72
72
  product
73
73
  else
74
74
  # For non-master variants, only look up the product
@@ -105,7 +105,11 @@ module Spree
105
105
  shipping_category = prepare_shipping_category
106
106
  product.shipping_category = shipping_category if shipping_category.present?
107
107
  end
108
- product.taxons = prepare_taxons
108
+
109
+ taxons = prepare_taxons
110
+ # Full product rows (with name/status/description) clear taxons when categories are blank.
111
+ # Price-only rows (no product attributes) never touch taxons.
112
+ product.taxons = taxons if taxons.any? || has_product_attributes?
109
113
  end
110
114
 
111
115
  product
@@ -197,6 +201,10 @@ module Spree
197
201
  end
198
202
  end
199
203
 
204
+ def has_product_attributes?
205
+ %w[name status description category1 category2 category3].any? { |key| attributes[key].present? }
206
+ end
207
+
200
208
  def handle_metafields(product)
201
209
  return unless product.class.included_modules.include?(Spree::Metafields)
202
210
 
@@ -33,8 +33,11 @@ module Spree
33
33
  existing_variant = variant_params[:id].presence && @product.variants.find_by(id: variant_params[:id])
34
34
  variants_to_remove.delete(variant_params[:id]) if variant_params[:id].present?
35
35
 
36
+ variant_params.delete(:price) # remove legacy price param
37
+
36
38
  if can_update_prices?
37
- # If the variant price is nil then mark it for destruction
39
+ backfill_price_ids!(variant_params, existing_variant)
40
+
38
41
  variant_params[:prices_attributes]&.each do |price_key, price_params|
39
42
  variant_params[:prices_attributes][price_key]['_destroy'] = '1' if price_params[:amount].blank?
40
43
  end
@@ -95,6 +98,20 @@ module Spree
95
98
 
96
99
  delegate :can?, :cannot?, to: :ability
97
100
 
101
+ # Backfill IDs for prices_attributes entries that reference existing prices
102
+ # so that ActiveRecord updates them instead of inserting duplicates
103
+ def backfill_price_ids!(variant_params, existing_variant)
104
+ return unless existing_variant && variant_params[:prices_attributes]
105
+
106
+ variant_params[:prices_attributes].each do |_key, price_params|
107
+ next if price_params[:id].present?
108
+ next if price_params[:currency].blank?
109
+
110
+ existing_price = existing_variant.prices.base_prices.find_by(currency: price_params[:currency])
111
+ price_params[:id] = existing_price.id if existing_price
112
+ end
113
+ end
114
+
98
115
  def product_option_types_params
99
116
  @product_option_types_params ||= {}
100
117
  end