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
openai_ads_conversion_meta :jsonb 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
merged_into_id :bigint
original_sales_rep_id :integer
parent_id :bigint
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
tracking_event_id :uuid
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_merged_into_id (merged_into_id)
index_opportunities_on_opportunity_type (opportunity_type)
index_opportunities_on_parent_id (parent_id)
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_... (merged_into_id => opportunities.id) ON DELETE => nullify
fk_rails_... (parent_id => opportunities.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 =

Regex pattern matching reference number.

/^ON\d+$/i
UNKNOWN_JOB_NAME_PREFIX =

Unknown job name prefix.

'Job'
WEB_ORIGINATED_RECEPTION_TYPES =

Reception types where the opportunity was created from a website touch
(quote builder, instant quote, or generic online lead form). Used as the
candidate parent set when a CRM opp is auto-linked on creation.

%w[Online IQ].freeze
180

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

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 Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#create_oppfuObject

Returns the value of attribute create_oppfu.



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

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 })


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

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

#msrp_valueObject (readonly)



151
152
# File 'app/models/opportunity.rb', line 151

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.



242
243
244
# File 'app/models/opportunity.rb', line 242

def must_have_one_room
  @must_have_one_room
end

#nameObject (readonly)



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

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

#opportunity_typeObject (readonly)



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

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

#summaryObject (readonly)



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

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 })


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

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:



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

scope :active, -> { open_opportunities.with_quotes_and_rooms }

.all_competitorsObject



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

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

.assigned_to_repActiveRecord::Relation<Opportunity>

A relation of Opportunities that are assigned to rep. Active Record Scope

Returns:

See Also:



220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/models/opportunity.rb', line 220

scope :assigned_to_rep, ->(party_or_ids) {
  # Guard against nil/blank input — without this, Rails turns the OR
  # branches into `IS NULL` predicates, which would silently match every
  # opportunity that has any null rep column.
  ids = Array.wrap(party_or_ids).compact
  next none if ids.empty?

  where.any_of(
    { primary_sales_rep_id: ids },
    { secondary_sales_rep_id: ids },
    { local_sales_rep_id: ids }
  )
}

.awaiting_iq_transmissionActiveRecord::Relation<Opportunity>

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

Returns:

See Also:



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

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

.cluster_rootsActiveRecord::Relation<Opportunity>

A relation of Opportunities that are cluster roots. Active Record Scope

Returns:

See Also:



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

scope :cluster_roots, -> { where(parent_id: nil, merged_into_id: nil) }

.fix_postal_codesObject



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

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



1119
1120
1121
# File 'app/models/opportunity.rb', line 1119

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

.get_unique_name(party, name) ⇒ Object



694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
# File 'app/models/opportunity.rb', line 694

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

.google_conversion_reportedActiveRecord::Relation<Opportunity>

A relation of Opportunities that are google conversion reported. Active Record Scope

Returns:

See Also:



196
197
198
# File 'app/models/opportunity.rb', line 196

scope :google_conversion_reported, -> {
  jsonb_where_exists(column_name: :google_conversion_meta, key: :reported_at)
}

.instant_quotingActiveRecord::Relation<Opportunity>

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

Returns:

See Also:



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

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

.lostActiveRecord::Relation<Opportunity>

A relation of Opportunities that are lost. Active Record Scope

Returns:

See Also:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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

.opportunity_reception_types_for_selectObject



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

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

.opportunity_types_for_selectObject



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

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

.states_for_selectObject



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

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

.web_originatedActiveRecord::Relation<Opportunity>

A relation of Opportunities that are web originated. Active Record Scope

Returns:

See Also:



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

scope :web_originated, -> { where(opportunity_reception_type: %w[Online IQ]) }

.with_google_conversion_gclidActiveRecord::Relation<Opportunity>

A relation of Opportunities that are with google conversion gclid. Active Record Scope

Returns:

See Also:



200
201
202
203
204
# File 'app/models/opportunity.rb', line 200

scope :with_google_conversion_gclid, ->(gclid) {
  next none if gclid.blank?

  jsonb_where(column_name: :google_conversion_meta, json_keys: %w[gclid], operator: :eq, value: gclid)
}

.with_quotes_and_roomsActiveRecord::Relation<Opportunity>

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

Returns:

See Also:



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

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:



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

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

.wonActiveRecord::Relation<Opportunity>

A relation of Opportunities that are won. Active Record Scope

Returns:

See Also:



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

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

Instance Method Details

#abandonable?Boolean

Returns:

  • (Boolean)


604
605
606
607
608
# File 'app/models/opportunity.rb', line 604

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

#abandonment_dateObject



618
619
620
# File 'app/models/opportunity.rb', line 618

def abandonment_date
  last_movement.to_date + 60.days
end

#account_specialistEmployee

Returns:

See Also:



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

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

#activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



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

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

#add_customer_to_campaignObject



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

def add_customer_to_campaign
  source.add_customer_to_campaign(customer)
end

#all_activitiesObject



554
555
556
# File 'app/models/opportunity.rb', line 554

def all_activities
  linked_activities
end

#all_participantsObject



1068
1069
1070
# File 'app/models/opportunity.rb', line 1068

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


541
542
543
544
545
546
547
548
549
550
551
552
# File 'app/models/opportunity.rb', line 541

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



1079
1080
1081
# File 'app/models/opportunity.rb', line 1079

def all_reps
  Employee.where(id: all_reps_ids)
end

#all_reps_idsObject



1072
1073
1074
1075
1076
1077
# File 'app/models/opportunity.rb', line 1072

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



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

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



532
533
534
# File 'app/models/opportunity.rb', line 532

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

#buying_groupBuyingGroup



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

belongs_to :buying_group, optional: true

#calculate_valueObject



903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
# File 'app/models/opportunity.rb', line 903

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)


435
436
437
# File 'app/models/opportunity.rb', line 435

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

#cancel_followups(include_large_op_activities: false) ⇒ Object



839
840
841
842
843
844
845
846
# File 'app/models/opportunity.rb', line 839

def cancel_followups(include_large_op_activities: false)
  sales_activity_ids = ActivityType.sales_activities.ids
  # 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.



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

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

#cancel_oppfuObject



1115
1116
1117
# File 'app/models/opportunity.rb', line 1115

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

#cancelable?Boolean

Returns:

  • (Boolean)


832
833
834
835
836
837
# File 'app/models/opportunity.rb', line 832

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

#childrenActiveRecord::Relation<Opportunity>

Returns:

See Also:



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

has_many :children, class_name: 'Opportunity', foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify

#closed?Boolean

Returns:

  • (Boolean)


443
444
445
# File 'app/models/opportunity.rb', line 443

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

#cluster_rootOpportunity

The canonical opp this row should report against. Walks parent_id up
to the root (loop-guarded so a cyclic parent chain returns the deepest
safe ancestor instead of looping forever). Used by reports that want
to dedupe a cluster.

Returns:

  • (Opportunity)

    the cluster root — self when there is no parent.



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

def cluster_root
  seen = Set[id]
  current = self
  while current.parent_id.present? && seen.exclude?(current.parent_id)
    seen << current.parent_id
    current = current.parent || break
  end
  current
end

#contactContact

Returns:

See Also:



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

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

#contact_select_optionsObject



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

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)


588
589
590
# File 'app/models/opportunity.rb', line 588

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



848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
# File 'app/models/opportunity.rb', line 848

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



938
939
940
941
942
943
944
945
946
947
948
949
# File 'app/models/opportunity.rb', line 938

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


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

def crm_link
  UrlHelper.instance.opportunity_path(self)
end

#current_heat_loss_roomRoomConfiguration

Returns:

  • (RoomConfiguration)

See Also:



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

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

#customerCustomer

Returns:

See Also:

Validations:



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

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

#deep_dupObject



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

def deep_dup
  deep_clone(except: :reference_number)
end

#digital_assetsActiveRecord::Relation<DigitalAsset>

Returns:

See Also:



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

has_and_belongs_to_many :digital_assets, inverse_of: :opportunities

#editable?Boolean

Returns:

  • (Boolean)


783
784
785
# File 'app/models/opportunity.rb', line 783

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

#effective_opportunity_won_dateObject



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

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)


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

def emailable?
  emails.present?
end

#emailsObject



1052
1053
1054
# File 'app/models/opportunity.rb', line 1052

def emails
  primary_party.contact_points.emails
end

#estimate_electricity_rateObject



1105
1106
1107
1108
1109
1110
1111
1112
1113
# File 'app/models/opportunity.rb', line 1105

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)


558
559
560
# File 'app/models/opportunity.rb', line 558

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

#find_gbraidObject



879
880
881
# File 'app/models/opportunity.rb', line 879

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

#find_gclidObject



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

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

#find_opprefString?

OpenAI Ads (ChatGPT) click identifier, captured by the Tracker into
Visit#marketing_meta['oppref'] (JSONB-only — no scalar column). Used by
the CAPI reporter to populate the event's top-level oppref field, which
OpenAI requires us to forward ourselves on server-side events.

Returns:

  • (String, nil)


890
891
892
893
# File 'app/models/opportunity.rb', line 890

def find_oppref
  (visit&.marketing_meta || {})['oppref'].presence ||
    (customer&.visit&.marketing_meta || {})['oppref'].presence
end

#find_wbraidObject



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

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

#heat_loss_roomObject



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

def heat_loss_room
  current_heat_loss_room || create_heat_loss_room
end

#in_canada?Boolean

Returns:

  • (Boolean)


951
952
953
# File 'app/models/opportunity.rb', line 951

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

#in_usa?Boolean

Returns:

  • (Boolean)


955
956
957
# File 'app/models/opportunity.rb', line 955

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

#installation_country_isoObject



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

def installation_country_iso
  installation_country_iso3.first(2)
end

#installation_country_iso3Object



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

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



1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
# File 'app/models/opportunity.rb', line 1123

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



959
960
961
962
963
# File 'app/models/opportunity.rb', line 959

def installation_state_code
  return if installation_postal_code.blank?

  PostalCode.get_state_code_from_postal_code(installation_postal_code)
end

#last_movementObject



614
615
616
# File 'app/models/opportunity.rb', line 614

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

#last_quote_dateObject



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

def last_quote_date
  quotes.maximum(:created_at)
end

#last_room_dateObject



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

def last_room_date
  room_configurations.maximum(:created_at)
end

#last_sales_activity_completionObject



622
623
624
# File 'app/models/opportunity.rb', line 622

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

#linked_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



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

has_many :linked_activities, class_name: 'Activity'

#local_sales_repEmployee

Returns:

See Also:



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

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

#locationObject



981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
# File 'app/models/opportunity.rb', line 981

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_into!(target) ⇒ void

This method returns an undefined value.

Mark this opportunity as a duplicate of target and retire it from
active pipelines. Persists merged_into_id and transitions to
cancelled so it stops appearing in open-pipeline reports.

Logic Details

The cancel transition is best-effort — if the opp is in a state that
cancelable? rejects (e.g. already cancelled, or has active orders),
only merged_into_id is persisted. The merged_into_id link is what
drives view_opportunity_conversions exclusion; the cancel is a UX
nicety so the duplicate stops appearing in active-pipeline lists.

Parameters:

  • target (Opportunity)

    the canonical opp this one is a duplicate of.

Raises:

  • (ArgumentError)

    if target is self (cannot merge into yourself).

  • (ActiveRecord::RecordInvalid)

    if persisting merged_into_id fails.



483
484
485
486
487
488
489
490
# File 'app/models/opportunity.rb', line 483

def merge_into!(target)
  raise ArgumentError, 'cannot merge an opportunity into itself' if target.id == id

  transaction do
    update!(merged_into_id: target.id)
    cancel if cancelable?
  end
end

#merge_with(opp_to_merge, delete_after: false) ⇒ Object



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

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

#merged_duplicatesActiveRecord::Relation<Opportunity>

Returns:

See Also:



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

has_many :merged_duplicates, class_name: 'Opportunity', foreign_key: :merged_into_id, inverse_of: :merged_into, dependent: :nullify

#merged_intoOpportunity



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

belongs_to :merged_into, class_name: 'Opportunity', optional: true, inverse_of: :merged_duplicates

#next_room_name(options) ⇒ Object



801
802
803
804
805
806
807
808
809
810
811
812
813
814
# File 'app/models/opportunity.rb', line 801

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)


828
829
830
# File 'app/models/opportunity.rb', line 828

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)


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

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

#open_activities_counterObject



725
726
727
# File 'app/models/opportunity.rb', line 725

def open_activities_counter
  related_activities.visible_by_default.open_activities.size
end

#open_opp_fu?Boolean

Returns:

  • (Boolean)


439
440
441
# File 'app/models/opportunity.rb', line 439

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

#open_opportunity?Boolean

Returns:

  • (Boolean)


634
635
636
# File 'app/models/opportunity.rb', line 634

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

#opportunity_participantsActiveRecord::Relation<OpportunityParticipant>

Returns:

See Also:



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

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

#opportunity_type_expandedObject



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

def opportunity_type_expanded
  OpportunityConstants::OPPORTUNITY_TYPES[opportunity_type]
end

#order_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



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

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

#ordersActiveRecord::Relation<Order>

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



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

has_many :orders, before_add: :set_default_for_order

#parentOpportunity



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

belongs_to :parent, class_name: 'Opportunity', optional: true, inverse_of: :children

#participants_options_for_selectObject



1064
1065
1066
# File 'app/models/opportunity.rb', line 1064

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:



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

has_many :parties, through: :opportunity_participants

#planned_installation_date_time_frameObject



1083
1084
1085
1086
1087
1088
1089
1090
1091
# File 'app/models/opportunity.rb', line 1083

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



1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
# File 'app/models/opportunity.rb', line 1093

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



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

def primary_party
  contact || customer
end

#primary_sales_repEmployee

Returns:

See Also:



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

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



1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'app/models/opportunity.rb', line 1011

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.



764
765
766
767
768
769
770
771
772
# File 'app/models/opportunity.rb', line 764

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)


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

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

#qualified_room_present?Boolean

Returns:

  • (Boolean)


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

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

#quote_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



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

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

#quotesActiveRecord::Relation<Quote>

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

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

#quotes_completed?Boolean

Returns:

  • (Boolean)


574
575
576
# File 'app/models/opportunity.rb', line 574

def quotes_completed?
  quotes.completed_quotes.present?
end

#quoting_event?Boolean

Returns:

  • (Boolean)


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

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)


582
583
584
# File 'app/models/opportunity.rb', line 582

def ready_for_follow_up?
  quotes_completed? && contactable_for_follow_up?
end

#recalculate_customer_profiling_informationObject



729
730
731
732
733
# File 'app/models/opportunity.rb', line 729

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


759
760
761
# File 'app/models/opportunity.rb', line 759

def related_activities
  Activity.where(id: related_activities_ids)
end


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

def related_activities_ids
  [activities.ids, room_activities.ids, quote_activities.ids].flatten
end

#reset_rooms_last_heat_lossObject



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

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

#room_activitiesActiveRecord::Relation<Activity>

Returns:

See Also:



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

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 ):



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

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

#room_uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



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

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

#room_without_installation_record?(room = nil) ⇒ Boolean

Returns:

  • (Boolean)


719
720
721
722
723
# File 'app/models/opportunity.rb', line 719

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

#sales_opportunity?Boolean

Returns:

  • (Boolean)


566
567
568
# File 'app/models/opportunity.rb', line 566

def sales_opportunity?
  opportunity_type == 'S'
end

#sales_present?Boolean

Returns:

  • (Boolean)


570
571
572
# File 'app/models/opportunity.rb', line 570

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

#sales_support_repEmployee

Returns:

See Also:



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

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



1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
# File 'app/models/opportunity.rb', line 1031

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:



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

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

#selection_nameObject



778
779
780
781
# File 'app/models/opportunity.rb', line 778

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

#send_online_email_confirmationObject



735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
# File 'app/models/opportunity.rb', line 735

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)



1162
1163
1164
1165
# File 'app/models/opportunity.rb', line 1162

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)



1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
# File 'app/models/opportunity.rb', line 1149

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)


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

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

#sms_enabled_numbersObject



427
428
429
# File 'app/models/opportunity.rb', line 427

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

#sms_messagesObject



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

def sms_messages
  SmsMessage.for_numbers(sms_enabled_numbers)
end

#sourceSource

Returns:

See Also:



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

belongs_to :source, optional: true

#state_description(describe_state = nil) ⇒ Object



562
563
564
# File 'app/models/opportunity.rb', line 562

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

#sync_stateObject



492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'app/models/opportunity.rb', line 492

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



1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
# File 'app/models/opportunity.rb', line 1136

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:



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

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

#technical_support_rep_secEmployee

Returns:

See Also:



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

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

#to_sObject



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

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

#tracking_email_addressObject



896
897
898
899
900
901
# File 'app/models/opportunity.rb', line 896

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



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

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.



674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'app/models/opportunity.rb', line 674

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.excluding(except) 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)


447
448
449
# File 'app/models/opportunity.rb', line 447

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

#update_primary_product_line_nameObject



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

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:



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

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

#visitVisit

Returns:

See Also:



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

belongs_to :visit, optional: true

#voice_callable?Boolean

Returns:

  • (Boolean)


1060
1061
1062
# File 'app/models/opportunity.rb', line 1060

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