Class: CustomerOutletPurchase::MatchEvidence

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

Overview

The working behind a CustomerOutletPurchase verdict: how closely what the
customer bought resembles what we quoted, how much of the project it covers,
and how confident the match is.

Extracted from the model, which had grown to 31 methods. This is a cohesive
unit — every method here answers "why do we think this retailer invoice
belongs to this project" — and none of it is persistence.

The model persists three of these values at creation (similarity,
coverage_ratio, confidence) because they are evidence about the match at
the moment it was made; the rest are computed on demand for the analysis page.

See Also:

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

Constant Summary collapse

IDENTITY_WEIGHTS =

Confidence is the sum of three independent signals, each capped so no one of
them can carry a match alone. Deliberately a plain weighted sum: explainable
to a rep looking at the number, and tunable without retraining anything.

{ 'both' => 0.4, 'manual' => 0.4, 'name' => 0.3, 'address' => 0.3 }.freeze
PRODUCT_WEIGHTS =

See Also:

{ 'exact_sku' => 0.4, 'same_category' => 0.25, 'unknown' => 0.1,
'different_category' => 0.0 }.freeze
PROMPT_PURCHASE_DAYS =

A purchase within a fortnight of the quote is far likelier to be the same
decision than one two months later.

14
SHIPPING_ROOT =

Product-line root of the Shipping tree. Its children (Economy,
Express) appear on most quotes and invoices and would otherwise make every
pair look related.

'244'

Instance Method Summary collapse

Constructor Details

#initialize(purchase, opportunity: purchase.opportunity) ⇒ MatchEvidence

Returns a new instance of MatchEvidence.

Parameters:

  • purchase (CustomerOutletPurchase)
  • opportunity (Opportunity, nil) (defaults to: purchase.opportunity)

    the project to compare against.
    Defaults to the attached one, which is what the persisted verdicts
    (similarity, coverage_ratio, confidence) are computed from and must
    keep being computed from. The analysis page passes the DETACHED project
    explicitly to show a customer-level reviewer what was quoted versus what
    was bought — a read-only second opinion that never reaches a column.



42
43
44
45
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 42

def initialize(purchase, opportunity: purchase.opportunity)
  @purchase = purchase
  @opportunity = opportunity
end

Instance Method Details

#confidenceBigDecimal

Overall likelihood this purchase really is this customer's — and, when an
opportunity is attached, really is that project.

Returns:

  • (BigDecimal)

    0.000 .. 1.000



88
89
90
91
92
93
94
95
96
97
98
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 88

def confidence
  score = if purchase.opportunity_id?
            identity_weight + product_weight + timing_weight
          else
            # No product or timing evidence to add, so normalise identity up
            # to the full scale rather than capping every customer-level row
            # at 0.4.
            identity_weight / IDENTITY_WEIGHTS.values.max
          end
  BigDecimal(score.to_s).clamp(0, 1).round(3)
end

#confidence_breakdownArray<Hash>

The three signals behind #confidence, so the number is auditable rather
than magic.

Returns:

  • (Array<Hash>)

    { signal:, detail:, weight: }



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

def confidence_breakdown
  [
    { signal: 'Identity', detail: identity_detail, weight: identity_weight },
    { signal: 'Product', detail: stored_or_computed_similarity.to_s.humanize, weight: product_weight },
    { signal: 'Timing', detail: timing_detail, weight: timing_weight }
  ]
end

#coverage_basis_descriptionString

Plain-English statement of what #coverage_ratio is measured against.
"The quote" is not a well-defined thing on an opportunity — there can be a
revision chain and parallel variation options — so name the basis explicitly
rather than letting the reader assume.

Returns:

  • (String)


118
119
120
121
122
123
124
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 118

def coverage_basis_description
  return 'No opportunity value recorded.' if opportunity&.value.to_f.zero?

  frozen = purchase.coverage_basis_value || opportunity.value
  "Measured against #{coverage_basis} as it stood when the match was made: " \
    "#{ActiveSupport::NumberHelper.number_to_currency(frozen)}."
end

#coverage_ratioBigDecimal?

Fraction of the opportunity's recorded value this purchase represents. Above
1.0 means they bought more than the opportunity was valued at.

The denominator is Opportunity#value — the house measure behind pipeline
reporting and ad conversion values — deliberately, so this does not become a
second, disagreeing notion of deal size. See #coverage_basis_description
for what that resolves to.

Returns:

  • (BigDecimal, nil)

    nil when the opportunity carries no value



78
79
80
81
82
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 78

def coverage_ratio
  return if opportunity.nil? || opportunity.value.to_f.zero?

  (purchase.invoice.total / opportunity.value).round(4)
end

#matched_item_idsArray<Integer>

Item ids present on both sides — the SKUs that drove an exact_sku verdict.

Returns:

  • (Array<Integer>)


158
159
160
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 158

def matched_item_ids
  @matched_item_ids ||= quoted_items.pluck(:id) & bought_items.pluck(:id)
end

#purchased_linesActiveRecord::Relation<LineItem>

Returns what they actually bought.

Returns:

  • (ActiveRecord::Relation<LineItem>)

    what they actually bought



151
152
153
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 151

def purchased_lines
  LineItem.where(resource_type: 'Invoice', resource_id: purchase.invoice_id).includes(:item)
end

#quoted_lines_by_quoteHash{Quote => Array<LineItem>}

Quote line items the opportunity ever carried, grouped by quote so the page
can name which revision or variation each came from.

Returns:



141
142
143
144
145
146
147
148
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 141

def quoted_lines_by_quote
  return {} if opportunity.nil?

  LineItem.where(resource_type: 'Quote', resource_id: opportunity.quotes.select(:id))
          .includes(:item)
          .group_by { |line| quotes_by_id[line.resource_id] }
          .compact
end

#shared_category_rootsArray<String>

Product-line roots present on both sides but via different SKUs.

Returns:

  • (Array<String>)


165
166
167
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 165

def shared_category_roots
  @shared_category_roots ||= quoted_items.pluck(:root) & bought_items.pluck(:root)
end

#similarityString

How closely what they bought resembles what was quoted:
exact_skusame_categorydifferent_category.

Compares against the union of EVERY quote on the opportunity — all revisions
and all parallel variation options. That is deliberate: the question is "did
they buy something we proposed", and a product quoted in r1 and dropped in
r2 was still proposed. It also sidesteps the fact that "the quote" is
ill-defined when an opportunity carries several option quotes.

unknown means one side has no classifiable items.

Returns:

  • (String)


59
60
61
62
63
64
65
66
67
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 59

def similarity
  return 'unknown' if opportunity.nil?
  return 'unknown' if quoted_items.empty? || bought_items.empty?

  return 'exact_sku' if quoted_items.pluck(:id).intersect?(bought_items.pluck(:id))
  return 'same_category' if quoted_items.pluck(:root).intersect?(bought_items.pluck(:root))

  'different_category'
end

#value_sourceQuote, ...

The quote Opportunity#value was derived from, or :sales_orders when
orders displaced quotes entirely.

Returns:



130
131
132
133
134
135
# File 'app/models/customer_outlet_purchase/match_evidence.rb', line 130

def value_source
  return if opportunity.nil?
  return :sales_orders if opportunity.orders.sales_orders.exists?

  opportunity.quotes.sales_quotes.active.where.not(line_total: nil).order(line_total: :desc).first
end