Class: CustomerOutletPurchase::RepEngagement
- Inherits:
-
Object
- Object
- CustomerOutletPurchase::RepEngagement
- 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.
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:dripis the email sequences ("Email 1: Welcome to WarmlyYours…",
the Snow Melt and PRO Illinois campaigns) andnurture-methe timed
follow-up ladders ("2nd follow up", "3rd follow up").Deliberately NOT
campaignormailing. Two-thirds ofcampaigntypes
already carrydrip, and the rest are things a person does ("Call Campaign
Dealer Resource");mailingis 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
- #purchase ⇒ CustomerOutletPurchase readonly
Instance Method Summary collapse
-
#activity_actor(activity) ⇒ Party?
Who did an activity, judged by its result rather than its assignment.
-
#band ⇒ String
'strong' | 'moderate' | 'thin'.
-
#band_color ⇒ String
Bootstrap contextual colour for the gauge.
-
#completed_activity?(activity) ⇒ Boolean
A result was recorded and it was not a cancellation.
-
#contradicts_match? ⇒ Boolean
A near-certain match on a project nobody actually worked.
-
#contributors ⇒ Array<Hash{Symbol=>Object}>
Everyone who actually touched the project, and what each of them did.
-
#days_chased_after_purchase ⇒ Integer?
Days the project stayed open past the purchase.
-
#inferred_actor?(activity) ⇒ Boolean
Whether #activity_actor is the recorded closer or a fallback to the assignee.
-
#initialize(purchase) ⇒ RepEngagement
constructor
A new instance of RepEngagement.
-
#measurable? ⇒ Boolean
Whether there is anything to show — a project to have worked, attached or detached.
-
#origin_source_name ⇒ String?
The account's original source, as recorded.
-
#post_purchase_summary ⇒ String?
One sentence about what happened after the customer had already bought.
-
#primary_rep ⇒ Employee?
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.
-
#retailer_origin? ⇒ Boolean
The customer arrived from a retailer, so the outlet sale is where they started rather than where we lost them.
-
#score ⇒ Integer
0-100.
-
#signals ⇒ Array<Hash{Symbol=>Object}>
Per-signal working, for the same table treatment the confidence breakdown already uses.
-
#wasted_touchpoints ⇒ Integer
Outreach logged AFTER the customer had already bought — the wasted-follow-up figure, and the reason this feature earns its keep regardless of commission.
Constructor Details
#initialize(purchase) ⇒ RepEngagement
Returns a new instance of RepEngagement.
76 77 78 |
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 76 def initialize(purchase) @purchase = purchase end |
Instance Attribute Details
#purchase ⇒ CustomerOutletPurchase (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.
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 |
#band ⇒ String
Returns 'strong' | 'moderate' | 'thin'.
96 |
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 96 def band = BANDS.find { |floor, _, _| score >= floor }[1] |
#band_color ⇒ String
Returns 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.
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.
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 |
#contributors ⇒ Array<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.
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_purchase ⇒ Integer?
Returns 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.
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.
209 |
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 209 def measurable? = opportunity.present? |
#origin_source_name ⇒ String?
Returns 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_summary ⇒ String?
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.
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_rep ⇒ Employee?
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.
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.
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 |
#score ⇒ Integer
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?.
91 92 93 |
# File 'app/models/customer_outlet_purchase/rep_engagement.rb', line 91 def score @score ||= signals.sum { |signal| signal[:earned] }.round end |
#signals ⇒ Array<Hash{Symbol=>Object}>
Per-signal working, for the same table treatment the confidence breakdown
already uses. Empty when there is no project — see #measurable?.
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_touchpoints ⇒ Integer
Outreach logged AFTER the customer had already bought — the wasted-follow-up
figure, and the reason this feature earns its keep regardless of commission.
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 |