Class: Opportunity
Overview
== Schema Information
Table name: opportunities
Database name: primary
id :integer not null, primary key
close_date :date
discount_percentage :decimal(10, 2)
electricity_rate :decimal(6, 3)
google_conversion_meta :jsonb
installation_postal_code :string(255)
latitude :decimal(9, 6)
legacy_awaiting_iq_transmission :boolean
longitude :decimal(9, 6)
lost_reason_code :string(255)
lost_reason_notes :text
media :string(255)
msrp_value :decimal(10, 2)
name :string(80) not null
opportunity_reception_type :string(255) default("CRM"), not null
opportunity_type :string(1) default("S"), not null
pinterest_conversion_meta :jsonb
planned_installation_date :datetime
primary_product_line_name :string(255)
purchase_outlet :string(255)
purchased_competitor :string(255)
purchased_system :string(255)
quote_underlayment_by_room :boolean
reference_number :string
specs_eta :datetime
state :string(255)
state_code :string(2)
summary :string(240)
value :decimal(10, 2)
won_lost_date :datetime
created_at :datetime
updated_at :datetime
account_specialist_id :integer
buying_group_id :integer
contact_id :integer
creator_id :integer
current_heat_loss_room_id :integer
customer_id :integer not null
local_sales_rep_id :integer
original_sales_rep_id :integer
primary_sales_rep_id :integer
sales_support_rep_id :integer
secondary_sales_rep_id :integer
source_id :integer
technical_support_rep_id :integer
technical_support_rep_sec_id :integer
updater_id :integer
visit_id :bigint
Indexes
idx_tsearch_opp_name (to_tsvector('hw_name_search'::regconfig, COALESCE((name)::text, ''::text))) USING gin
index_opportunities_created_at (created_at)
index_opportunities_on_contact_id (contact_id)
index_opportunities_on_current_heat_loss_room_id (current_heat_loss_room_id)
index_opportunities_on_google_conversion_meta (google_conversion_meta) USING gin
index_opportunities_on_legacy_awaiting_iq_transmission (legacy_awaiting_iq_transmission)
index_opportunities_on_opportunity_type (opportunity_type)
index_opportunities_on_quote_underlayment_by_room (quote_underlayment_by_room)
index_opportunities_on_sales_support_rep_id (sales_support_rep_id)
index_opportunities_on_source_id (source_id)
index_opportunities_on_technical_support_rep_sec_id (technical_support_rep_sec_id)
index_opportunities_on_visit_id (visit_id) WHERE (visit_id IS NOT NULL) USING hash
opportunities_customer_id_index (customer_id)
opportunities_state_index (state)
opportunities_technical_support_rep_id_idx (technical_support_rep_id)
Foreign Keys
fk_rails_... (current_heat_loss_room_id => room_configurations.id) ON DELETE => nullify
fk_rails_... (source_id => sources.id)
fk_rails_... (technical_support_rep_id => parties.id)
fk_rails_... (visit_id => visits.id) ON DELETE => nullify
Defined Under Namespace
Classes: Copier, Merger, Mover
Constant Summary
collapse
- REFERENCE_NUMBER_PATTERN =
/^ON\d+$/i
- UNKNOWN_JOB_NAME_PREFIX =
'Job'
Models::Auditable::ALWAYS_IGNORED
Instance Attribute Summary collapse
#creator, #updater
Has and belongs to many
collapse
Class Method Summary
collapse
Instance Method Summary
collapse
#has_google_ads_attribution?, #source_locked?, #source_locked_reason, #visit_with_google_click_id?
#quick_note
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
#publish_event
Instance Attribute Details
#create_oppfu ⇒ Object
Returns the value of attribute create_oppfu.
181
182
183
|
# File 'app/models/opportunity.rb', line 181
def create_oppfu
@create_oppfu
end
|
#electricity_rate ⇒ Object
DB column is numeric(6,3) — guard before PG::NumericValueOutOfRange (AppSignal #4498)
Validations:
-
Numericality
({ greater_than_or_equal_to: 0, less_than: 1000, allow_nil: true })
139
140
|
# File 'app/models/opportunity.rb', line 139
validates :electricity_rate,
numericality: { greater_than_or_equal_to: 0, less_than: 1000, allow_nil: true }
|
#msrp_value ⇒ Object
136
137
|
# File 'app/models/opportunity.rb', line 136
validates :msrp_value,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true }
|
#must_have_one_room ⇒ Object
Returns the value of attribute must_have_one_room.
180
181
182
|
# File 'app/models/opportunity.rb', line 180
def must_have_one_room
@must_have_one_room
end
|
#name ⇒ Object
128
|
# File 'app/models/opportunity.rb', line 128
validates :name, presence: true, length: { within: 1..80 }
|
#opportunity_type ⇒ Object
132
|
# File 'app/models/opportunity.rb', line 132
validates :opportunity_type, inclusion: { in: OpportunityConstants::OPPORTUNITY_TYPES.keys }
|
#summary ⇒ Object
130
|
# File 'app/models/opportunity.rb', line 130
validates :summary, length: { within: 1..240 }, if: -> { summary.present? }
|
#value ⇒ Object
DB column is numeric(10,2) — guard before PG::NumericValueOutOfRange (AppSignal #4498)
Validations:
-
Numericality
({ greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true })
134
135
|
# File 'app/models/opportunity.rb', line 134
validates :value,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 99_999_999.99, allow_nil: true }
|
Class Method Details
.active ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are active. Active Record Scope
159
|
# File 'app/models/opportunity.rb', line 159
scope :active, -> { open_opportunities.with_quotes_and_rooms }
|
.all_competitors ⇒ Object
517
518
519
|
# File 'app/models/opportunity.rb', line 517
def self.all_competitors
OpportunityConstants::COMPETITOR_SYSTEM.values.flatten.compact.uniq.sort
end
|
.awaiting_iq_transmission ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are awaiting iq transmission. Active Record Scope
170
|
# File 'app/models/opportunity.rb', line 170
scope :awaiting_iq_transmission, -> { where(legacy_awaiting_iq_transmission: true) }
|
.fix_postal_codes ⇒ Object
509
510
511
512
513
514
515
|
# File 'app/models/opportunity.rb', line 509
def self.fix_postal_codes
opps = Opportunity.where.not(installation_postal_code: nil).pluck(:id, :installation_postal_code)
opps.each do |r|
pc = r[1].scan(/^\d{5}/)&.first || r[1].scan(/([A-Za-z]\d[A-Za-z]) ?(\d[A-Za-z]\d)/)&.first&.join&.upcase
Opportunity.where(id: r[0]).update_all(installation_postal_code: pc) if pc && pc != r[1]
end
end
|
978
979
980
|
# File 'app/models/opportunity.rb', line 978
def self.format_address_for_zip_selection(address)
"#{address.zip_compact} - #{address.city} #{address.state}"
end
|
.get_unique_name(party, name) ⇒ Object
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
|
# File 'app/models/opportunity.rb', line 565
def self.get_unique_name(party, name)
logger.info "self.get_unique_name(party,name): party.name: #{party.name}"
logger.info "self.get_unique_name(party,name): name: #{name}"
name_root = name.split(' #').first
same_name_opps = party.opportunities.where('name LIKE ?', "#{name_root}%")
if same_name_opps.any?
same_name_opp_count = same_name_opps.length + 1
new_opp_name = nil
loop do
new_opp_name = "#{name_root} ##{same_name_opp_count}"
same_name_opp_count += 1
break unless party.opportunities.exists?(name: new_opp_name)
end
else
new_opp_name = name
end
logger.info "self.get_unique_name(party,name): new_opp_name: #{new_opp_name}"
new_opp_name
end
|
.instant_quoting ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are instant quoting. Active Record Scope
166
|
# File 'app/models/opportunity.rb', line 166
scope :instant_quoting, -> { where(state: 'instant_quoting') }
|
.lost ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost. Active Record Scope
161
|
# File 'app/models/opportunity.rb', line 161
scope :lost, -> { where(state: 'lost') }
|
.lost_or_abandoned ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost or abandoned. Active Record Scope
162
|
# File 'app/models/opportunity.rb', line 162
scope :lost_or_abandoned, -> { where(state: %w[lost abandoned]) }
|
.most_recent_first ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are most recent first. Active Record Scope
155
|
# File 'app/models/opportunity.rb', line 155
scope :most_recent_first, -> { order(:created_at).reverse_order }
|
.not_cancelled ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are not cancelled. Active Record Scope
164
|
# File 'app/models/opportunity.rb', line 164
scope :not_cancelled, -> { where.not(state: 'cancelled') }
|
.not_won_or_lost ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are not won or lost. Active Record Scope
158
|
# File 'app/models/opportunity.rb', line 158
scope :not_won_or_lost, -> { where.not(state: %w[won lost]) }
|
.open_for_iq ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are open for iq. Active Record Scope
167
|
# File 'app/models/opportunity.rb', line 167
scope :open_for_iq, -> { not_cancelled.with_quotes_and_rooms }
|
.open_opportunities ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are open opportunities. Active Record Scope
157
|
# File 'app/models/opportunity.rb', line 157
scope :open_opportunities, -> { where.not(state: %w[won lost abandoned cancelled untracked]) }
|
.opportunity_reception_types_for_select ⇒ Object
529
530
531
|
# File 'app/models/opportunity.rb', line 529
def self.opportunity_reception_types_for_select
OpportunityConstants::RECEPTION_TYPES.map { |rt| [rt, rt] }
end
|
.opportunity_types_for_select ⇒ Object
525
526
527
|
# File 'app/models/opportunity.rb', line 525
def self.opportunity_types_for_select
OpportunityConstants::OPPORTUNITY_TYPES.map { |k, v| [v, k] }
end
|
.started_as_iq_projects ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are started as iq projects. Active Record Scope
165
|
# File 'app/models/opportunity.rb', line 165
scope :started_as_iq_projects, -> { where(opportunity_reception_type: 'IQ') }
|
.states_for_select ⇒ Object
521
522
523
|
# File 'app/models/opportunity.rb', line 521
def self.states_for_select
state_machines[:state].states.map { |s| [s.human_name, s.value] }
end
|
.with_quotes_and_rooms ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with quotes and rooms. Active Record Scope
156
|
# File 'app/models/opportunity.rb', line 156
scope :with_quotes_and_rooms, -> { includes(:quotes, :room_configurations).most_recent_first }
|
.with_value ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with value. Active Record Scope
163
|
# File 'app/models/opportunity.rb', line 163
scope :with_value, -> { where.not(value: nil) }
|
.won ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are won. Active Record Scope
160
|
# File 'app/models/opportunity.rb', line 160
scope :won, -> { where(state: 'won') }
|
Instance Method Details
#abandonable? ⇒ Boolean
475
476
477
478
479
|
# File 'app/models/opportunity.rb', line 475
def abandonable?
related_activities.open_activities.blank? &&
(interest? || qualify? || follow_up?) &&
!sales_present?
end
|
#abandonment_date ⇒ Object
489
490
491
|
# File 'app/models/opportunity.rb', line 489
def abandonment_date
last_movement.to_date + 60.days
end
|
#account_specialist ⇒ Employee
103
|
# File 'app/models/opportunity.rb', line 103
belongs_to :account_specialist, class_name: 'Employee', optional: true
|
#activities ⇒ ActiveRecord::Relation<Activity>
113
|
# File 'app/models/opportunity.rb', line 113
has_many :activities, as: :resource, dependent: :nullify
|
#add_customer_to_campaign ⇒ Object
399
400
401
|
# File 'app/models/opportunity.rb', line 399
def add_customer_to_campaign
source.add_customer_to_campaign(customer)
end
|
#all_activities ⇒ Object
425
426
427
|
# File 'app/models/opportunity.rb', line 425
def all_activities
linked_activities
end
|
#all_participants ⇒ Object
927
928
929
|
# File 'app/models/opportunity.rb', line 927
def all_participants
Party.where(id: [customer_id, contact_id, *opportunity_participants.pluck(:party_id)].compact.uniq)
end
|
412
413
414
415
416
417
418
419
420
421
422
423
|
# File 'app/models/opportunity.rb', line 412
def all_related_communications
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_reps ⇒ Object
938
939
940
|
# File 'app/models/opportunity.rb', line 938
def all_reps
Employee.where(id: all_reps_ids)
end
|
#all_reps_ids ⇒ Object
931
932
933
934
935
936
|
# File 'app/models/opportunity.rb', line 931
def all_reps_ids
[
primary_sales_rep_id, secondary_sales_rep_id, local_sales_rep_id, account_specialist_id, technical_support_rep_id,
customer.primary_sales_rep_id, customer.secondary_sales_rep_id, customer.service_rep_id
].compact.uniq
end
|
#all_uploads ⇒ Object
387
388
389
390
391
|
# File 'app/models/opportunity.rb', line 387
def all_uploads
Upload.where("(resource_type = 'Opportunity' and resource_id = ?) OR (resource_type = 'RoomConfiguration' and resource_id IN (?))", id, room_configuration_ids)
.includes(:resource)
.order('uploads.created_at DESC')
end
|
#build_activity ⇒ Object
403
404
405
|
# File 'app/models/opportunity.rb', line 403
def build_activity
activities.build resource: self, party: primary_party
end
|
108
|
# File 'app/models/opportunity.rb', line 108
belongs_to :buying_group, optional: true
|
#calculate_value ⇒ Object
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
|
# File 'app/models/opportunity.rb', line 762
def calculate_value
return if destroyed?
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
347
348
349
|
# File 'app/models/opportunity.rb', line 347
def can_be_moved?
orders.active.non_carts.blank?
end
|
#cancel_followups(include_large_op_activities: false) ⇒ Object
710
711
712
713
714
715
716
717
|
# File 'app/models/opportunity.rb', line 710
def cancel_followups(include_large_op_activities: false)
sales_activity_ids = ActivityType.sales_activities.pluck(:id)
sales_activity_ids.delete(ActivityTypeConstants::NEW_LARGE_OP_ID) unless include_large_op_activities
prune_activities nil, sales_activity_ids
end
|
#cancel_open_quotes ⇒ Object
Best-effort cancel of in-progress quotes when the opportunity is closed.
Skips quotes that aren't in a cancelable state (e.g. already complete or
blocked by active orders); the sync_state guard above prevents any
remaining in_quoting quotes from flipping the opportunity back open.
381
382
383
384
385
|
# File 'app/models/opportunity.rb', line 381
def cancel_open_quotes
quotes.in_quoting.find_each do |q|
q.cancel if q.cancelable?
end
end
|
#cancel_oppfu ⇒ Object
974
975
976
|
# File 'app/models/opportunity.rb', line 974
def cancel_oppfu
activities.where(activity_type_id: oppfu_activity_type.id).find_each(&:cancel)
end
|
#cancelable? ⇒ Boolean
703
704
705
706
707
708
|
# File 'app/models/opportunity.rb', line 703
def cancelable?
OpportunityConstants::CANCELABLE_STATES.include?(state.to_sym) &&
quotes.active.completed_quotes.empty? &&
room_configurations.active.empty? &&
orders.not_cancelled.empty?
end
|
#closed? ⇒ Boolean
355
356
357
|
# File 'app/models/opportunity.rb', line 355
def closed?
won? || lost? || cancelled? || abandoned?
end
|
99
|
# File 'app/models/opportunity.rb', line 99
belongs_to :contact, inverse_of: :opportunities, optional: true
|
662
663
664
665
666
|
# File 'app/models/opportunity.rb', line 662
def contact_select_options
contacts_to_use = customer.contacts.active.to_a.map { |c| ["#{c.full_name}#{" (#{c.job_title})" if c.job_title.present?} ", c.id] }
contacts_to_use << [contact.full_name, contact.id] if contact&.inactive
contacts_to_use.sort
end
|
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).
459
460
461
|
# File 'app/models/opportunity.rb', line 459
def contactable_for_follow_up?
!customer.guest? || emailable? || voice_callable?
end
|
#create_follow_up_activity(follow_up_note: nil, current_user: nil, follow_up_activity_type: nil, follow_up_task_type: nil, target_date: nil) ⇒ Object
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
|
# File 'app/models/opportunity.rb', line 719
def create_follow_up_activity(follow_up_note: nil, current_user: nil, follow_up_activity_type: nil, follow_up_task_type: nil, target_date: nil)
return unless sales_opportunity?
follow_up_activity_type ||= ActivityType.find_by(task_type: follow_up_task_type)
follow_up_activity_type ||= ActivityType.find(ActivityTypeConstants::QUOFU)
current_user ||= Employee.find_by(id: CurrentScope.user_id)
assigned_rep = follow_up_activity_type.determine_assigned_resource(customer) || current_user || customer.sales_manager
target_date ||= assigned_rep.next_business_day_closing_time
follow_up_note ||= "Follow up on opportunity #{name}"
new_activity_values = { activity_type: follow_up_activity_type,
target_datetime: target_date,
party: primary_party,
resource: self,
assigned_resource: assigned_rep,
new_note: follow_up_note }
Activity.create(new_activity_values)
end
|
#create_heat_loss_room ⇒ Object
797
798
799
800
801
802
803
804
805
806
807
808
|
# File 'app/models/opportunity.rb', line 797
def create_heat_loss_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_link ⇒ Object
668
669
670
|
# File 'app/models/opportunity.rb', line 668
def crm_link
UrlHelper.instance.opportunity_path(self)
end
|
#current_heat_loss_room ⇒ RoomConfiguration
110
|
# File 'app/models/opportunity.rb', line 110
belongs_to :current_heat_loss_room, class_name: 'RoomConfiguration', optional: true
|
Validations:
107
|
# File 'app/models/opportunity.rb', line 107
belongs_to :customer, inverse_of: :opportunities, optional: true
|
#deep_dup ⇒ Object
95
96
97
|
# File 'app/models/opportunity.rb', line 95
def deep_dup
deep_clone(except: :reference_number)
end
|
#digital_assets ⇒ ActiveRecord::Relation<DigitalAsset>
126
|
# File 'app/models/opportunity.rb', line 126
has_and_belongs_to_many :digital_assets, inverse_of: :opportunities
|
#editable? ⇒ Boolean
654
655
656
|
# File 'app/models/opportunity.rb', line 654
def editable?
%w[won lost cancelled abandoned].exclude?(state)
end
|
#effective_opportunity_won_date ⇒ Object
407
408
409
410
|
# File 'app/models/opportunity.rb', line 407
def effective_opportunity_won_date
[orders.active.minimum(:shipped_date).try(:end_of_day), Time.current].compact.min
end
|
#emailable? ⇒ Boolean
915
916
917
|
# File 'app/models/opportunity.rb', line 915
def emailable?
emails.present?
end
|
#emails ⇒ Object
911
912
913
|
# File 'app/models/opportunity.rb', line 911
def emails
primary_party.contact_points.emails
end
|
#estimate_electricity_rate ⇒ Object
964
965
966
967
968
969
970
971
972
|
# File 'app/models/opportunity.rb', line 964
def estimate_electricity_rate
res = nil
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
429
430
431
|
# File 'app/models/opportunity.rb', line 429
def exclude_manually_initiated_event?(event)
%i[iq_advanced reset].include?(event.to_sym)
end
|
#find_gbraid ⇒ Object
750
751
752
|
# File 'app/models/opportunity.rb', line 750
def find_gbraid
visit&.gbraid || customer&.visit&.gbraid
end
|
#find_gclid ⇒ Object
740
741
742
|
# File 'app/models/opportunity.rb', line 740
def find_gclid
visit&.gclid || customer.gclid || customer&.visit&.gclid
end
|
#find_wbraid ⇒ Object
745
746
747
|
# File 'app/models/opportunity.rb', line 745
def find_wbraid
visit&.wbraid || customer&.visit&.wbraid
end
|
#heat_loss_room ⇒ Object
793
794
795
|
# File 'app/models/opportunity.rb', line 793
def heat_loss_room
current_heat_loss_room || create_heat_loss_room
end
|
#in_canada? ⇒ Boolean
810
811
812
|
# File 'app/models/opportunity.rb', line 810
def in_canada?
installation_postal_code.present? && installation_country_iso3 == 'CAN'
end
|
#in_usa? ⇒ Boolean
814
815
816
|
# File 'app/models/opportunity.rb', line 814
def in_usa?
installation_postal_code.present? && installation_country_iso3 == 'USA'
end
|
#installation_country_iso ⇒ Object
831
832
833
|
# File 'app/models/opportunity.rb', line 831
def installation_country_iso
installation_country_iso3.first(2)
end
|
#installation_country_iso3 ⇒ Object
824
825
826
827
828
829
|
# File 'app/models/opportunity.rb', line 824
def installation_country_iso3
customer&.store&.country_iso3 ||
PostalCode.find_by(code: installation_postal_code)&.state&.country_iso3 ||
{ 'en-CA': 'CAN', 'en-US': 'USA' }[I18n.locale] ||
'USA'
end
|
#installation_postal_codes_for_select ⇒ Object
#installation_state_code ⇒ Object
818
819
820
821
822
|
# File 'app/models/opportunity.rb', line 818
def installation_state_code
return if installation_postal_code.blank?
PostalCode.get_state_code_from_postal_code(installation_postal_code)
end
|
#last_movement ⇒ Object
485
486
487
|
# File 'app/models/opportunity.rb', line 485
def last_movement
[planned_installation_date, last_quote_date, last_room_date, last_sales_activity_completion, created_at].compact.max
end
|
#last_quote_date ⇒ Object
497
498
499
|
# File 'app/models/opportunity.rb', line 497
def last_quote_date
quotes.maximum(:created_at)
end
|
#last_room_date ⇒ Object
501
502
503
|
# File 'app/models/opportunity.rb', line 501
def last_room_date
room_configurations.maximum(:created_at)
end
|
#last_sales_activity_completion ⇒ Object
493
494
495
|
# File 'app/models/opportunity.rb', line 493
def last_sales_activity_completion
related_activities.sales_activities.maximum(:completion_datetime)
end
|
#linked_activities ⇒ ActiveRecord::Relation<Activity>
124
|
# File 'app/models/opportunity.rb', line 124
has_many :linked_activities, class_name: 'Activity'
|
#local_sales_rep ⇒ Employee
102
|
# File 'app/models/opportunity.rb', line 102
belongs_to :local_sales_rep, class_name: 'Employee', optional: true
|
#location ⇒ Object
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
|
# File 'app/models/opportunity.rb', line 840
def location
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)
if (r = Geocoder.search(postal_code).first)
loc = "#{r.city}, #{r.state_code} #{postal_code}"
end
elsif (v = visit || customer&.visit)
loc = begin
[v.city, v.region, v.postal_code].compact_blank.join(' ')
rescue StandardError
nil
end
end
loc
end
|
#merge_with(opp_to_merge, delete_after: false) ⇒ Object
687
688
689
690
691
692
693
|
# File 'app/models/opportunity.rb', line 687
def merge_with(opp_to_merge, delete_after: false)
opp_to_merge.room_configurations.each { |rc| rc.update(opportunity: self) }
opp_to_merge.activities.each { |act| act.update(resource: self) }
opp_to_merge.quotes.each { |quote| quote.update(opportunity: self) }
opp_to_merge.reload
opp_to_merge.destroy if delete_after == true
end
|
#next_room_name(options) ⇒ Object
672
673
674
675
676
677
678
679
680
681
682
683
684
685
|
# File 'app/models/opportunity.rb', line 672
def next_room_name(options)
room_base_name = options[:room_base_name] || options[:room_type]&.name || 'Room'
same_name_rooms = room_configurations.where('name LIKE ?', "#{room_base_name}%")
same_name_room_count = same_name_rooms.length || 0
return room_base_name if same_name_room_count.zero?
new_room_name = nil
loop do
same_name_room_count += 1
new_room_name = "#{room_base_name} ##{same_name_room_count}"
break unless room_configurations.exists?(name: new_room_name)
end
new_room_name
end
|
#ok_to_customer_cancel? ⇒ Boolean
699
700
701
|
# File 'app/models/opportunity.rb', line 699
def ok_to_customer_cancel?
OpportunityConstants::CUSTOMER_CANCELABLE_STATES.include?(state.to_sym) && quotes.active.completed_quotes.empty? && room_configurations.active.empty? && orders.not_cancelled.empty?
end
|
#ok_to_delete? ⇒ Boolean
695
696
697
|
# File 'app/models/opportunity.rb', line 695
def ok_to_delete?
quotes.completed_quotes.empty? && room_configurations.non_drafts.empty? && orders.empty?
end
|
#open_activities_counter ⇒ Object
596
597
598
|
# File 'app/models/opportunity.rb', line 596
def open_activities_counter
related_activities.visible_by_default.open_activities.size
end
|
#open_opp_fu? ⇒ Boolean
351
352
353
|
# File 'app/models/opportunity.rb', line 351
def open_opp_fu?
activities.open_activities.joins(:activity_type).where(activity_types: { task_type: 'OPPFU' }).present?
end
|
#open_opportunity? ⇒ Boolean
505
506
507
|
# File 'app/models/opportunity.rb', line 505
def open_opportunity?
interest? || qualify? || quoting? || follow_up?
end
|
#opportunity_participants ⇒ ActiveRecord::Relation<OpportunityParticipant>
122
|
# File 'app/models/opportunity.rb', line 122
has_many :opportunity_participants, inverse_of: :opportunity, dependent: :destroy, autosave: true
|
#opportunity_type_expanded ⇒ Object
586
587
588
|
# File 'app/models/opportunity.rb', line 586
def opportunity_type_expanded
OpportunityConstants::OPPORTUNITY_TYPES[opportunity_type]
end
|
#order_activities ⇒ ActiveRecord::Relation<Activity>
121
|
# File 'app/models/opportunity.rb', line 121
has_many :order_activities, class_name: 'Activity', through: :orders, source: :activities
|
#orders ⇒ ActiveRecord::Relation<Order>
116
|
# File 'app/models/opportunity.rb', line 116
has_many :orders, before_add: :set_default_for_order
|
#participants_options_for_select ⇒ Object
923
924
925
|
# File 'app/models/opportunity.rb', line 923
def participants_options_for_select
opportunity_participants.map { |scp| [scp.party.full_name, scp.party_id] }
end
|
#parties ⇒ ActiveRecord::Relation<Party>
123
|
# File 'app/models/opportunity.rb', line 123
has_many :parties, through: :opportunity_participants
|
#planned_installation_date_time_frame ⇒ Object
942
943
944
945
946
947
948
949
950
|
# File 'app/models/opportunity.rb', line 942
def planned_installation_date_time_frame
return nil if planned_installation_date.nil?
time_diff = planned_installation_date - Time.current
OpportunityConstants::PLANNED_INSTALLATION_DATE_TIME_FRAMES_IN_WEEKS.each do |k, v|
return k if (time_diff > v.first.weeks) && (time_diff <= v.last.weeks)
end
nil
end
|
#planned_installation_date_time_frame=(val) ⇒ Object
952
953
954
955
956
957
958
959
960
961
962
|
# File 'app/models/opportunity.rb', line 952
def planned_installation_date_time_frame=(val)
Rails.logger.debug { "planned_installation_date_time_frame= #{val.inspect}" }
if val.present?
week_range = OpportunityConstants::PLANNED_INSTALLATION_DATE_TIME_FRAMES_IN_WEEKS[val.to_sym]
date = week_range.present? ? week_range.average.weeks.from_now : nil
Rails.logger.debug { "planned_installation_date_time_frame= planned_installation_date: #{planned_installation_date}, week_range: #{week_range}" }
else
date = nil
end
update(planned_installation_date: date)
end
|
#primary_party ⇒ Object
658
659
660
|
# File 'app/models/opportunity.rb', line 658
def primary_party
contact || customer
end
|
#primary_sales_rep ⇒ Employee
100
|
# File 'app/models/opportunity.rb', line 100
belongs_to :primary_sales_rep, class_name: 'Employee', optional: true
|
#product_lines(reviewable_only: true) ⇒ Object
We will gather all product lines either ordered or quoted or in the rooms
870
871
872
873
874
875
876
877
878
879
880
881
882
|
# File 'app/models/opportunity.rb', line 870
def product_lines(reviewable_only: true)
pls = if (o = orders.last)
LineItem.collect_product_lines(o.line_items.goods)
elsif (q = quotes.last)
LineItem.collect_product_lines(q.line_items.goods)
else
LineItem.collect_product_lines(room_configurations.map(&:line_items).flatten)
end
pls ||= []
pls = pls.map(&:get_first_reviewable) if reviewable_only
pls.compact.uniq
end
|
#prune_activities(exclude_activity_id = nil, activity_type_ids = nil) ⇒ Object
This function looks for a particular activity in the context of the opportunity and cancel any found except the excluded one.
635
636
637
638
639
640
641
642
643
|
# File 'app/models/opportunity.rb', line 635
def prune_activities(exclude_activity_id = nil, activity_type_ids = nil)
activity_type_ids ||= ActivityTypeConstants::QUOFUS_IDS
activities_to_prune = related_activities.open_activities.where(activity_type_id: activity_type_ids)
activities_to_prune = activities_to_prune.where.not(id: exclude_activity_id) if exclude_activity_id.present?
activities_to_prune.each do |a|
logger.info "Pruning and closing activity #{a.id}"
a.cancel
end
end
|
#qualified_opportunity? ⇒ Boolean
467
468
469
|
# File 'app/models/opportunity.rb', line 467
def qualified_opportunity?
customer && !customer.guest? && qualified_room_present?
end
|
#qualified_room_present? ⇒ Boolean
471
472
473
|
# File 'app/models/opportunity.rb', line 471
def qualified_room_present?
room_configurations.where.not(state: %w[draft instant_quoting cancelled]).present?
end
|
#quote_activities ⇒ ActiveRecord::Relation<Activity>
120
|
# File 'app/models/opportunity.rb', line 120
has_many :quote_activities, class_name: 'Activity', through: :quotes, source: :activities
|
#quotes ⇒ ActiveRecord::Relation<Quote>
115
|
# File 'app/models/opportunity.rb', line 115
has_many :quotes, dependent: :destroy, before_add: :set_default_for_quote
|
#quotes_completed? ⇒ Boolean
445
446
447
|
# File 'app/models/opportunity.rb', line 445
def quotes_completed?
quotes.completed_quotes.present?
end
|
#quoting_event? ⇒ Boolean
463
464
465
|
# File 'app/models/opportunity.rb', line 463
def quoting_event?
quotes.in_quoting.present? || room_configurations.in_quoting.present?
end
|
#ready_for_follow_up? ⇒ Boolean
Ready for follow-up requires completed quotes AND a way to contact the customer.
This prevents Quote Builder opportunities from anonymous users (guest customers
with no contact info) from transitioning to follow_up state where they would
create noise in the CRM with no way for sales to action them.
453
454
455
|
# File 'app/models/opportunity.rb', line 453
def ready_for_follow_up?
quotes_completed? && contactable_for_follow_up?
end
|
600
601
602
603
604
|
# File 'app/models/opportunity.rb', line 600
def recalculate_customer_profiling_information
customer.recalculate_profiling_information
true
end
|
630
631
632
|
# File 'app/models/opportunity.rb', line 630
def related_activities
Activity.where(id: related_activities_ids)
end
|
626
627
628
|
# File 'app/models/opportunity.rb', line 626
def related_activities_ids
[activities.pluck(:id), room_activities.pluck(:id), quote_activities.pluck(:id)].flatten
end
|
#reset_rooms_last_heat_loss ⇒ Object
835
836
837
838
|
# File 'app/models/opportunity.rb', line 835
def reset_rooms_last_heat_loss
room_configurations.update_all(last_heat_loss: nil)
true
end
|
#room_activities ⇒ ActiveRecord::Relation<Activity>
119
|
# File 'app/models/opportunity.rb', line 119
has_many :room_activities, class_name: 'Activity', through: :room_configurations, source: :activities
|
#room_configurations ⇒ ActiveRecord::Relation<RoomConfiguration>
Validations (if => #must_have_one_room ):
114
|
# File 'app/models/opportunity.rb', line 114
has_many :room_configurations, dependent: :destroy, inverse_of: :opportunity
|
#room_uploads ⇒ ActiveRecord::Relation<Upload>
118
|
# File 'app/models/opportunity.rb', line 118
has_many :room_uploads, class_name: 'Upload', through: :room_configurations, source: :uploads
|
#room_without_installation_record?(room = nil) ⇒ Boolean
590
591
592
593
594
|
# File 'app/models/opportunity.rb', line 590
def room_without_installation_record?(room = nil)
rooms = room_configurations.where(installation_date: nil)
rooms = rooms.where.not(id: room.id) if room.present?
rooms.any?
end
|
#sales_opportunity? ⇒ Boolean
437
438
439
|
# File 'app/models/opportunity.rb', line 437
def sales_opportunity?
opportunity_type == 'S'
end
|
#sales_present? ⇒ Boolean
441
442
443
|
# File 'app/models/opportunity.rb', line 441
def sales_present?
orders.non_carts.so_only.active.present?
end
|
#sales_support_rep ⇒ Employee
106
|
# File 'app/models/opportunity.rb', line 106
belongs_to :sales_support_rep, class_name: 'Employee', optional: true
|
#schedule_interest_activity ⇒ Object
This method is responsible for creating the relevant activity based on the
opportunity flow for interest grade type of opportunities. The type of activity
scheduled is based on the contact point presents and the presence of a cart
INTEREST_CALL - If Phone only present
INTEREST_EMAIL - If Email preesnt
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
|
# File 'app/models/opportunity.rb', line 890
def schedule_interest_activity
if emailable?
task_type = 'INTEREST_EMAIL'
elsif voice_callable? task_type = 'INTEREST_CALL'
else
return false
end
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_rep ⇒ Employee
101
|
# File 'app/models/opportunity.rb', line 101
belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true
|
#selection_name ⇒ Object
649
650
651
652
|
# File 'app/models/opportunity.rb', line 649
def selection_name
base = "#{reference_number} - #{name}"
customer&.full_name.present? ? "#{base} - #{customer.full_name}" : base
end
|
#send_online_email_confirmation ⇒ Object
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
|
# File 'app/models/opportunity.rb', line 606
def send_online_email_confirmation
return unless opportunity_reception_type == 'Online' && customer&.email.present?
sender = customer.primary_sales_rep
co = CommunicationBuilder.new(
resource: self,
sender_party: sender,
sender: (sender.nil? ? INFO_EMAIL : nil),
recipient_party: customer,
emails: customer.email,
recipient_name: customer.name,
template_system_code: 'OPPORTUNITY_RECEIVED'
).create
if co.draft?
{ status_code: :error, status_message: co.errors_to_s }
else
{ status_code: :ok, status_message: "Opportunity confirmation e-mail sent to #{customer.name} #{customer.email}." }
end
end
|
#set_default_for_order(new_order) ⇒ Object
1021
1022
1023
1024
|
# File 'app/models/opportunity.rb', line 1021
protected def set_default_for_order(new_order)
new_order.order_type = "#{opportunity_type}O"
new_order
end
|
#set_default_for_quote(new_quote) ⇒ Object
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
|
# File 'app/models/opportunity.rb', line 1008
protected def set_default_for_quote(new_quote)
new_quote.validate_opportunity_and_prepared_for = true 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
481
482
483
|
# File 'app/models/opportunity.rb', line 481
def should_abandon?
abandonable? && Date.current > abandonment_date
end
|
#sms_enabled_numbers ⇒ Object
339
340
341
|
# File 'app/models/opportunity.rb', line 339
def sms_enabled_numbers
ContactPoint.joins(:party).merge(all_participants).sms_numbers.order(:detail).map(&:formatted_for_sms).uniq
end
|
#sms_messages ⇒ Object
343
344
345
|
# File 'app/models/opportunity.rb', line 343
def sms_messages
SmsMessage.for_numbers(sms_enabled_numbers)
end
|
109
|
# File 'app/models/opportunity.rb', line 109
belongs_to :source, optional: true
|
#state_description(describe_state = nil) ⇒ Object
433
434
435
|
# File 'app/models/opportunity.rb', line 433
def state_description(describe_state = nil)
OpportunityConstants::STATE_DESCRIPTION[describe_state || state]
end
|
#sync_state ⇒ Object
363
364
365
366
367
368
369
370
371
372
373
374
375
|
# File 'app/models/opportunity.rb', line 363
def sync_state
return if state.in?(%w[won lost abandoned cancelled])
untrack ||
win ||
follow ||
quote ||
(should_abandon? && abandon) ||
send_to_qualify ||
reset
end
|
#synchronize_reps ⇒ Object
Synchronize the reps on this opportunity with the customer
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
|
# File 'app/models/opportunity.rb', line 995
def synchronize_reps
return unless customer
self.primary_sales_rep_id = customer.primary_sales_rep_id
self.secondary_sales_rep_id = customer.secondary_sales_rep_id
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_rep ⇒ Employee
104
|
# File 'app/models/opportunity.rb', line 104
belongs_to :technical_support_rep, class_name: 'Employee', optional: true
|
#technical_support_rep_sec ⇒ Employee
105
|
# File 'app/models/opportunity.rb', line 105
belongs_to :technical_support_rep_sec, class_name: 'Employee', optional: true
|
#to_s ⇒ Object
645
646
647
|
# File 'app/models/opportunity.rb', line 645
def to_s
"#{name} - #{human_state_name}"
end
|
#tracking_email_address ⇒ Object
755
756
757
758
759
760
|
# File 'app/models/opportunity.rb', line 755
def tracking_email_address
domain = Rails.application.config.x.email_domain
prefix = 'opp'
encrypted_id = Encryption.encrypt_string(id.to_s)
"#{prefix}+id#{encrypted_id}@#{domain}"
end
|
#unattached_orders(**_pagination) ⇒ Object
Retrieve all orphaned orders that are candidate to be attached to this opportunity
394
395
396
397
|
# File 'app/models/opportunity.rb', line 394
def unattached_orders(**)
customer.orders.so_only.where(opportunity_id: nil).order('orders.created_at DESC')
end
|
#unique_room_name(base_name, except: nil) ⇒ Object
Generate a room-configuration name unique within this opportunity.
Strips any existing " #N" suffix from base_name so re-deriving from
the same root doesn't pile up ("Basement #2 #3"), then returns the
bare root if free, else "#Top Level Namespace #2", "#Top Level Namespace #3", ... until unique.
Pass except: (typically the record currently being edited) so its own
current name is not counted as a collision.
Modeled on Opportunity.get_unique_name (which uniquifies opportunities
within a customer); the differences are: scoped to this opportunity's
room_configurations, returns the bare root when free, and supports
excluding a record.
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
|
# File 'app/models/opportunity.rb', line 545
def unique_room_name(base_name, except: nil)
return base_name if base_name.blank?
name_root = base_name.to_s.sub(/\s#\d+\z/, '')
scope = room_configurations
scope = scope.where.not(id: except.id) if except&.persisted?
taken = scope.where('name LIKE ?', "#{self.class.sanitize_sql_like(name_root)}%").pluck(:name).to_set
return name_root unless taken.include?(name_root)
n = 2
loop do
candidate = "#{name_root} ##{n}"
return candidate unless taken.include?(candidate)
n += 1
end
end
|
#unknown_job_name? ⇒ Boolean
359
360
361
|
# File 'app/models/opportunity.rb', line 359
def unknown_job_name?
name&.start_with?(UNKNOWN_JOB_NAME_PREFIX)
end
|
#update_primary_product_line_name ⇒ Object
788
789
790
791
|
# File 'app/models/opportunity.rb', line 788
def update_primary_product_line_name
product_line_name = room_configurations.order(:id).map { |a| a.heating_system_product_line.heating_system_type_name }.first
update_column :primary_product_line_name, product_line_name
end
|
#uploads ⇒ ActiveRecord::Relation<Upload>
117
|
# File 'app/models/opportunity.rb', line 117
has_many :uploads, as: :resource, dependent: :destroy
|
111
|
# File 'app/models/opportunity.rb', line 111
belongs_to :visit, optional: true
|
#voice_callable? ⇒ Boolean
919
920
921
|
# File 'app/models/opportunity.rb', line 919
def voice_callable?
primary_party.contact_points.voice_callable.present?
end
|