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
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :sub
4
4
 
5
5
  include Spree::Metafields
6
+ include Spree::SingleStoreResource
6
7
 
7
8
  publishes_lifecycle_events
8
9
 
@@ -12,6 +13,7 @@ module Spree
12
13
  # Associations
13
14
  #
14
15
  belongs_to :user, optional: true, class_name: Spree.user_class&.name
16
+ belongs_to :store, class_name: 'Spree::Store', required: true
15
17
 
16
18
  #
17
19
  # Validations
@@ -19,7 +21,7 @@ module Spree
19
21
  validates :email,
20
22
  presence: true,
21
23
  format: { with: URI::MailTo::EMAIL_REGEXP },
22
- uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope }
24
+ uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope + [:store_id] }
23
25
 
24
26
  #
25
27
  # Scopes
@@ -30,6 +32,7 @@ module Spree
30
32
  #
31
33
  # Callbacks
32
34
  #
35
+ before_validation :set_store, unless: :store_id?
33
36
  normalizes :email, with: ->(email) { email.to_s.strip.downcase.presence }
34
37
 
35
38
  #
@@ -52,8 +55,15 @@ module Spree
52
55
  Spree::CSV::NewsletterSubscriberPresenter.new(self).call
53
56
  end
54
57
 
55
- def self.subscribe(email:, user: nil)
56
- Spree::Newsletter::Subscribe.new(email: email, current_user: user).call
58
+ def self.subscribe(email:, user: nil, store: nil, redirect_url: nil)
59
+ store ||= Spree::Current.store
60
+
61
+ Spree::Newsletter::Subscribe.new(
62
+ email: email,
63
+ current_user: user,
64
+ current_store: store,
65
+ redirect_url: redirect_url
66
+ ).call
57
67
  end
58
68
 
59
69
  def self.verify(token:)
@@ -61,5 +71,11 @@ module Spree
61
71
 
62
72
  Spree::Newsletter::Verify.new(subscriber: subscriber).call
63
73
  end
74
+
75
+ private
76
+
77
+ def set_store
78
+ self.store ||= Spree::Current.store
79
+ end
64
80
  end
65
81
  end
@@ -27,7 +27,14 @@ module Spree
27
27
  #
28
28
  # Associations
29
29
  with_options dependent: :destroy, inverse_of: :option_type do
30
- has_many :option_values, -> { order(:position) }
30
+ # `autosave: true` makes the parent's `save`/`update`:
31
+ # - persist any built / mutated children in one transaction,
32
+ # - collect their validation errors onto `self.errors`,
33
+ # - destroy any child marked via `mark_for_destruction`.
34
+ # The custom `option_values=` writer below leans on this so the v3
35
+ # ResourceController gets `save returning false` + structured errors
36
+ # rather than raised exceptions.
37
+ has_many :option_values, -> { order(:position) }, autosave: true
31
38
  has_many :product_option_types
32
39
  end
33
40
  has_many :products, through: :product_option_types
@@ -73,12 +80,6 @@ module Spree
73
80
  after_update :touch_all_products, if: -> { saved_changes.key?(:presentation) }
74
81
  after_destroy :touch_all_products
75
82
 
76
- # legacy, name itself is now parameterized before saving
77
- def filter_param
78
- Spree::Deprecation.warn('Spree::OptionType#filter_param is deprecated and will be removed in Spree 5.5. Please use Spree::OptionType#name instead.')
79
- name.parameterize
80
- end
81
-
82
83
  def self.color
83
84
  colors.first
84
85
  end
@@ -94,6 +95,46 @@ module Spree
94
95
  color_swatch?
95
96
  end
96
97
 
98
+ # Syncs option values from an array of hashes by mutating the in-memory
99
+ # `option_values` association — built/assigned children get persisted by
100
+ # `autosave: true` when the parent saves, and absent IDs get destroyed
101
+ # via `mark_for_destruction`. The single transaction is owned by the
102
+ # parent's `save`, so validation failures surface as `errors` and the
103
+ # whole thing rolls back together.
104
+ #
105
+ # Falls back to ActiveRecord's collection writer when given OptionValue
106
+ # records (e.g. from `accepts_nested_attributes_for` used by the legacy admin).
107
+ #
108
+ # @param option_values_params [Array<Hash>] array of option value attribute hashes
109
+ # @return [void]
110
+ def option_values=(option_values_params)
111
+ return super if option_values_params.blank? || option_values_params.first.is_a?(Spree::OptionValue)
112
+
113
+ # Load the association into the in-memory collection so subsequent
114
+ # `option_values.build` / `mark_for_destruction` mutations stay on the
115
+ # same instances `autosave` will traverse at parent-save time.
116
+ existing_by_id = option_values.to_a.index_by(&:id)
117
+ retained_ids = []
118
+
119
+ option_values_params.each do |value_data|
120
+ data = value_data.to_h.with_indifferent_access
121
+ value_id = data.delete(:id)
122
+
123
+ record = if value_id.present?
124
+ existing_by_id[Spree::PrefixedId.decode_prefixed_id(value_id) || value_id] ||
125
+ raise(ActiveRecord::RecordNotFound.new("Couldn't find Spree::OptionValue with param=#{value_id}", 'Spree::OptionValue'))
126
+ else
127
+ option_values.build
128
+ end
129
+ record.assign_attributes(data)
130
+ retained_ids << record.id if record.persisted?
131
+ end
132
+
133
+ existing_by_id.each_value do |existing|
134
+ existing.mark_for_destruction unless retained_ids.include?(existing.id)
135
+ end
136
+ end
137
+
97
138
  private
98
139
 
99
140
  def touch_all_products
@@ -135,7 +135,7 @@ module Spree
135
135
  def subscribe_to_newsletter
136
136
  return unless accept_marketing?
137
137
 
138
- Spree::NewsletterSubscriber.subscribe(email: email, user: user)
138
+ Spree::NewsletterSubscriber.subscribe(email: email, user: user, store: store)
139
139
  end
140
140
 
141
141
  def self.go_to_state(name, options = {})
@@ -317,8 +317,8 @@ module Spree
317
317
  # attributes for a single payment and its source, discarding attributes
318
318
  # for payment methods other than the one selected
319
319
  #
320
- # In case a existing credit card is provided it needs to build the payment
321
- # attributes from scratch so we can set the amount. example payload:
320
+ # If an existing credit card is provided, build the payment attributes
321
+ # from scratch so the amount can be set. Example payload:
322
322
  #
323
323
  # {
324
324
  # "order": {
@@ -9,6 +9,11 @@ module Spree
9
9
  class Order < Spree.base_class
10
10
  has_prefix_id :or # Stripe: or_
11
11
 
12
+ # Legacy free-text `channel` column was replaced by the `channel_id` FK
13
+ # (see 6.0-order-routing.md). The string column stays in the DB so the
14
+ # 5.4-to-5.5 backfill rake can read it; AR ignores it everywhere else.
15
+ self.ignored_columns += ['channel']
16
+
12
17
  PAYMENT_STATES = %w(balance_due credit_owed failed paid void)
13
18
  SHIPMENT_STATES = %w(backorder canceled partial pending ready shipped)
14
19
  LINE_ITEM_REMOVABLE_STATES = %w(cart address delivery payment confirm resumed)
@@ -104,23 +109,42 @@ module Spree
104
109
  go_to_state :complete
105
110
  end
106
111
 
107
- self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store]
112
+ self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store channel tags]
108
113
  self.whitelisted_ransackable_attributes = %w[
109
- completed_at email number state payment_state shipment_state
110
- total item_total item_count considered_risky channel
114
+ completed_at email number state status payment_state shipment_state
115
+ total item_total item_count considered_risky channel_id currency
111
116
  ]
112
- self.whitelisted_ransackable_scopes = %w[refunded partially_refunded search multi_search]
117
+ self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search multi_search]
113
118
 
114
119
  attr_reader :coupon_code
115
120
  attr_accessor :temporary_address
116
121
 
122
+ # Set to false on admin-initiated flows to suppress customer-facing emails.
123
+ attr_accessor :notify_customer
124
+
117
125
  attribute :state_machine_resumed, :boolean
118
126
 
127
+ STATUSES = %w[draft placed canceled].freeze
128
+
129
+ attribute :status, :string, default: 'draft'
130
+ validates :status, inclusion: { in: STATUSES }
131
+
132
+ scope :drafts, -> { where(status: 'draft') }
133
+ scope :placed_orders, -> { where(status: 'placed') }
134
+ scope :canceled_orders, -> { where(status: 'canceled') }
135
+
119
136
  acts_as_taggable_on :tags
120
137
  acts_as_taggable_tenant :store_id
121
138
 
139
+ def tags=(tags)
140
+ self.tag_list = tags
141
+ end
142
+
122
143
  ASSOCIATED_USER_ATTRIBUTES = [:user_id, :email, :bill_address_id, :ship_address_id]
123
144
 
145
+ # 6.0 forward-compat: User→Customer rename. Column stays user_id in 5.x.
146
+ alias_attribute :customer_id, :user_id
147
+
124
148
  belongs_to :user, class_name: "::#{Spree.user_class}", optional: true, autosave: true
125
149
  belongs_to :created_by, class_name: "::#{Spree.admin_user_class}", optional: true
126
150
  belongs_to :approver, class_name: "::#{Spree.admin_user_class}", optional: true
@@ -140,6 +164,8 @@ module Spree
140
164
 
141
165
  belongs_to :store, class_name: 'Spree::Store'
142
166
  belongs_to :market, class_name: 'Spree::Market', optional: true
167
+ belongs_to :channel, class_name: 'Spree::Channel', optional: true
168
+ belongs_to :preferred_stock_location, class_name: 'Spree::StockLocation', optional: true
143
169
 
144
170
  with_options dependent: :destroy do
145
171
  has_many :state_changes, as: :stateful, class_name: 'Spree::StateChange'
@@ -148,11 +174,14 @@ module Spree
148
174
  has_many :payment_sessions, inverse_of: :order, class_name: 'Spree::PaymentSession'
149
175
  has_many :return_authorizations, inverse_of: :order, class_name: 'Spree::ReturnAuthorization'
150
176
  has_many :adjustments, -> { order(:created_at) }, as: :adjustable, class_name: 'Spree::Adjustment'
177
+ has_many :cancellations, -> { order(:created_at) }, inverse_of: :order, class_name: 'Spree::OrderCancellation'
178
+ has_many :approvals, -> { order(:created_at) }, inverse_of: :order, class_name: 'Spree::OrderApproval'
151
179
  end
152
180
  has_many :reimbursements, inverse_of: :order, class_name: 'Spree::Reimbursement'
153
181
  has_many :customer_returns, class_name: 'Spree::CustomerReturn', through: :return_authorizations
154
182
  has_many :line_item_adjustments, through: :line_items, source: :adjustments
155
183
  has_many :inventory_units, inverse_of: :order, class_name: 'Spree::InventoryUnit'
184
+ has_many :stock_reservations, class_name: 'Spree::StockReservation', inverse_of: :order, dependent: :destroy
156
185
  has_many :return_items, through: :inventory_units, class_name: 'Spree::ReturnItem'
157
186
  has_many :variants, through: :line_items
158
187
  has_many :products, through: :variants
@@ -194,6 +223,7 @@ module Spree
194
223
  # Needs to happen before save_permalink is called
195
224
  before_validation :ensure_store_presence
196
225
  before_validation :ensure_market_presence
226
+ before_validation :ensure_channel_presence
197
227
  before_validation :ensure_currency_presence
198
228
  before_validation :ensure_locale_presence
199
229
  before_validation :resolve_market_from_currency, if: -> { persisted? && currency_changed? && !skip_market_resolution }
@@ -348,6 +378,21 @@ module Spree
348
378
  completed_at.present?
349
379
  end
350
380
 
381
+ # True when the order is mid-checkout: past the `cart` state but not yet
382
+ # completed or canceled. Used by stock reservation hooks and any flow
383
+ # that should only run during the active checkout phase.
384
+ def in_checkout?
385
+ !cart? && !complete? && !canceled?
386
+ end
387
+
388
+ def draft?
389
+ status == 'draft'
390
+ end
391
+
392
+ def placed?
393
+ status == 'placed'
394
+ end
395
+
351
396
  # Checks if the order is fully refunded
352
397
  # @return [Boolean]
353
398
  def order_refunded?
@@ -459,6 +504,12 @@ module Spree
459
504
  self.market ||= Spree::Current.market || store&.default_market
460
505
  end
461
506
 
507
+ def ensure_channel_presence
508
+ return if channel_id.present?
509
+
510
+ self.channel = store&.default_channel
511
+ end
512
+
462
513
  def allow_cancel?
463
514
  return false if !completed? || canceled?
464
515
 
@@ -591,6 +642,7 @@ module Spree
591
642
  end
592
643
 
593
644
  updater.update_shipment_state
645
+ self.status = 'placed'
594
646
  save!
595
647
  updater.run_hooks
596
648
 
@@ -719,7 +771,34 @@ module Spree
719
771
  # and are not returned or shipped should be deleted
720
772
  inventory_units.on_hand_or_backordered.delete_all
721
773
 
722
- self.shipments = Spree::Stock::Coordinator.new(self).shipments
774
+ self.shipments = order_routing_strategy.for_allocation.map do |package|
775
+ package.to_shipment.tap { |s| s.address_id = ship_address_id }
776
+ end
777
+ end
778
+
779
+ # Resolves the routing strategy from the channel override first, then the
780
+ # store default. Only a registered Spree::OrderRouting::Strategy::Base
781
+ # subclass is used; any other value (an unregistered/typo'd class, or a
782
+ # strategy that was unregistered after being persisted) is logged and
783
+ # skipped rather than raised, falling back to the default Rules strategy so
784
+ # a misconfiguration can't take down cart display or checkout.
785
+ #
786
+ # @return [Spree::OrderRouting::Strategy::Base]
787
+ def order_routing_strategy
788
+ klass = valid_order_routing_strategy_class(channel&.preferred_order_routing_strategy) ||
789
+ valid_order_routing_strategy_class(store.preferred_order_routing_strategy) ||
790
+ Spree::OrderRouting::Strategy::Rules
791
+
792
+ klass.new(order: self)
793
+ end
794
+
795
+ # Cascade for the `preferred_location` rule kind. Channel and B2B sources
796
+ # are layered in by their respective plans.
797
+ #
798
+ # @return [Integer, nil]
799
+ def inferred_preferred_stock_location_id
800
+ preferred_stock_location_id.presence ||
801
+ created_by&.try(:preferred_stock_location_id)
723
802
  end
724
803
 
725
804
  # Returns the total weight of the inventory units in the order
@@ -965,6 +1044,19 @@ module Spree
965
1044
 
966
1045
  private
967
1046
 
1047
+ def valid_order_routing_strategy_class(klass_name)
1048
+ return if klass_name.blank?
1049
+
1050
+ klass = Spree.order_routing.strategies.find { |strategy| strategy.to_s == klass_name.to_s }
1051
+ return klass if klass
1052
+
1053
+ Rails.logger.warn(
1054
+ "[Spree] Ignoring unregistered order routing strategy #{klass_name.inspect} " \
1055
+ "for order #{number.inspect}; falling back to the default strategy."
1056
+ )
1057
+ nil
1058
+ end
1059
+
968
1060
  def link_by_email
969
1061
  self.email = user.email if user
970
1062
  end
@@ -998,6 +1090,8 @@ module Spree
998
1090
  end
999
1091
 
1000
1092
  def after_cancel
1093
+ update_column(:status, 'canceled')
1094
+
1001
1095
  shipments.each(&:cancel!)
1002
1096
 
1003
1097
  # payments fully covered by gift card won't be refunded
@@ -1015,6 +1109,8 @@ module Spree
1015
1109
  end
1016
1110
 
1017
1111
  def after_resume
1112
+ update_column(:status, 'placed')
1113
+
1018
1114
  shipments.each(&:resume!)
1019
1115
  consider_risk
1020
1116
  send_order_resumed_webhook
@@ -1089,7 +1185,7 @@ module Spree
1089
1185
  end
1090
1186
 
1091
1187
  def publish_order_completed_event
1092
- publish_event('order.completed')
1188
+ publish_event('order.completed', event_payload.merge(notify_customer: notify_customer))
1093
1189
  end
1094
1190
 
1095
1191
  def publish_order_resumed_event
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ class OrderApproval < Spree.base_class
3
+ has_prefix_id :appr
4
+
5
+ STATUSES = %w[pending approved rejected].freeze
6
+
7
+ attribute :metadata, default: -> { {} }
8
+
9
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :approvals
10
+ belongs_to :approver, polymorphic: true, optional: true
11
+
12
+ validates :order, presence: true
13
+ validates :status, presence: true, inclusion: { in: STATUSES }
14
+
15
+ scope :approved, -> { where(status: 'approved') }
16
+ scope :pending, -> { where(status: 'pending') }
17
+ scope :rejected, -> { where(status: 'rejected') }
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ class OrderCancellation < Spree.base_class
3
+ has_prefix_id :cncl
4
+
5
+ REASONS = %w[customer declined fraud inventory staff other expired].freeze
6
+
7
+ attribute :restock_items, :boolean, default: false
8
+ attribute :refund_payments, :boolean, default: false
9
+ attribute :notify_customer, :boolean, default: false
10
+ attribute :metadata, default: -> { {} }
11
+
12
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :cancellations
13
+ belongs_to :canceled_by, polymorphic: true, optional: true
14
+
15
+ validates :order, presence: true
16
+ validates :reason, presence: true, inclusion: { in: REASONS }
17
+ validates :refund_amount, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module OrderRouting
3
+ # Shared validation for models carrying a +preferred_order_routing_strategy+
4
+ # preference (Spree::Store, Spree::Channel). A blank value is allowed (it
5
+ # falls back to the next level / the default Rules strategy); a present value
6
+ # must name a registered Spree::OrderRouting::Strategy::Base subclass.
7
+ module HasStrategyPreference
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ validate :order_routing_strategy_must_be_registered
12
+ end
13
+
14
+ private
15
+
16
+ def order_routing_strategy_must_be_registered
17
+ value = preferred_order_routing_strategy
18
+ return if value.blank?
19
+ return if Spree.order_routing.strategies.any? { |strategy| strategy.to_s == value.to_s }
20
+
21
+ errors.add(
22
+ :preferred_order_routing_strategy,
23
+ Spree.t(:invalid_order_routing_strategy, scope: [:errors, :messages], default: 'is not a registered order routing strategy')
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Rules
4
+ # Ranks the StockLocation marked `default: true` at 0 and others at 1.
5
+ # Provides a deterministic baseline so the reducer always has a winner
6
+ # once higher-priority rules abstain or tie.
7
+ class DefaultLocation < Spree::OrderRoutingRule
8
+ def rank(_order, locations)
9
+ locations.map do |loc|
10
+ LocationRanking.new(location: loc, rank: loc.default? ? 0 : 1)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Rules
4
+ # Prefers locations that can fulfill more demand on their own.
5
+ # Higher coverage → lower (better) rank, so the location that single-handedly
6
+ # covers the most variants wins. Coverage is counted per distinct variant
7
+ # so a variant repeated across multiple line items isn't double-counted.
8
+ class MinimizeSplits < Spree::OrderRoutingRule
9
+ def rank(order, locations)
10
+ demand = required_quantity_by_variant(order)
11
+ counts = stock_item_counts(demand.keys, locations)
12
+
13
+ locations.map do |loc|
14
+ coverage = demand.count do |variant_id, qty|
15
+ (counts[[loc.id, variant_id]] || 0) >= qty
16
+ end
17
+
18
+ LocationRanking.new(location: loc, rank: -coverage)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def required_quantity_by_variant(order)
25
+ order.line_items.each_with_object(Hash.new(0)) do |li, h|
26
+ next if li.variant_id.nil?
27
+
28
+ h[li.variant_id] += li.quantity
29
+ end
30
+ end
31
+
32
+ # One query for the entire location × variant matrix instead of
33
+ # N variants × M locations stock_item lookups.
34
+ def stock_item_counts(variant_ids, locations)
35
+ return {} if variant_ids.empty? || locations.empty?
36
+
37
+ Spree::StockItem
38
+ .where(stock_location_id: locations.map(&:id), variant_id: variant_ids)
39
+ .pluck(:stock_location_id, :variant_id, :count_on_hand)
40
+ .each_with_object({}) { |(loc_id, var_id, count), h| h[[loc_id, var_id]] = count }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Rules
4
+ # Ranks the order's inferred preferred location at 0 and abstains for
5
+ # everything else. Lets admins / staff / B2B contexts pin "fulfill from
6
+ # this location" without preventing fallback when the preferred location
7
+ # doesn't actually stock the items — subsequent rules tie-break.
8
+ class PreferredLocation < Spree::OrderRoutingRule
9
+ def rank(order, locations)
10
+ preferred_id = order.inferred_preferred_stock_location_id
11
+
12
+ locations.map do |loc|
13
+ LocationRanking.new(
14
+ location: loc,
15
+ rank: (preferred_id.present? && loc.id.to_s == preferred_id.to_s) ? 0 : nil
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Strategy
4
+ # Contract for order routing strategies. Subclasses implement all four
5
+ # methods — there are no defaults. New routing *signals* (proximity,
6
+ # day-of-week, etc.) ship as STI subclasses of Spree::OrderRoutingRule;
7
+ # a custom strategy is appropriate only when the algorithm itself is a
8
+ # different shape (OMS delegation, ML model, optimization solver).
9
+ #
10
+ # Selected per Order via Spree::Order#order_routing_strategy.
11
+ # See docs/plans/6.0-order-routing.md.
12
+ class Base
13
+ attr_reader :order
14
+
15
+ # Human label for admin strategy pickers. Override in a subclass or add
16
+ # an i18n key under +spree.order_routing.strategies+.
17
+ #
18
+ # @return [String]
19
+ def self.display_name
20
+ Spree.t(name.demodulize.underscore, scope: 'order_routing.strategies', default: name.demodulize.titleize)
21
+ end
22
+
23
+ def initialize(order:)
24
+ @order = order
25
+ end
26
+
27
+ # @return [Array<Spree::Stock::Package>]
28
+ def for_allocation
29
+ raise NotImplementedError, "#{self.class} must implement #for_allocation"
30
+ end
31
+
32
+ # @param fulfillment [Spree::Shipment]
33
+ def for_sale(fulfillment:)
34
+ raise NotImplementedError, "#{self.class} must implement #for_sale"
35
+ end
36
+
37
+ def for_release
38
+ raise NotImplementedError, "#{self.class} must implement #for_release"
39
+ end
40
+
41
+ def for_cancellation
42
+ raise NotImplementedError, "#{self.class} must implement #for_cancellation"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,33 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Strategy
4
+ # Pre-5.5 routing behavior. Delegates to Spree::Stock::Coordinator,
5
+ # which packs every active stock location and lets Prioritizer's
6
+ # Adjuster distribute units across the resulting packages — no rules
7
+ # consulted, no merchant-driven preferences, location order is
8
+ # whatever the database returns.
9
+ #
10
+ # Provided as an opt-in escape hatch for merchants upgrading from 5.4
11
+ # who are not ready to adopt rules-based routing. Configure via:
12
+ #
13
+ # store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy')
14
+ #
15
+ # Spree 6.0 drops this strategy along with the underlying Coordinator.
16
+ # See docs/plans/6.0-order-routing.md.
17
+ class Legacy < Base
18
+ def for_allocation
19
+ Spree::Stock::Coordinator.new(order).packages
20
+ end
21
+
22
+ # Stock decrement / restock today happens via Spree::Shipment's state
23
+ # machine (after_ship / after_cancel). The strategy hooks below are
24
+ # part of the contract for the future reservation + typed-movement
25
+ # phase. In 5.5 they are no-ops; existing model callbacks already do
26
+ # the right thing.
27
+ def for_sale(fulfillment:); end
28
+ def for_release; end
29
+ def for_cancellation; end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ require 'set'
2
+
3
+ module Spree
4
+ module OrderRouting
5
+ module Strategy
6
+ # Walks rules in priority order and applies a "first non-tie wins" reducer.
7
+ #
8
+ # For each rule:
9
+ # 1. Drop rankings where rank is nil (rule abstains for that location).
10
+ # 2. Find the location(s) with the lowest rank (best).
11
+ # 3. Unique winner -> return it.
12
+ # 4. Tie -> carry the tied set forward to the next rule.
13
+ #
14
+ # Out of rules with ties: prefer the StockLocation marked default,
15
+ # then by id. Guarantees a winner whenever locations is non-empty.
16
+ class Reducer
17
+ def initialize(rules, order:)
18
+ @rules = rules
19
+ @order = order
20
+ end
21
+
22
+ # @param locations [Array<Spree::StockLocation>]
23
+ # @return [Spree::StockLocation, nil]
24
+ def pick(locations)
25
+ return nil if locations.empty?
26
+
27
+ remaining = locations
28
+ remaining_ids = remaining.map(&:id).to_set
29
+
30
+ @rules.each do |rule|
31
+ rankings = rule.rank(@order, remaining).select do |r|
32
+ r.rank && remaining_ids.include?(r.location.id)
33
+ end
34
+ next if rankings.empty?
35
+
36
+ min_rank = rankings.map(&:rank).min
37
+ top = rankings.select { |r| r.rank == min_rank }.map(&:location)
38
+
39
+ return top.first if top.size == 1
40
+
41
+ remaining = top
42
+ remaining_ids = top.map(&:id).to_set
43
+ end
44
+
45
+ remaining.min_by { |l| [l.default? ? 0 : 1, l.id] }
46
+ end
47
+
48
+ # Returns every input location, ordered best-first by the same rule
49
+ # chain that drives #pick. Each successive location is the best of
50
+ # what remains — used by Strategy::Rules to fan out an allocation
51
+ # across multiple locations when no single location covers the cart.
52
+ #
53
+ # @param locations [Array<Spree::StockLocation>]
54
+ # @return [Array<Spree::StockLocation>]
55
+ def rank_all(locations)
56
+ remaining = locations.dup
57
+ ordered = []
58
+ until remaining.empty?
59
+ chosen = pick(remaining) or break
60
+ ordered << chosen
61
+ remaining = remaining.reject { |l| l.id == chosen.id }
62
+ end
63
+ ordered
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end