Class: Shipping::SpeedeeDispatchScienceClient

Inherits:
BaseService
  • Object
show all
Defined in:
app/services/shipping/speedee_dispatch_science_client.rb

Overview

Faraday-backed HTTP client for Spee-Dee Delivery's Dispatch Science-backed
"ShipIt Integration API" (REST/JSON, HTTP Basic Auth).

Step 1 of the migration off the home-grown Spee-Dee integration (local rate
tables in SpeedeeRate/SpeedeeZone/SpeedeeDasZip, a scraped weekly fuel
surcharge, and a home-rendered PDF label) onto Spee-Dee's platform API —
see doc/tasks/202607011617_SPEEDEE_DISPATCH_SCIENCE_MIGRATION.md for the
full plan. This client is intentionally NOT wired into
Shipping::SpeedeeDelivery yet: that requires a rollout-strategy decision
(shadow-run vs. behind a flag vs. direct cutover) on a path that prices
every live customer quote, so it's a deliberately separate, separately
reviewed follow-up PR. This commit only adds the tested HTTP plumbing.

The +serviceLevelId+ / +vehicleTypeId+ / +parcelTypeId+ constants below are
fixed platform-wide enums, NOT account-specific — confirmed live against
the https://speedeetest.dsapp.io sandbox (account 90035P) on 2026-07-02: a
quote request with these exact IDs returned HTTP 200 with an itemized
+charges+ array (Delivery, DAS, HAZMAT, ITEMPRICE, METRO, OVERSIZE,
UNBOXED, ...). Also documented at https://api.speedeeshipit.com/ (Spee-Dee
IT, Hunter Boelz, per email 2026-07-02).

Constant Summary collapse

QUOTE_PATH =

No leading slash: Faraday resolves request paths against the connection's
base +url+ per RFC 3986 relative-reference rules, so a leading slash
resets to the host root and silently drops the sandbox's +/integration+
base path (config[:api_url] is
"https://speedeetest.dsapp.io/integration") — caught live by
speedee_dispatch_science_client_test.rb, which asserts the full request
URL including that prefix.

'api/v1/orders/quote'
ORDERS_PATH =
'api/v1/orders'
DEFAULT_LABEL_TEMPLATE =

Default label template + format for label retrieval. STANDARD is the
4×6 label. Per the LabelFormat enum and the Label Template IDs table at
https://api.speedeeshipit.com/, the public templates (STANDARD,
4PERSHEETQUADRANT) only support format 0 = ZPL — requesting format
1 = PDF returns HTTP 400 "Template STANDARD doesn't support format
PDF" (verified live 2026-07-02). A viewable PDF template is a custom
request through integrations@speedeedelivery.com; until then ZPL is
the default (it's the printer-native format Spee-Dee's own system uses).

'STANDARD'
LABEL_FORMAT_ZPL =
0
LABEL_FORMAT_PDF =
1
LABEL_READY_POLL_INTERVAL =

A freshly created order gets placeholder barcodes ("01", "02", … per item)
until a Spee-Dee background job rewrites them with the account number and
internal type identifiers (e.g. "SP90035P47SD42972501"). Printing before
that finishes yields a non-scannable label — so poll GET /items until the
first barcode is no longer a bare 1–2 digit placeholder (Hunter Boelz,
2026-07-02; usually <1s, but poll to be safe).
Sandbox finalized barcodes in <1s, but PRODUCTION took >10s on the first
live order (SD6141201, 2026-07-28) — Hunter warned the rewrite job "could
jump" under load. 240 × 0.5s = up to 2 minutes; the default consumer is
SpeedeeLabelWorker (Sidekiq), where a long poll is cheap. Inline callers
(standalone deliveries) pass a tighter max_attempts.

0.5
LABEL_READY_MAX_ATTEMPTS =
240
PLACEHOLDER_BARCODE =
/\A\d{1,2}\z/
SERVICE_LEVEL_ID =

Parcel-service defaults — this client only covers Spee-Dee's small
package ("Parcel") service, not LTL. LTL uses different IDs
(LTL_NEXTDAYPU / SEMITRAILER_53TANDEMAXELTRACTOR / PALLET), out of scope
until Spee-Dee LTL volume needs this client too.

'PACKAGE_PARCEL'
VEHICLE_TYPE_ID =
'STEPVAN'
PARCEL_TYPE_ID =
'BOX'
REQUEST_TIMEOUT =
15

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#cancel_order(order_id:) ⇒ Hash

Cancels (voids) an order (DELETE /api/v1/orders/{orderId}) — the first
real void Spee-Dee has ever supported (the legacy adapter's #void was a
stub that returned empty XML). Idempotent on the carrier side: cancelling
an already-cancelled order is a no-op, so this stays on the retrying
+connection+ rather than +order_connection+.

Parameters:

Returns:

  • (Hash)

    +{ status: :ok, raw: }+ or +{ status: :error, message:, raw: }+.



202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 202

def cancel_order(order_id:)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  response = connection.delete("#{ORDERS_PATH}/#{order_id}")
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body } unless response.status.in?([200, 204])

  { status: :ok, raw: body }
rescue Faraday::Error => e
  log_warning("Dispatch Science cancel_order request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#create_order(pickup:, dropoff:, packages:, extra_fees: [], require_signature: false, collect_on_delivery: nil, reference_number: nil, ready_at: Time.current) ⇒ Hash

Creates an order (POST /api/v1/orders). Same pickup/dropoff/packages
shape as #quote, plus optional accessorials:

Parameters:

  • extra_fees (Array<Hash>) (defaults to: [])

    order-level accessorials, each
    +{ id: 'AOD', quantity: 1 }+ — maps to the ExtraFeeModel
    +{ extraFeeId:, quantity: }+. Valid ids documented at
    https://api.speedeeshipit.com/ (AOD, OVERSIZE, COD, HAZMAT, ...).
    NOTE: declared value is NOT an order-level extra fee — pass it
    per-package as +packages: [{ …, declared_value: 200.0 }]+ so it rides on
    the item's userFields (the extra-fee form silently computes $0).

  • require_signature (Boolean) (defaults to: false)

    sets +requireIdentityValidation+
    (signature/AOD on delivery).

  • collect_on_delivery (Numeric, nil) (defaults to: nil)

    COD amount to collect.

  • reference_number (String, nil) (defaults to: nil)

    our reference (referenceNumber1).

  • ready_at (Time) (defaults to: Time.current)

    pickup-ready timestamp (defaults to now).

Returns:

  • (Hash)

    +{ status: :ok, order_id:, validation_errors:, raw: }+
    or +{ status: :error, message:, raw: }+.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 127

def create_order(pickup:, dropoff:, packages:, extra_fees: [], require_signature: false, collect_on_delivery: nil, reference_number: nil, ready_at: Time.current)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  payload = quote_payload(pickup: pickup, dropoff: dropoff, packages: packages)
  payload[:readyAt] = ready_at.iso8601
  payload[:referenceNumber1] = reference_number if reference_number.present?
  payload[:requireIdentityValidation] = true if require_signature
  payload[:collectOnDelivery] = collect_on_delivery if collect_on_delivery.present?
  payload[:extraFees] = extra_fees.map { |fee| { extraFeeId: fee[:id], quantity: fee[:quantity] || 1 } } if extra_fees.any?

  # order_connection (no retry) — create_order is NOT idempotent: a timeout
  # after Dispatch Science has accepted the order would create a DUPLICATE
  # order/label on retry. quote and the GETs stay on the retrying connection.
  response = order_connection.post(ORDERS_PATH, payload.to_json)
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body, request: payload } unless response.status == 200

  { status: :ok, order_id: body['orderId'], validation_errors: body['validationErrors'], raw: body, request: payload }
rescue Faraday::Error => e
  log_warning("Dispatch Science create_order request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#get_items(order_id:) ⇒ Hash

Lists an order's items (GET /api/v1/orders/{orderId}/items). Each item
carries its finalized barcode and userFields. Used by
#label_when_ready to poll for barcode finalization before printing.

Parameters:

  • order_id (String)

Returns:

  • (Hash)

    +{ status: :ok, items: Array }+ or +{ status: :error, message: }+.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 222

def get_items(order_id:)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  response = connection.get("#{ORDERS_PATH}/#{order_id}/items")
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body } unless response.status == 200

  # The items endpoint returns a JSON array. A 200 with a non-JSON/empty body
  # (parse_body then returns the raw String) must not slip through — Array(str)
  # would wrap it as ["raw"], and label_barcode_ready?'s .dig on a String would
  # raise NoMethodError, crashing the label_when_ready poll loop. Same guard
  # class as #quote's malformed-200 check.
  return { status: :error, message: 'Unexpected Dispatch Science items response', raw: body } unless body.is_a?(Array)

  { status: :ok, items: body }
rescue Faraday::Error => e
  log_warning("Dispatch Science get_items request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#get_label(order_id:, template_id: DEFAULT_LABEL_TEMPLATE, format: LABEL_FORMAT_ZPL) ⇒ Hash

Downloads the label for an order
(GET /api/v1/orders/{orderId}/label/{templateId}/{format}).

Parameters:

  • order_id (String)

    the orderId from #create_order.

  • template_id (String) (defaults to: DEFAULT_LABEL_TEMPLATE)

    label template (default STANDARD, the 4×6).

  • format (Integer) (defaults to: LABEL_FORMAT_ZPL)

    1 = PDF, 0 = ZPL.

Returns:

  • (Hash)

    +{ status: :ok, body:, content_type: }+ (body is the raw
    label payload — text/plain or JSON per the endpoint) or
    +{ status: :error, message: }+.



181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 181

def get_label(order_id:, template_id: DEFAULT_LABEL_TEMPLATE, format: LABEL_FORMAT_ZPL)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  response = connection.get("#{ORDERS_PATH}/#{order_id}/label/#{template_id}/#{format}")

  return { status: :error, message: "HTTP #{response.status}: #{response.body.to_s.first(300)}" } unless response.status == 200

  { status: :ok, body: response.body, content_type: response.headers['content-type'] }
rescue Faraday::Error => e
  log_warning("Dispatch Science get_label request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#get_order(order_id:) ⇒ Hash

Fetches the full stored order (GET /api/v1/orders/{orderId}) — the
OrderDetailsResponse, including applied charges, accessorial flags, and
status. Used to confirm an order was created as intended and (phase 2)
to read order status/tracking.

Parameters:

Returns:

  • (Hash)

    +{ status: :ok, order: }+ or +{ status: :error, message: }+.



158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 158

def get_order(order_id:)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  response = connection.get("#{ORDERS_PATH}/#{order_id}")
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body } unless response.status == 200

  { status: :ok, order: body }
rescue Faraday::Error => e
  log_warning("Dispatch Science get_order request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#get_order_status(order_id:) ⇒ Hash

Fetches an order's status (GET /api/v1/orders/{orderId}/status) — the
lightweight body Shipping::SpeedeeTracker projects into ShipmentEvent
rows: +"orderId":…,"status":"Delivered","pickedUpDate":…,"deliveredDate":…+
(shape verified against production 2026-08-03).

This replaced GET /orders/{id}/trackeditemslog for tracking: Spee-Dee's
drivers don't item-scan through Dispatch Science, so the scan log is
permanently empty even on delivered orders (verified against three
delivered production orders, 2026-08-03).

Parameters:

  • order_id (String)

Returns:

  • (Hash)

    +{ status: :ok, order_status: }+ or
    +{ status: :error, message: }+.



256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 256

def get_order_status(order_id:)
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  response = connection.get("#{ORDERS_PATH}/#{order_id}/status")
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body } unless response.status == 200

  { status: :ok, order_status: body }
rescue Faraday::Error => e
  log_warning("Dispatch Science get_order_status request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end

#items_when_ready(order_id:, poll_interval: LABEL_READY_POLL_INTERVAL, max_attempts: LABEL_READY_MAX_ATTEMPTS) ⇒ Hash

Polls #get_items until the order's barcodes are finalized (no longer the
"01"/"02" placeholder) and returns the items. Same poll used by
#label_when_ready, but stops at the finalized items rather than
downloading a label — Spee-Dee's production label is our own
Pdf::Label::Speedee render fed the finalized barcode, so
Shipping::SpeedeeDelivery only needs the barcode, not the ZPL.

THREADING: sleeps synchronously (up to max_attempts * poll_interval plus
each get_items round-trip). Call from a background job, not a web request.

Parameters:

  • order_id (String)

Returns:

  • (Hash)

    +{ status: :ok, items: Array }+ (barcodes finalized) or
    +{ status: :error, message: }+ (never finalized, or an items poll errored).



310
311
312
313
314
315
316
317
318
319
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 310

def items_when_ready(order_id:, poll_interval: LABEL_READY_POLL_INTERVAL, max_attempts: LABEL_READY_MAX_ATTEMPTS)
  max_attempts.times do
    items = get_items(order_id: order_id)
    return items unless items[:status] == :ok
    return items if label_barcode_ready?(items[:items])

    pause(poll_interval)
  end
  { status: :error, message: "Label barcode for #{order_id} did not finalize after #{max_attempts} polls" }
end

#label_when_ready(order_id:, template_id: DEFAULT_LABEL_TEMPLATE, format: LABEL_FORMAT_ZPL, poll_interval: LABEL_READY_POLL_INTERVAL, max_attempts: LABEL_READY_MAX_ATTEMPTS) ⇒ Hash

Safe-print flow: polls #get_items until the order's barcodes are
finalized (no longer the "01"/"02" placeholder), then downloads the
label. Printing a placeholder barcode produces a non-scannable label, so
ALWAYS prefer this over calling #get_label directly on a just-created
order.

THREADING: this method sleeps synchronously — up to
max_attempts * poll_interval (~10s) plus each get_items HTTP round-trip.
When wiring into Shipping::SpeedeeDelivery, call it from a background job
(Sidekiq), NOT from a web request thread, or it will tie up a request
worker for several seconds.

Parameters:

  • order_id (String)
  • template_id (String) (defaults to: DEFAULT_LABEL_TEMPLATE)

    label template (default STANDARD).

  • format (Integer) (defaults to: LABEL_FORMAT_ZPL)

    0 = ZPL (default), 1 = PDF.

  • poll_interval (Numeric) (defaults to: LABEL_READY_POLL_INTERVAL)

    seconds between polls.

  • max_attempts (Integer) (defaults to: LABEL_READY_MAX_ATTEMPTS)

    polls before giving up.

Returns:

  • (Hash)

    the #get_label result, or +{ status: :error, message: }+
    if the barcode never finalized (or an items poll errored).



289
290
291
292
293
294
295
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 289

def label_when_ready(order_id:, template_id: DEFAULT_LABEL_TEMPLATE, format: LABEL_FORMAT_ZPL,
                     poll_interval: LABEL_READY_POLL_INTERVAL, max_attempts: LABEL_READY_MAX_ATTEMPTS)
  items = items_when_ready(order_id: order_id, poll_interval: poll_interval, max_attempts: max_attempts)
  return items unless items[:status] == :ok

  get_label(order_id: order_id, template_id: template_id, format: format)
end

#quote(pickup:, dropoff:, packages:, extra_fees: []) ⇒ Hash

Requests a rate quote for a single shipment.

Parameters:

  • pickup (Hash)

    :address_line1, :address_line2, :company, :city,
    :state, :zip, :name, :phone

  • dropoff (Hash)

    same shape as +pickup+

  • packages (Array<Hash>)

    each: :weight (lb), :length, :width,
    :height (in)

Returns:

  • (Hash)

    +{ status: :ok, total_price:, charges:, raw: }+ or
    +{ status: :error, message:, raw: }+. +charges+ is the verbatim
    Dispatch Science charges array (one entry per extraFeeTypeId — DAS,
    OVERSIZE, ITEMPRICE, etc.); +total_price+ is their sum.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/services/shipping/speedee_dispatch_science_client.rb', line 85

def quote(pickup:, dropoff:, packages:, extra_fees: [])
  return { status: :error, message: 'Dispatch Science API credentials not configured' } unless dispatch_science_configured?

  payload = quote_payload(pickup: pickup, dropoff: dropoff, packages: packages)
  # Same ExtraFeeModel mapping as #create_order — lets a quote price
  # accessorials (e.g. AOD for signature-required shipments) so the quoted
  # total matches what the order will actually charge.
  payload[:extraFees] = extra_fees.map { |fee| { extraFeeId: fee[:id], quantity: fee[:quantity] || 1 } } if extra_fees.any?
  response = connection.post(QUOTE_PATH, payload.to_json)
  body = parse_body(response.body)

  return { status: :error, message: error_message_from(body, response.status), raw: body } unless response.status == 200

  # A 200 with a non-JSON/empty/non-Hash body (e.g. an edge error page) must
  # not be priced as a $0 success — Array(body['charges']) would otherwise
  # yield [] and report status: :ok, total_price: 0.0.
  return { status: :error, message: 'Unexpected Dispatch Science quote response', raw: body } unless body.is_a?(Hash) && body['charges'].is_a?(Array)

  charges = body['charges']
  { status: :ok, total_price: charges.sum { |c| c['price'].to_f }.round(2), charges: charges, raw: body }
rescue Faraday::Error => e
  log_warning("Dispatch Science quote request failed: #{e.class}: #{e.message}")
  { status: :error, message: "#{e.class}: #{e.message}" }
end