spree_core 5.4.3 → 5.5.0.rc1

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 (231) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +53 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +5 -1
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +30 -3
  46. data/app/models/spree/gateway.rb +25 -0
  47. data/app/models/spree/gift_card.rb +1 -1
  48. data/app/models/spree/gift_card_batch.rb +4 -1
  49. data/app/models/spree/import.rb +5 -0
  50. data/app/models/spree/import_row.rb +12 -0
  51. data/app/models/spree/line_item.rb +6 -1
  52. data/app/models/spree/market.rb +32 -1
  53. data/app/models/spree/metafield.rb +38 -0
  54. data/app/models/spree/metafield_definition.rb +29 -6
  55. data/app/models/spree/metafields/json.rb +10 -0
  56. data/app/models/spree/newsletter_subscriber.rb +19 -3
  57. data/app/models/spree/option_type.rb +48 -7
  58. data/app/models/spree/order/checkout.rb +3 -3
  59. data/app/models/spree/order.rb +102 -6
  60. data/app/models/spree/order_approval.rb +19 -0
  61. data/app/models/spree/order_cancellation.rb +19 -0
  62. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  63. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  64. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  65. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  66. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  67. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  68. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  69. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  70. data/app/models/spree/order_routing_rule.rb +75 -0
  71. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  72. data/app/models/spree/permission_sets/product_display.rb +2 -0
  73. data/app/models/spree/permission_sets/product_management.rb +2 -0
  74. data/app/models/spree/price.rb +14 -1
  75. data/app/models/spree/price_list.rb +129 -17
  76. data/app/models/spree/price_rule.rb +11 -1
  77. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  78. data/app/models/spree/price_rules/market_rule.rb +16 -1
  79. data/app/models/spree/price_rules/user_rule.rb +21 -2
  80. data/app/models/spree/product/channels.rb +149 -0
  81. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  82. data/app/models/spree/product/slugs.rb +1 -1
  83. data/app/models/spree/product.rb +172 -31
  84. data/app/models/spree/product_publication.rb +43 -0
  85. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  86. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  87. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  88. data/app/models/spree/promotion/rules/country.rb +40 -18
  89. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  90. data/app/models/spree/promotion/rules/product.rb +4 -0
  91. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  92. data/app/models/spree/promotion/rules/user.rb +21 -0
  93. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  94. data/app/models/spree/promotion.rb +22 -1
  95. data/app/models/spree/promotion_action.rb +17 -11
  96. data/app/models/spree/promotion_rule.rb +17 -18
  97. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  98. data/app/models/spree/stock/availability_validator.rb +1 -1
  99. data/app/models/spree/stock/quantifier.rb +89 -9
  100. data/app/models/spree/stock_item.rb +36 -0
  101. data/app/models/spree/stock_location.rb +52 -0
  102. data/app/models/spree/stock_reservation.rb +38 -0
  103. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  104. data/app/models/spree/store.rb +18 -72
  105. data/app/models/spree/store_credit.rb +0 -8
  106. data/app/models/spree/store_product.rb +11 -23
  107. data/app/models/spree/taxon.rb +0 -5
  108. data/app/models/spree/user_identity.rb +1 -2
  109. data/app/models/spree/variant.rb +132 -18
  110. data/app/models/spree/variant_media.rb +46 -0
  111. data/app/models/spree/webhook_delivery.rb +1 -1
  112. data/app/models/spree/webhook_endpoint.rb +24 -0
  113. data/app/models/spree/wished_item.rb +0 -13
  114. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  115. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  116. data/app/presenters/spree/variant_presenter.rb +4 -3
  117. data/app/services/spree/addresses/update.rb +6 -8
  118. data/app/services/spree/cart/add_item.rb +10 -0
  119. data/app/services/spree/cart/empty.rb +2 -0
  120. data/app/services/spree/cart/remove_line_item.rb +10 -0
  121. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  122. data/app/services/spree/cart/set_quantity.rb +10 -0
  123. data/app/services/spree/carts/complete.rb +1 -0
  124. data/app/services/spree/carts/create.rb +1 -0
  125. data/app/services/spree/carts/update.rb +18 -2
  126. data/app/services/spree/carts/upsert_items.rb +6 -6
  127. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  128. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  129. data/app/services/spree/newsletter/link_user.rb +53 -0
  130. data/app/services/spree/newsletter/subscribe.rb +31 -9
  131. data/app/services/spree/orders/approve.rb +27 -6
  132. data/app/services/spree/orders/build_shipments.rb +29 -0
  133. data/app/services/spree/orders/cancel.rb +34 -3
  134. data/app/services/spree/orders/complete.rb +53 -0
  135. data/app/services/spree/orders/create.rb +156 -0
  136. data/app/services/spree/orders/update.rb +51 -0
  137. data/app/services/spree/orders/upsert_items.rb +70 -0
  138. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  139. data/app/services/spree/products/duplicator.rb +1 -1
  140. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  141. data/app/services/spree/sample_data/loader.rb +30 -0
  142. data/app/services/spree/stock_reservations/extend.rb +19 -0
  143. data/app/services/spree/stock_reservations/release.rb +12 -0
  144. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  145. data/app/services/spree/taxons/remove_products.rb +7 -1
  146. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  147. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  148. data/config/locales/en.yml +27 -10
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  151. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  152. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  153. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  154. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  155. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  156. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  157. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  158. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  159. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  160. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  161. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  162. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  163. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  164. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  165. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  166. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  167. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  168. data/db/sample_data/channels.rb +12 -0
  169. data/db/sample_data/orders.rb +1 -1
  170. data/db/sample_data/products.csv +212 -212
  171. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  172. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  173. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  174. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  175. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  176. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  177. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  178. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  179. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  180. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  181. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  182. data/lib/generators/spree/model/model_generator.rb +73 -7
  183. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  184. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  185. data/lib/spree/core/configuration.rb +7 -0
  186. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  187. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  188. data/lib/spree/core/controller_helpers/order.rb +0 -19
  189. data/lib/spree/core/dependencies.rb +5 -2
  190. data/lib/spree/core/engine.rb +54 -7
  191. data/lib/spree/core/permission_configuration.rb +15 -0
  192. data/lib/spree/core/preferences/masking.rb +47 -0
  193. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  194. data/lib/spree/core/version.rb +1 -1
  195. data/lib/spree/core.rb +56 -5
  196. data/lib/spree/permitted_attributes.rb +9 -7
  197. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  198. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  199. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  200. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  201. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  202. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  203. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  204. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  205. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  206. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  207. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  208. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  209. data/lib/spree/testing_support/store.rb +10 -0
  210. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  211. data/lib/tasks/channels.rake +94 -0
  212. data/lib/tasks/core.rake +1 -0
  213. data/lib/tasks/media.rake +27 -0
  214. data/lib/tasks/products.rake +4 -6
  215. data/lib/tasks/publications.rake +60 -0
  216. data/lib/tasks/upgrade.rake +211 -0
  217. metadata +83 -18
  218. data/app/finders/spree/variants/visible_finder.rb +0 -23
  219. data/app/paginators/spree/shared/paginate.rb +0 -30
  220. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  221. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  222. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  223. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  224. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  225. data/app/services/spree/classifications/reposition.rb +0 -23
  226. data/app/sorters/spree/orders/sort.rb +0 -10
  227. data/lib/spree/core/controller_helpers/common.rb +0 -14
  228. data/lib/spree/core/token_generator.rb +0 -23
  229. data/lib/spree/database_type_utilities.rb +0 -22
  230. data/lib/spree/testing_support/bar_ability.rb +0 -14
  231. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -0,0 +1,149 @@
1
+ module Spree
2
+ class Product
3
+ module Channels
4
+ extend ActiveSupport::Concern
5
+
6
+ DEPRECATED_DATE_TO_PUBLICATION_FIELD = {
7
+ available_on: :published_at,
8
+ discontinue_on: :unpublished_at
9
+ }.freeze
10
+
11
+ included do
12
+ belongs_to :store, class_name: 'Spree::Store', optional: true
13
+
14
+ # No +dependent: :destroy+: Product uses +acts_as_paranoid+, so destroy
15
+ # soft-deletes and publications outlive the product.
16
+ # +inverse_of: :product+ is what wires the parent into a child built off
17
+ # an unsaved Product (via +accepts_nested_attributes_for+ on the legacy
18
+ # alias below). Without it the child fails +validates :product,
19
+ # presence: true+ on +create+. Declared on both associations to keep
20
+ # the two in-memory caches symmetric (V-3454).
21
+ has_many :product_publications, class_name: 'Spree::ProductPublication',
22
+ inverse_of: :product, autosave: true
23
+ has_many :channels, -> { distinct }, through: :product_publications, class_name: 'Spree::Channel'
24
+
25
+ # Legacy Rails admin alias. The admin form submits
26
+ # +legacy_product_publications_attributes+ (with +_destroy+ flags and
27
+ # +reject_if+ semantics); the v3 API submits +product_publications+
28
+ # and goes through the custom writer below. Two names, one table —
29
+ # no +dependent:+ for the same +acts_as_paranoid+ reason as above.
30
+ has_many :legacy_product_publications, class_name: 'Spree::ProductPublication',
31
+ foreign_key: :product_id,
32
+ inverse_of: :product, autosave: true
33
+ accepts_nested_attributes_for :legacy_product_publications,
34
+ allow_destroy: true,
35
+ reject_if: ->(attrs) { attrs[:channel_id].blank? }
36
+
37
+ before_validation :assign_default_store, if: -> { store.nil? }
38
+ after_create :apply_pending_publications, if: :pending_publications?
39
+
40
+ DEPRECATED_DATE_TO_PUBLICATION_FIELD.each do |legacy_attr, publication_attr|
41
+ define_method("#{legacy_attr}=") do |value|
42
+ Spree::Deprecation.warn(
43
+ "Spree::Product##{legacy_attr}= is deprecated; set #{publication_attr} on " \
44
+ "ProductPublication instead (writes to every channel's publication). "
45
+ )
46
+ super(value)
47
+ product_publications.each { |publication| publication.public_send("#{publication_attr}=", value) }
48
+ end
49
+
50
+ # Reading +available_on+/+discontinue_on+ prefers the current-channel
51
+ # publication's date and falls back to the legacy Product column
52
+ # whenever the publication's value is nil. This 5.5 transition
53
+ # behavior is dropped in 6.0 when the legacy columns are removed.
54
+ define_method(legacy_attr) do
55
+ channel = Spree::Current.channel
56
+ publication = channel && publication_for(channel)
57
+ (publication && publication.public_send(publication_attr)) || super()
58
+ end
59
+ end
60
+ end
61
+
62
+ # Returns the publication for the given channel, or nil if the product isn't published there.
63
+ # @param channel [Spree::Channel] the channel to find the publication for
64
+ # @return [Spree::ProductPublication, nil] the publication for the channel, or nil if not published
65
+ def publication_for(channel)
66
+ return nil unless channel
67
+
68
+ if product_publications.loaded?
69
+ product_publications.find { |p| p.channel_id == channel.id }
70
+ else
71
+ product_publications.find_by(channel_id: channel.id)
72
+ end
73
+ end
74
+
75
+ # Syncs product publications from an array of hashes.
76
+ # Creates new publications, updates existing ones (matched by +:id+ or
77
+ # +:channel_id+), and removes ones absent from the payload. An empty
78
+ # array detaches the product from every channel.
79
+ # @param publications_params [Array<Hash>] array of publication attribute hashes
80
+ # @return [void]
81
+ def product_publications=(publications_params)
82
+ return super if publications_params.nil?
83
+ return super if publications_params.respond_to?(:first) && publications_params.first.is_a?(Spree::ProductPublication)
84
+
85
+ if new_record?
86
+ @pending_publications_params = publications_params
87
+ return
88
+ end
89
+
90
+ apply_product_publications(publications_params)
91
+ end
92
+
93
+ private
94
+
95
+ def assign_default_store
96
+ self.store ||= Spree::Current.store || Spree::Store.default
97
+ end
98
+
99
+ def pending_publications?
100
+ @pending_publications_params.present?
101
+ end
102
+
103
+ def apply_pending_publications
104
+ return unless @pending_publications_params
105
+
106
+ apply_product_publications(@pending_publications_params)
107
+ @pending_publications_params = nil
108
+ end
109
+
110
+ def apply_product_publications(publications_params)
111
+ publication_ids_in_payload = []
112
+
113
+ publications_params.each do |publication_data|
114
+ publication_data = publication_data.to_h.with_indifferent_access
115
+ publication_id = publication_data.delete(:id)
116
+ channel_id = decode_publication_channel_id(publication_data[:channel_id])
117
+
118
+ if publication_id.present?
119
+ decoded_id = Spree::PrefixedId.prefixed_id?(publication_id) ?
120
+ Spree::PrefixedId.decode_prefixed_id(publication_id) :
121
+ publication_id
122
+ publication = product_publications.find_by(id: decoded_id)
123
+ next unless publication
124
+
125
+ # Channel is immutable; ignore any rebind attempt.
126
+ publication.update!(publication_data.slice(:published_at, :unpublished_at))
127
+ publication_ids_in_payload << publication.id
128
+ elsif channel_id.present?
129
+ # Upsert by channel_id so repeat submissions are idempotent
130
+ # against the unique (product_id, channel_id) index.
131
+ publication = product_publications.find_or_initialize_by(channel_id: channel_id)
132
+ publication.assign_attributes(publication_data.slice(:published_at, :unpublished_at))
133
+ publication.save!
134
+ publication_ids_in_payload << publication.id
135
+ end
136
+ end
137
+
138
+ product_publications.where.not(id: publication_ids_in_payload).destroy_all
139
+ end
140
+
141
+ def decode_publication_channel_id(value)
142
+ return nil if value.blank?
143
+ return value unless Spree::PrefixedId.prefixed_id?(value)
144
+
145
+ Spree::PrefixedId.decode_prefixed_id(value) || value
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,40 @@
1
+ module Spree
2
+ class Product
3
+ # Legacy multi-store support. In Spree 5.5+ the +Spree::ProductPublication+ model
4
+ # handles the Product↔Store relation, so this module only provides a fallback
5
+ # for legacy code that still references +Spree::Product#stores+.
6
+ module LegacyMultiStoreSupport
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ def stores
11
+ Spree::Deprecation.warn(
12
+ "Spree::Product#stores is deprecated. Please use Spree::Product.store instead. If you want to continue using multiple stores please install spree_multi_store gem"
13
+ )
14
+ store ? [store] : []
15
+ end
16
+
17
+ def store_ids
18
+ Spree::Deprecation.warn(
19
+ "Spree::Product#store_ids is deprecated. Please use Spree::Product.store_id instead. If you want to continue using multiple stores please install spree_multi_store gem"
20
+ )
21
+ store_id ? [store_id] : []
22
+ end
23
+
24
+ def stores=(stores)
25
+ Spree::Deprecation.warn(
26
+ "Spree::Product#stores= is deprecated. Please use Spree::Product.store= instead. If you want to continue using multiple stores please install spree_multi_store gem"
27
+ )
28
+ self.store = Array(stores).compact.first
29
+ end
30
+
31
+ def store_ids=(store_ids)
32
+ Spree::Deprecation.warn(
33
+ "Spree::Product#store_ids= is deprecated. Please use Spree::Product.store_id= instead. If you want to continue using multiple stores please install spree_multi_store gem"
34
+ )
35
+ self.store_id = Array(store_ids).compact_blank.first
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -28,7 +28,7 @@ module Spree
28
28
 
29
29
  def generate_slug
30
30
  if name.blank? && slug.blank?
31
- translated_model.name.to_url
31
+ translated_model.name&.to_url
32
32
  elsif slug.blank?
33
33
  name.to_url
34
34
  else
@@ -27,13 +27,14 @@ module Spree
27
27
  normalizes :name, with: ->(value) { value&.to_s&.squish&.presence }
28
28
 
29
29
  include Spree::ProductScopes
30
- include Spree::StoreScopedResource
31
30
  include Spree::TranslatableResource
32
31
  include Spree::MemoizedData
33
32
  include Spree::Metafields
34
33
  include Spree::Metadata
35
34
  include Spree::Product::Webhooks
36
35
  include Spree::Product::Slugs
36
+ include Spree::Product::Channels
37
+ include Spree::Product::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
37
38
  include Spree::SearchIndexable
38
39
  if defined?(Spree::VendorConcern)
39
40
  include Spree::VendorConcern
@@ -121,8 +122,6 @@ module Spree
121
122
 
122
123
  has_many :prices_including_master, -> { non_zero }, through: :variants_including_master, source: :prices
123
124
 
124
- has_many :store_products, class_name: 'Spree::StoreProduct'
125
- has_many :stores, through: :store_products, class_name: 'Spree::Store'
126
125
  has_many :digitals, through: :variants_including_master
127
126
 
128
127
  after_initialize :ensure_master
@@ -133,6 +132,8 @@ module Spree
133
132
 
134
133
  after_create :add_associations_from_prototype
135
134
  after_create :build_variants_from_option_values_hash, if: :option_values_hash
135
+ after_create :apply_pending_variants, if: :pending_variants?
136
+ after_save :apply_pending_media, if: :pending_media?
136
137
 
137
138
  after_save :save_master
138
139
  after_save :run_touch_callbacks, if: :anything_changed?
@@ -153,7 +154,7 @@ module Spree
153
154
 
154
155
  validate :discontinue_on_must_be_later_than_make_active_at, if: -> { make_active_at && discontinue_on }
155
156
 
156
- scope :for_store, ->(store) { joins(:store_products).where(StoreProduct.table_name => { store_id: store.id }) }
157
+ scope :for_store, ->(store) { where(store_id: store.id) }
157
158
  scope :draft, -> { where(status: 'draft') }
158
159
  scope :archived, -> { where(status: 'archived') }
159
160
  scope :not_archived, -> { where.not(status: 'archived') }
@@ -197,8 +198,71 @@ module Spree
197
198
 
198
199
  alias options product_option_types
199
200
 
201
+ # Maps tags array to tag_list for API convenience.
202
+ # @param tags [Array<String>]
203
+ def tags=(tags)
204
+ self.tag_list = tags
205
+ end
206
+
207
+ # Sets prices on the master variant.
208
+ # Accepts array of { currency:, amount:, compare_at_amount: } hashes.
209
+ def prices=(prices_params)
210
+ find_or_build_master.prices = prices_params
211
+ end
212
+
213
+ # Maps 6.0 API name (category_ids) to model column (taxon_ids).
214
+ # Accepts both prefixed IDs and raw integer IDs.
215
+ def category_ids=(ids)
216
+ self.taxon_ids = Array(ids).filter_map do |id|
217
+ id.to_s.include?('_') ? Spree::Taxon.decode_prefixed_id(id) : id
218
+ end
219
+ end
220
+
221
+ # Sync media inline. Entries with `id` patch the existing asset
222
+ # (alt/position/variant_ids); entries with `signed_id` create + attach a
223
+ # fresh upload; missing items are left alone (delete still goes through
224
+ # the dedicated DELETE /media endpoint to avoid accidental data loss when
225
+ # a form ships stale state).
226
+ #
227
+ # Deferred: ActiveStorage attaches require a persisted record, so on new
228
+ # records we stash the params and replay them in `after_create`.
229
+ # @param media_params [Array<Hash>]
230
+ # @return [void]
231
+ def media=(media_params)
232
+ # Blank input is a no-op — never call `super` with an empty array,
233
+ # because the ActiveRecord collection setter would replace media with
234
+ # `[]` and trigger `dependent: :destroy` on every persisted asset.
235
+ # Explicit deletes go through the dedicated DELETE /media endpoint.
236
+ return if media_params.blank?
237
+ return super if media_params.first.is_a?(Spree::Asset)
238
+
239
+ if new_record?
240
+ @pending_media_params = media_params
241
+ return
242
+ end
243
+
244
+ apply_media(media_params)
245
+ end
246
+
247
+ # Syncs variants from an array of hashes.
248
+ # Creates new variants, updates existing ones (matched by :id), and removes unlisted ones.
249
+ # Must be called on a persisted product (use after_save or call explicitly after create).
250
+ # @param variants_params [Array<Hash>] array of variant attribute hashes
251
+ # @return [void]
252
+ def variants=(variants_params)
253
+ return super if variants_params.blank? || variants_params.first.is_a?(Spree::Variant)
254
+
255
+ # Store for deferred processing if product is not yet persisted
256
+ if new_record?
257
+ @pending_variants_params = variants_params
258
+ return
259
+ end
260
+
261
+ apply_variants(variants_params)
262
+ end
263
+
200
264
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at]
201
- self.whitelisted_ransackable_associations = %w[taxons categories stores variants_including_master master variants tags labels
265
+ self.whitelisted_ransackable_associations = %w[taxons categories store channels variants_including_master master variants tags labels
202
266
  shipping_category classifications option_types]
203
267
  self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon in_category in_categories price_between
204
268
  price_lte price_gte
@@ -495,7 +559,11 @@ module Spree
495
559
  @total_on_hand ||= if any_variants_not_track_inventory?
496
560
  BigDecimal::INFINITY
497
561
  else
498
- stock_items.loaded? ? stock_items.sum(&:count_on_hand) : stock_items.sum(:count_on_hand)
562
+ if variants_including_master.loaded?
563
+ variants_including_master.sum(&:total_on_hand)
564
+ else
565
+ stock_items.loaded? ? stock_items.sum(&:count_on_hand) : stock_items.sum(:count_on_hand)
566
+ end
499
567
  end
500
568
  end
501
569
 
@@ -506,19 +574,6 @@ module Spree
506
574
  super || variants_including_master.with_deleted.find_by(is_master: true)
507
575
  end
508
576
 
509
- # Returns the brand for the product
510
- # If a brand association is defined (e.g., belongs_to :brand), it will be used
511
- # Otherwise, falls back to brand_taxon for compatibility
512
- # @return [Spree::Brand, Spree::Taxon]
513
- def brand
514
- if self.class.reflect_on_association(:brand)
515
- super
516
- else
517
- Spree::Deprecation.warn('Spree::Product#brand is deprecated and will be removed in Spree 5.5. Please use Spree::Product#brand_taxon instead.')
518
- brand_taxon
519
- end
520
- end
521
-
522
577
  # Returns the brand taxon for the product
523
578
  # @return [Spree::Taxon]
524
579
  def brand_taxon
@@ -538,7 +593,7 @@ module Spree
538
593
  # Returns the brand name for the product
539
594
  # @return [String]
540
595
  def brand_name
541
- brand&.name
596
+ brand_taxon&.name
542
597
  end
543
598
 
544
599
  def main_taxon
@@ -573,15 +628,13 @@ module Spree
573
628
  def auto_match_taxons
574
629
  return if deleted?
575
630
  return if archived?
576
-
577
- store = stores.find_by(default: true) || stores.first
578
631
  return if store.nil? || store.taxons.automatic.none?
579
632
 
580
633
  Spree::Products::AutoMatchTaxonsJob.set(wait: 30.seconds).perform_later(id)
581
634
  end
582
635
 
583
636
  def to_csv(store = nil)
584
- store ||= stores.default || stores.first
637
+ store ||= self.store
585
638
  properties_for_csv = if respond_to?(:product_properties) && Spree::Config.respond_to?(:product_properties_enabled) && Spree::Config[:product_properties_enabled]
586
639
  Spree::Property.order(:position).flat_map do |property|
587
640
  [
@@ -645,6 +698,100 @@ module Spree
645
698
  nil
646
699
  end
647
700
 
701
+ def pending_variants?
702
+ @pending_variants_params.present?
703
+ end
704
+
705
+ def apply_pending_variants
706
+ return unless @pending_variants_params
707
+
708
+ apply_variants(@pending_variants_params)
709
+ @pending_variants_params = nil
710
+ end
711
+
712
+ def pending_media?
713
+ @pending_media_params.present?
714
+ end
715
+
716
+ def apply_pending_media
717
+ return unless @pending_media_params
718
+
719
+ apply_media(@pending_media_params)
720
+ @pending_media_params = nil
721
+ end
722
+
723
+ def apply_media(media_params)
724
+ # Eager-load Asset descendants once so the type allowlist is stable across
725
+ # the loop (and across requests once subclasses are referenced). Computed
726
+ # per-call rather than at class-load to avoid forcing autoload of every
727
+ # Asset subclass during boot.
728
+ allowed_types = [Spree::Asset, *Spree::Asset.descendants].map(&:name).to_set
729
+ media_params.each do |raw|
730
+ attrs = raw.respond_to?(:to_h) ? raw.to_h : raw
731
+ attrs = attrs.with_indifferent_access
732
+
733
+ # Upsert path: entries with an `id` patch an existing asset (alt,
734
+ # position, variant_ids). Entries with a `signed_id` create+attach.
735
+ # Omitting an entry leaves it alone — explicit DELETE on the dedicated
736
+ # media endpoint is still the only way to remove an asset.
737
+ asset_id = attrs.delete(:id)
738
+ if asset_id.present?
739
+ asset = media.find_by_param(asset_id) || next
740
+ asset.update!(attrs.except(:signed_id, :type))
741
+ next
742
+ end
743
+
744
+ signed_id = attrs.delete(:signed_id)
745
+ next if signed_id.blank?
746
+
747
+ media_type = attrs.delete(:type) || 'Spree::Image'
748
+ next unless allowed_types.include?(media_type)
749
+
750
+ asset = media.build(attrs.except(:id))
751
+ asset.type = media_type
752
+ asset.attachment.attach(signed_id)
753
+ asset.save!
754
+ end
755
+ end
756
+
757
+ def apply_variants(variants_params)
758
+ variant_ids_in_payload = []
759
+ master_touched = false
760
+
761
+ variants_params.each do |variant_data|
762
+ variant_data = variant_data.to_h.with_indifferent_access
763
+ variant_id = variant_data.delete(:id)
764
+ options = variant_data[:options]
765
+
766
+ if variant_id.present?
767
+ variant = variants_including_master.find_by_param!(variant_id)
768
+ variant.update!(variant_data)
769
+ variant_ids_in_payload << variant.id
770
+ elsif options.blank? || (options.is_a?(Array) && options.empty?)
771
+ # An entry with no options addresses the master variant. Building a
772
+ # non-master here would create a phantom duplicate (the auto-built
773
+ # master already exists, and `variants` excludes it). Upsert onto
774
+ # the master instead — the merchant-visible "default variant" on a
775
+ # simple product IS the master.
776
+ variant_data = variant_data.except(:options)
777
+ target = find_or_build_master
778
+ target.assign_attributes(variant_data)
779
+ target.save!
780
+ master_touched = true
781
+ else
782
+ variant = variants.build
783
+ variant.assign_attributes(variant_data)
784
+ variant.save!
785
+ variant_ids_in_payload << variant.id
786
+ end
787
+ end
788
+
789
+ # Remove variants not in the payload (only non-master). If only the
790
+ # master was touched (simple product), leave existing non-master
791
+ # variants alone — the payload is partial, not a full replacement.
792
+ variants.where.not(id: variant_ids_in_payload).destroy_all if variant_ids_in_payload.any? && !master_touched
793
+ end
794
+
648
795
  def add_associations_from_prototype
649
796
  if prototype_id && prototype = Spree::Prototype.find_by(id: prototype_id)
650
797
  self.option_types = prototype.option_types
@@ -668,7 +815,7 @@ module Spree
668
815
  values = option_values_hash.values
669
816
  values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
670
817
 
671
- default_currency = stores.first&.default_currency || Spree::Store.default.default_currency
818
+ default_currency = store&.default_currency || Spree::Store.default.default_currency
672
819
  master_price = master.price_in(default_currency).amount
673
820
 
674
821
  values.each do |ids|
@@ -678,12 +825,6 @@ module Spree
678
825
  save
679
826
  end
680
827
 
681
- def default_variant_cache_key
682
- Spree::Deprecation.warn('Spree::Product#default_variant_cache_key is deprecated and will be removed in Spree 5.5. Please remove any occurrences of it.')
683
-
684
- "spree/default-variant/#{cache_key_with_version}/#{Spree::Config[:track_inventory_levels]}"
685
- end
686
-
687
828
  def ensure_master
688
829
  return unless new_record?
689
830
 
@@ -691,7 +832,7 @@ module Spree
691
832
  end
692
833
 
693
834
  def assign_default_tax_category
694
- self.tax_category = Spree::TaxCategory.default if new_record?
835
+ self.tax_category = Spree::TaxCategory.default if new_record? && self[:tax_category_id].blank?
695
836
  end
696
837
 
697
838
  def anything_changed?
@@ -0,0 +1,43 @@
1
+ module Spree
2
+ # Per-channel publication record. A Product is "published" on a Channel when
3
+ # a ProductPublication exists for that pair; the optional window
4
+ # (+published_at+/+unpublished_at+) gates customer visibility.
5
+ #
6
+ # The owning Store is derived via +channel.store+ — no +store_id+ column
7
+ # lives on this table. Historic core had a +spree_products_stores+ join that
8
+ # also carried the Product↔Store relation; in 5.5+ that responsibility moves
9
+ # onto +Spree::Product#store_id+ directly, leaving this table single-purpose.
10
+ class ProductPublication < Spree.base_class
11
+ has_prefix_id :pp
12
+
13
+ belongs_to :product, class_name: 'Spree::Product', touch: true
14
+ belongs_to :channel, class_name: 'Spree::Channel'
15
+
16
+ validates :product, :channel, presence: true
17
+ validates :product_id, uniqueness: { scope: :channel_id }
18
+ validate :unpublished_at_after_published_at, if: -> { published_at && unpublished_at }
19
+
20
+ scope :published, lambda {
21
+ where('published_at IS NULL OR published_at <= ?', Time.current)
22
+ .where('unpublished_at IS NULL OR unpublished_at > ?', Time.current)
23
+ }
24
+
25
+ self.whitelisted_ransackable_attributes = %w[product_id channel_id published_at unpublished_at]
26
+ self.whitelisted_ransackable_associations = %w[product channel]
27
+
28
+ delegate :store, :store_id, to: :channel
29
+
30
+ def published?
31
+ (published_at.nil? || published_at <= Time.current) &&
32
+ (unpublished_at.nil? || unpublished_at > Time.current)
33
+ end
34
+
35
+ private
36
+
37
+ def unpublished_at_after_published_at
38
+ return if unpublished_at > published_at
39
+
40
+ errors.add(:unpublished_at, :must_be_after_published_at)
41
+ end
42
+ end
43
+ end
@@ -7,6 +7,10 @@ module Spree
7
7
 
8
8
  before_validation -> { self.calculator ||= Calculator::FlatPercentItemTotal.new }
9
9
 
10
+ def self.additional_permitted_attributes
11
+ [calculator: [:type, { preferences: {} }]]
12
+ end
13
+
10
14
  def perform(options = {})
11
15
  order = options[:order]
12
16
 
@@ -7,6 +7,10 @@ module Spree
7
7
 
8
8
  before_validation -> { self.calculator ||= Calculator::PercentOnLineItem.new }
9
9
 
10
+ def self.additional_permitted_attributes
11
+ [calculator: [:type, { preferences: {} }]]
12
+ end
13
+
10
14
  def perform(options = {})
11
15
  order = options[:order]
12
16
  promotion = options[:promotion]
@@ -8,6 +8,17 @@ module Spree
8
8
 
9
9
  after_save :handle_promotion_action_line_items
10
10
 
11
+ def self.additional_permitted_attributes
12
+ [line_items: [:variant_id, :quantity]]
13
+ end
14
+
15
+ # API v3 flat alias for `promotion_action_line_items_attributes`.
16
+ # Accepts an array of `{ variant_id:, quantity: }` rows; the list
17
+ # is the *desired* set, so anything missing on save is removed.
18
+ def line_items=(rows)
19
+ self.promotion_action_line_items_attributes = rows
20
+ end
21
+
11
22
  delegate :eligible?, to: :promotion
12
23
 
13
24
  # Adds a line item to the Order if the promotion is eligible
@@ -85,29 +96,36 @@ module Spree
85
96
 
86
97
  private
87
98
 
88
- # Handles the creation and updating of promotion action line items
89
- #
90
- # This is a hacky replacement for accepts_nested_attributes_for
91
- # that allows us to save the PromotionAction and PromotionActionLineItems
92
- # at the same time.
99
+ # Handles the creation, updating, and pruning of promotion action
100
+ # line items. The submitted list is the *desired* set — variants
101
+ # not present are deleted, ones that are get upserted. Accepts
102
+ # both the legacy Rails admin hash shape (`{ "0" => attrs }`) and
103
+ # a flat array from the API. Variant IDs may be raw or prefixed.
93
104
  def handle_promotion_action_line_items
94
105
  return unless promotion_action_line_items_attributes
95
106
 
96
- # remove the ones marked for destruction
97
- ids_for_destruction = promotion_action_line_items_attributes.map { |key, params| params["_destroy"] == "1" ? params["id"] : nil }.compact
98
- promotion_action_line_items.where(id: ids_for_destruction).delete_all if ids_for_destruction.present?
107
+ rows = promotion_action_line_items_attributes.is_a?(Hash) ? promotion_action_line_items_attributes.values : promotion_action_line_items_attributes
108
+ rows = rows.map { |row| row.respond_to?(:to_h) ? row.to_h.with_indifferent_access : row.with_indifferent_access }
109
+
110
+ rows = rows.map do |row|
111
+ variant_id = row['variant_id']
112
+ variant_id = Spree::Variant.find_by_param(variant_id)&.id if Spree::PrefixedId.prefixed_id?(variant_id)
113
+ row.merge('variant_id' => variant_id)
114
+ end
115
+
116
+ desired_variant_ids = rows.map { |row| row['variant_id'] }.compact
117
+ promotion_action_line_items.where.not(variant_id: desired_variant_ids).delete_all
99
118
 
100
- # upsert the rest
101
- records_for_upsert = promotion_action_line_items_attributes.map { |key, params| params["_destroy"] != "1" ? params : nil }.compact
119
+ return if rows.empty?
102
120
 
103
121
  opts = {}
104
- opts[:unique_by] = [:promotion_action_id, :variant_id] unless ActiveRecord::Base.connection.adapter_name == 'Mysql2'
122
+ opts[:unique_by] = [:promotion_action_id, :variant_id] unless mysql_adapter?
105
123
 
106
124
  promotion_action_line_items.upsert_all(
107
- records_for_upsert.map do |params|
125
+ rows.map do |params|
108
126
  {
109
- variant_id: params["variant_id"],
110
- quantity: params["quantity"],
127
+ variant_id: params['variant_id'],
128
+ quantity: params['quantity'],
111
129
  promotion_action_id: id
112
130
  }
113
131
  end,