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, OutletPurchaseMatcher

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 Models::Schedulable

Models::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_event_window_respected, #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::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.



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

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


212
213
# File 'app/models/opportunity.rb', line 212

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

#msrp_valueObject (readonly)

MSRP value must fit the numeric(10,2) column.

Validations:

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


209
210
# File 'app/models/opportunity.rb', line 209

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.



292
293
294
# File 'app/models/opportunity.rb', line 292

def must_have_one_room
  @must_have_one_room
end

#nameObject (readonly)

Name is required, 1-80 chars.

Validations:



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

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

#opportunity_typeObject (readonly)

Opportunity type must be a known type.

Validations:

  • Inclusion ({ in: OpportunityConstants::OPPORTUNITY_TYPES.keys })


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

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

#summaryObject (readonly)

Summary limited to 240 chars when present.

Validations (if => -> { summary.present? } ):



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

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


206
207
# File 'app/models/opportunity.rb', line 206

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:



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

scope :active, -> { open_opportunities.with_quotes_and_rooms }

.all_competitorsArray<String>

All known competitor system names.

Returns:

  • (Array<String>)


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

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:



270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'app/models/opportunity.rb', line 270

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:



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

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:



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

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

.fix_postal_codesvoid

This method returns an undefined value.

Normalizes installation postal codes to 5-digit US / 6-char CA forms.



804
805
806
807
808
809
810
# File 'app/models/opportunity.rb', line 804

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) ⇒ String

Formats an address as zip - city state for the postal-code select.

Parameters:

  • address (Address)

    address to format

Returns:

  • (String)


1454
1455
1456
# File 'app/models/opportunity.rb', line 1454

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

.get_unique_name(party, name) ⇒ String

Returns a unique opportunity name within the customer's opportunities,
appending #N when the base name is taken.

Parameters:

  • party (Party)

    the customer whose opportunities scope uniqueness

  • name (String)

    requested opportunity name

Returns:

  • (String)

    unique opportunity name



878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
# File 'app/models/opportunity.rb', line 878

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:



246
247
248
# File 'app/models/opportunity.rb', line 246

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:



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

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

.lostActiveRecord::Relation<Opportunity>

A relation of Opportunities that are lost. Active Record Scope

Returns:

See Also:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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

.opportunity_reception_types_for_selectArray<Array(String, String)>

Reception types as [label, value] select options.

Returns:

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


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

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

.opportunity_types_for_selectArray<Array(String, String)>

Opportunity types as [label, value] select options.

Returns:

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


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

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

.states_for_selectArray<Array(String, String)>

States as [human name, value] select options.

Returns:

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


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

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:



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

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:



250
251
252
253
254
# File 'app/models/opportunity.rb', line 250

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:



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

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:



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

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

.wonActiveRecord::Relation<Opportunity>

A relation of Opportunities that are won. Active Record Scope

Returns:

See Also:



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

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

Instance Method Details

#abandonable?Boolean

Whether the opportunity can be auto-abandoned.

Returns:

  • (Boolean)


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

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

#abandonment_dateDate

Date after which the opportunity is considered abandoned.

Returns:

  • (Date)


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

def abandonment_date
  last_movement.to_date + 60.days
end

#account_specialistEmployee

Account specialist assigned.

Returns:

See Also:



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

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

#activitiesActiveRecord::Relation<Activity>

Activity log entries for this opportunity.

Returns:

See Also:



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

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

#add_customer_to_campaignvoid

This method returns an undefined value.

Adds the customer to the source's marketing campaign.



612
613
614
# File 'app/models/opportunity.rb', line 612

def add_customer_to_campaign
  source.add_customer_to_campaign(customer)
end

#all_activitiesActiveRecord::Relation<Activity>

All activities linked to this opportunity.

Returns:



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

def all_activities
  linked_activities
end

#all_participantsActiveRecord::Relation<Party>

All parties participating in this opportunity (customer, contact, and
explicit participants).

Returns:

  • (ActiveRecord::Relation<Party>)


1387
1388
1389
# File 'app/models/opportunity.rb', line 1387

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

Communications across this opportunity's orders, quotes, and rooms.

Returns:



631
632
633
634
635
636
637
638
639
640
641
642
# File 'app/models/opportunity.rb', line 631

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_repsActiveRecord::Relation<Employee>

Every rep associated with this opportunity or its customer.

Returns:



1402
1403
1404
# File 'app/models/opportunity.rb', line 1402

def all_reps
  Employee.where(id: all_reps_ids)
end

#all_reps_idsArray<Integer>

IDs of every rep associated with this opportunity or its customer.

Returns:

  • (Array<Integer>)


1393
1394
1395
1396
1397
1398
# File 'app/models/opportunity.rb', line 1393

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_uploadsActiveRecord::Relation<Upload>

All uploads on this opportunity and its room configurations.

Returns:

  • (ActiveRecord::Relation<Upload>)


593
594
595
596
597
# File 'app/models/opportunity.rb', line 593

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_activityActivity

Builds an unsaved activity for this opportunity and primary party.

Returns:



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

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

#buying_groupBuyingGroup

Buying group the customer is purchasing through.



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

belongs_to :buying_group, optional: true

#calculate_valuevoid

This method returns an undefined value.

Recalculates and persists value, msrp_value, and discount_percentage from
orders, verified outlet purchases, or the top active sales quote.



1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
# File 'app/models/opportunity.rb', line 1172

def calculate_value
  return if destroyed?

  # Actual money beats expected money. Direct sales orders and VERIFIED outlet
  # purchases are both actual — the customer bought, one through us and one
  # through a retailer — so they sum, and a quote is only consulted when
  # neither exists. Extending the existing orders-beat-quotes rule rather than
  # adding the outlet total on top of a quote, which would double count: the
  # quote is the ask, the outlet purchase is (part of) the fulfilment.
  discounted_value = nil
  msrp_value = nil
  discount_percentage = nil
  sales_orders = orders.sales_orders
  sales_quotes = quotes.sales_quotes.active
  direct_value = sales_orders.sum(:line_total).to_d
  # Safe to join-sum: the partial unique index allows one row per
  # (opportunity, invoice), so an invoice cannot be counted twice here.
  outlet_value = outlet_purchases.accepted.joins(:invoice).sum('invoices.total').to_d
  actual_value = direct_value + outlet_value

  if actual_value.positive?
    discounted_value = actual_value
    # MSRP and discount describe only the part where WE set the price. A
    # retailer's margin is not our discount, so an outlet-only opportunity
    # carries value with no discount figure rather than a misleading one.
    if direct_value.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 - (direct_value / msrp_value)) * 100)
    end
  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

Whether this opportunity can be moved (no active orders).

Returns:

  • (Boolean)


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

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

#cancel_followups(include_large_op_activities: false) ⇒ void

This method returns an undefined value.

Cancels open sales follow-up activities on this opportunity.

Parameters:

  • include_large_op_activities (Boolean) (defaults to: false)

    also cancel NEW_LARGE_OP
    activities (used on terminal transitions)



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

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.



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

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

#cancel_oppfuvoid

This method returns an undefined value.

Cancels all open OPPFU follow-up activities on this opportunity.



1447
1448
1449
# File 'app/models/opportunity.rb', line 1447

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

#cancelable?Boolean

Whether the opportunity can be cancelled.

Returns:

  • (Boolean)


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

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>

Child opportunities.

Returns:

See Also:



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

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

#closed?Boolean

Whether the opportunity is in a terminal closed state.

Returns:

  • (Boolean)


514
515
516
# File 'app/models/opportunity.rb', line 514

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.



530
531
532
533
534
535
536
537
538
# File 'app/models/opportunity.rb', line 530

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

#collapse_colliding_outlet_purchasesvoid

This method returns an undefined value.

Destroy outlet purchases that cannot survive being nullified.

dependent: :nullify drops these rows to customer level when the project is
deleted, but index_outlet_purchases_on_customer_and_invoice is UNIQUE on
(customer_id, invoice_id) WHERE opportunity_id IS NULL. If the customer
already holds a customer-level row for the same invoice, nullifying raises
PG::UniqueViolation and the whole delete fails.

The colliding row carries nothing the survivor does not — same customer,
same invoice — so dropping it loses no information. Prepended so it runs
before the association's own nullify callback.



705
706
707
708
709
710
711
712
# File 'app/models/opportunity.rb', line 705

def collapse_colliding_outlet_purchases
  already_at_customer_level = CustomerOutletPurchase.customer_level
                                                    .where(customer_id: customer_id)
                                                    .pluck(:invoice_id)
  return if already_at_customer_level.empty?

  outlet_purchases.where(invoice_id: already_at_customer_level).destroy_all
end

#contactContact

Primary contact on the opportunity.

Returns:

See Also:



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

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

#contact_select_optionsArray<Array(String, Integer)>

Options for the contact select dropdown (active contacts plus the current
contact when inactive).

Returns:

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


997
998
999
1000
1001
# File 'app/models/opportunity.rb', line 997

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)


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

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) ⇒ Activity?

Creates a follow-up activity for this opportunity.

Parameters:

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

    note body for the activity

  • current_user (Employee, nil) (defaults to: nil)

    acting user

  • follow_up_activity_type (ActivityType, nil) (defaults to: nil)

    explicit activity type

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

    task type used to look up the activity type

  • target_date (Time, Date, nil) (defaults to: nil)

    target date for the activity

Returns:



1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
# File 'app/models/opportunity.rb', line 1088

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_roomRoomConfiguration

Creates a default heat-loss room and marks it current.

Returns:



1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
# File 'app/models/opportunity.rb', line 1229

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

CRM path for this opportunity.

Returns:

  • (String)


1005
1006
1007
# File 'app/models/opportunity.rb', line 1005

def crm_link
  UrlHelper.instance.opportunity_path(self)
end

#current_heat_loss_roomRoomConfiguration

Room configuration currently used for heat loss calculations.



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

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

#customerCustomer

Customer the opportunity belongs to.

Returns:

See Also:

Validations:



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

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

#deep_dupOpportunity

Duplicates this opportunity without its reference number.

Returns:



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

def deep_dup
  deep_clone(except: :reference_number)
end

#digital_assetsActiveRecord::Relation<DigitalAsset>

Digital assets attached to this opportunity.

Returns:

See Also:



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

has_and_belongs_to_many :digital_assets, inverse_of: :opportunities

#editable?Boolean

Whether the opportunity is still editable (not won/lost/cancelled/abandoned).

Returns:

  • (Boolean)


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

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

#effective_opportunity_won_dateTime

Won date derived from the earliest order ship date.

Returns:

  • (Time)


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

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

Whether the primary party has an email address.

Returns:

  • (Boolean)


1368
1369
1370
# File 'app/models/opportunity.rb', line 1368

def emailable?
  emails.present?
end

#emailsActiveRecord::Relation<ContactPoint>

Email contact points of the primary party.

Returns:



1362
1363
1364
# File 'app/models/opportunity.rb', line 1362

def emails
  primary_party.contact_points.emails
end

#estimate_electricity_rateBigDecimal, ...

Average electricity rate for the installation postal code.

Returns:

  • (BigDecimal, Float, nil)


1435
1436
1437
1438
1439
1440
1441
1442
1443
# File 'app/models/opportunity.rb', line 1435

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

Whether the given state event was initiated manually (excluded from
automated handling).

Parameters:

  • event (Symbol, String)

    state machine event name

Returns:

  • (Boolean)


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

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

#find_gbraidString?

Google Ads gbraid identifier, from the visit or the customer's visit.

Returns:

  • (String, nil)


1125
1126
1127
# File 'app/models/opportunity.rb', line 1125

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

#find_gclidString?

Google Ads click identifier, from the visit or the customer.

Returns:

  • (String, nil)


1111
1112
1113
# File 'app/models/opportunity.rb', line 1111

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

#find_msclkidString?

Microsoft Ads (Bing) click identifier, captured by the Tracker into
Visit#marketing_meta['msclkid'] (JSONB-only). Keys the offline-conversion
upload in MicrosoftAds::ConversionReporter. The landing-page fallback
covers visits created before the msclkid-capture deploy (id in ?msclkid=
on landing_page but not yet in marketing_meta).

Returns:

  • (String, nil)


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

def find_msclkid
  msclkid_for = lambda do |v|
    next nil unless v

    (v.marketing_meta || {})['msclkid'].presence || v.landing_page_params['msclkid'].presence
  end

  msclkid_for.call(visit) || msclkid_for.call(customer&.visit)
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)


1136
1137
1138
1139
# File 'app/models/opportunity.rb', line 1136

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

#find_wbraidString?

Google Ads wbraid identifier, from the visit or the customer's visit.

Returns:

  • (String, nil)


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

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

#heat_loss_roomRoomConfiguration

The current heat-loss room, creating a default one when absent.

Returns:



1223
1224
1225
# File 'app/models/opportunity.rb', line 1223

def heat_loss_room
  current_heat_loss_room || create_heat_loss_room
end

#in_canada?Boolean

Whether the installation address is in Canada.

Returns:

  • (Boolean)


1244
1245
1246
# File 'app/models/opportunity.rb', line 1244

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

#in_usa?Boolean

Whether the installation address is in the USA.

Returns:

  • (Boolean)


1250
1251
1252
# File 'app/models/opportunity.rb', line 1250

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

#installation_country_isoString

Two-letter ISO country code for the installation address.

Returns:

  • (String)


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

def installation_country_iso
  installation_country_iso3.first(2)
end

#installation_country_iso3String

ISO3 country code for the installation address (store country, postal
code lookup, or locale fallback; defaults to 'USA').

Returns:

  • (String)


1265
1266
1267
1268
1269
1270
# File 'app/models/opportunity.rb', line 1265

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_selectArray<Array(String, String)>

Postal-code options for the installation address select, gathered from
customer, order, and quote addresses.

Returns:

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


1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
# File 'app/models/opportunity.rb', line 1461

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_codeString?

State/province code derived from the installation postal code.

Returns:

  • (String, nil)


1256
1257
1258
1259
1260
# File 'app/models/opportunity.rb', line 1256

def installation_state_code
  return if installation_postal_code.blank?

  PostalCode.get_state_code_from_postal_code(installation_postal_code)
end

#last_movementTime, ...

Most recent meaningful activity date on the opportunity.

Returns:

  • (Time, Date, nil)


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

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

#last_quote_dateTime?

Most recent quote creation time.

Returns:

  • (Time, nil)


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

def last_quote_date
  quotes.maximum(:created_at)
end

#last_room_dateTime?

Most recent room configuration creation time.

Returns:

  • (Time, nil)


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

def last_room_date
  room_configurations.maximum(:created_at)
end

#last_sales_activity_completionTime?

Most recent sales activity completion.

Returns:

  • (Time, nil)


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

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

#linked_activitiesActiveRecord::Relation<Activity>

Activities linked to this opportunity via the legacy join.

Returns:

See Also:



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

has_many :linked_activities, class_name: 'Activity'

#local_sales_repEmployee

Local (field) sales rep assigned.

Returns:

See Also:



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

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

#locationString?

Best-effort geocodable location string for this opportunity, from order,
quote, or customer address, then postal code, then visit geo data.

Returns:

  • (String, nil)


1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
# File 'app/models/opportunity.rb', line 1288

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.



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

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) ⇒ void

This method returns an undefined value.

Merges another opportunity into this one, moving its rooms, activities,
and quotes; optionally destroys the merged opportunity.

Parameters:

  • opp_to_merge (Opportunity)

    opportunity to absorb

  • delete_after (Boolean) (defaults to: false)

    destroy opp_to_merge after merging



1038
1039
1040
1041
1042
1043
1044
# File 'app/models/opportunity.rb', line 1038

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>

Duplicate opportunities merged into this one.

Returns:

See Also:



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

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

#merged_intoOpportunity

Opportunity this duplicate was merged into.



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

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

#next_room_name(options) ⇒ String

Generates the next available room name for this opportunity,
appending an incrementing #N suffix when the base name is
already taken.

Parameters:

  • options (Hash)

Options Hash (options):

  • room_base_name (String)

    base name to number from

  • room_type (#name)

    room type whose name is used as
    the base name when :room_base_name is absent

Returns:

  • (String)


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

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

Whether the customer is allowed to cancel this opportunity.

Returns:

  • (Boolean)


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

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

Whether the opportunity may be deleted (no completed quotes, active
rooms, or orders).

Returns:

  • (Boolean)


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

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

#open_activities_counterInteger

Count of open, visible activities related to this opportunity.

Returns:

  • (Integer)


907
908
909
# File 'app/models/opportunity.rb', line 907

def open_activities_counter
  related_activities.visible_by_default.open_activities.size
end

#open_opp_fu?Boolean

Whether there is an open OPPFU follow-up activity.

Returns:

  • (Boolean)


508
509
510
# File 'app/models/opportunity.rb', line 508

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

#open_opportunity?Boolean

Whether the opportunity is in an open state.

Returns:

  • (Boolean)


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

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

#opportunity_participantsActiveRecord::Relation<OpportunityParticipant>

Join records linking parties to this opportunity.

Returns:

See Also:



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

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

#opportunity_type_expandedString?

Expanded (human-readable) label for the opportunity type code.

Returns:

  • (String, nil)


901
902
903
# File 'app/models/opportunity.rb', line 901

def opportunity_type_expanded
  OpportunityConstants::OPPORTUNITY_TYPES[opportunity_type]
end

#order_activitiesActiveRecord::Relation<Activity>

Activities across this opportunity's orders.

Returns:

See Also:



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

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

#ordersActiveRecord::Relation<Order>

Orders won from this opportunity.

Returns:

  • (ActiveRecord::Relation<Order>)

See Also:



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

has_many :orders, before_add: :set_default_for_order

#original_sourceSource

Original marketing source at creation.

Returns:

See Also:



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

belongs_to :original_source, class_name: 'Source', optional: true

#outlet_invoicesActiveRecord::Relation<OutletInvoice>

Invoices from this opportunity's outlet purchases.

Returns:

  • (ActiveRecord::Relation<OutletInvoice>)

See Also:



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

has_many :outlet_invoices, through: :outlet_purchases, source: :invoice

#outlet_purchasesActiveRecord::Relation<CustomerOutletPurchase>

Outlet purchases are record-keeping only and never drive state — see
doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md.
nullify, not destroy: the row is anchored on the customer, so deleting a
project must not erase the fact that the customer bought through a retailer.
See #collapse_colliding_outlet_purchases for why that needs a guard.

Returns:

See Also:



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

has_many :outlet_purchases, class_name: 'CustomerOutletPurchase', inverse_of: :opportunity, dependent: :nullify

#parentOpportunity

Parent opportunity when this is a child project.



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

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

#participants_options_for_selectArray<Array(String, Integer)>

Options for the participants select dropdown.

Returns:

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


1380
1381
1382
# File 'app/models/opportunity.rb', line 1380

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

#partiesActiveRecord::Relation<Party>

Parties participating in this opportunity.

Returns:

  • (ActiveRecord::Relation<Party>)

See Also:



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

has_many :parties, through: :opportunity_participants

#planned_installation_date_time_frameSymbol?

Time-frame bucket (in weeks) for the planned installation date.

Returns:

  • (Symbol, nil)


1408
1409
1410
1411
1412
1413
1414
1415
1416
# File 'app/models/opportunity.rb', line 1408

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) ⇒ void

This method returns an undefined value.

Sets planned_installation_date from a time-frame bucket.

Parameters:

  • val (String, Symbol, nil)

    time-frame key



1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
# File 'app/models/opportunity.rb', line 1421

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_partyParty?

Primary party for communication: the contact, falling back to the customer.

Returns:



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

def primary_party
  contact || customer
end

#primary_sales_repEmployee

Primary sales rep assigned.

Returns:

See Also:



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

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

#product_lines(reviewable_only: true) ⇒ Array<ProductLine>

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

Parameters:

  • reviewable_only (Boolean) (defaults to: true)

    reduce each line to its first reviewable product line

Returns:



1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
# File 'app/models/opportunity.rb', line 1320

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) ⇒ void

This method returns an undefined value.

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

Parameters:

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

    activity to keep open

  • activity_type_ids (Array<Integer>, nil) (defaults to: nil)

    activity types to cancel
    (defaults to QUOFUS_IDS)



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

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?

Whether this is a qualified opportunity (non-guest customer, qualified room).

Returns:

  • (Boolean, nil)


742
743
744
# File 'app/models/opportunity.rb', line 742

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

#qualified_room_present?Boolean

Whether any room configuration is past the draft/quoting states.

Returns:

  • (Boolean)


748
749
750
# File 'app/models/opportunity.rb', line 748

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

#quote_activitiesActiveRecord::Relation<Activity>

Activities across this opportunity's quotes.

Returns:

See Also:



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

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

#quotesActiveRecord::Relation<Quote>

Quotes issued for this opportunity.

Returns:

  • (ActiveRecord::Relation<Quote>)

See Also:



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

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

#quotes_completed?Boolean

Whether any quote is completed.

Returns:

  • (Boolean)


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

def quotes_completed?
  quotes.completed_quotes.present?
end

#quoting_event?Boolean

Whether any quote or room configuration is currently in quoting.

Returns:

  • (Boolean)


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

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)


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

def ready_for_follow_up?
  quotes_completed? && contactable_for_follow_up?
end

#recalculate_customer_profiling_informationtrue

Enqueues a recalculation of the customer's profiling information.

Returns:

  • (true)


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

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

All activities related to this opportunity.

Returns:



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

def related_activities
  Activity.where(id: related_activities_ids)
end

IDs of every activity related to this opportunity (direct, room, quote).

Returns:

  • (Array<Integer>)


944
945
946
# File 'app/models/opportunity.rb', line 944

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

#reset_rooms_last_heat_losstrue

Clears the cached last_heat_loss on every room configuration.

Returns:

  • (true)


1280
1281
1282
1283
# File 'app/models/opportunity.rb', line 1280

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

#room_activitiesActiveRecord::Relation<Activity>

Activities across this opportunity's room configurations.

Returns:

See Also:



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

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

#room_configurationsActiveRecord::Relation<RoomConfiguration>

Room configurations on this opportunity.

Returns:

See Also:

Validations (if => #must_have_one_room ):



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

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

#room_uploadsActiveRecord::Relation<Upload>

Uploads across this opportunity's room configurations.

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



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

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

#sales_opportunity?Boolean

Whether this is a sales-type opportunity.

Returns:

  • (Boolean)


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

def sales_opportunity?
  opportunity_type == 'S'
end

#sales_present?Boolean

Whether this opportunity produced a WarmlyYours sale.

An outlet purchase counts only once a human has marked it as closing this
opportunity (CustomerOutletPurchase#closes_opportunity) — merely
claiming one is attribution, not closure. The distinction is load-bearing
twice over:

  • Transitioning to won cancels every follow-up activity (see the
    after_transition below), and 28% of matched purchases are under a
    quarter of the quoted value. Auto-closing would kill the chase on the
    rest of a live project.
  • after_save :update_sync_state calls sync_state on every opportunity
    save, so anything this method returns true for gets closed by the back
    door on an unrelated edit.

The order FK is still deliberately never set on a retailer order — see
doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md § 3.

Returns:

  • (Boolean)


688
689
690
# File 'app/models/opportunity.rb', line 688

def sales_present?
  orders.non_carts.so_only.active.present? || outlet_purchases.closing.exists?
end

#sales_support_repEmployee

Sales support rep assigned.

Returns:

See Also:



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

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



1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
# File 'app/models/opportunity.rb', line 1339

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

Secondary sales rep assigned.

Returns:

See Also:



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

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

#selection_nameString

Label used in select dropdowns: reference number, name, and customer.

Returns:

  • (String)


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

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

#send_online_email_confirmationHash?

Sends the "opportunity received" email confirmation for online leads.

Returns:

  • (Hash, nil)

    status hash with :status_code/:status_message, or nil
    when not applicable



922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
# File 'app/models/opportunity.rb', line 922

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) ⇒ Order (protected)

Applies opportunity defaults to a new order built from this opportunity.

Parameters:

  • new_order (Order)

    order to populate

Returns:



1506
1507
1508
1509
# File 'app/models/opportunity.rb', line 1506

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

#set_default_for_quote(new_quote) ⇒ Quote (protected)

Applies customer defaults to a new quote built from this opportunity.

Parameters:

  • new_quote (Quote)

    quote to populate

Returns:



1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
# File 'app/models/opportunity.rb', line 1490

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

Whether the opportunity should be abandoned today.

Returns:

  • (Boolean)


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

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

#sms_enabled_numbersArray<String>

SMS-capable numbers of all participants, deduplicated.

Returns:

  • (Array<String>)


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

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

#sms_messagesActiveRecord::Relation<SmsMessage>

SMS messages sent to/from the participants' numbers.

Returns:



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

def sms_messages
  SmsMessage.for_numbers(sms_enabled_numbers)
end

#sourceSource

Marketing source attributed to this opportunity.

Returns:

See Also:



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

belongs_to :source, optional: true

#state_description(describe_state = nil) ⇒ String?

Human-readable description of a state (defaults to the current state).

Parameters:

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

    state to describe

Returns:

  • (String, nil)


661
662
663
# File 'app/models/opportunity.rb', line 661

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

#sync_stateBoolean?

Auto-derives the state from activity/quote/order signals unless terminal.

Returns:

  • (Boolean, nil)

    result of the fired transition



567
568
569
570
571
572
573
574
575
576
577
578
579
# File 'app/models/opportunity.rb', line 567

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



1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
# File 'app/models/opportunity.rb', line 1474

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

Technical support rep assigned.

Returns:

See Also:



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

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

#technical_support_rep_secEmployee

Secondary technical support rep assigned.

Returns:

See Also:



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

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

#to_sString

String representation: name plus humanized state.

Returns:

  • (String)


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

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

#tracking_email_addressString

Tracking email address embedding the encrypted opportunity id.

Returns:

  • (String)


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

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) ⇒ ActiveRecord::Relation

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

Parameters:

  • _pagination (Hash)

    pagination options accepted for controller compatibility (unused; pagination happens at the controller)

Options Hash (**_pagination):

  • page (Integer)

    page number

  • items (Integer)

    items per page

Returns:

  • (ActiveRecord::Relation)

    the unattached orders



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

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) ⇒ String

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.

Parameters:

  • base_name (String)

    requested room name

  • except (RoomConfiguration, nil) (defaults to: nil)

    record to exclude from the
    collision check (typically the record being edited)

Returns:

  • (String)

    unique room name



853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
# File 'app/models/opportunity.rb', line 853

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?

Whether the name still carries the unknown-job prefix.

Returns:

  • (Boolean, nil)


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

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

#update_primary_product_line_namevoid

This method returns an undefined value.

Persists the primary product line name from the first room configuration.



1216
1217
1218
1219
# File 'app/models/opportunity.rb', line 1216

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>

File uploads attached to this opportunity.

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



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

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

#visitVisit

Web visit that created this opportunity.

Returns:

See Also:



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

belongs_to :visit, optional: true

#voice_callable?Boolean

Whether the primary party has a voice-callable phone number.

Returns:

  • (Boolean)


1374
1375
1376
# File 'app/models/opportunity.rb', line 1374

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