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,201 @@
1
+ module Spree
2
+ module Prices
3
+ # Bulk-writes Spree::Price rows and sweeps stale placeholder rows in
4
+ # one transaction.
5
+ #
6
+ # `spree_prices` is guarded by two partial unique indexes on PG/SQLite
7
+ # (collapsed to one composite index on MySQL):
8
+ # - base prices (price_list_id IS NULL): unique on (variant_id, currency)
9
+ # - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
10
+ # A single `upsert_all` can only target one index, so rows ship in two
11
+ # batches — base vs override — each routed to the correct ON CONFLICT.
12
+ #
13
+ # Both indexes are also partial on `amount IS NOT NULL`, so `upsert_all`
14
+ # can't see placeholder rows (amount IS NULL) as conflict targets —
15
+ # filling in a placeholder via upsert inserts a sibling row instead of
16
+ # updating. The post-write sweep removes those.
17
+ class BulkUpsert
18
+ prepend Spree::ServiceModule::Base
19
+
20
+ # Two partial unique indexes guard `spree_prices` on PG/SQLite:
21
+ # - base prices (price_list_id IS NULL): unique on (variant_id, currency)
22
+ # - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
23
+ # A single `upsert_all` can only target one index, so base-price rows
24
+ # and override rows ship in separate batches.
25
+ BASE_UNIQUE_BY = %i[variant_id currency].freeze
26
+ OVERRIDE_UNIQUE_BY = %i[variant_id currency price_list_id].freeze
27
+
28
+ # @param rows [Array<Hash>] each row must carry
29
+ # `variant_id`, `currency`, and `amount`; `price_list_id` and
30
+ # `compare_at_amount` are optional. A blank `amount` is treated
31
+ # as "clear this price."
32
+ # @return [Spree::ServiceModule::Result] success carries
33
+ # `{ price_count: N }` — the number of rows passed to
34
+ # `upsert_all`.
35
+ def call(rows:)
36
+ rows = Array(rows).map { |r| r.with_indifferent_access }
37
+ keyed = rows.select { |r| r[:variant_id].present? && r[:currency].present? }
38
+ # PG rejects an upsert with two rows hitting the same unique-key
39
+ # triple in one statement ("ON CONFLICT DO UPDATE command cannot
40
+ # affect row a second time"). Last-write-wins: keep the last
41
+ # occurrence of each triple.
42
+ deduped = keyed.reverse.uniq { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.reverse
43
+ upsert_rows, clear_rows = deduped.partition { |r| r[:amount].present? }
44
+
45
+ payload = build_payload(upsert_rows)
46
+ affected_keys = deduped.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }
47
+
48
+ return success(price_count: 0) if affected_keys.empty?
49
+
50
+ base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? }
51
+
52
+ Spree::Price.transaction do
53
+ # MySQL treats NULL values as distinct in unique indexes, so
54
+ # `ON DUPLICATE KEY UPDATE` never fires for base prices —
55
+ # `upsert_all` would silently insert a sibling row. Route base
56
+ # rows through a SELECT-then-UPDATE/INSERT path on MySQL only.
57
+ if base_rows.any? && mysql?
58
+ upsert_base_rows_for_mysql(base_rows)
59
+ else
60
+ upsert_batch(base_rows, BASE_UNIQUE_BY)
61
+ end
62
+ upsert_batch(override_rows, OVERRIDE_UNIQUE_BY)
63
+ sweep(affected_keys, clear_rows)
64
+ # `upsert_all` and `delete_all` both skip AR callbacks, so the
65
+ # `Price -> Variant -> Product` `touch:` chain never fires —
66
+ # downstream caches (`cache_key_with_version`) would stay stale.
67
+ # Re-trigger the chain with one `.touch` per affected variant.
68
+ touch_variants(affected_keys.map(&:first).uniq)
69
+ end
70
+
71
+ success(price_count: payload.length)
72
+ end
73
+
74
+ private
75
+
76
+ # `update_only` lists only domain columns. Rails adds `updated_at`
77
+ # automatically (when `record_timestamps` is on, which it is for
78
+ # `Spree::Price`); listing it explicitly here produces
79
+ # `SET updated_at = …, updated_at = …` on PG and the statement fails
80
+ # with "multiple assignments to same column".
81
+ def upsert_batch(rows, unique_by)
82
+ return if rows.empty?
83
+
84
+ Spree::Price.upsert_all(
85
+ rows,
86
+ update_only: %i[amount compare_at_amount],
87
+ **upsert_opts(unique_by)
88
+ )
89
+ end
90
+
91
+ def build_payload(rows)
92
+ now = Time.current
93
+ rows.map do |row|
94
+ {
95
+ variant_id: row[:variant_id],
96
+ currency: row[:currency],
97
+ price_list_id: row[:price_list_id],
98
+ amount: parse_amount(row[:amount]),
99
+ compare_at_amount: parse_amount(row[:compare_at_amount]),
100
+ created_at: now,
101
+ updated_at: now
102
+ }
103
+ end
104
+ end
105
+
106
+ # Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
107
+ # in en-US). Numeric values pass through; blank values become nil.
108
+ def parse_amount(value)
109
+ return nil if value.blank?
110
+ return value if value.is_a?(Numeric)
111
+
112
+ Spree::LocalizedNumber.parse(value)
113
+ end
114
+
115
+ def sweep(affected_keys, clear_rows)
116
+ cleared_keys = clear_rows.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.to_set
117
+ affected_set = affected_keys.to_set
118
+
119
+ candidates = Spree::Price
120
+ .where(
121
+ variant_id: affected_keys.map(&:first).uniq,
122
+ currency: affected_keys.map { |k| k[1] }.uniq,
123
+ price_list_id: affected_keys.map(&:last).uniq
124
+ )
125
+ .pluck(:id, :variant_id, :currency, :price_list_id, :amount)
126
+
127
+ doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, amount|
128
+ key = [variant_id, currency, price_list_id]
129
+ next unless affected_set.include?(key)
130
+ next id if amount.nil?
131
+ next id if cleared_keys.include?(key)
132
+
133
+ nil
134
+ end
135
+
136
+ Spree::Price.where(id: doomed_ids).delete_all if doomed_ids.any?
137
+ end
138
+
139
+ # Bumps `updated_at` on the affected variants and their parent
140
+ # products to invalidate `cache_key_with_version`-based caches —
141
+ # `upsert_all` and `delete_all` skip the `Price -> Variant` and
142
+ # `Variant -> Product` `touch:` chains otherwise.
143
+ #
144
+ # We deliberately bypass AR callbacks: invoking `Variant#touch`
145
+ # would fire `after_commit :remove_prices_from_master_variant`,
146
+ # which `delete_all`s the master's prices whenever a non-master
147
+ # sibling has any prices — wiping exactly the rows the bulk
148
+ # upsert just persisted on a freshly-created list. `touch_all`
149
+ # gives us the cache bust without that side effect.
150
+ def touch_variants(variant_ids)
151
+ return if variant_ids.empty?
152
+
153
+ variants = Spree::Variant.where(id: variant_ids)
154
+ product_ids = variants.pluck(:product_id).uniq
155
+ variants.touch_all
156
+ Spree::Product.where(id: product_ids).touch_all if product_ids.any?
157
+ end
158
+
159
+ # MySQL infers conflict targets from its own unique indexes and
160
+ # rejects an explicit `unique_by`.
161
+ def upsert_opts(unique_by)
162
+ return {} if mysql?
163
+
164
+ { unique_by: unique_by }
165
+ end
166
+
167
+ def mysql?
168
+ ActiveRecord::Base.connection.adapter_name == 'Mysql2'
169
+ end
170
+
171
+ # MySQL-only path: NULLs are distinct in unique indexes, so
172
+ # `(variant_id, currency, NULL)` doesn't conflict with another
173
+ # `(variant_id, currency, NULL)` — `upsert_all` would insert a
174
+ # sibling instead of updating. Look up existing base rows first,
175
+ # update them one by one, and `insert_all` the rest.
176
+ def upsert_base_rows_for_mysql(rows)
177
+ rows_by_key = rows.index_by { |r| [r[:variant_id], r[:currency]] }
178
+
179
+ Spree::Price.where(
180
+ variant_id: rows.map { |r| r[:variant_id] }.uniq,
181
+ currency: rows.map { |r| r[:currency] }.uniq,
182
+ price_list_id: nil
183
+ ).find_each do |price|
184
+ # The `IN (...)` query can return cross-pairs (e.g. `v=1,c=EUR`
185
+ # exists in the DB even though the caller only passed `v=1,c=USD`
186
+ # and `v=2,c=EUR`). Skip rows the caller didn't request.
187
+ row = rows_by_key.delete([price.variant_id, price.currency])
188
+ next unless row
189
+
190
+ price.update_columns(
191
+ amount: row[:amount],
192
+ compare_at_amount: row[:compare_at_amount],
193
+ updated_at: row[:updated_at]
194
+ )
195
+ end
196
+
197
+ Spree::Price.insert_all(rows_by_key.values) if rows_by_key.any?
198
+ end
199
+ end
200
+ end
201
+ end
@@ -28,7 +28,7 @@ module Spree
28
28
  new_product.status = :draft
29
29
  new_product.name = "COPY OF #{product.name}"
30
30
  new_product.taxons = product.taxons
31
- new_product.stores = product.stores
31
+ new_product.channels = product.channels
32
32
  new_product.created_at = nil
33
33
  new_product.deleted_at = nil
34
34
  new_product.updated_at = nil
@@ -67,14 +67,8 @@ module Spree
67
67
  end
68
68
  end
69
69
 
70
- # ensure there is at least one store
71
- params[:store_ids] = [store.id] if params[:store_ids].blank?
72
-
73
- # Preserve taxon associations from other stores
74
- # Only merge taxon_ids from other stores if taxon_ids are being updated
75
- if params.key?(:taxon_ids)
76
- params[:taxon_ids] = merge_taxons_from_other_stores(params[:taxon_ids])
77
- end
70
+ # ensure the product is owned by a store
71
+ params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
78
72
 
79
73
  # Add empty list for option_type_ids and mark variants as removed if there are no variants and options
80
74
  if params[:variants_attributes].blank? && variants_to_remove.any? && !params.key?(:option_type_ids)
@@ -186,28 +180,6 @@ module Spree
186
180
  attributes
187
181
  end
188
182
 
189
- # Merges taxon IDs from other stores with submitted taxon IDs from current store.
190
- #
191
- # This prevents the loss of taxon associations from other stores when a product
192
- # is edited in one store. Each store's taxonomy is independent, so editing
193
- # categories in Store A should not affect categories in Store B.
194
- #
195
- # @param submitted_taxon_ids [Array<String>] Taxon IDs from the current store
196
- # @return [Array<String>] Combined unique taxon IDs
197
- def merge_taxons_from_other_stores(submitted_taxon_ids)
198
- return submitted_taxon_ids if product.new_record?
199
-
200
- # Get taxon IDs from other stores that should be preserved
201
- other_stores_taxon_ids = product.taxons
202
- .joins(:taxonomy)
203
- .where.not(spree_taxonomies: { store_id: store.id })
204
- .pluck(:id)
205
- .map(&:to_s)
206
-
207
- # Merge with submitted taxon IDs from current store and remove duplicates
208
- (submitted_taxon_ids + other_stores_taxon_ids).uniq
209
- end
210
-
211
183
  def update_option_value_variants(option_value_params, existing_variant)
212
184
  return {} unless option_value_params.present?
213
185
  return {} unless can_manage_option_types?
@@ -15,6 +15,9 @@ module Spree
15
15
  puts 'Loading sample markets...'
16
16
  load_ruby_file('markets')
17
17
 
18
+ puts 'Loading sample channels...'
19
+ load_ruby_file('channels')
20
+
18
21
  puts 'Loading sample metafield definitions...'
19
22
  load_ruby_file('metafield_definitions')
20
23
 
@@ -24,6 +27,12 @@ module Spree
24
27
  puts 'Loading sample products...'
25
28
  load_products
26
29
 
30
+ puts 'Publishing sample products on the default channel...'
31
+ publish_sample_products
32
+
33
+ puts 'Loading sample categories...'
34
+ load_categories
35
+
27
36
  puts 'Loading sample product translations...'
28
37
  load_product_translations
29
38
 
@@ -67,6 +76,27 @@ module Spree
67
76
  Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products)
68
77
  end
69
78
 
79
+ def publish_sample_products
80
+ store = Spree::Store.default
81
+ store.default_channel.add_products(store.product_ids)
82
+ end
83
+
84
+ def load_categories
85
+ store = Spree::Store.default
86
+ csv_path = sample_data_path.join('products.csv')
87
+
88
+ require 'csv'
89
+ ::CSV.foreach(csv_path, headers: true) do |row|
90
+ product = store.products.find_by(slug: row['slug'])
91
+ next unless product
92
+
93
+ categories = [row['category1'], row['category2'], row['category3']].compact_blank
94
+ next if categories.empty?
95
+
96
+ Spree::Imports::CreateCategoriesJob.perform_now(product.id, store.id, categories)
97
+ end
98
+ end
99
+
70
100
  def load_product_translations
71
101
  csv_path = sample_data_path.join('product_translations.csv')
72
102
  return unless csv_path.exist?
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ module StockReservations
3
+ class Extend
4
+ prepend Spree::ServiceModule::Base
5
+
6
+ def call(order:)
7
+ return success(order) unless Spree::Config[:stock_reservations_enabled]
8
+
9
+ expires_at = Time.current + Spree::StockReservation.ttl_for(order)
10
+
11
+ Spree::StockReservation
12
+ .where(order_id: order.id)
13
+ .update_all(expires_at: expires_at, updated_at: Time.current)
14
+
15
+ success(order)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module StockReservations
3
+ class Release
4
+ prepend Spree::ServiceModule::Base
5
+
6
+ def call(order:)
7
+ Spree::StockReservation.where(order_id: order.id).delete_all
8
+ success(order)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,103 @@
1
+ module Spree
2
+ module StockReservations
3
+ class Reserve
4
+ prepend Spree::ServiceModule::Base
5
+
6
+ def call(order:)
7
+ return success(order) unless Spree::Config[:stock_reservations_enabled]
8
+
9
+ expires_at = Time.current + Spree::StockReservation.ttl_for(order)
10
+
11
+ ApplicationRecord.transaction do
12
+ targets = build_targets(order)
13
+ break if targets.empty?
14
+
15
+ # Pessimistic lock + fresh read of count_on_hand. The lock serializes
16
+ # concurrent checkouts and we use the locked rows below so we never
17
+ # check stock against a stale association cache.
18
+ locked_stock_items = Spree::StockItem
19
+ .where(id: targets.map { |_, si| si.id })
20
+ .lock
21
+ .index_by(&:id)
22
+
23
+ held = held_by_others(locked_stock_items.keys, order.id)
24
+ existing = existing_reservations_for(targets)
25
+
26
+ this_order_used = Hash.new(0)
27
+
28
+ targets.each do |line_item, stock_item|
29
+ stock_item = locked_stock_items.fetch(stock_item.id)
30
+ available = stock_item.count_on_hand - held.fetch(stock_item.id, 0) - this_order_used[stock_item.id]
31
+
32
+ if available < line_item.quantity
33
+ raise InsufficientStockError.new(
34
+ line_item,
35
+ Spree.t(
36
+ :insufficient_stock_for_reservation,
37
+ default: '%{item} has only %{available} available',
38
+ item: line_item.variant.name,
39
+ available: [available, 0].max
40
+ )
41
+ )
42
+ end
43
+
44
+ this_order_used[stock_item.id] += line_item.quantity
45
+
46
+ reservation = existing[[stock_item.id, line_item.id]] ||
47
+ Spree::StockReservation.new(stock_item: stock_item, line_item: line_item)
48
+ reservation.order = order
49
+ reservation.quantity = line_item.quantity
50
+ reservation.expires_at = expires_at
51
+ reservation.save!
52
+ end
53
+ end
54
+
55
+ success(order)
56
+ rescue InsufficientStockError => e
57
+ failure(e.line_item, e.message)
58
+ end
59
+
60
+ private
61
+
62
+ def build_targets(order)
63
+ order.line_items.includes(variant: { stock_items: :stock_location }).filter_map do |line_item|
64
+ variant = line_item.variant
65
+ next unless variant&.should_track_inventory?
66
+
67
+ stock_item = select_stock_item(variant)
68
+ next if stock_item.nil? || stock_item.backorderable?
69
+
70
+ [line_item, stock_item]
71
+ end
72
+ end
73
+
74
+ def select_stock_item(variant)
75
+ variant.stock_items.detect { |si| si.stock_location&.active? && si.available? }
76
+ end
77
+
78
+ def held_by_others(stock_item_ids, exclude_order_id)
79
+ return {} if stock_item_ids.empty?
80
+
81
+ Spree::StockReservation
82
+ .active
83
+ .where(stock_item_id: stock_item_ids)
84
+ .where.not(order_id: exclude_order_id)
85
+ .group(:stock_item_id)
86
+ .sum(:quantity)
87
+ end
88
+
89
+ # One SELECT for all (stock_item_id, line_item_id) pairs we need to
90
+ # upsert. Returns a hash keyed by [stock_item_id, line_item_id].
91
+ def existing_reservations_for(targets)
92
+ return {} if targets.empty?
93
+
94
+ stock_item_ids = targets.map { |_, si| si.id }
95
+ line_item_ids = targets.map { |li, _| li.id }
96
+
97
+ Spree::StockReservation
98
+ .where(stock_item_id: stock_item_ids, line_item_id: line_item_ids)
99
+ .index_by { |r| [r.stock_item_id, r.line_item_id] }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -36,7 +36,7 @@ module Spree
36
36
 
37
37
  if classifications_params.any?
38
38
  opts = {}
39
- opts[:unique_by] = :index_spree_products_taxons_on_product_id_and_taxon_id unless ActiveRecord::Base.connection.adapter_name == 'Mysql2'
39
+ opts[:unique_by] = :index_spree_products_taxons_on_product_id_and_taxon_id unless mysql_adapter?
40
40
 
41
41
  Spree::Classification.upsert_all(
42
42
  classifications_params,
@@ -58,6 +58,12 @@ module Spree
58
58
 
59
59
  success(true)
60
60
  end
61
+
62
+ private
63
+
64
+ def mysql_adapter?
65
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql')
66
+ end
61
67
  end
62
68
  end
63
69
  end
@@ -1,11 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spree
4
- # Handles order completion events to update product metrics.
5
- #
6
- # When an order is completed, this subscriber enqueues background jobs
7
- # to refresh the metrics (units_sold_count, revenue) for each product
8
- # in the order.
4
+ # Handles order completion events to update product metrics
5
+ # (+units_sold_count+, +revenue+).
9
6
  class ProductMetricsSubscriber < Spree::Subscriber
10
7
  subscribes_to 'order.completed'
11
8
 
@@ -17,12 +14,11 @@ module Spree
17
14
 
18
15
  order = Spree::Order.find_by_param(order_id)
19
16
  return unless order
20
- return unless order.store_id
21
17
 
22
18
  product_ids = order.line_items.includes(:variant).map { |li| li.variant.product_id }.uniq
23
19
  return if product_ids.empty?
24
20
 
25
- jobs = product_ids.map { |product_id| Spree::Products::RefreshMetricsJob.new(product_id, order.store_id) }
21
+ jobs = product_ids.map { |product_id| Spree::Products::RefreshMetricsJob.new(product_id) }
26
22
  ActiveJob.perform_all_later(jobs)
27
23
  end
28
24
  end
@@ -12,6 +12,10 @@
12
12
  <p>
13
13
  <%= link_to Spree.t(:accept), spree.admin_invitation_url(@invitation, token: @invitation.token, host: @invitation.store.formatted_url) %>
14
14
  </p>
15
+ <% else %>
16
+ <p>
17
+ <%= link_to Spree.t(:accept), main_app.admin_invitation_acceptance_url(@invitation) %>
18
+ </p>
15
19
  <% end %>
16
20
 
17
21
  <p>
@@ -323,6 +323,10 @@ en:
323
323
  cannot_destroy_if_attached_to_line_items: Cannot delete Products that are added to placed Orders. In such cases, please discontinue them.
324
324
  discontinue_on:
325
325
  invalid_date_range: must be later than available date
326
+ spree/product_publication:
327
+ attributes:
328
+ unpublished_at:
329
+ must_be_after_published_at: must be after published date
326
330
  spree/promotion:
327
331
  attributes:
328
332
  expires_at:
@@ -785,6 +789,8 @@ en:
785
789
  are_you_sure: Are you sure?
786
790
  are_you_sure_delete: Are you sure you want to delete this record?
787
791
  assets: Media
792
+ assigned_variants: Assigned variants
793
+ assigned_variants_help: Pick the variants this image represents. Leave blank to apply to all variants.
788
794
  associated_adjustment_closed: The associated adjustment is closed, and will not be recalculated. Do you want to open it?
789
795
  at_symbol: "@"
790
796
  attachments: Attachments
@@ -879,7 +885,8 @@ en:
879
885
  change: Change
880
886
  change_password: Change password
881
887
  changes_published: Changes published!
882
- channel: Channel
888
+ channel: Sales channel
889
+ channels: Sales channels
883
890
  charged: Charged
884
891
  checkout: Checkout
885
892
  checkout_message: Checkout message
@@ -1129,8 +1136,12 @@ en:
1129
1136
  errors:
1130
1137
  messages:
1131
1138
  blank: can't be blank
1139
+ cannot_delete_default_channel: Default channel cannot be deleted. Promote another channel to default first.
1132
1140
  cannot_remove_icon: Cannot remove image
1141
+ channel_store_mismatch: must belong to the same store
1133
1142
  could_not_create_taxon: Could not create taxon
1143
+ invalid_order_routing_rule: is not a registered order routing rule
1144
+ invalid_order_routing_strategy: is not a registered order routing strategy
1134
1145
  must_be_origin_only: must be an origin (scheme and host) without path, query, or fragment
1135
1146
  no_shipping_methods_available: No shipping methods available for selected location, please change your address and try again.
1136
1147
  store_association_can_not_be_changed: The store association can not be changed
@@ -1453,6 +1464,7 @@ en:
1453
1464
  new_api_key: New API Key
1454
1465
  new_balance: New balance
1455
1466
  new_billing_address: New Billing Address
1467
+ new_channel: New sales channel
1456
1468
  new_country: New Country
1457
1469
  new_custom_domain: New Custom Domain
1458
1470
  new_customer: New Customer
@@ -1641,6 +1653,10 @@ en:
1641
1653
  order_number: Order %{number}
1642
1654
  order_processed_successfully: Your order has been processed successfully
1643
1655
  order_resumed: Order resumed
1656
+ order_routing:
1657
+ strategies:
1658
+ legacy: Legacy
1659
+ rules: Rules (ordered)
1644
1660
  order_state:
1645
1661
  address: address
1646
1662
  awaiting_return: awaiting return
@@ -1886,15 +1902,24 @@ en:
1886
1902
  promotion_not_cloned: 'Promotion has not been cloned. Reason: %{error}'
1887
1903
  promotion_rule: Promotion Rule
1888
1904
  promotion_rule_types:
1905
+ category:
1906
+ description: Order includes products in specified categories
1907
+ name: Categories
1889
1908
  country:
1890
1909
  description: Limit to orders with shipping address in a specific country
1891
1910
  name: Country
1892
1911
  currency:
1893
1912
  description: Limit to orders in a specific currency
1894
1913
  name: Currency
1914
+ customer:
1915
+ description: Available only to the specified customers
1916
+ name: Customers
1895
1917
  customer_group:
1896
1918
  description: Available only to customers in specified customer group(s)
1897
1919
  name: Customer Group(s)
1920
+ customer_logged_in:
1921
+ description: Available only to logged in customers
1922
+ name: Only logged in customers
1898
1923
  first_order:
1899
1924
  description: Must be the customer's first order
1900
1925
  name: First order
@@ -1910,15 +1935,6 @@ en:
1910
1935
  product:
1911
1936
  description: Order includes specified product(s)
1912
1937
  name: Product(s)
1913
- taxon:
1914
- description: Order includes products with specified taxon(s)
1915
- name: Taxon(s)
1916
- user:
1917
- description: Available only to the specified customers
1918
- name: User
1919
- user_logged_in:
1920
- description: Available only to logged in users
1921
- name: User Logged In
1922
1938
  promotion_uses: Promotion uses
1923
1939
  promotionable: Promotable
1924
1940
  promotions: Promotions
@@ -2487,6 +2503,7 @@ en:
2487
2503
  view: View
2488
2504
  view_all: View all
2489
2505
  view_full_details: View full details
2506
+ view_full_size: View full size
2490
2507
  view_store: View store
2491
2508
  view_your_store: View your store
2492
2509
  visibility: Visibility
data/config/routes.rb CHANGED
@@ -40,6 +40,15 @@ Rails.application.routes.draw do
40
40
  )
41
41
  end
42
42
  end
43
+ # Used by admin mailers; the SPA derives the URL from `window.location.origin` instead.
44
+ direct :admin_invitation_acceptance do |invitation, _options = {}|
45
+ path = "/accept-invitation/#{invitation.prefixed_id}?token=#{invitation.token}"
46
+ base = Spree::Config[:admin_url].presence ||
47
+ (Rails.env.development? ? 'http://localhost:5173' : nil) ||
48
+ invitation.store&.formatted_url
49
+
50
+ base.present? ? "#{base.chomp('/')}#{path}" : path
51
+ end
43
52
  end
44
53
 
45
54
  Spree::Core::Engine.draw_routes
@@ -0,0 +1,25 @@
1
+ class CreateSpreeOrderCancellations < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :spree_order_cancellations do |t|
4
+ t.references :order, null: false, index: false
5
+ t.string :reason, null: false
6
+ t.text :note
7
+ t.boolean :restock_items, null: false
8
+ t.boolean :refund_payments, null: false
9
+ t.decimal :refund_amount, precision: 10, scale: 2
10
+ t.boolean :notify_customer, null: false
11
+ t.references :canceled_by, polymorphic: true, index: false
12
+ if t.respond_to? :jsonb
13
+ t.jsonb :metadata
14
+ else
15
+ t.json :metadata
16
+ end
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_order_cancellations, :order_id
21
+ add_index :spree_order_cancellations, [:canceled_by_id, :canceled_by_type],
22
+ name: 'idx_order_cancellations_canceled_by'
23
+ add_index :spree_order_cancellations, :created_at
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ class CreateSpreeOrderApprovals < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :spree_order_approvals do |t|
4
+ t.references :order, null: false, index: false
5
+ t.string :status, null: false
6
+ t.string :level
7
+ t.text :note
8
+ t.references :approver, polymorphic: true, index: false
9
+ t.datetime :decided_at
10
+ if t.respond_to? :jsonb
11
+ t.jsonb :metadata
12
+ else
13
+ t.json :metadata
14
+ end
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :spree_order_approvals, [:order_id, :status]
19
+ add_index :spree_order_approvals, [:approver_id, :approver_type],
20
+ name: 'idx_order_approvals_approver'
21
+ end
22
+ end