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
@@ -1,39 +1,61 @@
1
- # A rule to limit a promotion based on shipment country.
1
+ # A rule to limit a promotion based on shipment country. Stores an
2
+ # array of ISO codes — countries are inherently identified by ISO
3
+ # in the API. The legacy single `country_id` / `country_iso`
4
+ # preferences still work; they fold into the multi-country list.
2
5
  module Spree
3
6
  class Promotion
4
7
  module Rules
5
8
  class Country < PromotionRule
6
- preference :country_id, :integer
7
- preference :country_iso, :string # Alternative way to configure the rule
9
+ preference :country_isos, :array, default: [], parse_on_set: lambda { |values|
10
+ normalize_id_preference.call(values).map(&:upcase)
11
+ }
12
+ preference :country_id, :integer # legacy single-country shortcut
13
+ preference :country_iso, :string # legacy ISO-based shortcut
8
14
 
9
15
  def applicable?(promotable)
10
16
  promotable.is_a?(Spree::Order)
11
17
  end
12
18
 
13
- def eligible?(order, options = {})
14
- if preferred_country_iso.present?
15
- validate_eligibility_by_country_iso(order)
16
- else
17
- validate_eligibility_by_country_id(order, options)
18
- end
19
+ def countries
20
+ isos = preferred_country_isos.presence || [preferred_country_iso].compact_blank
21
+ return Spree::Country.none if isos.blank?
22
+
23
+ Spree::Country.where(iso: isos.map { |s| s.to_s.upcase })
19
24
  end
20
25
 
21
- private
26
+ def eligible?(order, options = {})
27
+ allowed_isos = eligible_country_isos(order)
28
+ shipping_iso = options[:country_iso] || order.ship_address&.country_iso
22
29
 
23
- def validate_eligibility_by_country_id(order, options)
24
- country_id = options[:country_id] || order.ship_address.try(:country_id)
25
- return true if country_id == (preferred_country_id || order.store.default_country_id)
30
+ return true if allowed_isos.include?(shipping_iso)
26
31
 
27
32
  eligibility_errors.add(:base, eligibility_error_message(:wrong_country))
28
33
  false
29
34
  end
30
35
 
31
- def validate_eligibility_by_country_iso(order)
32
- country_iso = order.ship_address&.country_iso
33
- return true if country_iso == (preferred_country_iso || order.store.default_market&.default_country&.iso)
36
+ # Effective list of eligible country ISOs, merging legacy
37
+ # single-country preferences into the multi-country list.
38
+ # Order-of-precedence: explicit ISO list > legacy single ISO
39
+ # > legacy single ID > store default. Memoized per-instance —
40
+ # eligibility checks fire repeatedly per cart change.
41
+ def eligible_country_isos(order = nil)
42
+ @eligible_country_isos ||= compute_eligible_country_isos(order)
43
+ end
34
44
 
35
- eligibility_errors.add(:base, eligibility_error_message(:wrong_country))
36
- false
45
+ private
46
+
47
+ def compute_eligible_country_isos(order)
48
+ return preferred_country_isos.map { |v| v.to_s.upcase } if preferred_country_isos.present?
49
+ return [preferred_country_iso.to_s.upcase] if preferred_country_iso.present?
50
+
51
+ if preferred_country_id.present?
52
+ iso = Spree::Country.where(id: preferred_country_id).pick(:iso)
53
+ return [iso.to_s.upcase] if iso.present?
54
+ end
55
+
56
+ return [] if order.nil?
57
+
58
+ [order.store&.default_country&.iso, order.store&.default_market&.default_country&.iso].compact.map(&:upcase).uniq
37
59
  end
38
60
  end
39
61
  end
@@ -2,12 +2,21 @@ module Spree
2
2
  class Promotion
3
3
  module Rules
4
4
  class CustomerGroup < PromotionRule
5
- preference :customer_group_ids, :array, default: []
5
+ # Stored as raw IDs. Accepts prefixed IDs (`cg_…`) from API
6
+ # callers and decodes them on write so eligibility checks can
7
+ # compare against raw `customer_group_id` rows directly.
8
+ preference :customer_group_ids, :array, default: [], parse_on_set: normalize_id_preference(klass: Spree::CustomerGroup)
6
9
 
7
10
  def applicable?(promotable)
8
11
  promotable.is_a?(Spree::Order)
9
12
  end
10
13
 
14
+ def customer_groups
15
+ return Spree::CustomerGroup.none if preferred_customer_group_ids.blank?
16
+
17
+ Spree::CustomerGroup.where(id: preferred_customer_group_ids)
18
+ end
19
+
11
20
  def eligible?(order, _options = {})
12
21
  return false unless order.user_id.present?
13
22
  return false if preferred_customer_group_ids.empty?
@@ -13,6 +13,10 @@ module Spree
13
13
  dependent: :destroy
14
14
  has_many :products, through: :product_promotion_rules, class_name: 'Spree::Product'
15
15
 
16
+ def self.additional_permitted_attributes
17
+ [product_ids: []]
18
+ end
19
+
16
20
  #
17
21
  # Preferences
18
22
  #
@@ -10,11 +10,34 @@ module Spree
10
10
  dependent: :destroy
11
11
  has_many :taxons, through: :promotion_rule_taxons, class_name: 'Spree::Taxon'
12
12
 
13
+ def self.additional_permitted_attributes
14
+ [category_ids: []]
15
+ end
16
+
17
+ # Wire-format shorthand is `category` (the model is still `Taxon`
18
+ # pre-6.0 rename). `key` (instance) cascades through `api_type`.
19
+ def self.api_type
20
+ 'category'
21
+ end
22
+
23
+ # PrefixedId's auto-resolver in `assign_attributes` only fires
24
+ # when the `_ids` stem matches an association — `categories`
25
+ # doesn't, so decode prefixed IDs explicitly here.
26
+ def category_ids=(ids)
27
+ self.taxon_ids = Array(ids).map do |id|
28
+ Spree::PrefixedId.prefixed_id?(id) ? Spree::Taxon.find_by_param!(id).id : id
29
+ end
30
+ end
31
+
32
+ def category_ids
33
+ taxon_ids
34
+ end
35
+
13
36
  #
14
37
  # Preferences
15
38
  #
16
39
  MATCH_POLICIES = %w(any all)
17
- preference :match_policy, default: MATCH_POLICIES.first
40
+ preference :match_policy, :string, default: MATCH_POLICIES.first
18
41
 
19
42
  #
20
43
  # Attributes
@@ -10,6 +10,27 @@ module Spree
10
10
  dependent: :destroy
11
11
  has_many :users, through: :promotion_rule_users, class_name: "::#{Spree.user_class}"
12
12
 
13
+ # Customers, not admin users — the rule keys off `Spree::Order#user_id`.
14
+ # The data layer keeps the `users` association (legacy column name);
15
+ # the API exposes the same set as `customer_ids`.
16
+ def self.additional_permitted_attributes
17
+ [customer_ids: []]
18
+ end
19
+
20
+ # Wire-format shorthand is `customer` (the model is still `User`
21
+ # pre-6.0 rename, see docs/plans/6.0-platform-auth.md).
22
+ def self.api_type
23
+ 'customer'
24
+ end
25
+
26
+ def customer_ids
27
+ user_ids
28
+ end
29
+
30
+ def customer_ids=(ids)
31
+ self.user_ids = ids
32
+ end
33
+
13
34
  #
14
35
  # Attributes
15
36
  #
@@ -2,6 +2,12 @@ module Spree
2
2
  class Promotion
3
3
  module Rules
4
4
  class UserLoggedIn < PromotionRule
5
+ # Wire-format shorthand is `customer_logged_in` (the model is still
6
+ # `UserLoggedIn` pre-6.0 rename, see docs/plans/6.0-platform-auth.md).
7
+ def self.api_type
8
+ 'customer_logged_in'
9
+ end
10
+
5
11
  def applicable?(promotable)
6
12
  promotable.is_a?(Spree::Order)
7
13
  end
@@ -39,7 +39,8 @@ module Spree
39
39
  has_many :orders, through: :order_promotions, class_name: 'Spree::Order'
40
40
  has_many :store_promotions, class_name: 'Spree::StorePromotion'
41
41
  has_many :stores, class_name: 'Spree::Store', through: :store_promotions
42
- accepts_nested_attributes_for :promotion_actions, :promotion_rules
42
+
43
+ after_save :apply_pending_rules_and_actions, if: :pending_rules_or_actions?
43
44
 
44
45
  #
45
46
  # Callbacks
@@ -115,6 +116,21 @@ module Spree
115
116
  end
116
117
  end
117
118
 
119
+ # Flat-payload writer for `rules`. See
120
+ # {Spree::TypedAssociations#assign_typed_association}.
121
+ def rules=(rows)
122
+ assign_typed_association(:promotion_rules, rows)
123
+ end
124
+
125
+ # Mirrors `rules=` for promotion actions.
126
+ def actions=(rows)
127
+ assign_typed_association(:promotion_actions, rows)
128
+ end
129
+
130
+ def pending_rules_or_actions?
131
+ @pending_promotion_rules.present? || @pending_promotion_actions.present?
132
+ end
133
+
118
134
  def active?
119
135
  starts_at.present? && starts_at < Time.current && (expires_at.blank? || !expired?)
120
136
  end
@@ -283,6 +299,11 @@ module Spree
283
299
 
284
300
  private
285
301
 
302
+ def apply_pending_rules_and_actions
303
+ flush_pending_typed_association(:promotion_rules)
304
+ flush_pending_typed_association(:promotion_actions)
305
+ end
306
+
286
307
  def not_used?
287
308
  return true if orders.empty?
288
309
 
@@ -12,6 +12,15 @@ module Spree
12
12
 
13
13
  scope :of_type, ->(t) { where(type: t) }
14
14
 
15
+ # Per-subclass permitted attributes beyond `type` and `preferences`.
16
+ # Override in STI subclasses that accept nested attributes (e.g.
17
+ # CreateLineItems needs `promotion_action_line_items_attributes`,
18
+ # CreateAdjustment needs `calculator_type` + `calculator_attributes`).
19
+ # The Admin API merges these into its `params.permit(...)` allowlist.
20
+ def self.additional_permitted_attributes
21
+ []
22
+ end
23
+
15
24
  # This method should be overridden in subclass
16
25
  # Updates the state of the order or performs some other action depending on the subclass
17
26
  # options will contain the payload from the event that activated the promotion. This will include
@@ -27,25 +36,22 @@ module Spree
27
36
  type == 'Spree::Promotion::Actions::FreeShipping'
28
37
  end
29
38
 
30
- # Returns the human name of the promotion action
31
- #
32
- # @return [String] eg. Free Shipping
33
- def human_name
34
- Spree.t("promotion_action_types.#{key}.name")
39
+ def self.human_name
40
+ Spree.t("promotion_action_types.#{api_type}.name", default: api_type.titleize)
35
41
  end
36
42
 
37
- # Returns the human description of the promotion action
38
- #
39
- # @return [String]
40
- def human_description
41
- Spree.t("promotion_action_types.#{key}.description")
43
+ def self.human_description
44
+ Spree.t("promotion_action_types.#{api_type}.description", default: '')
42
45
  end
43
46
 
47
+ def human_name = self.class.human_name
48
+ def human_description = self.class.human_description
49
+
44
50
  # Returns the key of the promotion action
45
51
  #
46
52
  # @return [String] eg. free_shipping
47
53
  def key
48
- type.demodulize.underscore
54
+ self.class.api_type
49
55
  end
50
56
 
51
57
  protected
@@ -10,7 +10,15 @@ module Spree
10
10
  scope :of_type, ->(t) { where(type: t) }
11
11
 
12
12
  validates :promotion, presence: true
13
- validate :unique_per_promotion, on: :create
13
+ validates :type, uniqueness: { scope: [:promotion_id, *spree_base_uniqueness_scope] }
14
+
15
+ # Per-subclass permitted attributes beyond `type` and `preferences`.
16
+ # Override in STI subclasses that accept association IDs (e.g.
17
+ # Rules::Product needs `product_ids`). The Admin API merges these
18
+ # into its `params.permit(...)` allowlist.
19
+ def self.additional_permitted_attributes
20
+ []
21
+ end
14
22
 
15
23
  def self.for(promotable)
16
24
  all.select { |rule| rule.applicable?(promotable) }
@@ -34,35 +42,26 @@ module Spree
34
42
  @eligibility_errors ||= ActiveModel::Errors.new(self)
35
43
  end
36
44
 
37
- # Returns the human name of the promotion rule
38
- #
39
- # @return [String] eg. Currency
40
- def human_name
41
- Spree.t("promotion_rule_types.#{key}.name")
45
+ def self.human_name
46
+ Spree.t("promotion_rule_types.#{api_type}.name", default: api_type.titleize)
42
47
  end
43
48
 
44
- # Returns the human description of the promotion rule
45
- #
46
- # @return [String]
47
- def human_description
48
- Spree.t("promotion_rule_types.#{key}.description")
49
+ def self.human_description
50
+ Spree.t("promotion_rule_types.#{api_type}.description", default: '')
49
51
  end
50
52
 
53
+ def human_name = self.class.human_name
54
+ def human_description = self.class.human_description
55
+
51
56
  # Returns the key of the promotion rule
52
57
  #
53
58
  # @return [String] eg. currency
54
59
  def key
55
- type.demodulize.underscore
60
+ self.class.api_type
56
61
  end
57
62
 
58
63
  private
59
64
 
60
- def unique_per_promotion
61
- if Spree::PromotionRule.exists?(promotion_id: promotion_id, type: self.class.name)
62
- errors.add(:base, 'Promotion already contains this rule type')
63
- end
64
- end
65
-
66
65
  def eligibility_error_message(key, options = {})
67
66
  Spree.t(key, Hash[scope: [:eligibility_errors, :messages]].merge(options))
68
67
  end
@@ -233,7 +233,7 @@ module Spree
233
233
  end
234
234
 
235
235
  def filterable_attributes
236
- %w[product_id status in_stock store_ids locale currency discontinue_on price category_ids tags option_value_ids]
236
+ %w[product_id status in_stock store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
237
237
  end
238
238
 
239
239
  def sortable_attributes
@@ -259,12 +259,22 @@ module Spree
259
259
  # System scoping — always applied. Rarely overridden.
260
260
  # Mirrors the AR scope: store.products.active(currency) with locale.
261
261
  def system_filter_conditions
262
+ now = Time.current
262
263
  conditions = []
263
264
  conditions << "store_ids = '#{store.id}'"
265
+ conditions << "channel_ids = '#{Spree::Current.channel.id}'" if Spree::Current.channel
264
266
  conditions << "status = 'active'"
265
267
  conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
266
268
  conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
267
- conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
269
+ # Exclude future-dated products mirrors +Product.available(Time.current)+.
270
+ # ISO 8601 strings sort lexicographically in chronological order, so the
271
+ # string compare is sound. +NOT EXISTS+ catches docs indexed before this
272
+ # attribute was emitted (legacy indexes), +IS NULL+ catches docs where
273
+ # the field was emitted as explicit null, and the +<=+ clause filters
274
+ # the remaining future-dated docs — so the upgrade is non-breaking and
275
+ # no reindex is required.
276
+ conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}')"
277
+ conditions << "(discontinue_on = 0 OR discontinue_on > #{now.to_i})"
268
278
  conditions
269
279
  end
270
280
 
@@ -28,7 +28,7 @@ module Spree
28
28
  private
29
29
 
30
30
  def item_available?(line_item, quantity)
31
- Spree::Stock::Quantifier.new(line_item.variant).can_supply?(quantity)
31
+ Spree::Stock::Quantifier.new(line_item.variant, excluded_order: line_item.order).can_supply?(quantity)
32
32
  end
33
33
  end
34
34
  end
@@ -1,25 +1,89 @@
1
1
  module Spree
2
2
  module Stock
3
3
  class Quantifier
4
- attr_reader :variant, :stock_location
4
+ attr_reader :variant, :stock_location, :excluded_order
5
5
 
6
- def initialize(variant, stock_location = nil)
7
- @variant = variant
8
- @stock_location = stock_location
6
+ # @param excluded_order [Spree::Order, nil] when given, reservations
7
+ # belonging to this order are not counted against availability. Used
8
+ # when checking an order's own line items so the customer's own
9
+ # checkout hold doesn't make their item look out of stock.
10
+ def initialize(variant, stock_location = nil, excluded_order: nil)
11
+ @variant = variant
12
+ @stock_location = stock_location
13
+ @excluded_order = excluded_order
9
14
  end
10
15
 
16
+ # Units a customer can purchase right now: physical pool minus
17
+ # already-allocated units minus active checkout reservations. Clamped
18
+ # at zero so callers never see a negative count.
19
+ #
20
+ # Returns +BigDecimal::INFINITY+ when the variant does not track
21
+ # inventory (effectively unlimited supply).
22
+ #
23
+ # @return [Integer, BigDecimal] purchasable quantity, or +INFINITY+
11
24
  def total_on_hand
12
25
  @total_on_hand ||= if variant.should_track_inventory?
13
- if association_loaded?
14
- stock_items.sum(&:count_on_hand)
15
- else
16
- stock_items.sum(:count_on_hand)
17
- end
26
+ [available_stock - reserved_quantity, 0].max
18
27
  else
19
28
  BigDecimal::INFINITY
20
29
  end
21
30
  end
22
31
 
32
+ # Physical pool minus already-allocated units, summed across the
33
+ # variant's active stock items.
34
+ #
35
+ # In Spree 5.5 {Spree::StockItem#allocated_count} is a Ruby shim that
36
+ # always returns 0, so this equals +SUM(count_on_hand)+. In 6.0
37
+ # (Typed Stock Movements) +allocated_count+ becomes a real column and
38
+ # the SQL path subtracts it natively.
39
+ #
40
+ # @return [Integer] units available before checkout reservations
41
+ def available_stock
42
+ if association_loaded?
43
+ stock_items.sum(&:available_count)
44
+ elsif self.class.allocated_count_column?
45
+ stock_items.sum('count_on_hand - allocated_count')
46
+ else
47
+ stock_items.sum(:count_on_hand)
48
+ end
49
+ end
50
+
51
+ # Units currently held by active checkout reservations on the
52
+ # location-filtered stock items. Returns 0 when stock reservations
53
+ # are globally disabled.
54
+ #
55
+ # Reads through the same {#stock_items} collection as {#available_stock}
56
+ # so a per-location query (filtered by `stock_location`) only counts
57
+ # reservations that belong to those same stock items — otherwise a
58
+ # multi-location variant would subtract reservations from other
59
+ # warehouses.
60
+ #
61
+ # When +excluded_order+ is set, that order's own reservations are left
62
+ # out of the count so an order's own checkout hold doesn't count
63
+ # against the availability of its own line items.
64
+ #
65
+ # @return [Integer]
66
+ def reserved_quantity
67
+ return @reserved_quantity if defined?(@reserved_quantity)
68
+ return @reserved_quantity = 0 unless Spree::Config[:stock_reservations_enabled]
69
+ return @reserved_quantity = 0 if stock_items.blank?
70
+
71
+ excluded_order_id = excluded_order&.id
72
+
73
+ @reserved_quantity = if reservations_preloaded?
74
+ stock_items.sum do |si|
75
+ reservations = si.active_stock_reservations
76
+ reservations = reservations.reject { |r| r.order_id == excluded_order_id } if excluded_order_id
77
+ reservations.sum(&:quantity)
78
+ end
79
+ else
80
+ reservations = Spree::StockReservation.active.where(stock_item_id: stock_items.map(&:id))
81
+ reservations = reservations.where.not(order_id: excluded_order_id) if excluded_order_id
82
+ reservations.sum(:quantity)
83
+ end
84
+ end
85
+
86
+ # Check if any of variant stock items is backorderable
23
87
  def backorderable?
24
88
  @backorderable ||= stock_items.any?(&:backorderable)
25
89
  end
@@ -32,12 +96,28 @@ module Spree
32
96
  @stock_items ||= scope_to_location(variant.stock_items)
33
97
  end
34
98
 
99
+ # Memoized schema check so {#available_stock} doesn't introspect the
100
+ # column list on every call. Flips from false → true when 6.0 Typed
101
+ # Stock Movements adds the `allocated_count` column.
102
+ #
103
+ # @return [Boolean]
104
+ def self.allocated_count_column?
105
+ return @allocated_count_column if defined?(@allocated_count_column)
106
+
107
+ @allocated_count_column = Spree::StockItem.connection.column_exists?(:spree_stock_items, :allocated_count)
108
+ end
109
+
35
110
  private
36
111
 
37
112
  def association_loaded?
38
113
  variant.association(:stock_items).loaded?
39
114
  end
40
115
 
116
+ def reservations_preloaded?
117
+ association_loaded? &&
118
+ stock_items.all? { |si| si.association(:active_stock_reservations).loaded? }
119
+ end
120
+
41
121
  def scope_to_location(collection)
42
122
  if stock_location.blank?
43
123
  if association_loaded?
@@ -15,6 +15,8 @@ module Spree
15
15
  belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
16
16
  end
17
17
  has_many :stock_movements, inverse_of: :stock_item
18
+ has_many :stock_reservations, class_name: 'Spree::StockReservation', inverse_of: :stock_item, dependent: :destroy
19
+ has_many :active_stock_reservations, -> { active }, class_name: 'Spree::StockReservation', inverse_of: :stock_item
18
20
 
19
21
  validates :stock_location, :variant, presence: true
20
22
  validates :variant_id, uniqueness: { scope: :stock_location_id }, unless: :deleted_at
@@ -38,6 +40,18 @@ module Spree
38
40
 
39
41
  scope :with_active_stock_location, -> { joins(:stock_location).merge(Spree::StockLocation.active) }
40
42
 
43
+ # Stock items for products assigned to `store`. Walks
44
+ # `variant → product → stores`, dedups via `distinct` so a product
45
+ # in multiple stores doesn't double-count its stock items.
46
+ #
47
+ # Used by the admin API as the base scope (`StockItem.for_store`)
48
+ # so the controller can filter directly by stock_location/variant
49
+ # without inheriting `Spree::Store#stock_items`'s extra joins or
50
+ # the variant default ordering.
51
+ scope :for_store, ->(store) {
52
+ joins(variant: :product).where(spree_products: { store_id: store.id })
53
+ }
54
+
41
55
  def backordered_inventory_units
42
56
  Spree::InventoryUnit.backordered_for_stock_item(self)
43
57
  end
@@ -64,6 +78,28 @@ module Spree
64
78
  in_stock? || backorderable?
65
79
  end
66
80
 
81
+ # Units already allocated to pending shipments at this stock item.
82
+ #
83
+ # Always returns 0 in Spree 5.5. The 6.0 Typed Stock Movements plan
84
+ # (see docs/plans/6.0-typed-stock-movements.md) adds an indexed
85
+ # `allocated_count` column updated by typed movements (`allocated`,
86
+ # `released`, `shipped`); the Rails column accessor then takes
87
+ # precedence over this method automatically.
88
+ #
89
+ # @return [Integer]
90
+ def allocated_count
91
+ 0
92
+ end
93
+
94
+ # Physical stock minus allocated units at this stock item. Distinct from
95
+ # {Spree::Stock::Quantifier#available_stock}, which sums this across all
96
+ # stock items belonging to a variant.
97
+ #
98
+ # @return [Integer]
99
+ def available_count
100
+ count_on_hand - allocated_count
101
+ end
102
+
67
103
  def reduce_count_on_hand_to_zero
68
104
  set_count_on_hand(0) if count_on_hand > 0
69
105
  end
@@ -2,6 +2,15 @@ module Spree
2
2
  class StockLocation < Spree.base_class
3
3
  has_prefix_id :sloc # Spree-specific: stock location
4
4
 
5
+ # Categorizes the location. Open string — extensible by setting any value;
6
+ # KINDS lists the built-in options used by the admin UI dropdown.
7
+ KINDS = %w[warehouse store fulfillment_center].freeze
8
+
9
+ # Pickup stock policy: 'local' = only items physically at this location are
10
+ # collectable; 'any' = items can be transferred from other locations
11
+ # (ship-to-store). See docs/plans/6.0-fulfillment-and-delivery.md.
12
+ PICKUP_STOCK_POLICIES = %w[local any].freeze
13
+
5
14
  include Spree::UniqueName
6
15
  if defined?(Spree::Security::StockLocations)
7
16
  include Spree::Security::StockLocations
@@ -20,14 +29,41 @@ module Spree
20
29
  belongs_to :state, class_name: 'Spree::State', optional: true
21
30
  belongs_to :country, class_name: 'Spree::Country'
22
31
 
32
+ validates :kind, presence: true
33
+ validates :pickup_stock_policy, inclusion: { in: PICKUP_STOCK_POLICIES }
34
+ validates :pickup_ready_in_minutes,
35
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 },
36
+ allow_nil: true
37
+
38
+ self.whitelisted_ransackable_attributes = %w[
39
+ name active default kind pickup_enabled
40
+ country_id state_id created_at updated_at
41
+ ]
42
+
23
43
  scope :active, -> { where(active: true) }
44
+ scope :pickup_enabled, -> { where(pickup_enabled: true) }
24
45
  scope :order_default, -> { order(default: :desc, name: :asc) }
25
46
 
47
+ before_validation :normalize_country
48
+ before_validation :normalize_state
49
+
26
50
  after_create :create_stock_items, if: :propagate_all_variants?
27
51
  after_save :ensure_one_default
28
52
  after_update :conditional_touch_records
29
53
 
30
54
  delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
55
+ delegate :abbr, to: :state, prefix: true, allow_nil: true
56
+
57
+ # Writer methods for API convenience — accept ISO/abbr codes instead of FK IDs.
58
+ # Mirrors Spree::Address: SDK clients use country_iso/state_abbr because
59
+ # Country/State don't expose prefixed IDs (their `iso` is the public handle).
60
+ def country_iso=(value)
61
+ @country_iso_input = value
62
+ end
63
+
64
+ def state_abbr=(value)
65
+ @state_abbr_input = value
66
+ end
31
67
 
32
68
  def state_text
33
69
  state.try(:abbr) || state.try(:name) || state_name
@@ -168,6 +204,22 @@ module Spree
168
204
 
169
205
  private
170
206
 
207
+ def normalize_country
208
+ iso = @country_iso_input
209
+ return if iso.blank?
210
+
211
+ self.country = Spree::Country.by_iso(iso)
212
+ @country_iso_input = nil
213
+ end
214
+
215
+ def normalize_state
216
+ abbr = @state_abbr_input
217
+ return if abbr.blank? || country.blank?
218
+
219
+ self.state = country.states.find_by(abbr: abbr)
220
+ @state_abbr_input = nil
221
+ end
222
+
171
223
  def create_stock_items
172
224
  Spree::StockLocations::StockItems::CreateJob.perform_later(self)
173
225
  end