Class: ProductLine

Inherits:
ApplicationRecord show all
Extended by:
FriendlyId
Includes:
Memery, Models::Auditable, Models::EdgeCachePurgeable, Models::Embeddable, Models::LtreeLineage, Models::LtreePathBuilder, Models::Taggable, Models::Translatable, Models::Utilities::Html, PgSearch::Model
Defined in:
app/models/product_line.rb

Overview

== Schema Information

Table name: product_lines
Database name: primary

id :integer not null, primary key
app_code :string
available_to_public :boolean default(TRUE), not null
blog_tag :string(255)
cached_ancestor_ids :integer default([]), is an Array
children_count :integer
description_html :text
feature_1 :string
feature_2 :string
feature_3 :string
feature_4 :string
feature_5 :string
first_sale_date :date
inactive :boolean default(FALSE), not null
is_heating_system_type :boolean default(FALSE), not null
is_main_product_line :boolean default(FALSE), not null
last_sale_date :date
legacy_url :string(255)
level :integer
lineage_expanded :string(255)
ltree_path_ids :ltree
ltree_path_slugs :ltree
name :string(255)
path :string
photofeed_tags :string(255) default([]), not null, is an Array
popularity :integer default(0)
popularity_offset :integer default(0)
priority :integer default(100)
public_name :string
repairability_notice :text
restricted_for_sales :boolean default(FALSE), not null
reviewable :boolean default(FALSE), not null
seo_description :text
seo_keywords :string
seo_support_description :string(255)
seo_support_title :string(255)
seo_title :string
short_description :string
show_in_sales_portal :boolean default(FALSE), not null
show_in_support_portal :boolean default(TRUE), not null
slug :string
slug_ltree :ltree
support_category_priority :integer default(100)
support_category_title :string
support_intro :text
support_items_sort_method :enum default("popularity"), not null
support_portal_should_use_images :boolean default(FALSE), not null
support_priority :integer default(1000)
support_sub_lines_sort_method :enum default("default"), not null
support_title :string
tag_line :string
translations :jsonb
created_at :datetime
updated_at :datetime
default_product_category_id :integer
item_image_id :integer
lifestyle_image_id :integer
parent_id :integer
primary_image_id :integer
quote_builder_video_id :bigint
vignette_room_id :integer

Indexes

by_ismpl_atp_sisp (is_main_product_line,available_to_public,show_in_sales_portal)
by_ismpl_sisp (is_main_product_line,show_in_support_portal)
idx_id_is_heating_system_type (id,is_heating_system_type)
idx_id_reviewable (id,reviewable)
idx_id_show_in_support_portal (id,show_in_support_portal)
idx_id_sisp_atp (id,show_in_sales_portal,available_to_public)
idx_product_lines_ltree_path_ids (ltree_path_ids) USING gist
idx_product_lines_ltree_path_slugs (ltree_path_slugs) USING gist
idx_product_lines_slug_ltree (slug_ltree) USING gist
index_product_lines_on_app_code (app_code)
index_product_lines_on_cached_ancestor_ids (cached_ancestor_ids) USING gin
index_product_lines_on_legacy_url (legacy_url) UNIQUE
index_product_lines_on_name (name)
index_product_lines_on_parent_id (parent_id)
index_product_lines_on_parent_id_and_slug (parent_id,slug) UNIQUE
index_product_lines_on_quote_builder_video_id (quote_builder_video_id)
index_product_lines_on_reviewable (reviewable)
index_product_lines_on_root_slug (slug) UNIQUE WHERE (parent_id IS NULL)
index_product_lines_on_show_in_support_portal (show_in_support_portal)
index_product_lines_on_vignette_room_id (vignette_room_id)
product_lines_default_product_category_id_idx (default_product_category_id)

Foreign Keys

fk_rails_... (default_product_category_id => product_categories.id)
fk_rails_... (quote_builder_video_id => digital_assets.id)

Defined Under Namespace

Classes: CacheFlusher, ContentOptimizer, ImageRetriever, PublicationRetriever, SpecificationsMasher, VideoRetriever

Constant Summary collapse

TRANSLATABLE_ATTRIBUTES =

Translatable attributes.

%i[description_html feature_1 feature_2 feature_3 feature_4 feature_5
name public_name repairability_notice seo_description seo_keywords seo_support_description seo_support_title
seo_title short_description support_category_title support_intro support_title tag_line].freeze
TRANSLATION_NAMESPACE =

Translation namespace.

'ItemAttributes'
IQ_SUPPORTED_HEATING_SYSTEMS =

Iq supported heating systems.

['TempZone Flex Roll', 'TempZone Easy Mat', 'TempZone Shower Mat', 'TempZone Cable', 'TempZone Thin Cable',
'Environ Easy Mat', 'Slab Heat Cable', 'Slab Heat Mat', 'Snow Melt Cable', 'Snow Melt Mat', 'Environ Flex Roll'].freeze
CONTROL_PRODUCT_LINE_ID =

Control product line id.

31
RESERVED_SECTION_SLUGS =

Slugs reserved by the catalog URL resolver as section suffixes.
A ProductLine with any of these as its slug would be unreachable.

%w[support reviews faqs].freeze
RESERVED_FILTER_SLUGS =

All filter slugs that could collide with product line slugs under towel-warmer

(
  Models::TowelWarmerFilterSlugs::FINISH_SLUGS.keys +
  Models::TowelWarmerFilterSlugs::COMPOSITE_FINISH_SLUGS.keys +
  Models::TowelWarmerFilterSlugs::STYLE_SLUGS.keys +
  Models::TowelWarmerFilterSlugs::CONNECTION_SLUGS.keys +
  Models::TowelWarmerFilterSlugs::MOUNTING_SLUGS.keys +
  Models::TowelWarmerFilterSlugs::SIZE_SLUGS
).freeze
HEATING_SYSTEM_TYPE_NAMES =

Heating system type names.

{
  'countertop_heater' => 'Countertop Heater',
  'floor_heating.environ.easy_mat' => 'Environ Easy Mat',
  'floor_heating.environ.flex_roll' => 'Environ Flex Roll',
  'floor_heating.slab_heat.cable' => 'Slab Heat Cable',
  'floor_heating.slab_heat.mat' => 'Slab Heat Mat',
  'floor_heating.tempzone.cable' => 'TempZone Cable',
  'floor_heating.tempzone.ruler_cable' => 'TempZone Ruler Cable',
  'floor_heating.tempzone.custom_mat' => 'TempZone Custom Mat',
  'floor_heating.tempzone.easy_mat' => 'TempZone Easy Mat',
  'floor_heating.tempzone.flex_roll' => 'TempZone Flex Roll',
  'floor_heating.tempzone.shower_mat.bench' => 'Shower Mat Bench',
  'floor_heating.tempzone.shower_mat.floor' => 'Shower Mat Floor',
  'floor_heating.tempzone.shower_mat' => 'Shower Mat',
  'floor_heating.tempzone.thin_cable' => 'TempZone Thin Cable',
  'pipe_freeze_protection.constant_wattage_cable' => 'Pipe Freeze Protection Constant Wattage Cable',
  'pipe_freeze_protection.self_regulating_cable' => 'Pipe Freeze Protection Self Regulating Cable',
  'infrared_heating_panels.ember' => 'Infrared Heating Panel Ember',
  'roof_and_gutter_deicing.constant_wattage_pre_assembled_plug_in_kits' => 'Roof & Gutter Deicing Plug-in Kits',
  'roof_and_gutter_deicing.self_regulating_cut_to_length_cable' => 'Roof & Gutter Deicing Cable',
  'snow_melting.cable' => 'Snow Melt Cable',
  'snow_melting.mat.powermat' => 'Snow Melt PowerMat',
  'snow_melting.mat.omnimat' => 'Snow Melt OmniMat',
  'snow_melting.mat.ecomat' => 'Snow Melt EcoMat',
  'snow_melting.mat' => 'Snow Melt Mat'
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Embeddable

Models::Embeddable::MAX_CONTENT_LENGTH

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Attributes included from Models::Translatable

#do_not_compact_translation_container

Attributes included from Models::LtreePathBuilder

#skip_ltree_rebuild

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Methods included from Models::LtreeLineage

#parent

Has many collapse

Methods included from Models::Taggable

#tag_records, #taggings

Methods included from Models::Embeddable

#content_embeddings

Methods included from Models::LtreeLineage

#children

Has and belongs to many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::EdgeCachePurgeable

#edge_cache_urls, #enqueue_edge_cache_purge

Methods included from Models::Taggable

#add_tag, all_tags, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, #tags=, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags

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 included from Models::LtreePathBuilder

#build_ltree_path_ids_value, #build_ltree_path_slugs_value, #build_slug_ltree_value, builds_ltree_path, #compute_ltree_ancestor_ids, #derive_cached_ancestor_ids, #ltree_descendant_ids, rebuild_all_ltree_paths!, rebuild_subtree_ltree_paths!

Methods included from Models::Embeddable

embeddable_content_types, #embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search, with_shape_lock

Methods included from Models::LtreeLineage

acts_as_ltree_lineage, #ancestors_ids, ancestors_ids, define_ltree_scopes, define_ordered_ltree_methods, #descendant_of_path?, #descendants_ids, descendants_ids, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #ltree_ancestor_of?, #ltree_descendant_of?, #ltree_slug, #ltree_slug_path, #path_includes?, #root, #root?, #root_id, root_ids, self_ancestors_and_descendants_ids, #self_ancestors_and_descendants_ids, #self_and_ancestors_ids, self_and_ancestors_ids, #self_and_children, #self_and_descendants_ids, self_and_descendants_ids, #self_and_siblings, #siblings

Methods inherited from ApplicationRecord

ransackable_associations, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#name_enObject (readonly)

Validations
English name is required.

Validations:



179
# File 'app/models/product_line.rb', line 179

validates :name_en, presence: true

#slugObject (readonly)

Slug is required.

Validations:



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

validates :slug, presence: true

Class Method Details

.available_to_publicActiveRecord::Relation<ProductLine>

A relation of ProductLines that are available to public. Active Record Scope

Returns:

See Also:



233
# File 'app/models/product_line.rb', line 233

scope :available_to_public,     -> { where(available_to_public: true) }

.by_name_with_descendantsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by name with descendants. Active Record Scope

Returns:

See Also:



236
237
238
239
# File 'app/models/product_line.rb', line 236

scope :by_name_with_descendants, ->(pl_n) {
  path = where(name: pl_n).pick(:ltree_path_ids)
  path ? where(ProductLine[:ltree_path_ids].ltree_descendant(path)) : none
}

.by_slug_ltreeActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by slug ltree. Active Record Scope

Returns:

See Also:



250
# File 'app/models/product_line.rb', line 250

scope :by_slug_ltree,              ->(ltree_path) { where(slug_ltree: ltree_path) }

.by_slug_ltree_ancestorsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by slug ltree ancestors. Active Record Scope

Returns:

See Also:



252
# File 'app/models/product_line.rb', line 252

scope :by_slug_ltree_ancestors,    ->(ltree_path) { where(arel_table[:slug_ltree].ltree_ancestor(ltree_path)) }

.by_slug_ltree_descendantsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by slug ltree descendants. Active Record Scope

Returns:

See Also:



251
# File 'app/models/product_line.rb', line 251

scope :by_slug_ltree_descendants,  ->(ltree_path) { where(arel_table[:slug_ltree].ltree_descendant(ltree_path)) }

.by_url_with_ancestorsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by url with ancestors. Active Record Scope

Returns:

See Also:



244
245
246
247
# File 'app/models/product_line.rb', line 244

scope :by_url_with_ancestors, ->(slug_or_url) {
  slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(slug_or_url)
  slug.present? ? by_slug_ltree_ancestors(slug) : none
}

.by_url_with_descendantsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are by url with descendants. Active Record Scope

Returns:

See Also:



240
241
242
243
# File 'app/models/product_line.rb', line 240

scope :by_url_with_descendants, ->(slug_or_url) {
  slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(slug_or_url)
  slug.present? ? by_slug_ltree_descendants(slug) : none
}

.canonical_paths_for(product_lines) ⇒ Hash{Integer => String, nil}

Batch-precompute canonical_path for a collection to avoid N+1.
Returns a Hash of { product_line_id => canonical_path_string }.
Skips non-public intermediary product lines, consistent with #canonical_path.

Parameters:

  • product_lines (Array<ProductLine>)

    lines to compute paths for

Returns:

  • (Hash{Integer => String, nil})


410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'app/models/product_line.rb', line 410

def self.canonical_paths_for(product_lines)
  return {} if product_lines.blank?

  all_ids = product_lines.flat_map { |pl| Array(pl.cached_ancestor_ids) + [pl.id] }.uniq
  rows = ProductLine.where(id: all_ids).pluck(:id, :slug, :level, :available_to_public, :is_main_product_line)

  public_ids = Set.new
  slugs_map = {}
  levels_map = {}
  rows.each do |row_id, row_slug, row_level, row_public, row_main|
    slugs_map[row_id] = row_slug
    levels_map[row_id] = row_level
    public_ids << row_id if row_public || row_main
  end

  product_lines.each_with_object({}) do |pl, result|
    ids = (Array(pl.cached_ancestor_ids) + [pl.id]).select { |pid| public_ids.include?(pid) }
    path = ids.sort_by { |pid| levels_map[pid] || 0 }.filter_map { |pid| slugs_map[pid] }.join('/')
    result[pl.id] = path.presence
  end
end

.default_repairability_noticeString?

Default Quebec repairability notice text (memoized).

Returns:

  • (String, nil)


857
858
859
# File 'app/models/product_line.rb', line 857

def self.default_repairability_notice
  @default_repairability_notice ||= I18n.t('products.notices.default_quebec_repairability.text', default: nil).presence
end

.effective_seo_description(product_line, char_limit: SEO_META_DESCRIPTION_MAX_LENGTH) ⇒ String?

Builds a normalized, truncated SEO description for a product line.

Parameters:

  • product_line (ProductLine)

    line to describe

  • char_limit (Integer, nil) (defaults to: SEO_META_DESCRIPTION_MAX_LENGTH)

    maximum length

Returns:

  • (String, nil)


1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
# File 'app/models/product_line.rb', line 1029

def self.effective_seo_description(product_line, char_limit: SEO_META_DESCRIPTION_MAX_LENGTH)
  d = product_line.seo_description.presence
  d ||= product_line.short_description.presence
  d ||= product_line.description.presence
  return unless d

  # We replace by spaces first in case line feed is used as a sentence separator.
  d.gsub!(/[\n\r\t]/, ' ')
  d.squeeze!(' ')
  d.squish!
  d.truncate(char_limit, separator: /[\s|]/, omission: '') if char_limit
end

.for_indexActiveRecord::Relation<ProductLine>

A relation of ProductLines that are for index. Active Record Scope

Returns:

See Also:



269
# File 'app/models/product_line.rb', line 269

scope :for_index,               -> { available_to_public.where(is_main_product_line: false, show_in_sales_portal: true).order(:priority, :slug_ltree) }

.for_sales_portalActiveRecord::Relation<ProductLine>

A relation of ProductLines that are for sales portal. Active Record Scope

Returns:

See Also:



271
# File 'app/models/product_line.rb', line 271

scope :for_sales_portal,         -> { where(available_to_public: true, show_in_sales_portal: true) }

.for_support_portalActiveRecord::Relation<ProductLine>

A relation of ProductLines that are for support portal. Active Record Scope

Returns:

See Also:



258
# File 'app/models/product_line.rb', line 258

scope :for_support_portal,      -> { where(show_in_support_portal: true) }

.heating_system_typesActiveRecord::Relation<ProductLine>

A relation of ProductLines that are heating system types. Active Record Scope

Returns:

See Also:



267
# File 'app/models/product_line.rb', line 267

scope :heating_system_types,    -> { where(is_heating_system_type: true) }

.main_product_linesActiveRecord::Relation<ProductLine>

A relation of ProductLines that are main product lines. Active Record Scope

Returns:

See Also:



268
# File 'app/models/product_line.rb', line 268

scope :main_product_lines,      -> { where(is_main_product_line: true) }

.not_self_or_ancestorActiveRecord::Relation<ProductLine>

A relation of ProductLines that are not self or ancestor. Active Record Scope

Returns:

See Also:



254
255
256
257
# File 'app/models/product_line.rb', line 254

scope :not_self_or_ancestor,    ->(pl) {
  plids = [pl.id.to_i] + pl.ancestors.map(&:id)
  where(ProductLine[:id].not_in(plids))
}

.past_modelsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are past models. Active Record Scope

Returns:

See Also:



274
# File 'app/models/product_line.rb', line 274

scope :past_models,              -> { with_any_tags('past-model') }

.photo_tags_for_selectArray<String>

All photofeed tags in use, sorted case-insensitively.

Returns:

  • (Array<String>)


510
511
512
# File 'app/models/product_line.rb', line 510

def self.photo_tags_for_select
  ProductLine.where('cardinality(photofeed_tags) > 0 and photofeed_tags IS NOT NULL').pluck(:photofeed_tags).flatten.uniq.compact_blank.compact.sort_by(&:downcase)
end

.prioritizedActiveRecord::Relation<ProductLine>

A relation of ProductLines that are prioritized. Active Record Scope

Returns:

See Also:



234
# File 'app/models/product_line.rb', line 234

scope :prioritized,             -> { order(:priority) }

.ransackable_attributes(_auth_object = nil) ⇒ Array<String>

Exclude ltree-typed columns from generic _cont / _eq predicates — they produce
operator does not exist: ltree ~~* unknown when Ransack applies ILIKE (AppSignal #4449).

Parameters:

  • _auth_object (Object, nil) (defaults to: nil)

    ransack authorization object (unused)

Returns:

  • (Array<String>)


293
294
295
# File 'app/models/product_line.rb', line 293

def self.ransackable_attributes(_auth_object = nil)
  super - %w[slug_ltree ltree_path_ids ltree_path_slugs]
end

.ransackable_scopes(_auth_object = nil) ⇒ Array<Symbol>

Ransack scopes exposed to search forms.

Parameters:

  • _auth_object (Object, nil) (defaults to: nil)

    ransack authorization object (unused)

Returns:

  • (Array<Symbol>)


285
286
287
# File 'app/models/product_line.rb', line 285

def self.ransackable_scopes(_auth_object = nil)
  %i[with_ancestor_id text_search_with_highlights slug_ltree_contains]
end

.reviewableActiveRecord::Relation<ProductLine>

A relation of ProductLines that are reviewable. Active Record Scope

Returns:

See Also:



260
261
262
263
264
265
266
# File 'app/models/product_line.rb', line 260

scope :reviewable,              -> {
  # Use ltree to get all descendants of reviewable product lines in one query
  reviewable_paths = where(reviewable: true).pluck(:ltree_path_ids).compact
  return none if reviewable_paths.empty?

  where(ProductLine[:ltree_path_ids].ltree_descendant(reviewable_paths))
}

.select_options(options = {}) {|ActiveRecord::Relation| ... } ⇒ Array<Array(String, Integer)>

Builds a sorted [label, id] pair list for select boxes, with
optional scope filters.

Parameters:

  • options (Hash) (defaults to: {})

Options Hash (options):

  • name (String)

    restrict to lines matching this name
    (with descendants)

  • slug_ltree (String)

    restrict to this slug_ltree
    subtree (with descendants)

  • reviewable (Boolean)

    restrict to reviewable lines

  • is_heating_system_type (Boolean)

    restrict to heating
    system type lines

  • for_sales_portal (Boolean)

    restrict to sales portal
    lines

Yields:

  • (ActiveRecord::Relation)

    optional further scoping of the
    relation before mapping

Returns:

  • (Array<Array(String, Integer)>)


320
321
322
323
324
325
326
327
328
329
330
# File 'app/models/product_line.rb', line 320

def self.select_options(options = {})
  res = ProductLine.all
  res = res.by_name_with_descendants(options[:name]) if options[:name]
  res = res.by_slug_ltree_descendants(options[:slug_ltree]) if options[:slug_ltree]
  res = res.reviewable if options[:reviewable]
  res = res.heating_system_types if options[:is_heating_system_type]
  # NOTE: non_binders option removed - no binder product lines exist
  res = res.for_sales_portal if options[:for_sales_portal]
  res = yield(res) if block_given?
  res.map { |pl| [pl.lineage_expanded.presence || pl.name_en, pl.id] }.sort_by { |pl| pl[0].to_s }
end

.slug_ltree_containsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are slug ltree contains. Active Record Scope

Returns:

See Also:



297
298
299
300
301
302
# File 'app/models/product_line.rb', line 297

scope :slug_ltree_contains, ->(term) {
  sanitized = term.to_s.strip.tr(' ', '_').gsub(/[^a-zA-Z0-9_.]/, '')
  return none if sanitized.blank?

  where(arel_table[:slug_ltree].ltree_matches("*.#{sanitized}*.*"))
}

.support_items_sort_method_for_selectArray<Array(String, Symbol)>

Support item sort methods as [titleized label, key] select options.

Returns:

  • (Array<Array(String, Symbol)>)


579
580
581
# File 'app/models/product_line.rb', line 579

def self.support_items_sort_method_for_select
  support_items_sort_methods.keys.map { |k| [k.to_s.titleize, k] }
end

.support_portal_sortedActiveRecord::Relation<ProductLine>

A relation of ProductLines that are support portal sorted. Active Record Scope

Returns:

See Also:



259
# File 'app/models/product_line.rb', line 259

scope :support_portal_sorted,   -> { for_support_portal.reorder(:support_priority, ProductLine[:popularity].desc) }

.support_sub_lines_sort_method_for_selectArray<Array(String, Symbol)>

Support sub-line sort methods as [titleized label, key] select options.

Returns:

  • (Array<Array(String, Symbol)>)


573
574
575
# File 'app/models/product_line.rb', line 573

def self.support_sub_lines_sort_method_for_select
  support_sub_lines_sort_methods.keys.map { |k| [k.to_s.titleize, k] }
end

.syncs_items_on_ltree_change?Boolean

Enable item ltree sync when product line paths change

Returns:

  • (Boolean)


149
150
151
# File 'app/models/product_line.rb', line 149

def self.syncs_items_on_ltree_change?
  true
end

.text_search_with_highlightsActiveRecord::Relation<ProductLine>

A relation of ProductLines that are text search with highlights. Active Record Scope

Returns:

See Also:



280
# File 'app/models/product_line.rb', line 280

scope :text_search_with_highlights, ->(search_text) { text_search(search_text).with_pg_search_highlight }

.unrestrictedActiveRecord::Relation<ProductLine>

A relation of ProductLines that are unrestricted. Active Record Scope

Returns:

See Also:



272
# File 'app/models/product_line.rb', line 272

scope :unrestricted,             -> { where(restricted_for_sales: false) }

.with_ancestor_idActiveRecord::Relation<ProductLine>

A relation of ProductLines that are with ancestor id. Active Record Scope

Returns:

See Also:



270
# File 'app/models/product_line.rb', line 270

scope :with_ancestor_id, ->(pl_ids) { where(id: ProductLine.self_and_descendants_ids([pl_ids].flatten.uniq.compact)) }

.with_featuresActiveRecord::Relation<ProductLine>

A relation of ProductLines that are with features. Active Record Scope

Returns:

See Also:



273
# File 'app/models/product_line.rb', line 273

scope :with_features,            -> { where.not(feature_1: nil).where.not(feature_2: nil).where.not(feature_3: nil).where.not(feature_4: nil).where.not(feature_5: nil) }

Instance Method Details

#all_my_itemsActiveRecord::Relation<Item>

Items whose primary product line is this line or a descendant.

Returns:

  • (ActiveRecord::Relation<Item>)


516
517
518
# File 'app/models/product_line.rb', line 516

def all_my_items
  Item.where(primary_product_line_id: self_and_descendants_ids)
end

#all_my_items_all_product_linesActiveRecord::Relation<Item>

Items linked to this line through any product-line join.

Returns:

  • (ActiveRecord::Relation<Item>)


522
523
524
# File 'app/models/product_line.rb', line 522

def all_my_items_all_product_lines
  Item.by_product_line_id(id)
end

#all_my_support_itemsActiveRecord::Relation<Item>

Goods items visible in the support portal, ordered by SKU.

Returns:

  • (ActiveRecord::Relation<Item>)


528
529
530
531
532
533
534
# File 'app/models/product_line.rb', line 528

def all_my_support_items
  items = all_my_items
  items = items.by_product_category_id(product_category_ids) if product_category_ids.present?
  items.goods_visible_for_support
       .includes(:taggings, primary_product_line: :taggings)
       .order(:sku)
end

#any_translatable_attribute_changed?Boolean

We detect if any of the translatable attributes have changed, this can be used
to trigger auto translate or cleanup functionalities

Returns:

  • (Boolean)


1078
1079
1080
# File 'app/models/product_line.rb', line 1078

def any_translatable_attribute_changed?
  TRANSLATABLE_ATTRIBUTES.any? { |attr| send(:"#{attr}_changed?") || send(:"saved_change_to_#{attr}?") }
end

#articlesActiveRecord::Relation<Article>

Related articles.

Returns:

  • (ActiveRecord::Relation<Article>)

See Also:



216
# File 'app/models/product_line.rb', line 216

has_and_belongs_to_many :articles

#available_content_localesObject

Find locales used by all the items of this product line, this means this product line and its descendants
This information is stored in the catalogs.



1099
1100
1101
# File 'app/models/product_line.rb', line 1099

def available_content_locales
  Catalog.joins(catalog_items: :item).merge(all_my_items).merge(CatalogItem.not_discontinued).pluck(:locales).flatten.compact.uniq.sort
end

#canonical_pathObject

Hierarchical slug path built from FriendlyId slug chain, including only
public-facing product lines (available_to_public or is_main_product_line).
Internal organizational nodes (e.g. "twin-conductor", "120-v") are skipped
to produce shorter, cleaner SEO URLs.

e.g. "floor-heating/tempzone/flex-roll" (skips twin-conductor/120-v)

Uses cached_ancestor_ids (array stored on the record) to batch-load
ancestor data in a single query instead of walking the tree.



389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'app/models/product_line.rb', line 389

def canonical_path
  @canonical_path ||= begin
    ancestor_ids = Array(cached_ancestor_ids)
    path = if ancestor_ids.present?
             rows = ProductLine.where(id: ancestor_ids + [id])
                               .pluck(:id, :slug, :level, :available_to_public, :is_main_product_line)
             rows.select! { |_, _, _, pub, main| pub || main }
             rows.sort_by! { |_, _, lvl, _, _| lvl }
             rows.filter_map { |_, s, _, _, _| s }.join('/')
           else
             slug.to_s
           end
    path.presence
  end
end

#canonical_url(locale: I18n.locale) ⇒ String

Canonical URL with locale prefix.
e.g. "/en-US/floor-heating/tempzone/flex-roll"

Parameters:

  • locale (Symbol, String) (defaults to: I18n.locale)

    locale to prefix

Returns:

  • (String)


436
437
438
# File 'app/models/product_line.rb', line 436

def canonical_url(locale: I18n.locale)
  "/#{locale}/#{canonical_path}"
end

#catalogsActiveRecord::Relation<Catalog>

Catalogs containing items from this product line.

Returns:

  • (ActiveRecord::Relation<Catalog>)

See Also:



213
# File 'app/models/product_line.rb', line 213

has_many :catalogs, through: :primary_items

#compatibility_scope_ids(seed_sales_portal_ancestor: false) ⇒ Array<Integer>

Every product line whose items count as compatible add-ons for this one:
self, every ancestor tier's complimentary lines, and all of their
descendants — plus the first sales-portal ancestor when the caller seeds it
(compatible controls do).

Complimentary lines are hung on whichever tier the catalog team picked:
snow_melting.cable owns all 15, snow_melting.mat owns the same 15 while its
powermat / omnimat / ecomat children own none. Readers that consulted a
single tier (root_system_product_line, which stops at powermat because it is
is_heating_system_type) found nothing — every PowerMat page shipped an empty
Related Products section — so walk the whole chain.

One ltree containment query: the ancestor chain comes free out of
ltree_path_ids, and complimentary lookup, the sales-portal ancestor, and
descendant expansion are all subqueries rather than seven Ruby round trips.

Parameters:

  • seed_sales_portal_ancestor (Boolean) (defaults to: false)

    also seed the first sales-portal ancestor

Returns:

  • (Array<Integer>)

    compatible product line ids



684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
# File 'app/models/product_line.rb', line 684

def compatibility_scope_ids(seed_sales_portal_ancestor: false)
  chain_ids = self_and_ancestors_ids # parsed from ltree_path_ids — no query
  seeds = ProductLine.sanitize_sql_array([<<~SQL.squish, id, chain_ids])
    id = ?
      OR id IN (SELECT complimentary_product_line_id FROM complimentary_product_lines
                WHERE product_line_id = ANY(ARRAY[?]::integer[]))
  SQL
  if seed_sales_portal_ancestor
    deepest = ProductLine.for_sales_portal.where(id: chain_ids)
                         .order(level: :desc, priority: :asc).limit(1).select(:id)
    seeds += " OR id = (#{deepest.to_sql})"
  end

  ProductLine.where("ltree_path_ids <@ ANY (SELECT ltree_path_ids FROM product_lines WHERE #{seeds})").ids
end

#complimentary_product_linesActiveRecord::Relation<ProductLine>

Product lines commonly sold alongside this one.

Returns:

See Also:



218
219
# File 'app/models/product_line.rb', line 218

has_and_belongs_to_many :complimentary_product_lines, class_name: 'ProductLine', join_table: :complimentary_product_lines,
association_foreign_key: :complimentary_product_line_id

#complimentary_product_lines_for_selectArray<Array(String, Integer)>

Candidate complimentary lines (excluding self/ancestors) as select options.

Returns:

  • (Array<Array(String, Integer)>)


565
566
567
568
569
# File 'app/models/product_line.rb', line 565

def complimentary_product_lines_for_select
  ProductLine.not_self_or_ancestor(self)
             .order(:lineage_expanded)
             .pluck(:lineage_expanded, :id)
end

#content_for_embedding(_content_type = :primary) ⇒ String?

Plain-text content used for embeddings.

Parameters:

  • _content_type (Symbol) (defaults to: :primary)

    embedding content variant (unused)

Returns:

  • (String, nil)


1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
# File 'app/models/product_line.rb', line 1106

def content_for_embedding(_content_type = :primary)
  parts = []
  parts << lineage_expanded.presence
  parts << "Public name: #{effective_public_name}" if effective_public_name.present? && effective_public_name != name
  parts << "Tag line: #{effective_tag_line}" if effective_tag_line.present?
  parts << "Summary: #{effective_short_description}" if effective_short_description.present?

  desc_text = Nokogiri::HTML(description_html.to_s).text.gsub(/\s+/, ' ').strip
  parts << "Description: #{desc_text}" if desc_text.present?

  features = [feature_1, feature_2, feature_3, feature_4, feature_5].compact_blank
  parts << "Features: #{features.join(' | ')}" if features.any?

  parts << "SEO: #{seo_description}" if seo_description.present?

  tag_names = tags.compact_blank
  parts << "Tags: #{tag_names.join(', ')}" if tag_names.any?

  parts.compact.join("\n").presence
end

#content_locales_to_renderObject

Which locales should we render for this product line? This is the intersection of the
configured locales for mobility and the default locale + the locales used by the catalogs.



1093
1094
1095
# File 'app/models/product_line.rb', line 1093

def content_locales_to_render
  (Mobility.available_locales & ([I18n.default_locale] + available_content_locales)).uniq
end

#decorated_product_line_nameString

Name decorated with glyphs for main/reviewable/heating/public/portal flags.

Returns:

  • (String)


497
498
499
500
501
502
503
504
505
506
# File 'app/models/product_line.rb', line 497

def decorated_product_line_name
  dn = name_en.dup
  dn << '' if is_main_product_line
  dn << '' if reviewable
  dn << '' if is_heating_system_type
  dn << '' if available_to_public
  dn << ' $' if show_in_sales_portal
  dn << '' if show_in_support_portal
  dn
end

#descriptionString

Plain-text description extracted from description_html.

Returns:

  • (String)


1044
1045
1046
# File 'app/models/product_line.rb', line 1044

def description
  html_to_text(description_html)
end

#digital_asset_product_linesActiveRecord::Relation<DigitalAssetProductLine>

Join records linking digital assets to this product line.

Returns:

See Also:



207
# File 'app/models/product_line.rb', line 207

has_many :digital_asset_product_lines, inverse_of: :product_line

#digital_assetsActiveRecord::Relation<DigitalAsset>

Digital assets attached to this product line.

Returns:

See Also:



209
# File 'app/models/product_line.rb', line 209

has_many :digital_assets, through: :digital_asset_product_lines

#display_nameString?

Display name (public name when set, else internal name).

Returns:

  • (String, nil)


491
492
493
# File 'app/models/product_line.rb', line 491

def display_name
  public_name.presence || name.presence
end

#easy_mat?Boolean

Whether this line's path includes an easy_mat segment.

Returns:

  • (Boolean)


906
907
908
# File 'app/models/product_line.rb', line 906

def easy_mat?
  path_includes?('easy_mat')
end

#effective_header_titleString?

Header title from public name, short description, or lineage.

Returns:

  • (String, nil)


1010
1011
1012
1013
1014
1015
# File 'app/models/product_line.rb', line 1010

def effective_header_title
  public_name.presence ||
    short_description.presence ||
    lineage_expanded&.gsub(' > ', ' - ') ||
    name
end

#effective_public_nameObject

Quote Builder effective methods - climb ltree for inheritance
These use self_and_ancestors (ltree-powered) to find first non-null value



639
640
641
# File 'app/models/product_line.rb', line 639

def effective_public_name
  public_name.presence || self_and_ancestors.where.not(public_name: [nil, '']).order(level: :desc).pick(:public_name) || name
end

#effective_quote_builder_videoVideo?

Quote-builder video, inheriting from the nearest ancestor when unset.

Returns:



657
658
659
# File 'app/models/product_line.rb', line 657

def effective_quote_builder_video
  quote_builder_video || self_and_ancestors.where.not(quote_builder_video_id: nil).order(level: :desc).first&.quote_builder_video
end

#effective_quote_builder_video_uidString?

Cloudflare UID of the effective quote-builder video.

Returns:

  • (String, nil)


663
664
665
# File 'app/models/product_line.rb', line 663

def effective_quote_builder_video_uid
  effective_quote_builder_video&.cloudflare_uid
end

#effective_seo_description(char_limit: SEO_META_DESCRIPTION_MAX_LENGTH) ⇒ String?

SEO description for this line, falling back to the reviewable ancestor.

Parameters:

  • char_limit (Integer, nil) (defaults to: SEO_META_DESCRIPTION_MAX_LENGTH)

    maximum length

Returns:

  • (String, nil)


1020
1021
1022
1023
# File 'app/models/product_line.rb', line 1020

def effective_seo_description(char_limit: SEO_META_DESCRIPTION_MAX_LENGTH)
  self.class.effective_seo_description(self, char_limit:) ||
    self.class.effective_seo_description(get_first_reviewable || root, char_limit:)
end

#effective_seo_keywordsArray<String>

SEO keywords for this line, inheriting from the first reviewable
ancestor or falling back to the generated name parts.

Returns:

  • (Array<String>)


364
365
366
367
368
369
370
371
372
# File 'app/models/product_line.rb', line 364

def effective_seo_keywords
  return seo_keywords if seo_keywords.present?

  plr = get_first_reviewable
  return plr.seo_keywords if plr && plr.seo_keywords.present?

  keywords = generate_full_name_array + [public_name]
  keywords.filter_map(&:presence)
end

#effective_seo_titleString

SEO title, falling back to public name or lineage, truncated to limit.

Returns:

  • (String)


1002
1003
1004
1005
1006
# File 'app/models/product_line.rb', line 1002

def effective_seo_title
  pn = seo_title.presence || public_name.presence
  pn ||= lineage_expanded.gsub(' > ', ' ')
  pn.truncate(SEO_TITLE_MAX_LENGTH, separator: /\s/, omission: '')
end

#effective_short_descriptionString?

Short description, inheriting from the nearest ancestor when unset.

Returns:

  • (String, nil)


651
652
653
# File 'app/models/product_line.rb', line 651

def effective_short_description
  short_description.presence || self_and_ancestors.where.not(short_description: [nil, '']).order(level: :desc).pick(:short_description)
end

#effective_support_category_priorityInteger

Support category sort priority (defaults to 100).

Returns:

  • (Integer)


442
443
444
# File 'app/models/product_line.rb', line 442

def effective_support_category_priority
  support_category_priority || 100
end

#effective_support_category_titleString

Support category title, falling back to the display name.

Returns:

  • (String)


448
449
450
# File 'app/models/product_line.rb', line 448

def effective_support_category_title
  support_category_title.presence || display_name
end

#effective_support_titleString

Support title, falling back to the display name.

Returns:

  • (String)


996
997
998
# File 'app/models/product_line.rb', line 996

def effective_support_title
  support_title.presence || display_name
end

#effective_tag_lineString?

Tag line, inheriting from the nearest ancestor when unset.

Returns:

  • (String, nil)


645
646
647
# File 'app/models/product_line.rb', line 645

def effective_tag_line
  tag_line.presence || self_and_ancestors.where.not(tag_line: [nil, '']).order(level: :desc).pick(:tag_line)
end

#embedding_content_changed?Boolean

Whether any embedding-relevant field changed on the last save.

Returns:

  • (Boolean)


1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
# File 'app/models/product_line.rb', line 1129

def embedding_content_changed?
  saved_change_to_name? ||
    saved_change_to_public_name? ||
    saved_change_to_description_html? ||
    saved_change_to_short_description? ||
    saved_change_to_tag_line? ||
    saved_change_to_feature_1? ||
    saved_change_to_feature_2? ||
    saved_change_to_feature_3? ||
    saved_change_to_feature_4? ||
    saved_change_to_feature_5? ||
    saved_change_to_seo_description?
end

#environ_easy_mat?Boolean

Whether this line sits under the Environ Easy Mat branch.

Returns:

  • (Boolean)


948
949
950
# File 'app/models/product_line.rb', line 948

def environ_easy_mat?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_ENVIRON_EASY_MAT)
end

#environ_flex_roll?Boolean

Whether this line sits under the Environ Flex Roll branch.

Returns:

  • (Boolean)


942
943
944
# File 'app/models/product_line.rb', line 942

def environ_flex_roll?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_ENVIRON_FLEX_ROLL)
end

#featuresArray<String>

Feature bullets, inheriting from the nearest ancestor with features.

Returns:

  • (Array<String>)


480
481
482
483
484
485
486
487
# File 'app/models/product_line.rb', line 480

def features
  if has_features?
    [feature_1, feature_2, feature_3, feature_4, feature_5].filter_map(&:presence)
  else # Return the first ancestor with features or nothing
    fa = ancestors.find(&:has_features?)
    fa&.features || []
  end
end

#flex_roll?Boolean

Heating system type predicates using ltree paths
Whether this line's path includes a flex_roll segment.

Returns:

  • (Boolean)


900
901
902
# File 'app/models/product_line.rb', line 900

def flex_roll?
  path_includes?('flex_roll')
end

#full_complimentary_product_line_idsArray<Integer>

Ids of this line, its descendants, and all complimentary lines with
their descendants (cached).

Returns:

  • (Array<Integer>)


703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
# File 'app/models/product_line.rb', line 703

def full_complimentary_product_line_ids
  Rails.cache.fetch([cache_key, :complimentary_product_lines], expires_in: 1.month) do
    # Start with the product line selected and all its descendants
    rt_pl_ids = []
    # Now to evaluate the complimentary lines, we climb the tree up and down
    ancestors.each do |pl|
      # Then each complimentary line is added back in + all the children
      rt_pl_ids << pl.id
      pl.complimentary_product_lines.each do |plc|
        rt_pl_ids += plc.self_and_descendants_ids
      end
    end
    rt_pl_ids += self_and_descendants_ids
    rt_pl_ids.uniq
  end
end

#get_first_heating_system_typeProductLine?

Nearest ancestor (or self) flagged as a heating system type.

Returns:



811
812
813
814
815
# File 'app/models/product_line.rb', line 811

def get_first_heating_system_type
  return @get_first_heating_system_type if instance_variable_defined?(:@get_first_heating_system_type)

  @get_first_heating_system_type = self_and_ancestors.where(is_heating_system_type: true).order(level: :desc, priority: :asc).first
end

#get_first_publicProductLine?

Nearest public (available_to_public) ancestor or self.

Returns:



875
876
877
878
879
# File 'app/models/product_line.rb', line 875

def get_first_public
  return self if available_to_public?

  self_and_ancestors.where(available_to_public: true).order(level: :desc, priority: :asc).first
end

#get_first_public_for_salesProductLine?

Nearest public, non-main, sales-portal ancestor or self.

Returns:



883
884
885
886
887
# File 'app/models/product_line.rb', line 883

def get_first_public_for_sales
  return self if available_to_public? && !is_main_product_line? && show_in_sales_portal?

  self_and_ancestors.where(available_to_public: true, is_main_product_line: false, show_in_sales_portal: true).order(level: :desc, priority: :asc).first
end

#get_first_public_for_supportProductLine?

Nearest public, non-main, support-portal ancestor or self.

Returns:



891
892
893
894
895
# File 'app/models/product_line.rb', line 891

def get_first_public_for_support
  return self if available_to_public? && !is_main_product_line? && show_in_support_portal?

  self_and_ancestors.where(available_to_public: true, is_main_product_line: false, show_in_support_portal: true).order(level: :desc, priority: :asc).first
end

#get_first_reviewableProductLine?

Nearest reviewable ancestor (or self).

Returns:



803
804
805
806
807
# File 'app/models/product_line.rb', line 803

def get_first_reviewable
  return @get_first_reviewable if instance_variable_defined?(:@get_first_reviewable)

  @get_first_reviewable = self_and_ancestors.where(reviewable: true).order(level: :desc, priority: :asc).first
end

#get_first_show_in_sales_portal_ancestor(exclude_main_line: false, exclude_self: false) ⇒ ProductLine?

Deepest ancestor (or self) shown in the sales portal.

Parameters:

  • exclude_main_line (Boolean) (defaults to: false)

    skip main product lines

  • exclude_self (Boolean) (defaults to: false)

    search ancestors only

Returns:



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
# File 'app/models/product_line.rb', line 744

def get_first_show_in_sales_portal_ancestor(exclude_main_line: false, exclude_self: false)
  # Check for pre-populated value first (set by preload_product_type_roots optimization)
  # Only use pre-populated value if it matches the exclude_main_line criteria
  return @get_first_show_in_sales_portal_ancestor if instance_variable_defined?(:@get_first_show_in_sales_portal_ancestor) && !exclude_main_line && !exclude_self

  # Exclude the main lines from this check, e.g towel-warmer wouldn't be valid
  scope = (exclude_self ? ancestors : self_and_ancestors).for_sales_portal
  scope = scope.where.not(is_main_product_line: true) if exclude_main_line
  result = scope.order(level: :desc, priority: :asc).first

  # Cache the result if using default parameters
  @get_first_show_in_sales_portal_ancestor = result if !exclude_main_line && !exclude_self && result

  result
end

#get_first_show_in_sales_portal_ancestor_idInteger?

Id of the first sales-portal ancestor.

Returns:

  • (Integer, nil)


762
763
764
# File 'app/models/product_line.rb', line 762

def get_first_show_in_sales_portal_ancestor_id
  get_first_show_in_sales_portal_ancestor&.id
end

#get_first_show_in_sales_portal_ancestor_pathObject

Returns ltree path for the first ancestor shown in sales portal



786
787
788
# File 'app/models/product_line.rb', line 786

def get_first_show_in_sales_portal_ancestor_path
  get_first_show_in_sales_portal_ancestor&.ltree_path_slugs
end

#get_first_show_in_sales_portal_ancestor_slug_ltreeString?

Slug path of the first sales-portal ancestor.

Returns:

  • (String, nil)


768
769
770
# File 'app/models/product_line.rb', line 768

def get_first_show_in_sales_portal_ancestor_slug_ltree
  get_first_show_in_sales_portal_ancestor&.slug_ltree
end

#get_first_show_in_support_portal_ancestorProductLine?

Deepest ancestor (or self) shown in the support portal.

Returns:



774
775
776
# File 'app/models/product_line.rb', line 774

def get_first_show_in_support_portal_ancestor
  self_and_ancestors.where(show_in_support_portal: true).order(level: :desc, priority: :asc).first
end

#get_first_show_in_support_portal_ancestor_pathObject

Returns ltree path for the first ancestor shown in support portal



791
792
793
# File 'app/models/product_line.rb', line 791

def get_first_show_in_support_portal_ancestor_path
  get_first_show_in_support_portal_ancestor&.ltree_path_slugs
end

#get_first_show_in_support_portal_ancestor_slug_ltreeString?

Slug path of the first support-portal ancestor.

Returns:

  • (String, nil)


781
782
783
# File 'app/models/product_line.rb', line 781

def get_first_show_in_support_portal_ancestor_slug_ltree
  get_first_show_in_support_portal_ancestor&.slug_ltree
end

#get_main_product_lineObject

Returns the root/main product line (top of hierarchy)
Uses ltree path to find the root ancestor



797
798
799
# File 'app/models/product_line.rb', line 797

def get_main_product_line
  root
end

#has_features?Boolean

Whether any feature field is set.

Returns:

  • (Boolean)


468
469
470
# File 'app/models/product_line.rb', line 468

def has_features?
  [feature_1, feature_2, feature_3, feature_4, feature_5].any?(&:present?)
end

#heating_element_product_line_optionsActiveRecord::Relation<HeatingElementProductLineOption>

Heating-element configuration options for this product line.

Returns:

See Also:



205
# File 'app/models/product_line.rb', line 205

has_many :heating_element_product_line_options, inverse_of: :product_line

#heating_system_type_nameString

Human name of the heating system type this line belongs to.

Returns:

  • (String)


847
848
849
850
851
852
853
# File 'app/models/product_line.rb', line 847

def heating_system_type_name
  ltree = slug_ltree.to_s
  HEATING_SYSTEM_TYPE_NAMES.each do |prefix, name|
    return name if ltree.start_with?(prefix)
  end
  display_name || lineage_expanded&.titleize || slug.to_s.tr('-', ' ').titleize
end

#installed_room_configurationsActiveRecord::Relation<RoomConfiguration>

Inverse of RoomConfiguration#installed_product_lines — legacy Installation
Record data now on Warranty v2; deprecated pending the join-table drop.

Returns:

See Also:



222
223
# File 'app/models/product_line.rb', line 222

has_and_belongs_to_many :installed_room_configurations, class_name: 'RoomConfiguration', join_table: :installed_product_lines,
association_foreign_key: :room_configuration_id, deprecated: true

#is_cable_system?Boolean

Whether this line's path includes a cable segment.

Returns:

  • (Boolean)


990
991
992
# File 'app/models/product_line.rb', line 990

def is_cable_system?
  path_includes?('cable')
end

#is_cerazorb?Boolean

Whether this line sits under the Cerazorb underlayment branch.

Returns:

  • (Boolean)


598
599
600
# File 'app/models/product_line.rb', line 598

def is_cerazorb?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_UNDERLAYMENT_CERAZORB)
end

#is_cork?Boolean

Predicate methods using ltree paths for hierarchy checks
Whether this line sits under the Cork underlayment branch.

Returns:

  • (Boolean)


592
593
594
# File 'app/models/product_line.rb', line 592

def is_cork?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_UNDERLAYMENT_CORK)
end

#is_led_mirror?Boolean

Whether this line sits under the LED Mirror branch.

Returns:

  • (Boolean)


863
864
865
# File 'app/models/product_line.rb', line 863

def is_led_mirror?
  descendant_of_path?(LtreePaths::PL_LED_MIRROR)
end

#is_made_to_order_led_mirror?Boolean

Whether this line sits under the made-to-order LED mirror branch.

Returns:

  • (Boolean)


616
617
618
# File 'app/models/product_line.rb', line 616

def is_made_to_order_led_mirror?
  descendant_of_path?(LtreePaths::PL_LED_MIRROR_MADE_TO_ORDER)
end

#is_prodeso?Boolean

Whether this line sits under the Prodeso underlayment branch.

Returns:

  • (Boolean)


869
870
871
# File 'app/models/product_line.rb', line 869

def is_prodeso?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_UNDERLAYMENT_PRODESO)
end

#is_reviewable?Boolean

Whether this line or any ancestor is reviewable.

Returns:

  • (Boolean)


622
623
624
625
626
627
628
629
# File 'app/models/product_line.rb', line 622

def is_reviewable?
  return true if reviewable?

  # Check if any ancestor is reviewable using ltree
  self.class.where(reviewable: true)
      .where(ProductLine[:ltree_path_ids].ltree_ancestor(ltree_path_ids))
      .exists?
end

#is_thermalsheet?Boolean

Whether this line sits under the ThermalSheet underlayment branch.

Returns:

  • (Boolean)


604
605
606
# File 'app/models/product_line.rb', line 604

def is_thermalsheet?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_UNDERLAYMENT_THERMALSHEET)
end

#is_underlayment?Boolean

Whether this line sits under the Underlayment branch.

Returns:

  • (Boolean)


610
611
612
# File 'app/models/product_line.rb', line 610

def is_underlayment?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_UNDERLAYMENT)
end

#item_product_linesActiveRecord::Relation<ItemProductLine>

Join records linking items to this product line.

Returns:

See Also:



193
# File 'app/models/product_line.rb', line 193

has_many :item_product_lines, inverse_of: :product_line, dependent: :destroy

#itemsActiveRecord::Relation<Item>

Items in this product line.

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



195
# File 'app/models/product_line.rb', line 195

has_many :items, through: :item_product_lines

#main_line_slug_ltreeString?

Slug path of the root line when it is a main product line.

Returns:

  • (String, nil)


550
551
552
# File 'app/models/product_line.rb', line 550

def main_line_slug_ltree
  root.slug_ltree if root&.is_main_product_line
end

#move_to_new_parent(new_product_line_id) ⇒ Hash

Convenience method to move a product line to a new parent
while you can just update parent_id too, this will also refresh/touch items and their specs
associated with this product line.

Parameters:

  • new_product_line_id (Integer)

    id of the new parent line

Returns:

  • (Hash)

    new slug path and count of queued item updates



457
458
459
460
461
462
463
464
# File 'app/models/product_line.rb', line 457

def move_to_new_parent(new_product_line_id)
  item_ids = all_my_items.ids
  self.parent_id = new_product_line_id
  save # This in turns updates lineage on all children
  # Async update all items
  item_ids.each { |iid| ItemAttributeWorker.perform_async(iid) }
  { new_slug_ltree: slug_ltree, item_queued_for_update: item_ids.size }
end

#normalize_friendly_id(value) ⇒ String

Normalizes a value into a slug candidate.

Parameters:

  • value (String)

    source value

Returns:

  • (String)


1169
1170
1171
# File 'app/models/product_line.rb', line 1169

def normalize_friendly_id(value)
  value.to_s.parameterize
end

#parent_options_for_selectArray<Array(String, Integer)>

Candidate parent lines (excluding self) as [label, id] select options.

Returns:

  • (Array<Array(String, Integer)>)


556
557
558
559
560
561
# File 'app/models/product_line.rb', line 556

def parent_options_for_select
  pl = ProductLine.all
  pl = pl.where(ProductLine[:id].not_eq(id)) unless new_record?
  pl = pl.order(:lineage_expanded)
  pl.pluck(:lineage_expanded, :id)
end

#past_model?Boolean

Whether this line is tagged as a past model.

Returns:

  • (Boolean)


474
475
476
# File 'app/models/product_line.rb', line 474

def past_model?
  tags.include?('past-model')
end

#primary_imageImage

Associations
Primary image for this product line.

Returns:

See Also:



189
# File 'app/models/product_line.rb', line 189

belongs_to :primary_image, class_name: 'Image', optional: true

#primary_image_inheritedImage?

Primary image, falling back to the nearest ancestor's.

Returns:



633
634
635
# File 'app/models/product_line.rb', line 633

def primary_image_inherited
  primary_image || parent&.primary_image_inherited
end

#primary_itemsActiveRecord::Relation<Item>

Items that designate this product line as their primary line.

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



199
200
201
202
203
# File 'app/models/product_line.rb', line 199

has_many :primary_items,
inverse_of: :primary_product_line,
class_name: 'Item',
foreign_key: :primary_product_line_id,
dependent: :nullify

#product_categoriesActiveRecord::Relation<ProductCategory>

Product categories this line belongs to.

Returns:

See Also:



225
# File 'app/models/product_line.rb', line 225

has_and_belongs_to_many :product_categories

#product_specificationsActiveRecord::Relation<ProductSpecification>

Specification templates for this product line.

Returns:

See Also:



191
# File 'app/models/product_line.rb', line 191

has_many :product_specifications, inverse_of: :product_line, dependent: :destroy

#product_type(separator: nil, limit: nil) ⇒ String

Feed product-type string for this line.

Parameters:

  • separator (String, nil) (defaults to: nil)

    segment separator

  • limit (Integer, nil) (defaults to: nil)

    maximum segments

Returns:

  • (String)


1064
1065
1066
1067
1068
# File 'app/models/product_line.rb', line 1064

def product_type(separator: nil, limit: nil)
  Feed::ProductTypeGenerator.process(product_line: self,
                                     separator:,
                                     limit:)
end

#prune_unused_translationsObject

We prune unused translations, this is done by removing all the locales that are not used
We know a locale is not used by querying the catalogs that are using it.



1084
1085
1086
1087
1088
1089
# File 'app/models/product_line.rb', line 1084

def prune_unused_translations
  valid_locales = content_locales_to_render
  translations.each do |(locale, _translation_attribute)|
    translations.delete(locale) unless locale.to_sym.in?(valid_locales)
  end
end

#purge_cachevoid

This method returns an undefined value.

Enqueues a cache flush for this line after commit.



1056
1057
1058
# File 'app/models/product_line.rb', line 1056

def purge_cache
  CacheWorker.perform_async('cache_class' => 'ProductLine::CacheFlusher', 'params' => id)
end

#purge_edge_cacheArray<Hash>

Purges edge cache for this line and all descendants.

Returns:

  • (Array<Hash>)

    purged url/job-id pairs



343
344
345
346
347
348
349
350
351
# File 'app/models/product_line.rb', line 343

def purge_edge_cache
  res = []
  urls = self_and_descendants.flat_map(&:edge_cache_urls)
  if urls.present?
    jid = EdgeCacheWorker.perform_async('urls' => urls)
    res += urls.map { |url| { url:, jid: } }
  end
  res
end

#quote_builder_videoVideo

Video used by the quote builder for this line.

Returns:

See Also:



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

belongs_to :quote_builder_video, class_name: 'Video', optional: true

#roof_deicing?Boolean

Whether this line sits under the Roof & Gutter Deicing branch.

Returns:

  • (Boolean)


984
985
986
# File 'app/models/product_line.rb', line 984

def roof_deicing?
  descendant_of_path?(LtreePaths::PL_ROOF_GUTTER_DEICING)
end

#root_system_product_lineObject

We often check for the significant umbrella product line from which we will
derive our search for compatible accesories, controls, etc.



722
723
724
725
726
# File 'app/models/product_line.rb', line 722

def root_system_product_line
  @root_system_product_line ||= get_first_heating_system_type ||
                                get_first_show_in_sales_portal_ancestor(exclude_main_line: false) ||
                                get_first_reviewable
end

#root_system_product_line_idInteger?

Id of the root system product line.

Returns:

  • (Integer, nil)


730
731
732
# File 'app/models/product_line.rb', line 730

def root_system_product_line_id
  root_system_product_line&.id
end

#root_system_product_line_slug_ltreeString?

Slug path of the root system product line.

Returns:

  • (String, nil)


736
737
738
# File 'app/models/product_line.rb', line 736

def root_system_product_line_slug_ltree
  root_system_product_line&.slug_ltree
end

#self_and_descendants_product_category_idsArray<Integer>

Category ids of this line and all its descendants.

Returns:

  • (Array<Integer>)


538
539
540
# File 'app/models/product_line.rb', line 538

def self_and_descendants_product_category_ids
  ProductCategory.self_and_descendants_ids(product_category_ids)
end

#self_and_inherited_specificationsActiveRecord::Relation<ProductSpecification>

Specifications for this line and inherited from ancestors.

Returns:



544
545
546
# File 'app/models/product_line.rb', line 544

def self_and_inherited_specifications
  ProductSpecification.where(product_line_id: self_and_ancestors_ids)
end

#should_generate_new_friendly_id?Boolean

Whether FriendlyId should regenerate the slug on save.

Returns:

  • (Boolean)


1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
# File 'app/models/product_line.rb', line 1150

def should_generate_new_friendly_id?
  # NOTE: `name_en` is a Mobility accessor; for the default locale the
  # column_fallback plugin writes through to the real `name` column, so
  # dirty tracking only fires on :name, never on :name_en.
  return true if slug.blank?
  return false unless will_save_change_to_attribute?(:name)

  # An explicitly assigned slug wins in the save where it is assigned. A
  # slug that differs from the previous name's generated form was manually
  # overridden at some point (e.g. "powermat") and is preserved across
  # later name-only renames.
  return false if will_save_change_to_attribute?(:slug)

  slug == normalize_friendly_id(attribute_was(:name))
end

#showcasesActiveRecord::Relation<Showcase>

Showcases featuring this product line.

Returns:

See Also:



197
# File 'app/models/product_line.rb', line 197

has_many :showcases

#site_mapsActiveRecord::Relation<SiteMap>

Sitemap entries for this product line.

Returns:

  • (ActiveRecord::Relation<SiteMap>)

See Also:



211
# File 'app/models/product_line.rb', line 211

has_many :site_maps, as: :resource, dependent: :destroy

#slab_heat?Boolean

Whether this line sits under the Slab Heat branch.

Returns:

  • (Boolean)


972
973
974
# File 'app/models/product_line.rb', line 972

def slab_heat?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_SLAB_HEAT)
end

#slug_not_reserved_filter_slugvoid

This method returns an undefined value.

Validates the slug does not collide with reserved section/filter slugs.



1175
1176
1177
1178
1179
1180
1181
1182
1183
# File 'app/models/product_line.rb', line 1175

def slug_not_reserved_filter_slug
  return if slug.blank?

  if slug.in?(RESERVED_SECTION_SLUGS)
    errors.add(:slug, "'#{slug}' is reserved as a catalog URL section keyword")
  elsif root&.slug == 'towel-warmer' && slug.in?(RESERVED_FILTER_SLUGS)
    errors.add(:slug, "conflicts with towel warmer filter slug '#{slug}'")
  end
end

#slug_sourceObject

-- FriendlyId ----------------------------------------------------------



1144
1145
1146
# File 'app/models/product_line.rb', line 1144

def slug_source
  name_en
end

#snow_melt?Boolean

Whether this line sits under the Snow Melting branch.

Returns:

  • (Boolean)


954
955
956
# File 'app/models/product_line.rb', line 954

def snow_melt?
  descendant_of_path?(LtreePaths::PL_SNOW_MELTING)
end

#snow_melt_cable?Boolean

Whether this line sits under the Snow Melting Cable branch.

Returns:

  • (Boolean)


966
967
968
# File 'app/models/product_line.rb', line 966

def snow_melt_cable?
  descendant_of_path?(LtreePaths::PL_SNOW_MELTING_CABLE)
end

#snow_melt_mat?Boolean

Whether this line sits under the Snow Melting Mat branch.

Returns:

  • (Boolean)


960
961
962
# File 'app/models/product_line.rb', line 960

def snow_melt_mat?
  descendant_of_path?(LtreePaths::PL_SNOW_MELTING_MAT)
end

#snow_melt_or_slab_heat?Boolean

Whether this line is a snow-melt or slab-heat product.

Returns:

  • (Boolean)


978
979
980
# File 'app/models/product_line.rb', line 978

def snow_melt_or_slab_heat?
  snow_melt? || slab_heat?
end

#supported_in_iq?Boolean

Whether this line's heating system type is supported in IQ.

Returns:

  • (Boolean)


1050
1051
1052
# File 'app/models/product_line.rb', line 1050

def supported_in_iq?
  IQ_SUPPORTED_HEATING_SYSTEMS.include?(heating_system_type_name)
end

#tempzone?Boolean

Whether this line sits under the TempZone branch.

Returns:

  • (Boolean)


912
913
914
# File 'app/models/product_line.rb', line 912

def tempzone?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE)
end

#tempzone_cable?Boolean

Whether this line sits under the TempZone Cable branch.

Returns:

  • (Boolean)


930
931
932
# File 'app/models/product_line.rb', line 930

def tempzone_cable?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE_CABLE)
end

#tempzone_easy_mat?Boolean

Whether this line sits under the TempZone Easy Mat branch.

Returns:

  • (Boolean)


924
925
926
# File 'app/models/product_line.rb', line 924

def tempzone_easy_mat?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE_EASY_MAT)
end

#tempzone_flex_roll?Boolean

Whether this line sits under the TempZone Flex Roll branch.

Returns:

  • (Boolean)


918
919
920
# File 'app/models/product_line.rb', line 918

def tempzone_flex_roll?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE_FLEX_ROLL)
end

#tempzone_thin_cable?Boolean

Whether this line sits under the TempZone Thin Cable branch.

Returns:

  • (Boolean)


936
937
938
# File 'app/models/product_line.rb', line 936

def tempzone_thin_cable?
  descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE_THIN_CABLE)
end

#to_sString

String representation of the product line.

Returns:

  • (String)

    the expanded lineage name



585
586
587
# File 'app/models/product_line.rb', line 585

def to_s
  lineage_expanded || generate_full_name
end

#url_pathsArray<String>

Slug path segments for this line's ltree path.

Returns:

  • (Array<String>)


376
377
378
# File 'app/models/product_line.rb', line 376

def url_paths
  slug_ltree.to_s.split('.')
end

#vignette_roomRoomConfiguration

Room configuration shown as this line's vignette.



228
# File 'app/models/product_line.rb', line 228

belongs_to :vignette_room, class_name: 'RoomConfiguration', optional: true

#warm_cacheSymbol, Array

Legacy lineage cache removed - ltree queries are fast enough

Returns:

  • (Symbol, Array)

    :disabled when edge cache is off

See Also:

  • ProductLine.doc/tasks/202512032250_LTREE_HIERARCHY_MIGRATIONdoc/tasks/202512032250_LTREE_HIERARCHY_MIGRATION.md


335
336
337
338
339
# File 'app/models/product_line.rb', line 335

def warm_cache
  return :disabled unless Cache::EdgeCacheUtility.edge_cache_enabled?

  site_maps.each(&:warm_cache)
end