Class: CustomerOutletPurchase::RepEngagement

Inherits:
Object
  • Object
show all
Defined in:
app/models/customer_outlet_purchase/rep_engagement.rb

Overview

What the rep actually did on this project, for the person deciding whether an
outlet purchase earns credit.

MatchEvidence answers "is this the same customer
buying the same thing?". It says nothing about whether we earned the sale,
and that is the question review kept running aground on: a customer who
arrived from Amazon, got a 20-second quick quote and bought on Amazon the same
day is a correct match and a poor case for credit.

Two windows, because they mean opposite things:

  • Before the purchase — work that could have influenced the sale. This is
    the credit case.
  • After the purchase — outreach spent chasing an order that had already
    landed. This is the waste the whole feature exists to stop, and it is worth
    showing even when the credit answer is no.

Deliberately NOT a model. It scores effort, and effort is one input to a human
decision — nothing here gates a transition or computes money.

See Also:

  • MatchEvidence
  • doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTIONdoc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md

Constant Summary collapse

WEIGHTS =

Weights sum to 100 so the score reads as a percentage without scaling.

Calls carry the most because they are the only signal that cannot be
automated — nothing dials a customer on its own, so a logged conversation is
proof a person did something. Email carries the least for the opposite
reason: there is no field on a communication saying a machine sent it, and
#rep_initiated? is only a proxy, so email is worth showing a reviewer and
not worth much of a score. Closing that gap would let this weight rise again:
doc/tasks/202608161030_COMMUNICATION_ORIGIN_TRACKING.md.

{ activities: 30, communications: 10, calls: 45, quote: 15 }.freeze
QUICK_QUOTE_SECONDS =

A quote raised this soon after the opportunity opened came from a
quick-quote flow, not from working a lead. Reference case: account, project
and quote created 20 seconds apart on a customer who arrived from Amazon.

120
RETAILER_ORIGIN_SOURCES =

Sources that mean the customer arrived FROM a retailer. Crediting a
marketplace sale to our own funnel is exactly backwards when the marketplace
sent them, so this is surfaced as a flag rather than folded into the score.

%w[Amazon Retail].freeze
BANDS =

Score bands. Nothing enforces them — they set the gauge colour and give
reviewers shared language.

[[70, 'strong', 'success'], [35, 'moderate', 'warning'], [0, 'thin', 'danger']].freeze
CONTRADICTION_CONFIDENCE =

Match confidence at or above which #contradicts_match? calls a thin
engagement score a contradiction worth flagging.

0.9
AUTOMATED_TAGS =

Activity-type tags marking a step the sequence engine fired rather than a
person: drip is the email sequences ("Email 1: Welcome to WarmlyYours…",
the Snow Melt and PRO Illinois campaigns) and nurture-me the timed
follow-up ladders ("2nd follow up", "3rd follow up").

Deliberately NOT campaign or mailing. Two-thirds of campaign types
already carry drip, and the rest are things a person does ("Call Campaign
Dealer Resource"); mailing is a person posting a brochure. Nor
lead-the-way, which is ordinary rep follow-up despite the name.

%w[drip nurture-me].freeze
CONNECTED_OUTCOMES =

Call outcomes that mean somebody picked up.

%w[inquiry support sale].freeze
CONNECTED_CALL_SECONDS =

An unclassified call this long was a conversation, whatever nobody recorded.

60
VOICEMAIL_CREDIT =

A voicemail is real effort that reached nobody, so it earns half a call.

0.5

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(purchase) ⇒ RepEngagement

Returns a new instance of RepEngagement.

Parameters:



76
77
78
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 76

def initialize(purchase)
  @purchase = purchase
end

Instance Attribute Details

#purchaseCustomerOutletPurchase (readonly)



81
82
83
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 81

def purchase
  @purchase
end

Instance Method Details

#activity_actor(activity) ⇒ Party?

Who did an activity, judged by its result rather than its assignment.

Nobody for a cancelled or still-open task: assignment is who it was given
to, and a task nobody finished is not work anyone did. Otherwise the
recorded closer, falling back to the assignee when no closer was captured
— see #actors_for for why that fallback has to exist.

Parameters:

Returns:



147
148
149
150
151
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 147

def activity_actor(activity)
  return nil unless completed_activity?(activity)

  activity.closed_by || activity.assigned_resource
end

#bandString

Returns 'strong' | 'moderate' | 'thin'.

Returns:

  • (String)

    'strong' | 'moderate' | 'thin'



96
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 96

def band = BANDS.find { |floor, _, _| score >= floor }[1]

#band_colorString

Returns Bootstrap contextual colour for the gauge.

Returns:

  • (String)

    Bootstrap contextual colour for the gauge



99
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 99

def band_color = BANDS.find { |floor, _, _| score >= floor }[2]

#completed_activity?(activity) ⇒ Boolean

A result was recorded and it was not a cancellation. Mirrors
ActivityResultType.completed, which the score already uses, so the names
and the number are drawn from the same set.

Parameters:

Returns:

  • (Boolean)


169
170
171
172
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 169

def completed_activity?(activity)
  activity.activity_result_type_id.present? &&
    activity.activity_result_type_id != ActivityResultTypeConstants::CANCEL
end

#contradicts_match?Boolean

A near-certain match on a project nobody actually worked.

The two scores answer different questions and are close to independent —
across the first 76 scored proposals they correlated at r = 0.09, and 12 of
them paired a confidence of 0.90 or better with thin engagement (three at a
confidence of 1.00, engagement 8, 18 and 26). So a strong match says nothing
about whether we earned the sale, which is why the review queue flags the
pairing rather than letting a reviewer read confidence as a verdict.

Returns:

  • (Boolean)


221
222
223
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 221

def contradicts_match?
  measurable? && purchase.confidence.to_f >= CONTRADICTION_CONFIDENCE && band == 'thin'
end

#contributorsArray<Hash{Symbol=>Object}>

Everyone who actually touched the project, and what each of them did.

The score answers "how much effort", never "whose" — and a reviewer ruling
on commission needs the second question answered too, because the rep the
opportunity names is often not the person in the activity log. On ON861053
the primary rep is one person while the work is split across three others.

Counted from the same records the signals score, so the names and the number
cannot disagree. Ordered by how much each did.

Returns:

  • (Array<Hash{Symbol=>Object}>)

    { party:, name:, actions: }



122
123
124
125
126
127
128
129
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 122

def contributors
  @contributors ||= begin
    tally = Hash.new { |h, party| h[party] = 0 }
    signals.each { |signal| signal[:records].to_a.each { |record| actors_for(record).each { |a| tally[a] += 1 } } }
    tally.sort_by { |party, count| [-count, party.full_name.to_s] }
         .map { |party, count| { party: party, name: party.full_name, actions: count } }
  end
end

#days_chased_after_purchaseInteger?

Returns days the project stayed open past the purchase.

Returns:

  • (Integer, nil)

    days the project stayed open past the purchase



199
200
201
202
203
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 199

def days_chased_after_purchase
  return if invoice_date.nil? || opportunity.nil? || opportunity.closed?

  (Date.current - invoice_date).to_i.clamp(0, nil)
end

#inferred_actor?(activity) ⇒ Boolean

Whether #activity_actor is the recorded closer or a fallback to the
assignee. The page marks the second kind rather than presenting a guess as
a fact on a screen where commission is approved.

Parameters:

Returns:

  • (Boolean)


159
160
161
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 159

def inferred_actor?(activity)
  completed_activity?(activity) && activity.closed_by.nil? && activity.assigned_resource.present?
end

#measurable?Boolean

Whether there is anything to show — a project to have worked, attached or
detached.

Returns:

  • (Boolean)


209
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 209

def measurable? = opportunity.present?

#origin_source_nameString?

Returns the account's original source, as recorded.

Returns:

  • (String, nil)

    the account's original source, as recorded



183
184
185
186
187
188
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 183

def origin_source_name
  return @origin_source_name if defined?(@origin_source_name)

  customer = purchase.customer
  @origin_source_name = (customer&.original_source || customer&.source)&.name
end

#post_purchase_summaryString?

One sentence about what happened after the customer had already bought.

Assembled here rather than in the template: the view built it from 'and t'
and 'T' fragments across two conditionals, which is correct exactly until
someone edits one branch.

Returns:

  • (String, nil)

    nil when there is nothing to report



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 232

def post_purchase_summary
  clauses = []
  if wasted_touchpoints.positive?
    clauses << "#{ActionController::Base.helpers.pluralize(wasted_touchpoints, 'touchpoint')} " \
               'spent on a customer who had already bought'
  end
  days = days_chased_after_purchase.to_i
  if days.positive?
    clauses << "the project has stayed open #{ActionController::Base.helpers.pluralize(days, 'day')} " \
               'past the order'
  end
  return if clauses.empty?

  "Since the purchase: #{clauses.to_sentence}."
end

#primary_repEmployee?

The rep the project names, whether or not they appear in #contributors
a rep credited with a project they did none of is exactly what a reviewer
needs to see.

Returns:



136
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 136

def primary_rep = opportunity&.primary_sales_rep

#retailer_origin?Boolean

The customer arrived from a retailer, so the outlet sale is where they
started rather than where we lost them.

Returns:

  • (Boolean)


178
179
180
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 178

def retailer_origin?
  RETAILER_ORIGIN_SOURCES.include?(origin_source_name)
end

#scoreInteger

0-100. A weighted sum of four independent signals, so no single one can
carry the verdict.

Zero when there is no project to have worked at all — including the
detached one #opportunity falls back to — rather than raising, so callers
need not repeat #measurable?.

Returns:

  • (Integer)


91
92
93
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 91

def score
  @score ||= signals.sum { |signal| signal[:earned] }.round
end

#signalsArray<Hash{Symbol=>Object}>

Per-signal working, for the same table treatment the confidence breakdown
already uses. Empty when there is no project — see #measurable?.

Returns:

  • (Array<Hash{Symbol=>Object}>)

    { signal:, detail:, earned:, weight: }



105
106
107
108
109
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 105

def signals
  return @signals = [] unless measurable?

  @signals ||= [activity_signal, communication_signal, call_signal, quote_signal]
end

#wasted_touchpointsInteger

Outreach logged AFTER the customer had already bought — the wasted-follow-up
figure, and the reason this feature earns its keep regardless of commission.

Returns:

  • (Integer)


194
195
196
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 194

def wasted_touchpoints
  @wasted_touchpoints ||= after_purchase_communications + after_purchase_calls
end