Class: CustomerSearch

Inherits:
Search show all
Includes:
Models::RepSearchable, Models::SearchAddressable
Defined in:
app/models/customer_search.rb

Overview

== Schema Information

Table name: searches
Database name: primary

id :integer not null, primary key
name :string(255)
persist :boolean default(FALSE)
pinned :boolean default(FALSE), not null
query_params :jsonb
result_set_size :integer default(0)
selected_columns :string is an Array
set_limit :integer default(25)
sort_column :string(255)
sort_columns :string default([]), not null, is an Array
sort_direction :string(255) default("ASC")
type :string(255) not null
created_at :datetime
updated_at :datetime
employee_id :integer

Indexes

employee_id_pinned (employee_id,pinned)
employee_id_type (employee_id,type)

Constant Summary collapse

CUSTOM_CRITERIA_KEYS =

Keys consumed by apply_custom_criteria — excluded from the Ransack strict probe
so we don't get false-positive InvalidSearchError reports in AppSignal.

%w[
  has_ordered_item_id has_ordered_product_category_id has_ordered_product_line_id
  has_ordered_days has_ordered_days_time_range_gteq has_ordered_days_time_range_lteq
  minimum_number_of_orders has_ordered_type
  campaign_id_in campaign_id_not_in has_campaign_activity_status
  custom_drop_reason_codes custom_drop_rep_ids
  profile_in profile_not_in is_certified_installer
].freeze

Constants inherited from Search

Search::DISTANCE_SEARCH_KEYS

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary

Attributes inherited from Search

#composite_columns, #pagy_count, #pagy_limit, #pagy_page, #sql_select_columns

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::SearchAddressable

#mass_googlemap

Methods inherited from Search

#all_composite_columns, allowed_role_ids, #append_custom_column, #append_ouput_column, #append_to_sql_select_columns, #applicable_sort_terms, #apply_array_match, #apply_criteria, #apply_customer_cross_reference, #apply_distance_search, #apply_select, #apply_sort, #available_events, available_output_columns, #available_output_columns, base_search_class_name, #cleanup_search_results, composite_column, #composite_column, database_columns, default_columns, default_sort, #discard_excess, #effective_name, #effective_short_name, #employee, #enqueue_navbar_pinned_refresh, #fast_count, favorites, friendly_column_name, #get_pinned_results, #get_sort_term, global_favorite, has_role_for_search?, #human_query_params, instantiate_query_template, #instantiate_resource_updater, #limit_options, main_resource_class, main_resource_table, mass_actions_for_select, #mass_export, maximum_unpersisted_queries, #normalize_for_mass_update, options, options_classes, #output_columns_for_select, #pagy_from_search, #perform, #perform_selected, #pinned_query, #prepend_custom_column, #prepend_output_column, query_favorites_templates, #ransack_probe_params, recent_searches_for_employee_id, #record_list, #refresh_pinned_results, #remove_other_pins, search_name, #search_name, #search_results, search_type, search_type_humanized, select_sort_columns, #select_sort_columns, #select_statement, #set_sort_term, #sort_column1, #sort_column1=, #sort_column2, #sort_column2=, #sort_column3, #sort_column3=, #sort_columns_for_select, #sort_columns_to_hash, #target_geocoding, unpersisted_queries_quote_reached?, #unrecord_list, #validated_selected_columns, view_resource_class, view_resource_klass, #view_resource_klass, view_resource_table, visible?, with_search_results

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Class Method Details

.custom_criteria_keysObject



94
95
96
# File 'app/models/customer_search.rb', line 94

def self.custom_criteria_keys
  CUSTOM_CRITERIA_KEYS
end

.scope_options_for_mass_schedule_activityObject



330
331
332
# File 'app/models/customer_search.rb', line 330

def self.scope_options_for_mass_schedule_activity
  [['Customer Only', 'customer_only'], ['Primary Contact', 'primary_contact'], ['All Active Contacts', 'all_active_contacts']]
end

.select_options_for_profileObject



398
399
400
# File 'app/models/customer_search.rb', line 398

def self.select_options_for_profile
  [['Any Organization', 'ORG']] + Profile.options_for_select(false)
end

Instance Method Details

#address_id_fieldsObject



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

def address_id_fields
  [%w[Shipping shipping_address_id], %w[Billing billing_address_id], %w[Mailing mailing_address_id]]
end

#apply_custom_criteria(results) ⇒ Object



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'app/models/customer_search.rb', line 113

def apply_custom_criteria(results)
  results = apply_distance_search(results, custom_table_join: 'INNER JOIN addresses on addresses.party_id = view_customers.id', custom_table_alias: 'addresses')

  has_ordered_query = []
  has_quoted_query = []
  if query_params[:has_ordered_item_id].present?
    item_ids = Array(query_params[:has_ordered_item_id]).map(&:to_i)
    cond = LineItem[:item_id].in(item_ids).to_sql
    has_ordered_query << cond
    has_quoted_query << cond
  end
  if query_params[:has_ordered_product_category_id].present?
    # Use ltree inline subselect for descendants - single query, no ID expansion needed
    pc_ids = Array(query_params[:has_ordered_product_category_id]).map(&:to_i)
    cond = "items.pc_path_ids <@ ANY(#{ProductCategory.where(id: pc_ids).select(:ltree_path_ids).to_sql})"
    has_ordered_query << cond
    has_quoted_query << cond
  end
  if query_params[:has_ordered_product_line_id].present?
    # Use ltree inline subselect for descendants - single query, no ID expansion needed
    pl_ids = Array(query_params[:has_ordered_product_line_id]).map(&:to_i)
    cond = "items.primary_pl_path_ids <@ ANY(#{ProductLine.where(id: pl_ids).select(:ltree_path_ids).to_sql})"
    has_ordered_query << cond
    has_quoted_query << cond
  end

  if query_params[:has_ordered_days].present?
    has_ordered_query << "orders.shipped_date >= '#{Date.current.days_ago(query_params[:has_ordered_days].to_i)}'"
    has_quoted_query << "quotes.complete_datetime >= '#{Date.current.days_ago(query_params[:has_ordered_days].to_i)}'"
  end

  if query_params[:has_ordered_days_time_range_gteq].present?
    if query_params[:has_ordered_days_time_range_gteq].present? && query_params[:has_ordered_days_time_range_lteq].present?
      has_ordered_query << "orders.shipped_date between '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_lteq].to_i)}' and '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_gteq].to_i)}'"
      has_quoted_query << "quotes.complete_datetime between '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_lteq].to_i)}' and '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_gteq].to_i)}'"
    else
      has_ordered_query << "orders.shipped_date >= '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_gteq].to_i)}'"
      has_quoted_query << "quotes.complete_datetime >= '#{Date.current.days_ago(query_params[:has_ordered_days_time_range_gteq].to_i)}'"
    end
  end
  if has_ordered_query.any?
    oq = "from line_items inner join orders on orders.id = line_items.resource_id and line_items.resource_type = 'Order' inner join items on items.id = line_items.item_id inner join item_product_lines on item_product_lines.item_id = items.id where orders.customer_id = view_customers.id and orders.state = 'invoiced' and #{has_ordered_query.join(' and ')}"
    qq = "from line_items inner join quotes on quotes.id = line_items.resource_id and line_items.resource_type = 'Quote' inner join opportunities on opportunities.id = quotes.opportunity_id inner join items on items.id = line_items.item_id inner join item_product_lines on item_product_lines.item_id = items.id where opportunities.customer_id = view_customers.id and quotes.state = 'complete' and #{has_quoted_query.join(' and ')}"
    order_query = "EXISTS(select 1 #{oq})"
    quote_query = "EXISTS(select 1 #{qq})"
    if query_params[:minimum_number_of_orders].present?
      min_orders = query_params[:minimum_number_of_orders].to_i
      order_query += " and (select COUNT(distinct resource_id) #{oq}) >= #{min_orders}"
      quote_query += " and (select COUNT(distinct resource_id) #{qq}) >= #{min_orders}"
    end
    results = case query_params[:has_ordered_type]
              when 'both'
                results.where("(#{order_query} or #{quote_query})")
              when 'quotes'
                results.where(quote_query)
              else
                results.where(order_query)
              end
  end
  if query_params[:campaign_id_in].present?
    campaign_ids = Array(query_params[:campaign_id_in]).map(&:to_i)
    campaigns = Campaign.where(campaign_type: 'outside_sales', id: campaign_ids)
    # A customer is "in" a campaign directly (subscribers.customer_id) or via
    # the subscriber-email -> contact_point -> party path; match both so
    # campaign_id_in stays the complement of campaign_id_not_in below.
    direct_ids = campaigns.joins(:subscribers).select(Subscriber[:customer_id])
    email_ids = campaigns.joins(subscribers: :contact_points).select(ContactPoint[:party_id])
    results = results.where.any_of({ id: direct_ids }, { id: email_ids })
    # Correlated EXISTS on the outer view_customers row: kept as a fragment
    # (AR has no first-class correlated-subquery API); only the id list is Arel.
    campaign_in = Activity[:campaign_id].in(campaign_ids).to_sql
    case query_params[:has_campaign_activity_status]
    when 'open'
      results = results.where("EXISTS(select 1 from activities where activities.party_id = view_customers.id and #{campaign_in} and activity_type_id is not null and activity_result_type_id is null)")
    when 'any'
      results = results.where("EXISTS(select 1 from activities where activities.party_id = view_customers.id and #{campaign_in} and activity_type_id is not null)")
    when 'none'
      results = results.where("NOT EXISTS(select 1 from activities where activities.party_id = view_customers.id and #{campaign_in} and activity_type_id is not null)")
    end
  end
  if query_params[:campaign_id_not_in].present?
    campaign_not_in_ids = Array(query_params[:campaign_id_not_in]).map(&:to_i)
    campaign_not_in = Campaign[:id].in(campaign_not_in_ids).to_sql
    results = results.where("NOT EXISTS(select 1 from campaigns LEFT OUTER JOIN campaigns_subscriber_lists ON campaigns_subscriber_lists.campaign_id = campaigns.id LEFT OUTER JOIN subscriber_lists ON subscriber_lists.id = campaigns_subscriber_lists.subscriber_list_id LEFT OUTER JOIN subscribers ON subscribers.subscriber_list_id = subscriber_lists.id LEFT OUTER JOIN contact_points ON contact_points.detail = subscribers.email_address LEFT OUTER JOIN parties ON parties.id = contact_points.party_id where (subscribers.customer_id = view_customers.id or parties.id = view_customers.id) and #{campaign_not_in})")
  end

  drop_reason_codes = Array(query_params[:custom_drop_reason_codes]).filter_map(&:presence)
  results = results.joins(:customer_drop_events).where(customer_drop_events: { reason_code: drop_reason_codes }) if drop_reason_codes.present?

  drop_rep_ids = Array(query_params[:custom_drop_rep_ids]).filter_map(&:presence)
  results = results.joins(:customer_drop_events).where(customer_drop_events: { sales_rep_id: drop_rep_ids }) if drop_rep_ids.present?

  profile_ids = Array(query_params[:profile_in]).filter_map(&:presence)
  if profile_ids.present?
    results = results.where.not(profile_id: [ProfileConstants::HOMEOWNER]) if profile_ids.delete('ORG')
    results = results.where(profile_id: profile_ids) if profile_ids.present?
  end
  not_profile_ids = Array(query_params[:profile_not_in]).filter_map(&:presence)
  if not_profile_ids.present?
    results = results.where(profile_id: ProfileConstants::HOMEOWNER) if not_profile_ids.delete('ORG')
    results = results.where.not(profile_id: not_profile_ids) if not_profile_ids.present?
  end

  results = results.joins(:certifications) if query_params[:is_certified_installer].present? && query_params[:is_certified_installer].to_b
  results
end

#drop_action_reps_for_selectObject

Mass actions section #



224
225
226
227
# File 'app/models/customer_search.rb', line 224

def drop_action_reps_for_select
  selected_customer_ids = search_results.pluck(:resource_id)
  Employee.assigned_sales_rep_options_for_select(customer_ids: selected_customer_ids)
end

#mass_assign_reps(params, cur_user = nil) ⇒ Object



229
230
231
232
233
234
235
236
237
# File 'app/models/customer_search.rb', line 229

def mass_assign_reps(params, cur_user = nil)
  jid = MassSearch::CustomerAssignRepsWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'resource_params' => (params[:resource] || {}).to_h, 'activity' => (params[:activity] || {}).to_h },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_assign_source(params, cur_user = nil) ⇒ Object



253
254
255
256
257
258
259
260
261
# File 'app/models/customer_search.rb', line 253

def mass_assign_source(params, cur_user = nil)
  jid = ::SearchResourceUpdateWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'resource_params' => { 'source_id' => params[:source_id] } },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_assign_to_campaign(params, cur_user = nil) ⇒ Object



283
284
285
286
287
288
289
290
291
292
293
294
# File 'app/models/customer_search.rb', line 283

def mass_assign_to_campaign(params, cur_user = nil)
  campaign_params = params[:campaign] || {}
  return { status: :error, message: 'You must specify an existing campaign or enter a name for a new campaign' } if campaign_params[:campaign_id].blank? && campaign_params[:new_campaign_name].blank?

  jid = MassSearch::CustomerAssignToCampaignWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'campaign' => campaign_params.to_h },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_assign_to_subscriber_list(params, cur_user = nil) ⇒ Object



273
274
275
276
277
278
279
280
281
# File 'app/models/customer_search.rb', line 273

def mass_assign_to_subscriber_list(params, cur_user = nil)
  jid = MassSearch::CustomerAssignToSubscriberListWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'subscriber_list_id' => params[:subscriber_list_id] },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_auto_assign_sales_rep(params, cur_user = nil) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'app/models/customer_search.rb', line 239

def mass_auto_assign_sales_rep(params, cur_user = nil)
  jid = MassSearch::CustomerAutoAssignSalesRepWorker.perform_async(
    'search_id'     => id,
    'action_params' => {
      'remove_existing_reps'    => params[:remove_existing_reps],
      'skip_lead_assignment'    => params[:skip_lead_assignment],
      'activity'                => (params[:activity] || {}).to_h
    },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_create_subscriber_list(params, cur_user = nil) ⇒ Object



263
264
265
266
267
268
269
270
271
# File 'app/models/customer_search.rb', line 263

def mass_create_subscriber_list(params, cur_user = nil)
  jid = MassSearch::CustomerCreateSubscriberListWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'subscriber_list' => (params[:subscriber_list] || {}).to_h },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_delete_on_lead_qualify_and_guest_state(_params, cur_user = nil) ⇒ Object



306
307
308
309
310
311
312
313
314
# File 'app/models/customer_search.rb', line 306

def mass_delete_on_lead_qualify_and_guest_state(_params, cur_user = nil)
  jid = MassSearch::CustomerDeleteLeadsWorker.perform_async(
    'search_id'     => id,
    'action_params' => {},
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_drop_sales_rep(params, cur_user) ⇒ Object



334
335
336
337
338
339
340
341
342
# File 'app/models/customer_search.rb', line 334

def mass_drop_sales_rep(params, cur_user)
  jid = MassSearch::CustomerDropSalesRepWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'sales_rep_id' => params[:sales_rep_id], 'reason_code' => params[:reason_code], 'comment' => params[:comment] },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_locator_blacklist(params, cur_user) ⇒ Object



354
355
356
357
358
359
360
361
362
# File 'app/models/customer_search.rb', line 354

def mass_locator_blacklist(params, cur_user)
  jid = MassSearch::CustomerLocatorBlacklistWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'reason' => params[:reason] },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_locator_unblacklist(params, cur_user) ⇒ Object



374
375
376
377
378
379
380
381
382
# File 'app/models/customer_search.rb', line 374

def mass_locator_unblacklist(params, cur_user)
  jid = MassSearch::CustomerLocatorUnblacklistWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'inform_customer' => params[:inform_customer] },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_locator_unwhitelist(_params, cur_user) ⇒ Object



364
365
366
367
368
369
370
371
372
# File 'app/models/customer_search.rb', line 364

def mass_locator_unwhitelist(_params, cur_user)
  jid = MassSearch::CustomerLocatorUnwhitelistWorker.perform_async(
    'search_id'     => id,
    'action_params' => {},
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_locator_whitelist(params, cur_user) ⇒ Object



344
345
346
347
348
349
350
351
352
# File 'app/models/customer_search.rb', line 344

def mass_locator_whitelist(params, cur_user)
  jid = MassSearch::CustomerLocatorWhitelistWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'reason' => params[:reason] },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_online_account_invite(_params, cur_user = nil) ⇒ Object



384
385
386
387
388
389
390
391
392
# File 'app/models/customer_search.rb', line 384

def (_params, cur_user = nil)
  jid = MassSearch::CustomerOnlineAccountInviteWorker.perform_async(
    'search_id'     => id,
    'action_params' => {},
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_schedule_activity(params, cur_user = nil) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'app/models/customer_search.rb', line 316

def mass_schedule_activity(params, cur_user = nil)
  activity_params = (params[:activity] || {}).to_h
  apply_scope = activity_params.delete('apply_scope')
  campaign_id = activity_params.delete('campaign_id')

  jid = MassSearch::CustomerScheduleActivityWorker.perform_async(
    'search_id'     => id,
    'action_params' => { 'activity' => activity_params, 'apply_scope' => apply_scope, 'campaign_id' => campaign_id },
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#mass_switch_rep_roles(_params, cur_user = nil) ⇒ Object



296
297
298
299
300
301
302
303
304
# File 'app/models/customer_search.rb', line 296

def mass_switch_rep_roles(_params, cur_user = nil)
  jid = MassSearch::CustomerSwitchRepRolesWorker.perform_async(
    'search_id'     => id,
    'action_params' => {},
    'user_id'       => cur_user&.id,
    'locale'        => I18n.locale.to_s
  )
  { status: :ok, job_id: jid }
end

#set_defaultsObject

Default new customer searches to every state except guest. Expressed as
state_in (positive list) rather than state_not_in: ['guest'] so the
Status filter renders as "is any of" with the included states visible —
clearer to users than an exclusion they have to mentally invert.



102
103
104
105
106
107
108
109
110
111
# File 'app/models/customer_search.rb', line 102

def set_defaults
  super
  return unless new_record?

  qp = (query_params || {}).with_indifferent_access
  return if qp[:state_in].present? || qp[:state_not_in].present?

  non_guest_states = Customer.states_for_select.map(&:last) - ['guest']
  self.query_params = (query_params || {}).merge('state_in' => non_guest_states)
end