Class: FreightEvent
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- FreightEvent
- 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
- NAVISPHERE_NOISE_EVENT_TYPES =
CHR's Navisphere runs two parallel subsystems (load mgmt + order mgmt)
that emit independently. The load subsystem is the operationally
meaningful one — its events (LOAD CREATED/BOOKED/PICKED UP/DELIVERED/
CANCELLED, APPOINTMENT UPDATED, PRO NUMBER ADDED) drive every workflow
we care about. The order subsystem replays the same booking ~20 sec
later as a billing/reference wrapper; ORDER CREATED + ORDER UPDATED
carry no carrier, no items, no locations — justloadNumberspointing
back to the load events we already processed. They confuse the Freight
Events tab (ORDER CREATED appears AFTER LOAD BOOKED, which is CHR's
actual lifecycle but reads as nonsense) and trigger no side effect
anywhere in the codebase.The OTHER ORDER-* events are NOT noise: ORDER REJECTED + ORDER CANCELED
drive carrier-rejection alerts and void confirmation; ORDER COMPLETED
is a terminal-delivery signal in FreightEventStatusSummary. Only the
two below are pure replication chatter.Filtered at the FreightquoteProcessor entry point — the raw payload
still lives in webhook_logs.data, so if CHR ever attaches meaning to
these we can revisit without losing history. ['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.
- APPOINTMENT UPDATED is NY, not IT: CHR emits it while SCHEDULING a stop,
{ '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
-
#event_time ⇒ Object
readonly
Validates event time.
-
#event_type ⇒ Object
readonly
Validates event type.
-
#idempotency_key ⇒ Object
readonly
Validates idempotency key.
Belongs to collapse
-
#delivery ⇒ Delivery?
The delivery this record belongs to.
-
#webhook_log ⇒ WebhookLog?
The webhook log this record belongs to.
Class Method Summary collapse
-
.carrier_rejections ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are carrier rejections.
-
.extract_load_number(event) ⇒ Object
LOAD-scope events expose
loadNumber(scalar); ORDER-scope events useloadNumbers(array). -
.extract_navisphere_tracking_number(event) ⇒ Object
Extract navisphere tracking number.
-
.extract_order_number(event) ⇒ Object
LOAD-scope events use
orderDetails(plural array); ORDER-scope events useorderDetail(singular array). -
.for_load ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are for load.
-
.for_order ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are for order.
-
.idempotency_key_for(event_type:, event_time:, load_number: nil, order_number: nil) ⇒ String
Build the natural-key string used for replay-safe upserts.
-
.of_type ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are of type.
-
.parse_payload(payload) ⇒ Hash
Parse one Navisphere event-callback payload into the attribute hash this model expects.
-
.pickup_milestones ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are pickup milestones.
-
.since ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are since.
Instance Method Summary collapse
-
#city_locality ⇒ nil
CHR events carry no scan city.
-
#delivered? ⇒ Boolean
Terminal delivered event.
-
#description ⇒ String
Human-facing event text.
-
#deviation? ⇒ Boolean
CHR has no deviation signal, so freight overdue rests purely on silence (ProblematicDeliverySweep::FREIGHT_TRAIL_HEALTHY_WINDOW).
-
#exception? ⇒ Boolean
The load was cancelled or the carrier rejected it.
-
#occurred_at ⇒ Time?
CHR's event timestamp, under the shared scan name.
-
#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.
-
#state_province ⇒ nil
CHR events carry no scan state.
-
#status_code ⇒ String?
A ShipmentEvent::STATUS_CODE_LABELS key, or nil for an event type we have never seen and so deliberately do not classify.
-
#status_description ⇒ String?
The shared vocabulary's label for #status_code.
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#event_time ⇒ Object (readonly)
Validates event time.
Validations:
112 |
# File 'app/models/freight_event.rb', line 112 validates :event_time, presence: true |
#event_type ⇒ Object (readonly)
Validates event type.
Validations:
110 |
# File 'app/models/freight_event.rb', line 110 validates :event_type, presence: true |
#idempotency_key ⇒ Object (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_rejections ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are carrier rejections. Active Record Scope
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.
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.
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.
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_load ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are for load. Active Record Scope
116 |
# File 'app/models/freight_event.rb', line 116 scope :for_load, ->(load_number) { where(load_number: load_number) } |
.for_order ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are for order. Active Record Scope
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.
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_type ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are of type. Active Record Scope
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.
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_milestones ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are pickup milestones. Active Record Scope
121 |
# File 'app/models/freight_event.rb', line 121 scope :pickup_milestones, -> { where(event_type: PICKUP_MILESTONE_TYPES) } |
.since ⇒ ActiveRecord::Relation<FreightEvent>
A relation of FreightEvents that are since. Active Record Scope
119 |
# File 'app/models/freight_event.rb', line 119 scope :since, ->(time) { where(event_time: time..) } |
Instance Method Details
#city_locality ⇒ nil
Returns 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.
209 210 211 |
# File 'app/models/freight_event.rb', line 209 def delivered? ShipmentEvent::DELIVERED_STATUS_CODES.include?(status_code) end |
#delivery ⇒ Delivery?
Returns the delivery this record belongs to.
105 |
# File 'app/models/freight_event.rb', line 105 belongs_to :delivery, inverse_of: :freight_events, optional: true |
#description ⇒ String
Returns 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".
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.
214 215 216 |
# File 'app/models/freight_event.rb', line 214 def exception? ShipmentEvent::EXCEPTION_STATUS_CODES.include?(status_code) end |
#occurred_at ⇒ Time?
Returns 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.
261 262 263 |
# File 'app/models/freight_event.rb', line 261 def return_to_sender? false end |
#state_province ⇒ nil
Returns CHR events carry no scan state.
254 |
# File 'app/models/freight_event.rb', line 254 def state_province; end |
#status_code ⇒ String?
Returns a ShipmentEvent::STATUS_CODE_LABELS key, or nil for
an event type we have never seen and so deliberately do not classify.
204 205 206 |
# File 'app/models/freight_event.rb', line 204 def status_code STATUS_CODE_BY_EVENT_TYPE[event_type] end |
#status_description ⇒ String?
Returns 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_log ⇒ WebhookLog?
Returns the webhook log this record belongs to.
107 |
# File 'app/models/freight_event.rb', line 107 belongs_to :webhook_log, optional: true |