Class: SpreeCmCommissioner::TransitOrder::Create
- Inherits:
-
Object
- Object
- SpreeCmCommissioner::TransitOrder::Create
- Includes:
- Spree::ServiceModule::Base
- Defined in:
- app/services/spree_cm_commissioner/transit_order/create.rb
Instance Method Summary collapse
-
#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.
- #build_guests_for!(line_item, seat_selections) ⇒ Object
- #build_line_items_for!(leg, order, date, connected_trip_id = nil) ⇒ Object
-
#build_line_items_for_legs!(order:, legs:, initial_date:) ⇒ Object
Build line items for each leg.
-
#build_parent_stops_map(legs) ⇒ Object
Builds a mapping of leg trip_id => { sequence:, offset_days: } from the parent trip's branch stops.
- #calculate_line_item_duration!(date:, departure_time:, arrival_time:, departure_offset_days: 0, arrival_offset_days: 0) ⇒ Object
-
#call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, include_return: false) ⇒ Object
rubocop:disable Metrics/ParameterLists.
-
#create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return) ⇒ Object
rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists.
- #insert_saved_guests_per_line_items_leg(line_items) ⇒ Object
-
#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.
- #resolve_leg_dates(trip, leg, date) ⇒ Object
-
#validity_window(addon, outbound_date) ⇒ Object
[valid_from, valid_until] for the entitlement.
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
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.) 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..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 |