spree_core 5.4.3 → 5.5.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +53 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +5 -1
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +30 -3
  46. data/app/models/spree/gateway.rb +25 -0
  47. data/app/models/spree/gift_card.rb +1 -1
  48. data/app/models/spree/gift_card_batch.rb +4 -1
  49. data/app/models/spree/import.rb +5 -0
  50. data/app/models/spree/import_row.rb +12 -0
  51. data/app/models/spree/line_item.rb +6 -1
  52. data/app/models/spree/market.rb +32 -1
  53. data/app/models/spree/metafield.rb +38 -0
  54. data/app/models/spree/metafield_definition.rb +29 -6
  55. data/app/models/spree/metafields/json.rb +10 -0
  56. data/app/models/spree/newsletter_subscriber.rb +19 -3
  57. data/app/models/spree/option_type.rb +48 -7
  58. data/app/models/spree/order/checkout.rb +3 -3
  59. data/app/models/spree/order.rb +102 -6
  60. data/app/models/spree/order_approval.rb +19 -0
  61. data/app/models/spree/order_cancellation.rb +19 -0
  62. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  63. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  64. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  65. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  66. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  67. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  68. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  69. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  70. data/app/models/spree/order_routing_rule.rb +75 -0
  71. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  72. data/app/models/spree/permission_sets/product_display.rb +2 -0
  73. data/app/models/spree/permission_sets/product_management.rb +2 -0
  74. data/app/models/spree/price.rb +14 -1
  75. data/app/models/spree/price_list.rb +129 -17
  76. data/app/models/spree/price_rule.rb +11 -1
  77. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  78. data/app/models/spree/price_rules/market_rule.rb +16 -1
  79. data/app/models/spree/price_rules/user_rule.rb +21 -2
  80. data/app/models/spree/product/channels.rb +149 -0
  81. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  82. data/app/models/spree/product/slugs.rb +1 -1
  83. data/app/models/spree/product.rb +172 -31
  84. data/app/models/spree/product_publication.rb +43 -0
  85. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  86. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  87. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  88. data/app/models/spree/promotion/rules/country.rb +40 -18
  89. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  90. data/app/models/spree/promotion/rules/product.rb +4 -0
  91. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  92. data/app/models/spree/promotion/rules/user.rb +21 -0
  93. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  94. data/app/models/spree/promotion.rb +22 -1
  95. data/app/models/spree/promotion_action.rb +17 -11
  96. data/app/models/spree/promotion_rule.rb +17 -18
  97. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  98. data/app/models/spree/stock/availability_validator.rb +1 -1
  99. data/app/models/spree/stock/quantifier.rb +89 -9
  100. data/app/models/spree/stock_item.rb +36 -0
  101. data/app/models/spree/stock_location.rb +52 -0
  102. data/app/models/spree/stock_reservation.rb +38 -0
  103. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  104. data/app/models/spree/store.rb +18 -72
  105. data/app/models/spree/store_credit.rb +0 -8
  106. data/app/models/spree/store_product.rb +11 -23
  107. data/app/models/spree/taxon.rb +0 -5
  108. data/app/models/spree/user_identity.rb +1 -2
  109. data/app/models/spree/variant.rb +132 -18
  110. data/app/models/spree/variant_media.rb +46 -0
  111. data/app/models/spree/webhook_delivery.rb +1 -1
  112. data/app/models/spree/webhook_endpoint.rb +24 -0
  113. data/app/models/spree/wished_item.rb +0 -13
  114. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  115. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  116. data/app/presenters/spree/variant_presenter.rb +4 -3
  117. data/app/services/spree/addresses/update.rb +6 -8
  118. data/app/services/spree/cart/add_item.rb +10 -0
  119. data/app/services/spree/cart/empty.rb +2 -0
  120. data/app/services/spree/cart/remove_line_item.rb +10 -0
  121. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  122. data/app/services/spree/cart/set_quantity.rb +10 -0
  123. data/app/services/spree/carts/complete.rb +1 -0
  124. data/app/services/spree/carts/create.rb +1 -0
  125. data/app/services/spree/carts/update.rb +18 -2
  126. data/app/services/spree/carts/upsert_items.rb +6 -6
  127. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  128. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  129. data/app/services/spree/newsletter/link_user.rb +53 -0
  130. data/app/services/spree/newsletter/subscribe.rb +31 -9
  131. data/app/services/spree/orders/approve.rb +27 -6
  132. data/app/services/spree/orders/build_shipments.rb +29 -0
  133. data/app/services/spree/orders/cancel.rb +34 -3
  134. data/app/services/spree/orders/complete.rb +53 -0
  135. data/app/services/spree/orders/create.rb +156 -0
  136. data/app/services/spree/orders/update.rb +51 -0
  137. data/app/services/spree/orders/upsert_items.rb +70 -0
  138. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  139. data/app/services/spree/products/duplicator.rb +1 -1
  140. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  141. data/app/services/spree/sample_data/loader.rb +30 -0
  142. data/app/services/spree/stock_reservations/extend.rb +19 -0
  143. data/app/services/spree/stock_reservations/release.rb +12 -0
  144. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  145. data/app/services/spree/taxons/remove_products.rb +7 -1
  146. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  147. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  148. data/config/locales/en.yml +27 -10
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  151. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  152. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  153. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  154. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  155. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  156. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  157. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  158. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  159. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  160. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  161. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  162. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  163. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  164. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  165. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  166. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  167. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  168. data/db/sample_data/channels.rb +12 -0
  169. data/db/sample_data/orders.rb +1 -1
  170. data/db/sample_data/products.csv +212 -212
  171. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  172. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  173. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  174. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  175. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  176. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  177. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  178. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  179. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  180. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  181. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  182. data/lib/generators/spree/model/model_generator.rb +73 -7
  183. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  184. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  185. data/lib/spree/core/configuration.rb +7 -0
  186. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  187. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  188. data/lib/spree/core/controller_helpers/order.rb +0 -19
  189. data/lib/spree/core/dependencies.rb +5 -2
  190. data/lib/spree/core/engine.rb +54 -7
  191. data/lib/spree/core/permission_configuration.rb +15 -0
  192. data/lib/spree/core/preferences/masking.rb +47 -0
  193. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  194. data/lib/spree/core/version.rb +1 -1
  195. data/lib/spree/core.rb +56 -5
  196. data/lib/spree/permitted_attributes.rb +9 -7
  197. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  198. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  199. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  200. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  201. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  202. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  203. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  204. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  205. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  206. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  207. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  208. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  209. data/lib/spree/testing_support/store.rb +10 -0
  210. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  211. data/lib/tasks/channels.rake +94 -0
  212. data/lib/tasks/core.rake +1 -0
  213. data/lib/tasks/media.rake +27 -0
  214. data/lib/tasks/products.rake +4 -6
  215. data/lib/tasks/publications.rake +60 -0
  216. data/lib/tasks/upgrade.rake +211 -0
  217. metadata +83 -18
  218. data/app/finders/spree/variants/visible_finder.rb +0 -23
  219. data/app/paginators/spree/shared/paginate.rb +0 -30
  220. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  221. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  222. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  223. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  224. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  225. data/app/services/spree/classifications/reposition.rb +0 -23
  226. data/app/sorters/spree/orders/sort.rb +0 -10
  227. data/lib/spree/core/controller_helpers/common.rb +0 -14
  228. data/lib/spree/core/token_generator.rb +0 -23
  229. data/lib/spree/database_type_utilities.rb +0 -22
  230. data/lib/spree/testing_support/bar_ability.rb +0 -14
  231. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -0,0 +1,81 @@
1
+ module Spree
2
+ module OrderRouting
3
+ module Strategy
4
+ # Default order routing strategy: walks Spree::OrderRoutingRule rows in
5
+ # priority order, runs the Reducer to fully rank eligible locations,
6
+ # packs each location, and lets Spree::Stock::Prioritizer distribute
7
+ # inventory units across packages so units that the top-ranked
8
+ # location can't cover spill over to subsequent locations.
9
+ #
10
+ # See docs/plans/6.0-order-routing.md.
11
+ class Rules < Base
12
+ def for_allocation
13
+ locations = eligible_locations
14
+ return [] if locations.empty?
15
+
16
+ ordered = Reducer.new(applicable_rules.to_a, order: order).rank_all(locations)
17
+ return [] if ordered.empty?
18
+
19
+ packages = build_packages(ordered)
20
+ packages = prioritize_packages(packages)
21
+ estimate_rates(packages)
22
+ end
23
+
24
+ # Stock decrement / restock today happens via Spree::Shipment's state
25
+ # machine (after_ship / after_cancel). The strategy methods below are
26
+ # part of the contract for the future reservation + typed-movement
27
+ # phase — see 6.0-stock-reservations.md and 6.0-typed-stock-movements.md.
28
+ # In 5.5 they are no-ops; the existing model callbacks already do the
29
+ # right thing.
30
+ def for_sale(fulfillment:); end
31
+ def for_release; end
32
+ def for_cancellation; end
33
+
34
+ private
35
+
36
+ def applicable_rules
37
+ order.channel.order_routing_rules.active.ordered
38
+ end
39
+
40
+ def eligible_locations
41
+ Spree::StockLocation.active
42
+ .joins(:stock_items)
43
+ .where(spree_stock_items: { variant_id: requested_variant_ids })
44
+ .distinct
45
+ .to_a
46
+ end
47
+
48
+ def requested_variant_ids
49
+ inventory_units.map(&:variant_id).uniq
50
+ end
51
+
52
+ def inventory_units
53
+ @inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
54
+ end
55
+
56
+ # Pack each ranked location independently. Packages are emitted in
57
+ # rank order so the Prioritizer's first-package-wins-on-hand logic
58
+ # honors the routing decision.
59
+ def build_packages(locations)
60
+ locations.flat_map do |location|
61
+ Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters).packages
62
+ end
63
+ end
64
+
65
+ # Prioritizer's Adjuster distributes each inventory_unit across
66
+ # packages: the first package with on-hand stock fulfills the unit,
67
+ # and downstream packages have that unit removed. Packages whose
68
+ # items all get stripped are pruned.
69
+ def prioritize_packages(packages)
70
+ Spree::Stock::Prioritizer.new(packages).prioritized_packages
71
+ end
72
+
73
+ def estimate_rates(packages)
74
+ estimator = Spree::Stock::Estimator.new(order)
75
+ packages.each { |pkg| pkg.shipping_rates = estimator.shipping_rates(pkg) }
76
+ packages
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,75 @@
1
+ module Spree
2
+ # STI base for order routing rules. Subclasses live in
3
+ # app/models/spree/order_routing/rules/ and implement #rank(order, locations).
4
+ #
5
+ # Plugins extend the engine by defining a new subclass:
6
+ #
7
+ # class AcmeFresh::OrderRouting::RefrigeratedRule < Spree::OrderRoutingRule
8
+ # preference :max_temp_c, :integer, default: 4
9
+ #
10
+ # def rank(order, locations)
11
+ # # ... return Array<LocationRanking>
12
+ # end
13
+ # end
14
+ #
15
+ # See docs/plans/6.0-order-routing.md.
16
+ class OrderRoutingRule < Spree.base_class
17
+ self.table_name = 'spree_order_routing_rules'
18
+
19
+ # `rank` is integer (lower = better) when the rule has an opinion,
20
+ # nil to abstain (the reducer skips abstaining rankings).
21
+ LocationRanking = Struct.new(:location, :rank, keyword_init: true)
22
+
23
+ has_prefix_id :orule
24
+
25
+ include Spree::SingleStoreResource
26
+
27
+ belongs_to :store, class_name: 'Spree::Store'
28
+ belongs_to :channel, class_name: 'Spree::Channel'
29
+
30
+ attribute :active, :boolean, default: true
31
+
32
+ validates :type, :channel, presence: true
33
+ validates :position, presence: true, numericality: { only_integer: true }
34
+ validate :channel_belongs_to_store
35
+
36
+ scope :active, -> { where(active: true) }
37
+ scope :ordered, -> { order(:position) }
38
+ scope :for_channel, ->(channel) { where(channel_id: channel.id) }
39
+
40
+ acts_as_list scope: :channel_id
41
+
42
+ self.whitelisted_ransackable_attributes = %w[type position active store_id channel_id]
43
+
44
+ validate :type_must_be_registered
45
+
46
+ # Subclasses override. Returns an Array<LocationRanking> — one per location,
47
+ # with rank=nil to abstain.
48
+ #
49
+ # @param order [Spree::Order]
50
+ # @param locations [Array<Spree::StockLocation>]
51
+ # @return [Array<LocationRanking>]
52
+ def rank(_order, _locations)
53
+ raise NotImplementedError, "#{self.class} must implement #rank(order, locations)"
54
+ end
55
+
56
+ private
57
+
58
+ # The +type+ presence validation already covers blank; here we only reject
59
+ # a present-but-unregistered STI type so arbitrary class names can't be
60
+ # persisted via the +type+ column.
61
+ def type_must_be_registered
62
+ return if type.blank?
63
+ return if Spree.order_routing.rules.any? { |rule| rule.to_s == type }
64
+
65
+ errors.add(:type, Spree.t(:invalid_order_routing_rule, scope: [:errors, :messages], default: 'is not a registered order routing rule'))
66
+ end
67
+
68
+ def channel_belongs_to_store
69
+ return if channel.nil? || store_id.nil?
70
+ return if channel.store_id == store_id
71
+
72
+ errors.add(:channel, Spree.t('errors.messages.channel_store_mismatch'))
73
+ end
74
+ end
75
+ end
@@ -23,15 +23,31 @@ module Spree
23
23
  can :manage, Spree::Zone
24
24
  can :manage, Spree::ZoneMember
25
25
 
26
+ # Markets — Channel / Market is the long-term replacement for
27
+ # Zone (see docs/plans/6.0-tax-provider.md), but both coexist
28
+ # during the migration and need admin read/write either way.
29
+ can :manage, Spree::Market
30
+
26
31
  # Tax configuration
27
32
  can :manage, Spree::TaxCategory
28
33
  can :manage, Spree::TaxRate
29
34
 
35
+ # CORS allowlist used by Rack::Cors + admin cookie auth (see
36
+ # docs/plans/5.5-admin-auth-cookie-refresh.md).
37
+ can :manage, Spree::AllowedOrigin
38
+
39
+ # Webhooks
40
+ can :manage, Spree::WebhookEndpoint
41
+ can :manage, Spree::WebhookDelivery
42
+
30
43
  # General configuration
31
44
  can :manage, Spree::RefundReason
32
45
  can :manage, Spree::ReimbursementType
33
46
  can :manage, Spree::ReturnReason
34
47
 
48
+ # Channels
49
+ can :manage, Spree::Channel
50
+
35
51
  # Restrictions on immutable types
36
52
  cannot [:edit, :update], Spree::RefundReason, mutable: false
37
53
  cannot [:edit, :update], Spree::ReimbursementType, mutable: false
@@ -19,6 +19,8 @@ module Spree
19
19
  can [:read, :admin], Spree::Taxonomy
20
20
  can [:read, :admin], Spree::Classification
21
21
  can [:read, :admin], Spree::Price
22
+ can [:read, :admin], Spree::PriceList
23
+ can [:read, :admin], Spree::PriceRule
22
24
  end
23
25
  end
24
26
  end
@@ -18,6 +18,8 @@ module Spree
18
18
  can :manage, Spree::Taxonomy
19
19
  can :manage, Spree::Classification
20
20
  can :manage, Spree::Price
21
+ can :manage, Spree::PriceList
22
+ can :manage, Spree::PriceRule
21
23
  can :manage, Spree::Asset
22
24
  end
23
25
  end
@@ -55,7 +55,20 @@ module Spree
55
55
  money_methods :amount, :price, :compare_at_amount
56
56
  alias display_compare_at_price display_compare_at_amount
57
57
 
58
- self.whitelisted_ransackable_attributes = ['amount', 'compare_at_amount']
58
+ self.whitelisted_ransackable_attributes = %w[amount compare_at_amount currency price_list_id variant_id]
59
+ self.whitelisted_ransackable_associations = %w[variant price_list]
60
+ self.whitelisted_ransackable_scopes = %i[search]
61
+
62
+ # Free-text search delegated to `Spree::Variant.search` (SKU + product
63
+ # name + option-value presentation), wrapped in a subquery so that
64
+ # multi-option-value variants don't produce duplicate Price rows —
65
+ # the prices index has `collection_distinct?` off (PG DISTINCT +
66
+ # ORDER BY incompat), so any join-based predicate would double up.
67
+ scope :search, ->(query) {
68
+ next all if query.blank?
69
+
70
+ where(variant_id: Spree::Variant.search(query).select(:id))
71
+ }
59
72
 
60
73
  attribute :eligible_for_taxon_matching, :boolean, default: false
61
74
  before_validation -> { self.eligible_for_taxon_matching = new_record? ? discounted? : discounted? != was_discounted? }
@@ -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