Class: Shipping::ShipengineLtlBase
- Inherits:
-
ShipengineBase
- Object
- Base
- ShipengineBase
- Shipping::ShipengineLtlBase
- Defined in:
- app/services/shipping/shipengine_ltl_base.rb
Overview
Service object: shipengine ltl base.
Direct Known Subclasses
RlCarriersDisabled, ShipengineFedExFreight, ShipengineFedExFreightEconomy, ShipengineRlCarriers, ShipengineRoadrunner, ShipengineSaia, ShipengineXpo, YrcFreight
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_datewhen 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. rawaptdto 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
-
#find_rates(logger = nil) ⇒ Object
── find_rates ────────────────────────────────────────────────────── Requests an LTL quote from ShipEngine for this carrier.
-
#label(_return_label = false, logger = nil) ⇒ Object
── label (pickup + BOL) ──────────────────────────────────────────── LTL "label" means: re-quote → book pickup → receive BOL PDF.
- #sanitize_response_for_log(payload) ⇒ Object
-
#scac ⇒ Object
Carrier SCAC used by ShipEngine's LTL tracking endpoint's
carrier_codeparam. -
#track(pro_number) ⇒ HashWithIndifferentAccess
Track an LTL shipment by its PRO number.
-
#void(pickup_id) ⇒ Object
── void (cancel pickup) ────────────────────────────────────────────.
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 = [] = nil = nil retry_exceptions = (Retryable::TIMEOUT_CLASSES + [ShipEngineRb::Exceptions::ShipEngineError, Timeout::Error]) service_codes_to_quote.each do |svc_code| = (shipengine_account_id, service_code: svc_code) next unless = quote_op = qualifies_for_spot_quote?() ? :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, , [], exclusion_reason: reason) end [:shipment][:options] = (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, ) 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, shipengine_account_id, , config: { idempotency_key: idempotency_key }) end end end rate_estimates.concat(normalize_and_cast_quotes(quote_result)) rescue StandardError => e = e. logger.warn("Shipping #{carrier} LTL find_rates failed for service_code=#{svc_code}: #{e.}") end logger.debug { "Shipping #{carrier} LTL find_rates response rate_count=#{rate_estimates.size}" } build_rate_response(rate_estimates.any?, rate_estimates.any? ? nil : , , 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.
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? = # 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 = @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, , 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(' ') = [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: #{.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: .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 |
#scac ⇒ Object
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).
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.
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 |