Class: Shipping::SpeedeeDispatchScienceClient
- Inherits:
-
BaseService
- Object
- BaseService
- Shipping::SpeedeeDispatchScienceClient
- 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
Instance Method Summary collapse
-
#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). -
#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).
-
#get_items(order_id:) ⇒ Hash
Lists an order's items (GET /api/v1/orders/
{orderId}/items). -
#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}). -
#get_order(order_id:) ⇒ Hash
Fetches the full stored order (GET /api/v1/orders/
{orderId}) — the OrderDetailsResponse, including applied charges, accessorial flags, and status. -
#get_order_status(order_id:) ⇒ Hash
Fetches an order's status (GET /api/v1/orders/
{orderId}/status) — the lightweight body SpeedeeTracker projects into ShipmentEvent rows: +"orderId":…,"status":"Delivered","pickedUpDate":…,"deliveredDate":…+ (shape verified against production 2026-08-03). -
#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.
-
#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.
-
#quote(pickup:, dropoff:, packages:, extra_fees: []) ⇒ Hash
Requests a rate quote for a single shipment.
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+.
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } 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:
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } 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.
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } 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}).
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.}") { status: :error, message: "#{e.class}: #{e.}" } 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.
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } 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).
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } 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.
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 (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.
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.
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: (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.}") { status: :error, message: "#{e.class}: #{e.}" } end |