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, created_by: nil, include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL) ⇒ Object
rubocop:disable Metrics/ParameterLists.
-
#create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return, created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL) ⇒ 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.
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 184 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
232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 232 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
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 145 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)
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 94 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 } }
125 126 127 128 129 130 131 132 133 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 125 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
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 257 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, created_by: nil, include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL) ⇒ Object
rubocop:disable Metrics/ParameterLists
19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 19 def call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, created_by: nil, # rubocop:disable Metrics/ParameterLists include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL ) 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, created_by: created_by, channel: channel ) success(order: order) rescue StandardError => e failure(nil, e.) end end |
#create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return, created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL) ⇒ Object
rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists
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 78 79 80 81 82 83 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 34 def create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return, # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL ) order = Spree::Order.new(state: 'cart', use_billing: true, user: user, created_by: created_by, channel: channel) 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
246 247 248 249 250 251 252 253 254 255 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 246 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.
137 138 139 140 141 142 143 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 137 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
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 215 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.
208 209 210 211 212 213 |
# File 'app/services/spree_cm_commissioner/transit_order/create.rb', line 208 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 |