spree_core 5.4.2 → 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 (248) 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 +3 -2
  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/publishable.rb +1 -1
  28. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  29. data/app/models/concerns/spree/search_indexable.rb +8 -7
  30. data/app/models/concerns/spree/searchable.rb +11 -2
  31. data/app/models/concerns/spree/stores/channels.rb +20 -0
  32. data/app/models/concerns/spree/stores/markets.rb +21 -5
  33. data/app/models/concerns/spree/typed_associations.rb +120 -0
  34. data/app/models/concerns/spree/user_methods.rb +71 -12
  35. data/app/models/spree/ability.rb +4 -117
  36. data/app/models/spree/api_key.rb +53 -0
  37. data/app/models/spree/asset.rb +37 -14
  38. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  39. data/app/models/spree/base.rb +18 -1
  40. data/app/models/spree/channel.rb +159 -0
  41. data/app/models/spree/country.rb +2 -0
  42. data/app/models/spree/current.rb +5 -1
  43. data/app/models/spree/custom_field.rb +9 -0
  44. data/app/models/spree/custom_field_definition.rb +7 -0
  45. data/app/models/spree/customer_group.rb +8 -2
  46. data/app/models/spree/event.rb +6 -6
  47. data/app/models/spree/export.rb +32 -5
  48. data/app/models/spree/exports/product_translations.rb +1 -1
  49. data/app/models/spree/gateway/bogus.rb +6 -1
  50. data/app/models/spree/gateway.rb +25 -0
  51. data/app/models/spree/gift_card.rb +1 -1
  52. data/app/models/spree/gift_card_batch.rb +4 -1
  53. data/app/models/spree/import.rb +5 -0
  54. data/app/models/spree/import_row.rb +12 -0
  55. data/app/models/spree/line_item.rb +7 -2
  56. data/app/models/spree/market.rb +57 -1
  57. data/app/models/spree/metafield.rb +38 -0
  58. data/app/models/spree/metafield_definition.rb +29 -6
  59. data/app/models/spree/metafields/json.rb +10 -0
  60. data/app/models/spree/newsletter_subscriber.rb +19 -3
  61. data/app/models/spree/option_type.rb +48 -7
  62. data/app/models/spree/order/checkout.rb +3 -3
  63. data/app/models/spree/order.rb +102 -6
  64. data/app/models/spree/order_approval.rb +19 -0
  65. data/app/models/spree/order_cancellation.rb +19 -0
  66. data/app/models/spree/order_inventory.rb +24 -2
  67. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  68. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  69. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  70. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  71. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  72. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  73. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  74. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  75. data/app/models/spree/order_routing_rule.rb +75 -0
  76. data/app/models/spree/payment_setup_sessions/bogus.rb +4 -0
  77. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  78. data/app/models/spree/permission_sets/product_display.rb +2 -0
  79. data/app/models/spree/permission_sets/product_management.rb +2 -0
  80. data/app/models/spree/price.rb +14 -1
  81. data/app/models/spree/price_list.rb +129 -17
  82. data/app/models/spree/price_rule.rb +11 -1
  83. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  84. data/app/models/spree/price_rules/market_rule.rb +16 -1
  85. data/app/models/spree/price_rules/user_rule.rb +21 -2
  86. data/app/models/spree/product/channels.rb +149 -0
  87. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  88. data/app/models/spree/product/slugs.rb +1 -1
  89. data/app/models/spree/product.rb +172 -31
  90. data/app/models/spree/product_publication.rb +43 -0
  91. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  92. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  93. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  94. data/app/models/spree/promotion/rules/country.rb +40 -18
  95. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  96. data/app/models/spree/promotion/rules/product.rb +4 -0
  97. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  98. data/app/models/spree/promotion/rules/user.rb +21 -0
  99. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  100. data/app/models/spree/promotion.rb +22 -1
  101. data/app/models/spree/promotion_action.rb +17 -11
  102. data/app/models/spree/promotion_rule.rb +17 -18
  103. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  104. data/app/models/spree/shipment.rb +10 -4
  105. data/app/models/spree/stock/availability_validator.rb +1 -1
  106. data/app/models/spree/stock/quantifier.rb +89 -9
  107. data/app/models/spree/stock_item.rb +36 -0
  108. data/app/models/spree/stock_location.rb +52 -0
  109. data/app/models/spree/stock_reservation.rb +38 -0
  110. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  111. data/app/models/spree/store.rb +18 -72
  112. data/app/models/spree/store_credit.rb +0 -8
  113. data/app/models/spree/store_product.rb +11 -23
  114. data/app/models/spree/subscriber.rb +12 -12
  115. data/app/models/spree/taxon.rb +0 -5
  116. data/app/models/spree/user_identity.rb +1 -2
  117. data/app/models/spree/variant.rb +132 -18
  118. data/app/models/spree/variant_media.rb +46 -0
  119. data/app/models/spree/webhook_delivery.rb +1 -1
  120. data/app/models/spree/webhook_endpoint.rb +24 -0
  121. data/app/models/spree/wished_item.rb +0 -13
  122. data/app/presenters/spree/csv/formula_sanitizer.rb +28 -0
  123. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  124. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  125. data/app/presenters/spree/variant_presenter.rb +4 -3
  126. data/app/services/spree/addresses/update.rb +6 -8
  127. data/app/services/spree/cart/add_item.rb +10 -0
  128. data/app/services/spree/cart/empty.rb +2 -0
  129. data/app/services/spree/cart/remove_line_item.rb +10 -0
  130. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  131. data/app/services/spree/cart/set_quantity.rb +10 -0
  132. data/app/services/spree/carts/complete.rb +1 -0
  133. data/app/services/spree/carts/create.rb +1 -0
  134. data/app/services/spree/carts/update.rb +18 -2
  135. data/app/services/spree/carts/upsert_items.rb +6 -6
  136. data/app/services/spree/credit_cards/destroy.rb +1 -1
  137. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  138. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  139. data/app/services/spree/newsletter/link_user.rb +53 -0
  140. data/app/services/spree/newsletter/subscribe.rb +31 -9
  141. data/app/services/spree/orders/approve.rb +27 -6
  142. data/app/services/spree/orders/build_shipments.rb +29 -0
  143. data/app/services/spree/orders/cancel.rb +34 -3
  144. data/app/services/spree/orders/complete.rb +53 -0
  145. data/app/services/spree/orders/create.rb +156 -0
  146. data/app/services/spree/orders/update.rb +51 -0
  147. data/app/services/spree/orders/upsert_items.rb +70 -0
  148. data/app/services/spree/payments/handle_webhook.rb +3 -10
  149. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  150. data/app/services/spree/products/duplicator.rb +1 -1
  151. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  152. data/app/services/spree/sample_data/loader.rb +30 -0
  153. data/app/services/spree/stock_reservations/extend.rb +19 -0
  154. data/app/services/spree/stock_reservations/release.rb +12 -0
  155. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  156. data/app/services/spree/taxons/remove_products.rb +7 -1
  157. data/app/subscribers/spree/event_log_subscriber.rb +1 -1
  158. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  159. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  160. data/config/locales/en.yml +35 -10
  161. data/config/routes.rb +9 -0
  162. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  163. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  164. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  165. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  166. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  167. data/db/migrate/20260504103113_add_type_to_spree_payment_setup_sessions.rb +6 -0
  168. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  169. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  170. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  171. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  172. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  173. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  174. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  175. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  176. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  177. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  178. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  179. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  180. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  181. data/db/sample_data/channels.rb +12 -0
  182. data/db/sample_data/orders.rb +1 -1
  183. data/db/sample_data/products.csv +212 -212
  184. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  185. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  186. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  187. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  188. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  189. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  190. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  191. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  192. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  193. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  194. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  195. data/lib/generators/spree/model/model_generator.rb +73 -7
  196. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  197. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  198. data/lib/spree/core/configuration.rb +7 -0
  199. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  200. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  201. data/lib/spree/core/controller_helpers/order.rb +0 -19
  202. data/lib/spree/core/dependencies.rb +5 -2
  203. data/lib/spree/core/engine.rb +54 -7
  204. data/lib/spree/core/permission_configuration.rb +15 -0
  205. data/lib/spree/core/preferences/masking.rb +47 -0
  206. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  207. data/lib/spree/core/version.rb +1 -1
  208. data/lib/spree/core.rb +56 -5
  209. data/lib/spree/events/adapters/active_support_notifications.rb +1 -1
  210. data/lib/spree/events/adapters/base.rb +3 -3
  211. data/lib/spree/events/registry.rb +1 -1
  212. data/lib/spree/events.rb +1 -1
  213. data/lib/spree/permitted_attributes.rb +9 -7
  214. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  215. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  216. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  217. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  218. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  219. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  220. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  221. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  222. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  223. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  224. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  225. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  226. data/lib/spree/testing_support/store.rb +10 -0
  227. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  228. data/lib/tasks/channels.rake +94 -0
  229. data/lib/tasks/core.rake +1 -0
  230. data/lib/tasks/media.rake +27 -0
  231. data/lib/tasks/products.rake +4 -6
  232. data/lib/tasks/publications.rake +60 -0
  233. data/lib/tasks/upgrade.rake +211 -0
  234. metadata +86 -18
  235. data/app/finders/spree/variants/visible_finder.rb +0 -23
  236. data/app/paginators/spree/shared/paginate.rb +0 -30
  237. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  238. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  239. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  240. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  241. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  242. data/app/services/spree/classifications/reposition.rb +0 -23
  243. data/app/sorters/spree/orders/sort.rb +0 -10
  244. data/lib/spree/core/controller_helpers/common.rb +0 -14
  245. data/lib/spree/core/token_generator.rb +0 -23
  246. data/lib/spree/database_type_utilities.rb +0 -22
  247. data/lib/spree/testing_support/bar_ability.rb +0 -14
  248. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -11,10 +11,11 @@ module Spree
11
11
 
12
12
  belongs_to :store, class_name: 'Spree::Store'
13
13
 
14
- has_many :price_rules, class_name: 'Spree::PriceRule', dependent: :destroy
14
+ has_many :price_rules, class_name: 'Spree::PriceRule', autosave: true, dependent: :destroy
15
+ alias rules price_rules
15
16
  has_many :prices, class_name: 'Spree::Price', dependent: :destroy_async
16
- has_many :variants, -> { where(spree_prices: { deleted_at: nil }).distinct }, through: :prices, source: :variant
17
- has_many :products, -> { where(spree_prices: { deleted_at: nil }).distinct }, through: :variants, source: :product
17
+ has_many :variants, -> { distinct }, through: :prices, source: :variant
18
+ has_many :products, -> { distinct }, through: :variants, source: :product
18
19
  alias price_list_products products
19
20
 
20
21
  # Override default nested attributes to use bulk_update_prices for performance
@@ -28,20 +29,59 @@ module Spree
28
29
  end
29
30
 
30
31
  after_update :process_bulk_prices_update
32
+ after_save :apply_pending_rules
33
+ after_save :apply_pending_product_ids
34
+ after_save :apply_pending_prices
35
+
36
+ # @return [Array<String>] prefixed product ids in this list,
37
+ # encoded inline to avoid hydrating N Product records.
38
+ def product_prefixed_ids
39
+ prefix = Spree::Product._prefix_id_prefix
40
+ product_ids.sort.map { |pk| "#{prefix}_#{Spree::PrefixedId::SQIDS.encode([pk])}" }
41
+ end
31
42
 
32
- # Processes the bulk prices update
43
+ # Reconciles list membership. Removes prices for products no longer
44
+ # in `ids` and adds placeholder prices for the new ones.
45
+ #
46
+ # @param ids [Array<String>] raw product PKs (prefixed strings are
47
+ # resolved upstream by `Spree::Base#assign_attributes`).
33
48
  # @return [void]
34
- def process_bulk_prices_update
35
- return if @prices_attributes.blank?
49
+ def product_ids=(ids)
50
+ @pending_product_ids = Array(ids).compact.uniq
51
+ end
36
52
 
37
- bulk_update_prices(@prices_attributes)
38
- @prices_attributes = nil
53
+ # Flat-payload writer for `prices`. Bulk-upserts the listed rows in
54
+ # `after_save` so newly-added products have their placeholder rows
55
+ # materialized first. Nullability contract:
56
+ # - `nil` → no-op
57
+ # - `[]` → clear every override on this list
58
+ # - `[…]` → upsert listed rows, leave the rest alone
59
+ #
60
+ # @param rows [Array<Hash>, Array<Spree::Price>, nil]
61
+ # @return [void]
62
+ def prices=(rows)
63
+ first = Array(rows).first
64
+ return super(rows) if first.is_a?(Spree::Price)
65
+ return if rows.nil?
66
+
67
+ @pending_prices = Array(rows).map do |row|
68
+ row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h.with_indifferent_access : row.with_indifferent_access
69
+ end
70
+ @pending_prices_clear = rows.empty?
71
+ end
72
+
73
+ # Flat-payload writer for `rules`. See
74
+ # {Spree::TypedAssociations#assign_typed_association}.
75
+ def rules=(rows)
76
+ assign_typed_association(:price_rules, rows)
39
77
  end
40
78
 
41
79
  validates :name, :store, presence: true
42
80
  validates :match_policy, presence: true, inclusion: { in: MATCH_POLICIES }
43
81
  validate :starts_at_before_ends_at
44
82
 
83
+ self.whitelisted_ransackable_attributes = %w[status match_policy starts_at ends_at]
84
+
45
85
  scope :by_position, -> { order(position: :asc) }
46
86
  scope :for_store, ->(store) { where(store: store) }
47
87
  scope :current, lambda { |timezone = nil|
@@ -119,9 +159,10 @@ module Spree
119
159
  active_or_scheduled? && within_date_range?(Time.current)
120
160
  end
121
161
 
122
- # Adds products to the price list
123
- # Creates placeholder prices (with nil amount) for all variants and currencies
124
- # @param product_ids [Array<String>] of product ids
162
+ # Adds products to the list, materializing a placeholder price
163
+ # (amount nil) for every variant × store currency.
164
+ #
165
+ # @param product_ids [Array<String>] raw product PKs
125
166
  # @return [void]
126
167
  def add_products(product_ids)
127
168
  return if product_ids.blank?
@@ -130,7 +171,6 @@ module Spree
130
171
  variant_ids = Spree::Variant.eligible.where(product_id: product_ids).distinct.pluck(:id)
131
172
  return if variant_ids.empty?
132
173
 
133
- # Get existing variant_id/currency combinations to avoid duplicates
134
174
  existing = prices.where(variant_id: variant_ids)
135
175
  .pluck(:variant_id, :currency)
136
176
  .to_set
@@ -160,9 +200,12 @@ module Spree
160
200
  touch
161
201
  end
162
202
 
163
- # Removes products from the price list
164
- # Hard deletes prices (not soft delete) to allow re-adding products later
165
- # @param product_ids [Array<String>] of product ids
203
+ # Removes products from the list. Hard-deletes their prices so the
204
+ # unique index doesn't block re-adding the same products later
205
+ # (acts_as_paranoid would leave soft-deleted rows blocking the
206
+ # `(variant_id, currency, price_list_id)` slot).
207
+ #
208
+ # @param product_ids [Array<String>] raw product PKs
166
209
  # @return [void]
167
210
  def remove_products(product_ids)
168
211
  return if product_ids.blank?
@@ -195,7 +238,12 @@ module Spree
195
238
  next if attrs[:id].blank?
196
239
 
197
240
  price_id = attrs[:id].to_i
198
- current = current_values[price_id] || {}
241
+ # Reject rows that aren't in *this* list's prices — `upsert_all`
242
+ # otherwise keys solely by primary id and would silently cross
243
+ # list boundaries.
244
+ next unless current_values.key?(price_id)
245
+
246
+ current = current_values[price_id]
199
247
 
200
248
  # Parse amounts using LocalizedNumber for proper decimal handling
201
249
  amount = attrs[:amount].present? ? Spree::LocalizedNumber.parse(attrs[:amount]) : nil
@@ -222,7 +270,7 @@ module Spree
222
270
  return true if records_to_upsert.empty?
223
271
 
224
272
  opts = { update_only: [:amount, :compare_at_amount], on_duplicate: :update }
225
- opts[:unique_by] = :id unless ActiveRecord::Base.connection.adapter_name == 'Mysql2'
273
+ opts[:unique_by] = :id unless mysql_adapter?
226
274
 
227
275
  Spree::Price.upsert_all(records_to_upsert, **opts)
228
276
 
@@ -232,6 +280,70 @@ module Spree
232
280
 
233
281
  private
234
282
 
283
+ # Processes the bulk prices update
284
+ # @return [void]
285
+ def process_bulk_prices_update
286
+ return if @prices_attributes.blank?
287
+
288
+ bulk_update_prices(@prices_attributes)
289
+ @prices_attributes = nil
290
+ end
291
+
292
+ def apply_pending_rules
293
+ flush_pending_typed_association(:price_rules)
294
+ end
295
+
296
+ def apply_pending_product_ids
297
+ return unless @pending_product_ids
298
+
299
+ desired = @pending_product_ids
300
+ @pending_product_ids = nil
301
+
302
+ current = product_ids
303
+ to_remove = current - desired
304
+ to_add = desired - current
305
+
306
+ remove_products(to_remove) if to_remove.any?
307
+ add_products(to_add) if to_add.any?
308
+ end
309
+
310
+ def apply_pending_prices
311
+ pending = @pending_prices
312
+ cleared = @pending_prices_clear
313
+ return if pending.nil?
314
+
315
+ @pending_prices = nil
316
+ @pending_prices_clear = nil
317
+
318
+ if cleared
319
+ variant_ids = prices.distinct.pluck(:variant_id)
320
+ prices.update_all(amount: nil, compare_at_amount: nil, updated_at: Time.current)
321
+ touch_variants(variant_ids)
322
+ return
323
+ end
324
+
325
+ rows = pending.filter_map do |row|
326
+ # `variant_id` may arrive as a prefixed string (legacy callers,
327
+ # console) or already decoded (the controller's `permitted_params`
328
+ # runs through `normalize_params`). Handle both.
329
+ raw = row[:variant_id]
330
+ variant_id = Spree::PrefixedId.prefixed_id?(raw) ? Spree::PrefixedId.decode_prefixed_id(raw) : raw
331
+ next if variant_id.blank? || row[:currency].blank?
332
+
333
+ {
334
+ variant_id: variant_id,
335
+ currency: row[:currency],
336
+ price_list_id: id,
337
+ amount: row[:amount],
338
+ compare_at_amount: row[:compare_at_amount]
339
+ }
340
+ end
341
+ return if rows.empty?
342
+
343
+ Spree::Prices::BulkUpsert.call(rows: rows)
344
+ touch_variants(rows.map { |r| r[:variant_id] }.uniq)
345
+ end
346
+
235
347
  # Touches the variants in a background job
236
348
  # @param variant_ids [Array<String>] array of variant ids
237
349
  # @return [void]
@@ -4,7 +4,10 @@ module Spree
4
4
 
5
5
  belongs_to :price_list, class_name: 'Spree::PriceList', touch: true
6
6
 
7
- validates :type, :price_list,presence: true
7
+ delegate :store, to: :price_list
8
+
9
+ validates :type, :price_list, presence: true
10
+ validates :type, uniqueness: { scope: [:price_list_id, *spree_base_uniqueness_scope] }
8
11
 
9
12
  # Returns true if the price rule is applicable to the context
10
13
  # @param context [Spree::Pricing::Context]
@@ -24,5 +27,12 @@ module Spree
24
27
  def self.description
25
28
  ''
26
29
  end
30
+
31
+ # Pull the rule registry off the global pricing config so
32
+ # PreferenceSchema's `.subclasses_with_preference_schema` can power
33
+ # the admin's "Add rule" picker.
34
+ def self.registered_subclasses
35
+ Array(Spree.pricing&.rules)
36
+ end
27
37
  end
28
38
  end
@@ -1,7 +1,21 @@
1
1
  module Spree
2
2
  module PriceRules
3
3
  class CustomerGroupRule < Spree::PriceRule
4
- preference :customer_group_ids, :array, default: []
4
+ # Stored as raw IDs. Accepts prefixed IDs (`cg_…`) from API
5
+ # callers and decodes them on write so eligibility checks compare
6
+ # against raw `customer_group_id` rows directly. Scope confines
7
+ # the existence check to the price-list's store.
8
+ preference :customer_group_ids, :array, default: [],
9
+ parse_on_set: normalize_id_preference(
10
+ klass: Spree::CustomerGroup,
11
+ scope: ->(rule) { rule.store.customer_groups }
12
+ )
13
+
14
+ def customer_groups
15
+ return [] if preferred_customer_group_ids.blank?
16
+
17
+ Spree::CustomerGroup.where(id: preferred_customer_group_ids)
18
+ end
5
19
 
6
20
  def applicable?(context)
7
21
  return false unless context.user
@@ -1,7 +1,22 @@
1
1
  module Spree
2
2
  module PriceRules
3
3
  class MarketRule < Spree::PriceRule
4
- preference :market_ids, :array, default: []
4
+ # Stored as raw IDs. Accepts prefixed IDs (`mkt_…`) from API
5
+ # callers and decodes them on write so eligibility checks compare
6
+ # against raw `market_id` rows directly. Scope confines the
7
+ # existence check to the price-list's store so cross-store market
8
+ # IDs can't sneak in.
9
+ preference :market_ids, :array, default: [],
10
+ parse_on_set: normalize_id_preference(
11
+ klass: Spree::Market,
12
+ scope: ->(rule) { rule.store.markets }
13
+ )
14
+
15
+ def markets
16
+ return [] if preferred_market_ids.blank?
17
+
18
+ Spree::Market.where(id: preferred_market_ids)
19
+ end
5
20
 
6
21
  def applicable?(context)
7
22
  return false unless context.market
@@ -1,7 +1,19 @@
1
1
  module Spree
2
2
  module PriceRules
3
3
  class UserRule < Spree::PriceRule
4
- preference :user_ids, :array, default: []
4
+ # Stored as raw IDs. Accepts prefixed IDs (the user class's prefix,
5
+ # e.g. `usr_…`) from API callers and decodes them on write. Resolves
6
+ # `Spree.user_class` lazily — the user class is configured at boot,
7
+ # and class-body evaluation runs before that on cold loads.
8
+ preference :user_ids, :array, default: [], parse_on_set: ->(values) {
9
+ normalize_id_preference(klass: Spree.user_class).call(values)
10
+ }
11
+
12
+ def users
13
+ return [] if preferred_user_ids.blank?
14
+
15
+ Spree.user_class.where(id: preferred_user_ids)
16
+ end
5
17
 
6
18
  def applicable?(context)
7
19
  return false unless context.user
@@ -12,7 +24,14 @@ module Spree
12
24
  end
13
25
 
14
26
  def self.description
15
- 'Apply pricing to specific users'
27
+ 'Apply pricing to specific customers'
28
+ end
29
+
30
+ # Public-facing label — keeps the wire `api_type` as `user_rule`
31
+ # (preference column is `user_ids`) so existing data stays valid,
32
+ # but every UI surface reads "Customer rule".
33
+ def self.human_name
34
+ 'Customer rule'
16
35
  end
17
36
  end
18
37
  end
@@ -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