Class: FreightEvent

Inherits:
ApplicationRecord show all
Defined in:
app/models/freight_event.rb

Overview

Typed projection of CHR/Freightquote (CH Robinson Navisphere Events product)
payloads into indexed columns for the freight events pipeline.

Populated by WebhookProcessors::FreightquoteProcessor from WebhookLog
records ingested at Webhooks::V1::FreightquoteController#create. Each row
is one Navisphere event (LOAD CREATED, LOAD BOOKED, LOAD CANCELLED,
ORDER REJECTED, APPOINTMENT UPDATED, PRO NUMBER ADDED, etc.). The raw
payload stays in webhook_logs.data; this table is the read-optimised
projection for cron sweeps (pickup-window safety net) and triage queries.

Idempotency: CHR does not provide an event ID, so we synthesise a natural
key from (event_type, event_time, load_number, order_number). Replay
safety relies on the unique index on idempotency_key.

Constant Summary collapse

CARRIER_REJECTION_TYPES =

Event types that signal the carrier won't fulfill (or the load was
cancelled by ops). Triggers the failure-path side effects in the
processor.

['ORDER REJECTED', 'LOAD CANCELLED', 'ORDER CANCELED'].freeze
PICKUP_MILESTONE_TYPES =

Stop-scope events confirming forward motion against the booked pickup.

['CARRIER ARRIVED', 'CARRIER DEPARTED', 'LOAD PICKED UP'].freeze
DELIVERED_TYPES =

Terminal happy-path events; the pickup-window safety sweep stops alerting
for a delivery once we have any of these.

['LOAD DELIVERED'].freeze
['ORDER CREATED', 'ORDER UPDATED'].freeze
STATUS_CODE_BY_EVENT_TYPE =

CHR event type → the shared ShipmentEvent::STATUS_CODE_LABELS vocabulary
(UN/AC/IT/DE/EX/AT/NY/SP), so freight and parcel scans can be classified by
one set of rules instead of two.

That vocabulary is already the codebase's lingua franca: the ShipEngine
parcel webhook maps into it (ShipmentEvent.parse_event), carriers that
return nil codes are recovered into it (ShipmentEvent.infer_status_code),
and the ShipEngine LTL poller writes it directly
(Shipping::ShipengineLtlTracker::STATUS_CODE_BY_LABEL). CHR was the one
producer speaking its own language — this map is what closes that gap and
lets ProblematicDeliverySweep read freight without a parallel rule set.

Every key was taken from event types actually present in freight_events,
not from CHR's docs. Unmapped types fall through to nil (not UN), so a new
CHR event type is invisible to the sweep rather than misclassified.

Notes on the non-obvious ones:

  • APPOINTMENT UPDATED is NY, not IT: CHR emits it while SCHEDULING a stop,
    before the carrier has the freight (FreightEventStatusSummary likewise
    buckets it as pickup-scheduled). Calling it movement would let a load
    that was never picked up read as in-transit and be flagged as an overdue
    delivery rather than a missed pickup. It is not a deviation either — see
    #deviation?.
  • CARRIER ARRIVED fires at BOTH ends of the move (50 events across 25
    deliveries), so it can only mean "in carrier custody", never "delivered".
  • ORDER COMPLETED is terminal alongside LOAD DELIVERED, matching
    FreightEventStatusSummary::DELIVERY_COMPLETED_TYPES.
{
  'LOAD CREATED' => 'NY',
  'LOAD BOOKED' => 'NY',
  'PRO NUMBER ADDED' => 'NY',
  'LOAD PICKED UP' => 'AC',
  'CARRIER ARRIVED' => 'AC',
  'CARRIER DEPARTED' => 'AC',
  'IN TRANSIT' => 'IT',
  'APPOINTMENT UPDATED' => 'NY',
  # NY, not IT, despite the name: this is the TRUCK moving to the pickup
  # location, not the freight moving. On DE797517 it lands at 13:32, an hour
  # before CARRIER ARRIVED (16:31) and four before LOAD PICKED UP (17:29) —
  # we don't have the freight yet. Mapping it to a movement code would fake
  # custody before pickup and pull loads that never shipped out of
  # {MissedFreightPickupSweep}'s hands into the delivery sweep's.
  'IN TRANSIT TO ORIGIN' => 'NY',
  'LOAD DELIVERED' => 'DE',
  'ORDER COMPLETED' => 'DE',
  'LOAD CANCELLED' => 'EX',
  'ORDER CANCELED' => 'EX',
  'ORDER REJECTED' => 'EX'
}.freeze

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#event_timeObject (readonly)

Validates event time.

Validations:



112
# File 'app/models/freight_event.rb', line 112

validates :event_time, presence: true

#event_typeObject (readonly)

Validates event type.

Validations:



110
# File 'app/models/freight_event.rb', line 110

validates :event_type, presence: true

#idempotency_keyObject (readonly)

Validates idempotency key.

Validations:



114
# File 'app/models/freight_event.rb', line 114

validates :idempotency_key, presence: true, uniqueness: true

Class Method Details

.carrier_rejectionsActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are carrier rejections. Active Record Scope

Returns:

See Also:



120
# File 'app/models/freight_event.rb', line 120

scope :carrier_rejections, -> { where(event_type: CARRIER_REJECTION_TYPES) }

.extract_load_number(event) ⇒ Object

LOAD-scope events expose loadNumber (scalar); ORDER-scope events use
loadNumbers (array). Same value when an order has one load.

Parameters:

  • event (Object)

    the event



171
172
173
174
175
176
# File 'app/models/freight_event.rb', line 171

def self.extract_load_number(event)
  scalar = event['loadNumber']
  return scalar.to_i if scalar.present?

  Array(event['loadNumbers']).compact.first&.to_i
end

.extract_navisphere_tracking_number(event) ⇒ Object

Extract navisphere tracking number.

Parameters:

  • event (Object)

    the event



267
268
269
270
# File 'app/models/freight_event.rb', line 267

def self.extract_navisphere_tracking_number(event)
  details = Array(event['orderDetails']).presence || Array(event['orderDetail']).presence
  details&.first&.dig('navisphereTrackingNumber').presence
end

.extract_order_number(event) ⇒ Object

LOAD-scope events use orderDetails (plural array); ORDER-scope events
use orderDetail (singular array). Both contain objects with
orderNumber.

Parameters:

  • event (Object)

    the event



182
183
184
185
# File 'app/models/freight_event.rb', line 182

def self.extract_order_number(event)
  details = Array(event['orderDetails']).presence || Array(event['orderDetail']).presence
  details&.first&.dig('orderNumber')&.to_i
end

.for_loadActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are for load. Active Record Scope

Returns:

See Also:



116
# File 'app/models/freight_event.rb', line 116

scope :for_load, ->(load_number) { where(load_number: load_number) }

.for_orderActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are for order. Active Record Scope

Returns:

See Also:



117
# File 'app/models/freight_event.rb', line 117

scope :for_order, ->(order_number) { where(order_number: order_number) }

.idempotency_key_for(event_type:, event_time:, load_number: nil, order_number: nil) ⇒ String

Build the natural-key string used for replay-safe upserts.

Parameters:

  • event_type (String)
  • event_time (Time, String)
  • load_number (Integer, nil) (defaults to: nil)
  • order_number (Integer, nil) (defaults to: nil)

Returns:

  • (String)


132
133
134
135
136
137
138
139
140
# File 'app/models/freight_event.rb', line 132

def self.idempotency_key_for(event_type:, event_time:, load_number: nil, order_number: nil)
  time = event_time.is_a?(String) ? Time.zone.parse(event_time) : event_time
  time_str = time.utc.iso8601(6)
  # join already stringifies each element, and nil renders as '' either way,
  # so this produces byte-identical keys to the previous map(&:to_s).join —
  # which matters, because a changed key format would re-insert every event
  # CHR has ever replayed to us as a duplicate.
  [event_type, time_str, load_number, order_number].join('|')
end

.of_typeActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are of type. Active Record Scope

Returns:

See Also:



118
# File 'app/models/freight_event.rb', line 118

scope :of_type, ->(types) { where(event_type: types) }

.parse_payload(payload) ⇒ Hash

Parse one Navisphere event-callback payload into the attribute hash this
model expects. CHR's shape varies by scope (LOAD vs ORDER vs APPOINTMENT
events use different singular/plural keys), so the helpers below
normalise across all variants seen on DE787078.

Parameters:

  • payload (Hash)

    the full event-callback body

Returns:

  • (Hash)

    FreightEvent attribute hash; event_time stays a string
    for AR coercion, payload is the inner event object only.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'app/models/freight_event.rb', line 150

def self.parse_payload(payload)
  event = payload['event'] || {}
  {
    event_type: event['eventType'].to_s,
    event_sub_type: event['eventSubType'],
    event_time: payload['eventTime'],
    emitted_at: payload['time'],
    load_number: extract_load_number(event),
    order_number: extract_order_number(event),
    carrier_scac: event.dig('carrier', 'scac'),
    carrier_name: event.dig('carrier', 'name'),
    pro_number: event.dig('carrier', 'proNumber'),
    customer_reference_number: payload['customerReferenceNumber'].presence,
    navisphere_tracking_number: extract_navisphere_tracking_number(event),
    payload: event
  }
end

.pickup_milestonesActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are pickup milestones. Active Record Scope

Returns:

See Also:



121
# File 'app/models/freight_event.rb', line 121

scope :pickup_milestones, -> { where(event_type: PICKUP_MILESTONE_TYPES) }

.sinceActiveRecord::Relation<FreightEvent>

A relation of FreightEvents that are since. Active Record Scope

Returns:

See Also:



119
# File 'app/models/freight_event.rb', line 119

scope :since, ->(time) { where(event_time: time..) }

Instance Method Details

#city_localitynil

Returns CHR events carry no scan city.

Returns:

  • (nil)

    CHR events carry no scan city.



251
# File 'app/models/freight_event.rb', line 251

def city_locality; end

#delivered?Boolean

Returns terminal delivered event.

Returns:

  • (Boolean)

    terminal delivered event.



209
210
211
# File 'app/models/freight_event.rb', line 209

def delivered?
  ShipmentEvent::DELIVERED_STATUS_CODES.include?(status_code)
end

#deliveryDelivery?

Returns the delivery this record belongs to.

Returns:

  • (Delivery, nil)

    the delivery this record belongs to



105
# File 'app/models/freight_event.rb', line 105

belongs_to :delivery, inverse_of: :freight_events, optional: true

#descriptionString

Returns human-facing event text.

Returns:

  • (String)

    human-facing event text.



241
242
243
# File 'app/models/freight_event.rb', line 241

def description
  [event_type, event_sub_type].compact_blank.join('')
end

#deviation?Boolean

CHR has no deviation signal, so freight overdue rests purely on silence
(ProblematicDeliverySweep::FREIGHT_TRAIL_HEALTHY_WINDOW).

APPOINTMENT UPDATED was the obvious candidate — a rescheduled load reads
like the freight counterpart of the parcel reroute/delay scans — and the
data refutes it: ALL 27 loads carry at least one (6 have 1, 12 have 2, 5
have 3, 3 have 4, 1 has 8), and 21 of 27 delivered normally, including the
one with 8. It is routine appointment scheduling, not trouble. Treating it
as a deviation would have flagged every in-transit load — the same trap as
the rejected UPS event_code == "X" rule, whose commonest description is
a benign "On the Way".

Returns:

  • (Boolean)

    always false; see above.



231
232
233
# File 'app/models/freight_event.rb', line 231

def deviation?
  false
end

#exception?Boolean

Returns the load was cancelled or the carrier rejected it.

Returns:

  • (Boolean)

    the load was cancelled or the carrier rejected it.



214
215
216
# File 'app/models/freight_event.rb', line 214

def exception?
  ShipmentEvent::EXCEPTION_STATUS_CODES.include?(status_code)
end

#occurred_atTime?

Returns CHR's event timestamp, under the shared scan name.

Returns:

  • (Time, nil)

    CHR's event timestamp, under the shared scan name.



198
199
200
# File 'app/models/freight_event.rb', line 198

def occurred_at
  event_time
end

#return_to_sender?Boolean

Freight never delivers to a collection location — the parcel
returned-to-sender trap ProblematicDeliverySweep.exception? guards
against has no freight analogue, so this is always false.

Returns:

  • (Boolean)


261
262
263
# File 'app/models/freight_event.rb', line 261

def return_to_sender?
  false
end

#state_provincenil

Returns CHR events carry no scan state.

Returns:

  • (nil)

    CHR events carry no scan state.



254
# File 'app/models/freight_event.rb', line 254

def state_province; end

#status_codeString?

Returns a ShipmentEvent::STATUS_CODE_LABELS key, or nil for
an event type we have never seen and so deliberately do not classify.

Returns:



204
205
206
# File 'app/models/freight_event.rb', line 204

def status_code
  STATUS_CODE_BY_EVENT_TYPE[event_type]
end

#status_descriptionString?

Returns the shared vocabulary's label for #status_code.

Returns:

  • (String, nil)

    the shared vocabulary's label for #status_code.



246
247
248
# File 'app/models/freight_event.rb', line 246

def status_description
  ShipmentEvent::STATUS_CODE_LABELS[status_code]
end

#webhook_logWebhookLog?

Returns the webhook log this record belongs to.

Returns:

  • (WebhookLog, nil)

    the webhook log this record belongs to



107
# File 'app/models/freight_event.rb', line 107

belongs_to :webhook_log, optional: true