Class: Opportunity
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Opportunity
- 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
- AUTO_LINK_LOOKBACK_DAYS =
Window in days within which a CRM opp will auto-link to an earlier web opp
for the same customer/project. Matches BackfillOpportunityParents. 180
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#create_oppfu ⇒ Object
Returns the value of attribute create_oppfu.
-
#electricity_rate ⇒ Object
readonly
DB column is numeric(6,3) — guard before PG::NumericValueOutOfRange (AppSignal #4498).
-
#msrp_value ⇒ Object
readonly
MSRP value must fit the numeric(10,2) column.
-
#must_have_one_room ⇒ Object
Returns the value of attribute must_have_one_room.
-
#name ⇒ Object
readonly
Name is required, 1-80 chars.
-
#opportunity_type ⇒ Object
readonly
Opportunity type must be a known type.
-
#summary ⇒ Object
readonly
Summary limited to 240 chars when present.
-
#value ⇒ Object
readonly
DB column is numeric(10,2) — guard before PG::NumericValueOutOfRange (AppSignal #4498).
Belongs to collapse
-
#account_specialist ⇒ Employee
Account specialist assigned.
-
#buying_group ⇒ BuyingGroup
Buying group the customer is purchasing through.
-
#contact ⇒ Contact
Primary contact on the opportunity.
-
#current_heat_loss_room ⇒ RoomConfiguration
Room configuration currently used for heat loss calculations.
-
#customer ⇒ Customer
Customer the opportunity belongs to.
-
#local_sales_rep ⇒ Employee
Local (field) sales rep assigned.
-
#merged_into ⇒ Opportunity
Opportunity this duplicate was merged into.
-
#original_source ⇒ Source
Original marketing source at creation.
-
#parent ⇒ Opportunity
Parent opportunity when this is a child project.
-
#primary_sales_rep ⇒ Employee
Primary sales rep assigned.
-
#sales_support_rep ⇒ Employee
Sales support rep assigned.
-
#secondary_sales_rep ⇒ Employee
Secondary sales rep assigned.
-
#source ⇒ Source
Marketing source attributed to this opportunity.
-
#technical_support_rep ⇒ Employee
Technical support rep assigned.
-
#technical_support_rep_sec ⇒ Employee
Secondary technical support rep assigned.
-
#visit ⇒ Visit
Web visit that created this opportunity.
Methods included from Models::Auditable
Has many collapse
-
#activities ⇒ ActiveRecord::Relation<Activity>
Activity log entries for this opportunity.
-
#children ⇒ ActiveRecord::Relation<Opportunity>
Child opportunities.
-
#linked_activities ⇒ ActiveRecord::Relation<Activity>
Activities linked to this opportunity via the legacy join.
-
#merged_duplicates ⇒ ActiveRecord::Relation<Opportunity>
Duplicate opportunities merged into this one.
-
#opportunity_participants ⇒ ActiveRecord::Relation<OpportunityParticipant>
Join records linking parties to this opportunity.
-
#order_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's orders.
-
#orders ⇒ ActiveRecord::Relation<Order>
Orders won from this opportunity.
-
#outlet_invoices ⇒ ActiveRecord::Relation<OutletInvoice>
Invoices from this opportunity's outlet purchases.
-
#outlet_purchases ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
Outlet purchases are record-keeping only and never drive
state— see doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md. -
#parties ⇒ ActiveRecord::Relation<Party>
Parties participating in this opportunity.
-
#quote_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's quotes.
-
#quotes ⇒ ActiveRecord::Relation<Quote>
Quotes issued for this opportunity.
-
#room_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's room configurations.
-
#room_configurations ⇒ ActiveRecord::Relation<RoomConfiguration>
Room configurations on this opportunity.
-
#room_uploads ⇒ ActiveRecord::Relation<Upload>
Uploads across this opportunity's room configurations.
-
#uploads ⇒ ActiveRecord::Relation<Upload>
File uploads attached to this opportunity.
Has and belongs to many collapse
-
#digital_assets ⇒ ActiveRecord::Relation<DigitalAsset>
Digital assets attached to this opportunity.
Class Method Summary collapse
-
.active ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are active.
-
.all_competitors ⇒ Array<String>
All known competitor system names.
-
.assigned_to_rep ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are assigned to rep.
-
.awaiting_iq_transmission ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are awaiting iq transmission.
-
.cluster_roots ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are cluster roots.
-
.fix_postal_codes ⇒ void
Normalizes installation postal codes to 5-digit US / 6-char CA forms.
-
.format_address_for_zip_selection(address) ⇒ String
Formats an address as
zip - city statefor the postal-code select. -
.get_unique_name(party, name) ⇒ String
Returns a unique opportunity name within the customer's opportunities, appending
#Nwhen the base name is taken. -
.google_conversion_reported ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are google conversion reported.
-
.instant_quoting ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are instant quoting.
-
.lost ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost.
-
.lost_or_abandoned ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost or abandoned.
-
.most_recent_first ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are most recent first.
-
.not_cancelled ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are not cancelled.
-
.not_won_or_lost ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are not won or lost.
-
.open_for_iq ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are open for iq.
-
.open_opportunities ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are open opportunities.
-
.opportunity_reception_types_for_select ⇒ Array<Array(String, String)>
Reception types as [label, value] select options.
-
.opportunity_types_for_select ⇒ Array<Array(String, String)>
Opportunity types as [label, value] select options.
-
.states_for_select ⇒ Array<Array(String, String)>
States as [human name, value] select options.
-
.web_originated ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are web originated.
-
.with_google_conversion_gclid ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with google conversion gclid.
-
.with_quotes_and_rooms ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with quotes and rooms.
-
.with_value ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with value.
-
.won ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are won.
Instance Method Summary collapse
-
#abandonable? ⇒ Boolean
Whether the opportunity can be auto-abandoned.
-
#abandonment_date ⇒ Date
Date after which the opportunity is considered abandoned.
-
#add_customer_to_campaign ⇒ void
Adds the customer to the source's marketing campaign.
-
#all_activities ⇒ ActiveRecord::Relation<Activity>
All activities linked to this opportunity.
-
#all_participants ⇒ ActiveRecord::Relation<Party>
All parties participating in this opportunity (customer, contact, and explicit participants).
-
#all_related_communications ⇒ ActiveRecord::Relation<Communication>
Communications across this opportunity's orders, quotes, and rooms.
-
#all_reps ⇒ ActiveRecord::Relation<Employee>
Every rep associated with this opportunity or its customer.
-
#all_reps_ids ⇒ Array<Integer>
IDs of every rep associated with this opportunity or its customer.
-
#all_uploads ⇒ ActiveRecord::Relation<Upload>
All uploads on this opportunity and its room configurations.
-
#build_activity ⇒ Activity
Builds an unsaved activity for this opportunity and primary party.
-
#calculate_value ⇒ void
Recalculates and persists value, msrp_value, and discount_percentage from orders, verified outlet purchases, or the top active sales quote.
-
#can_be_moved? ⇒ Boolean
Whether this opportunity can be moved (no active orders).
-
#cancel_followups(include_large_op_activities: false) ⇒ void
Cancels open sales follow-up activities on this opportunity.
-
#cancel_open_quotes ⇒ Object
Best-effort cancel of in-progress quotes when the opportunity is closed.
-
#cancel_oppfu ⇒ void
Cancels all open OPPFU follow-up activities on this opportunity.
-
#cancelable? ⇒ Boolean
Whether the opportunity can be cancelled.
-
#closed? ⇒ Boolean
Whether the opportunity is in a terminal closed state.
-
#cluster_root ⇒ Opportunity
The canonical opp this row should report against.
-
#collapse_colliding_outlet_purchases ⇒ void
Destroy outlet purchases that cannot survive being nullified.
-
#contact_select_options ⇒ Array<Array(String, Integer)>
Options for the contact select dropdown (active contacts plus the current contact when inactive).
-
#contactable_for_follow_up? ⇒ Boolean
Check if the opportunity can be followed up on.
-
#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.
-
#create_heat_loss_room ⇒ RoomConfiguration
Creates a default heat-loss room and marks it current.
-
#crm_link ⇒ String
CRM path for this opportunity.
-
#deep_dup ⇒ Opportunity
Duplicates this opportunity without its reference number.
-
#editable? ⇒ Boolean
Whether the opportunity is still editable (not won/lost/cancelled/abandoned).
-
#effective_opportunity_won_date ⇒ Time
Won date derived from the earliest order ship date.
-
#emailable? ⇒ Boolean
Whether the primary party has an email address.
-
#emails ⇒ ActiveRecord::Relation<ContactPoint>
Email contact points of the primary party.
-
#estimate_electricity_rate ⇒ BigDecimal, ...
Average electricity rate for the installation postal code.
-
#exclude_manually_initiated_event?(event) ⇒ Boolean
Whether the given state event was initiated manually (excluded from automated handling).
-
#find_gbraid ⇒ String?
Google Ads gbraid identifier, from the visit or the customer's visit.
-
#find_gclid ⇒ String?
Google Ads click identifier, from the visit or the customer.
-
#find_msclkid ⇒ String?
Microsoft Ads (Bing) click identifier, captured by the Tracker into
Visit#marketing_meta['msclkid'](JSONB-only). -
#find_oppref ⇒ String?
OpenAI Ads (ChatGPT) click identifier, captured by the Tracker into
Visit#marketing_meta['oppref'](JSONB-only — no scalar column). -
#find_wbraid ⇒ String?
Google Ads wbraid identifier, from the visit or the customer's visit.
-
#heat_loss_room ⇒ RoomConfiguration
The current heat-loss room, creating a default one when absent.
-
#in_canada? ⇒ Boolean
Whether the installation address is in Canada.
-
#in_usa? ⇒ Boolean
Whether the installation address is in the USA.
-
#installation_country_iso ⇒ String
Two-letter ISO country code for the installation address.
-
#installation_country_iso3 ⇒ String
ISO3 country code for the installation address (store country, postal code lookup, or locale fallback; defaults to 'USA').
-
#installation_postal_codes_for_select ⇒ Array<Array(String, String)>
Postal-code options for the installation address select, gathered from customer, order, and quote addresses.
-
#installation_state_code ⇒ String?
State/province code derived from the installation postal code.
-
#last_movement ⇒ Time, ...
Most recent meaningful activity date on the opportunity.
-
#last_quote_date ⇒ Time?
Most recent quote creation time.
-
#last_room_date ⇒ Time?
Most recent room configuration creation time.
-
#last_sales_activity_completion ⇒ Time?
Most recent sales activity completion.
-
#location ⇒ String?
Best-effort geocodable location string for this opportunity, from order, quote, or customer address, then postal code, then visit geo data.
-
#merge_into!(target) ⇒ void
Mark this opportunity as a duplicate of
targetand retire it from active pipelines. -
#merge_with(opp_to_merge, delete_after: false) ⇒ void
Merges another opportunity into this one, moving its rooms, activities, and quotes; optionally destroys the merged opportunity.
-
#next_room_name(options) ⇒ String
Generates the next available room name for this opportunity, appending an incrementing
#Nsuffix when the base name is already taken. -
#ok_to_customer_cancel? ⇒ Boolean
Whether the customer is allowed to cancel this opportunity.
-
#ok_to_delete? ⇒ Boolean
Whether the opportunity may be deleted (no completed quotes, active rooms, or orders).
-
#open_activities_counter ⇒ Integer
Count of open, visible activities related to this opportunity.
-
#open_opp_fu? ⇒ Boolean
Whether there is an open OPPFU follow-up activity.
-
#open_opportunity? ⇒ Boolean
Whether the opportunity is in an open state.
-
#opportunity_type_expanded ⇒ String?
Expanded (human-readable) label for the opportunity type code.
-
#participants_options_for_select ⇒ Array<Array(String, Integer)>
Options for the participants select dropdown.
-
#planned_installation_date_time_frame ⇒ Symbol?
Time-frame bucket (in weeks) for the planned installation date.
-
#planned_installation_date_time_frame=(val) ⇒ void
Sets planned_installation_date from a time-frame bucket.
-
#primary_party ⇒ Party?
Primary party for communication: the contact, falling back to the customer.
-
#product_lines(reviewable_only: true) ⇒ Array<ProductLine>
We will gather all product lines either ordered or quoted or in the rooms.
-
#prune_activities(exclude_activity_id = nil, activity_type_ids = nil) ⇒ void
This function looks for a particular activity in the context of the opportunity and cancel any found except the excluded one.
-
#qualified_opportunity? ⇒ Boolean?
Whether this is a qualified opportunity (non-guest customer, qualified room).
-
#qualified_room_present? ⇒ Boolean
Whether any room configuration is past the draft/quoting states.
-
#quotes_completed? ⇒ Boolean
Whether any quote is completed.
-
#quoting_event? ⇒ Boolean
Whether any quote or room configuration is currently in quoting.
-
#ready_for_follow_up? ⇒ Boolean
Ready for follow-up requires completed quotes AND a way to contact the customer.
-
#recalculate_customer_profiling_information ⇒ true
Enqueues a recalculation of the customer's profiling information.
-
#related_activities ⇒ ActiveRecord::Relation<Activity>
All activities related to this opportunity.
-
#related_activities_ids ⇒ Array<Integer>
IDs of every activity related to this opportunity (direct, room, quote).
-
#reset_rooms_last_heat_loss ⇒ true
Clears the cached last_heat_loss on every room configuration.
-
#sales_opportunity? ⇒ Boolean
Whether this is a sales-type opportunity.
-
#sales_present? ⇒ Boolean
Whether this opportunity produced a WarmlyYours sale.
-
#schedule_interest_activity ⇒ Object
This method is responsible for creating the relevant activity based on the opportunity flow for interest grade type of opportunities.
-
#selection_name ⇒ String
Label used in select dropdowns: reference number, name, and customer.
-
#send_online_email_confirmation ⇒ Hash?
Sends the "opportunity received" email confirmation for online leads.
-
#set_default_for_order(new_order) ⇒ Order
protected
Applies opportunity defaults to a new order built from this opportunity.
-
#set_default_for_quote(new_quote) ⇒ Quote
protected
Applies customer defaults to a new quote built from this opportunity.
-
#should_abandon? ⇒ Boolean
Whether the opportunity should be abandoned today.
-
#sms_enabled_numbers ⇒ Array<String>
SMS-capable numbers of all participants, deduplicated.
-
#sms_messages ⇒ ActiveRecord::Relation<SmsMessage>
SMS messages sent to/from the participants' numbers.
-
#state_description(describe_state = nil) ⇒ String?
Human-readable description of a state (defaults to the current state).
-
#sync_state ⇒ Boolean?
Auto-derives the state from activity/quote/order signals unless terminal.
-
#synchronize_reps ⇒ Object
Synchronize the reps on this opportunity with the customer.
-
#to_s ⇒ String
String representation: name plus humanized state.
-
#tracking_email_address ⇒ String
Tracking email address embedding the encrypted opportunity id.
-
#unattached_orders(**_pagination) ⇒ ActiveRecord::Relation
Retrieve all orphaned orders that are candidate to be attached to this opportunity.
-
#unique_room_name(base_name, except: nil) ⇒ String
Generate a room-configuration name unique within this opportunity.
-
#unknown_job_name? ⇒ Boolean?
Whether the name still carries the unknown-job prefix.
-
#update_primary_product_line_name ⇒ void
Persists the primary product line name from the first room configuration.
-
#voice_callable? ⇒ Boolean
Whether the primary party has a voice-callable phone number.
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
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
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#create_oppfu ⇒ Object
Returns the value of attribute create_oppfu.
293 294 295 |
# File 'app/models/opportunity.rb', line 293 def create_oppfu @create_oppfu end |
#electricity_rate ⇒ Object (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_value ⇒ Object (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_room ⇒ Object
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 |
#name ⇒ Object (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_type ⇒ Object (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 } |
#summary ⇒ Object (readonly)
Summary limited to 240 chars when present.
Validations (if => -> { summary.present? } ):
- Length ({ within: 1..240 })
200 |
# File 'app/models/opportunity.rb', line 200 validates :summary, length: { within: 1..240 }, if: -> { summary.present? } |
#value ⇒ Object (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
.active ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are active. Active Record Scope
226 |
# File 'app/models/opportunity.rb', line 226 scope :active, -> { open_opportunities.with_quotes_and_rooms } |
.all_competitors ⇒ Array<String>
All known competitor system names.
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_rep ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are assigned to rep. Active Record Scope
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_transmission ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are awaiting iq transmission. Active Record Scope
235 |
# File 'app/models/opportunity.rb', line 235 scope :awaiting_iq_transmission, -> { where(legacy_awaiting_iq_transmission: true) } |
.cluster_roots ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are cluster roots. Active Record Scope
236 |
# File 'app/models/opportunity.rb', line 236 scope :cluster_roots, -> { where(parent_id: nil, merged_into_id: nil) } |
.fix_postal_codes ⇒ void
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.
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.
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_reported ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are google conversion reported. Active Record Scope
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_quoting ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are instant quoting. Active Record Scope
232 |
# File 'app/models/opportunity.rb', line 232 scope :instant_quoting, -> { where(state: 'instant_quoting') } |
.lost ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost. Active Record Scope
228 |
# File 'app/models/opportunity.rb', line 228 scope :lost, -> { where(state: 'lost') } |
.lost_or_abandoned ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are lost or abandoned. Active Record Scope
229 |
# File 'app/models/opportunity.rb', line 229 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
222 |
# File 'app/models/opportunity.rb', line 222 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
231 |
# File 'app/models/opportunity.rb', line 231 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
225 |
# File 'app/models/opportunity.rb', line 225 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
233 |
# File 'app/models/opportunity.rb', line 233 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
224 |
# File 'app/models/opportunity.rb', line 224 scope :open_opportunities, -> { where.not(state: %w[won lost abandoned cancelled untracked]) } |
.opportunity_reception_types_for_select ⇒ Array<Array(String, String)>
Reception types as [label, value] select options.
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_select ⇒ Array<Array(String, String)>
Opportunity types as [label, value] select options.
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_select ⇒ Array<Array(String, String)>
States as [human name, value] select options.
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_originated ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are web originated. Active Record Scope
237 |
# File 'app/models/opportunity.rb', line 237 scope :web_originated, -> { where(opportunity_reception_type: %w[Online IQ]) } |
.with_google_conversion_gclid ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with google conversion gclid. Active Record Scope
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_rooms ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are with quotes and rooms. Active Record Scope
223 |
# File 'app/models/opportunity.rb', line 223 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
230 |
# File 'app/models/opportunity.rb', line 230 scope :with_value, -> { where.not(value: nil) } |
.won ⇒ ActiveRecord::Relation<Opportunity>
A relation of Opportunities that are won. Active Record Scope
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.
754 755 756 757 758 |
# File 'app/models/opportunity.rb', line 754 def abandonable? .open_activities.blank? && (interest? || qualify? || follow_up?) && !sales_present? end |
#abandonment_date ⇒ Date
Date after which the opportunity is considered abandoned.
774 775 776 |
# File 'app/models/opportunity.rb', line 774 def abandonment_date last_movement.to_date + 60.days end |
#account_specialist ⇒ Employee
Account specialist assigned.
121 |
# File 'app/models/opportunity.rb', line 121 belongs_to :account_specialist, class_name: 'Employee', optional: true |
#activities ⇒ ActiveRecord::Relation<Activity>
Activity log entries for this opportunity.
150 |
# File 'app/models/opportunity.rb', line 150 has_many :activities, as: :resource, dependent: :nullify |
#add_customer_to_campaign ⇒ void
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_activities ⇒ ActiveRecord::Relation<Activity>
All activities linked to this opportunity.
646 647 648 |
# File 'app/models/opportunity.rb', line 646 def all_activities linked_activities end |
#all_participants ⇒ ActiveRecord::Relation<Party>
All parties participating in this opportunity (customer, contact, and
explicit participants).
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 |
#all_related_communications ⇒ ActiveRecord::Relation<Communication>
Communications across this opportunity's orders, quotes, and rooms.
631 632 633 634 635 636 637 638 639 640 641 642 |
# File 'app/models/opportunity.rb', line 631 def # 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_reps ⇒ ActiveRecord::Relation<Employee>
Every rep associated with this opportunity or its customer.
1402 1403 1404 |
# File 'app/models/opportunity.rb', line 1402 def all_reps Employee.where(id: all_reps_ids) end |
#all_reps_ids ⇒ Array<Integer>
IDs of every rep associated with this opportunity or its customer.
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, 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 ⇒ ActiveRecord::Relation<Upload>
All uploads on this opportunity and its room configurations.
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_activity ⇒ Activity
Builds an unsaved activity for this opportunity and primary party.
618 619 620 |
# File 'app/models/opportunity.rb', line 618 def build_activity activities.build resource: self, party: primary_party end |
#buying_group ⇒ BuyingGroup
Buying group the customer is purchasing through.
131 |
# File 'app/models/opportunity.rb', line 131 belongs_to :buying_group, optional: true |
#calculate_value ⇒ void
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).
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.
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_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.
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_oppfu ⇒ void
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.
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 |
#children ⇒ ActiveRecord::Relation<Opportunity>
Child opportunities.
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.
514 515 516 |
# File 'app/models/opportunity.rb', line 514 def closed? won? || lost? || cancelled? || abandoned? end |
#cluster_root ⇒ Opportunity
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.
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_purchases ⇒ void
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 |
#contact ⇒ Contact
Primary contact on the opportunity.
113 |
# File 'app/models/opportunity.rb', line 113 belongs_to :contact, inverse_of: :opportunities, optional: true |
#contact_select_options ⇒ Array<Array(String, Integer)>
Options for the contact select dropdown (active contacts plus the current
contact when inactive).
997 998 999 1000 1001 |
# File 'app/models/opportunity.rb', line 997 def 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).
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.
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_room ⇒ RoomConfiguration
Creates a default heat-loss room and marks it current.
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_link ⇒ String
CRM path for this opportunity.
1005 1006 1007 |
# File 'app/models/opportunity.rb', line 1005 def crm_link UrlHelper.instance.opportunity_path(self) end |
#current_heat_loss_room ⇒ RoomConfiguration
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 |
#customer ⇒ Customer
Customer the opportunity belongs to.
Validations:
129 |
# File 'app/models/opportunity.rb', line 129 belongs_to :customer, inverse_of: :opportunities, optional: true |
#deep_dup ⇒ Opportunity
Duplicates this opportunity without its reference number.
108 109 110 |
# File 'app/models/opportunity.rb', line 108 def deep_dup deep_clone(except: :reference_number) end |
#digital_assets ⇒ ActiveRecord::Relation<DigitalAsset>
Digital assets attached to this opportunity.
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).
984 985 986 |
# File 'app/models/opportunity.rb', line 984 def editable? %w[won lost cancelled abandoned].exclude?(state) end |
#effective_opportunity_won_date ⇒ Time
Won date derived from the earliest order ship date.
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.
1368 1369 1370 |
# File 'app/models/opportunity.rb', line 1368 def emailable? emails.present? end |
#emails ⇒ ActiveRecord::Relation<ContactPoint>
Email contact points of the primary party.
1362 1363 1364 |
# File 'app/models/opportunity.rb', line 1362 def emails primary_party.contact_points.emails end |
#estimate_electricity_rate ⇒ BigDecimal, ...
Average electricity rate for the installation postal code.
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).
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_gbraid ⇒ String?
Google Ads gbraid identifier, from the visit or the customer's visit.
1125 1126 1127 |
# File 'app/models/opportunity.rb', line 1125 def find_gbraid visit&. || customer&.visit&. end |
#find_gclid ⇒ String?
Google Ads click identifier, from the visit or the customer.
1111 1112 1113 |
# File 'app/models/opportunity.rb', line 1111 def find_gclid visit&. || customer&.gclid || customer&.visit&. end |
#find_msclkid ⇒ String?
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).
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. || {})['msclkid'].presence || v.landing_page_params['msclkid'].presence end msclkid_for.call(visit) || msclkid_for.call(customer&.visit) end |
#find_oppref ⇒ String?
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.
1136 1137 1138 1139 |
# File 'app/models/opportunity.rb', line 1136 def find_oppref (visit&. || {})['oppref'].presence || (customer&.visit&. || {})['oppref'].presence end |
#find_wbraid ⇒ String?
Google Ads wbraid identifier, from the visit or the customer's visit.
1118 1119 1120 |
# File 'app/models/opportunity.rb', line 1118 def find_wbraid visit&. || customer&.visit&. end |
#heat_loss_room ⇒ RoomConfiguration
The current heat-loss room, creating a default one when absent.
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.
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.
1250 1251 1252 |
# File 'app/models/opportunity.rb', line 1250 def in_usa? installation_postal_code.present? && installation_country_iso3 == 'USA' end |
#installation_country_iso ⇒ String
Two-letter ISO country code for the installation address.
1274 1275 1276 |
# File 'app/models/opportunity.rb', line 1274 def installation_country_iso installation_country_iso3.first(2) end |
#installation_country_iso3 ⇒ String
ISO3 country code for the installation address (store country, postal
code lookup, or locale fallback; defaults to 'USA').
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_select ⇒ Array<Array(String, String)>
Postal-code options for the installation address select, gathered from
customer, order, and quote addresses.
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 = [] += 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 += 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 += quotes.select(&:shipping_address).map { |o| [Opportunity.format_address_for_zip_selection(o.shipping_address), o.shipping_address.zip_compact] } .uniq.sort end |
#installation_state_code ⇒ String?
State/province code derived from the installation postal code.
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_movement ⇒ Time, ...
Most recent meaningful activity date on the opportunity.
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_date ⇒ Time?
Most recent quote creation time.
786 787 788 |
# File 'app/models/opportunity.rb', line 786 def last_quote_date quotes.maximum(:created_at) end |
#last_room_date ⇒ Time?
Most recent room configuration creation time.
792 793 794 |
# File 'app/models/opportunity.rb', line 792 def last_room_date room_configurations.maximum(:created_at) end |
#last_sales_activity_completion ⇒ Time?
Most recent sales activity completion.
780 781 782 |
# File 'app/models/opportunity.rb', line 780 def last_sales_activity_completion .sales_activities.maximum(:completion_datetime) end |
#linked_activities ⇒ ActiveRecord::Relation<Activity>
Activities linked to this opportunity via the legacy join.
172 |
# File 'app/models/opportunity.rb', line 172 has_many :linked_activities, class_name: 'Activity' |
#local_sales_rep ⇒ Employee
Local (field) sales rep assigned.
119 |
# File 'app/models/opportunity.rb', line 119 belongs_to :local_sales_rep, class_name: 'Employee', optional: true |
#location ⇒ String?
Best-effort geocodable location string for this opportunity, from order,
quote, or customer address, then postal code, then visit geo data.
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.
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.
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_duplicates ⇒ ActiveRecord::Relation<Opportunity>
Duplicate opportunities merged into this one.
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_into ⇒ Opportunity
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.
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() room_base_name = [:room_base_name] || [: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.
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).
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_counter ⇒ Integer
Count of open, visible activities related to this opportunity.
907 908 909 |
# File 'app/models/opportunity.rb', line 907 def open_activities_counter .visible_by_default.open_activities.size end |
#open_opp_fu? ⇒ Boolean
Whether there is an open OPPFU follow-up activity.
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.
798 799 800 |
# File 'app/models/opportunity.rb', line 798 def open_opportunity? interest? || qualify? || quoting? || follow_up? end |
#opportunity_participants ⇒ ActiveRecord::Relation<OpportunityParticipant>
Join records linking parties to this opportunity.
168 |
# File 'app/models/opportunity.rb', line 168 has_many :opportunity_participants, inverse_of: :opportunity, dependent: :destroy, autosave: true |
#opportunity_type_expanded ⇒ String?
Expanded (human-readable) label for the opportunity type code.
901 902 903 |
# File 'app/models/opportunity.rb', line 901 def OpportunityConstants::OPPORTUNITY_TYPES[opportunity_type] end |
#order_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's orders.
166 |
# File 'app/models/opportunity.rb', line 166 has_many :order_activities, class_name: 'Activity', through: :orders, source: :activities |
#orders ⇒ ActiveRecord::Relation<Order>
Orders won from this opportunity.
156 |
# File 'app/models/opportunity.rb', line 156 has_many :orders, before_add: :set_default_for_order |
#original_source ⇒ Source
Original marketing source at creation.
135 |
# File 'app/models/opportunity.rb', line 135 belongs_to :original_source, class_name: 'Source', optional: true |
#outlet_invoices ⇒ ActiveRecord::Relation<OutletInvoice>
Invoices from this opportunity's outlet purchases.
180 |
# File 'app/models/opportunity.rb', line 180 has_many :outlet_invoices, through: :outlet_purchases, source: :invoice |
#outlet_purchases ⇒ ActiveRecord::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.
178 |
# File 'app/models/opportunity.rb', line 178 has_many :outlet_purchases, class_name: 'CustomerOutletPurchase', inverse_of: :opportunity, dependent: :nullify |
#parent ⇒ Opportunity
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_select ⇒ Array<Array(String, Integer)>
Options for the participants select dropdown.
1380 1381 1382 |
# File 'app/models/opportunity.rb', line 1380 def opportunity_participants.map { |scp| [scp.party.full_name, scp.party_id] } end |
#parties ⇒ ActiveRecord::Relation<Party>
Parties participating in this opportunity.
170 |
# File 'app/models/opportunity.rb', line 170 has_many :parties, through: :opportunity_participants |
#planned_installation_date_time_frame ⇒ Symbol?
Time-frame bucket (in weeks) for the planned installation date.
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.
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_party ⇒ Party?
Primary party for communication: the contact, falling back to the customer.
990 991 992 |
# File 'app/models/opportunity.rb', line 990 def primary_party contact || customer end |
#primary_sales_rep ⇒ Employee
Primary sales rep assigned.
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
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.
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 = .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).
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.
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_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's quotes.
164 |
# File 'app/models/opportunity.rb', line 164 has_many :quote_activities, class_name: 'Activity', through: :quotes, source: :activities |
#quotes ⇒ ActiveRecord::Relation<Quote>
Quotes issued for this opportunity.
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.
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.
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.
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_information ⇒ true
Enqueues a recalculation of the customer's profiling information.
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 |
#related_activities ⇒ ActiveRecord::Relation<Activity>
All activities related to this opportunity.
950 951 952 |
# File 'app/models/opportunity.rb', line 950 def Activity.where(id: ) end |
#related_activities_ids ⇒ Array<Integer>
IDs of every activity related to this opportunity (direct, room, quote).
944 945 946 |
# File 'app/models/opportunity.rb', line 944 def [activities.ids, room_activities.ids, quote_activities.ids].flatten end |
#reset_rooms_last_heat_loss ⇒ true
Clears the cached last_heat_loss on every room configuration.
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_activities ⇒ ActiveRecord::Relation<Activity>
Activities across this opportunity's room configurations.
162 |
# File 'app/models/opportunity.rb', line 162 has_many :room_activities, class_name: 'Activity', through: :room_configurations, source: :activities |
#room_configurations ⇒ ActiveRecord::Relation<RoomConfiguration>
Room configurations on this opportunity.
Validations (if => #must_have_one_room ):
152 |
# File 'app/models/opportunity.rb', line 152 has_many :room_configurations, dependent: :destroy, inverse_of: :opportunity |
#room_uploads ⇒ ActiveRecord::Relation<Upload>
Uploads across this opportunity's room configurations.
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.
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
woncancels every follow-up activity (see the
after_transitionbelow), 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_statecallssync_stateon 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.
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_rep ⇒ Employee
Sales support rep assigned.
127 |
# File 'app/models/opportunity.rb', line 127 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
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_rep ⇒ Employee
Secondary sales rep assigned.
117 |
# File 'app/models/opportunity.rb', line 117 belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true |
#selection_name ⇒ String
Label used in select dropdowns: reference number, name, and customer.
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_confirmation ⇒ Hash?
Sends the "opportunity received" email confirmation for online leads.
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.
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.
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.
762 763 764 |
# File 'app/models/opportunity.rb', line 762 def should_abandon? abandonable? && Date.current > abandonment_date end |
#sms_enabled_numbers ⇒ Array<String>
SMS-capable numbers of all participants, deduplicated.
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_messages ⇒ ActiveRecord::Relation<SmsMessage>
SMS messages sent to/from the participants' numbers.
496 497 498 |
# File 'app/models/opportunity.rb', line 496 def SmsMessage.for_numbers(sms_enabled_numbers) end |
#source ⇒ Source
Marketing source attributed to this opportunity.
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).
661 662 663 |
# File 'app/models/opportunity.rb', line 661 def state_description(describe_state = nil) OpportunityConstants::STATE_DESCRIPTION[describe_state || state] end |
#sync_state ⇒ Boolean?
Auto-derives the state from activity/quote/order signals unless terminal.
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_reps ⇒ Object
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_rep ⇒ Employee
Technical support rep assigned.
123 |
# File 'app/models/opportunity.rb', line 123 belongs_to :technical_support_rep, class_name: 'Employee', optional: true |
#technical_support_rep_sec ⇒ Employee
Secondary technical support rep assigned.
125 |
# File 'app/models/opportunity.rb', line 125 belongs_to :technical_support_rep_sec, class_name: 'Employee', optional: true |
#to_s ⇒ String
String representation: name plus humanized state.
971 972 973 |
# File 'app/models/opportunity.rb', line 971 def to_s "#{name} - #{human_state_name}" end |
#tracking_email_address ⇒ String
Tracking email address embedding the encrypted opportunity id.
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
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.
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.
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_name ⇒ void
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 |
#uploads ⇒ ActiveRecord::Relation<Upload>
File uploads attached to this opportunity.
158 |
# File 'app/models/opportunity.rb', line 158 has_many :uploads, as: :resource, dependent: :destroy |
#visit ⇒ Visit
Web visit that created this opportunity.
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.
1374 1375 1376 |
# File 'app/models/opportunity.rb', line 1374 def voice_callable? primary_party.contact_points.voice_callable.present? end |