Class: CatalogItemRetailerProbe

Inherits:
ApplicationRecord show all
Includes:
Turbo::Broadcastable
Defined in:
app/models/catalog_item_retailer_probe.rb

Overview

== Schema Information

Table name: catalog_item_retailer_probes
Database name: primary

id :bigint not null, primary key
content_size_bytes :integer
currency :string(3)
error_message :string
geo_location :string(64)
page_accessible :boolean default(FALSE)
price :decimal(10, 2)
product_available :boolean
response_time_ms :integer
status :string default("pending"), not null
store_json :jsonb
url :string
created_at :datetime not null
updated_at :datetime not null
catalog_item_id :bigint not null

Indexes

idx_retailer_probes_item_created (catalog_item_id,created_at)
index_catalog_item_retailer_probes_on_created_at (created_at)
index_catalog_item_retailer_probes_on_status (status)

Foreign Keys

fk_rails_... (catalog_item_id => catalog_items.id)

Constant Summary collapse

STATUSES =

Status values

{
  pending: 'pending',                 # Probe queued but not yet performed
  success: 'success',                 # Page loaded and data extracted
  failed: 'failed',                   # Request failed (timeout, error)
  not_found: 'not_found',             # Page returned 404 or product not on page
  product_mismatch: 'product_mismatch' # Page loaded but product identifiers not found
}.freeze
CURRENT_STATE_LOOKBACK_DAYS =

Lookback window for treating a probe as the item's current external state.
Retailer::DailyComplianceReport derives its probe-based columns (scrape
failures / unreachable / out of stock online) from this same window, and the
latest-probe scopes below back the deep links in that daily email, so the
report's counts and the lists those links open stay in lockstep.

14
STALE_PROBE_DAYS =

Age past which a probe-derived price is no longer decision-grade. Probed
catalogs run a daily batch, so a full week with no probe row at all means
the item silently fell out of the run — see
ListingIssues::RetailerProbeAdapter#stale_probe_ids. Matches the 7-day
freshness floor the repricer already applies to sibling retailer ceilings
(Catalog::AmazonPriceRaisingService#lowest_sibling_retailer).

7
GEO_LOCATION_MAX_LENGTH =

Matches the geo_location varchar length in db/structure.sql. Callers that
persist an externally-sourced geo_location (e.g. WebhookProcessors::OxylabsProcessor)
clamp to this so an over-long value can't raise on create (AppSignal #6019).

64
TAB_LISTING_LIMIT =

Probes rendered in the CRM retailer-probes tab, newest first. Shared by the
controller and by the completion broadcast so both paint the same set — the
broadcast replaces the whole table rather than prepending a row, so it has to
re-derive the identical query.

50

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#currencyString?

Returns ISO currency code of the captured price.

Returns:

  • (String, nil)

    ISO currency code of the captured price.

Validations:



88
# File 'app/models/catalog_item_retailer_probe.rb', line 88

validates :status, presence: true, inclusion: { in: STATUSES.values }

#geo_locationString?

Returns Geographic location the probe ran from.

Returns:

  • (String, nil)

    Geographic location the probe ran from.

Validations:

  • Length ({ maximum: GEO_LOCATION_MAX_LENGTH })


88
# File 'app/models/catalog_item_retailer_probe.rb', line 88

validates :status, presence: true, inclusion: { in: STATUSES.values }

#statusString

Returns Probe status.

Returns:

  • (String)

    Probe status.



88
# File 'app/models/catalog_item_retailer_probe.rb', line 88

validates :status, presence: true, inclusion: { in: STATUSES.values }

Class Method Details

.actively_probedActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are actively probed. Active Record Scope

Returns:

See Also:



121
122
123
124
125
126
# File 'app/models/catalog_item_retailer_probe.rb', line 121

scope :actively_probed, lambda {
  where(catalog_item_id: CatalogItem.where(skip_url_checks: false)
                                    .joins(:catalog)
                                    .where(catalogs: { external_price_check_enabled: true })
                                    .select(:id))
}

.current_out_of_stock_online_item_idsActiveRecord::Relation

catalog_item_ids whose most-recent probe in the window reported the item out
of stock at the retailer.

Raw retailer state — it says nothing about whether that is a PROBLEM. The
retailer being out of stock when we are too is expected; only disagreement
with our own inventory is actionable. Callers add that comparison:
ListingIssues::RetailerProbeAdapter#stock_mismatch_ids intersects with
CatalogItem.in_stock, and the dashboard's out_of_stock_online column /
ViewProductCatalog.latest_probe_out_of_stock_online exclude
product_stock_status = 'OutOfStock'.

Returns:

  • (ActiveRecord::Relation)

    a select(:catalog_item_id) relation



186
187
188
# File 'app/models/catalog_item_retailer_probe.rb', line 186

def self.current_out_of_stock_online_item_ids
  where(id: latest_probe_ids_in_window).out_of_stock.select(:catalog_item_id)
end

.current_scrape_failure_item_idsActiveRecord::Relation

catalog_item_ids whose most-recent probe in the window loaded the page but
could not yield a valid product/price — a scrape failure (product not on
page / 404-on-page / product mismatch). Distinct from the page being
unreachable.

Includes auto-skipped items, because "the page loaded and our product wasn't
on it" is evidence about the LISTING that stays true after we stop probing —
ListingIssues::RetailerProbeAdapter wants that. Chain .actively_probed for
the scraper-health reading the dashboard's scrape_failures column shows.

Returns:

  • (ActiveRecord::Relation)

    a select(:catalog_item_id) relation



157
158
159
160
161
# File 'app/models/catalog_item_retailer_probe.rb', line 157

def self.current_scrape_failure_item_ids
  where(id: latest_probe_ids_in_window)
    .where(status: %w[not_found product_mismatch])
    .select(:catalog_item_id)
end

.current_unreachable_online_item_idsActiveRecord::Relation

catalog_item_ids whose most-recent probe in the window could not load the
product page at all (request failed / timed out / blocked / Oxylabs job
faulted → status 'failed'). Mirrors the unreachable_online column. Uses
status rather than the page_accessible flag, which the async batch path
historically left at its FALSE default even on success.

Returns:

  • (ActiveRecord::Relation)

    a select(:catalog_item_id) relation



170
171
172
# File 'app/models/catalog_item_retailer_probe.rb', line 170

def self.current_unreachable_online_item_ids
  where(id: latest_probe_ids_in_window).where(status: 'failed').select(:catalog_item_id)
end

.failedActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are failed. Active Record Scope

Returns:

See Also:



93
# File 'app/models/catalog_item_retailer_probe.rb', line 93

scope :failed, -> { where(status: %w[failed not_found product_mismatch]) }

.in_stockActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are in stock. Active Record Scope

Returns:

See Also:



97
# File 'app/models/catalog_item_retailer_probe.rb', line 97

scope :in_stock, -> { where(product_available: true) }

.latestActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are latest. Active Record Scope

Returns:

See Also:



95
# File 'app/models/catalog_item_retailer_probe.rb', line 95

scope :latest, -> { order(created_at: :desc).first }

.latest_probe_ids_in_windowActiveRecord::Relation

Probe-row ids of the single most-recent probe per catalog item within the
current-state lookback window (Postgres DISTINCT ON). Shaped as a relation so
it can drive where(id: …) subqueries that evaluate each item's latest state.

Returns:

  • (ActiveRecord::Relation)


140
141
142
143
144
# File 'app/models/catalog_item_retailer_probe.rb', line 140

def self.latest_probe_ids_in_window
  where(created_at: CURRENT_STATE_LOOKBACK_DAYS.days.ago..)
    .select('DISTINCT ON (catalog_item_id) id')
    .order(:catalog_item_id, created_at: :desc)
end

.out_of_stockActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are out of stock. Active Record Scope

Returns:

See Also:



98
# File 'app/models/catalog_item_retailer_probe.rb', line 98

scope :out_of_stock, -> { where(product_available: false) }

.recentActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are recent. Active Record Scope

Returns:

See Also:



94
# File 'app/models/catalog_item_retailer_probe.rb', line 94

scope :recent, -> { where(created_at: 7.days.ago..) }

.successfulActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are successful. Active Record Scope

Returns:

See Also:



92
# File 'app/models/catalog_item_retailer_probe.rb', line 92

scope :successful, -> { where(status: 'success') }

.tab_listingActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are tab listing. Active Record Scope

Returns:

See Also:



133
# File 'app/models/catalog_item_retailer_probe.rb', line 133

scope :tab_listing, -> { order(created_at: :desc).limit(TAB_LISTING_LIMIT) }

.todayActiveRecord::Relation<CatalogItemRetailerProbe>

A relation of CatalogItemRetailerProbes that are today. Active Record Scope

Returns:

See Also:



96
# File 'app/models/catalog_item_retailer_probe.rb', line 96

scope :today, -> { where(created_at: Time.current.beginning_of_day..) }

Instance Method Details

#amazon_probe?Boolean

Check if this is an Amazon probe (has buy box tracking)

Returns:

  • (Boolean)


237
238
239
# File 'app/models/catalog_item_retailer_probe.rb', line 237

def amazon_probe?
  has_buy_box != nil
end

#availability_textObject

Availability as text



264
265
266
267
268
269
270
271
272
# File 'app/models/catalog_item_retailer_probe.rb', line 264

def availability_text
  if product_available.nil?
    'Unknown'
  elsif product_available
    'In Stock'
  else
    'Out of Stock'
  end
end

#buy_box?Boolean

Check if this probe detected a buy box (Amazon-specific)
Returns true only if we explicitly detected a buy box

Returns:

  • (Boolean)


226
227
228
# File 'app/models/catalog_item_retailer_probe.rb', line 226

def buy_box?
  has_buy_box == true
end

#catalog_itemCatalogItem

Returns:



40
# File 'app/models/catalog_item_retailer_probe.rb', line 40

belongs_to :catalog_item

#in_stock?Boolean

Check if product appears to be in stock

Returns:

  • (Boolean)


215
216
217
# File 'app/models/catalog_item_retailer_probe.rb', line 215

def in_stock?
  product_available == true
end

#no_buy_box?Boolean

Check if this probe explicitly detected NO buy box (Amazon-specific)
Returns true only if we explicitly detected no buy box (not just nil/unknown)

Returns:

  • (Boolean)


232
233
234
# File 'app/models/catalog_item_retailer_probe.rb', line 232

def no_buy_box?
  has_buy_box == false
end

#out_of_stock?Boolean

Check if product is definitely out of stock

Returns:

  • (Boolean)


220
221
222
# File 'app/models/catalog_item_retailer_probe.rb', line 220

def out_of_stock?
  product_available == false
end

#price_captured?Boolean

Check if this represents a successful price capture

Returns:

  • (Boolean)


210
211
212
# File 'app/models/catalog_item_retailer_probe.rb', line 210

def price_captured?
  status == 'success' && price.present?
end

#summaryObject

Human-readable summary



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'app/models/catalog_item_retailer_probe.rb', line 242

def summary
  case status
  when 'success'
    parts = []
    if no_buy_box?
      parts << 'No Buy Box'
    elsif price.present?
      parts << format_price
      parts << "Seller: #{buy_box_seller}" if buy_box_seller.present?
    end
    parts << availability_text if product_available.present?
    parts.join(' | ')
  when 'failed'
    "Failed: #{error_message&.truncate(50)}"
  when 'not_found'
    'Product not found'
  else
    'Pending'
  end
end