Class: ProductSpecification

Inherits:
ApplicationRecord show all
Includes:
ActionView::Helpers::TagHelper, ActionView::Helpers::UrlHelper, Models::Auditable, Models::Translatable, PgSearch::Model
Defined in:
app/models/product_specification.rb

Overview

== Schema Information

Table name: product_specifications
Database name: primary

id :integer not null, primary key
description :text
do_not_merge :boolean default(FALSE), not null
formatter :string
grouping :string
has_liquid_blurb :boolean default(FALSE), not null
method :string(255) not null
name :string(255) not null
position :integer not null
print_on_item_label :boolean default(FALSE), not null
propagation :enum default("unrestricted")
sku_regexp :string
template :boolean default(FALSE)
text_blurb :text
token :string(255)
tokenizer_character :string(1)
translations :jsonb
units :string(255)
visibility :enum not null
created_at :datetime not null
updated_at :datetime not null
creator_id :integer
image_id :integer
product_category_id :integer
product_line_id :integer
template_product_specification_id :integer
updater_id :integer

Indexes

idx_name_template (name,template)
idx_pc_id_product_line_id (product_category_id,product_line_id)
idx_propagation_token_template (propagation,token,template)
index_product_specifications_grouping (grouping)
index_product_specifications_on_image_id (image_id)
index_product_specifications_on_position (position)
index_product_specifications_on_product_line_id (product_line_id)
index_product_specifications_on_token (token)
product_specifications_template_product_specification_id_idx (template_product_specification_id)

Foreign Keys

fk_rails_... (image_id => digital_assets.id)
fk_rails_... (product_category_id => product_categories.id)
fk_rails_... (product_line_id => product_lines.id)
fk_rails_... (template_product_specification_id => product_specifications.id) ON DELETE => nullify

Constant Summary collapse

TRANSLATION_NAMESPACE =

Translation namespace.

'ProductSpecification'
SAFE_METHODS =

Safe methods.

%w[
  text
  base_weight
  total_shipping_weight
  btu_per_hour
  calculate_amps
  calculate_kilowatts
  calculate_number_of_fixing_strips_at_3_in
  calculate_number_of_fixing_strips_at_4_in
  calculate_number_of_fixing_strips_at_5_in
  calculate_ohms
  calculate_ohms_per_ft
  calculate_geometric_sqft
  calculate_sqft
  calculate_watts
  calculate_watts_per_sqft
  calculate_heated_area_sqft_for_panels
  calculate_heated_area_sqm_for_panels
  calculate_thermal_power_per_hour
  calculate_coverage_for_membrane
  country_of_origin_name
  coverage_at_3_5_in
  coverage_at_3_75_in
  coverage_at_3_in
  coverage_at_4_in
  coverage_at_4_5_in
  coverage_at_5_in
  harmonization_code
  ohms
  package_contents_friendly
  package_content_html
  sensor
  sku
  upc
  watts_per_linear_feet
  watts_per_sqft_at_3_in_spacing
  watts_per_sqft_at_3_5_in_spacing
  watts_per_sqft_at_3_75_in_spacing
  watts_per_sqft_at_4_in_spacing
  watts_per_sqft_at_4_5_in_spacing
  watts_per_sqft_at_5_in_spacing
  width_by_length_text
  width_by_length_text_2
  calculate_size_2d
  calculate_size_2d_in
  calculate_size_2d_ft
  calculate_size_2d_metric_mm
  calculate_size_2d_metric_cm
  calculate_size_3d
  calculate_size_3d_in
  calculate_size_3d_ft
  calculate_size_3d_metric_mm
  calculate_size_3d_metric_cm
].freeze
NUMERIC_SPECS =

Numeric specs.

%w[
  maximum_current maximum_current_per_relay num_poles
  length width cold_lead_length sqft coverage watts ohms voltage amps
].freeze
CALCULATION_DEPENDENCIES =

Maps each dynamic (non-text) calculation method to the spec tokens its
value is DERIVED from, plus a human-readable formula. The rendered value of
a calculated spec is recomputed from these inputs every refresh, so editing
its text_blurb does not change what items display — and quietly setting a
value that contradicts the inputs breaks the relationship (e.g. Ohm's law,
W = V*A). Surfaced before any edit so the editor understands the dependency.
Methods not listed are still dynamic (pulled from item attributes) but carry
no cross-spec dependency worth calling out; the *_at_* families are matched
by pattern in #inferred_calculation_dependency.

{
  'calculate_watts'                  => { derives_from: %w[voltage amps ohms],            formula: "Ohm's law / power: W = V*A, or V^2/R, or A^2*R (any two of voltage, amps, ohms)" },
  'calculate_amps'                   => { derives_from: %w[voltage ohms watts],           formula: "Ohm's law: A = V/R, or W/V, or sqrt(W/R)" },
  'calculate_ohms'                   => { derives_from: %w[voltage amps watts],           formula: "Ohm's law: R = V/A, or V^2/W, or W/A^2" },
  'calculate_kilowatts'              => { derives_from: %w[watts voltage amps ohms],      formula: 'kW = W / 1000 (W falls back to calculate_watts)' },
  'btu_per_hour'                     => { derives_from: %w[watts thermal_output_efficiency], formula: 'BTU/hr = W * (efficiency/100) * BTU_PER_HOUR_PER_WATT' },
  'calculate_thermal_power_per_hour' => { derives_from: %w[watts thermal_output_efficiency], formula: 'kW/hr = W * (efficiency/100) / 1000' },
  'calculate_ohms_per_ft'            => { derives_from: %w[ohms heating_cable_length length], formula: 'ohms/ft = ohms / length_ft' },
  'calculate_watts_per_sqft'         => { derives_from: %w[watts coverage],               formula: 'W/sqft = watts / coverage (each may itself be calculated)' },
  'watts_per_linear_feet'            => { derives_from: %w[watts length],                 formula: 'W/linear-ft = watts / (length_in / 12)' },
  'calculate_sqft'                   => { derives_from: %w[width length coverage],        formula: 'sqft = (length * width) / 144 (heated-area variant adds padding)' },
  'calculate_geometric_sqft'         => { derives_from: %w[width length coverage],        formula: 'sqft = (length * width) / 144 (no heated-area padding)' },
  'calculate_heated_area_sqft_for_panels' => { derives_from: %w[watts],                  formula: 'sqft = ceil(watts / 7)' },
  'calculate_heated_area_sqm_for_panels'  => { derives_from: %w[watts],                  formula: 'sqm = heated_area_sqft * 0.092903' }
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Attributes included from Models::Translatable

#do_not_compact_translation_container

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Has and belongs to many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Translatable

#compact_translation_container, skip_compaction_for

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#groupingObject (readonly)



172
# File 'app/models/product_specification.rb', line 172

validates :method, :name, :grouping, presence: true

#methodObject (readonly)



172
# File 'app/models/product_specification.rb', line 172

validates :method, :name, :grouping, presence: true

#nameObject (readonly)



172
# File 'app/models/product_specification.rb', line 172

validates :method, :name, :grouping, presence: true

#should_auto_translateObject

Returns the value of attribute should_auto_translate.



152
153
154
# File 'app/models/product_specification.rb', line 152

def should_auto_translate
  @should_auto_translate
end

#skip_spec_refreshObject

Returns the value of attribute skip_spec_refresh.



152
153
154
# File 'app/models/product_specification.rb', line 152

def skip_spec_refresh
  @skip_spec_refresh
end

#text_blurbObject (readonly)



181
# File 'app/models/product_specification.rb', line 181

validates :text_blurb, numericality: { if: :spec_requires_numeric? }

#text_blurb_lookupObject

Returns the value of attribute text_blurb_lookup.



152
153
154
# File 'app/models/product_specification.rb', line 152

def text_blurb_lookup
  @text_blurb_lookup
end

#unitsObject (readonly)



174
# File 'app/models/product_specification.rb', line 174

validates :units, ruby_unit: true

Class Method Details

.by_product_category_idActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are by product category id. Active Record Scope

Returns:

See Also:



217
218
219
220
221
222
223
224
225
# File 'app/models/product_specification.rb', line 217

scope :by_product_category_id, ->(*pc_id) {
  ids = [pc_id].flatten.compact
  next none if ids.empty?

  paths = ProductCategory.where(id: ids).pluck(:ltree_path_ids).compact
  next none if paths.empty?

  joins(:product_category).where(ProductCategory[:ltree_path_ids].ltree_descendant(paths))
}

.by_product_category_id_ancestryActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are by product category id ancestry. Active Record Scope

Returns:

See Also:



226
227
228
229
# File 'app/models/product_specification.rb', line 226

scope :by_product_category_id_ancestry, ->(*pc_id) {
  ids = [pc_id].flatten.compact
  ids.empty? ? none : where(product_category_id: ProductCategory.self_and_ancestors_ids(ids))
}

.by_product_line_idActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are by product line id. Active Record Scope

Returns:

See Also:



204
205
206
207
208
209
210
211
212
# File 'app/models/product_specification.rb', line 204

scope :by_product_line_id, ->(*pl_id) {
  ids = [pl_id].flatten.compact
  next none if ids.empty?

  paths = ProductLine.where(id: ids).pluck(:ltree_path_ids).compact
  next none if paths.empty?

  joins(:product_line).where(ProductLine[:ltree_path_ids].ltree_descendant(paths))
}

.by_product_line_id_ancestryActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are by product line id ancestry. Active Record Scope

Returns:

See Also:



213
214
215
216
# File 'app/models/product_specification.rb', line 213

scope :by_product_line_id_ancestry, ->(*pl_id) {
  ids = [pl_id].flatten.compact
  ids.empty? ? none : where(product_line_id: ProductLine.self_and_ancestors_ids(ids))
}

.compact_positionsObject



407
408
409
410
411
# File 'app/models/product_specification.rb', line 407

def self.compact_positions
  readable_order.each_with_index do |pf, i|
    pf.update_column(:position, i)
  end
end

.convert_unit_for_locale(ruby_unit, target_locale) ⇒ Object



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
# File 'app/models/product_specification.rb', line 247

def self.convert_unit_for_locale(ruby_unit, target_locale)
  # If our target locale is the US (except for myanmar and libya or some odd country) we are always going to use SI (Système International) aka Metric
  # The only question becomes which unit is best suited
  lang_code, country_code = target_locale.to_s.split('-')

  # This assumes a static conversion from non-SI to SI
  conversion_map = {
    'yd' => 'm',
    'ft' => 'mm', # Later we check if we should do meters instead based on scale
    'in' => 'mm',
    'lbs' => 'kg',
    'oz' => 'g',
    'degF' => 'degC',
    'gal' => 'l',
    'quart' => 'l',
    'pint' => 'l',
    'sqft' => 'm2',
    'sqin' => 'm2'
  }
  # This only supports going one way, from imperial to SI
  target_unit = conversion_map[ruby_unit.units]

  return ruby_unit if target_unit.nil? ||
                      country_code == 'US' ||
                      (country_code.nil? && lang_code == 'en') # No need for conversion

  # Now we're going to do some smart metric conversion
  # If our target unit is > 1000 mm, we will convert to meter
  if target_unit == 'mm' && ruby_unit.convert_to(target_unit).scalar.to_i >= 1000
    target_unit = 'm' # Jump straight to meters
  end
  if target_unit == 'g' && ruby_unit.convert_to(target_unit).scalar.to_i >= 1000
    target_unit = 'kg' # Go to kgs when large numbers
  end
  target_unit = 'ml' if target_unit == 'l' && ruby_unit.convert_to(target_unit).scalar.to_i < 100
  ruby_unit.convert_to(target_unit)
end

.empty_non_template_specsActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are empty non template specs. Active Record Scope

Returns:

See Also:



203
# File 'app/models/product_specification.rb', line 203

scope :empty_non_template_specs, -> { where(text_blurb: nil).where(template: false) }

.express(raw_value, native_unit: nil, formatter: nil, locale: nil) ⇒ Object

Formats a value using a formatter and unit



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
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
# File 'app/models/product_specification.rb', line 547

def self.express(raw_value, native_unit: nil, formatter: nil, locale: nil)
  return raw_value unless native_unit

  # Special cases
  if native_unit == 'y' # Years
    s = "#{raw_value.to_i} "
    s += (raw_value.to_i > 1 ? 'years' : 'year')
    return s
  end

  native_value = "#{raw_value} #{native_unit}".squish
  native_value_pretty = "#{raw_value}#{UnitHelper.unit_symbol(native_unit)}".squish

  # There's little to gain from running the number through the ruby unit
  # parser just to spit it out again, since ruby unit is a US centric parsing
  # you would need to understand the format of the raw_value and convert it first
  return native_value_pretty if formatter.blank? || (locale.present? && locale.to_s.first(2) != 'en')

  native_value_unitized = begin
    # Ruby unit is a very US centric parsing fyi
    RubyUnits::Unit.new(native_value)
  rescue StandardError
    nil
  end
  return native_value unless native_value_unitized

  case formatter
  when 'FeetAndInches'
    inches = native_value_unitized.convert_to('in').scalar
    r = UnitConversions.inches_to_feetinches(inches, separator: ' ')
  when 'PoundsAndOunces'
    s = []
    ounces = native_value_unitized.convert_to('oz').scalar
    pounds = (ounces / 16).truncate
    ounces = (ounces % 16)
    if pounds.positive?
      pounds = ActionController::Base.helpers.number_with_precision pounds, precision: 0, strip_insignificant_zeros: true
      s << "#{pounds} lbs"
    end
    if ounces.positive?
      ounces = ActionController::Base.helpers.number_with_precision ounces, precision: 0, strip_insignificant_zeros: true
      s << "#{ounces} oz"
    end
    r = s.join(', ')
  when 'ClosestRational'
    fs = []
    fs << UnitConversions.closest_rational(raw_value.to_f).to_s
    fs << UnitHelper.unit_symbol(native_unit) if native_unit
    r = fs.compact.join
  else
    native_value_unitized.to_s
    # render with pretty symbol
    v = ActionController::Base.helpers.number_with_precision native_value_unitized.scalar.to_f, precision: 4, strip_insignificant_zeros: true
    r = "#{v}#{UnitHelper.unit_symbol(native_value_unitized.units)}".squish
  end
  r
end

.formatters_for_selectObject



542
543
544
# File 'app/models/product_specification.rb', line 542

def self.formatters_for_select
  %w[FeetAndInches PoundsAndOunces ClosestRational]
end

.generate_name_from_token_and_grouping(token, grouping = nil) ⇒ Object



814
815
816
817
818
819
820
821
822
# File 'app/models/product_specification.rb', line 814

def self.generate_name_from_token_and_grouping(token, grouping = nil)
  token = token.to_s
  if grouping&.start_with?('X-')
    # Namespace, e.g. if X-Amazon then amazon
    namespace = grouping.split('-').last.parameterize.underscore
    token = token.gsub("#{namespace}_", '')
  end
  token.humanize.capitalize
end

.generate_token_from_name_and_grouping(name, grouping = nil) ⇒ Object



803
804
805
806
807
808
809
810
811
812
# File 'app/models/product_specification.rb', line 803

def self.generate_token_from_name_and_grouping(name, grouping = nil)
  tokenized_name = name.parameterize.underscore
  if grouping&.start_with?('X-')

    # Namespace, e.g. if X-Amazon then amazon
    namespace = grouping.split('-').last.parameterize.underscore
    tokenized_name = "#{namespace}_#{tokenized_name}"
  end
  tokenized_name
end

.grouping_select_options(locale = nil) ⇒ Object



450
451
452
453
454
455
# File 'app/models/product_specification.rb', line 450

def self.grouping_select_options(locale = nil)
  locales = [locale, LocaleUtility.root_content_locale(locale)].uniq.compact.presence || Mobility.available_locales
  locales.flat_map do |locale|
    Mobility.with_locale(locale) { i18n.where.not(grouping: [nil, '']).distinct.pluck(:grouping) }
  end.compact.uniq.sort
end

.import_translation_keysObject



413
414
415
# File 'app/models/product_specification.rb', line 413

def self.import_translation_keys
  ProductSpecification.where.not(translations: {}).where(units: nil).find_each(&:import_translation_keys)
end

.locales_for_language(lang_code) ⇒ Object



239
240
241
# File 'app/models/product_specification.rb', line 239

def self.locales_for_language(lang_code)
  translated_locales.select { |locale| locale.to_s.first(2) == lang_code.to_s }
end

.method_select_optionsObject



359
360
361
# File 'app/models/product_specification.rb', line 359

def self.method_select_options
  where.not(method: [nil, '']).order(:method).distinct.pluck(:method)
end

.methods_for_selectObject



500
501
502
# File 'app/models/product_specification.rb', line 500

def self.methods_for_select
  SAFE_METHODS
end

.name_select_options(grouping: nil) ⇒ Object



436
437
438
439
440
# File 'app/models/product_specification.rb', line 436

def self.name_select_options(grouping: nil)
  search_scope = ProductSpecification.order(:name)
  search_scope = search_scope.where(grouping:) if grouping
  [['All', nil]] + search_scope.distinct.pluck(:name).map { |name| [name, name] }
end

.next_positionObject

TODO, we have acts as list, don't need this!



403
404
405
# File 'app/models/product_specification.rb', line 403

def self.next_position
  (maximum(:position) || 0) + 1
end

.options_scope(options = {}, _store_id = nil) ⇒ Object



386
387
388
389
390
391
392
# File 'app/models/product_specification.rb', line 386

def self.options_scope(options = {}, _store_id = nil)
  Rails.logger.debug("ProductLatchable.options_scope", class_name: self.class.to_s)
  res = all
  crits = (options || {}).reject { |_k, v| v.blank? or v == 'NULL' }
  res = res.where(crits) if crits.present?
  res
end

.positionedActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are positioned. Active Record Scope

Returns:

See Also:



230
# File 'app/models/product_specification.rb', line 230

scope :positioned, -> { order(:position) }

.product_category_select_optionsObject



398
399
400
# File 'app/models/product_specification.rb', line 398

def self.product_category_select_options
  [['All', nil]] + ProductCategory.joins(to_s.tableize.to_sym).order(:priority).pluck(:lineage_expanded, :id).uniq
end

.product_line_select_optionsObject



394
395
396
# File 'app/models/product_specification.rb', line 394

def self.product_line_select_options
  [['All', nil]] + ProductLine.joins(to_s.tableize.to_sym).order(:priority).pluck(:lineage_expanded, :id).uniq
end

.propagations_for_selectObject



351
352
353
# File 'app/models/product_specification.rb', line 351

def self.propagations_for_select
  ProductSpecification.propagations.keys.map { |p| [p.titleize, p] }
end

.readable_orderActiveRecord::Relation<ProductSpecification>

A relation of ProductSpecifications that are readable order. Active Record Scope

Returns:

See Also:



197
198
199
200
201
202
# File 'app/models/product_specification.rb', line 197

scope :readable_order, -> {
  left_outer_joins(:product_line)
    .left_outer_joins(:product_category)
    .order(:position, ProductLine[:priority], ProductCategory[:priority], :name, :method)
    .readonly(false)
}

.token_select_optionsObject



355
356
357
# File 'app/models/product_specification.rb', line 355

def self.token_select_options
  where.not(token: [nil, '']).order(:token).distinct.pluck(:token)
end

.translated_lang_codesObject



243
244
245
# File 'app/models/product_specification.rb', line 243

def self.translated_lang_codes
  translated_locales.map { |l| l.to_s.first(2) }.uniq.sort.map(&:to_sym) - [:en]
end

.translated_localesObject



234
235
236
237
# File 'app/models/product_specification.rb', line 234

def self.translated_locales
  # [] + Mobility.available_locales - [I18n.default_locale]
  [] + EDITABLE_TRANSLATIONS.map(&:to_sym) - [I18n.default_locale]
end

.units_select_options(locale = Mobility.locale) ⇒ Object



425
426
427
428
429
# File 'app/models/product_specification.rb', line 425

def self.units_select_options(locale = Mobility.locale)
  I18n.with_locale(locale) do
    UnitHelper.all_units
  end
end

.visibilities_for_selectObject



345
346
347
348
349
# File 'app/models/product_specification.rb', line 345

def self.visibilities_for_select
  [['Internal, for heatwave use only', 'internal'],
   ['Hidden, exposed to API and Web but recommended not displayed', 'hidden'],
   ['Open, exposed to API and Web and display recommended', 'open_visibility']]
end

Instance Method Details

#affected_itemsObject



667
668
669
670
671
672
673
674
675
676
677
# File 'app/models/product_specification.rb', line 667

def affected_items
  if items.present?
    items
  else
    item_query = Item.active
    item_query = item_query.by_product_category_id(product_category_id) if product_category_id.present?
    item_query = item_query.by_product_line_id(product_line_id) if product_line_id.present?
    item_query = item_query.where('items.sku ~ ?', sku_regexp) if sku_regexp.present?
    item_query
  end
end

#affected_items_idsObject

Alias for Affected_items#ids

Returns:

  • (Object)

    Affected_items#affected_items_ids

See Also:



697
# File 'app/models/product_specification.rb', line 697

delegate :ids, to: :affected_items, prefix: true

#async_refresh_itemsObject



703
704
705
706
707
# File 'app/models/product_specification.rb', line 703

def async_refresh_items
  return if skip_spec_refresh

  ProductSpecsWorker.perform_async(id)
end

#auto_translate(reset_previous_values: false, locales: nil, _attributes: nil) ⇒ Object

We're going to auto translate this spec for each locale, we translate
name, grouping, unit, and text content
TranslationKey is a proxy



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
# File 'app/models/product_specification.rb', line 288

def auto_translate(reset_previous_values: false, locales: nil, _attributes: nil)
  result = {}
  if text_blurb_en.blank?
    logger.warn "Auto translate called on Product Specification #{id} with a blank text_blurb_en, skipping"
    return result
  end

  locales = if locales.nil?
              self.class.translated_locales
            else # make sure they're supported languages
              locales.map(&:to_sym) & self.class.translated_locales
            end

  # Unit based specs will not be localized via text translations but via rubyunit and numeric localization
  ruby_unit = to_ruby_unit
  locales.each do |target_locale|
    translated_text = nil

    if ruby_unit
      converted_unit = self.class.convert_unit_for_locale(ruby_unit, target_locale)
      translated_units = converted_unit.units
      # Only if there was a text blurb, then do we need to store the translated text, otherwise it is just a unit conversion
      translated_text = (I18n.with_locale(target_locale) { ActionController::Base.helpers.number_with_precision(converted_unit.scalar.to_f, strip_insignificant_zeros: true, precision: 2) } if text_blurb.present?)
    else
      # We only need to translate non-numeric values, so that's fun how would you detect that?
      # I will try anything with more than 2 letters contiguous
      translated_text = TranslationKey.translate(text_blurb_en, target_locale, namespace: TRANSLATION_NAMESPACE, resource: self, resource_attribute: 'text_blurb') if text_blurb_en&.match?(/[a-zA-Z]{3}/)
      translated_text ||= text_blurb_en
      translated_units = nil
    end

    # If reset is desired, we have to purge all locale first including those country localized ones
    if reset_previous_values
      target_locales_for_purge = Mobility.available_locales.select { |l| l.to_s.starts_with?(target_locale.to_s) }
      target_locales_for_purge.each do |target_locale_for_purge|
        Mobility.with_locale(target_locale_for_purge) do
          self.name = nil
          self.grouping = nil
          self.units = nil
          self.text_blurb = nil
        end
      end
    end

    # Time to store it with our mobility friend
    Mobility.with_locale(target_locale) do
      result[target_locale] = {
        units: self.units ||= translated_units,
        text_blurb: self.text_blurb ||= translated_text
      }
    end
  end
  @should_auto_translate = false
  @skip_spec_refresh = true
  save && result
end

#cached_liquid_template(content) ⇒ Object

Returns the parsed Liquid template for the given content string, or nil if it contains no
Liquid variables. Results are memoized by content so multi-locale renders share a single parse
for identical content, and the ~99.5% of non-liquid specs skip parsing entirely after the
first call.



687
688
689
690
691
692
693
694
695
# File 'app/models/product_specification.rb', line 687

def cached_liquid_template(content)
  return nil if content.blank?

  @cached_liquid_templates ||= {}
  return @cached_liquid_templates[content] if @cached_liquid_templates.key?(content)

  lt = Liquid::ParseEnvironment.parse(content.to_s)
  @cached_liquid_templates[content] = lt.root.nodelist.any?(Liquid::Variable) ? lt : nil
end

#calculation_explanationHash{Symbol => Object, nil}

Explains how a dynamic spec's value is derived, for surfacing BEFORE an edit.
Returns nil for static (text) specs — those are safe to edit in place.
For calculated specs it names the input tokens and the formula so an editor
(human or agent) knows that editing text_blurb is a no-op for the rendered
value and that a contradictory value would break the relationship.

Returns:

  • (Hash{Symbol => Object, nil})

    { method:, derives_from:, formula:, note: }



482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'app/models/product_specification.rb', line 482

def calculation_explanation
  return if text_method?

  dep     = CALCULATION_DEPENDENCIES[method] || inferred_calculation_dependency(method)
  derives = dep ? dep[:derives_from] : []
  note =
    if derives.any?
      "This spec is COMPUTED (method=#{method}); its rendered value is recalculated from " \
        "#{derives.join(', ')}. Editing text_blurb will NOT change what items display and risks " \
        "an inconsistency (e.g. breaking Ohm's law, W = V*A). To change a value, correct the input " \
        'specs, or create an item-specific text override with clone_spec_to_item.'
    else
      "This spec is dynamic (method=#{method}); its value is pulled from the item, not text_blurb. " \
        'Editing text_blurb only sets a fallback used when the computation returns nil.'
    end
  { method: method, derives_from: derives, formula: dep && dep[:formula], note: note }
end

#dynamic_method?Boolean

Returns:

  • (Boolean)


471
472
473
# File 'app/models/product_specification.rb', line 471

def dynamic_method?
  !text_method?
end

#get_dynamic_spec_data(target, locale) ⇒ Object



605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# File 'app/models/product_specification.rb', line 605

def get_dynamic_spec_data(target, locale)
  # First try a method in the non default locale
  target = target.call if target.respond_to?(:call)
  if locale != I18n.default_locale
    method_localized = :"#{method}_#{LocaleUtility.parameterized_locale(locale)}"
    spec_data = target.try(method_localized)
  end
  if spec_data.blank?
    Mobility.with_locale(I18n.default_locale) do
      # Fallback to the native method, note we have to use a default locale
      # wrapper here so that calculations retrieving other specs also pull the correct one
      spec_data = target.try(method.to_sym)
    end
  end
  spec_data
end

#get_specification_data(target, options = {}) ⇒ Object

Target could be: item, catalog_item, product_line



623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
# File 'app/models/product_specification.rb', line 623

def get_specification_data(target, options = {})
  locale = (options[:locale] || Mobility.locale).to_sym
  spec_data = nil
  if dynamic_method?
    target_chain = case target.class.name
                   when 'Item'
                     target.get_self_and_kit_items(spec_only: true).to_a
                   when 'CatalogItem'
                     target.item.get_self_and_kit_items(spec_only: true).to_a
                   else
                     raise StandardError, "#{target} is not a valid Item or CatalogItem"
                   end
    target_chain << target.primary_product_line if target.primary_product_line_id.present?
    target_chain << product_line if product_line_id && product_line_id != target.primary_product_line_id
    target_chain.each do |t|
      spec_data = get_dynamic_spec_data(t, locale)
      break if spec_data
    end
  end
  spec_data ||= Mobility.with_locale(locale) { text_blurb.presence }
  spec_data ||= Mobility.with_locale(I18n.default_locale) { text_blurb.presence }
  # If our spec data has liquid tag, we will try to render them based on the items in the target chain
  if spec_data.present? && target.respond_to?(:token_specs_values_for_liquid) && (lt = cached_liquid_template(spec_data))
    spec_data = lt.render(target.token_specs_values_for_liquid(include_legacy: false))
  end

  spec_data = TypeCoercer.coerce(spec_data)

  result = OpenStruct.new(raw: spec_data, formatted: spec_data)
  return result if options[:skip_units] || units.blank? || spec_data.blank?

  # If a tokenizer is present, the spec data is first split according to it
  values = if tokenizer_character.present?
             spec_data.to_s.split(tokenizer_character)
           else
             [spec_data]
           end
  # Each value is passed through the formatter, native units is what v is expressed in, formatter is the
  # Formatter class to filter through, e.g 26" passed through the FormattedFt filter is 2' 4"
  localized_units = Mobility.with_locale(locale) { units } || units
  result.formatted = values.map { |v| self.class.express(v, native_unit: localized_units, formatter:, locale:) }.join(', ')
  result
end

#grouping_select_options(locale = nil) ⇒ Object



442
443
444
445
446
447
448
# File 'app/models/product_specification.rb', line 442

def grouping_select_options(locale = nil)
  r = self.class.grouping_select_options(locale)
  Mobility.with_locale(locale) do
    r << grouping if grouping.present?
  end
  r.compact.uniq.sort
end

#imageImage

Returns:

See Also:



167
# File 'app/models/product_specification.rb', line 167

belongs_to :image, optional: true

#import_translation_keys(force: false) ⇒ Object

This method will import existing translations or link to existing ones



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'app/models/product_specification.rb', line 364

def import_translation_keys(force: false)
  tkrs = []

  translations.each do |(locale_string, existing_translations)|
    existing_translations.each do |attr, translation|
      puts "--- ps: #{id} -- #{attr} -- #{translation}"
      # The presence of resources implies it was imported
      next if translation_key_resources.joins(translation_key: :translations).where(resource_attribute: attr).joins(translation_key: :translations).where(TranslationText[:locale].eq(locale_string)).exists?
      next if (text = TranslationKey.newline_normalize(send(:"#{attr}_en"))).blank? # e.g text_blurb, units
      next unless force || text&.match?(/[a-zA-Z]{3}/) # only 3 letters+

      # Does this translation key exists?
      tk = TranslationKey.where(key: text, namespace: TRANSLATION_NAMESPACE).first_or_create!
      # Do we need to import this translation
      tk.translations.where(locale: locale_string.first(2)).first_or_create!(text: translation)
      # And link our new resource
      tkrs << tk.translation_key_resources.where(resource: self, resource_attribute: attr).first_or_create!
    end
  end
  tkrs
end

#itemsActiveRecord::Relation<Item>

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



170
# File 'app/models/product_specification.rb', line 170

has_and_belongs_to_many :items, inverse_of: :direct_product_specifications, autosave: false

#name_select_options(filter_grouping: nil) ⇒ Object



431
432
433
434
# File 'app/models/product_specification.rb', line 431

def name_select_options(filter_grouping: nil)
  filter_grouping = grouping if grouping&.start_with?('X-')
  self.class.name_select_options(grouping: filter_grouping)
end

#product_categoryProductCategory



165
# File 'app/models/product_specification.rb', line 165

belongs_to :product_category, optional: true

#product_lineProductLine



164
# File 'app/models/product_specification.rb', line 164

belongs_to :product_line, optional: true

#refresh_itemsObject



709
710
711
712
713
714
# File 'app/models/product_specification.rb', line 709

def refresh_items
  return if skip_spec_refresh

  affected_items_ids.each { |iid| ItemAttributeWorker.perform_async(iid) }
  affected_items_ids
end

#render_priorityObject

spec value that are static will be first returned, this is so they can be computed and established
first in case they are used by future dynamic calculations. e.g ohms is static and should
be rendered before the calculate_watts method is called which uses it.



507
508
509
# File 'app/models/product_specification.rb', line 507

def render_priority
  text_method? ? 0 : 1
end

#rendering_priorityObject



461
462
463
464
465
466
467
468
469
# File 'app/models/product_specification.rb', line 461

def rendering_priority
  if text_method? && !text_blurb_is_liquid?
    0
  elsif dynamic_method?
    1
  else
    2
  end
end

#set_has_liquid_blurbObject



720
721
722
723
724
725
# File 'app/models/product_specification.rb', line 720

def set_has_liquid_blurb
  blurb_content = [text_blurb_en.presence, *translations.values.filter_map { |v| v['text_blurb'].presence }].compact
  self.has_liquid_blurb = blurb_content.any? do |content|
    (lt = Liquid::ParseEnvironment.parse(content)) && lt.root.nodelist.any?(Liquid::Variable)
  end
end

#summaryObject



699
700
701
# File 'app/models/product_specification.rb', line 699

def summary
  "#{product_line.slug_ltree} | #{product_category.url} | #{name} | #{method}"
end

#template_product_specificationProductSpecification



166
# File 'app/models/product_specification.rb', line 166

belongs_to :template_product_specification, class_name: 'ProductSpecification', optional: true

#text_blurb_is_liquid?Boolean

Returns:

  • (Boolean)


679
680
681
# File 'app/models/product_specification.rb', line 679

def text_blurb_is_liquid?
  has_liquid_blurb?
end

#text_method?Boolean

Returns:

  • (Boolean)


457
458
459
# File 'app/models/product_specification.rb', line 457

def text_method?
  method == 'text'
end

#text_output(locale = Mobility.locale) ⇒ Object



519
520
521
522
523
# File 'app/models/product_specification.rb', line 519

def text_output(locale = Mobility.locale)
  Mobility.with_locale(locale) do
    "#{text_blurb}#{unit_symbol}".squish.presence
  end
end

#to_ruby_unit(locale = :en) ⇒ Object



525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
# File 'app/models/product_specification.rb', line 525

def to_ruby_unit(locale = :en)
  native_value_unitized = nil

  Mobility.with_locale(locale) do
    return if units.blank?

    native_value = "#{text_blurb} #{units}".squish

    native_value_unitized = begin
      RubyUnits::Unit.new(native_value)
    rescue StandardError
      nil
    end
  end
  native_value_unitized
end

#to_sObject



511
512
513
# File 'app/models/product_specification.rb', line 511

def to_s
  "#{name} (#{id})"
end

#translation_key_resourcesActiveRecord::Relation<TranslationKeyResource>

Returns:

See Also:



168
# File 'app/models/product_specification.rb', line 168

has_many :translation_key_resources, as: :resource, dependent: :destroy

#translation_keysActiveRecord::Relation<TranslationKey>

Returns:

See Also:



169
# File 'app/models/product_specification.rb', line 169

has_many :translation_keys, through: :translation_key_resources

#trim_empty_translationsObject



716
717
718
# File 'app/models/product_specification.rb', line 716

def trim_empty_translations
  translations.delete_if { |_, value| !value.key?('text_blurb') || value['text_blurb'].blank? }
end

#unit_symbolObject



515
516
517
# File 'app/models/product_specification.rb', line 515

def unit_symbol
  UnitHelper.unit_symbol(units)
end

#units_select_options(locale = Mobility.locale) ⇒ Object



417
418
419
420
421
422
423
# File 'app/models/product_specification.rb', line 417

def units_select_options(locale = Mobility.locale)
  I18n.with_locale(locale) do
    select_units = self.class.units_select_options(locale)
    select_units << [units, units] unless units.blank? || select_units.any? { |v| v.last == units }
    select_units.compact.uniq.sort
  end
end