Class: Retailer::WalmartItemIdDiscovery

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

Overview

Backfills the Walmart product id onto CatalogItem#third_party_part_number
from Walmart's own Marketplace API — the system of record for our listings.

Walmart items need no stored url at all: UrlConstructor's
walmart_usa_url / walmart_canada_url synthesize /ip/product/{id} from the
part number. What was missing was the id. We had been sending inventory and
price feeds for 337 US items since November 2025 without ever reading the
resulting ids back, so every one of them had no probeable URL and silently
left the daily probe run (see ListingIssues::RetailerProbeAdapter's
probe_stale).

GET /v3/items returns wpid — Walmart's alphanumeric product id, e.g.
"4KRAWZGZCAO2". That is not the numeric usItemId in a canonical Walmart URL,
but walmart.com resolves the wpid in the same position (verified 2026-08-08:
/ip/product/4KRAWZGZCAO2 returns the right product page), so it is a valid
direct-PDP identifier and the existing builders need no change.

== Identity

Matching is by seller SKU — ours, echoed back by Walmart — which alone would
be self-referential. So every match is confirmed against the UPC Walmart
publishes for the item independently of us. A mismatch is never written; it is
reported, because a wrong id here puts a wrong competitor price into repricing.

Examples:

Preview

Retailer::WalmartItemIdDiscovery.new.run(dry_run: true).report

Defined Under Namespace

Classes: Result

Constant Summary collapse

PROFILES =

Catalog => Heatwave::Configuration profile for that storefront's API.

{
  CatalogConstants::WALMART_SELLER_USA => :walmart_seller_us_api,
  CatalogConstants::WALMART_SELLER_CANADA => :walmart_seller_ca_api
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(catalog_id: CatalogConstants::WALMART_SELLER_USA, logger: Rails.logger) ⇒ WalmartItemIdDiscovery

Returns a new instance of WalmartItemIdDiscovery.

Parameters:

  • catalog_id (Integer) (defaults to: CatalogConstants::WALMART_SELLER_USA)
  • logger (Logger) (defaults to: Rails.logger)


50
51
52
53
54
# File 'app/services/retailer/walmart_item_id_discovery.rb', line 50

def initialize(catalog_id: CatalogConstants::WALMART_SELLER_USA, logger: Rails.logger)
  @catalog_id = catalog_id
  @logger = logger
  @profile = PROFILES.fetch(catalog_id)
end

Instance Method Details

#run(dry_run: false) ⇒ Result

Parameters:

  • dry_run (Boolean) (defaults to: false)

Returns:



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'app/services/retailer/walmart_item_id_discovery.rb', line 58

def run(dry_run: false)
  updated = []
  already = 0
  unlisted = []
  conflicts = []

  candidates.each do |catalog_item|
    listing = remote_listing(catalog_item.sku.to_s)
    next unlisted << catalog_item.sku.to_s if listing.nil?

    wpid = listing['wpid'].to_s
    next unlisted << catalog_item.sku.to_s if wpid.blank?

    # Matching is by seller SKU, which is OUR string echoed back, so it proves
    # nothing on its own. A positive barcode match is REQUIRED: absent or
    # unreadable barcodes on either side mean we cannot confirm the listing is
    # this product, and an unconfirmed id is exactly what puts a wrong
    # competitor price into repricing. Unconfirmed is reported, never written.
    ours = normalize_gtin(catalog_item.item&.upc)
    theirs = remote_barcodes(listing)
    unless ours.present? && theirs.include?(ours)
      conflicts << { sku: catalog_item.sku.to_s, ours: ours.presence || '(none)',
                     theirs: theirs.presence&.join('/') || '(none)' }
      next
    end

    if catalog_item.third_party_part_number == wpid
      already += 1
      next
    end

    updated << { catalog_item_id: catalog_item.id, sku: catalog_item.sku.to_s, wpid: wpid }
    next if dry_run

    # Conditional on the column still being blank: this walks hundreds of
    # items over a slow external API, and a concurrent EDI pull or a second
    # run of this service could have filled it meanwhile. Re-checking in the
    # UPDATE means the later writer never clobbers the earlier one.
    written = CatalogItem.where(id: catalog_item.id, third_party_part_number: [nil, ''])
                         .update_all(third_party_part_number: wpid, updated_at: Time.current)
    if written.zero?
      @logger.info "[WalmartItemIdDiscovery] #{catalog_item.id} #{catalog_item.sku}: id set concurrently, left alone"
      updated.pop
      already += 1
      next
    end

    @logger.info "[WalmartItemIdDiscovery] #{catalog_item.id} #{catalog_item.sku}: tppn -> #{wpid}"
  end

  Result.new(updated:, already_set: already, unlisted:, conflicts:, dry_run:)
end