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.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 178

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



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

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



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 139

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)



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 88

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 } }



119
120
121
122
123
124
125
126
127
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 119

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)


251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 251

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/AbcSize,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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 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/AbcSize,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

    # Same as the regular add-to-cart path (Spree::Cart::Recalculate): activate eligible
    # promotions first and persist their totals, then (re)compute tax on the now-discounted
    # base — both batched once for the whole order instead of relying on LineItem's per-item
    # tax callbacks. The interim update_with_updater! is required: TaxRate.adjust computes
    # each item's taxable base off its `taxable_adjustment_total` column, which only gets
    # (re)persisted by OrderUpdater#recalculate_adjustments — without it tax would still be
    # computed pre-discount. The reload is required too: recalculate_adjustments persists
    # those columns onto adjustable objects it fetches itself (via all_adjustments), not onto
    # the LineItem instances already loaded in order.line_items, which create_tax_charge! reads.
    ::Spree::PromotionHandler::Cart.new(order).activate
    order.update_with_updater!
    order.line_items.reload
    order.create_tax_charge!

    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



240
241
242
243
244
245
246
247
248
249
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 240

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.



131
132
133
134
135
136
137
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 131

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



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

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.



202
203
204
205
206
207
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 202

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