/
product.rb
587 lines (479 loc) 路 19.3 KB
/
product.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# PRODUCTS
# Products represent an entity for sale in a store.
# Products can have variations, called variants
# Products properties include description, permalink, availability,
# shipping category, etc. that do not change by variant.
#
# MASTER VARIANT
# Every product has one master variant, which stores master price and sku, size and weight, etc.
# The master variant does not have option values associated with it.
# Price, SKU, size, weight, etc. are all delegated to the master variant.
# Contains on_hand inventory levels only when there are no variants for the product.
#
# VARIANTS
# All variants can access the product properties directly (via reverse delegation).
# Inventory units are tied to Variant.
# The master variant can have inventory units, but not option values.
# All other variants have option values and may have inventory units.
# Sum of on_hand each variant's inventory level determine "on_hand" level for the product.
#
module Spree
class Product < Spree::Base
extend FriendlyId
include Spree::ProductScopes
include Spree::MultiStoreResource
include Spree::TranslatableResource
include Spree::TranslatableResourceSlug
include Spree::MemoizedData
include Spree::Metadata
if defined?(Spree::Webhooks::HasWebhooks)
include Spree::Webhooks::HasWebhooks
end
if defined?(Spree::VendorConcern)
include Spree::VendorConcern
end
MEMOIZED_METHODS = %w[total_on_hand taxonomy_ids taxon_and_ancestors category
default_variant_id tax_category default_variant
purchasable? in_stock? backorderable?]
TRANSLATABLE_FIELDS = %i[name description slug meta_description meta_keywords meta_title].freeze
translates(*TRANSLATABLE_FIELDS)
self::Translation.class_eval do
before_save :set_slug
acts_as_paranoid
# deleted translation values also need to be accessible for index views listing deleted resources
default_scope { unscope(where: :deleted_at) }
def set_slug
self.slug = generate_slug
end
private
def generate_slug
if name.blank? && slug.blank?
translated_model.name.to_url
elsif slug.blank?
name.to_url
else
slug.to_url
end
end
end
friendly_id :slug_candidates, use: [:history, :mobility]
acts_as_paranoid
auto_strip_attributes :name
# we need to have this callback before any dependent: :destroy associations
# https://github.com/rails/rails/issues/3458
before_destroy :ensure_not_in_complete_orders
has_many :product_option_types, dependent: :destroy, inverse_of: :product
has_many :option_types, through: :product_option_types
has_many :product_properties, dependent: :destroy, inverse_of: :product
has_many :properties, through: :product_properties
has_many :menu_items, as: :linked_resource
has_many :classifications, dependent: :delete_all, inverse_of: :product
has_many :taxons, through: :classifications, before_remove: :remove_taxon
has_many :product_promotion_rules, class_name: 'Spree::ProductPromotionRule'
has_many :promotion_rules, through: :product_promotion_rules, class_name: 'Spree::PromotionRule'
has_many :promotions, through: :promotion_rules, class_name: 'Spree::Promotion'
has_many :possible_promotions, -> { advertised.active }, through: :promotion_rules,
class_name: 'Spree::Promotion',
source: :promotion
belongs_to :tax_category, class_name: 'Spree::TaxCategory'
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', inverse_of: :products
has_one :master,
-> { where is_master: true },
inverse_of: :product,
class_name: 'Spree::Variant'
has_many :variants,
-> { where(is_master: false).order(:position) },
inverse_of: :product,
class_name: 'Spree::Variant'
has_many :variants_including_master,
-> { order(:position) },
inverse_of: :product,
class_name: 'Spree::Variant',
dependent: :destroy
has_many :prices, -> { order('spree_variants.position, spree_variants.id, currency') }, through: :variants
has_many :stock_items, through: :variants_including_master
has_many :line_items, through: :variants_including_master
has_many :orders, through: :line_items
has_many :variant_images, -> { order(:position) }, source: :images, through: :variants_including_master
has_many :variant_images_without_master, -> { order(:position) }, source: :images, through: :variants
has_many :store_products, class_name: 'Spree::StoreProduct'
has_many :stores, through: :store_products, class_name: 'Spree::Store'
has_many :digitals, through: :variants_including_master
after_create :add_associations_from_prototype
after_create :build_variants_from_option_values_hash, if: :option_values_hash
after_destroy :punch_slug
after_restore :update_slug_history
after_initialize :ensure_master
after_save :save_master
after_save :run_touch_callbacks, if: :anything_changed?
after_save :reset_nested_changes
after_touch :touch_taxons
before_validation :downcase_slug
before_validation :normalize_slug, on: :update
before_validation :validate_master
with_options length: { maximum: 255 }, allow_blank: true do
validates :meta_keywords
validates :meta_title
end
with_options presence: true do
validates :name
validates :shipping_category, if: :requires_shipping_category?
validates :price, if: :requires_price?
end
validates :slug, presence: true, uniqueness: { allow_blank: true, case_sensitive: true, scope: spree_base_uniqueness_scope }
validate :discontinue_on_must_be_later_than_make_active_at, if: -> { make_active_at && discontinue_on }
scope :for_store, ->(store) { joins(:store_products).where(StoreProduct.table_name => { store_id: store.id }) }
attr_accessor :option_values_hash
accepts_nested_attributes_for :product_properties, allow_destroy: true, reject_if: ->(pp) { pp[:property_name].blank? }
alias options product_option_types
self.whitelisted_ransackable_associations = %w[taxons stores variants_including_master master variants]
self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status]
self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between]
[
:sku, :barcode, :price, :currency, :weight, :height, :width, :depth, :is_master,
:cost_currency, :price_in, :amount_in, :cost_price, :compare_at_price, :compare_at_amount_in
].each do |method_name|
delegate method_name, :"#{method_name}=", to: :find_or_build_master
end
delegate :display_amount, :display_price, :has_default_price?,
:display_compare_at_price, :images, to: :find_or_build_master
alias master_images images
state_machine :status, initial: :draft do
event :activate do
transition to: :active
end
after_transition to: :active, do: :after_activate
event :archive do
transition to: :archived
end
after_transition to: :archived, do: :after_archive
event :draft do
transition to: :draft
end
after_transition to: :draft, do: :after_draft
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def purchasable?
default_variant.purchasable? || variants.any?(&:purchasable?)
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def in_stock?
default_variant.in_stock? || variants.any?(&:in_stock?)
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def backorderable?
default_variant.backorderable? || variants.any?(&:backorderable?)
end
def find_or_build_master
master || build_master
end
# the master variant is not a member of the variants array
def has_variants?
variants.any?
end
# Returns default Variant for Product
# If `track_inventory_levels` is enabled it will try to find the first Variant
# in stock or backorderable, if there's none it will return first Variant sorted
# by `position` attribute
# If `track_inventory_levels` is disabled it will return first Variant sorted
# by `position` attribute
#
# @return [Spree::Variant]
def default_variant
@default_variant ||= Rails.cache.fetch(default_variant_cache_key) do
if Spree::Config[:track_inventory_levels] && available_variant = variants.detect(&:purchasable?)
available_variant
else
has_variants? ? variants.first : master
end
end
end
# Returns default Variant ID for Product
# @return [Integer]
def default_variant_id
@default_variant_id ||= default_variant.id
end
def tax_category
@tax_category ||= super || TaxCategory.find_by(is_default: true)
end
# Adding properties and option types on creation based on a chosen prototype
attr_accessor :prototype_id
# Ensures option_types and product_option_types exist for keys in option_values_hash
def ensure_option_types_exist_for_values_hash
return if option_values_hash.nil?
required_option_type_ids = option_values_hash.keys.map(&:to_i)
missing_option_type_ids = required_option_type_ids - option_type_ids
missing_option_type_ids.each do |id|
product_option_types.create(option_type_id: id)
end
end
# for adding products which are closely related to existing ones
# define "duplicate_extra" for site-specific actions, eg for additional fields
def duplicate
duplicator = ProductDuplicator.new(self)
duplicator.duplicate
end
# use deleted? rather than checking the attribute directly. this
# allows extensions to override deleted? if they want to provide
# their own definition.
def deleted?
!!deleted_at
end
# determine if product is available.
# deleted products and products with status different than active
# are not available
def available?
active? && !deleted?
end
def discontinue!
self.discontinue_on = Time.current
self.status = 'archived'
save(validate: false)
end
def discontinued?
!!discontinue_on && discontinue_on <= Time.current
end
# determine if any variant (including master) can be supplied
def can_supply?
variants_including_master.any?(&:can_supply?)
end
# determine if any variant (including master) is out of stock and backorderable
def backordered?
variants_including_master.any?(&:backordered?)
end
# split variants list into hash which shows mapping of opt value onto matching variants
# eg categorise_variants_from_option(color) => {"red" -> [...], "blue" -> [...]}
def categorise_variants_from_option(opt_type)
return {} unless option_types.include?(opt_type)
variants.active.group_by { |v| v.option_values.detect { |o| o.option_type == opt_type } }
end
def self.like_any(fields, values)
conditions = fields.product(values).map do |(field, value)|
arel_table[field].matches("%#{value}%")
end
where conditions.inject(:or)
end
# Suitable for displaying only variants that has at least one option value.
# There may be scenarios where an option type is removed and along with it
# all option values. At that point all variants associated with only those
# values should not be displayed to frontend users. Otherwise it breaks the
# idea of having variants
def variants_and_option_values(current_currency = nil)
variants.active(current_currency).joins(:option_value_variants)
end
def empty_option_values?
options.empty? || options.any? do |opt|
opt.option_type.option_values.empty?
end
end
def property(property_name)
product_properties.joins(:property).
join_translation_table(Property).
find_by(Property.translation_table_alias => { name: property_name }).try(:value)
end
def set_property(property_name, property_value, property_presentation = property_name)
ApplicationRecord.transaction do
# Manual first_or_create to work around Mobility bug
property = if Property.where(name: property_name).exists?
Property.where(name: property_name).first
else
Property.create(name: property_name, presentation: property_presentation)
end
product_property = if ProductProperty.where(product: self, property: property).exists?
ProductProperty.where(product: self, property: property).first
else
ProductProperty.create(product: self, property: property)
end
product_property.value = property_value
product_property.save!
end
end
def total_on_hand
@total_on_hand ||= Rails.cache.fetch(['product-total-on-hand', cache_key_with_version]) do
if any_variants_not_track_inventory?
BigDecimal::INFINITY
else
stock_items.sum(:count_on_hand)
end
end
end
# Master variant may be deleted (i.e. when the product is deleted)
# which would make AR's default finder return nil.
# This is a stopgap for that little problem.
def master
super || variants_including_master.with_deleted.find_by(is_master: true)
end
def brand
@brand ||= taxons.joins(:taxonomy).
join_translation_table(Taxonomy).
find_by(Taxonomy.translation_table_alias => { name: Spree.t(:taxonomy_brands_name) })
end
def category
@category ||= taxons.joins(:taxonomy).
join_translation_table(Taxonomy).
order(depth: :desc).
find_by(Taxonomy.translation_table_alias => { name: Spree.t(:taxonomy_categories_name) })
end
def taxons_for_store(store)
Rails.cache.fetch("#{cache_key_with_version}/taxons-per-store/#{store.id}") do
taxons.for_store(store)
end
end
def any_variant_in_stock_or_backorderable?
if variants.any?
variants_including_master.in_stock_or_backorderable.exists?
else
master.in_stock_or_backorderable?
end
end
def digital?
shipping_category&.name == I18n.t('spree.seed.shipping.categories.digital')
end
private
def add_associations_from_prototype
if prototype_id && prototype = Spree::Prototype.find_by(id: prototype_id)
prototype.properties.each do |property|
product_properties.create(property: property, value: 'Placeholder')
end
self.option_types = prototype.option_types
self.taxons = prototype.taxons
end
end
def any_variants_not_track_inventory?
return true unless Spree::Config.track_inventory_levels
if variants_including_master.loaded?
variants_including_master.any? { |v| !v.track_inventory? }
else
variants_including_master.where(track_inventory: false).exists?
end
end
# Builds variants from a hash of option types & values
def build_variants_from_option_values_hash
ensure_option_types_exist_for_values_hash
values = option_values_hash.values
values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
values.each do |ids|
variants.create(
option_value_ids: ids,
price: master.price
)
end
save
end
def default_variant_cache_key
"spree/default-variant/#{cache_key_with_version}/#{Spree::Config[:track_inventory_levels]}"
end
def ensure_master
return unless new_record?
self.master ||= build_master
end
def normalize_slug
self.slug = normalize_friendly_id(slug)
end
def punch_slug
# punch slug with date prefix to allow reuse of original
return if frozen?
translations.with_deleted.each do |t|
t.update_column :slug, "#{Time.current.to_i}_#{t.slug}"[0..254]
end
end
def update_slug_history
save!
end
def anything_changed?
saved_changes? || @nested_changes
end
def reset_nested_changes
@nested_changes = false
end
def master_updated?
master && (
master.new_record? ||
master.changed? ||
(
master.default_price &&
(
master.default_price.new_record? ||
master.default_price.changed?
)
)
)
end
# there's a weird quirk with the delegate stuff that does not automatically save the delegate object
# when saving so we force a save using a hook
# Fix for issue #5306
def save_master
if master_updated?
master.save!
@nested_changes = true
end
end
# If the master cannot be saved, the Product object will get its errors
# and will be destroyed
def validate_master
# We call master.default_price here to ensure price is initialized.
# Required to avoid Variant#check_price validation failing on create.
unless master.default_price && master.valid?
master.errors.map { |error| { field: error.attribute, message: error&.message } }.each do |err|
next if err[:field].blank? || err[:message].blank?
errors.add(err[:field], err[:message])
end
end
end
# Try building a slug based on the following fields in increasing order of specificity.
def slug_candidates
[
:name,
[:name, :sku]
]
end
def run_touch_callbacks
run_callbacks(:touch)
end
def taxon_and_ancestors
@taxon_and_ancestors ||= taxons.map(&:self_and_ancestors).flatten.uniq
end
# Get the taxonomy ids of all taxons assigned to this product and their ancestors.
def taxonomy_ids
@taxonomy_ids ||= taxon_and_ancestors.map(&:taxonomy_id).flatten.uniq
end
# Iterate through this products taxons and taxonomies and touch their timestamps in a batch
def touch_taxons
Spree::Taxon.where(id: taxon_and_ancestors.map(&:id)).update_all(updated_at: Time.current)
Spree::Taxonomy.where(id: taxonomy_ids).update_all(updated_at: Time.current)
end
def ensure_not_in_complete_orders
if orders.complete.any?
errors.add(:base, :cannot_destroy_if_attached_to_line_items)
throw(:abort)
end
end
def remove_taxon(taxon)
removed_classifications = classifications.where(taxon: taxon)
removed_classifications.each &:remove_from_list
end
def discontinue_on_must_be_later_than_make_active_at
if discontinue_on < make_active_at
errors.add(:discontinue_on, :invalid_date_range)
end
end
def requires_price?
Spree::Config[:require_master_price]
end
def requires_shipping_category?
true
end
def downcase_slug
slug&.downcase!
end
def after_activate
# this method is prepended in api/ to queue Webhooks requests
end
def after_archive
# this method is prepended in api/ to queue Webhooks requests
end
def after_draft
# this method is prepended in api/ to queue Webhooks requests
end
end
end