Class: Quote

Overview

== Schema Information

Table name: quotes
Database name: primary

id :integer not null, primary key
ancestors_count :integer
bill_shipping_to_customer :boolean
children_count :integer
complete_datetime :datetime
currency :string(255)
descendants_count :integer
disable_auto_coupon :boolean default(FALSE), not null
discount :decimal(10, 2)
expiration_date :date
expiration_notice_sent :boolean default(FALSE), not null
first_view_date :datetime
hidden :boolean default(FALSE), not null
hold_for_transmission :boolean default(FALSE), not null
jde_b_ab :integer
jde_s_ab :integer
line_offset :decimal(10, 2)
line_total :decimal(10, 2)
line_total_discounted :decimal(10, 2)
ltl_freight :boolean
ltl_freight_guaranteed :boolean
max_discount_override :decimal(4, 2)
min_profit_markup :integer default(0)
override_coupon_date :date
override_coupon_date_without_limits :boolean default(FALSE), not null
override_line_lock :boolean default(FALSE), not null
position :integer default(100)
pricing_program_description :string(255)
pricing_program_discount :integer
priority :string(255)
quote_type :string(2)
recalculate_discounts :boolean default(FALSE), not null
recalculate_shipping :boolean default(TRUE), not null
reference_number :string(255) not null
requires_upgrade :boolean
retrieving_shipping_costs :boolean
saturday_delivery :boolean default(FALSE), not null
shipping_cost :decimal(10, 2)
shipping_coupon :decimal(10, 2)
shipping_issue_alerted :boolean
shipping_method :string(255)
ships_economy :boolean default(FALSE), not null
signature_confirmation :boolean default(FALSE), not null
single_origin :boolean default(FALSE), not null
state :string(255)
suffix :string(255)
tax_exempt :boolean default(FALSE), not null
tax_offset :decimal(10, 2)
tax_total :decimal(10, 2)
taxable_total :decimal(12, 2)
total :decimal(10, 2)
uploads_count :integer default(0)
created_at :datetime
updated_at :datetime
billing_address_id :integer
buying_group_id :integer
creator_id :integer
legacy_proto_iq_room_id :integer
opportunity_id :integer
parent_id :integer
resource_tax_rate_id :integer
rma_id :integer
shipping_address_id :integer
updater_id :integer
visit_id :bigint

Indexes

idx_quotes_reference_number_trgm_gist (reference_number) USING gist
idx_quotes_rma_id (rma_id)
index_quotes_on_legacy_proto_iq_room_id (legacy_proto_iq_room_id)
index_quotes_on_parent_id (parent_id)
index_quotes_on_reference_number (reference_number) UNIQUE
index_quotes_on_suffix (suffix) USING gin
index_quotes_on_visit_id (visit_id) WHERE (visit_id IS NOT NULL) USING hash
quotes_aasm_state_index (state)
quotes_opportunity_id_index (opportunity_id)
quotes_shipping_address_id_idx (shipping_address_id)

Foreign Keys

fk_rails_... (shipping_address_id => addresses.id)
fk_rails_... (visit_id => visits.id) ON DELETE => nullify

Defined Under Namespace

Classes: AssignLargeOppActivity, CombinedPdfGenerator, ConvertToOrder, Copier, LetterPdfGenerator, Mover, QuoteCompletedHandler, Reviser

Constant Summary collapse

SALES_QUOTE =

Sales quote.

'SQ'
MARKETING_QUOTE =

Marketing quote.

'MQ'
TECH_QUOTE =

Tech quote.

'TQ'
INSTANT_QUOTE =

Instant quote.

'IQ'
QUOTE_TYPES =

Recognised quote types.

%w[SQ MQ TQ IQ].freeze
REFERENCE_NUMBER_PATTERN =

Regex pattern matching reference number.

Regexp.new("^(#{QUOTE_TYPES.join('|')})(\\d+)(-R\\d+)?$", Regexp::IGNORECASE)
CORK_MIN_QTY =

Cork min qty.

1
MIN_INSTALL_SQ_FT =

Minimum install sq ft.

88
CUSTOMER_CANCELABLE_STATES =

Recognised customer cancelable states.

%i[pending].freeze
CANCELABLE_STATES =

Recognised cancelable states.

CUSTOMER_CANCELABLE_STATES + %i[pending_project_details awaiting_completed_installation_plans awaiting_transmission]
CLOSED_STATES =

Recognised closed states.

%i[complete cancelled].freeze
CAN_REQUEST_PRE_PACK_STATES =

Recognised can request pre pack states.

%i[pending pre_pack awaiting_transmission profit_review].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::Profitable

#min_profit_markup

Attributes included from Models::Itemizable

#force_total_reset, #total_reset

Delegated Instance Attributes collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Methods included from Models::ShipQuotable

#shipping_address

Methods included from Models::TaxableResource

#resource_tax_rate

Methods included from Models::Itemizable

#account_specialist

Has many collapse

Methods included from Models::ShipQuotable

#deliveries, #shipments

Methods included from Models::Pickable

#line_discounts, #line_items

Methods included from Models::Itemizable

#coupons, #discounts

Has and belongs to many collapse

Methods included from Models::MultiRoom

#room_configurations

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::CouponDateOverridable

#override_coupon_date_limit

Methods included from Models::LegacyRateRequest

#last_shipping_rate_request_result, #last_shipping_rate_request_result=

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods included from Models::Profitable

#default_sales_markup, #profit_margins_met?, #profitable_line_items, #profitable_status, #profitable_total_discounted, #profitable_total_estimated_cost, #profitable_total_estimated_line_cost, #profitable_total_profit, #profitable_total_profit_margin, #profitable_total_profit_markup, #validate_min_profit_markup?

Methods included from Models::Lineage

#ancestors, #ancestors_ids, #children_and_roots, #descendants, #descendants_ids, #ensure_non_recursive_lineage, #family_members, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #root, #root_id, #self_ancestors_and_descendants, #self_ancestors_and_descendants_ids, #self_and_ancestors, #self_and_ancestors_ids, #self_and_children, #self_and_descendants, #self_and_descendants_ids, #self_and_siblings, #self_and_siblings_ids, #siblings, #siblings_ids

Methods included from Models::MultiRoom

#add_line_items_to_all_rooms, #add_lines_for_room_configuration, #all_rooms_complete?, #all_rooms_complete_or_cancelled?, #all_rooms_complete_or_cancelled_or_draft?, #all_rooms_in_design?, #all_rooms_ppd?, #any_room_ppd?, #any_rooms_in_design?, #get_operating_costs, #get_recommended_materials, #heated_sq_ft, #prioritize_room, #remove_line_items_from_all_rooms, #remove_lines_for_room_configuration, #replace_line_items_in_all_rooms, #suggested_items, #suggested_services, #synchronization_targets

Methods included from Models::ShipQuotable

#carrier, #chosen_shipping_method, #days_commitment, #determine_origin_address, #everything_in_stock?, #friendly_shipping_method, #is_drop_ship?, #is_override?, #is_warehouse_pickup?, #line_items_grouped_by_deliveries_quoting, #line_items_match_deliveries_if_any, #need_to_pre_pack_reasons, #need_to_recalculate_shipping, #one_time_shipping_address, #one_time_shipping_address=, #qualifies_for_cod?, #refresh_deliveries_quoting, #reset_deliveries_shipping_option, #reset_shipping, #retrieve_friendly_shipping_method, #retrieve_shipping_costs, #ship_quoted, #ship_weight, #shipping_non_db_default_but_db_customer?, #shipping_signature_confirmation_non_db_default_but_db_customer?, #shipping_via_wy_but_has_shipping_account?, #ships_economy_package?, #ships_freight?, #ships_freight_but_address_not_freight_ready?, #should_ship_freight?, #should_ship_freight_but_address_not_freight_ready?, #validate_deliveries

Methods included from Models::ShipMeasurable

#cartons_total, #crates_total, #pallets_total, #ship_freight_class_from_shipments, #ship_volume_from_shipments, #ship_volume_from_shipments_in_cubic_feet, #ship_weight_from_shipments, #shipment_set, #shipments_for_measure

Methods included from Models::Pickable

#all_my_publications, #append_suggested_items, #append_suggested_materials, #control_capacity, #determine_catalog_for_picking, #determine_skus_to_filter, #discounted_shipping_total, #electrical_heating_elements, #fix_catalog, #get_material_alerts, #has_custom_products?, #invalidate_material_alerts!, #line_items_grouped_by_room_configuration, #meets_custom_products_threshold?, #pricing_program_discount_factor, #purge_empty_line_items, #soft_recalc, #subtotal, #subtotal_after_trade_discount_without_shipping, #subtotal_discounted_without_shipping, #subtotal_msrp_without_shipping, #synchronize_lines, #total_amps_by_product_line, #total_coverage_by_product_line, #total_linear_feet_by_product_line, #total_spec_by_product_line, #total_watts_by_product_line, #underlayments

Methods included from Models::TaxableResource

#apply_tax_rate_to_line_items, #build_tax_params, #calculate_tax_for_all_lines, #copy_tax_rate, #effective_date, #get_rates_for_line, #get_tax_rate, #manual_rate_goods, #manual_rate_services, #manual_rate_shipping, #origin_address, #refresh_tax_rate, #resource_not_taxable?, #set_initial_tax_rate, #should_refresh_tax_rate?, #state_code, #state_code_sym, #taxes_grouped_by_rate, #taxes_grouped_by_type

Methods included from Models::Itemizable

#add_line_item, #additional_items, #assign_sequence, #billing_entity, #breakdown_of_prices, #calculate_actual_insured_value, #calculate_discounts, #calculate_shipping_cost, #coupon_search, #customer_applied_coupons, #customer_can_apply_coupon?, #discounts_changed?, #discounts_grouped_by_coupon, #discounts_subtotal, #effective_discount, #effective_shipping_discount, #has_kits?, #has_kits_or_serial_numbers?, #has_serial_numbers?, #is_credit_order?, #line_items_requiring_serial_number, #line_items_with_counters, #line_total_plus_tax, #perform_db_total, #purge_empty_quoting_deliveries, #purge_shipping_when_no_other_lines, #remove_line_item, #require_total_reset?, #reset_discount, #set_for_recalc, #set_signature_confirmation_on_shipping_address_change, #set_totals, #shipping_conditions_changed?, #shipping_discounted, #shipping_method_changed?, #should_recalculate_shipping?, #smartinstall_data, #smartsupport_data, #subtotal_cogs, #sync_shipping_line, #total_cogs

Methods included from Models::Notable

#quick_note

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

#do_not_detect_shippingObject

Returns the value of attribute do_not_detect_shipping.



164
165
166
# File 'app/models/quote.rb', line 164

def do_not_detect_shipping
  @do_not_detect_shipping
end

#do_not_set_totalsObject

Returns the value of attribute do_not_set_totals.



164
165
166
# File 'app/models/quote.rb', line 164

def do_not_set_totals
  @do_not_set_totals
end

#max_discount_overrideObject (readonly)



206
# File 'app/models/quote.rb', line 206

validates :max_discount_override, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, allow_nil: true }

#opportunity_idObject (readonly)



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

validates :opportunity_id, presence: { if: :validate_opportunity_and_prepared_for }

#suffixObject (readonly)



204
# File 'app/models/quote.rb', line 204

validates :suffix, length: { maximum: 150 }

#validate_opportunity_and_prepared_forObject

Returns the value of attribute validate_opportunity_and_prepared_for.



164
165
166
# File 'app/models/quote.rb', line 164

def validate_opportunity_and_prepared_for
  @validate_opportunity_and_prepared_for
end

Class Method Details

.activeActiveRecord::Relation<Quote>

A relation of Quotes that are active. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



217
# File 'app/models/quote.rb', line 217

scope :active, -> { where.not(state: 'cancelled') }

.auto_quote_from_room(room, options = {}) ⇒ Quote

One-shot factory used by the legacy "quote-from-room" entry
point — constructs a single-origin quote attached to the given
RoomConfiguration's opportunity, saves it twice (once to get
an id so discounts can attach, once to recompute), then fires
the requested state event (defaulting to complete).

Parameters:

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

Options Hash (options):

  • :force_single_origin (Boolean) — default: true
  • :do_not_set_shipping_address (Boolean) — default: false
  • :state_event (String) — default: 'complete'

    terminal
    event to fire once the quote is fully populated

Returns:



1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
# File 'app/models/quote.rb', line 1303

def self.auto_quote_from_room(room, options = {})
  force_single_origin = options[:force_single_origin] || true
  do_not_set_shipping_address = options[:do_not_set_shipping_address] || false
  target_state_event = options[:state_event] || 'complete'

  q = Quote.new(opportunity_id: room.opportunity_id)
  q.shipping_address_id = room.customer.shipping_address_id if !room.installation_postal_code && (do_not_set_shipping_address == false) && room.customer && room.customer.shipping_address.present?
  q.single_origin = true if force_single_origin
  q.room_configuration_ids = [room.id]
  q.do_not_detect_shipping = true

  # First save the quote WITHOUT calculating discounts to get an ID
  # Discounts require itemizable_id to be set, which only exists after the quote is persisted
  q.save

  # Now that the quote has an ID, trigger discount calculation (tier2 pricing, etc.)
  # The quote must be persisted before discounts can be created
  if q.persisted?
    q.recalculate_discounts = true
    q.force_total_reset = true
    q.do_not_detect_shipping = true # Still skip shipping detection
    q.save
  end

  # Now apply the target state event (typically 'complete')
  if q.persisted? && target_state_event.present? && q.respond_to?("#{target_state_event}!")
    q.do_not_set_totals = true # Discounts already calculated, don't recalculate
    q.send("#{target_state_event}!")
  end

  q
end

.by_reference_numberActiveRecord::Relation<Quote>

A relation of Quotes that are by reference number. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

scope :by_reference_number, -> { order(:reference_number).reverse_order }

.completed_quotesActiveRecord::Relation<Quote>

A relation of Quotes that are completed quotes. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

scope :completed_quotes, -> { where(state: 'complete') }

.contains_coupon_idsActiveRecord::Relation<Quote>

A relation of Quotes that are contains coupon ids. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

scope :contains_coupon_ids, ->(coupon_ids) { where("EXISTS (select discounts.id from discounts where discounts.coupon_id IN (?) and discounts.itemizable_type = 'Quote' and discounts.itemizable_id = quotes.id)", coupon_ids) }

.contains_item_idsActiveRecord::Relation<Quote>

A relation of Quotes that are contains item ids. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



215
# File 'app/models/quote.rb', line 215

scope :contains_item_ids, ->(item_ids) { where("EXISTS (select 1 from line_items li where li.resource_id = quotes.id and li.resource_type = 'Quote' and li.item_id IN (?))", item_ids) }

.count_by_incomplete_state_by_rep(rep_id = nil, sales_rep_type_id_label = 'primary_sales_rep_id') ⇒ Hash{String=>Integer}

Counts open quotes grouped by state, optionally constrained to
one rep. The sales_rep_type_id_label parameter lets the caller
ask for primary, secondary, or local rep counts.

Parameters:

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

    when set, restrict to one rep

  • sales_rep_type_id_label (String) (defaults to: 'primary_sales_rep_id')

    column name to match the
    rep against

Returns:

  • (Hash{String=>Integer})


1179
1180
1181
1182
1183
1184
1185
# File 'app/models/quote.rb', line 1179

def self.count_by_incomplete_state_by_rep(rep_id = nil, sales_rep_type_id_label = 'primary_sales_rep_id')
  if rep_id.nil?
    count(:state, conditions: ["state not in ('complete','cancelled')"], group: :state)
  else
    count(:state, conditions: ["state not in ('complete','cancelled') and #{sales_rep_type_id_label} = ?", rep_id], group: :state)
  end
end

.formatted_resources_query(quotes) ⇒ ActiveRecord::Relation<Quote>

Lightweight projection used by the global "resource picker"
search — returns just the columns the autocomplete needs (ref,
id, created_at, state, opportunity name) to keep the payload
small.

Parameters:

  • quotes (ActiveRecord::Relation<Quote>)

    base scope to
    project from

Returns:

  • (ActiveRecord::Relation<Quote>)


557
558
559
# File 'app/models/quote.rb', line 557

def self.formatted_resources_query(quotes)
  quotes.select('quotes.reference_number,quotes.id,quotes.created_at,quotes.state,opportunities.name as opportunity_name').joins(:opportunity).order(reference_number: :desc)
end

.ignore_fixture_attributesArray<String>

Columns to skip when comparing fixture-loaded quotes — the cached
rate-request XML blobs are timestamped and would otherwise cause
spurious test diffs.

Returns:

  • (Array<String>)


536
537
538
# File 'app/models/quote.rb', line 536

def self.ignore_fixture_attributes
  %w[last_rate_request_xml last_rate_response_xml]
end

.in_quotingActiveRecord::Relation<Quote>

A relation of Quotes that are in quoting. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



219
# File 'app/models/quote.rb', line 219

scope :in_quoting, -> { where(state: %w[awaiting_completed_installation_plans pre_pack awaiting_transmission]) }

.last_quote_update_cache_keyString

Cache key derived from the most-recently-updated quote in the
whole table — used to invalidate index-page fragment caches the
moment any quote changes.

Returns:

  • (String)


545
546
547
# File 'app/models/quote.rb', line 545

def self.last_quote_update_cache_key
  Quote.select('id,updated_at').order(:updated_at).reverse_order.first.cache_key
end

.last_revisionsActiveRecord::Relation<Quote>

A relation of Quotes that are last revisions. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



214
# File 'app/models/quote.rb', line 214

scope :last_revisions, -> { where("NOT EXISTS (select id from quotes q2 where q2.parent_id = quotes.id and quotes.state <> 'cancelled')") }

.like_lookupActiveRecord::Relation<Quote>

A relation of Quotes that are like lookup. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



221
# File 'app/models/quote.rb', line 221

scope :like_lookup, ->(q) { where('quotes.reference_number ILIKE :term', { term: "%#{q}%" }) }

.lookupActiveRecord::Relation<Quote>

A relation of Quotes that are lookup. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



220
# File 'app/models/quote.rb', line 220

scope :lookup, ->(q) { where('quotes.reference_number ILIKE :term', { term: q }) }

.most_recent_firstActiveRecord::Relation<Quote>

A relation of Quotes that are most recent first. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



212
# File 'app/models/quote.rb', line 212

scope :most_recent_first, -> { order(:created_at).reverse_order }

.not_convertedActiveRecord::Relation<Quote>

A relation of Quotes that are not converted. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



218
# File 'app/models/quote.rb', line 218

scope :not_converted, -> { where("not exists(select 1 from orders where quote_id = quotes.id and orders.state = 'invoiced')") }

.open_quotesActiveRecord::Relation<Quote>

A relation of Quotes that are open quotes. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



208
# File 'app/models/quote.rb', line 208

scope :open_quotes, -> { where.not(state: %w[complete cancelled]) }

.pendingActiveRecord::Relation<Quote>

A relation of Quotes that are pending. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



210
# File 'app/models/quote.rb', line 210

scope :pending, -> { where(state: 'pending') }

.sales_quotesActiveRecord::Relation<Quote>

A relation of Quotes that are sales quotes. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



224
# File 'app/models/quote.rb', line 224

scope :sales_quotes, -> { where(quote_type: SALES_QUOTE) }

.states_for_selectArray<Array(String, String)>

State-machine states formatted for a Rails select helper —
[[human_name, machine_value], …]. Used by the CRM filter
dropdowns.

Returns:

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


527
528
529
# File 'app/models/quote.rb', line 527

def self.states_for_select
  state_machines[:state].states.map { |s| [s.human_name, s.value] }
end

.with_contact_point_categoryActiveRecord::Relation<Quote>

A relation of Quotes that are with contact point category. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



222
# File 'app/models/quote.rb', line 222

scope :with_contact_point_category, ->(category) { where('exists(select 1 from contact_points_quotes cq inner join contact_points cp on cp.id = cq.contact_point_id and cq.quote_id = quotes.id where cp.category = ?)', category) }

.with_emailActiveRecord::Relation<Quote>

A relation of Quotes that are with email. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



223
# File 'app/models/quote.rb', line 223

scope :with_email, -> { with_contact_point_category(ContactPoint::EMAIL) }

.with_line_itemsActiveRecord::Relation<Quote>

A relation of Quotes that are with line items. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

scope :with_line_items, -> { includes(line_items: { catalog_item: { store_item: :item } }) }

.without_quofusActiveRecord::Relation<Quote>

A relation of Quotes that are without quofus. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



225
226
227
228
229
230
231
232
233
234
# File 'app/models/quote.rb', line 225

scope :without_quofus, -> {
  where(%{
           not exists(
             select 1 from activities a
             where (
               (a.resource_id = quotes.opportunity_id and a.resource_type = 'Opportunity')
               or (a.resource_id = quotes.id and a.resource_type = 'Quote')
             ) and a.activity_type_id IN (?)
           ) }, ActivityTypeConstants::QUOFUS_IDS)
}

Instance Method Details

#active_ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



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

has_many    :active_orders, -> { where(Order[:state].not_in(%w[cancelled fraudulent])) }, class_name: 'Order'

#activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



183
# File 'app/models/quote.rb', line 183

has_many    :activities, as: :resource, dependent: :nullify

#addendumsArray<Upload>

PDF uploads that should be appended to the customer-facing quote
bundle — e.g. seasonal promotions, terms-and-conditions revisions,
warranty inserts. Only publications tagged for-quote AND either
the current Month-YYYY tag or permanent are returned, so an
April quote doesn't ship March's flyer.

Returns:



816
817
818
819
820
821
822
823
# File 'app/models/quote.rb', line 816

def addendums
  current_month = Date.current.strftime('%B-%Y')
  # Use with_all_tags to require BOTH 'for-quote' AND the time-based tag (AND logic)
  publications = Item.publications.with_all_tags('for-quote', current_month.downcase)
                     .or(Item.publications.with_all_tags('for-quote', 'permanent'))
  list = publications.map(&:upload)
  list.uniq.compact
end

#address_bookArray<Address>

Address picker source for the quote's "ship to" field — the
customer's home store warehouse first (for will-call quotes),
then every address on the customer record.

Returns:



1460
1461
1462
# File 'app/models/quote.rb', line 1460

def address_book
  [customer.store.warehouse_address] + customer.addresses
end

#adjust_status_based_on_room_status(rc) ⇒ void

This method returns an undefined value.

Synchronises the quote's state machine with a child
RoomConfiguration that just changed status. Invoked from the
room state-machine after_transition so a room going
complete/cancelled marches the quote toward
installation_plans_complete, a room dropping back to draft
parks the quote in pending_project_details, and a room entering
design pushes the quote to ready_for_design.

Parameters:

  • rc (RoomConfiguration)

    the room whose state just changed



1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
# File 'app/models/quote.rb', line 1018

def adjust_status_based_on_room_status(rc)
  Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status: self.state: #{state}, rc: #{rc.reference_number}, #{rc.id}, rc state: #{rc.state}, DateTime: #{Time.current}"
  self.do_not_set_totals = true
  if rc.complete? || rc.cancelled?
    Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, BEFORE installation_plans_complete: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}"
    res = reload.installation_plans_complete unless complete?
    Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, AFTER installation_plans_complete: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}, result of installation_plans_complete: #{res}"
  elsif rc.draft?
    pending_project_details if can_pending_project_details? && awaiting_completed_installation_plans?
  elsif rc.in_design?
    Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, BEFORE ready_for_design: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}"
    res = ready_for_design
    Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, AFTER ready_for_design: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}, result of ready_for_design: #{res}"
  end
end

#all_activitiesActiveRecord::Relation<Activity>

Every Activity linked to this quote or its parent
Opportunity — what the CRM "activities" tab shows. Reaches
through opportunity so quote and opp activities aren't split
across two tabs.

Returns:



1451
1452
1453
# File 'app/models/quote.rb', line 1451

def all_activities
  opportunity.linked_activities
end

#all_orders_sold?Boolean

Whether every order spawned from this quote has been invoiced —
used as the guard for closing out the quote once shipping is
done.

Returns:

  • (Boolean)


694
695
696
# File 'app/models/quote.rb', line 694

def all_orders_sold?
  orders.any? && orders.all?(&:invoiced?)
end

#all_participantsObject

Alias for Opportunity#all_participants

Returns:

  • (Object)

    Opportunity#all_participants

See Also:



720
# File 'app/models/quote.rb', line 720

delegate :all_participants, to: :opportunity

#all_suggested_itemsHash{Symbol=>Object}

Aggregate of recommended add-ons for this quote: goods grouped by
room configuration, services, a deduplicated goods list, and the
SKUs of those unique goods. Drives the "you might also need…"
cross-sell panel on the quote builder and quote PDF.

Returns:

  • (Hash{Symbol=>Object})

    keys: :goods, :services,
    :unique_goods, :unique_goods_skus



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

def all_suggested_items
  goods = suggested_items
  services = suggested_services
  unique_goods = []
  goods.each do |_rc, lines|
    lines.each do |li|
      unique_goods << li unless unique_goods.any? { |si| si.item == li.item }
    end
  end
  unique_goods_skus = unique_goods.compact.uniq.map(&:sku)
  { goods:, services:, unique_goods:, unique_goods_skus: }
end

#all_support_casesActiveRecord::Relation<SupportCase>

Support cases linked to this quote (directly via the HABTM and
transitively through its room configurations), ordered newest
case-number first for the CRM sidebar.

Returns:



488
489
490
491
492
# File 'app/models/quote.rb', line 488

def all_support_cases
  support_case_ids
  linked_support_cases.ids
  SupportCase.where(id: support_case_ids).order(Arel.sql('support_cases.case_number desc'))
end

#applies_for_smartinstallBoolean

Whether this quote qualifies for SmartInstall — WarmlyYours's
in-house installation service for homeowners. Currently disabled
(return false) pending the smart-services refactor; the
original geofence/role/heated-items check is preserved below
the early return for when it's reactivated.

Returns:

  • (Boolean)


1490
1491
1492
1493
1494
1495
# File 'app/models/quote.rb', line 1490

def applies_for_smartinstall
  return false # added by Roman Aug 19th until service refactor
  return true if installation_is_within_range? && customer.is_homeowner? && has_selected_heated_items?

  false
end

#applies_for_smartsupportBoolean

Whether this quote qualifies for SmartSupport — the
dealer/trade-pro version of the in-house service program (we send
an installer to support the trade pro on site).

Returns:

  • (Boolean)


1502
1503
1504
1505
1506
# File 'app/models/quote.rb', line 1502

def applies_for_smartsupport
  return true if customer.is_dealer_or_trade_pro_for_locator? && has_selected_heated_items?

  false
end

#apply_tier2_pricing?Boolean

Whether or not to apply the tier2 pricing (customer discount) by default

Returns:

  • (Boolean)


469
470
471
# File 'app/models/quote.rb', line 469

def apply_tier2_pricing?
  is_sales_quote?
end

#available_contact_pointsArray<ContactPoint>

Every ContactPoint reachable through this quote's opportunity
(primary party, customer, and every opportunity participant),
deduped — used to populate the recipient picker on the quote
transmission form.

Returns:



849
850
851
852
853
854
# File 'app/models/quote.rb', line 849

def available_contact_points
  cps = opportunity.primary_party.contact_points.order(:position).to_a
  cps += opportunity.customer.contact_points.order(:position).to_a
  cps += opportunity.opportunity_participants.map { |op| op.party.contact_points.order(:position).to_a }.flatten
  cps.uniq
end

#available_contact_points_grouped_for_selectHash{String=>Array<Array(String, Integer)>}

#available_contact_points reshaped into Rails grouped-select
input: { "Person Full Name" => [["detail (category)", id], …] }
so the transmission form renders an <optgroup> per party.

Returns:

  • (Hash{String=>Array<Array(String, Integer)>})


861
862
863
864
865
866
# File 'app/models/quote.rb', line 861

def available_contact_points_grouped_for_select
  available_contact_points.each_with_object({}) do |cp, hsh|
    hsh[cp.party.full_name] ||= []
    hsh[cp.party.full_name] << ["#{cp.detail} (#{cp.category})", cp.id]
  end
end

#best_contact_pointContactPoint?

Best contact point we can send the quote to — falls through quote
→ contact → customer until something transmittable (email/fax)
turns up. Used as the default to: when an agent clicks "Send"
without picking a recipient.

Returns:



831
832
833
834
835
836
837
838
839
840
841
# File 'app/models/quote.rb', line 831

def best_contact_point
  contact_points.transmittable.first || (begin
    contact.contact_points.transmittable.first
  rescue StandardError
    nil
  end) || (begin
    customer.contact_points.transmittable.first
  rescue StandardError
    nil
  end)
end

#billing_addressObject

Alias for Customer#billing_address

Returns:

  • (Object)

    Customer#billing_address

See Also:



171
# File 'app/models/quote.rb', line 171

delegate    :billing_address, :company, to: :customer

#build_activityActivity

Builds (but does not save) a new Activity attached to this quote
with the quote's primary party already populated. Used by the CRM
activity-form helpers to seed a new activity without round-tripping
through nested form params.

Returns:



479
480
481
# File 'app/models/quote.rb', line 479

def build_activity
  activities.build resource: self, party: primary_party
end

#buying_groupBuyingGroup



176
# File 'app/models/quote.rb', line 176

belongs_to  :buying_group, optional: true

#calculate_expiration_dateDate

Computed expiration date for the quote — the soonest of every
attached coupon's expiration and a default 60-day window from
created_at. Persisted by the #set_expiration_date before-update
callback so the customer sees a stable "valid through" date on
the PDF.

Returns:

  • (Date)


949
950
951
952
953
954
955
956
# File 'app/models/quote.rb', line 949

def calculate_expiration_date
  expirations = [] + (begin
    coupons.filter_map(&:expiration_date)
  rescue StandardError
    []
  end) + [created_at + 60.days]
  expirations.min.to_date
end

#can_be_moved?Boolean

Whether this quote can be reassigned to a different opportunity.
Locked once the quote has at least one non-cart, non-cancelled
Order so we don't orphan invoiced sales from their opportunity.

Returns:

  • (Boolean)


499
500
501
# File 'app/models/quote.rb', line 499

def can_be_moved?
  orders.active.non_carts.blank?
end

#can_be_transmitted?Boolean

Whether the customer-facing transmission flow is allowed in this
state. Awaiting-transmission and pending-project-details quotes
can be sent for review; complete quotes can be re-sent for the
confirmation copy.

Returns:

  • (Boolean)


675
676
677
# File 'app/models/quote.rb', line 675

def can_be_transmitted?
  awaiting_transmission? || pending_project_details? || complete?
end

#can_convert?Object

Alias for Convert_to_order_service#can_convert?

Returns:

  • (Object)

    Convert_to_order_service#can_convert?

See Also:



1093
# File 'app/models/quote.rb', line 1093

delegate :can_convert?, to: :convert_to_order_service

#cancelable?Boolean

State-machine guard for the cancel event — the quote can be
cancelled as long as no surviving order points at it.

Returns:

  • (Boolean)


1158
1159
1160
# File 'app/models/quote.rb', line 1158

def cancelable?
  orders.not_cancelled.empty?
end

#catalogObject

Alias for Customer#catalog

Returns:

  • (Object)

    Customer#catalog

See Also:



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

delegate    :catalog, to: :customer

#check_pre_packvoid

This method returns an undefined value.

after_save callback that auto-transitions the quote into
pre_pack when it's awaiting transmission and the warehouse
signals that estimated packaging needs to happen first (see
#stop_for_pre_pack?). Skipped when every delivery is already
in pre-pack.



1647
1648
1649
# File 'app/models/quote.rb', line 1647

def check_pre_pack
  request_estimated_packaging if awaiting_transmission? && can_request_estimated_packaging? && !pre_pack? && !deliveries.all?(&:pre_pack?) && stop_for_pre_pack?
end

#communicationsActiveRecord::Relation<Communication>

Returns:

See Also:



184
# File 'app/models/quote.rb', line 184

has_many    :communications, as: :resource, dependent: :nullify

#companyObject

Alias for Customer#company

Returns:

  • (Object)

    Customer#company

See Also:



171
# File 'app/models/quote.rb', line 171

delegate    :billing_address, :company, to: :customer

#contactObject

Alias for Opportunity#contact

Returns:

  • (Object)

    Opportunity#contact

See Also:



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

delegate    :customer, :contact, to: :opportunity

#contact_pointsActiveRecord::Relation<ContactPoint>

Returns:

See Also:



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

has_and_belongs_to_many :contact_points, inverse_of: :quotes

#convert_to_order_serviceQuote::ConvertToOrder

Memoised ConvertToOrder service for this quote. The
service encapsulates the logic to turn a complete quote into one
or more Orders (one per delivery / origin) — view and controller
code calls quote.convert_to_order_service.can_convert? and
quote.to_order rather than instantiating the service themselves.



1089
1090
1091
# File 'app/models/quote.rb', line 1089

def convert_to_order_service
  @convert_to_order_service ||= Quote::ConvertToOrder.new(self)
end

#countryString?

ISO country of the Store this quote was placed against — used
by tax / shipping / addendum logic to branch on US vs CA vs other.
Swallows nil-chain errors and returns nil rather than raising.

Returns:

  • (String, nil)


1665
1666
1667
1668
1669
# File 'app/models/quote.rb', line 1665

def country
  customer.catalog.store.country
rescue StandardError
  nil
end

Internal CRM URL for this quote's show page. Shared Activity,
Communication, and notification helpers call this to produce the
"open in CRM" link without each having to know the routes module.

Returns:

  • (String)


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

def crm_link
  UrlHelper.instance.quote_path(self)
end

#currency_symbolString

Currency glyph ($, , £, …) for this quote, derived from the
quote's currency ISO code via the money gem. Used wherever the
quote PDF / CRM views need to print prices.

Returns:

  • (String)


938
939
940
# File 'app/models/quote.rb', line 938

def currency_symbol
  Money::Currency.new(currency).symbol
end

#customerObject

Alias for Opportunity#customer

Returns:

  • (Object)

    Opportunity#customer

See Also:



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

delegate    :customer, :contact, to: :opportunity

#deep_dupQuote

Deep-clones the quote and its non-shipping parent LineItems, plus
the contact-points join, but resets identity / lifecycle columns
(state goes back to pending, tax fields drop, parent_id and
delivery_id are stripped) so the duplicate can be saved as a
fresh draft. Used by "Revise quote" CRM action and by the
quote-revision workflow.

Returns:

  • (Quote)

    the unsaved duplicate



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'app/models/quote.rb', line 129

def deep_dup
  deep_clone(include: :contact_points) do |original, copy|
    next unless copy.is_a?(Quote)

    copy.state = 'pending'
    original.line_items.parents_only.non_shipping.each do |li|
      li_attrs = li.attributes.dup.symbolize_keys
      li_attrs = li_attrs.except(:id, :resource_type, :resource_id,
                                 :delivery_id, :tax_total, :tax_rate,
                                 :tax_type, :resource_tax_rate_id,
                                 :original_delivery_id, :children_count, :parent_id)
      li_attrs[:original_delivery_id] = li[:delivery_id]
      li_attrs[:discounted_price] = li_attrs[:price] if li_attrs[:discounted_price].to_f.zero? && li_attrs[:price].to_f.positive?
      copy.line_items.new(li_attrs)
    end
  end
end

#dependents(limit: 5) ⇒ Hash{Symbol=>ActiveRecord::Relation}

Quote dependents that block destruction — orders, child
revisions, and customer communications. Returned as a hash for
the "cannot delete" UI to enumerate why.

A quote with communications to a customer cannot be deleted.
A quote used as a parent of another quote is also restricted
(clear the children first). A quote with orders cannot be
deleted.

Parameters:

  • limit (Integer) (defaults to: 5)

    cap per association (display-only)

Returns:

  • (Hash{Symbol=>ActiveRecord::Relation})


1129
1130
1131
1132
1133
1134
1135
# File 'app/models/quote.rb', line 1129

def dependents(limit: 5)
  {
    orders: orders.limit(limit),
    children: children.limit(limit),
    communications: communications.limit(limit)
  }
end

#editing_locked?Boolean

True once the quote is in a terminal state — used by the
form/controller layer to read-only the line item rows so a
finalised or cancelled quote can't be edited in place.

Returns:

  • (Boolean)


929
930
931
# File 'app/models/quote.rb', line 929

def editing_locked?
  cancelled? || complete?
end

#effective_date_for_couponDate

Date used by the coupon engine to evaluate "is this coupon
currently valid?" against start_date/end_date windows. Prefers
an explicit override_coupon_date, then the date the quote was
completed, then created — falling back to today for brand-new
in-memory quotes. Keeps coupon evaluation stable after the quote
is finalised even if the coupon's window later shifts.

Returns:

  • (Date)


569
570
571
# File 'app/models/quote.rb', line 569

def effective_date_for_coupon
  override_coupon_date.presence || complete_datetime.try(:to_date) || created_at.try(:to_date) || Date.current
end

#exclude_manually_initiated_event?(_event) ⇒ false

State-machine helper — Models::Auditable hook used to suppress
certain manually-initiated events from PaperTrail's audit trail.
Quotes don't suppress anything, so this always returns false.

Returns:

  • (false)


518
519
520
# File 'app/models/quote.rb', line 518

def exclude_manually_initiated_event?(_event)
  false
end

#extra_plans_file_name(with_extension = true) ⇒ String

Filename for the secondary "installation plans" PDF that ships
alongside the main quote — same naming convention as #file_name
with a _plans suffix so attachments don't collide.

Parameters:

  • with_extension (Boolean) (defaults to: true)

    include the .pdf suffix

Returns:

  • (String)


775
776
777
# File 'app/models/quote.rb', line 775

def extra_plans_file_name(with_extension = true)
  "quote_#{reference_number}_plans#{'.pdf' if with_extension}"
end

#file_name(with_extension = true) ⇒ String

Filename used when the customer-facing quote PDF is downloaded or
attached to an email — reference-number-stamped so a customer with
multiple revisions can tell them apart.

Parameters:

  • with_extension (Boolean) (defaults to: true)

    include the .pdf suffix

Returns:

  • (String)


765
766
767
# File 'app/models/quote.rb', line 765

def file_name(with_extension = true)
  "quote_#{reference_number}#{'.pdf' if with_extension}"
end

#get_next_reference_numberString

Pulls the next quote reference number from the
quotes_reference_numbers_seq Postgres sequence and prefixes it
with the quote-type code (e.g. SQ, MQ, TQ). Sequence-based
so concurrent quote creates can't collide.

Returns:

  • (String)

    e.g. "SQ123456"



1363
1364
1365
1366
1367
1368
1369
1370
# File 'app/models/quote.rb', line 1363

def get_next_reference_number
  ref_base = begin
    self.class.lease_connection.execute("SELECT nextval('quotes_reference_numbers_seq') AS reference_number").first['reference_number']
  rescue StandardError
    nil
  end
  "#{quote_type}#{ref_base}"
end

#goods_product_line_idsArray<Integer>

Distinct primary product-line ids represented by the goods on this
quote. Drives the "what kind of project is this?" routing in the
CRM (heated floors vs snow melt vs towel warmers) and the addendum
selection for the customer-facing PDF.

Returns:

  • (Array<Integer>)


464
465
466
# File 'app/models/quote.rb', line 464

def goods_product_line_ids
  line_items.goods.joins(:item).where.not(Item[:primary_product_line_id].eq(nil)).pluck(Arel.sql('distinct items.primary_product_line_id'))
end

#has_discounts?Boolean

True when at least one line item carries a discount — drives the
"savings" badge on the customer-facing PDF and unlocks the
discount-detail rows.

Returns:

  • (Boolean)


805
806
807
# File 'app/models/quote.rb', line 805

def has_discounts?
  line_items.any?(&:is_discounted?)
end

#has_no_instant_quoting_roomstrue

Validation stub left over from the deprecated Instant Quote (IQ)
system — kept registered as a validate :has_no_instant_quoting_rooms
so removing it is a separate, easy-to-revert change.

Returns:

  • (true)


1395
1396
1397
1398
# File 'app/models/quote.rb', line 1395

def has_no_instant_quoting_rooms
  # Legacy IQ validation - no longer needed after IQ system deprecated
  true
end

#has_onsite_smartsupport?Boolean

Whether on-site SmartSupport is available — install is within the
service-distance radius from the WarmlyYours HQ.

Returns:

  • (Boolean)


1523
1524
1525
1526
1527
# File 'app/models/quote.rb', line 1523

def has_onsite_smartsupport?
  return true if service_distance.present? && (service_distance < SMART_SERVICES_MAX_DISTANCE)

  false
end

#has_pre_pack_deliveries?Boolean

True when the quote itself is in pre_pack or any of its
deliveries is in pre-pack. Used by the warehouse dashboard.

Returns:

  • (Boolean)


1618
1619
1620
# File 'app/models/quote.rb', line 1618

def has_pre_pack_deliveries?
  pre_pack? || deliveries.any?(&:pre_pack?)
end

#has_selected_heated_items?Boolean

Whether the quote actually contains items eligible for the
smart-services program — branches on customer type so we look at
the right side of the eligibility data.

Returns:

  • (Boolean)


1534
1535
1536
1537
1538
1539
1540
1541
1542
# File 'app/models/quote.rb', line 1534

def has_selected_heated_items?
  if customer.is_homeowner?
    smartinstall_data.present?
  elsif customer.is_dealer_or_trade_pro_for_locator?
    smartsupport_info.present?
  else
    false
  end
end

#installation_country_isoObject

Alias for Opportunity#installation_country_iso

Returns:

  • (Object)

    Opportunity#installation_country_iso

See Also:



173
# File 'app/models/quote.rb', line 173

delegate    :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity

#installation_country_iso3Object

Alias for Opportunity#installation_country_iso3

Returns:

  • (Object)

    Opportunity#installation_country_iso3

See Also:



173
# File 'app/models/quote.rb', line 173

delegate    :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity

#installation_is_within_range?Boolean

True when #service_distance is non-nil and inside the
SMART_SERVICES_MAX_DISTANCE radius (currently 100 mi from HQ).

Returns:

  • (Boolean)


1548
1549
1550
# File 'app/models/quote.rb', line 1548

def installation_is_within_range?
  service_distance.present? && (service_distance <= SMART_SERVICES_MAX_DISTANCE) # within 100 miles from office
end

#installation_postal_codeObject

Alias for Opportunity#installation_postal_code

Returns:

  • (Object)

    Opportunity#installation_postal_code

See Also:



173
# File 'app/models/quote.rb', line 173

delegate    :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity

#installation_state_codeObject

Alias for Opportunity#installation_state_code

Returns:

  • (Object)

    Opportunity#installation_state_code

See Also:



173
# File 'app/models/quote.rb', line 173

delegate    :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity

#insulation_sq_ftInteger

Total square footage of underlayment / insulation across every
indoor room on the quote — used by the cork/insulation
cross-sell logic and by the catalog to suggest the right
underlayment quantity. Outdoor rooms (snow-melt) are excluded
because they don't take indoor underlayment.

Returns:

  • (Integer)

    square feet



1245
1246
1247
1248
1249
1250
1251
# File 'app/models/quote.rb', line 1245

def insulation_sq_ft
  sqft = 0
  room_configurations.each do |rc|
    sqft += rc.insulation_surface.to_i if rc.room_type&.is_indoor? && (rc.insulation_surface.to_i > 0)
  end
  sqft
end

#is_first_quote?Boolean

True when this is the original quote rather than a revision —
i.e. nothing pointed at as parent_id.

Returns:

  • (Boolean)


1226
1227
1228
# File 'app/models/quote.rb', line 1226

def is_first_quote?
  parent_id.nil?
end

#is_revision?Boolean

True when this quote was spawned via #deep_dup from an earlier
quote — the inverse of #is_first_quote?.

Returns:

  • (Boolean)


1234
1235
1236
# File 'app/models/quote.rb', line 1234

def is_revision?
  parent_id.present?
end

#is_sales_quote?Boolean

True for the standard customer-facing sales quote (SQ) —
the main quote-type in production. Marketing (MQ) and tech
(TQ) quotes return false.

Returns:

  • (Boolean)


1218
1219
1220
# File 'app/models/quote.rb', line 1218

def is_sales_quote?
  quote_type == SALES_QUOTE
end

#is_smart_service_quote?Boolean

True when every non-shipping line on the quote is a smart-service
SKU — used to switch the PDF / fulfillment path to the
services-only template.

Returns:

  • (Boolean)


1557
1558
1559
# File 'app/models/quote.rb', line 1557

def is_smart_service_quote?
  line_items.non_shipping.all?(&:is_smart_service?)
end

#linked_support_casesActiveRecord::Relation<LinkedSupportCase>

Returns:

  • (ActiveRecord::Relation<LinkedSupportCase>)

See Also:



186
# File 'app/models/quote.rb', line 186

has_many    :linked_support_cases, through: :room_configurations, source: :support_cases

#local_sales_repObject

Alias for Customer#local_sales_rep

Returns:

  • (Object)

    Customer#local_sales_rep

See Also:



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

delegate    :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer

Public-facing URL for the customer's "look up your quote" page
(warmlyyours.com /quote-lookup). Embedded in transmission emails
so the customer can pull up their quote without an account.

Returns:

  • (String)


1656
1657
1658
# File 'app/models/quote.rb', line 1656

def lookup_link
  "https://#{WEB_HOSTNAME}/quote-lookup?quote_id=#{id}"
end

#main_repEmployee?

Primary rep this quote "belongs to" for commission and CRM
ownership. Aliases #primary_sales_rep so view code can call a
consistent name across Quote, Order, and Opportunity.

Returns:



883
884
885
# File 'app/models/quote.rb', line 883

def main_rep
  primary_sales_rep
end

#material_alertsActiveRecord::Relation<MaterialAlert>

Returns:

See Also:



187
# File 'app/models/quote.rb', line 187

has_many    :material_alerts, dependent: :destroy

#messaging_logsActiveRecord::Relation<MessagingLog>

Returns:

See Also:



185
# File 'app/models/quote.rb', line 185

has_many    :messaging_logs, dependent: :destroy, as: :resource

#nameString

Short human label like Quote #SQ12345. Used in notifications,
mailers, and audit-trail rows where reference number alone needs
the word "Quote" in front of it.

Returns:

  • (String)


1049
1050
1051
# File 'app/models/quote.rb', line 1049

def name
  "Quote ##{reference_number}"
end

#ok_to_customer_cancel?Boolean

Whether the customer is allowed to self-cancel from the public
quote-lookup page — only when the quote is still pending and has
no surviving orders attached.

Returns:

  • (Boolean)


1150
1151
1152
# File 'app/models/quote.rb', line 1150

def ok_to_customer_cancel?
  CUSTOMER_CANCELABLE_STATES.include?(state.to_sym) && orders.not_cancelled.empty?
end

#ok_to_delete?Boolean

True when #dependents is empty across the board — the precondition
for the destroy button.

Returns:

  • (Boolean)


1141
1142
1143
# File 'app/models/quote.rb', line 1141

def ok_to_delete?
  dependents.values.flatten.empty?
end

#open_activities_counterInteger

Count of CRM follow-ups still open against this quote (or its
opportunity), filtered to the activities that show by default in
the rep's queue. Drives the badge on the quote sidebar.

Returns:

  • (Integer)


1595
1596
1597
# File 'app/models/quote.rb', line 1595

def open_activities_counter
  all_activities.open_activities.visible_by_default.size
end

#opportunityOpportunity



175
# File 'app/models/quote.rb', line 175

belongs_to  :opportunity, optional: true

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



180
# File 'app/models/quote.rb', line 180

has_many    :orders

#participants_options_for_selectArray<Array(String, Integer)>?

Opportunity participants formatted for a Rails select helper —
[[full_name, party_id], …]. Drives the "additional participants"
multi-select on the quote form.

Returns:

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


1469
1470
1471
# File 'app/models/quote.rb', line 1469

def participants_options_for_select
  opportunity&.opportunity_participants&.map { |scp| [scp.party.full_name, scp.party_id] }
end

#post_communication_queued_hook(_params = {}) ⇒ void

This method returns an undefined value.

Hook fired by the communication subsystem the moment a transmission
email/fax is enqueued — finalises the quote if every other gate has
already cleared. Called by the Communication observer after the
outbound message is queued for delivery.



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

def post_communication_queued_hook(_params = {})
  # Handles post transmission
  complete if can_complete?
end

#post_communication_sent_hook(_params = {}) ⇒ void

This method returns an undefined value.

Hook fired once the transmission has actually been sent by the
provider — finalises the quote if every other gate has cleared.
Mirror of #post_communication_queued_hook for providers that
emit a separate "sent" callback.



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

def post_communication_sent_hook(_params = {})
  # Handles post transmission
  complete if can_complete?
end

#pre_packable?Boolean

Whether the warehouse can run an estimated-packaging pre-pack on
this quote — at least one delivery must have a shipment that
isn't already packed or pre-packed.

Returns:

  • (Boolean)


1167
1168
1169
# File 'app/models/quote.rb', line 1167

def pre_packable?
  deliveries.any? && deliveries.any? { |d| d.shipments.any? { |s| !s.packed_or_pre_packed? } }
end

#prefix_for_reference_numberString

Fallback prefix used when #quote_type hasn't been chosen yet —
derives the two-letter code from the parent Opportunity's type
(so a sales opp gives SQ, marketing opp MQ, etc.).

Returns:

  • (String)


1386
1387
1388
# File 'app/models/quote.rb', line 1386

def prefix_for_reference_number
  "#{opportunity.opportunity_type}Q"
end

#prepared_for_nameString

Display name printed under "Prepared for" on the quote PDF —
the Contact's name when one is selected, otherwise the
Customer's.

Returns:

  • (String)


1192
1193
1194
# File 'app/models/quote.rb', line 1192

def prepared_for_name
  (contact || customer).name
end

#prevent_recalculate_shipping?Boolean

Guard used by Models::ShipQuotable to skip live ShipEngine rate
quoting — already-finalised quotes don't requote, and the flag
also blocks re-entrancy while a rate request is in flight.

Returns:

  • (Boolean)


1405
1406
1407
# File 'app/models/quote.rb', line 1405

def prevent_recalculate_shipping?
  %i[complete cancelled].include?(state.to_sym) || retrieving_shipping_costs?
end

#pricing_program_descriptionString

Human label for the pricing program in effect on this quote — the
tier2 coupon title when the customer is on a dealer/contractor
tier, otherwise "MSRP/Catalog". Surfaced on the quote PDF and
on the CRM "pricing" badge.

Returns:

  • (String)


1432
1433
1434
# File 'app/models/quote.rb', line 1432

def pricing_program_description
  tier2_program_pricing_coupon.try(:title) || 'MSRP/Catalog'
end

#pricing_program_discountInteger

Percentage discount the active tier2 pricing program applies to
goods, or 0 when the quote is on plain MSRP. Companion to
#pricing_program_description.

Returns:

  • (Integer)

    percentage 0..100



1441
1442
1443
# File 'app/models/quote.rb', line 1441

def pricing_program_discount
  (tier2_program_pricing_coupon.try(:amount_goods).presence || 0).to_i
end

#primary_partyParty

Person or company this quote is "for" — the Contact when a
specific contact has been picked out of the customer's contacts
list, otherwise the Customer itself. Used as the addressee on
PDFs and as the default activity party.

Returns:



509
510
511
# File 'app/models/quote.rb', line 509

def primary_party
  contact || customer
end

#primary_sales_repObject

Alias for Customer#primary_sales_rep

Returns:

  • (Object)

    Customer#primary_sales_rep

See Also:



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

delegate    :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer

#ready_to_complete?Boolean

Coarser readiness check than #will_complete? — only requires
that no room is in a half-resolved state. Used by the UI to grey
out the "Send to Customer" button before margins/etc are
validated.

Returns:

  • (Boolean)


785
786
787
788
789
# File 'app/models/quote.rb', line 785

def ready_to_complete?
  # here we want good shipping address (allow nil if instant_quoting), and any cancelled rooms to be deleted or resolved before we can get a complete and convertable quote
  # shipping_address_nil_or_valid? &&
  all_rooms_complete_or_cancelled_or_draft?
end

#recipient_emailString?

Email address the quote should be sent to — prefers an explicit
quote-level contact-point email, then the primary party's email,
then the customer's. Used as the default to: on transmission.

Returns:

  • (String, nil)


620
621
622
623
624
# File 'app/models/quote.rb', line 620

def recipient_email
  contact_points.emails.first&.detail ||
    primary_party&.email ||
    customer&.email
end

#recipient_nameString?

Best display name for the person this quote is addressed to —
walks #primary_party → shipping address → customer until it
finds one. Used as the salutation on the quote PDF and in email.

Returns:

  • (String, nil)


611
612
613
# File 'app/models/quote.rb', line 611

def recipient_name
  primary_party&.name || shipping_address&.person_name || customer&.name
end

#reference_number_with_nameString

Reference number plus the rep-supplied free-text suffix (the rep
uses this slot for the project name). Format: SQ12345 - Smith Master Bath. Used as the display label in CRM lists and on the
quote PDF header.

Returns:

  • (String)


1040
1041
1042
# File 'app/models/quote.rb', line 1040

def reference_number_with_name
  [reference_number, suffix].compact.join(' - ')
end

#reference_number_with_opp_nameString

Reference number with the parent Opportunity's name appended —
used in dropdowns where the rep needs to disambiguate between
multiple quotes on the same opportunity.

Returns:

  • (String)


1058
1059
1060
# File 'app/models/quote.rb', line 1058

def reference_number_with_opp_name
  "#{reference_number} #{opportunity.try(:name)}"
end

#reference_number_with_opp_name_when_specifiedString

#reference_number_with_opp_name but skips the appended job name
when the opportunity has a placeholder/unknown name (so we don't
render "SQ12345 Job for Customer XYZ" — just SQ12345).

Returns:

  • (String)


1067
1068
1069
1070
1071
# File 'app/models/quote.rb', line 1067

def reference_number_with_opp_name_when_specified
  s = reference_number.to_s
  s += " #{opportunity.name}" unless opportunity.nil? || opportunity.unknown_job_name?
  s
end

#request_plans_or_completevoid

This method returns an undefined value.

State-machine driver that nudges the quote forward (or back) based
on its current room-configuration mix and line-item presence.
Called from the rooms controller after a room edit/destroy so the
quote auto-advances to awaiting_transmission,
awaiting_completed_installation_plans, or
pending_project_details without the rep having to click a state
button. No-op once the quote is complete or cancelled.



896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
# File 'app/models/quote.rb', line 896

def request_plans_or_complete
  return if complete? || cancelled?

  # Let's see if this is still necessary, as far as i can find it's only on quotes_controller # enter_rooms and room destroy that
  # this is called and the room association is already up to date at that point
  # room_configurations.each(&:reload)
  self.do_not_set_totals = true
  log_prefix = "Quote(#{id}):request_plans_or_complete"
  # If no room but line items, let's go straight to awaiting transmission
  if room_configurations.empty? && line_items.present?
    logger.info "#{log_prefix} no rooms but line present. attempting ready to transmit"
    ready_to_transmit! if can_ready_to_transmit?
  # If all rooms are complete then set quote to be awaiting_transmission
  elsif can_installation_plans_complete?
    logger.info "#{log_prefix} installation plan can complete, trying"
    installation_plans_complete!
  elsif can_ready_for_design?
    logger.info "#{log_prefix} can ready for design, trying"
    ready_for_design!
  elsif can_pending_project_details?
    logger.info "#{log_prefix} no line items and nothing else happening, go back to draft"
    pending_project_details!
  else
    logger.info "#{log_prefix} reverting to draft instant quoting rooms"
    # Legacy IQ cleanup removed - no longer needed after IQ system deprecated
  end
end

#reset_discount_on_ships_economy_changeBoolean? (protected)

after_save callback: when the rep flips a quote between economy
and standard ground shipping, drop any previously-applied
shipping discount so the next pricing pass recalculates against
the new shipping basis. Item-level pricing is left alone.

Returns:

  • (Boolean, nil)


1700
1701
1702
1703
1704
1705
# File 'app/models/quote.rb', line 1700

def reset_discount_on_ships_economy_change
  return unless saved_change_to_ships_economy?

  reset_discount(reset_item_pricing: false)
  true
end

#rmaRma

Returns:

See Also:



177
# File 'app/models/quote.rb', line 177

belongs_to  :rma, optional: true

#schedule_follow_up(follow_up_task_type: nil) ⇒ Activity?

Creates (or replaces) a quote-centric follow-up activity on the
opportunity so a rep is reminded to chase the customer. Cancels
any existing opp-level follow-ups first, and skips entirely for
anonymous Quote-Builder guests with no email/phone.

Parameters:

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

    override the default
    task type (used for "first follow-up" vs "second follow-up")

Returns:



1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
# File 'app/models/quote.rb', line 1344

def schedule_follow_up(follow_up_task_type: nil)
  # Don't create follow-up activities for guest customers without contact info
  # These are anonymous Quote Builder users who haven't provided email/phone
  return if customer.guest? && !opportunity.emailable? && !opportunity.voice_callable?

  return if opportunity.activities.quofu.open_activities.present?

  # cancel oppfu follow ups
  opportunity.cancel_oppfu
  # recreate a quofu centric follow up
  opportunity.create_follow_up_activity(follow_up_task_type:)
end

#secondary_sales_repObject

Alias for Customer#secondary_sales_rep

Returns:

  • (Object)

    Customer#secondary_sales_rep

See Also:



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

delegate    :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer

#selection_nameString

Label used by the global Ransack/select2 lookup widget when this
quote is offered as a search result. Aliases
#reference_number_with_name.

Returns:

  • (String)


1274
1275
1276
# File 'app/models/quote.rb', line 1274

def selection_name
  reference_number_with_name
end

#send_profit_review_notificationvoid

This method returns an undefined value.

Notifies internal staff (margin desk / pricing) that this quote
has entered profit_review so they can review the pricing
exception. Wired up as an after_transition from the state
machine.



1479
1480
1481
# File 'app/models/quote.rb', line 1479

def send_profit_review_notification
  OrdersMailer.quote_profit_review_notification(self).deliver_later
end

#senderEmployee?

Rep treated as the "from" on outbound communications and
notifications about this quote. Falls through customer-attached
primary rep → opportunity-attached primary rep → customer's
primary rep so an orphan quote without a directly-assigned rep
still has a sender.

Returns:



1203
1204
1205
1206
1207
1208
1209
1210
1211
# File 'app/models/quote.rb', line 1203

def sender
  if primary_sales_rep
    primary_sales_rep
  elsif opportunity.primary_sales_rep
    opportunity.primary_sales_rep
  elsif customer.primary_sales_rep
    customer.primary_sales_rep
  end
end

#service_distanceFloat?

Driving distance (in miles) from the WarmlyYours Lake Zurich HQ
to the installation postal code on this quote — the gating
measure for SmartInstall / SmartSupport eligibility (see
SMART_SERVICES_MAX_DISTANCE).

Returns:

  • (Float, nil)


1567
1568
1569
1570
# File 'app/models/quote.rb', line 1567

def service_distance
  zip_code = opportunity.installation_postal_code
  SmartServicesController.helpers.calculate_distance_from_lz(zip_code)
end

#set_currencyString?

Pulls the currency from the customer's home Store and writes it
onto the quote so prices print in the right denomination. Used
when a quote is created on behalf of a customer whose store
differs from the agent's default.

Returns:

  • (String, nil)

    the assigned currency code, or nil when the
    store chain is broken (e.g. fixture data)



977
978
979
980
981
982
983
984
# File 'app/models/quote.rb', line 977

def set_currency
  store_currency = begin
    opportunity.customer.store.currency
  rescue StandardError
    nil
  end
  self.currency = store_currency if store_currency
end

#set_default_quote_typeObject (protected)



1687
1688
1689
1690
1691
1692
# File 'app/models/quote.rb', line 1687

def set_default_quote_type
  self.quote_type ||= prefix_for_reference_number
  return unless quote_type_changed? && reference_number.present?

  self.reference_number = "#{quote_type}#{reference_number[2..50]}"
end

#set_expiration_dateObject (protected)



1673
1674
1675
1676
1677
# File 'app/models/quote.rb', line 1673

def set_expiration_date
  return if complete?

  self.expiration_date = calculate_expiration_date
end

#set_min_profit_markupObject (protected)



1679
1680
1681
1682
1683
1684
1685
# File 'app/models/quote.rb', line 1679

def set_min_profit_markup
  self.min_profit_markup = if quote_type == 'SQ'
                             default_sales_markup
                           else
                             0
                           end
end

#set_opportunity_valuevoid

This method returns an undefined value.

after_commit hook that recomputes the parent Opportunity's
rolled-up dollar value when this quote's totals change.



1257
1258
1259
# File 'app/models/quote.rb', line 1257

def set_opportunity_value
  opportunity&.calculate_value
end

#set_priority(save = true) ⇒ String, true

Computes the highest urgency among the quote's room
configurations and writes it onto priority (urgent, rush,
standard, etc.). Drives badge colour in the rep queue.

Parameters:

  • save (Boolean) (defaults to: true)

    when true, persists immediately via
    update_attribute (skipping validations); when false, leaves
    the change in memory for the caller to save with the rest of
    the record

Returns:

  • (String, true)

    depends on save



995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
# File 'app/models/quote.rb', line 995

def set_priority(save = true)
  highest_priority = begin
    RoomConfiguration::PRIORITIES[room_configurations.map(&:priority_urgency_factor).min]
  rescue StandardError
    'standard'
  end
  if save
    update_attribute(:priority, highest_priority)
  else
    self.priority = highest_priority
  end
end

#set_reference_numberString

before_create callback that stamps #reference_number from the
Postgres sequence unless the caller (e.g. data import) has already
supplied one.

Returns:

  • (String)


1377
1378
1379
# File 'app/models/quote.rb', line 1377

def set_reference_number
  self.reference_number ||= get_next_reference_number
end

#shipping_address_nil_or_valid?Boolean

True when the quote either has no shipping address or has one
that passes its own validations — used as a precondition before
transitioning to states that require shippable goods.

Returns:

  • (Boolean)


796
797
798
# File 'app/models/quote.rb', line 796

def shipping_address_nil_or_valid?
  shipping_address.nil? || shipping_address.valid?
end

#smartsupport_infoHash?

SmartSupport metadata bundle (line items eligible for the program,
estimated visit time, etc.) when the install location is within
service range; nil otherwise.

Returns:

  • (Hash, nil)


1513
1514
1515
1516
1517
# File 'app/models/quote.rb', line 1513

def smartsupport_info
  return nil if service_distance.nil?

  smartsupport_data(service_distance)
end

#sms_enabled_numbersArray<String>

Distinct SMS-capable numbers for every participant on this quote's
opportunity, formatted for the SMS gateway. Drives the recipient
list when the rep texts an update from the quote page.

Returns:

  • (Array<String>)

    E.164-style numbers



727
728
729
# File 'app/models/quote.rb', line 727

def sms_enabled_numbers
  ContactPoint.joins(:party).merge(all_participants).sms_numbers.order(:detail).map(&:formatted_for_sms).uniq
end

#sms_messagesActiveRecord::Relation<SmsMessage>

Two-way SMS history for #sms_enabled_numbers — the message
thread shown on the quote sidebar.

Returns:



735
736
737
# File 'app/models/quote.rb', line 735

def sms_messages
  SmsMessage.for_numbers(sms_enabled_numbers)
end

#sold?Boolean

True once at least one non-cart, non-cancelled Order exists
against this quote — i.e. the customer accepted and we
transmitted.

Returns:

  • (Boolean)


1114
1115
1116
# File 'app/models/quote.rb', line 1114

def sold?
  orders.non_carts.active.present?
end

#special_ordering_instructionsString?

Free-text "special instructions" stored on the billing-address
party — printed on the warehouse pick ticket when present (e.g.
"no liftgate", "call before delivery"). Swallows nil-chain errors
for headless test fixtures.

Returns:

  • (String, nil)


964
965
966
967
968
# File 'app/models/quote.rb', line 964

def special_ordering_instructions
  customer.billing_address.party.ordering_instructions
rescue StandardError
  nil
end

#stop_for_pre_pack?Boolean

State-machine guard — true when transmission must wait for the
warehouse to provide an estimated-packaging quote. Skipped for
e-commerce, no-shipping-address, empty-line, or already-packed
cases. The list of reasons (need_to_pre_pack_reasons) keeps the
rep apprised of why the quote is parked.

Returns:

  • (Boolean)


1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
# File 'app/models/quote.rb', line 1629

def stop_for_pre_pack?
  return false unless can_request_estimated_packaging?
  return false if shipping_address.nil?
  return false if customer.is_e_commerce_misc?
  return false if line_items.empty?
  return false if room_configurations.present? && !room_configurations.all?(&:transmittable_and_settled?)
  return false if deliveries.all? { |d| (d.shipments.packed.any? && d.all_lines_allocated_to_shipments?) || d.is_smart_service? }

  need_to_pre_pack_reasons.any?
end

#stop_for_profit_review?Boolean

State-machine guard — true when transmission must pause for
margin desk review. E-commerce miscellaneous customers are
exempt; rooms-mid-design and empty-line-item quotes don't trigger
it either.

Returns:

  • (Boolean)


1605
1606
1607
1608
1609
1610
1611
1612
# File 'app/models/quote.rb', line 1605

def stop_for_profit_review?
  # Etailer orders are immune
  return false if customer.is_e_commerce_misc?
  return false if line_items.empty?
  return false if room_configurations.present? && !room_configurations.all?(&:transmittable?)

  !profit_margins_met?
end

#storeObject

Alias for Customer#store

Returns:

  • (Object)

    Customer#store

See Also:



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

delegate    :store, to: :customer

#support_casesActiveRecord::Relation<SupportCase>

Returns:

See Also:



190
# File 'app/models/quote.rb', line 190

has_and_belongs_to_many :support_cases

#tier2_program_pricingDiscount?

The tier2 (Discount) row representing the customer's
contractor/dealer pricing program on this quote.

Returns:



1413
1414
1415
# File 'app/models/quote.rb', line 1413

def tier2_program_pricing
  discounts.tier2.first
end

#tier2_program_pricing_couponCoupon?

The Coupon that powers #tier2_program_pricing, when there is
one — used by #pricing_program_description and
#pricing_program_discount to label the program.

Returns:



1422
1423
1424
# File 'app/models/quote.rb', line 1422

def tier2_program_pricing_coupon
  tier2_program_pricing&.coupon
end

#to_liquidLiquid::QuoteDrop

Liquid drop wrapper used when this quote is rendered into the
transmission email/SMS templates — exposes only the safe-for-template
accessors via Liquid::QuoteDrop.

Returns:



454
455
456
# File 'app/models/quote.rb', line 454

def to_liquid
  Liquid::QuoteDrop.new self
end

#to_order(order = nil, txid = nil) ⇒ Array<Order>

Convert this quote into one or more Orders via the
ConvertToOrder service. Optionally seeds with an
existing order (e.g. a cart already created in the e-commerce
flow) and a transaction id used to dedupe webhook retries.

Parameters:

  • order (Order, nil) (defaults to: nil)

    existing order to populate instead of
    creating a fresh one

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

    idempotency token from the upstream
    payment/webhook

Returns:

  • (Array<Order>)

    the resulting order(s)



1105
1106
1107
# File 'app/models/quote.rb', line 1105

def to_order(order = nil, txid = nil)
  convert_to_order_service.convert(order, txid)
end

#to_sString

Display string used by Rails helpers and inspectors —
"Quote # SQ12345 - Smith Master Bath".

Returns:

  • (String)


1265
1266
1267
# File 'app/models/quote.rb', line 1265

def to_s
  "Quote # #{reference_number_with_name}"
end

#total_ampsFloat

Sum of every room's calculated electrical load, in amps. Used by
the controls/breaker recommendation engine.

Returns:

  • (Float)


1585
1586
1587
# File 'app/models/quote.rb', line 1585

def total_amps
  room_configurations.to_a.sum { |rc| rc.calculate_total_amps.round(2) }&.round(2)
end

#track_profit?Boolean

Whether this quote should run through the profit-margin gate —
only sales quotes with at least one profitable line item are
subject to the profit-review state.

Returns:

  • (Boolean)


1577
1578
1579
# File 'app/models/quote.rb', line 1577

def track_profit?
  profitable_line_items.present? && is_sales_quote?
end

#tracking_email_addressString

Encrypted plus-addressed inbox alias used as the Reply-To on
quote emails — replies bounce back into Heatwave and are
auto-attached to this quote's communication thread.

Returns:

  • (String)


1283
1284
1285
1286
1287
1288
# File 'app/models/quote.rb', line 1283

def tracking_email_address
  domain = Rails.application.config.x.email_domain
  prefix = 'quo'
  encrypted_id = Encryption.encrypt_string(id.to_s)
  "#{prefix}+id#{encrypted_id}@#{domain}"
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



182
# File 'app/models/quote.rb', line 182

has_many    :uploads, as: :resource, dependent: :destroy

#versions_for_audit_trail(_params = {}) ⇒ ActiveRecord::Relation<RecordVersion>

PaperTrail versions for the quote and every record that hangs off
it — its line items, deliveries, and discounts — pulled in a
single query via JSONB reference_data lookup. Powers the
quote's "audit trail" tab in the CRM.

Returns:



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
604
# File 'app/models/quote.rb', line 579

def versions_for_audit_trail(_params = {})
  query_sql = %q{
                (item_type = 'Quote' AND item_id = :id)
                OR (
                  item_type = 'LineItem'
                    AND reference_data @> '{"resource_type": "Quote"}'
                    AND reference_data @> :resource_id_json
                )
                OR (
                  item_type = 'Delivery'
                    AND reference_data @> :quote_id_json
                )
                OR (
                  item_type = 'Discount'
                    AND reference_data @> :itemizable_id_json
                    AND reference_data @> '{"itemizable_type": "Quote"}'
                )
              }
  RecordVersion.where(
    query_sql,
    id:,
    resource_id_json: { resource_id: id }.to_json,
    quote_id_json: { quote_id: id }.to_json,
    itemizable_id_json: { itemizable_id: id }.to_json
  )
end

#visitVisit

Returns:

See Also:



178
# File 'app/models/quote.rb', line 178

belongs_to  :visit, optional: true

#will_complete?Boolean

State-machine guard — true when the quote has every gate cleared
for a complete transition: at least one line item, valid
shipping address (or none), all rooms settled, and profit margins
met.

Returns:

  • (Boolean)


632
633
634
635
636
637
# File 'app/models/quote.rb', line 632

def will_complete?
  line_items.any? &&
    shipping_address_nil_or_valid? &&
    all_rooms_complete_or_cancelled_or_draft? &&
    profit_margins_met?
end

#will_installation_plans_complete?Boolean

State-machine guard for the installation_plans_complete event —
rooms must exist and all be settled, and at least one line item
must be on the quote.

Returns:

  • (Boolean)


644
645
646
# File 'app/models/quote.rb', line 644

def will_installation_plans_complete?
  room_configurations.exists? && all_rooms_complete_or_cancelled_or_draft? && line_items.present?
end

#will_pending_project_details?Boolean

State-machine guard for the pending_project_details event —
we're either coming from awaiting-design, or there are no line
items but at least one draft room.

Returns:

  • (Boolean)


684
685
686
687
# File 'app/models/quote.rb', line 684

def will_pending_project_details?
  awaiting_completed_installation_plans? ||
    (line_items.empty? && room_configurations.any?(&:draft?))
end

#will_ready_for_design?Boolean

State-machine guard for the ready_for_design event — at least
one room exists and at least one is currently in design.

Returns:

  • (Boolean)


652
653
654
# File 'app/models/quote.rb', line 652

def will_ready_for_design?
  room_configurations.exists? && any_rooms_in_design?
end

#will_ready_to_transmit?Boolean

State-machine guard for the ready_to_transmit event — line
items exist, shipping is valid, margins are met, and the warehouse
isn't asking for a pre-pack first.

Returns:

  • (Boolean)


661
662
663
664
665
666
667
# File 'app/models/quote.rb', line 661

def will_ready_to_transmit?
  line_items.any? &&
    shipping_address_nil_or_valid? &&
    profit_margins_met? &&
    !stop_for_pre_pack? &&
    (room_configurations.empty? || room_configurations.all?(&:transmittable?))
end

#zonesnil

Placeholder for a planned "zones" concept (groups of rooms
controlled by a single thermostat). Not implemented — left as a
stub because the schema and view code expect the method to exist.

Returns:

  • (nil)


873
874
875
876
# File 'app/models/quote.rb', line 873

def zones
  # Zones are combinations of room configurations which are controlled together and regular rooms
  # Ramie: I'd say this is an unfortunate choice of name. we already have a well developed concept of room zones, rectangular areas within a room that are heateded areas, along with the model, methods and etc.
end