Class: Opportunity

Inherits:
ApplicationRecord show all
Includes:
Memery, Models::Auditable, Models::LiquidMethods, Models::Notable, Models::SourceAttributable, PgSearch::Model
Defined in:
app/models/opportunity.rb

Overview

== Schema Information

Table name: opportunities
Database name: primary

id :integer not null, primary key
close_date :date
discount_percentage :decimal(10, 2)
electricity_rate :decimal(6, 3)
google_conversion_meta :jsonb
installation_postal_code :string(255)
latitude :decimal(9, 6)
legacy_awaiting_iq_transmission :boolean
longitude :decimal(9, 6)
lost_reason_code :string(255)
lost_reason_notes :text
media :string(255)
msrp_value :decimal(10, 2)
name :string(80) not null
opportunity_reception_type :string(255) default("CRM"), not null
opportunity_type :string(1) default("S"), not null
pinterest_conversion_meta :jsonb
planned_installation_date :datetime
primary_product_line_name :string(255)
purchase_outlet :string(255)
purchased_competitor :string(255)
purchased_system :string(255)
quote_underlayment_by_room :boolean
reference_number :string
specs_eta :datetime
state :string(255)
state_code :string(2)
summary :string(240)
value :decimal(10, 2)
won_lost_date :datetime
created_at :datetime
updated_at :datetime
account_specialist_id :integer
buying_group_id :integer
contact_id :integer
creator_id :integer
current_heat_loss_room_id :integer
customer_id :integer not null
local_sales_rep_id :integer
original_sales_rep_id :integer
primary_sales_rep_id :integer
sales_support_rep_id :integer
secondary_sales_rep_id :integer
source_id :integer
technical_support_rep_id :integer
technical_support_rep_sec_id :integer
updater_id :integer
visit_id :bigint

Indexes

idx_tsearch_opp_name (to_tsvector('hw_name_search'::regconfig, COALESCE((name)::text, ''::text))) USING gin
index_opportunities_created_at (created_at)
index_opportunities_on_contact_id (contact_id)
index_opportunities_on_current_heat_loss_room_id (current_heat_loss_room_id)
index_opportunities_on_google_conversion_meta (google_conversion_meta) USING gin
index_opportunities_on_legacy_awaiting_iq_transmission (legacy_awaiting_iq_transmission)
index_opportunities_on_opportunity_type (opportunity_type)
index_opportunities_on_quote_underlayment_by_room (quote_underlayment_by_room)
index_opportunities_on_sales_support_rep_id (sales_support_rep_id)
index_opportunities_on_source_id (source_id)
index_opportunities_on_technical_support_rep_sec_id (technical_support_rep_sec_id)
index_opportunities_on_visit_id (visit_id) WHERE (visit_id IS NOT NULL) USING hash
opportunities_customer_id_index (customer_id)
opportunities_state_index (state)
opportunities_technical_support_rep_id_idx (technical_support_rep_id)

Foreign Keys

fk_rails_... (current_heat_loss_room_id => room_configurations.id) ON DELETE => nullify
fk_rails_... (source_id => sources.id)
fk_rails_... (technical_support_rep_id => parties.id)
fk_rails_... (visit_id => visits.id) ON DELETE => nullify

Defined Under Namespace

Classes: Copier, Merger, Mover

Constant Summary collapse

REFERENCE_NUMBER_PATTERN =
/^ON\d+$/i
UNKNOWN_JOB_NAME_PREFIX =
'Job'

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Has and belongs to many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::SourceAttributable

#has_google_ads_attribution?, #source_locked?, #source_locked_reason, #visit_with_google_click_id?

Methods included from Models::Notable

#quick_note

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#create_oppfuObject

Returns the value of attribute create_oppfu.



181
182
183
# File 'app/models/opportunity.rb', line 181

def create_oppfu
  @create_oppfu
end

#electricity_rateObject (readonly)

DB column is numeric(6,3) — guard before PG::NumericValueOutOfRange (AppSignal #4498)

Validations:

  • Numericality ({ greater_than_or_equal_to: 0, less_than: 1000, allow_nil: true })


139
140
# File 'app/models/opportunity.rb', line 139

validates :electricity_rate,
numericality: { greater_than_or_equal_to: 0, less_than: 1000, allow_nil: true }

#msrp_valueObject (readonly)



136
137
# File 'app/models/opportunity.rb', line 136

validates :msrp_value,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true }

#must_have_one_roomObject

Returns the value of attribute must_have_one_room.



180
181
182
# File 'app/models/opportunity.rb', line 180

def must_have_one_room
  @must_have_one_room
end

#nameObject (readonly)



128
# File 'app/models/opportunity.rb', line 128

validates :name, presence: true, length: { within: 1..80 }

#opportunity_typeObject (readonly)



132
# File 'app/models/opportunity.rb', line 132

validates :opportunity_type, inclusion: { in: OpportunityConstants::OPPORTUNITY_TYPES.keys }

#summaryObject (readonly)



130
# File 'app/models/opportunity.rb', line 130

validates :summary, length: { within: 1..240 }, if: -> { summary.present? }

#valueObject (readonly)

DB column is numeric(10,2) — guard before PG::NumericValueOutOfRange (AppSignal #4498)

Validations:

  • Numericality ({ greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true })


134
135
# File 'app/models/opportunity.rb', line 134

validates :value,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true }

Class Method Details

.activeActiveRecord::Relation<Opportunity>

A relation of Opportunities that are active. Active Record Scope

Returns:

See Also:



159
# File 'app/models/opportunity.rb', line 159

scope :active, -> { open_opportunities.with_quotes_and_rooms }

.all_competitorsObject



517
518
519
# File 'app/models/opportunity.rb', line 517

def self.all_competitors
  OpportunityConstants::COMPETITOR_SYSTEM.values.flatten.compact.uniq.sort
end

.awaiting_iq_transmissionActiveRecord::Relation<Opportunity>

A relation of Opportunities that are awaiting iq transmission. Active Record Scope

Returns:

See Also:



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

scope :awaiting_iq_transmission, -> { where(legacy_awaiting_iq_transmission: true) }

.fix_postal_codesObject



509
510
511
512
513
514
515
# File 'app/models/opportunity.rb', line 509

def self.fix_postal_codes
  opps = Opportunity.where.not(installation_postal_code: nil).pluck(:id, :installation_postal_code)
  opps.each do |r|
    pc = r[1].scan(/^\d{5}/)&.first || r[1].scan(/([A-Za-z]\d[A-Za-z]) ?(\d[A-Za-z]\d)/)&.first&.join&.upcase
    Opportunity.where(id: r[0]).update_all(installation_postal_code: pc) if pc && pc != r[1]
  end
end

.format_address_for_zip_selection(address) ⇒ Object



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

def self.format_address_for_zip_selection(address)
  "#{address.zip_compact} - #{address.city} #{address.state}"
end

.get_unique_name(party, name) ⇒ Object



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'app/models/opportunity.rb', line 565

def self.get_unique_name(party, name)
  # new_name
  logger.info "self.get_unique_name(party,name): party.name: #{party.name}"
  logger.info "self.get_unique_name(party,name): name: #{name}"
  name_root = name.split(' #').first
  same_name_opps = party.opportunities.where('name LIKE ?', "#{name_root}%")
  if same_name_opps.any?
    same_name_opp_count = same_name_opps.length + 1
    new_opp_name = nil
    loop do
      new_opp_name = "#{name_root} ##{same_name_opp_count}"
      same_name_opp_count += 1
      break unless party.opportunities.exists?(name: new_opp_name)
    end
  else
    new_opp_name = name
  end
  logger.info "self.get_unique_name(party,name): new_opp_name: #{new_opp_name}"
  new_opp_name
end

.instant_quotingActiveRecord::Relation<Opportunity>

A relation of Opportunities that are instant quoting. Active Record Scope

Returns:

See Also:



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

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

.lostActiveRecord::Relation<Opportunity>

A relation of Opportunities that are lost. Active Record Scope

Returns:

See Also:



161
# File 'app/models/opportunity.rb', line 161

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

.lost_or_abandonedActiveRecord::Relation<Opportunity>

A relation of Opportunities that are lost or abandoned. Active Record Scope

Returns:

See Also:



162
# File 'app/models/opportunity.rb', line 162

scope :lost_or_abandoned, -> { where(state: %w[lost abandoned]) }

.most_recent_firstActiveRecord::Relation<Opportunity>

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

Returns:

See Also:



155
# File 'app/models/opportunity.rb', line 155

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

.not_cancelledActiveRecord::Relation<Opportunity>

A relation of Opportunities that are not cancelled. Active Record Scope

Returns:

See Also:



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

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

.not_won_or_lostActiveRecord::Relation<Opportunity>

A relation of Opportunities that are not won or lost. Active Record Scope

Returns:

See Also:



158
# File 'app/models/opportunity.rb', line 158

scope :not_won_or_lost, -> { where.not(state: %w[won lost]) }

.open_for_iqActiveRecord::Relation<Opportunity>

A relation of Opportunities that are open for iq. Active Record Scope

Returns:

See Also:



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

scope :open_for_iq, -> { not_cancelled.with_quotes_and_rooms }

.open_opportunitiesActiveRecord::Relation<Opportunity>

A relation of Opportunities that are open opportunities. Active Record Scope

Returns:

See Also:



157
# File 'app/models/opportunity.rb', line 157

scope :open_opportunities, -> { where.not(state: %w[won lost abandoned cancelled untracked]) }

.opportunity_reception_types_for_selectObject



529
530
531
# File 'app/models/opportunity.rb', line 529

def self.opportunity_reception_types_for_select
  OpportunityConstants::RECEPTION_TYPES.map { |rt| [rt, rt] }
end

.opportunity_types_for_selectObject



525
526
527
# File 'app/models/opportunity.rb', line 525

def self.opportunity_types_for_select
  OpportunityConstants::OPPORTUNITY_TYPES.map { |k, v| [v, k] }
end

.started_as_iq_projectsActiveRecord::Relation<Opportunity>

A relation of Opportunities that are started as iq projects. Active Record Scope

Returns:

See Also:



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

scope :started_as_iq_projects, -> { where(opportunity_reception_type: 'IQ') }

.states_for_selectObject



521
522
523
# File 'app/models/opportunity.rb', line 521

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

.with_quotes_and_roomsActiveRecord::Relation<Opportunity>

A relation of Opportunities that are with quotes and rooms. Active Record Scope

Returns:

See Also:



156
# File 'app/models/opportunity.rb', line 156

scope :with_quotes_and_rooms, -> { includes(:quotes, :room_configurations).most_recent_first }

.with_valueActiveRecord::Relation<Opportunity>

A relation of Opportunities that are with value. Active Record Scope

Returns:

See Also:



163
# File 'app/models/opportunity.rb', line 163

scope :with_value, -> { where.not(value: nil) }

.wonActiveRecord::Relation<Opportunity>

A relation of Opportunities that are won. Active Record Scope

Returns:

See Also:



160
# File 'app/models/opportunity.rb', line 160

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

Instance Method Details

#abandonable?Boolean

Returns:

  • (Boolean)


475
476
477
478
479
# File 'app/models/opportunity.rb', line 475

def abandonable?
  related_activities.open_activities.blank? &&
    (interest? || qualify? || follow_up?) &&
    !sales_present?
end

#abandonment_dateObject



489
490
491
# File 'app/models/opportunity.rb', line 489

def abandonment_date
  last_movement.to_date + 60.days
end

#account_specialistEmployee

Returns:

See Also:



103
# File 'app/models/opportunity.rb', line 103

belongs_to :account_specialist, class_name: 'Employee', optional: true

#activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



113
# File 'app/models/opportunity.rb', line 113

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

#add_customer_to_campaignObject



399
400
401
# File 'app/models/opportunity.rb', line 399

def add_customer_to_campaign
  source.add_customer_to_campaign(customer)
end

#all_activitiesObject



425
426
427
# File 'app/models/opportunity.rb', line 425

def all_activities
  linked_activities
end

#all_participantsObject



927
928
929
# File 'app/models/opportunity.rb', line 927

def all_participants
  Party.where(id: [customer_id, contact_id, *opportunity_participants.pluck(:party_id)].compact.uniq)
end


412
413
414
415
416
417
418
419
420
421
422
423
# File 'app/models/opportunity.rb', line 412

def all_related_communications
  # Use UNION for efficient single-query execution with index usage
  # Previous approach made 5 separate queries (4 plucks + 1 where)
  order_query = Communication.where(resource_type: 'Order', resource_id: order_ids)
  quote_query = Communication.where(resource_type: 'Quote', resource_id: quote_ids)
  room_query = Communication.where(resource_type: 'RoomConfiguration', resource_id: room_configuration_ids)
  opp_query = Communication.where(resource_type: 'Opportunity', resource_id: id)

  Communication.from(
    "(#{order_query.to_sql} UNION #{quote_query.to_sql} UNION #{room_query.to_sql} UNION #{opp_query.to_sql}) AS communications"
  )
end

#all_repsObject



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

def all_reps
  Employee.where(id: all_reps_ids)
end

#all_reps_idsObject



931
932
933
934
935
936
# File 'app/models/opportunity.rb', line 931

def all_reps_ids
  [
    primary_sales_rep_id, secondary_sales_rep_id, local_sales_rep_id, , technical_support_rep_id,
    customer.primary_sales_rep_id, customer.secondary_sales_rep_id, customer.service_rep_id
  ].compact.uniq
end

#all_uploadsObject



387
388
389
390
391
# File 'app/models/opportunity.rb', line 387

def all_uploads
  Upload.where("(resource_type = 'Opportunity' and resource_id = ?) OR (resource_type = 'RoomConfiguration' and resource_id IN (?))", id, room_configuration_ids)
        .includes(:resource)
        .order('uploads.created_at DESC')
end

#build_activityObject



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

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

#buying_groupBuyingGroup



108
# File 'app/models/opportunity.rb', line 108

belongs_to :buying_group, optional: true

#calculate_valueObject



762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
# File 'app/models/opportunity.rb', line 762

def calculate_value
  return if destroyed?

  # Value is calculated based on order, quote then rooms
  discounted_value = nil
  msrp_value = nil
  discount_percentage = nil
  sales_orders = orders.sales_orders
  sales_quotes = quotes.sales_quotes.active

  if sales_orders.present? && (discounted_value = sales_orders.sum(:line_total)).positive?
    msrp_value = LineItem.where(resource_type: 'Order', resource_id: sales_orders.select(:id))
                         .non_shipping.sum('quantity * price')
    discount_percentage = msrp_value.zero? ? 0.0 : ((1.0 - (discounted_value / msrp_value)) * 100)
  elsif (top_quote = sales_quotes.where.not(line_total: nil).order(line_total: :desc).first) &&
        (discounted_value = top_quote.line_total).positive?
    msrp_value = LineItem.where(resource_type: 'Quote', resource_id: top_quote.id)
                         .non_shipping.sum('quantity * price')
    discount_percentage = msrp_value.zero? ? 0.0 : ((1.0 - (discounted_value / msrp_value)) * 100)
  end

  Opportunity.with_advisory_lock("opportunity_value_#{id}", timeout_seconds: 10) do
    update_columns value: discounted_value, msrp_value:, discount_percentage:
  end
end

#can_be_moved?Boolean

Returns:

  • (Boolean)


347
348
349
# File 'app/models/opportunity.rb', line 347

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

#cancel_followups(include_large_op_activities: false) ⇒ Object



710
711
712
713
714
715
716
717
# File 'app/models/opportunity.rb', line 710

def cancel_followups(include_large_op_activities: false)
  sales_activity_ids = ActivityType.sales_activities.pluck(:id)
  # NEW_LARGE_OP carries the 'sale' tag so it would already be in sales_activity_ids.
  # Explicitly remove it here so it is only cancelled on terminal opp transitions
  # (won/lost/abandoned/cancelled), where callers pass include_large_op_activities: true.
  sales_activity_ids.delete(ActivityTypeConstants::NEW_LARGE_OP_ID) unless include_large_op_activities
  prune_activities nil, sales_activity_ids
end

#cancel_open_quotesObject

Best-effort cancel of in-progress quotes when the opportunity is closed.
Skips quotes that aren't in a cancelable state (e.g. already complete or
blocked by active orders); the sync_state guard above prevents any
remaining in_quoting quotes from flipping the opportunity back open.



381
382
383
384
385
# File 'app/models/opportunity.rb', line 381

def cancel_open_quotes
  quotes.in_quoting.find_each do |q|
    q.cancel if q.cancelable?
  end
end

#cancel_oppfuObject



974
975
976
# File 'app/models/opportunity.rb', line 974

def cancel_oppfu
  activities.where(activity_type_id: oppfu_activity_type.id).find_each(&:cancel)
end

#cancelable?Boolean

Returns:

  • (Boolean)


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

def cancelable?
  OpportunityConstants::CANCELABLE_STATES.include?(state.to_sym) &&
    quotes.active.completed_quotes.empty? &&
    room_configurations.active.empty? &&
    orders.not_cancelled.empty?
end

#closed?Boolean

Returns:

  • (Boolean)


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

def closed?
  won? || lost? || cancelled? || abandoned?
end

#contactContact

Returns:

See Also:



99
# File 'app/models/opportunity.rb', line 99

belongs_to :contact, inverse_of: :opportunities, optional: true

#contact_select_optionsObject



662
663
664
665
666
# File 'app/models/opportunity.rb', line 662

def contact_select_options
  contacts_to_use = customer.contacts.active.to_a.map { |c| ["#{c.full_name}#{" (#{c.job_title})" if c.job_title.present?} ", c.id] }
  contacts_to_use << [contact.full_name, contact.id] if contact&.inactive
  contacts_to_use.sort
end

#contactable_for_follow_up?Boolean

Check if the opportunity can be followed up on.
Returns true if customer is not a guest OR if we have contact info (email/phone).

Returns:

  • (Boolean)


459
460
461
# File 'app/models/opportunity.rb', line 459

def contactable_for_follow_up?
  !customer.guest? || emailable? || voice_callable?
end

#create_follow_up_activity(follow_up_note: nil, current_user: nil, follow_up_activity_type: nil, follow_up_task_type: nil, target_date: nil) ⇒ Object



719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
# File 'app/models/opportunity.rb', line 719

def create_follow_up_activity(follow_up_note: nil, current_user: nil, follow_up_activity_type: nil, follow_up_task_type: nil, target_date: nil)
  return unless sales_opportunity?

  follow_up_activity_type ||= ActivityType.find_by(task_type: follow_up_task_type)
  follow_up_activity_type ||= ActivityType.find(ActivityTypeConstants::QUOFU)
  current_user ||= Employee.find_by(id: CurrentScope.user_id)

  assigned_rep = follow_up_activity_type.determine_assigned_resource(customer) || current_user || customer.sales_manager

  target_date ||= assigned_rep.next_business_day_closing_time
  follow_up_note ||= "Follow up on opportunity #{name}"
  new_activity_values = { activity_type: follow_up_activity_type,
                          target_datetime: target_date,
                          party: primary_party,
                          resource: self,
                          assigned_resource: assigned_rep,
                          new_note: follow_up_note }
  # Now save the new one
  Activity.create(new_activity_values)
end

#create_heat_loss_roomObject



797
798
799
800
801
802
803
804
805
806
807
808
# File 'app/models/opportunity.rb', line 797

def create_heat_loss_room
  # default to Bathroom, Tile/Stone on new room
  room = room_configurations.create(
    RoomConfiguration.default_attributes(customer.store.id).merge(
      name: 'New Room',
      reception_type: 'HeatLossCalculator',
      state: 'draft'
    )
  )
  update!(current_heat_loss_room: room)
  room
end


668
669
670
# File 'app/models/opportunity.rb', line 668

def crm_link
  UrlHelper.instance.opportunity_path(self)
end

#current_heat_loss_roomRoomConfiguration

Returns:

  • (RoomConfiguration)

See Also:



110
# File 'app/models/opportunity.rb', line 110

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

#customerCustomer

Returns:

See Also:

Validations:



107
# File 'app/models/opportunity.rb', line 107

belongs_to :customer, inverse_of: :opportunities, optional: true

#deep_dupObject



95
96
97
# File 'app/models/opportunity.rb', line 95

def deep_dup
  deep_clone(except: :reference_number)
end

#digital_assetsActiveRecord::Relation<DigitalAsset>

Returns:

See Also:



126
# File 'app/models/opportunity.rb', line 126

has_and_belongs_to_many :digital_assets, inverse_of: :opportunities

#editable?Boolean

Returns:

  • (Boolean)


654
655
656
# File 'app/models/opportunity.rb', line 654

def editable?
  %w[won lost cancelled abandoned].exclude?(state)
end

#effective_opportunity_won_dateObject



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

def effective_opportunity_won_date
  # Shipped date of order
  [orders.active.minimum(:shipped_date).try(:end_of_day), Time.current].compact.min
end

#emailable?Boolean

Returns:

  • (Boolean)


915
916
917
# File 'app/models/opportunity.rb', line 915

def emailable?
  emails.present?
end

#emailsObject



911
912
913
# File 'app/models/opportunity.rb', line 911

def emails
  primary_party.contact_points.emails
end

#estimate_electricity_rateObject



964
965
966
967
968
969
970
971
972
# File 'app/models/opportunity.rb', line 964

def estimate_electricity_rate
  res = nil
  # || quotes.most_recent_first.first&.shipping_address&.zip || customer.shipping_address&.zip || customer.mailing_address&.zip || customer.billing_address&.zip
  if (postal_code_to_use = installation_postal_code).present?
    rate_data = ElectricityRate.get_from_postal_code(postal_code_to_use)
    res = rate_data[:average_rate] if rate_data[:status] == :ok
  end
  res
end

#exclude_manually_initiated_event?(event) ⇒ Boolean

Returns:

  • (Boolean)


429
430
431
# File 'app/models/opportunity.rb', line 429

def exclude_manually_initiated_event?(event)
  %i[iq_advanced reset].include?(event.to_sym)
end

#find_gbraidObject



750
751
752
# File 'app/models/opportunity.rb', line 750

def find_gbraid
  visit&.gbraid || customer&.visit&.gbraid
end

#find_gclidObject



740
741
742
# File 'app/models/opportunity.rb', line 740

def find_gclid
  visit&.gclid || customer.gclid || customer&.visit&.gclid
end

#find_wbraidObject



745
746
747
# File 'app/models/opportunity.rb', line 745

def find_wbraid
  visit&.wbraid || customer&.visit&.wbraid
end

#heat_loss_roomObject



793
794
795
# File 'app/models/opportunity.rb', line 793

def heat_loss_room
  current_heat_loss_room || create_heat_loss_room
end

#in_canada?Boolean

Returns:

  • (Boolean)


810
811
812
# File 'app/models/opportunity.rb', line 810

def in_canada?
  installation_postal_code.present? && installation_country_iso3 == 'CAN'
end

#in_usa?Boolean

Returns:

  • (Boolean)


814
815
816
# File 'app/models/opportunity.rb', line 814

def in_usa?
  installation_postal_code.present? && installation_country_iso3 == 'USA'
end

#installation_country_isoObject



831
832
833
# File 'app/models/opportunity.rb', line 831

def installation_country_iso
  installation_country_iso3.first(2)
end

#installation_country_iso3Object



824
825
826
827
828
829
# File 'app/models/opportunity.rb', line 824

def installation_country_iso3
  customer&.store&.country_iso3 ||
    PostalCode.find_by(code: installation_postal_code)&.state&.country_iso3 ||
    { 'en-CA': 'CAN', 'en-US': 'USA' }[I18n.locale] ||
    'USA'
end

#installation_postal_codes_for_selectObject



982
983
984
985
986
987
988
989
990
991
992
# File 'app/models/opportunity.rb', line 982

def installation_postal_codes_for_select
  # get all addresses from customer
  postal_options = []
  postal_options += customer.all_addresses_including_contacts.map { |a| [Opportunity.format_address_for_zip_selection(a), a.zip_compact] }
  # Get addresses from any orders in this opportunity
  postal_options += orders.select(&:shipping_address).map { |o| [Opportunity.format_address_for_zip_selection(o.shipping_address), o.shipping_address.zip_compact] }
  # Get addresses from any quotes in this opportunity
  postal_options += quotes.select(&:shipping_address).map { |o| [Opportunity.format_address_for_zip_selection(o.shipping_address), o.shipping_address.zip_compact] }

  postal_options.uniq.sort
end

#installation_state_codeObject



818
819
820
821
822
# File 'app/models/opportunity.rb', line 818

def installation_state_code
  return if installation_postal_code.blank?

  PostalCode.get_state_code_from_postal_code(installation_postal_code)
end

#last_movementObject



485
486
487
# File 'app/models/opportunity.rb', line 485

def last_movement
  [planned_installation_date, last_quote_date, last_room_date, last_sales_activity_completion, created_at].compact.max
end

#last_quote_dateObject



497
498
499
# File 'app/models/opportunity.rb', line 497

def last_quote_date
  quotes.maximum(:created_at)
end

#last_room_dateObject



501
502
503
# File 'app/models/opportunity.rb', line 501

def last_room_date
  room_configurations.maximum(:created_at)
end

#last_sales_activity_completionObject



493
494
495
# File 'app/models/opportunity.rb', line 493

def last_sales_activity_completion
  related_activities.sales_activities.maximum(:completion_datetime)
end

#linked_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



124
# File 'app/models/opportunity.rb', line 124

has_many :linked_activities, class_name: 'Activity'

#local_sales_repEmployee

Returns:

See Also:



102
# File 'app/models/opportunity.rb', line 102

belongs_to :local_sales_rep, class_name: 'Employee', optional: true

#locationObject



840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
# File 'app/models/opportunity.rb', line 840

def location
  # See if we can find an address, take the most recent order with an address
  addr = nil
  if (o = orders.order(:id).last) && o.shipping_address
    addr = o.shipping_address
  elsif (q = quotes.order(:id).last) && q.shipping_address
    addr = q.shipping_address
  elsif (a = customer.try(:main_address))
    addr = a
  end

  if addr
    loc = addr.address_for_geocoder
  elsif (postal_code = installation_postal_code.presence)
    # Let's start with the user provided
    if (r = Geocoder.search(postal_code).first)
      loc = "#{r.city}, #{r.state_code} #{postal_code}"
    end
  elsif (v = visit || customer&.visit)
    loc = begin
      [v.city, v.region, v.postal_code].compact_blank.join(' ')
    rescue StandardError
      nil
    end
  end
  loc
end

#merge_with(opp_to_merge, delete_after: false) ⇒ Object



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

def merge_with(opp_to_merge, delete_after: false)
  opp_to_merge.room_configurations.each { |rc| rc.update(opportunity: self) }
  opp_to_merge.activities.each { |act| act.update(resource: self) }
  opp_to_merge.quotes.each { |quote| quote.update(opportunity: self) }
  opp_to_merge.reload
  opp_to_merge.destroy if delete_after == true
end

#next_room_name(options) ⇒ Object



672
673
674
675
676
677
678
679
680
681
682
683
684
685
# File 'app/models/opportunity.rb', line 672

def next_room_name(options)
  room_base_name = options[:room_base_name] || options[:room_type]&.name || 'Room'
  same_name_rooms = room_configurations.where('name LIKE ?', "#{room_base_name}%")
  same_name_room_count = same_name_rooms.length || 0
  return room_base_name if same_name_room_count.zero?

  new_room_name = nil
  loop do
    same_name_room_count += 1
    new_room_name = "#{room_base_name} ##{same_name_room_count}"
    break unless room_configurations.exists?(name: new_room_name)
  end
  new_room_name
end

#ok_to_customer_cancel?Boolean

Returns:

  • (Boolean)


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

def ok_to_customer_cancel?
  OpportunityConstants::CUSTOMER_CANCELABLE_STATES.include?(state.to_sym) && quotes.active.completed_quotes.empty? && room_configurations.active.empty? && orders.not_cancelled.empty?
end

#ok_to_delete?Boolean

Returns:

  • (Boolean)


695
696
697
# File 'app/models/opportunity.rb', line 695

def ok_to_delete?
  quotes.completed_quotes.empty? && room_configurations.non_drafts.empty? && orders.empty?
end

#open_activities_counterObject



596
597
598
# File 'app/models/opportunity.rb', line 596

def open_activities_counter
  related_activities.visible_by_default.open_activities.size
end

#open_opp_fu?Boolean

Returns:

  • (Boolean)


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

def open_opp_fu?
  activities.open_activities.joins(:activity_type).where(activity_types: { task_type: 'OPPFU' }).present?
end

#open_opportunity?Boolean

Returns:

  • (Boolean)


505
506
507
# File 'app/models/opportunity.rb', line 505

def open_opportunity?
  interest? || qualify? || quoting? || follow_up?
end

#opportunity_participantsActiveRecord::Relation<OpportunityParticipant>

Returns:

See Also:



122
# File 'app/models/opportunity.rb', line 122

has_many :opportunity_participants, inverse_of: :opportunity, dependent: :destroy, autosave: true

#opportunity_type_expandedObject



586
587
588
# File 'app/models/opportunity.rb', line 586

def opportunity_type_expanded
  OpportunityConstants::OPPORTUNITY_TYPES[opportunity_type]
end

#order_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



121
# File 'app/models/opportunity.rb', line 121

has_many :order_activities, class_name: 'Activity', through: :orders, source: :activities

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



116
# File 'app/models/opportunity.rb', line 116

has_many :orders, before_add: :set_default_for_order

#participants_options_for_selectObject



923
924
925
# File 'app/models/opportunity.rb', line 923

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

#partiesActiveRecord::Relation<Party>

Returns:

  • (ActiveRecord::Relation<Party>)

See Also:



123
# File 'app/models/opportunity.rb', line 123

has_many :parties, through: :opportunity_participants

#planned_installation_date_time_frameObject



942
943
944
945
946
947
948
949
950
# File 'app/models/opportunity.rb', line 942

def planned_installation_date_time_frame
  return nil if planned_installation_date.nil?

  time_diff = planned_installation_date - Time.current
  OpportunityConstants::PLANNED_INSTALLATION_DATE_TIME_FRAMES_IN_WEEKS.each do |k, v|
    return k if (time_diff > v.first.weeks) && (time_diff <= v.last.weeks)
  end
  nil
end

#planned_installation_date_time_frame=(val) ⇒ Object



952
953
954
955
956
957
958
959
960
961
962
# File 'app/models/opportunity.rb', line 952

def planned_installation_date_time_frame=(val)
  Rails.logger.debug { "planned_installation_date_time_frame= #{val.inspect}" }
  if val.present?
    week_range = OpportunityConstants::PLANNED_INSTALLATION_DATE_TIME_FRAMES_IN_WEEKS[val.to_sym]
    date = week_range.present? ? week_range.average.weeks.from_now : nil
    Rails.logger.debug { "planned_installation_date_time_frame= planned_installation_date: #{planned_installation_date}, week_range: #{week_range}" }
  else
    date = nil
  end
  update(planned_installation_date: date)
end

#primary_partyObject



658
659
660
# File 'app/models/opportunity.rb', line 658

def primary_party
  contact || customer
end

#primary_sales_repEmployee

Returns:

See Also:



100
# File 'app/models/opportunity.rb', line 100

belongs_to :primary_sales_rep, class_name: 'Employee', optional: true

#product_lines(reviewable_only: true) ⇒ Object

We will gather all product lines either ordered or quoted or in the rooms



870
871
872
873
874
875
876
877
878
879
880
881
882
# File 'app/models/opportunity.rb', line 870

def product_lines(reviewable_only: true)
  pls = if (o = orders.last)
          LineItem.collect_product_lines(o.line_items.goods)
        elsif (q = quotes.last)
          LineItem.collect_product_lines(q.line_items.goods)
        else
          LineItem.collect_product_lines(room_configurations.map(&:line_items).flatten)
        end
  pls ||= []
  # Only collect first reviewable
  pls = pls.map(&:get_first_reviewable) if reviewable_only
  pls.compact.uniq
end

#prune_activities(exclude_activity_id = nil, activity_type_ids = nil) ⇒ Object

This function looks for a particular activity in the context of the opportunity and cancel any found except the excluded one.



635
636
637
638
639
640
641
642
643
# File 'app/models/opportunity.rb', line 635

def prune_activities(exclude_activity_id = nil, activity_type_ids = nil)
  activity_type_ids ||= ActivityTypeConstants::QUOFUS_IDS
  activities_to_prune = related_activities.open_activities.where(activity_type_id: activity_type_ids)
  activities_to_prune = activities_to_prune.where.not(id: exclude_activity_id) if exclude_activity_id.present?
  activities_to_prune.each do |a|
    logger.info "Pruning and closing activity #{a.id}"
    a.cancel
  end
end

#qualified_opportunity?Boolean

Returns:

  • (Boolean)


467
468
469
# File 'app/models/opportunity.rb', line 467

def qualified_opportunity?
  customer && !customer.guest? && qualified_room_present?
end

#qualified_room_present?Boolean

Returns:

  • (Boolean)


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

def qualified_room_present?
  room_configurations.where.not(state: %w[draft instant_quoting cancelled]).present?
end

#quote_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



120
# File 'app/models/opportunity.rb', line 120

has_many :quote_activities, class_name: 'Activity', through: :quotes, source: :activities

#quotesActiveRecord::Relation<Quote>

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



115
# File 'app/models/opportunity.rb', line 115

has_many :quotes, dependent: :destroy, before_add: :set_default_for_quote

#quotes_completed?Boolean

Returns:

  • (Boolean)


445
446
447
# File 'app/models/opportunity.rb', line 445

def quotes_completed?
  quotes.completed_quotes.present?
end

#quoting_event?Boolean

Returns:

  • (Boolean)


463
464
465
# File 'app/models/opportunity.rb', line 463

def quoting_event?
  quotes.in_quoting.present? || room_configurations.in_quoting.present?
end

#ready_for_follow_up?Boolean

Ready for follow-up requires completed quotes AND a way to contact the customer.
This prevents Quote Builder opportunities from anonymous users (guest customers
with no contact info) from transitioning to follow_up state where they would
create noise in the CRM with no way for sales to action them.

Returns:

  • (Boolean)


453
454
455
# File 'app/models/opportunity.rb', line 453

def ready_for_follow_up?
  quotes_completed? && contactable_for_follow_up?
end

#recalculate_customer_profiling_informationObject



600
601
602
603
604
# File 'app/models/opportunity.rb', line 600

def recalculate_customer_profiling_information
  # This will put it in the queue for later
  customer.recalculate_profiling_information
  true
end


630
631
632
# File 'app/models/opportunity.rb', line 630

def related_activities
  Activity.where(id: related_activities_ids)
end


626
627
628
# File 'app/models/opportunity.rb', line 626

def related_activities_ids
  [activities.pluck(:id), room_activities.pluck(:id), quote_activities.pluck(:id)].flatten
end

#reset_rooms_last_heat_lossObject



835
836
837
838
# File 'app/models/opportunity.rb', line 835

def reset_rooms_last_heat_loss
  room_configurations.update_all(last_heat_loss: nil)
  true
end

#room_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



119
# File 'app/models/opportunity.rb', line 119

has_many :room_activities, class_name: 'Activity', through: :room_configurations, source: :activities

#room_configurationsActiveRecord::Relation<RoomConfiguration>

Returns:

  • (ActiveRecord::Relation<RoomConfiguration>)

See Also:

Validations (if => #must_have_one_room ):



114
# File 'app/models/opportunity.rb', line 114

has_many :room_configurations, dependent: :destroy, inverse_of: :opportunity

#room_uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



118
# File 'app/models/opportunity.rb', line 118

has_many :room_uploads, class_name: 'Upload', through: :room_configurations, source: :uploads

#room_without_installation_record?(room = nil) ⇒ Boolean

Returns:

  • (Boolean)


590
591
592
593
594
# File 'app/models/opportunity.rb', line 590

def room_without_installation_record?(room = nil)
  rooms = room_configurations.where(installation_date: nil)
  rooms = rooms.where.not(id: room.id) if room.present?
  rooms.any?
end

#sales_opportunity?Boolean

Returns:

  • (Boolean)


437
438
439
# File 'app/models/opportunity.rb', line 437

def sales_opportunity?
  opportunity_type == 'S'
end

#sales_present?Boolean

Returns:

  • (Boolean)


441
442
443
# File 'app/models/opportunity.rb', line 441

def sales_present?
  orders.non_carts.so_only.active.present?
end

#sales_support_repEmployee

Returns:

See Also:



106
# File 'app/models/opportunity.rb', line 106

belongs_to :sales_support_rep, class_name: 'Employee', optional: true

#schedule_interest_activityObject

This method is responsible for creating the relevant activity based on the
opportunity flow for interest grade type of opportunities. The type of activity
scheduled is based on the contact point presents and the presence of a cart
INTEREST_CALL - If Phone only present
INTEREST_EMAIL - If Email preesnt



890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
# File 'app/models/opportunity.rb', line 890

def schedule_interest_activity
  # Do we have an email on this party?
  if emailable?
    task_type = 'INTEREST_EMAIL'
  elsif voice_callable? # Can We Call?
    task_type = 'INTEREST_CALL'
  else
    # Should probably abandon then
    return false
  end

  # Do we already have an open activity of this type? if so skip
  return false if primary_party.activities.open_activities.joins(:activity_type).where(activity_types: { task_type: }).present?

  new_activity = primary_party.activities.new(activity_type: ActivityType.find_by(task_type:))
  new_activity.resource = self
  new_activity.auto_assign_and_schedule
  new_activity.save
  new_activity
end

#secondary_sales_repEmployee

Returns:

See Also:



101
# File 'app/models/opportunity.rb', line 101

belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true

#selection_nameObject



649
650
651
652
# File 'app/models/opportunity.rb', line 649

def selection_name
  base = "#{reference_number} - #{name}"
  customer&.full_name.present? ? "#{base} - #{customer.full_name}" : base
end

#send_online_email_confirmationObject



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# File 'app/models/opportunity.rb', line 606

def send_online_email_confirmation
  return unless opportunity_reception_type == 'Online' && customer&.email.present?

  sender = customer.primary_sales_rep
  co = CommunicationBuilder.new(
    resource: self,
    sender_party: sender,
    sender: (sender.nil? ? INFO_EMAIL : nil),
    recipient_party: customer,
    emails: customer.email,
    recipient_name: customer.name,
    template_system_code: 'OPPORTUNITY_RECEIVED'
  ).create
  if co.draft?
    { status_code: :error, status_message: co.errors_to_s }
  else
    { status_code: :ok, status_message: "Opportunity confirmation e-mail sent to #{customer.name} #{customer.email}." }
  end
end

#set_default_for_order(new_order) ⇒ Object (protected)



1021
1022
1023
1024
# File 'app/models/opportunity.rb', line 1021

protected def set_default_for_order(new_order)
  new_order.order_type = "#{opportunity_type}O"
  new_order
end

#set_default_for_quote(new_quote) ⇒ Object (protected)



1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
# File 'app/models/opportunity.rb', line 1008

protected def set_default_for_quote(new_quote)
  new_quote.validate_opportunity_and_prepared_for = true # TBD refactor
  new_quote.shipping_address = customer.shipping_address
  new_quote.shipping_method = customer.default_shipping_option_name
  new_quote.bill_shipping_to_customer = customer.bill_shipping_to_customer
  new_quote.pricing_program_description = customer.pricing_program_description
  new_quote.pricing_program_discount = customer.pricing_program_discount
  new_quote.currency = customer.catalog.currency
  new_quote.total = nil
  new_quote.tax_total = nil
  new_quote
end

#should_abandon?Boolean

Returns:

  • (Boolean)


481
482
483
# File 'app/models/opportunity.rb', line 481

def should_abandon?
  abandonable? && Date.current > abandonment_date
end

#sms_enabled_numbersObject



339
340
341
# File 'app/models/opportunity.rb', line 339

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

#sms_messagesObject



343
344
345
# File 'app/models/opportunity.rb', line 343

def sms_messages
  SmsMessage.for_numbers(sms_enabled_numbers)
end

#sourceSource

Returns:

See Also:



109
# File 'app/models/opportunity.rb', line 109

belongs_to :source, optional: true

#state_description(describe_state = nil) ⇒ Object



433
434
435
# File 'app/models/opportunity.rb', line 433

def state_description(describe_state = nil)
  OpportunityConstants::STATE_DESCRIPTION[describe_state || state]
end

#sync_stateObject



363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'app/models/opportunity.rb', line 363

def sync_state
  # Once an opportunity reaches a terminal state, do not let auto-derivation
  # bounce it back to an open state. Use `reopen` to leave terminal states.
  return if state.in?(%w[won lost abandoned cancelled])

  untrack ||
    win ||
    follow ||
    quote ||
    (should_abandon? && abandon) ||
    send_to_qualify ||
    reset
end

#synchronize_repsObject

Synchronize the reps on this opportunity with the customer



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

def synchronize_reps
  return unless customer

  self.primary_sales_rep_id = customer.primary_sales_rep_id
  self.secondary_sales_rep_id = customer.secondary_sales_rep_id
  # The local is the only exception, only reset it if the customer has a local sales rep
  self.local_sales_rep_id = customer.local_sales_rep_id if customer.local_sales_rep_id.present?
  rep_changes = {}
  rep_changes = changes.symbolize_keys.slice(:primary_sales_rep_id, :secondary_sales_rep_id, :local_sales_rep_id) if changes.present?
  Rails.logger.debug('Changing reps on opportunity', opportunity_id: id, changes: rep_changes) if rep_changes.present?
  rep_changes
end

#technical_support_repEmployee

Returns:

See Also:



104
# File 'app/models/opportunity.rb', line 104

belongs_to :technical_support_rep, class_name: 'Employee', optional: true

#technical_support_rep_secEmployee

Returns:

See Also:



105
# File 'app/models/opportunity.rb', line 105

belongs_to :technical_support_rep_sec, class_name: 'Employee', optional: true

#to_sObject



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

def to_s
  "#{name} - #{human_state_name}"
end

#tracking_email_addressObject



755
756
757
758
759
760
# File 'app/models/opportunity.rb', line 755

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

#unattached_orders(**_pagination) ⇒ Object

Retrieve all orphaned orders that are candidate to be attached to this opportunity



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

def unattached_orders(**_pagination)
  # Return orders relation for controller-level pagination with Pagy
  customer.orders.so_only.where(opportunity_id: nil).order('orders.created_at DESC')
end

#unique_room_name(base_name, except: nil) ⇒ Object

Generate a room-configuration name unique within this opportunity.
Strips any existing " #N" suffix from base_name so re-deriving from
the same root doesn't pile up ("Basement #2 #3"), then returns the
bare root if free, else "#Top Level Namespace #2", "#Top Level Namespace #3", ... until unique.

Pass except: (typically the record currently being edited) so its own
current name is not counted as a collision.

Modeled on Opportunity.get_unique_name (which uniquifies opportunities
within a customer); the differences are: scoped to this opportunity's
room_configurations, returns the bare root when free, and supports
excluding a record.



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'app/models/opportunity.rb', line 545

def unique_room_name(base_name, except: nil)
  return base_name if base_name.blank?

  # Strip only a *terminal* " #N" suffix so legitimate names containing " #"
  # ("Zone #2 East") don't get collapsed to "Zone".
  name_root = base_name.to_s.sub(/\s#\d+\z/, '')
  scope = room_configurations
  scope = scope.where.not(id: except.id) if except&.persisted?
  taken = scope.where('name LIKE ?', "#{self.class.sanitize_sql_like(name_root)}%").pluck(:name).to_set
  return name_root unless taken.include?(name_root)

  n = 2
  loop do
    candidate = "#{name_root} ##{n}"
    return candidate unless taken.include?(candidate)

    n += 1
  end
end

#unknown_job_name?Boolean

Returns:

  • (Boolean)


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

def unknown_job_name?
  name&.start_with?(UNKNOWN_JOB_NAME_PREFIX)
end

#update_primary_product_line_nameObject



788
789
790
791
# File 'app/models/opportunity.rb', line 788

def update_primary_product_line_name
  product_line_name = room_configurations.order(:id).map { |a| a.heating_system_product_line.heating_system_type_name }.first
  update_column :primary_product_line_name, product_line_name
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



117
# File 'app/models/opportunity.rb', line 117

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

#visitVisit

Returns:

See Also:



111
# File 'app/models/opportunity.rb', line 111

belongs_to :visit, optional: true

#voice_callable?Boolean

Returns:

  • (Boolean)


919
920
921
# File 'app/models/opportunity.rb', line 919

def voice_callable?
  primary_party.contact_points.voice_callable.present?
end