spree_core 6.0.0.rc4 → 6.0.0.rc6
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.
- checksums.yaml +4 -4
- data/app/jobs/spree/images/save_from_url_job.rb +24 -5
- data/app/models/concerns/spree/account_deactivation.rb +54 -0
- data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
- data/app/models/concerns/spree/derived_number.rb +1 -1
- data/app/models/concerns/spree/has_external_references.rb +24 -16
- data/app/models/concerns/spree/prefixed_id.rb +15 -5
- data/app/models/concerns/spree/publishable.rb +47 -1
- data/app/models/concerns/spree/purchase/addresses.rb +114 -36
- data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
- data/app/models/concerns/spree/purchase/freight.rb +2 -1
- data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
- data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
- data/app/models/concerns/spree/stores/channels.rb +3 -1
- data/app/models/concerns/spree/translatable_resource.rb +6 -1
- data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- data/app/models/spree/admin_user.rb +1 -0
- data/app/models/spree/base.rb +33 -1
- data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
- data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
- data/app/models/spree/calculator/shipping/per_item.rb +3 -1
- data/app/models/spree/cart.rb +7 -7
- data/app/models/spree/category.rb +76 -5
- data/app/models/spree/channel.rb +79 -40
- data/app/models/spree/company.rb +46 -3
- data/app/models/spree/customer.rb +1 -1
- data/app/models/spree/customer_group.rb +59 -41
- data/app/models/spree/data_request.rb +34 -1
- data/app/models/spree/delivery_method.rb +18 -8
- data/app/models/spree/delivery_profile.rb +18 -0
- data/app/models/spree/digital_asset.rb +14 -0
- data/app/models/spree/event.rb +20 -5
- data/app/models/spree/fulfillment.rb +8 -1
- data/app/models/spree/fulfillment_changer.rb +3 -1
- data/app/models/spree/fulfillment_item.rb +11 -0
- data/app/models/spree/import.rb +20 -2
- data/app/models/spree/order.rb +1 -1
- data/app/models/spree/order_routing/strategy/rules.rb +4 -1
- data/app/models/spree/price_list.rb +15 -0
- data/app/models/spree/product/channels.rb +16 -8
- data/app/models/spree/product.rb +7 -2
- data/app/models/spree/return.rb +8 -1
- data/app/models/spree/sales_context.rb +162 -0
- data/app/models/spree/search_provider/base.rb +8 -3
- data/app/models/spree/search_provider/database.rb +2 -10
- data/app/models/spree/seller.rb +35 -5
- data/app/models/spree/seller_balance.rb +28 -0
- data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
- data/app/models/spree/stock/coordinator.rb +7 -4
- data/app/models/spree/stock/estimator.rb +6 -0
- data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
- data/app/models/spree/stock/packer.rb +39 -5
- data/app/models/spree/stock/quantifier.rb +16 -0
- data/app/models/spree/stock_location.rb +15 -8
- data/app/models/spree/store.rb +31 -2
- data/app/models/spree/store_credit.rb +4 -4
- data/app/models/spree/tax_line.rb +5 -4
- data/app/models/spree/variant.rb +23 -6
- data/app/models/spree/webhook_endpoint.rb +45 -11
- data/app/services/spree/carts/destroy.rb +7 -2
- data/app/services/spree/carts/price_items.rb +3 -0
- data/app/services/spree/carts/split_by_seller.rb +3 -1
- data/app/services/spree/carts/update.rb +1 -4
- data/app/services/spree/catalogs/set_product_terms.rb +8 -3
- data/app/services/spree/collections/add_products.rb +1 -1
- data/app/services/spree/collections/regenerate_products.rb +1 -3
- data/app/services/spree/collections/remove_products.rb +1 -1
- data/app/services/spree/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- data/app/services/spree/newsletter/subscribe.rb +9 -1
- data/app/services/spree/order_groups/allocate_payments.rb +7 -5
- data/app/services/spree/orders/update.rb +19 -0
- data/app/services/spree/product_memberships.rb +16 -6
- data/app/services/spree/products/duplicator.rb +12 -4
- data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
- data/app/services/spree/stock_levels/correct.rb +11 -4
- data/app/services/spree/stock_reservations/reserve.rb +1 -1
- data/app/services/spree/stores/provision_defaults.rb +11 -7
- data/app/services/spree/tags/bulk_add.rb +16 -6
- data/app/workflows/spree/carts/complete.rb +25 -9
- data/app/workflows/spree/carts/upsert_items.rb +31 -4
- data/app/workflows/spree/customers/anonymize.rb +40 -19
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- data/app/workflows/spree/digital_assets/create.rb +56 -0
- data/app/workflows/spree/digital_assets/update.rb +44 -0
- data/app/workflows/spree/digital_assets/writing.rb +108 -0
- data/app/workflows/spree/imports/start_mapping.rb +3 -1
- data/app/workflows/spree/orders/complete.rb +12 -5
- data/app/workflows/spree/orders/create.rb +41 -17
- data/app/workflows/spree/payments/capture.rb +12 -0
- data/app/workflows/spree/products/nested_attributes.rb +12 -28
- data/app/workflows/spree/products/propose.rb +9 -4
- data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
- data/app/workflows/spree/variants/nested_attributes.rb +35 -10
- data/config/locales/en.yml +14 -0
- data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
- data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
- data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
- data/lib/spree/checkout/default_requirements.rb +12 -2
- data/lib/spree/core/dependencies.rb +5 -0
- data/lib/spree/core/engine.rb +2 -0
- data/lib/spree/core/permissions/default_catalog.rb +7 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/reporting/default_vocabulary.rb +1 -1
- data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
- data/lib/spree/testing_support/factories/product_factory.rb +10 -4
- data/lib/spree/testing_support/factories/store_factory.rb +4 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
- data/lib/tasks/backfill_tagging_tenants.rake +35 -0
- data/lib/tasks/seller_sample_data.rake +114 -112
- metadata +15 -6
data/app/models/spree/ability.rb
CHANGED
|
@@ -167,6 +167,9 @@ module Spree
|
|
|
167
167
|
|
|
168
168
|
# Reference data every staff member can read regardless of keys — address
|
|
169
169
|
# forms need countries/states, and the dashboard shell reads the store.
|
|
170
|
+
# The back office also reads the store's markets and channels: its
|
|
171
|
+
# currencies and languages come from the markets, and list filters offer
|
|
172
|
+
# both.
|
|
170
173
|
#
|
|
171
174
|
# The store grant is unrestricted only for the store's own back office.
|
|
172
175
|
# Another panel reads the one store it operates under and no other, so a
|
|
@@ -180,6 +183,11 @@ module Spree
|
|
|
180
183
|
else
|
|
181
184
|
can :read, Spree::Store, id: @store.id
|
|
182
185
|
end
|
|
186
|
+
|
|
187
|
+
return unless store_resource?
|
|
188
|
+
|
|
189
|
+
can :read, Spree::Market, store_id: @resource.id
|
|
190
|
+
can :read, Spree::Channel, store_id: @resource.id
|
|
183
191
|
end
|
|
184
192
|
|
|
185
193
|
end
|
data/app/models/spree/address.rb
CHANGED
|
@@ -343,6 +343,12 @@ module Spree
|
|
|
343
343
|
def snapshot
|
|
344
344
|
dup.tap do |copy|
|
|
345
345
|
copy.owner = nil
|
|
346
|
+
|
|
347
|
+
if business_owned?
|
|
348
|
+
copy.company = company.presence || owner&.name
|
|
349
|
+
copy.owner_type = owner_type
|
|
350
|
+
end
|
|
351
|
+
|
|
346
352
|
copy.label = nil
|
|
347
353
|
copy.deleted_at = nil
|
|
348
354
|
end
|
data/app/models/spree/base.rb
CHANGED
|
@@ -46,13 +46,21 @@ class Spree::Base < ApplicationRecord
|
|
|
46
46
|
select("#{klass.table_name}.*").select(*(fields || klass::TRANSLATABLE_FIELDS))
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
# The store's own rows of this model, through the association named after
|
|
50
|
+
# it. A model the store has no association for answers nothing: a tenant
|
|
51
|
+
# boundary that quietly widened to every store's rows is how a cross-store
|
|
52
|
+
# read ships unnoticed. Global reference data (countries, roles) and
|
|
53
|
+
# records reached through a parent are fetched as such, never through here.
|
|
54
|
+
#
|
|
55
|
+
# @param store [Spree::Store]
|
|
56
|
+
# @return [ActiveRecord::Relation]
|
|
49
57
|
def self.for_store(store)
|
|
50
58
|
plural_model_name = model_name.plural.gsub(/spree_/, '').to_sym
|
|
51
59
|
|
|
52
60
|
if store.respond_to?(plural_model_name)
|
|
53
61
|
store.send(plural_model_name)
|
|
54
62
|
else
|
|
55
|
-
|
|
63
|
+
none
|
|
56
64
|
end
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -81,6 +89,30 @@ class Spree::Base < ApplicationRecord
|
|
|
81
89
|
association(name).loaded? || lazy_preload_context.present?
|
|
82
90
|
end
|
|
83
91
|
|
|
92
|
+
# Recomputes a counter cache for many records at once. Rails'
|
|
93
|
+
# +reset_counters+ spends two queries per record, which a bulk write pays
|
|
94
|
+
# for every row it touched; this is one grouped count plus one UPDATE per
|
|
95
|
+
# distinct value.
|
|
96
|
+
#
|
|
97
|
+
# @param ids [Array] the records whose counter is recomputed
|
|
98
|
+
# @param association [Symbol] the has_many whose rows are counted
|
|
99
|
+
# @return [void]
|
|
100
|
+
def self.reset_counters_in_bulk(ids, association)
|
|
101
|
+
ids = Array(ids).compact.uniq
|
|
102
|
+
return if ids.empty?
|
|
103
|
+
|
|
104
|
+
reflection = reflect_on_association(association)
|
|
105
|
+
counter = reflection.klass.reflect_on_all_associations(:belongs_to).find do |belongs_to|
|
|
106
|
+
belongs_to.foreign_key.to_s == reflection.foreign_key.to_s && belongs_to.options[:counter_cache]
|
|
107
|
+
end.counter_cache_column
|
|
108
|
+
|
|
109
|
+
counts = reflection.klass.where(reflection.foreign_key => ids).group(reflection.foreign_key).count
|
|
110
|
+
|
|
111
|
+
ids.group_by { |id| counts.fetch(id, 0) }.each do |count, grouped_ids|
|
|
112
|
+
unscoped.where(id: grouped_ids).where.not(counter => count).update_all(counter => count)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
84
116
|
# @see Spree.mysql? — the single definition; this reads it so a model can
|
|
85
117
|
# branch without reaching for the connection itself.
|
|
86
118
|
def mysql_adapter?
|
|
@@ -3,7 +3,9 @@ module Spree
|
|
|
3
3
|
class DigitalDelivery < ShippingCalculator
|
|
4
4
|
include Spree::Calculator::CurrencyAmounts
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Blank once its figure has moved into `amounts`, so the method quotes
|
|
7
|
+
# nothing outside the hash.
|
|
8
|
+
preference :amount, :money, default: 0, nullable: true
|
|
7
9
|
preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
|
|
8
10
|
|
|
9
11
|
def self.description
|
|
@@ -3,7 +3,9 @@ module Spree
|
|
|
3
3
|
class FlatRate < ShippingCalculator
|
|
4
4
|
include Spree::Calculator::CurrencyAmounts
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Blank once its figure has moved into `amounts`, so the method quotes
|
|
7
|
+
# nothing outside the hash.
|
|
8
|
+
preference :amount, :money, default: 0, nullable: true
|
|
7
9
|
# @deprecated Together with the single `amount`, superseded by the
|
|
8
10
|
# per-currency `amounts` hash; kept as the fallback for its own
|
|
9
11
|
# currency so upgraded stores quote unchanged.
|
|
@@ -3,7 +3,9 @@ module Spree
|
|
|
3
3
|
class PerItem < ShippingCalculator
|
|
4
4
|
include Spree::Calculator::CurrencyAmounts
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Blank once its figure has moved into `amounts`, so the method quotes
|
|
7
|
+
# nothing outside the hash.
|
|
8
|
+
preference :amount, :money, default: 0, nullable: true
|
|
7
9
|
preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
|
|
8
10
|
|
|
9
11
|
def self.description
|
data/app/models/spree/cart.rb
CHANGED
|
@@ -203,10 +203,13 @@ module Spree
|
|
|
203
203
|
# delivery_unavailable warning per affected line item (parity with the
|
|
204
204
|
# order-side ensure_available_delivery_rates).
|
|
205
205
|
def prune_undeliverable_fulfillments!
|
|
206
|
-
|
|
206
|
+
candidates = fulfillments.reload.to_a
|
|
207
|
+
ActiveRecord::Associations::Preloader.new(records: candidates, associations: :delivery_rates).call
|
|
208
|
+
undeliverable = candidates.select { |fulfillment| fulfillment.delivery_rates.empty? }
|
|
207
209
|
return if undeliverable.empty?
|
|
208
210
|
|
|
209
211
|
affected_line_items = undeliverable.flat_map(&:line_items).uniq
|
|
212
|
+
ActiveRecord::Associations::Preloader.new(records: affected_line_items, associations: { variant: :product }).call
|
|
210
213
|
undeliverable.each(&:destroy)
|
|
211
214
|
self.warnings |= affected_line_items.map do |line_item|
|
|
212
215
|
{
|
|
@@ -284,12 +287,9 @@ module Spree
|
|
|
284
287
|
#
|
|
285
288
|
# @return [ActiveRecord::Relation<Spree::Variant>]
|
|
286
289
|
def orderable_variants
|
|
287
|
-
|
|
288
|
-
store: store, channel: channel, customer: customer, company: resolved_company,
|
|
289
|
-
|
|
290
|
-
).value
|
|
291
|
-
|
|
292
|
-
Spree::Variant.for_products(products)
|
|
290
|
+
Spree::SalesContext.new(
|
|
291
|
+
store: store, channel: channel, customer: customer, company: resolved_company, resolve_company: false
|
|
292
|
+
).orderable_variants
|
|
293
293
|
end
|
|
294
294
|
|
|
295
295
|
# Removes out-of-stock/discontinued items and populates warnings
|
|
@@ -81,7 +81,13 @@ module Spree
|
|
|
81
81
|
validates :meta_description
|
|
82
82
|
validates :meta_title
|
|
83
83
|
end
|
|
84
|
-
|
|
84
|
+
# Only a file assigned in this write is checked: a stored one passed when
|
|
85
|
+
# it was attached, and reading it back would cost a query per slot on
|
|
86
|
+
# every save — including each category a product write validates.
|
|
87
|
+
validates :image, content_type: Rails.application.config.active_storage.web_image_content_types,
|
|
88
|
+
if: -> { attachment_changes.key?('image') }
|
|
89
|
+
validates :square_image, content_type: Rails.application.config.active_storage.web_image_content_types,
|
|
90
|
+
if: -> { attachment_changes.key?('square_image') }
|
|
85
91
|
|
|
86
92
|
#
|
|
87
93
|
# Callbacks
|
|
@@ -183,16 +189,81 @@ module Spree
|
|
|
183
189
|
def self.recalculate_products_count(category_ids)
|
|
184
190
|
changed = unscoped.where(id: Array(category_ids).compact.uniq)
|
|
185
191
|
|
|
186
|
-
# The affected nodes are each changed category plus its ancestors
|
|
187
|
-
|
|
192
|
+
# The affected nodes are each changed category plus its ancestors,
|
|
193
|
+
# collected in one read: an ancestor chain per category is a query per
|
|
194
|
+
# node, which a bulk catalog move pays for every category it touched.
|
|
195
|
+
# A nested set makes an ancestor a node whose `lft`/`rgt` span the
|
|
196
|
+
# child's, so one range query answers for all of them.
|
|
197
|
+
changed = changed.to_a
|
|
198
|
+
return if changed.empty?
|
|
199
|
+
|
|
200
|
+
ancestor_conditions = changed.map do |category|
|
|
201
|
+
arel_table[:store_id].eq(category.store_id).
|
|
202
|
+
and(arel_table[:lft].lteq(category.lft)).
|
|
203
|
+
and(arel_table[:rgt].gteq(category.rgt))
|
|
204
|
+
end.reduce(:or)
|
|
205
|
+
|
|
206
|
+
affected = unscoped.where(ancestor_conditions).to_a
|
|
207
|
+
|
|
208
|
+
# One grouped query for the whole set rather than a count per node:
|
|
209
|
+
# this runs on every catalog move, and a deep tree would otherwise
|
|
210
|
+
# spend a query on each affected ancestor.
|
|
211
|
+
counts = distinct_product_counts_for(affected)
|
|
188
212
|
|
|
189
213
|
affected.each do |category|
|
|
190
|
-
count =
|
|
191
|
-
.distinct.count(:product_id)
|
|
214
|
+
count = counts.fetch(category.id, 0)
|
|
192
215
|
category.update_column(:products_count, count) if category.products_count != count
|
|
193
216
|
end
|
|
194
217
|
end
|
|
195
218
|
|
|
219
|
+
# Distinct product counts per category, each counting its whole subtree.
|
|
220
|
+
# A nested set stores the subtree as an `lft`/`rgt` range, so the tie
|
|
221
|
+
# between a node and its descendants is a range join rather than a
|
|
222
|
+
# separate descendant query for every node.
|
|
223
|
+
#
|
|
224
|
+
# @param categories [Array<Spree::Category>]
|
|
225
|
+
# @return [Hash{Integer => Integer}] category id => distinct product count
|
|
226
|
+
def self.distinct_product_counts_for(categories)
|
|
227
|
+
return {} if categories.empty?
|
|
228
|
+
|
|
229
|
+
nodes = arel_table.alias('counted_nodes')
|
|
230
|
+
descendants = arel_table
|
|
231
|
+
product_categories = Spree::ProductCategory.arel_table
|
|
232
|
+
|
|
233
|
+
relation = unscoped.
|
|
234
|
+
from(nodes).
|
|
235
|
+
joins(
|
|
236
|
+
Arel::Nodes::InnerJoin.new(
|
|
237
|
+
descendants,
|
|
238
|
+
Arel::Nodes::On.new(
|
|
239
|
+
descendants[:store_id].eq(nodes[:store_id]).
|
|
240
|
+
and(descendants[:lft].gteq(nodes[:lft])).
|
|
241
|
+
and(descendants[:rgt].lteq(nodes[:rgt]))
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
).
|
|
245
|
+
joins(
|
|
246
|
+
Arel::Nodes::InnerJoin.new(
|
|
247
|
+
product_categories,
|
|
248
|
+
Arel::Nodes::On.new(product_categories[:category_id].eq(descendants[:id]))
|
|
249
|
+
)
|
|
250
|
+
).
|
|
251
|
+
where(nodes[:id].in(categories.map(&:id))).
|
|
252
|
+
group(nodes[:id])
|
|
253
|
+
|
|
254
|
+
relation.distinct.count(product_categories[:product_id])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The breadcrumb trail, with the images a serializer renders alongside
|
|
258
|
+
# each crumb already loaded. `ancestors` comes from the nested set and
|
|
259
|
+
# preloads nothing, so drawing a trail would otherwise cost two
|
|
260
|
+
# attachment lookups per level.
|
|
261
|
+
#
|
|
262
|
+
# @return [ActiveRecord::Relation<Spree::Category>]
|
|
263
|
+
def ancestors
|
|
264
|
+
super.with_attached_image.with_attached_square_image
|
|
265
|
+
end
|
|
266
|
+
|
|
196
267
|
def slug
|
|
197
268
|
permalink
|
|
198
269
|
end
|
data/app/models/spree/channel.rb
CHANGED
|
@@ -62,6 +62,47 @@ module Spree
|
|
|
62
62
|
self.whitelisted_ransackable_attributes = %w[name code active default store_id]
|
|
63
63
|
search_by :name
|
|
64
64
|
|
|
65
|
+
# Publishes the given products on every channel in the current scope
|
|
66
|
+
# (`store.channels.where(id: ids).add_products(product_ids)`), touching
|
|
67
|
+
# and reindexing each product once rather than once per channel.
|
|
68
|
+
# @param product_ids [Array<Integer>, Integer]
|
|
69
|
+
# @return [Integer] the number of ProductPublications created or updated
|
|
70
|
+
def self.add_products(product_ids)
|
|
71
|
+
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
72
|
+
channels = all.to_a
|
|
73
|
+
return 0 if product_ids.empty? || channels.empty?
|
|
74
|
+
|
|
75
|
+
count = channels.sum { |channel| channel.send(:upsert_publications, product_ids) }
|
|
76
|
+
refresh_products(product_ids)
|
|
77
|
+
channels.each(&:touch)
|
|
78
|
+
count
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Unpublishes the given products from every channel in the current scope.
|
|
82
|
+
# @param product_ids [Array<Integer>, Integer]
|
|
83
|
+
# @return [Integer] the number of ProductPublications destroyed
|
|
84
|
+
def self.remove_products(product_ids)
|
|
85
|
+
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
86
|
+
channels = all.to_a
|
|
87
|
+
return 0 if product_ids.empty? || channels.empty?
|
|
88
|
+
|
|
89
|
+
removed = Spree::ProductPublication.includes(:product).where(channel_id: channels.map(&:id), product_id: product_ids).destroy_all
|
|
90
|
+
refresh_products(product_ids)
|
|
91
|
+
emptied_channel_ids = removed.map(&:channel_id).uniq
|
|
92
|
+
channels.select { |channel| emptied_channel_ids.include?(channel.id) }.each(&:touch)
|
|
93
|
+
removed.size
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param product_ids [Array<String>]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def self.refresh_products(product_ids)
|
|
99
|
+
products = Spree::Product.where(id: product_ids)
|
|
100
|
+
products.touch_all
|
|
101
|
+
# `to_a` once: `touch_all` does not load the relation, so iterating it
|
|
102
|
+
# afterwards would re-run the query.
|
|
103
|
+
products.to_a.each(&:enqueue_search_index)
|
|
104
|
+
end
|
|
105
|
+
|
|
65
106
|
# Publishes the given products on this channel by creating/upserting ProductPublications.
|
|
66
107
|
# Optionally sets the publication window; if not given, the products will be published immediately
|
|
67
108
|
# with no end date.
|
|
@@ -73,42 +114,10 @@ module Spree
|
|
|
73
114
|
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
74
115
|
return 0 if product_ids.empty?
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# explicitly passed a value. Leaving them out keeps existing
|
|
79
|
-
# publication schedules intact on re-publish — otherwise +on_duplicate:
|
|
80
|
-
# :update+ + +update_only+ would rewrite scheduled +published_at+ /
|
|
81
|
-
# +unpublished_at+ to NULL whenever the bulk action re-runs without
|
|
82
|
-
# dates.
|
|
83
|
-
base = { channel_id: id, created_at: now, updated_at: now }
|
|
84
|
-
base[:published_at] = published_at unless published_at.nil?
|
|
85
|
-
base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
|
|
86
|
-
|
|
87
|
-
records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
|
|
88
|
-
|
|
89
|
-
# Only update the window columns the caller passed. When neither was
|
|
90
|
-
# passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
|
|
91
|
-
# +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
|
|
92
|
-
update_columns = []
|
|
93
|
-
update_columns << :published_at unless published_at.nil?
|
|
94
|
-
update_columns << :unpublished_at unless unpublished_at.nil?
|
|
95
|
-
opts = if update_columns.empty?
|
|
96
|
-
{ on_duplicate: :skip }
|
|
97
|
-
else
|
|
98
|
-
{ record_timestamps: false, update_only: update_columns, on_duplicate: :update }
|
|
99
|
-
end
|
|
100
|
-
# MySQL infers the conflict target from the table's unique constraints
|
|
101
|
-
# and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
|
|
102
|
-
opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
|
|
103
|
-
|
|
104
|
-
Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
|
|
105
|
-
|
|
106
|
-
products = Spree::Product.where(id: product_ids)
|
|
107
|
-
products.touch_all
|
|
108
|
-
products.each(&:enqueue_search_index)
|
|
117
|
+
count = upsert_publications(product_ids, published_at: published_at, unpublished_at: unpublished_at)
|
|
118
|
+
self.class.refresh_products(product_ids)
|
|
109
119
|
touch
|
|
110
|
-
|
|
111
|
-
records_to_upsert.size
|
|
120
|
+
count
|
|
112
121
|
end
|
|
113
122
|
|
|
114
123
|
# Unpublishes the given products from this channel.
|
|
@@ -118,12 +127,9 @@ module Spree
|
|
|
118
127
|
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
119
128
|
return 0 if product_ids.empty?
|
|
120
129
|
|
|
121
|
-
count = publications.where(product_id: product_ids).destroy_all.size
|
|
122
|
-
|
|
123
|
-
products = Spree::Product.where(id: product_ids)
|
|
124
|
-
products.touch_all
|
|
125
|
-
products.each(&:enqueue_search_index)
|
|
130
|
+
count = publications.includes(:product).where(product_id: product_ids).destroy_all.size
|
|
126
131
|
|
|
132
|
+
self.class.refresh_products(product_ids)
|
|
127
133
|
touch if count.positive?
|
|
128
134
|
count
|
|
129
135
|
end
|
|
@@ -161,6 +167,39 @@ module Spree
|
|
|
161
167
|
|
|
162
168
|
private
|
|
163
169
|
|
|
170
|
+
def upsert_publications(product_ids, published_at: nil, unpublished_at: nil)
|
|
171
|
+
now = Time.current
|
|
172
|
+
# Only include window columns in the upsert payload when the caller
|
|
173
|
+
# explicitly passed a value. Leaving them out keeps existing
|
|
174
|
+
# publication schedules intact on re-publish — otherwise +on_duplicate:
|
|
175
|
+
# :update+ + +update_only+ would rewrite scheduled +published_at+ /
|
|
176
|
+
# +unpublished_at+ to NULL whenever the bulk action re-runs without
|
|
177
|
+
# dates.
|
|
178
|
+
base = { channel_id: id, created_at: now, updated_at: now }
|
|
179
|
+
base[:published_at] = published_at unless published_at.nil?
|
|
180
|
+
base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
|
|
181
|
+
|
|
182
|
+
records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
|
|
183
|
+
|
|
184
|
+
# Only update the window columns the caller passed. When neither was
|
|
185
|
+
# passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
|
|
186
|
+
# +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
|
|
187
|
+
update_columns = []
|
|
188
|
+
update_columns << :published_at unless published_at.nil?
|
|
189
|
+
update_columns << :unpublished_at unless unpublished_at.nil?
|
|
190
|
+
opts = if update_columns.empty?
|
|
191
|
+
{ on_duplicate: :skip }
|
|
192
|
+
else
|
|
193
|
+
{ record_timestamps: false, update_only: update_columns, on_duplicate: :update }
|
|
194
|
+
end
|
|
195
|
+
# MySQL infers the conflict target from the table's unique constraints
|
|
196
|
+
# and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
|
|
197
|
+
opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
|
|
198
|
+
|
|
199
|
+
Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
|
|
200
|
+
records_to_upsert.size
|
|
201
|
+
end
|
|
202
|
+
|
|
164
203
|
def ensure_not_default
|
|
165
204
|
return unless default?
|
|
166
205
|
return if destroyed_by_association.present?
|
data/app/models/spree/company.rb
CHANGED
|
@@ -177,10 +177,47 @@ module Spree
|
|
|
177
177
|
self_and_descendants.where.not(id: id)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# Distinct members across each node's whole subtree, for many nodes in
|
|
181
|
+
# one query.
|
|
182
|
+
#
|
|
183
|
+
# @param company_ids [Array] the subtree roots
|
|
184
|
+
# @return [Hash{Integer => Integer}] member count by root id; roots with none are absent
|
|
185
|
+
def self.members_counts_for(company_ids)
|
|
186
|
+
return {} if company_ids.empty?
|
|
187
|
+
|
|
188
|
+
membership_table = Spree::CompanyMembership.table_name
|
|
189
|
+
subtree_sql = sanitize_sql_array([<<~SQL.squish, company_ids])
|
|
190
|
+
WITH RECURSIVE subtree(root_id, id) AS (
|
|
191
|
+
SELECT id, id FROM #{table_name} WHERE id IN (?)
|
|
192
|
+
UNION
|
|
193
|
+
SELECT subtree.root_id, child.id FROM #{table_name} child
|
|
194
|
+
INNER JOIN subtree ON child.parent_id = subtree.id
|
|
195
|
+
)
|
|
196
|
+
SELECT root_id, id FROM subtree
|
|
197
|
+
SQL
|
|
198
|
+
|
|
199
|
+
Spree::CompanyMembership.
|
|
200
|
+
joins("INNER JOIN (#{subtree_sql}) company_subtree ON company_subtree.id = #{membership_table}.company_id").
|
|
201
|
+
group('company_subtree.root_id').
|
|
202
|
+
distinct.count(:customer_id)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# On a lazily preloaded list, the first call counts every node of the
|
|
206
|
+
# list at once.
|
|
207
|
+
#
|
|
180
208
|
# @return [Integer]
|
|
181
209
|
def members_count
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
return @members_count if defined?(@members_count)
|
|
211
|
+
|
|
212
|
+
siblings = lazy_preload_context&.records&.grep(self.class)
|
|
213
|
+
if siblings.blank?
|
|
214
|
+
return Spree::CompanyMembership.where(company_id: self_and_descendants.select(:id)).
|
|
215
|
+
distinct.count(:customer_id)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
counts = self.class.members_counts_for(siblings.map(&:id))
|
|
219
|
+
siblings.each { |company| company.preloaded_members_count = counts.fetch(company.id, 0) }
|
|
220
|
+
@members_count
|
|
184
221
|
end
|
|
185
222
|
|
|
186
223
|
# The single tax anchor: the nearest self-or-ancestor +company+ node. Tax
|
|
@@ -245,7 +282,13 @@ module Spree
|
|
|
245
282
|
#
|
|
246
283
|
# @return [ActiveRecord::Relation]
|
|
247
284
|
def address_book
|
|
248
|
-
Spree::Address.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
|
|
285
|
+
Spree::Address.not_deleted.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
protected
|
|
289
|
+
|
|
290
|
+
def preloaded_members_count=(count)
|
|
291
|
+
@members_count = count
|
|
249
292
|
end
|
|
250
293
|
|
|
251
294
|
private
|
|
@@ -16,7 +16,7 @@ module Spree
|
|
|
16
16
|
include Spree::AccountLockout
|
|
17
17
|
include Spree::PasswordPolicy
|
|
18
18
|
|
|
19
|
-
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
|
19
|
+
validates :email, presence: true, uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope }
|
|
20
20
|
|
|
21
21
|
attribute :accepts_email_marketing, :boolean, default: false
|
|
22
22
|
|
|
@@ -21,7 +21,10 @@ module Spree
|
|
|
21
21
|
# own association (rather than `alias_method`) is what lets `customer_ids=`
|
|
22
22
|
# exist and what makes the `PrefixedId` auto-decoder in `assign_attributes`
|
|
23
23
|
# recognise the key — that lookup keys off `reflect_on_association(:customers)`.
|
|
24
|
-
|
|
24
|
+
# Joining a group changes nothing on the customer, so saving the group
|
|
25
|
+
# does not re-validate every member it lists.
|
|
26
|
+
has_many :customers, through: :customer_group_users, source: :customer, source_type: Spree.customer_class.to_s,
|
|
27
|
+
validate: false
|
|
25
28
|
|
|
26
29
|
#
|
|
27
30
|
# Validations
|
|
@@ -41,64 +44,79 @@ module Spree
|
|
|
41
44
|
# @param user_ids [Array] array of user IDs to add
|
|
42
45
|
# @return [Integer] number of customers added
|
|
43
46
|
def add_customers(user_ids)
|
|
44
|
-
|
|
47
|
+
self.class.add_customers_to([self], user_ids)
|
|
48
|
+
end
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
# Bulk remove customers from the group
|
|
51
|
+
# @param user_ids [Array] array of user IDs to remove
|
|
52
|
+
# @return [Integer] number of customers removed
|
|
53
|
+
def remove_customers(user_ids)
|
|
54
|
+
self.class.remove_customers_from([self], user_ids)
|
|
55
|
+
end
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
# Adds customers to every given group, skipping memberships that already
|
|
58
|
+
# exist, with one lookup for all groups.
|
|
59
|
+
# @param groups [Array<Spree::CustomerGroup>]
|
|
60
|
+
# @param customer_ids [Array] customer IDs to add
|
|
61
|
+
# @return [Integer] number of memberships created
|
|
62
|
+
def self.add_customers_to(groups, customer_ids)
|
|
63
|
+
customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
|
|
64
|
+
groups = Array(groups)
|
|
65
|
+
return 0 if customer_ids.empty? || groups.empty?
|
|
66
|
+
|
|
67
|
+
existing_pairs = Spree::CustomerGroupUser.
|
|
68
|
+
where(customer_group_id: groups.map(&:id), customer_id: customer_ids).
|
|
69
|
+
pluck(:customer_group_id, :customer_id).
|
|
70
|
+
to_set { |group_id, customer_id| [group_id, customer_id.to_s] }
|
|
51
71
|
|
|
52
72
|
now = Time.current
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
customer_type = Spree.customer_class.to_s
|
|
74
|
+
records_to_insert = groups.flat_map do |group|
|
|
75
|
+
customer_ids.filter_map do |customer_id|
|
|
76
|
+
next if existing_pairs.include?([group.id, customer_id])
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
customer_group_id: group.id,
|
|
80
|
+
customer_id: customer_id,
|
|
81
|
+
customer_type: customer_type,
|
|
82
|
+
created_at: now,
|
|
83
|
+
updated_at: now
|
|
84
|
+
}
|
|
85
|
+
end
|
|
65
86
|
end
|
|
66
|
-
|
|
67
87
|
return 0 if records_to_insert.empty?
|
|
68
88
|
|
|
69
89
|
Spree::CustomerGroupUser.upsert_all(records_to_insert, on_duplicate: :skip)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
touch
|
|
90
|
+
touch_customers(records_to_insert.map { |record| record[:customer_id] }.uniq)
|
|
91
|
+
changed_group_ids = records_to_insert.map { |record| record[:customer_group_id] }.to_set
|
|
92
|
+
groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
|
|
73
93
|
|
|
74
94
|
records_to_insert.size
|
|
75
95
|
end
|
|
76
96
|
|
|
77
|
-
#
|
|
78
|
-
# @param
|
|
79
|
-
# @
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
# Removes customers from every given group.
|
|
98
|
+
# @param groups [Array<Spree::CustomerGroup>]
|
|
99
|
+
# @param customer_ids [Array] customer IDs to remove
|
|
100
|
+
# @return [Integer] number of memberships removed
|
|
101
|
+
def self.remove_customers_from(groups, customer_ids)
|
|
102
|
+
customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
|
|
103
|
+
groups = Array(groups)
|
|
104
|
+
return 0 if customer_ids.empty? || groups.empty?
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
memberships = Spree::CustomerGroupUser.where(customer_group_id: groups.map(&:id), customer_id: customer_ids)
|
|
107
|
+
changed_group_ids = memberships.distinct.pluck(:customer_group_id).to_set
|
|
108
|
+
return 0 if changed_group_ids.empty?
|
|
85
109
|
|
|
86
|
-
deleted_count =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
touch_users(user_ids)
|
|
90
|
-
touch
|
|
91
|
-
end
|
|
110
|
+
deleted_count = memberships.delete_all
|
|
111
|
+
touch_customers(customer_ids)
|
|
112
|
+
groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
|
|
92
113
|
|
|
93
114
|
deleted_count
|
|
94
115
|
end
|
|
95
116
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def touch_users(user_ids)
|
|
99
|
-
return if user_ids.blank?
|
|
100
|
-
|
|
101
|
-
Spree.customer_class.where(id: user_ids).touch_all
|
|
117
|
+
def self.touch_customers(customer_ids)
|
|
118
|
+
Spree.customer_class.where(id: customer_ids).touch_all
|
|
102
119
|
end
|
|
120
|
+
private_class_method :touch_customers
|
|
103
121
|
end
|
|
104
122
|
end
|