Class: Shipping::RoadrunnerTracker

Inherits:
ShipengineLtlTracker show all
Defined in:
app/services/shipping/roadrunner_tracker.rb

Overview

Reads Roadrunner LTL scans from the carrier's public tracking page, because
ShipEngine's LTL endpoint returns nothing for them — 0 scans across every
Roadrunner delivery, while Saia / XPO / R&L on the identical code path all
return full trails. Roadrunner is frequently the cheapest LTL quote, so
without this those loads are a tracking black hole: invisible to the
delivery status icon and unflaggable by ProblematicDeliverySweep, whose
freight rules need at least one scan to establish carrier custody.

Subclasses ShipengineLtlTracker and overrides only the two carrier-facing
steps — where the payload comes from (#fetch_tracking) and how it maps to
rows (#event_rows). Candidate selection, the delivered / age-backstop stop
conditions, idempotent upsert, and SCAC stamping are all inherited, so these
events are indistinguishable downstream from ShipEngine-sourced ones.

This is a scrape, and it will break

There is no Roadrunner tracking API available to us. The endpoint below is
the plain GET the carrier's own tracking page issues to fill its content
tab: no authentication, no anti-forgery token, no cookie, no JavaScript —
it returns a rendered HTML fragment containing a "Travel History" table.
That makes it cheap, not stable. Every failure path here is deliberately
silent-but-reported (log + ErrorReporting.warning, return nil) so a markup
change degrades to "no new scans" rather than breaking the hourly sweep for
every other LTL carrier.

ShipEngine have said they are replacing their LTL backend with WWEX group's
and deprecating the current one; if that lands and returns Roadrunner scans,
delete this class and drop the ShipengineLtlTracker::TRACKER_BY_CARRIER
entry — nothing else references it.

Constant Summary collapse

TRACKING_URL =

The fragment endpoint the tracking page itself calls. The visible page at
tools.rrts.com/LTLTrack/?searchValues=<pro> renders an empty shell and
fetches this for the actual content, so going straight here skips the
JavaScript entirely.

'https://tools.rrts.com/LTLTrack/TrackingShipment/ShipmentDataTab/%<pro>s'
TIMEOUTS =

Short: this runs inside the hourly sweep's per-delivery loop, and a
hanging carrier page must not stall the other candidates.

{ open_timeout: 5, timeout: 15 }.freeze
TIME_FORMAT =

Roadrunner renders "07/24/2026" and "11:24 AM" in separate cells, with no
offset — US Central, matching ShipengineLtlTracker::TRACKING_TZ.

'%m/%d/%Y %I:%M %p'
TRAVEL_HISTORY_MARKER =

A carrier page that answers 200 with an error body would otherwise parse
to zero rows and look like "no scans yet" forever. Requiring the table
header keeps a broken fetch distinguishable from a genuinely empty one.

'Travel History'
PRE_PICKUP_PATTERN =

Roadrunner's only pre-custody rows. Matched before the shared inference
because "Pickup Request Received." / "Pickup rescheduled from … to …"
describe a booking, not a scan — treating them as movement would let
ProblematicDeliverySweep read a load that was never collected as being
in carrier custody, which is MissedFreightPickupSweep's job.

/\bpickup\s+(?:request|rescheduled)\b/i
DEFAULT_STATUS_CODE =

Every other row on this page is a physical handling scan — "Trailer
Closed - ready for dispatch", "Trailer unloaded at Detroit terminal.",
"Attempting to schedule a delivery appointment." Six of ten rows on a real
trail match none of ShipmentEvent::STATUS_INFERENCE_PATTERNS, and
leaving those nil would show the delivery's status icon as unknown for
most of the move. They are in-transit by construction: the carrier only
lists an event once it has the freight.

Deliberately a local default rather than widening the shared IT catch-all,
which every parcel carrier also runs through.

'IT'

Constants inherited from ShipengineLtlTracker

ShipengineLtlTracker::POLL_MAX_AGE, ShipengineLtlTracker::STATUS_CODE_BY_LABEL, ShipengineLtlTracker::TRACKER_BY_CARRIER, ShipengineLtlTracker::TRACKING_TZ

Instance Method Summary collapse

Methods inherited from ShipengineLtlTracker

candidates, #delivered?, for, #initialize, #past_backstop?, #poll!, #pollable?

Constructor Details

This class inherits a constructor from Shipping::ShipengineLtlTracker

Instance Method Details

#event_rows(result) ⇒ Array<Hash>

Project the Travel History table into ShipmentEvent attribute hashes.

The status strings are carrier prose, so the code comes from
#status_code_for — the shared ShipmentEvent.infer_status_code matcher
the parcel path uses for carriers that return nil codes, bracketed by two
Roadrunner-local rules (PRE_PICKUP_PATTERN, DEFAULT_STATUS_CODE).

Parameters:

  • result (String)

    tracking fragment HTML

Returns:

  • (Array<Hash>)

    ShipmentEvent attribute hashes



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/services/shipping/roadrunner_tracker.rb', line 99

def event_rows(result)
  travel_history_rows(result).filter_map do |date, time, status|
    occurred_at = parse_scan_time(date, time)
    next if occurred_at.blank? || status.blank?

    status_code = status_code_for(status)
    {
      tracking_number: pro,
      carrier_code: tracking_scac,
      occurred_at: occurred_at,
      status_code: status_code,
      status_description: ShipmentEvent::STATUS_CODE_LABELS[status_code],
      carrier_status_description: status,
      description: status,
      payload: { source: 'roadrunner_ltltrack', date: date, time: time, status: status }
    }
  end
end

#fetch_trackingString?

Returns the tracking fragment HTML, or nil on any failure.

Returns:

  • (String, nil)

    the tracking fragment HTML, or nil on any failure.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'app/services/shipping/roadrunner_tracker.rb', line 75

def fetch_tracking
  response = connection.get(format(TRACKING_URL, pro: CGI.escape(pro)), appIDEncoded: '')
  body = response.body.to_s
  return body if body.include?(TRAVEL_HISTORY_MARKER)

  Rails.logger.warn("[RoadrunnerTracker] delivery=#{delivery.id} pro=#{pro} " \
                    "response carried no #{TRAVEL_HISTORY_MARKER.inspect} table (#{body.bytesize} bytes)")
  nil
rescue StandardError => e
  Rails.logger.error("[RoadrunnerTracker] delivery=#{delivery.id} pro=#{pro} fetch failed: #{e.class}: #{e.message}")
  ErrorReporting.warning('RoadrunnerTracker tracking fetch failed',
                         error: e.message, delivery_id: delivery.id, pro_number: pro)
  nil
end