Class: Shipping::SpeedeeDelivery
- 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
-
#api_estimate(quote, speedee_service) ⇒ Object
Builds the estimate from a successful Dispatch Science quote.
- #find_rates(logger = nil) ⇒ Object
- #get_delivery_estimate ⇒ Object
- #get_fuel_surcharge_rate ⇒ Object
- #get_service_charges_hash(_zone, package_arr) ⇒ Object
- #get_transportation_charges_hash(zone, package_arr) ⇒ Object
-
#label(_return_label = false, _logger = nil) ⇒ Object
Creates the Dispatch Science order and hands the (slow) label off to SpeedeeLabelWorker.
-
#legacy_estimate(zone, package_arr, speedee_service) ⇒ Object
LEGACY fallback (pre-cutover pricing): 2019-frozen rate tables + fuel surcharge scrape.
-
#void(identifier) ⇒ Object
Cancels the Dispatch Science order — the first real Spee-Dee void (the legacy stub returned empty XML).
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_estimate ⇒ Object
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_rate ⇒ Object
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.
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.
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 |