Class: Shipping::ShipengineLtlBase

Inherits:
ShipengineBase show all
Defined in:
app/services/shipping/shipengine_ltl_base.rb

Overview

Service object: shipengine ltl base.

Constant Summary collapse

COST_DISCREPANCY_THRESHOLD =

Threshold for cost discrepancy.

200.0
COST_DISCREPANCY_THRESHOLD_RATIO =
0.5
COST_DISCREPANCY_THRESHOLD_BY_TOTAL_VALUE_RATIO =
0.05
LTL_QUOTE_TIMEOUT_SECONDS =
20
RATE_NO_LONGER_VALID_MESSAGE =

Surfaced to the warehouse operator (via flash on the ship-label screen)
when the at-label re-rate no longer matches the originally quoted rate —
the shipment changed (pallet added/removed, address edited) or the carrier
stopped quoting this lane/size. Booking the stale quote_id here would mint
a BOL that doesn't match the shipment, so we stop and let the operator
decide: hold + re-rate, pick another carrier, or escalate.

'Delivery changed and the carrier rate is no longer valid. Please HOLD the order and re-rate ' \
'(refresh shipping methods), then choose a carrier — or ask Heatwave Team/IT support if unsure.'
DEFAULT_LTL_TRANSIT_DAYS =

Fallback transit length (calendar days) used to compute book_pickup
delivery_date when the quote response did not return days_in_transit.
ShipEngine requires delivery_date in book_pickup; carriers compute the
actual delivery from their own transit time, so this value is a hint.

5
FREIGHT_CLASS_BY_PCF =

Density-based freight class lookup.
Keys are NMFC freight classes; values define the PCF (lbs-per-cubic-foot)
range that maps to each class. Ordered highest-class-first so the first
match wins when iterating.

{
  500   => { lower: 0.0,  upper: 0.5 },
  400   => { lower: 0.5,  upper: 1.0 },
  300   => { lower: 1.0,  upper: 2.0 },
  250   => { lower: 2.0,  upper: 3.0 },
  200   => { lower: 3.0,  upper: 4.0 },
  175   => { lower: 4.0,  upper: 5.0 },
  150   => { lower: 5.0,  upper: 6.0 },
  125   => { lower: 6.0,  upper: 7.0 },
  110   => { lower: 7.0,  upper: 8.0 },
  100   => { lower: 8.0,  upper: 9.0 },
  92.5  => { lower: 9.0,  upper: 10.5 },
  85    => { lower: 10.5, upper: 12.0 },
  77.5  => { lower: 12.0, upper: 13.5 },
  70    => { lower: 13.5, upper: 15.0 },
  65    => { lower: 15.0, upper: 22.5 },
  60    => { lower: 22.5, upper: 30.0 },
  55    => { lower: 30.0, upper: 35.0 },
  50    => { lower: 35.0, upper: 50.0 }
}.freeze
ACCESSORIAL_CODES =

Standardized ShipEngine LTL accessorial codes (lowercase per API convention).

{
  appointment_delivery:     'aptd',
  appointment_pickup:       'aptp',
  construction_delivery:    'cnstd',
  construction_pickup:      'cnstp',
  tradeshow_delivery:       'ebd',
  tradeshow_pickup:         'ebp',
  inside_delivery:          'idl',
  inside_pickup:            'ipu',
  liftgate_delivery:        'lftd',
  liftgate_pickup:          'lftp',
  limited_access_delivery:  'ltdad',
  limited_access_pickup:    'ltdap',
  residential_delivery:     'res',
  residential_pickup:       'rep',
  must_notify_consignee:    'mnc'
}.freeze
VOLUME_SERVICES_MIN_LINEAR_INCHES =

FedEx Volume Services minimums — shipments at or above these thresholds
qualify for spot quotes (capacity-based pricing that can be cheaper for
very large shipments like store transfers or commercial projects).

120
VOLUME_SERVICES_MIN_WEIGHT_LBS =

10 linear feet

4_000
DELIVERY_CRITICAL_ACCESSORIALS =

── Per-carrier accessorial filtering ────────────────────────────────
Delivery-critical accessorials are required for a shipment to be
physically deliverable at a non-warehouse endpoint. If a carrier
doesn't support one of these (and no fallback exists), the carrier is
excluded from quoting and the user sees why.

Non-critical accessorials (tradeshow, construction site) are passed
when the carrier supports them, silently omitted when not.

%w[aptd idl lftd ltdad res].freeze
PICKUP_CRITICAL_ACCESSORIALS =
%w[aptp ipu lftp ltdap rep].freeze
ACCESSORIAL_FALLBACKS =
{
  'aptd' => 'mnc',
  'aptp' => 'mnc'
}.freeze
ACCESSORIAL_NAMES =
{
  'aptd'  => 'appointment delivery',  'aptp'  => 'appointment pickup',
  'idl'   => 'inside delivery',       'ipu'   => 'inside pickup',
  'lftd'  => 'liftgate delivery',     'lftp'  => 'liftgate pickup',
  'ltdad' => 'limited access delivery', 'ltdap' => 'limited access pickup',
  'res'   => 'residential delivery',  'rep'   => 'residential pickup',
  'ebd'   => 'tradeshow delivery',    'ebp'   => 'tradeshow pickup',
  'cnstd' => 'construction delivery', 'cnstp' => 'construction pickup',
  'mnc'   => 'notify consignee'
}.freeze
SUPPORTED_ACCESSORIALS =

Per-carrier accessorial support, hardcoded as constants on each subclass.
Replaces a previous Rails.cache + nightly worker design which silently
failed when local Redis was unavailable on a web host (NullStore fallback
in 150_redis_cache.rb), causing the "empty cache → trust raw input"
short-circuit in filter_accessorials_for_carrier to ship un-mapped
accessorial codes (e.g. raw aptd to Roadrunner) and the carrier API
to reject the quote. Subclasses override SUPPORTED_ACCESSORIALS with
the carrier's actual support, harvested via ShipEngine list_carriers
and verified empirically against the quote endpoint.

{}.freeze
BOL_IMAGE_LOG_PREFIX_CHARS =

Truncate embedded BOL image bytes on a ShipEngine LTL book_pickup
response before persisting to deliveries.shipping_api_log. The image is
a Base64 PDF (~100-500KB) we already extracted into a Tempfile; storing
the full string in JSONB on every delivery would balloon the column.
Keep the first 10 chars so a glance at the log row still distinguishes
"BOL was returned" (starts with "JVBERi0xLj...", the base64 PDF magic)
from "BOL was missing" (nil) — useful when reconstructing what the
carrier actually sent.

10

Constants inherited from ShipengineBase

Shipping::ShipengineBase::CDN_DOWNLOAD_TIMEOUTS, Shipping::ShipengineBase::MAX_LABEL_REFERENCE_LENGTH, Shipping::ShipengineBase::RATES_TIMEOUT_MS

Instance Attribute Summary

Attributes inherited from Base

#address, #address2, #address3, #address_residential, #attention_name, #billing_account, #billing_country, #billing_zip, #ci_comments, #city, #close_report_only, #cod_amount, #cod_collection_type, #company, #country, #currency_code, #data, #debug, #declared_value, #delivery_instructions, #delivery_total_value, #description, #discount_price, #dropoff_type, #email, #eta, #export_reason, #freight_class, #freightquote_authorization_url, #freightquote_client_id, #freightquote_client_secret, #freightquote_customer_code, #freightquote_events_url, #freightquote_rating_url, #freightquote_shipping_url, #freightquote_voiding_url, #handling_instructions, #has_loading_dock, #image_type, #include_first_class_mail_options, #insured_value, #is_construction_site, #is_trade_show, #label_type, #last_request_payload, #last_request_quote_id, #last_response_payload, #limited_access, #line_items, #master_tracking_number, #measure_height, #measure_length, #measure_units, #measure_width, #media_mail, #multiple_piece_shipping, #negotiated_rates, #package, #package_count, #package_sequence_number, #package_total, #packages, #packaging_type, #paperless, #pay_type, #phone, #pickup_datetime, #pickup_instructions, #plain_response, #price, #rate_data, #reference_number_1, #reference_number_2, #reference_number_3, #reference_number_code_1, #reference_number_code_2, #required, #requires_appointment, #requires_inside_delivery, #requires_liftgate, #response, #response_headers, #response_status, #return_to_address, #return_to_address2, #return_to_address3, #return_to_address_residential, #return_to_attention_name, #return_to_city, #return_to_company, #return_to_country, #return_to_email, #return_to_has_loading_dock, #return_to_is_construction_site, #return_to_is_trade_show, #return_to_limited_access, #return_to_name, #return_to_phone, #return_to_requires_appointment, #return_to_requires_inside_delivery, #return_to_requires_liftgate, #return_to_state, #return_to_zip, #rl_carriers_api_key, #rl_carriers_shipping_url, #saturday_delivery, #sender_address, #sender_address2, #sender_address3, #sender_address_residential, #sender_attention_name, #sender_city, #sender_company, #sender_country, #sender_email, #sender_has_loading_dock, #sender_is_construction_site, #sender_is_trade_show, #sender_limited_access, #sender_name, #sender_phone, #sender_requires_appointment, #sender_requires_inside_delivery, #sender_requires_liftgate, #sender_state, #sender_tax_identification_number, #sender_zip, #service_code, #service_type, #services, #ship_date, #shipengine_api_key, #shipengine_canadapost_account_id, #shipengine_canadapost_parent_account_number, #shipengine_canpar_account_id, #shipengine_dhl_express_account_id, #shipengine_fed_ex_account_id, #shipengine_fed_ex_ca_account_id, #shipengine_purolator_account_id, #shipengine_ups_account_id, #shipengine_ups_ca_account_id, #shipengine_usps_account_id, #shipper_address, #shipper_address2, #shipper_address3, #shipper_address_residential, #shipper_attention_name, #shipper_city, #shipper_company, #shipper_country, #shipper_email, #shipper_has_loading_dock, #shipper_is_construction_site, #shipper_is_trade_show, #shipper_limited_access, #shipper_name, #shipper_phone, #shipper_requires_appointment, #shipper_requires_inside_delivery, #shipper_requires_liftgate, #shipper_state, #shipper_zip, #signature_confirmation, #skip_png_download, #skip_rate_test, #special_instructions, #state, #tax_identification_number, #time_in_transit, #total_shipment_weight, #transaction_type, #weight, #weight_units, #zip

Instance Method Summary collapse

Methods inherited from ShipengineBase

cdn_connection, cdn_fetch, #get_separate_labels_per_package_from_label_pdf_url, #recover_actual_delivery_date_from_events!, #start_tracking, #valid_address?

Methods inherited from Base

#fedex, #initialize, #purolator, state_from_zip, #ups, #ups_freight

Constructor Details

This class inherits a constructor from Shipping::Base

Instance Method Details

#find_rates(logger = nil) ⇒ Object

── find_rates ──────────────────────────────────────────────────────
Requests an LTL quote from ShipEngine for this carrier.
Uses contract rates by default; automatically upgrades to spot quotes
when the shipment meets Volume Services minimums (≥10 ft or ≥4,000 lbs).
Returns the same { success:, rates:, ... } envelope the parcel base uses.



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/services/shipping/shipengine_ltl_base.rb', line 123

def find_rates(logger = nil)
  logger ||= Rails.logger

  @required = [:shipengine_api_key]
  @required += %i[zip country sender_state sender_zip sender_country packages]

  @country ||= 'US'
  @sender_country ||= 'US'

  service_codes_to_quote = requested_service_codes

  rate_estimates = []
  last_rate_options = nil
  last_error_message = nil
  retry_exceptions = (Retryable::TIMEOUT_CLASSES + [ShipEngineRb::Exceptions::ShipEngineError, Timeout::Error])

  service_codes_to_quote.each do |svc_code|
    rate_options = get_rate_options_hash(, service_code: svc_code)
    next unless rate_options

    last_rate_options = rate_options

    quote_op = qualifies_for_spot_quote?(rate_options) ? :ltl_spot_quote : :ltl_get_quote
    quote_type = quote_op == :ltl_spot_quote ? :spot_quote : :quote

    unsupported = delivery_critical_unsupported_accessorials(quote_type)
    if unsupported.any?
      names = unsupported.map { |acc| acc[:name] }.join(', ')
      reason = "#{carrier} excluded: does not support #{names}"
      logger.info(reason)
      return build_rate_response(false, nil, rate_options, [], exclusion_reason: reason)
    end

    rate_options[:shipment][:options] = get_accessorial_options_array(quote_type: quote_type)

    logger.debug { "Shipping #{carrier} LTL find_rates request service_code=#{svc_code} quote_type=#{quote_op} destination_zip=#{@zip} destination_country=#{@country}" }

    quote_result = nil
    idempotency_key = quote_idempotency_key(quote_op, svc_code, rate_options)
    ErrorReporting.scoped({ carrier: carrier, service_code: svc_code,
                             destination_zip: @zip, destination_country: @country,
                             package_count: Array(@packages).size }) do
      Retryable.retryable(tries: 2, sleep: 2, on: retry_exceptions) do |attempt, _ex|
        logger.warn("Shipping #{carrier} LTL find_rates retry ##{attempt}") if attempt > 1
        Timeout.timeout(LTL_QUOTE_TIMEOUT_SECONDS) do
          quote_result = shipengine_call(quote_op, , rate_options,
                                         config: { idempotency_key: idempotency_key })
        end
      end
    end

    rate_estimates.concat(normalize_and_cast_quotes(quote_result))
  rescue StandardError => e
    last_error_message = e.message
    logger.warn("Shipping #{carrier} LTL find_rates failed for service_code=#{svc_code}: #{e.message}")
  end

  logger.debug { "Shipping #{carrier} LTL find_rates response rate_count=#{rate_estimates.size}" }

  build_rate_response(rate_estimates.any?, rate_estimates.any? ? nil : last_error_message, last_rate_options, rate_estimates)
end

#label(_return_label = false, logger = nil) ⇒ Object

── label (pickup + BOL) ────────────────────────────────────────────
LTL "label" means: re-quote → book pickup → receive BOL PDF.
Returns the same { labels:, shipment_identification_number:, ... } shape.

Raises:



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'app/services/shipping/shipengine_ltl_base.rb', line 189

def label(_return_label = false, logger = nil)
  logger ||= Rails.logger

  @required = [:shipengine_api_key]
  @required += %i[phone company address city state zip]
  @required += %i[sender_phone sender_email sender_company sender_address sender_city sender_state sender_zip]
  @required += %i[packages service_code]
  @required += [:billing_account] if %w[bill_third_party freight_collect].include?(@pay_type)
  @required += %i[billing_zip billing_country] if @pay_type == 'bill_third_party'
  @required += [:rate_data]

  @country ||= 'US'
  @sender_country ||= 'US'

  quote_id = resolve_quote_id_for_label(logger)

  raise ShippingError, 'Missing data, please HOLD order and refresh shipping rates/methods' if quote_id.blank?

  pickup_options = build_pickup_options_hash
  # Stash on the instance so WyShipping's rescue can read it back when
  # ShipEngine raises — the rescue happens outside this method's scope.
  @last_request_payload = pickup_options
  @last_request_quote_id = quote_id
  logger.debug { "Shipping #{carrier} LTL label/pickup request" }

  res = Timeout.timeout(LTL_QUOTE_TIMEOUT_SECONDS * 2) do
    shipengine_call(:ltl_schedule_pickup, quote_id, pickup_options,
                    config: { idempotency_key: "ltl-book-pickup-#{quote_id}" })
  end

  # Stash the response on the instance so WyShipping's StandardError rescue
  # can persist it as ship_reply_xml when the validation below raises —
  # otherwise the structured response is lost behind res.inspect in the
  # exception message and unsearchable in the shipping_api_log JSONB.
  @last_response_payload = sanitize_response_for_log(res.respond_to?(:to_hash) ? res.to_hash : res)

  logger.debug { "Shipping #{carrier} LTL label/pickup response confirmation=#{res[:confirmation_number]} pro=#{res[:pro_number]}" }

  warnings_text = Array(res[:warnings]).map { |warning| "Code: #{warning[:external_code]}, Message: #{warning[:message]}" }.join(' ')
  full_message = [res[:message], warnings_text].compact_blank.join(' ')

  # Success guard: ShipEngine LTL doesn't expose a clean success flag, and
  # the per-carrier response shape varies. Empirically (Roadrunner pickup
  # 55191112 on delivery 785574, 2026-05-06) `confirmation_number` is
  # carrier-discretionary and `pro_number` is assigned out-of-band by some
  # carriers (Roadrunner returns null at booking time). `pickup_id` and the
  # BOL document are the two things ShipEngine consistently returns when
  # the pickup actually books — so anchor success on those. PRO backfill
  # for slow-assign carriers happens later via GET /v-beta/ltl/pickups/{id}.
  raise ShippingError, "LTL pickup failed: #{full_message.presence || res.inspect}" unless res[:pickup_id].present? && res[:documents].present?

  rd = @rate_data.is_a?(Hash) ? @rate_data.with_indifferent_access : {}
  total_price = @resolved_label_price || rd['total_price'].to_f

  response = {}
  response[:tracking_number] = res[:pro_number]

  bol_doc = Array(res[:documents]).detect { |doc| doc[:type] == 'bill_of_lading' }
  if bol_doc && bol_doc[:image].present?
    response[:bol_image] = Tempfile.new('bol')
    response[:bol_image].binmode
    response[:bol_image].write(Base64.decode64(bol_doc[:image]))
    response[:bol_image].rewind
    response[:bol_image].flush
    response[:bol_image].fsync
  end

  def response.method_missing(name, *args)
    key?(name) ? self[name] : super
  end

  {
    labels: [response],
    shipment_identification_number: res[:pro_number],
    carrier_bol: res[:confirmation_number],
    pickup_confirmation_number: res[:pickup_id],
    shipengine_label_id: res[:pickup_id],
    total_charges: total_price,
    confirmed_pickup_date: res[:pickup_date],
    confirmed_pickup_window_start_at: res.dig(:pickup_window, :start_at),
    confirmed_pickup_window_end_at: res.dig(:pickup_window, :end_at),
    ship_request_xml: pickup_options.to_hash,
    ship_reply_xml: sanitize_response_for_log(res.to_hash)
  }
end

#sanitize_response_for_log(payload) ⇒ Object



285
286
287
288
289
290
291
292
293
294
295
# File 'app/services/shipping/shipengine_ltl_base.rb', line 285

def sanitize_response_for_log(payload)
  return payload unless payload.is_a?(Hash)

  cleaned = payload.deep_dup
  Array(cleaned[:documents]).each do |doc|
    next unless doc.is_a?(Hash) && doc[:image].present?

    doc[:image] = "#{doc[:image].to_s[0, BOL_IMAGE_LOG_PREFIX_CHARS]}..."
  end
  cleaned
end

#scacObject

Carrier SCAC used by ShipEngine's LTL tracking endpoint's carrier_code
param. Subclasses MUST override — there is no reliable derivation from
our internal carrier_code (e.g. FedEx Freight's internal code is
fxfr but its SCAC is FXFE, so carrier_code.upcase would be wrong).

Raises:

  • (NotImplementedError)


361
362
363
# File 'app/services/shipping/shipengine_ltl_base.rb', line 361

def scac
  raise NotImplementedError, "#{self.class} must define #scac (carrier SCAC for ShipEngine LTL tracking)"
end

#track(pro_number) ⇒ HashWithIndifferentAccess

Track an LTL shipment by its PRO number.

ShipEngine's LTL tracking endpoint is GET /v-beta/ltl/tracking and takes
exactly two query params: pro_number and carrier_code, where
carrier_code is the carrier's SCAC (e.g. CNWY for XPO, FXFE for
FedEx Freight) — verified live against prod 2026-06-02. It is NOT our
internal lowercase carrier_code (cnwy) and NOT the connected
carrier_id UUID; both of those are rejected by the endpoint
(:unexpected_field / "A carrier account required"). See #scac.

Parameters:

  • pro_number (String)

    the carrier PRO (e.g. "285-141102")

Returns:

  • (HashWithIndifferentAccess)

    tracking payload — keys include
    :delivery (estimated/actual), :events, :pickup, :pro_number



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'app/services/shipping/shipengine_ltl_base.rb', line 337

def track(pro_number)
  logger = Rails.logger
  @required = [:shipengine_api_key]

  logger.debug { "Shipping #{carrier} LTL track request pro_number=#{pro_number} scac=#{scac}" }

  tracking_result = nil
  retry_exceptions = (Retryable::TIMEOUT_CLASSES + [ShipEngineRb::Exceptions::ShipEngineError])
  Retryable.retryable(tries: 2, sleep: lambda { |n| 4**n }, on: retry_exceptions) do |attempt, _ex|
    logger.warn("Shipping #{carrier} LTL tracking attempt ##{attempt}") if attempt > 1
    tracking_result = shipengine_call(:ltl_track,
                                      { pro_number: pro_number,
                                        carrier_code: scac })
  end

  logger.debug { "Shipping #{carrier} LTL track response pro_number=#{pro_number} status=#{tracking_result&.dig(:status_description)}" }

  (tracking_result || {}).with_indifferent_access
end

#void(pickup_id) ⇒ Object

── void (cancel pickup) ────────────────────────────────────────────



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'app/services/shipping/shipengine_ltl_base.rb', line 299

def void(pickup_id)
  logger = Rails.logger
  @required = [:shipengine_api_key]

  # Stash the request before the call so WyShipping's rescue can persist it
  # as void_request_xml when shipengine_call raises (the request side is
  # lost otherwise — same blind spot we just closed on the label path).
  @last_request_payload = { pickup_id: pickup_id }

  logger.debug { "Shipping #{carrier} LTL void/cancel pickup pickup_id=#{pickup_id}" }

  res = shipengine_call(:ltl_cancel_pickup, pickup_id,
                        config: { idempotency_key: "ltl-cancel-pickup-#{pickup_id}" })

  response_payload = res.respond_to?(:to_hash) ? sanitize_response_for_log(res.to_hash) : (res || 'cancelled')
  @last_response_payload = response_payload

  logger.debug { "Shipping #{carrier} LTL pickup cancelled pickup_id=#{pickup_id}" }

  { void_request_xml: @last_request_payload,
    void_response_xml: response_payload }
end