Class: ShipmentEvent
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- ShipmentEvent
- Defined in:
- app/models/shipment_event.rb
Overview
Typed projection of ShipEngine tracking webhook events into indexed
columns. One row per carrier scan emitted in the data.events[] array
of each tracking webhook (POST /webhooks/v1/shipengine).
Populated by WebhookProcessors::ShipengineProcessor from WebhookLog
records ingested at Webhooks::V1::ShipengineController#create. The
roll-up status (latest status_code, estimated_delivery_date, etc.)
continues to live as columns on Shipment; this table is the per-scan
audit trail used by the Tracking Events tab and future sweep workers.
Idempotency: ShipEngine does not stamp events with a stable ID and each
webhook re-sends the cumulative events[] array. We synthesise a
natural key from (tracking_number, occurred_at, event_code, status_code).
Replay safety relies on the unique index on idempotency_key.
Mirrors FreightEvent (the Freightquote/CHR Navisphere projection) in
structure and conventions.
Constant Summary collapse
- STATUS_CODE_LABELS =
ShipEngine normalised status codes (mirrors
WebhookProcessors::ShipengineProcessor::TRACKING_STATUS_CODES). { 'UN' => 'unknown', 'AC' => 'accepted', 'IT' => 'in_transit', 'DE' => 'delivered', 'EX' => 'exception', 'AT' => 'delivery_attempt', 'NY' => 'not_yet_in_system', 'SP' => 'delivered_to_collection_location' }.freeze
- MOVEMENT_STATUS_CODES =
Status codes where the carrier has physically accepted the package
(i.e. past "label created"). These are the events that meaningfully
advance a shipment's lifecycle. Mirrors
WebhookProcessors::ShipengineProcessor::MOVEMENT_STATUS_CODES. %w[IT DE EX AT SP].freeze
- DELIVERED_STATUS_CODES =
Terminal happy-path code; reaching this is the strongest signal a
package was delivered. %w[DE SP].freeze
- EXCEPTION_STATUS_CODES =
%w[EX AT].freeze
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
- #idempotency_key ⇒ Object readonly
- #occurred_at ⇒ Object readonly
- #tracking_number ⇒ Object readonly
Belongs to collapse
Class Method Summary collapse
-
.chronological ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are chronological.
-
.delivered ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are delivered.
-
.derive_carrier_code(payload) ⇒ Object
Pull the carrier_code out of the
resource_urlquery string when present (e.g./v1/tracking?carrier_code=usps&tracking_number=...). -
.exceptions ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are exceptions.
-
.for_tracking_number ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are for tracking number.
-
.idempotency_key_for(tracking_number:, occurred_at:, event_code: nil, status_code: nil) ⇒ String
Build the natural-key string used for replay-safe upserts.
-
.infer_status_code(description) ⇒ String?
Maps a carrier-side description (e.g. "Our Package was Delivered", "Out for Delivery-Almost There!", "Picked Up and On the Move!") to one of the STATUS_CODE_LABELS keys (UN/AC/IT/DE/EX/AT/NY/SP).
-
.movement ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are movement.
-
.parse_event(event, tracking_number:, carrier_code:) ⇒ Hash
Parse one entry from
data.events[]into our column shape. -
.parse_payload(payload) ⇒ Array<Hash>
Parse one ShipEngine tracking webhook into an array of attribute hashes — one per entry in
data.events[]. -
.since ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are since.
Instance Method Summary collapse
-
#delivered? ⇒ Boolean
True if this scan is a terminal delivered event.
-
#exception? ⇒ Boolean
True if this scan is an exception / failed-attempt.
-
#movement? ⇒ Boolean
True if this scan is one of the movement-stage codes (carrier has physically accepted the package).
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#idempotency_key ⇒ Object (readonly)
127 |
# File 'app/models/shipment_event.rb', line 127 validates :idempotency_key, presence: true, uniqueness: true |
#occurred_at ⇒ Object (readonly)
125 |
# File 'app/models/shipment_event.rb', line 125 validates :occurred_at, presence: true |
#tracking_number ⇒ Object (readonly)
126 |
# File 'app/models/shipment_event.rb', line 126 validates :tracking_number, presence: true |
Class Method Details
.chronological ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are chronological. Active Record Scope
134 |
# File 'app/models/shipment_event.rb', line 134 scope :chronological, -> { order(:occurred_at) } |
.delivered ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are delivered. Active Record Scope
132 |
# File 'app/models/shipment_event.rb', line 132 scope :delivered, -> { where(status_code: DELIVERED_STATUS_CODES) } |
.derive_carrier_code(payload) ⇒ Object
Pull the carrier_code out of the resource_url query string when
present (e.g. /v1/tracking?carrier_code=usps&tracking_number=...).
ShipEngine does not surface carrier_code in the structured payload,
so we sniff it from the URL — best-effort, returns nil when absent.
173 174 175 176 177 178 179 180 181 182 183 |
# File 'app/models/shipment_event.rb', line 173 def self.derive_carrier_code(payload) url = payload['resource_url'].to_s return nil if url.blank? query = URI.parse(url).query return nil if query.blank? URI.decode_www_form(query).to_h['carrier_code'] rescue URI::InvalidURIError nil end |
.exceptions ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are exceptions. Active Record Scope
133 |
# File 'app/models/shipment_event.rb', line 133 scope :exceptions, -> { where(status_code: EXCEPTION_STATUS_CODES) } |
.for_tracking_number ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are for tracking number. Active Record Scope
129 |
# File 'app/models/shipment_event.rb', line 129 scope :for_tracking_number, ->(tracking_number) { where(tracking_number: tracking_number) } |
.idempotency_key_for(tracking_number:, occurred_at:, event_code: nil, status_code: nil) ⇒ String
Build the natural-key string used for replay-safe upserts. Mirrors
FreightEvent.idempotency_key_for in shape — UTC-normalised time so
the same payload arriving as a string or a Time produces an identical
key.
148 149 150 151 152 |
# File 'app/models/shipment_event.rb', line 148 def self.idempotency_key_for(tracking_number:, occurred_at:, event_code: nil, status_code: nil) time = occurred_at.is_a?(String) ? Time.zone.parse(occurred_at) : occurred_at time_str = time&.utc&.iso8601(6) [tracking_number, time_str, event_code, status_code].map(&:to_s).join('|') end |
.infer_status_code(description) ⇒ String?
Maps a carrier-side description (e.g. "Our Package was Delivered",
"Out for Delivery-Almost There!", "Picked Up and On the Move!") to
one of the STATUS_CODE_LABELS keys (UN/AC/IT/DE/EX/AT/NY/SP).
Returns nil when no pattern matches; the field then stays nil.
Used as a fallback by parse_event when ShipEngine leaves the
per-event status_code nil (Canpar consistently does this) and by
Shipping::ShipengineBase#recover_actual_delivery_date_from_events!
so the polling tracking path also recognizes delivered scans from
description text.
93 94 95 96 97 98 99 100 101 |
# File 'app/models/shipment_event.rb', line 93 def self.infer_status_code(description) return nil if description.blank? text = description.to_s STATUS_INFERENCE_PATTERNS.each do |code, pattern| return code if pattern.match?(text) end nil end |
.movement ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are movement. Active Record Scope
131 |
# File 'app/models/shipment_event.rb', line 131 scope :movement, -> { where(status_code: MOVEMENT_STATUS_CODES) } |
.parse_event(event, tracking_number:, carrier_code:) ⇒ Hash
Parse one entry from data.events[] into our column shape.
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 |
# File 'app/models/shipment_event.rb', line 191 def self.parse_event(event, tracking_number:, carrier_code:) # Synthesize status_code from the carrier description when ShipEngine # leaves the per-event field nil (Canpar's consistent behavior). status_code = event['status_code'].presence || infer_status_code(event['event_description'].presence || event['description']) { tracking_number: tracking_number, carrier_code: carrier_code, occurred_at: event['occurred_at'], status_code: status_code, status_description: STATUS_CODE_LABELS[status_code], carrier_status_code: event['carrier_status_code'], carrier_status_description: event['carrier_status_description'], event_code: event['event_code'], event_description: event['event_description'], description: event['description'], carrier_detail_code: event['carrier_detail_code'], city_locality: event['city_locality'].presence, state_province: event['state_province'].presence, postal_code: event['postal_code'].presence, country_code: event['country_code'].presence, company_name: event['company_name'].presence, signer: event['signer'].presence, latitude: event['latitude'], longitude: event['longitude'], payload: event } end |
.parse_payload(payload) ⇒ Array<Hash>
Parse one ShipEngine tracking webhook into an array of attribute
hashes — one per entry in data.events[]. The roll-up status_code
and tracking_number live at data.*; per-scan fields are extracted
from each event in the array.
161 162 163 164 165 166 167 |
# File 'app/models/shipment_event.rb', line 161 def self.parse_payload(payload) data = payload['data'] || {} tracking_number = data['tracking_number'] carrier_code = derive_carrier_code(payload) events = Array(data['events']) events.map { |event| parse_event(event, tracking_number: tracking_number, carrier_code: carrier_code) } end |
.since ⇒ ActiveRecord::Relation<ShipmentEvent>
A relation of ShipmentEvents that are since. Active Record Scope
130 |
# File 'app/models/shipment_event.rb', line 130 scope :since, ->(time) { where('occurred_at >= ?', time) } |
Instance Method Details
#delivered? ⇒ Boolean
Returns true if this scan is a terminal delivered event.
228 229 230 |
# File 'app/models/shipment_event.rb', line 228 def delivered? DELIVERED_STATUS_CODES.include?(status_code) end |
#exception? ⇒ Boolean
Returns true if this scan is an exception / failed-attempt.
233 234 235 |
# File 'app/models/shipment_event.rb', line 233 def exception? EXCEPTION_STATUS_CODES.include?(status_code) end |
#movement? ⇒ Boolean
Returns true if this scan is one of the movement-stage codes
(carrier has physically accepted the package).
223 224 225 |
# File 'app/models/shipment_event.rb', line 223 def movement? MOVEMENT_STATUS_CODES.include?(status_code) end |
#shipment ⇒ Shipment
115 |
# File 'app/models/shipment_event.rb', line 115 belongs_to :shipment, inverse_of: :shipment_events, optional: true |
#webhook_log ⇒ WebhookLog
116 |
# File 'app/models/shipment_event.rb', line 116 belongs_to :webhook_log, optional: true |