Class: CustomerOutletPurchase::MatchEvidence
- Inherits:
-
Object
- Object
- CustomerOutletPurchase::MatchEvidence
- 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.
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 =
{ '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
Shippingtree. Its children (Economy,
Express) appear on most quotes and invoices and would otherwise make every
pair look related. '244'
Instance Method Summary collapse
-
#confidence ⇒ BigDecimal
Overall likelihood this purchase really is this customer's — and, when an opportunity is attached, really is that project.
-
#confidence_breakdown ⇒ Array<Hash>
The three signals behind #confidence, so the number is auditable rather than magic.
-
#coverage_basis_description ⇒ String
Plain-English statement of what #coverage_ratio is measured against.
-
#coverage_ratio ⇒ BigDecimal?
Fraction of the opportunity's recorded value this purchase represents.
-
#initialize(purchase, opportunity: purchase.opportunity) ⇒ MatchEvidence
constructor
A new instance of MatchEvidence.
-
#matched_item_ids ⇒ Array<Integer>
Item ids present on both sides — the SKUs that drove an
exact_skuverdict. -
#purchased_lines ⇒ ActiveRecord::Relation<LineItem>
What they actually bought.
-
#quoted_lines_by_quote ⇒ Hash{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.
-
#shared_category_roots ⇒ Array<String>
Product-line roots present on both sides but via different SKUs.
-
#similarity ⇒ String
How closely what they bought resembles what was quoted:
exact_sku›same_category›different_category. -
#value_source ⇒ Quote, ...
The quote
Opportunity#valuewas derived from, or:sales_orderswhen orders displaced quotes entirely.
Constructor Details
#initialize(purchase, opportunity: purchase.opportunity) ⇒ MatchEvidence
Returns a new instance of MatchEvidence.
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
#confidence ⇒ BigDecimal
Overall likelihood this purchase really is this customer's — and, when an
opportunity is attached, really is that project.
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_breakdown ⇒ Array<Hash>
The three signals behind #confidence, so the number is auditable rather
than magic.
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_description ⇒ String
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.
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_ratio ⇒ BigDecimal?
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.
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_ids ⇒ Array<Integer>
Item ids present on both sides — the SKUs that drove an exact_sku verdict.
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_lines ⇒ ActiveRecord::Relation<LineItem>
Returns 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_quote ⇒ Hash{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.
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_roots ⇒ Array<String>
Product-line roots present on both sides but via different SKUs.
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 |
#similarity ⇒ String
How closely what they bought resembles what was quoted:
exact_sku › same_category › different_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.
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_source ⇒ Quote, ...
The quote Opportunity#value was derived from, or :sales_orders when
orders displaced quotes entirely.
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 |