Class: CustomerOutletPurchase
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- CustomerOutletPurchase
- Includes:
- Models::Auditable
- Defined in:
- app/models/customer_outlet_purchase.rb
Overview
Links an Opportunity a sales rep worked to the Invoice the customer
actually generated through a retail outlet — Amazon, Home Depot, Costco,
Build.com, a distributor — instead of buying direct.
Verification is a single gate answering two questions at once: is this order
part of that project, and does the rep earn commission on it? Sales
management set the rate at 1% (Setting.outlet_purchase_commission_rate),
treating it like an assisted tech call rather than a direct sale.
Nothing here writes to the Opportunity: orders.opportunity_id and the rep
columns on Order/Invoice deliberately stay untouched, because four separate
reporting paths key off them and would silently count an outlet sale as a
direct win. Commission is read FROM this table, never pushed into those.
See doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md § 3.
The invoice is the anchor for every channel, never the order: Amazon FBA
sales produce an invoice with no order at all.
The nightly sweep's proposals land unverified and the orders team rules on
them. A rep adding an order they already know about skips the queue.
Defined Under Namespace
Classes: Commission, MatchEvidence, RepEngagement
Constant Summary collapse
- MATCH_KINDS =
How the opportunity and the invoice were tied together.
manualis a human
asserting the link — the only route that works for Amazon FBA, which lands
as a consignment invoice (Invoice::CI, no order) whose ship-to Amazon
anonymises: no name at all, andstreet1merely repeats the city. %i[name address both manual].freeze
- SOURCES =
Who proposed the row.
%i[nightly rep manager].freeze
- OUTLET_LABELS =
Catalog → the vocabulary
Opportunity#purchase_outletalready uses. 327
existing records use these labels, so mapping keeps old and new rows
grouping together in reports instead of fragmenting "Amazon.com" from
"Amazon Seller (USA)". Anything unmapped falls back to the catalog name. { CatalogConstants::AMAZON_SC_US_CATALOG_ID => 'Amazon.com', CatalogConstants::AMAZON_SC_CA_CATALOG_ID => 'Amazon.com', CatalogConstants::HOME_DEPOT_USA => 'Home Depot', CatalogConstants::HOME_DEPOT_CANADA => 'Home Depot', CatalogConstants::COSTCO_CANADA => 'Costco.com', CatalogConstants::LOWES_USA => 'Lowes', # The Lowe's Canada catalog is retired — the chain is RONA now, and RONA has # no entry of its own in `Opportunity#purchase_outlet`, so both land on the # same label RONA_CANADA already uses rather than claiming a Lowe's sale. CatalogConstants::LOWES_CANADA => 'Other Retailer', CatalogConstants::WAYFAIR_USA => 'Other E-Tailer', CatalogConstants::WAYFAIR_CANADA => 'Other E-Tailer', CatalogConstants::BUILD_COM => 'Other E-Tailer', CatalogConstants::RONA_CANADA => 'Other Retailer', CatalogConstants::WALMART_SELLER_USA => 'Other Retailer' }.freeze
- WRITTEN_OFF_STATES =
States where we recorded that the sale did not happen. A verified outlet
purchase says it did — just not through us — so the record is wrong and gets
corrected.cancelledis deliberately NOT here: cancelling is an explicit
"this project is not happening" decision, often a duplicate or junk record,
and silently reversing one would be a surprise rather than a correction. %w[lost abandoned].freeze
- MATCH_WINDOW_DAYS =
An invoice more than this many days after the opportunity is coincidence,
not follow-through: 0-60 days holds 89% of the revenue a 180-day window
finds, and 80% of it lands inside 30. 60
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Belongs to collapse
-
#customer ⇒ Customer
Whose purchase this is; the anchor, always present.
-
#invoice ⇒ Invoice
The retailer invoice; never an order, FBA has none.
-
#opportunity ⇒ Opportunity?
The project, when the purchase relates to one.
-
#verified_by ⇒ Party?
Whoever accepted the attribution.
Instance Attribute Summary collapse
-
#invoice_id ⇒ Object
readonly
Mirror the two partial UNIQUE indexes so a duplicate surfaces as a form error instead of a 500.
Delegated Instance Attributes collapse
-
#commission_basis ⇒ Object
Alias for Commission#basis.
-
#commission_rate ⇒ Object
Alias for Commission#rate.
-
#commission_value ⇒ Object
Alias for Commission#value.
-
#confidence_breakdown ⇒ Object
Alias for Evidence#confidence_breakdown.
-
#coverage_basis_description ⇒ Object
Alias for Evidence#coverage_basis_description.
-
#matched_item_ids ⇒ Object
Alias for Evidence#matched_item_ids.
-
#purchased_lines ⇒ Object
Alias for Evidence#purchased_lines.
-
#quoted_lines_by_quote ⇒ Object
Alias for Evidence#quoted_lines_by_quote.
-
#shared_category_roots ⇒ Object
Alias for Evidence#shared_category_roots.
-
#value_source ⇒ Object
Alias for Evidence#value_source.
Class Method Summary collapse
-
.accepted ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are accepted.
-
.closing ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are closing.
-
.customer_level ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are customer level.
-
.unverified ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are unverified.
Instance Method Summary collapse
-
#announce_ruling ⇒ void
Announce a ruling so the handlers can react to committed state.
-
#attributed? ⇒ Boolean
Verified: belongs to this project and approved for commission.
-
#candidate_opportunity ⇒ Opportunity?
The project a customer-level row came off, recomputed.
-
#channel_label ⇒ String?
The
Opportunity#purchase_outletvocabulary this purchase maps onto. -
#close_opportunity! ⇒ Boolean
Mark this purchase as satisfying the whole opportunity and close it
won. -
#commission ⇒ CustomerOutletPurchase::Commission
What this purchase pays the rep, once verified.
-
#correct_a_written_off_opportunity ⇒ Boolean
Turn a written-off opportunity
wonon verification, and leave every other state alone. -
#evidence ⇒ CustomerOutletPurchase::MatchEvidence
The reasoning behind this match — similarity, coverage, confidence and the line-item comparison.
- #invoice_date ⇒ Date?
-
#lag_days ⇒ Integer?
Days between the opportunity opening and the outlet invoice.
-
#opportunity_date ⇒ Date?
Nil for a customer-level link with no project attached.
-
#outlet_name ⇒ String?
The outlet this purchase came through, for display and for seeding
Opportunity#purchase_outlet. -
#product_detached? ⇒ Boolean
Whether the nightly sweep matched this purchase to a project and then detached it because the products had nothing in common.
-
#rep_engagement ⇒ CustomerOutletPurchase::RepEngagement
What the rep actually did on the project — the credit question, which #evidence deliberately does not answer.
-
#restamp_purchase_outlet ⇒ void
Recompute
opportunity.purchase_outletfrom whatever is still accepted, after this row stopped being one of them. -
#reviewed_opportunity ⇒ Opportunity?
The project this ruling is about, attached or not — what the reviewer needs on screen either way.
-
#stamp_purchase_outlet ⇒ void
Record the channel on the opportunity so a won project never hides where the sale landed.
-
#undo_written_off_correction ⇒ Boolean
Put an opportunity back the way we found it when the verification that moved it is withdrawn.
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#customer ⇒ Customer
Returns whose purchase this is; the anchor, always present.
75 |
# File 'app/models/customer_outlet_purchase.rb', line 75 belongs_to :customer, inverse_of: :outlet_purchases |
#invoice ⇒ Invoice
Returns the retailer invoice; never an order, FBA has none.
81 |
# File 'app/models/customer_outlet_purchase.rb', line 81 belongs_to :invoice, inverse_of: :outlet_purchases |
#invoice_id ⇒ Object (readonly)
Mirror the two partial UNIQUE indexes so a duplicate surfaces as a form
error instead of a 500. Project-level and customer-level rows are scoped
separately, exactly as the indexes are.
Validations (if => #opportunity_id? ):
- Uniqueness ({ scope: :opportunity_id, message: 'is already linked to this opportunity' })
Validations (unless => #opportunity_id? ):
- Uniqueness ({ scope: %i[customer_id opportunity_id], message: 'is already linked to this customer' })
94 95 96 |
# File 'app/models/customer_outlet_purchase.rb', line 94 validates :invoice_id, uniqueness: { scope: :opportunity_id, message: 'is already linked to this opportunity' }, if: :opportunity_id? |
#opportunity ⇒ Opportunity?
Returns the project, when the purchase relates to one.
78 |
# File 'app/models/customer_outlet_purchase.rb', line 78 belongs_to :opportunity, inverse_of: :outlet_purchases, optional: true |
#verified_by ⇒ Party?
Returns whoever accepted the attribution.
84 |
# File 'app/models/customer_outlet_purchase.rb', line 84 belongs_to :verified_by, class_name: 'Party', optional: true |
Class Method Details
.accepted ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are accepted. Active Record Scope
131 |
# File 'app/models/customer_outlet_purchase.rb', line 131 scope :accepted, -> { where(state: 'pending') } |
.closing ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are closing. Active Record Scope
134 |
# File 'app/models/customer_outlet_purchase.rb', line 134 scope :closing, -> { accepted.where(closes_opportunity: true) } |
.customer_level ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are customer level. Active Record Scope
136 |
# File 'app/models/customer_outlet_purchase.rb', line 136 scope :customer_level, -> { where(opportunity_id: nil) } |
.unverified ⇒ ActiveRecord::Relation<CustomerOutletPurchase>
A relation of CustomerOutletPurchases that are unverified. Active Record Scope
128 |
# File 'app/models/customer_outlet_purchase.rb', line 128 scope :unverified, -> { where(state: 'unverified') } |
Instance Method Details
#announce_ruling ⇒ void
This method returns an undefined value.
Announce a ruling so the handlers can react to committed state.
after_commit rather than the state machine: a row born pending from
#accept_when_added_by_hand never transitions, and hanging reactions off
after_transition twice produced the same silent bug — the rep's own link
skipping the channel stamp, then skipping the written-off correction.
Rejection is only announced when it withdraws an ACCEPTED attribution.
Rejecting an unverified proposal undoes nothing, because nothing had been
counted.
217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'app/models/customer_outlet_purchase.rb', line 217 def announce_ruling return unless saved_change_to_state? if pending? publish_event(Events::OutletPurchaseVerified, data: { customer_outlet_purchase_id: id, opportunity_id: opportunity_id, verified_by_id: verified_by_id }) elsif rejected? && state_before_last_save == 'pending' publish_event(Events::OutletPurchaseRejected, data: { customer_outlet_purchase_id: id, opportunity_id: opportunity_id }) end end |
#attributed? ⇒ Boolean
Verified: belongs to this project and approved for commission.
233 234 235 |
# File 'app/models/customer_outlet_purchase.rb', line 233 def attributed? pending? end |
#candidate_opportunity ⇒ Opportunity?
The project a customer-level row came off, recomputed.
Opportunity::OutletPurchaseMatcher matches on name and address, then
detaches the row when the purchase shares no product line with anything
quoted — a towel warmer against a floor-heating quote. It does not record
WHICH project it walked away from, so a reviewer opening one of these saw a
purchase, a confidence score, and no way to tell whether a rep had been
anywhere near this customer.
Recomputed rather than stored: the matcher's own rule is "nearest in time
within the window", which is cheap to reproduce and needs no backfill for
the rows already written. Every customer-level row in the first backlog
resolves to exactly one project this way.
Nil on an attached row — #opportunity is already the answer there.
404 405 406 407 408 409 410 411 412 413 414 415 416 417 |
# File 'app/models/customer_outlet_purchase.rb', line 404 def candidate_opportunity return @candidate_opportunity if defined?(@candidate_opportunity) return @candidate_opportunity = nil unless product_detached? # Ambiguity resolves to silence. The matcher picked its project with the # normalised ship-to name and address, which this does not reproduce — so # with more than one project in the window there is no way to say WHICH one # it walked away from, and naming the nearest would be a guess printed as a # fact. Every detached row in the first backlog has exactly one. window = (invoice_date - MATCH_WINDOW_DAYS).beginning_of_day..invoice_date.end_of_day in_window = Opportunity.where(customer_id: customer_id, created_at: window).limit(2).to_a @candidate_opportunity = in_window.one? ? in_window.first : nil end |
#channel_label ⇒ String?
The Opportunity#purchase_outlet vocabulary this purchase maps onto.
271 272 273 |
# File 'app/models/customer_outlet_purchase.rb', line 271 def channel_label OUTLET_LABELS[invoice&.customer&.catalog_id] || outlet_name end |
#close_opportunity! ⇒ Boolean
Mark this purchase as satisfying the whole opportunity and close it won.
Never automatic. 28% of matched purchases are under a quarter of the quoted
value, and won cancels every follow-up activity — so a machine closing a
$402 towel warmer against a $10,079 floor-heating quote would destroy the
chase on the remainder. coverage_ratio and similarity exist to inform
this decision, not to make it.
339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'app/models/customer_outlet_purchase.rb', line 339 def close_opportunity! # `sales_present?` reads `closing`, which is `attributed AND # closes_opportunity`. Persisting the flag on an unclaimed row would leave a # close decision on record with no `won` opportunity behind it. return false if opportunity.nil? || !attributed? update!(closes_opportunity: true) # `win` covers open states; a terminal opportunity needs `reopen`, which # routes to `won` on the same `sales_present?` guard. opportunity.win || opportunity.reopen unless opportunity.won? opportunity.reload.won? end |
#commission ⇒ CustomerOutletPurchase::Commission
What this purchase pays the rep, once verified. Extracted so the model stays
about persistence and lifecycle; see Commission.
356 357 358 |
# File 'app/models/customer_outlet_purchase.rb', line 356 def commission @commission ||= CustomerOutletPurchase::Commission.new(self) end |
#commission_basis ⇒ Object
Alias for Commission#basis
203 |
# File 'app/models/customer_outlet_purchase.rb', line 203 delegate :value, :basis, :rate, to: :commission, prefix: true |
#commission_rate ⇒ Object
Alias for Commission#rate
203 |
# File 'app/models/customer_outlet_purchase.rb', line 203 delegate :value, :basis, :rate, to: :commission, prefix: true |
#commission_value ⇒ Object
Alias for Commission#value
203 |
# File 'app/models/customer_outlet_purchase.rb', line 203 delegate :value, :basis, :rate, to: :commission, prefix: true |
#confidence_breakdown ⇒ Object
Alias for Evidence#confidence_breakdown
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#correct_a_written_off_opportunity ⇒ Boolean
Turn a written-off opportunity won on verification, and leave every other
state alone.
Two cases, opposite handling:
- Already written off (
lost/abandoned) — the record is simply
wrong. The customer bought; we logged a loss. 16abandonedand 13
lostin the dev backfill were mis-stated this way. Correcting it is
the whole point of "mark it won instead of lost". - Still open (
follow_up,quoting, …) — leave it open. A marketplace
order routinely misses the thermostat, sensor and rough-in kit, and 28%
of matches cover under a quarter of the quoted value. Closing those
would cancel every follow-up activity and kill a live upsell.
Note this wins on ANY coverage: a verified purchase against a written-off
project is better recorded as a partial win than as a loss that never
happened. #close_opportunity! stays available for the open-project case,
where a human decides the purchase finished the job.
295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'app/models/customer_outlet_purchase.rb', line 295 def correct_a_written_off_opportunity return false unless opportunity&.state&.in?(WRITTEN_OFF_STATES) # Remembered so {#undo_written_off_correction} can put it back. The state # machine has no route from `won` to `lost`, so this cannot be re-derived # after the fact. was = opportunity.state return false unless close_opportunity! update_column(:corrected_opportunity_from, was) true end |
#coverage_basis_description ⇒ Object
Alias for Evidence#coverage_basis_description
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#evidence ⇒ CustomerOutletPurchase::MatchEvidence
The reasoning behind this match — similarity, coverage, confidence and the
line-item comparison. Extracted so the model stays about persistence and
lifecycle; see MatchEvidence.
188 189 190 |
# File 'app/models/customer_outlet_purchase.rb', line 188 def evidence @evidence ||= CustomerOutletPurchase::MatchEvidence.new(self) end |
#invoice_date ⇒ Date?
378 379 380 |
# File 'app/models/customer_outlet_purchase.rb', line 378 def invoice_date invoice&.document_date || invoice&.created_at&.to_date end |
#lag_days ⇒ Integer?
Days between the opportunity opening and the outlet invoice.
371 372 373 374 375 |
# File 'app/models/customer_outlet_purchase.rb', line 371 def lag_days return if opportunity_date.nil? || invoice_date.nil? (invoice_date - opportunity_date).to_i end |
#matched_item_ids ⇒ Object
Alias for Evidence#matched_item_ids
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#opportunity_date ⇒ Date?
Returns nil for a customer-level link with no project attached.
383 384 385 |
# File 'app/models/customer_outlet_purchase.rb', line 383 def opportunity_date opportunity&.created_at&.to_date end |
#outlet_name ⇒ String?
The outlet this purchase came through, for display and for seeding
Opportunity#purchase_outlet.
364 365 366 |
# File 'app/models/customer_outlet_purchase.rb', line 364 def outlet_name invoice&.customer&.catalog&.reported_name end |
#product_detached? ⇒ Boolean
Whether the nightly sweep matched this purchase to a project and then
detached it because the products had nothing in common.
The narrow test matters: a row can be customer-level for reasons that say
nothing about products — a rep adding an order by hand (match_kind
manual), or Opportunity#outlet_purchases' dependent: :nullify orphaning
one when a project is deleted. Offering those a "matched, then detached"
explanation would invent a match that never happened, and the copy renders
match_kind literally — "Ship-to manual matched this project".
different_category is exactly what the matcher stamps before it clears
opportunity_id (see Opportunity::OutletPurchaseMatcher#build_candidate),
and every customer-level row in production carries it.
434 435 436 437 |
# File 'app/models/customer_outlet_purchase.rb', line 434 def product_detached? opportunity_id.nil? && from_nightly? && similarity == 'different_category' && customer_id.present? && invoice_date.present? end |
#purchased_lines ⇒ Object
Alias for Evidence#purchased_lines
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#quoted_lines_by_quote ⇒ Object
Alias for Evidence#quoted_lines_by_quote
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#rep_engagement ⇒ CustomerOutletPurchase::RepEngagement
What the rep actually did on the project — the credit question, which
#evidence deliberately does not answer.
196 197 198 |
# File 'app/models/customer_outlet_purchase.rb', line 196 def rep_engagement @rep_engagement ||= CustomerOutletPurchase::RepEngagement.new(self) end |
#restamp_purchase_outlet ⇒ void
This method returns an undefined value.
Recompute opportunity.purchase_outlet from whatever is still accepted,
after this row stopped being one of them.
The ad conversion is NOT retracted. Retracting an uploaded conversion is a
different Google API (conversion adjustments) and a decision about a
signal we already delivered, so it is deliberately out of scope here — see
the ledger's open items.
255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'app/models/customer_outlet_purchase.rb', line 255 def restamp_purchase_outlet return if opportunity.nil? # Undo only what this row plausibly wrote. `purchase_outlet` predates this # feature — 327 opportunities carry a value set by hand — and # `stamp_purchase_outlet` never overwrites, so a channel that isn't ours was # never ours to clear. (Two rows carrying the same label are # indistinguishable, but then the survivor rewrites the same string.) return unless opportunity.purchase_outlet == channel_label survivor = opportunity.outlet_purchases.accepted.order(:created_at).first opportunity.update_column(:purchase_outlet, survivor&.channel_label) end |
#reviewed_opportunity ⇒ Opportunity?
The project this ruling is about, attached or not — what the reviewer needs
on screen either way.
443 444 445 |
# File 'app/models/customer_outlet_purchase.rb', line 443 def reviewed_opportunity opportunity || candidate_opportunity end |
#shared_category_roots ⇒ Object
Alias for Evidence#shared_category_roots
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |
#stamp_purchase_outlet ⇒ void
This method returns an undefined value.
Record the channel on the opportunity so a won project never hides where
the sale landed.
240 241 242 243 244 |
# File 'app/models/customer_outlet_purchase.rb', line 240 def stamp_purchase_outlet return if opportunity.nil? || opportunity.purchase_outlet.present? opportunity.update_column(:purchase_outlet, channel_label) end |
#undo_written_off_correction ⇒ Boolean
Put an opportunity back the way we found it when the verification that moved
it is withdrawn.
Only ever undoes OUR change: it does nothing unless this row is the one that
corrected the opportunity, and it stands down if anything else now justifies
the won — another accepted closing purchase, or a real order. Written with
update_columns because the state machine has no won → lost route; this
is a correction of an automated change, not a business transition.
318 319 320 321 322 323 324 325 326 327 328 |
# File 'app/models/customer_outlet_purchase.rb', line 318 def undo_written_off_correction return false if corrected_opportunity_from.blank? || opportunity.nil? update_column(:closes_opportunity, false) restored = corrected_opportunity_from update_column(:corrected_opportunity_from, nil) return false unless opportunity.reload.won? && !opportunity.sales_present? opportunity.update_columns(state: restored, won_lost_date: nil) true end |
#value_source ⇒ Object
Alias for Evidence#value_source
200 201 |
# File 'app/models/customer_outlet_purchase.rb', line 200 delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines, :quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence |