Class: SpreeCmCommissioner::TransitOrder::Create

Inherits:
Object
  • Object
show all
Includes:
Spree::ServiceModule::Base
Defined in:
app/services/spree_cm_commissioner/transit_order/create.rb

Instance Method Summary collapse

Instance Method Details

#add_return_line_item!(order, outbound_legs, quantity, outbound_date) ⇒ Object

Attach the outbound trip’s open-return add-on (a plain ecommerce product) as a regular line item. The add-on master is backorderable, so no inventory wiring is needed.

The entitlement stays redeemable for the add-on’s open_dated_validity_days (default 90), counted from the outbound journey date; that window is stored as valid_until (to_date) so LineItem#expired? can enforce it. Redemption later overwrites these with the real trip dates.

We persist the return journey’s endpoints (already reversed from the outbound trip) so redemption can match a chosen return trip directly, with no flip and no trip/route load. Setup is single-leg today, so the endpoints come straight off the outbound trip; when multi-leg outbound is supported, the return origin is the last leg’s drop-off place and the return destination is the first leg’s boarding place.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 164

def add_return_line_item!(order, outbound_legs, quantity, outbound_date)
  return if quantity <= 0
  return unless outbound_legs.size == 1

  trip = @preloaded_trips[outbound_legs.first.trip_id] || SpreeCmCommissioner::Trip.find(outbound_legs.first.trip_id)
  addon = trip.open_dated_product
  return if addon.nil?

  from_date, to_date = validity_window(addon, outbound_date)

  order.line_items.new(
    product_type: :ecommerce,
    variant_id: addon.master.id,
    quantity: quantity,
    from_date: from_date,
    to_date: to_date,
    open_dated_product_id: addon.id,
    open_dated_return_origin_place_id: trip.destination_place_id,
    open_dated_return_destination_place_id: trip.origin_place_id
  )
end

#build_guests_for!(line_item, seat_selections) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 212

def build_guests_for!(line_item, seat_selections)
  block_ids = seat_selections.flat_map(&:block_ids)

  if block_ids.any? && block_ids.size != line_item.quantity
    raise StandardError, "Number of blocks (#{block_ids.size}) does not match quantity (#{line_item.quantity})"
  end

  Array.new(line_item.quantity) do |index|
    line_item.guests.new(block_id: block_ids[index])
  end

  line_item
end

#build_line_items_for!(leg, order, date, connected_trip_id = nil) ⇒ Object



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

def build_line_items_for!(leg, order, date, connected_trip_id = nil)
  trip = @preloaded_trips[leg.trip_id] || SpreeCmCommissioner::Trip.find(leg.trip_id)
  from_date, to_date = resolve_leg_dates(trip, leg, date)

  leg.seat_selections.group_by(&:variant_id).map do |variant_id, seat_selections|
     = {
      direction: leg.direction,
      trip_id: leg.trip_id.to_s,
      boarding_trip_stop_id: leg.boarding_trip_stop_id&.to_s,
      drop_off_trip_stop_id: leg.drop_off_trip_stop_id&.to_s,
      connected_trip_id: connected_trip_id.presence&.to_s,
      service_origin_id: trip.service_origin_id.presence&.to_s
    }.compact

    line_item = order.line_items.new(
      product_type: :transit,
      from_date: from_date,
      to_date: to_date,
      variant_id: variant_id,
      quantity: seat_selections.sum(&:quantity),
      private_metadata: 
    )

    build_guests_for!(line_item, seat_selections)
  end
end

#build_line_items_for_legs!(order:, legs:, initial_date:) ⇒ Object

Build line items for each leg. Legs arrive in sometimes not by order from the client, so we sort by the parent trip’s branch stop sequence to ensure chronological processing. Each leg’s date is computed via offset_days from initial_date; falls back to chaining from the previous leg’s to_date for direct (single-leg) trips.

Example: “Phnom Penh → Siem Reap → Poipet”, initial_date = 2026-04-01

input:  [leg(trip=201, seq=2), leg(trip=200, seq=1)]
sorted: [leg(trip=200, seq=1), leg(trip=201, seq=2)]
dates:  trip 200 → 2026-04-01 (offset 0), trip 201 → 2026-04-02 (offset 1)


74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 74

def build_line_items_for_legs!(order:, legs:, initial_date:)
  all_line_items = []
  parent_stops_map = build_parent_stops_map(legs)
  current_leg_date = initial_date

  # Sort legs by parent trip's branch stop sequence to ensure chronological order.
  sorted_legs = parent_stops_map.any? ? legs.sort_by { |leg| parent_stops_map.dig(leg.trip_id, :sequence) || 0 } : legs

  sorted_legs.each do |leg|
    connected_trip_id = leg.main_trip_id.to_s if leg.main_trip_id.present?
    offset_days = parent_stops_map.dig(leg.trip_id, :offset_days)
    leg_date = offset_days.present? ? initial_date + offset_days.days : current_leg_date
    leg_line_items = build_line_items_for!(leg, order, leg_date, connected_trip_id)
    leg_line_items = insert_saved_guests_per_line_items_leg(leg_line_items)

    all_line_items.concat(leg_line_items)
    current_leg_date = leg_line_items.last.to_date.to_date if leg_line_items.any?
  end

  all_line_items
end

#build_parent_stops_map(legs) ⇒ Object

Builds a mapping of leg trip_id => { sequence:, offset_days: } from the parent trip’s branch stops. For direct trips (no main_trip_id), returns an empty hash (falls back to date chaining).

Example: parent trip 100 has branch stops pointing to child trips:

TripStop(trip_id: 100, board_to_trip_id: 200, sequence: 1, offset_days: 0)
TripStop(trip_id: 100, board_to_trip_id: 201, sequence: 2, offset_days: 1)

Result: { 200 => { sequence: 1, offset_days: 0 },

201 => { sequence: 2, offset_days: 1 } }


105
106
107
108
109
110
111
112
113
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 105

def build_parent_stops_map(legs)
  parent_trip_id = legs.first&.main_trip_id
  return {} if parent_trip_id.blank?

  SpreeCmCommissioner::TripStop
    .where(trip_id: parent_trip_id, board_to_trip_id: legs.map(&:trip_id))
    .pluck(:board_to_trip_id, :sequence, :offset_days)
    .to_h { |trip_id, sequence, offset_days| [trip_id, { sequence: sequence, offset_days: offset_days }] }
end

#calculate_line_item_duration!(date:, departure_time:, arrival_time:, departure_offset_days: 0, arrival_offset_days: 0) ⇒ Object

Raises:

  • (StandardError)


237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 237

def calculate_line_item_duration!(date:, departure_time:, arrival_time:, departure_offset_days: 0, arrival_offset_days: 0)
  raise StandardError, 'Departure or arrival time in trip stop is missing' if departure_time.blank? || arrival_time.blank?

  dep_offset = departure_offset_days.to_i
  arr_offset = arrival_offset_days.to_i

  # Build exact departure datetime using departure offset
  dep_date = date + dep_offset.days
  from_date = Time.zone.local(dep_date.year, dep_date.month, dep_date.day, departure_time.hour, departure_time.min, departure_time.sec)

  # First, trust the actual time-of-day/datetime difference stored in the stop record.
  # This handles the common case where `arrival_time` already rolls to the next day.
  duration_seconds = arrival_time - departure_time
  to_date = from_date + duration_seconds.seconds

  # If arrival_time is still earlier than departure_time (clock-only mismatch),
  # correct using the provided offset-days difference.
  to_date += (arr_offset - dep_offset).days if to_date < from_date

  raise StandardError, 'Arrival time cannot be before departure time' if to_date < from_date

  [from_date, to_date]
end

#call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, include_return: false) ⇒ Object

rubocop:disable Metrics/ParameterLists



19
20
21
22
23
24
25
26
27
28
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 19

def call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, include_return: false) # rubocop:disable Metrics/ParameterLists
  return failure(nil, 'Outbound legs are missing') if outbound_legs.blank?

  begin
    order = create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return)
    success(order: order)
  rescue StandardError => e
    failure(nil, e.message)
  end
end

#create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 30

def create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists
  order = Spree::Order.new(state: 'cart', use_billing: true, user: user)

  all_legs = outbound_legs + inbound_legs
  preload_trips_and_stops!(all_legs)

  outbound_line_items = build_line_items_for_legs!(order: order, legs: outbound_legs, initial_date: outbound_date)
  inbound_line_items = inbound_legs.blank? ? [] : build_line_items_for_legs!(order: order, legs: inbound_legs, initial_date: inbound_date)

  # For connected trips, sum of quantities will be (passengers * legs).
  # We need to ensure the number of passengers per leg matching between outbound and inbound.
  outbound_qty_per_leg = outbound_legs.any? ? outbound_line_items.sum(&:quantity) / outbound_legs.size : 0
  inbound_qty_per_leg = inbound_legs.any? ? inbound_line_items.sum(&:quantity) / inbound_legs.size : 0

  if inbound_line_items.any? && outbound_qty_per_leg != inbound_qty_per_leg
    raise StandardError, "Outbound & inbound passenger counts do not match (#{outbound_qty_per_leg} vs #{inbound_qty_per_leg})"
  end

  add_return_line_item!(order, outbound_legs, outbound_qty_per_leg, outbound_date) if include_return

  order.preload_trip_ids = (outbound_legs.map(&:trip_id) + inbound_legs.map(&:trip_id)).flatten.uniq
  order.preload_main_trip_ids = (outbound_legs.map(&:main_trip_id) + inbound_legs.map(&:main_trip_id)).flatten.compact.uniq

  ActiveRecord::Base.transaction do
    raise StandardError, order.errors.full_messages.to_sentence unless order.save

    order.update_with_updater!

    # Move the order from 'cart' to 'address' & hold the selected seats.
    order.next!
  end

  order
end

#insert_saved_guests_per_line_items_leg(line_items) ⇒ Object



226
227
228
229
230
231
232
233
234
235
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 226

def insert_saved_guests_per_line_items_leg(line_items)
  line_items.flat_map(&:guests).each_with_index do |guest, index|
    @saved_guests ||= []
    @saved_guests << SpreeCmCommissioner::SavedGuest.new if @saved_guests[index].blank?

    guest.saved_guest = @saved_guests[index]
  end

  line_items
end

#preload_trips_and_stops!(legs) ⇒ Object

Preloads all trips and their relevant trip stops in 2 queries total, avoiding N+1 queries in build_line_items_for! and resolve_leg_dates.



117
118
119
120
121
122
123
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 117

def preload_trips_and_stops!(legs)
  trip_ids = legs.map(&:trip_id).uniq
  @preloaded_trips = SpreeCmCommissioner::Trip.where(id: trip_ids).index_by(&:id)

  stop_ids = legs.flat_map { |l| [l.boarding_trip_stop_id, l.drop_off_trip_stop_id] }.compact.uniq
  @preloaded_trip_stops = SpreeCmCommissioner::TripStop.where(id: stop_ids).index_by(&:id)
end

#resolve_leg_dates(trip, leg, date) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 195

def resolve_leg_dates(trip, leg, date)
  boarding_stop = @preloaded_trip_stops[leg.boarding_trip_stop_id] if leg.boarding_trip_stop_id.present?
  drop_off_stop = @preloaded_trip_stops[leg.drop_off_trip_stop_id] if leg.drop_off_trip_stop_id.present?

  # Fallback to querying trip stops if IDs were not provided
  boarding_stop ||= trip.trip_stops.first
  drop_off_stop ||= trip.trip_stops.last

  calculate_line_item_duration!(
    date: date,
    departure_time: boarding_stop&.departure_time,
    arrival_time: drop_off_stop&.arrival_time,
    departure_offset_days: boarding_stop&.offset_days.to_i,
    arrival_offset_days: drop_off_stop&.offset_days.to_i
  )
end

#validity_window(addon, outbound_date) ⇒ Object

valid_from, valid_until

for the entitlement. Returns nils when no outbound date is given,

leaving the entitlement non-expiring rather than guessing a window.



188
189
190
191
192
193
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 188

def validity_window(addon, outbound_date)
  return [nil, nil] if outbound_date.blank?

  from_date = outbound_date.in_time_zone.beginning_of_day
  [from_date, from_date + addon.open_dated_validity_days.days]
end