Class: Retailer::DailyComplianceReport

Inherits:
Object
  • Object
show all
Defined in:
app/services/retailer/daily_compliance_report.rb

Overview

Per-retailer compliance digest emailed daily after the retailer probe runs
(see RetailerComplianceReportWorker + the cron in
config/sidekiq_production_schedule.yml).

Scope: catalogs the probe checks (external_price_check_enabled = true), the
Amazon Seller catalogs (US / Canada / Europe), and any catalog with an open
listing_issues row. One row per catalog, grouped by region (US / Canada /
Europe), each column a count of active catalog items in a problem state,
sourced from:

  • view_product_catalogs — map_violation, sale_price_in_effect,
    price_diverging, product_stock_status (our inventory)
  • listing_issues — open marketplace-reported listing problems
    (Amazon suppression / SP-API issues, Walmart unpublished reasons, …);
    this is the column that covers the (unprobed) Amazon catalogs.
  • the latest CatalogItemRetailerProbe per item (last 14 days) — status:
    'not_found'/'product_mismatch' (scrape failures), 'failed' (unreachable
    online), product_available = FALSE (out of stock online). Amazon
    catalogs are not Oxylabs-probed, so the probe columns read 0 for them.

MAP-violation counts depend on view_product_catalogs resolving MSRP from each
catalog's tree root (fixed in view v54) — before that, all CA/EU catalogs
reported zero.

Constant Summary collapse

TZ =

America/Chicago — the server/report timezone.

'America/Chicago'
PROBE_LOOKBACK_DAYS =

Only consider probes from this lookback window as the item's current state.
Shared with CatalogItemRetailerProbe's latest-probe scopes (which back the
deep links below) so the counts and the linked lists use one window.

CatalogItemRetailerProbe::CURRENT_STATE_LOOKBACK_DAYS
REGIONS =

Root catalog id => region label / display order. Roots: US=1, CA=2, EU=125.

{
  1   => { label: 'United States', order: 0 },
  2   => { label: 'Canada',        order: 1 },
  125 => { label: 'Europe',        order: 2 }
}.freeze
OTHER_REGION =

Fallback bucket for any catalog not rooted at a known region.

{ label: 'Other', order: 99 }.freeze
COUNT_COLUMNS =

Numeric metric columns, in display order. Two distinct "things are wrong
with the listing" signals are kept separate on purpose:

  • listing_issues — the marketplace reports a problem with our listing
    (Amazon suppression / SP-API issues, Walmart unpublished reasons, …),
    sourced from the listing_issues table (ListingIssues::Sync).
  • scrape_failures — our Oxylabs probe loaded the page but couldn't read
    a valid product/price (not_found / product_mismatch). A scraper-health
    metric, not a retailer listing defect.
%i[
  active_public
  map_violations
  promotions
  price_diverging
  out_of_stock_active
  listing_issues
  scrape_failures
  unreachable_online
  out_of_stock_online
].freeze
CLICKABLE_QUERY_PARAMS =

Every metric here maps to a ProductCatalogSearch query so its email count
links straight to the filtered CRM list. View-derived columns use Ransack
attribute predicates; the probe-derived columns (scrape failures /
unreachable / out of stock online) use the latest-probe Ransack scopes on
ViewProductCatalog, which reuse this report's lookback window so the linked
list reproduces the count. Each lambda takes a catalog id and returns the
ProductCatalogSearch query_params. (listing_issues links to the dedicated
dashboard instead — see .listing_issues_url.)

{
  active_public: ->(cid) { { catalog_id_in: [cid], catalog_item_state_in: ['active'] } },
  map_violations: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], map_violation_eq: true }
  },
  promotions: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], sale_price_in_effect_eq: true }
  },
  price_diverging: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], price_diverging_eq: true }
  },
  out_of_stock_active: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], product_stock_status_eq: 'OutOfStock' }
  },
  # listing_issues links to the CRM Listing Issues dashboard (review +
  # resolve), not a ProductCatalogSearch — handled in the email view, so it
  # is intentionally absent here.
  scrape_failures: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], latest_probe_scrape_failure: true }
  },
  unreachable_online: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], latest_probe_unreachable_online: true }
  },
  out_of_stock_online: lambda { |cid|
    { catalog_id_in: [cid], catalog_item_state_in: ['active'], latest_probe_out_of_stock_online: true }
  }
}.freeze

Instance Attribute Summary collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(generated_at: nil) ⇒ DailyComplianceReport

Returns a new instance of DailyComplianceReport.



113
114
115
# File 'app/services/retailer/daily_compliance_report.rb', line 113

def initialize(generated_at: nil)
  @generated_at = generated_at || ActiveSupport::TimeZone[TZ].now
end

Instance Attribute Details

#generated_atActiveSupport::TimeWithZone (readonly)

Returns when the report was generated.

Returns:

  • (ActiveSupport::TimeWithZone)

    when the report was generated



111
112
113
# File 'app/services/retailer/daily_compliance_report.rb', line 111

def generated_at
  @generated_at
end

Class Method Details

.for_scheduled_runRetailer::DailyComplianceReport

Entry point for the scheduled worker.



119
120
121
# File 'app/services/retailer/daily_compliance_report.rb', line 119

def self.for_scheduled_run
  new
end

.listing_issues_url(catalog_id, host) ⇒ String

CRM Listing Issues dashboard URL for a catalog's open issues — the
"Listing Issues" column links here (review + mark-fixed) rather than to a
ProductCatalogSearch list.

Parameters:

  • catalog_id (Integer)
  • host (String)

    CRM host

Returns:

  • (String)


105
106
107
108
# File 'app/services/retailer/daily_compliance_report.rb', line 105

def self.listing_issues_url(catalog_id, host)
  query = { catalog_id:, status: 'open' }.to_query
  "https://#{host}/listing_issues?#{query}"
end

.query_params_for(metric, catalog_id) ⇒ Hash?

ProductCatalogSearch query_params for a clickable metric, or nil.

Parameters:

  • metric (Symbol)
  • catalog_id (Integer)

Returns:

  • (Hash, nil)


127
128
129
# File 'app/services/retailer/daily_compliance_report.rb', line 127

def self.query_params_for(metric, catalog_id)
  CLICKABLE_QUERY_PARAMS[metric]&.call(catalog_id)
end

Instance Method Details

#empty?Object

Alias for Rows#empty?

Returns:

  • (Object)

    Rows#empty?

See Also:



171
# File 'app/services/retailer/daily_compliance_report.rb', line 171

delegate :empty?, to: :rows

#flagged_rowsArray<Hash>

Retailers carrying at least one MAP violation or marketplace listing issue
— the rows worth eyeballing first.

Returns:

  • (Array<Hash>)


166
167
168
# File 'app/services/retailer/daily_compliance_report.rb', line 166

def flagged_rows
  rows.select { |r| r[:map_violations].to_i.positive? || r[:listing_issues].to_i.positive? }
end

#rowsArray<Hash>

One hash per probed/Amazon catalog with its compliance counts and region.

Returns:

  • (Array<Hash>)


133
134
135
136
137
138
139
140
# File 'app/services/retailer/daily_compliance_report.rb', line 133

def rows
  @rows ||= ActiveRecord::Base
            .connection
            .select_all(report_sql)
            .to_a
            .map(&:symbolize_keys)
            .map { |r| decorate(r) }
end

#rows_by_regionArray<Array(String, Array<Hash>)>

Rows grouped by region label, in display order (US, Canada, Europe, …).

Returns:

  • (Array<Array(String, Array<Hash>)>)

    [region_label, rows] pairs



144
145
146
147
# File 'app/services/retailer/daily_compliance_report.rb', line 144

def rows_by_region
  rows.group_by { |r| r[:region_label] }
      .sort_by { |label, _| region_order_for_label(label) }
end

#totalsHash

Column-wise totals across every retailer.

Returns:

  • (Hash)


151
152
153
154
155
# File 'app/services/retailer/daily_compliance_report.rb', line 151

def totals
  @totals ||= COUNT_COLUMNS.index_with do |col|
    rows.sum { |r| r[col].to_i }
  end
end

#totals_for(region_rows) ⇒ Hash

Column-wise totals for one region's rows.

Returns:

  • (Hash)


159
160
161
# File 'app/services/retailer/daily_compliance_report.rb', line 159

def totals_for(region_rows)
  COUNT_COLUMNS.index_with { |col| region_rows.sum { |r| r[col].to_i } }
end