Class: Shipping::SpeedeeDelivery

Inherits:
Base
  • Object
show all
Defined in:
app/services/shipping/speedee_delivery.rb

Overview

Service object: speedee delivery.

Constant Summary collapse

COST_DISCREPANCY_THRESHOLD =

Threshold for cost discrepancy.

100.0
COST_DISCREPANCY_THRESHOLD_RATIO =

this is max ratio of discrepancy by total shipping cost

0.25
COST_DISCREPANCY_THRESHOLD_BY_TOTAL_VALUE_RATIO =

this is max ratio of discrepancy by total delivery value

0.02
OVERSIZE_SURCHARGE =

Oversize surcharge.

{ '2': 60.00, '3': 65.00, '4': 70.00, '5': 75.00, '6': 80.00 }.freeze
DAS_SURCHARGE =

Das surcharge.

1.0
DAS_SURCHARGE_FOR_OVERSIZED =

Das surcharge for oversized.

1.0
HIGH_DIMENSIONAL_LENGTH_THRESHOLD =

Threshold for high dimensional length.

130.0
DIMENSIONAL_LENGTH_MAX =

Dimensional length max.

170.0
LENGTH_MAX =

Length max.

120.0
HIGH_WEIGHT_THRESHOLD =

Threshold for high weight.

100.0
WEIGHT_MAX =

Weight max.

150.0
DECLARED_VALUE_MIN =

COD_SURCHARGE = 11.0 # we do NOT support COD for Speedee or any other carrier

100.0
DECLARED_VALUE_MAX =

Declared value max.

5000.0
DECLARED_VALUE_SURCHARGE_PER_100 =

Declared value surcharge per 100.

0.8
DECLARED_VALUE_SURCHARGE_MIN =

Declared value surcharge min.

2.4
SIGNATURE_CONFIRMATION_SURCHARGE =

THis is the Direct Signature from SpeeDee

3.25
DISPATCH_SCIENCE_ORDER_ID =

Dispatch Science order ids are "SD" followed by digits (e.g. SD429725).

/\ASD\d/i

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, #importer_of_record, #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 Base

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

Constructor Details

This class inherits a constructor from Shipping::Base

Instance Method Details

#api_estimate(quote, speedee_service) ⇒ Object

Builds the estimate from a successful Dispatch Science quote. The charges
array is itemized per extraFeeTypeId; ITEMPRICE (+ any type=Delivery rows)
is the base transportation price, everything else is an accessorial.
extra_charges_hash keys mirror the legacy shape (das / oversized /
declared_value / aod / cod) that Pdf::Document::SpeedeeSummary and the
manifest QR read, plus the API-native extras (metro / unboxed / hazardous
/ pickup_tag). sort_code / days_in_transit come from our SpeedeeService
table when it knows the zip — display/label enrichment, nil-safe
downstream (the label falls back to '00', insurance defaults transit).



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'app/services/shipping/speedee_delivery.rb', line 271

def api_estimate(quote, speedee_service)
  charges = Array(quote[:charges])
  fee = ->(id) { charges.select { |c| c['extraFeeTypeId'] == id }.sum { |c| c['price'].to_f }.round(2) }
  extras_total = charges.select { |c| c['type'] == 'Extra' && c['extraFeeTypeId'] != 'ITEMPRICE' }.sum { |c| c['price'].to_f }.round(2)
  total_price = quote[:total_price].to_f
  {
    status: :ok,
    price: total_price,
    base_price: (total_price - extras_total).round(2),
    service_charges: extras_total,
    transportation_charges: (total_price - extras_total).round(2),
    extra_charges_hash: {
      total_charge: extras_total,
      das: fee.call('DAS'), oversized: fee.call('OVERSIZE'),
      declared_value: fee.call('DECLAREDVALUE'), aod: fee.call('AOD'),
      cod: 0.0, hazardous: fee.call('HAZMAT'), pickup_tag: fee.call('PICKUPTAG'),
      metro: fee.call('METRO'), unboxed: fee.call('UNBOXED'),
      shipment_weight: fee.call('SHIPMENTWEIGHT')
    },
    days_in_transit: speedee_service&.days_in_transit_from_60047,
    signature_confirmation: @signature_confirmation,
    sort_code: speedee_service&.sort_code
  }
end

#find_rates(logger = nil) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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
# File 'app/services/shipping/speedee_delivery.rb', line 37

def find_rates(logger = nil)
  logger ||= Rails.logger
  @required = %i[zip country sender_zip sender_country packages]

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

  logger.debug "Shipping SpeedeeDelivery find_rates"
  rate_estimates = []
  successful, msg = nil
  res = get_delivery_estimate
  if res[:status] == :ok
    successful = true
    service_code = "SPEEDEE_DELIVERY"
    currency = "USD"
    estimate = {}
    estimate[:carrier] = 'SpeedeeDelivery'
    estimate[:service_name] = 'Spee-Dee Delivery'
    estimate[:service_code] = service_code
    estimate[:price] = res[:price]
    estimate[:total_charges] = res[:price]
    estimate[:transportation_charges] = res[:transportation_charges]
    estimate[:service_options_charges] = res[:service_charges]
    rate_data = {
      base_price: res[:base_price],
      total_price: res[:price],
      carrier_name: service_code,
      days_in_transit: res[:days_in_transit],
      # we do NOT support COD for Speedee or any other carrier
      # cod_amount: res[:cod_amount],
      # cod_collection_type: res[:cod_collection_type],
      sort_code: res[:sort_code],
      signature_confirmation: res[:signature_confirmation],
      extra_charges_hash: res[:extra_charges_hash]
    }
    estimate[:rate_data] = rate_data
    estimate[:insured_value] = @insured_value.to_f.round(2)
    estimate[:currency] = currency
    # allows for things like estimate.service_code — via HashMethodAccess
    estimate.extend(HashMethodAccess)
    # logger.info "!!!ups find_rates estimate: #{estimate.inspect}"
    rate_estimates << estimate
  else
    successful = false
    msg = res[:message]
  end

  response = {}
  response[:success] = successful
  response[:message] = "SpeedeeDelivery: #{msg}" if msg.present?
  response[:request] = @data.to_s
  response[:xml] = @response.to_s
  response[:rates] = rate_estimates

  # allows for things like fedex.success? — via HashMethodAccess
  response.extend(HashMethodAccess)

  response
end

#get_delivery_estimateObject



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
# File 'app/services/shipping/speedee_delivery.rb', line 199

def get_delivery_estimate
  res = {}
  qualifies = true
  errs = []
  zip = @zip[0..4].to_i
  unless @country == "US"
    qualifies = false
    errs << "Speedee Delivery does not ship outside the US"
  end
  # Enrichment + fallback inputs only — NOT a coverage gate. The Dispatch
  # Science API decides serviceability; these lookups just supply transit
  # days / label sort code where our tables know the zip, and feed the
  # legacy fallback when the API is unavailable.
  speedee_service = SpeedeeService.by_zip(zip).first
  zone = SpeedeeZone.get_zone_from_zip(zip)
  total_weight = 0.0
  package_arr = []
  @packages.each_with_index do |package, _i|
    package_hash = {}
    package_hash[:length] = [package.inches(:length).to_f, 0.1].max.round
    package_hash[:width] = [package.inches(:width).to_f, 0.1].max.round
    package_hash[:height] = [package.inches(:height).to_f, 0.1].max.round
    package_hash[:dimensional_length] = package_hash[:length] + (2.0 * (package_hash[:width] + package_hash[:height]))
    package_hash[:weight] = [package.lbs.to_f, 1.0].max.round
    package_hash[:declared_value] = package.value.to_f.round
    package_arr << package_hash
    total_weight += package_hash[:weight]
  end
  unless package_arr.all? { |ph| ph[:dimensional_length] <= DIMENSIONAL_LENGTH_MAX }
    qualifies = false
    errs << "Speedee Delivery package service does not ship packages with dimensional length (length plus girth) more than #{DIMENSIONAL_LENGTH_MAX} inches"
  end
  unless package_arr.all? { |ph| ph[:length] <= LENGTH_MAX }
    qualifies = false
    errs << "Speedee Delivery package service does not ship packages with length more than #{LENGTH_MAX} inches"
  end
  unless package_arr.all? { |ph| ph[:weight] <= WEIGHT_MAX }
    qualifies = false
    errs << "Speedee Delivery package service does not ship packages weighing more than #{WEIGHT_MAX} LBS"
  end
  if qualifies
    # RATING CUTOVER (2026-07-28): the Dispatch Science API is authoritative
    # for both coverage and price — the legacy SpeedeeRate/SpeedeeZone
    # tables (frozen at 2019 values, limited to zips we've historically
    # shipped) are only an outage fallback, so a Spee-Dee API blip can't
    # break live quoting for lanes our tables know.
    quote = dispatch_science_quote
    if quote
      res = api_estimate(quote, speedee_service)
    elsif speedee_service && zone
      Rails.logger.warn("SpeedeeDelivery: Dispatch Science quote unavailable — legacy-table fallback for zip #{@zip}")
      res = legacy_estimate(zone, package_arr, speedee_service)
    else
      res[:status] = :error
      res[:message] = "Spee-Dee (Dispatch Science) did not return a quote for zip #{@zip}"
    end
  else
    res[:status] = :error
    res[:message] = errs.join(", ")
  end
  res
end

#get_fuel_surcharge_rateObject



362
363
364
365
# File 'app/services/shipping/speedee_delivery.rb', line 362

def get_fuel_surcharge_rate
  res = SpeedeeService.get_fuel_surcharge_rate
  res['rate']
end

#get_service_charges_hash(_zone, package_arr) ⇒ Object



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'app/services/shipping/speedee_delivery.rb', line 341

def get_service_charges_hash(_zone, package_arr)
  total_charge = 0.0
  declared_value_charge = 0.0
  aod_charge = 0.0
  # charge += COD_SURCHARGE if @cod_amount.to_f > 0.0 # we do NOT support COD for Speedee or any other carrier
  package_arr.each do |package_hash|
    declared_value_surcharge = 0.0
    declared_value = [package_hash[:declared_value], DECLARED_VALUE_MAX].min # limit to the maximum allowed
    if declared_value > DECLARED_VALUE_MIN
      declared_value_surcharge = [((declared_value - DECLARED_VALUE_MIN) / 100.0).ceil * DECLARED_VALUE_SURCHARGE_PER_100, DECLARED_VALUE_SURCHARGE_MIN].max
      declared_value_charge += declared_value_surcharge
      total_charge += declared_value_surcharge
    end
    if @signature_confirmation
      aod_charge += SIGNATURE_CONFIRMATION_SURCHARGE
      total_charge += SIGNATURE_CONFIRMATION_SURCHARGE
    end
  end
  { total_charge: total_charge, cod: 0.0, declared_value: declared_value_charge, aod: aod_charge }
end

#get_transportation_charges_hash(zone, package_arr) ⇒ Object



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'app/services/shipping/speedee_delivery.rb', line 317

def get_transportation_charges_hash(zone, package_arr)
  total_charge = 0.0
  oversized = 0.0
  package_arr.each do |package_hash|
    if package_hash[:dimensional_length] >= HIGH_DIMENSIONAL_LENGTH_THRESHOLD
      # charge oversize fee if oversize via dimensional length
      oversized += OVERSIZE_SURCHARGE[zone.to_s.to_sym] || 0.0
      total_charge += oversized
      if package_hash[:weight] >= HIGH_WEIGHT_THRESHOLD
        # additionally, *also* charge from rate table if weight > threshold
        total_charge += SpeedeeRate.get_rate_from_weight_and_zone(package_hash[:weight], zone)
      end
    else
      # charge from rate table if not oversize
      total_charge += SpeedeeRate.get_rate_from_weight_and_zone(package_hash[:weight], zone)
    end
  end
  # ponytail: DAS omitted from the outage-only fallback — the 2019
  # SpeedeeDasZip list is deleted and the API quote is the only DAS source
  # now, so fallback pricing underquotes DAS-zip lanes by ~$4 during an
  # API outage. Acceptable for a temporary degraded mode.
  { total_charge: total_charge, oversized: oversized, das: 0.0 }
end

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

Creates the Dispatch Science order and hands the (slow) label off to
SpeedeeLabelWorker. Unlike the legacy path — which synthesized a
tracking number locally and rendered the label inline — the real
tracking number is the carrier-issued barcode, which isn't final until a
Spee-Dee background job rewrites the placeholder "01"/"02" (usually <1s,
up to ~10s). Polling for it would block the web request, so #label only
does the fast create POST and returns +speedee_deferred: true+;
Delivery::GenerateLabels records the orderId and the controller enqueues
SpeedeeLabelWorker, which polls the barcode, sets the shipment tracking
numbers, renders the label PDFs, and transitions the delivery — the same
async shape Freightquote uses for its load number.

Raises:



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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
# File 'app/services/shipping/speedee_delivery.rb', line 108

def label(_return_label = false, _logger = nil)
  @required = %i[zip country sender_zip sender_country packages]
  @required += %i[rate_data delivery_total_value]

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

  check_required

  quoted_rate = ActiveSupport::HashWithIndifferentAccess.new(@rate_data || {})
  # Re-rate to guard against the shipping criteria drifting from what was
  # quoted. Billing still uses the local rate tables in phase 2 (the rating
  # migration is a separate rollout) — this only warns on a large delta.
  original_total_price = quoted_rate[:total_price].to_f
  estimate = nil
  new_total_price = nil
  estimate_res = find_rates
  if estimate_res[:success]
    estimate = estimate_res[:rates].first
    new_total_price = estimate&.dig(:rate_data, :total_price)&.to_f
    if new_total_price
      diff = (new_total_price - original_total_price).abs
      if @skip_rate_test || ((diff < COST_DISCREPANCY_THRESHOLD && diff / original_total_price < COST_DISCREPANCY_THRESHOLD_RATIO) || (@delivery_total_value.to_f > COST_DISCREPANCY_THRESHOLD * 10.0 && diff / @delivery_total_value.to_f < COST_DISCREPANCY_THRESHOLD_BY_TOTAL_VALUE_RATIO))
        # we match and the price is proportionately close enough
      else
        ErrorReporting.warning("Threshold exceeded for SpeedeeDelivery label: original_total_price: #{original_total_price.round(2)}, new_total_price: #{new_total_price.round(2)}, difference: #{diff.round(2)}, original rate_data: #{@rate_data.inspect} new rate_data: #{estimate&.dig(:rate_data).inspect}")
      end
    end
  end

  effective_rate_nested = estimate&.dig(:rate_data) || @rate_data
  rate_data = ActiveSupport::HashWithIndifferentAccess.new(effective_rate_nested || {})

  result = dispatch_science_client.create_order(
    pickup: dispatch_science_pickup,
    dropoff: dispatch_science_dropoff,
    packages: dispatch_science_packages,
    extra_fees: (@signature_confirmation ? [{ id: 'AOD' }] : []),
    require_signature: @signature_confirmation.present?,
    reference_number: @reference_number_1.presence
  )
  @last_request_payload = result[:request]
  @last_response_payload = result[:raw]
  raise ShippingError, "could not create SpeedeeDelivery order: #{result[:message]}" unless result[:status] == :ok && result[:order_id].present?

  # Dispatch Science can return status 200 + an orderId WITH validationErrors —
  # meaning the order was accepted but some accessorial (e.g. signature/AOD)
  # may have been rejected. Don't let that pass silently: surface it (AppSignal
  # + the shipping_api_log via the returned key) so it's caught, without
  # blocking the shipment on validation content whose exact semantics we
  # haven't seen populated yet.
  if result[:validation_errors].present?
    ErrorReporting.warning(
      "SpeedeeDelivery order #{result[:order_id]} created WITH validation errors (an accessorial may have been rejected)",
      order_id: result[:order_id], validation_errors: result[:validation_errors]
    )
  end

  # No tracking numbers / labels yet — SpeedeeLabelWorker finalizes them.
  { speedee_deferred: true,
    speedee_order_id: result[:order_id],
    shipment_identification_number: result[:order_id],
    labels: [],
    validation_errors: result[:validation_errors],
    total_charges: new_total_price || original_total_price,
    rate_data: rate_data,
    ship_request_xml: result[:request].to_json,
    ship_reply_xml: result[:raw].to_json }
end

#legacy_estimate(zone, package_arr, speedee_service) ⇒ Object

LEGACY fallback (pre-cutover pricing): 2019-frozen rate tables + fuel
surcharge scrape. Only reached when the Dispatch Science API is
unavailable AND our tables cover the destination zip.



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

def legacy_estimate(zone, package_arr, speedee_service)
  transportation_charges_hash = get_transportation_charges_hash(zone, package_arr)
  service_charges_hash = get_service_charges_hash(zone, package_arr)
  base_price = transportation_charges_hash[:total_charge] + service_charges_hash[:total_charge]
  total_price = (1.0 + get_fuel_surcharge_rate) * base_price.round(2)
  {
    status: :ok,
    base_price: base_price,
    price: total_price,
    service_charges: service_charges_hash[:total_charge],
    extra_charges_hash: service_charges_hash.merge(transportation_charges_hash),
    transportation_charges: total_price - service_charges_hash[:total_charge],
    days_in_transit: speedee_service.days_in_transit_from_60047,
    signature_confirmation: @signature_confirmation,
    sort_code: speedee_service.sort_code
  }
end

#void(identifier) ⇒ Object

Cancels the Dispatch Science order — the first real Spee-Dee void (the
legacy stub returned empty XML). +identifier+ is the orderId, which
WyShipping.void_delivery passes through from delivery.freight_order_number
(where Delivery::GenerateLabels stored it at label time).

Shipments labeled before the Dispatch Science cutover carry a synthesized
"SP…" barcode (no DS order exists to cancel) — for those we no-op exactly
like the pre-cutover stub, so voiding an in-flight legacy shipment keeps
working instead of erroring on a bogus cancel.

Raises:



190
191
192
193
194
195
196
197
# File 'app/services/shipping/speedee_delivery.rb', line 190

def void(identifier)
  return { void_request_xml: '', void_response_xml: '' } if identifier.blank? || !identifier.to_s.match?(DISPATCH_SCIENCE_ORDER_ID)

  result = dispatch_science_client.cancel_order(order_id: identifier)
  raise ShippingError, "could not void SpeedeeDelivery order #{identifier}: #{result[:message]}" unless result[:status] == :ok

  { void_request_xml: identifier.to_s, void_response_xml: result[:raw].to_json }
end