Class: Quote::ConvertToOrder

Inherits:
Object
  • Object
show all
Defined in:
app/services/quote/convert_to_order.rb

Overview

Service object: convert to order.

Defined Under Namespace

Classes: QuoteUnpurchasable

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(quote, options = {}) ⇒ ConvertToOrder

Returns a new instance of ConvertToOrder.

Parameters:

  • quote (Quote)

    the quote to convert

  • options (Hash) (defaults to: {})

    configurable options

Options Hash (options):

  • logger (Logger)

    logger to use (defaults to Rails.logger)



13
14
15
16
17
18
# File 'app/services/quote/convert_to_order.rb', line 13

def initialize(quote, options = {})
  @quote = quote
  @options = options
  @logger = options[:logger] || Rails.logger
  @errors = []
end

Instance Attribute Details

#errorsObject (readonly)

Returns the value of attribute errors.



5
6
7
# File 'app/services/quote/convert_to_order.rb', line 5

def errors
  @errors
end

#loggerObject (readonly)

Returns the value of attribute logger.



5
6
7
# File 'app/services/quote/convert_to_order.rb', line 5

def logger
  @logger
end

#optionsObject (readonly)

Returns the value of attribute options.



5
6
7
# File 'app/services/quote/convert_to_order.rb', line 5

def options
  @options
end

#quoteObject (readonly)

Returns the value of attribute quote.



5
6
7
# File 'app/services/quote/convert_to_order.rb', line 5

def quote
  @quote
end

Class Method Details

.can_convert?(quote) ⇒ Boolean

Returns:

  • (Boolean)


20
21
22
# File 'app/services/quote/convert_to_order.rb', line 20

def self.can_convert?(quote)
  new(quote).can_convert?
end

.can_convert_errors?(quote) ⇒ Boolean

Returns:

  • (Boolean)


24
25
26
# File 'app/services/quote/convert_to_order.rb', line 24

def self.can_convert_errors?(quote)
  new(quote).run_pre_check
end

.check_quote_line_items_room_integrity(quote, item_map: {}) ⇒ Object

Check if all quote line items belonging to a room have the same exact items and quantities as the room configuration's line items

item_map (old item_id => successor item_id) folds successor pairs into one
identity before comparing, so a quote on the discontinued item still matches
a room that has already moved to the successor — which happens the moment a
sibling quote sharing the room converts (see #substitute_room_lines).



100
101
102
103
104
105
106
107
108
109
110
# File 'app/services/quote/convert_to_order.rb', line 100

def self.check_quote_line_items_room_integrity(quote, item_map: {})
  normalize = ->(pairs) { pairs.map { |item_id, quantity| [item_map.fetch(item_id, item_id), quantity] }.sort }
  errors = []
  quote.room_configurations.each do |room|
    room_line_items = normalize.call(room.line_items.pluck(:item_id, :quantity))
    quote_line_items = normalize.call(quote.line_items.where(room_configuration_id: room.id).pluck(:item_id, :quantity))

    errors << "Room configuration #{room.reference_number} does not match quote line items linked to same room" if room_line_items != quote_line_items
  end
  errors
end

Instance Method Details

#append_core_data(order) ⇒ Object



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'app/services/quote/convert_to_order.rb', line 169

def append_core_data(order)
  order.opportunity = @quote.opportunity
  order.shipping_method = @quote.shipping_method
  # order.shipping_account_number = (@quote.chosen_shipping_method.shipping_account_number rescue nil)
  # order.shipping_cost = @quote.shipping_cost
  if @quote.contact.present?
    order.contact_id = @quote.contact.id
    if @quote.contact.active_spiff_enrollment.present? && @quote.contact.active_spiff_enrollment.is_active?
      order.spiff_rep_id = @quote.contact.id
      order.spiff_enrollment = @quote.contact.active_spiff_enrollment
    end
  end
  order.shipping_address = @quote.shipping_address # This is NOT necessary and potentially problematic || @quote.customer.shipping_address

  # Always inherit the customer's pricing program
  order.pricing_program_discount = @quote.customer.pricing_program_discount
  order.pricing_program_description = @quote.customer.pricing_program_description
  order.max_discount_override = @quote.max_discount_override
  order.override_coupon_date = @quote.override_coupon_date
  order.override_coupon_date_without_limits = @quote.override_coupon_date_without_limits
  order.customer ||= @quote.opportunity.customer
  order.currency ||= @quote.currency
  order.buying_group ||= @quote.buying_group
  order.single_origin = @quote.single_origin
  order.bill_shipping_to_customer = @quote.bill_shipping_to_customer
  order.saturday_delivery = @quote.saturday_delivery
  order.signature_confirmation = @quote.signature_confirmation
  order.ltl_freight = @quote.ltl_freight
  order.ltl_freight_guaranteed = @quote.ltl_freight_guaranteed
  order.quote = @quote
  # Set defaults tracking email
  order.set_default_tracking_email(force: true)

  order.source = @quote.opportunity.source
  order.rma = @quote.rma
  order.override_line_lock = @quote.override_line_lock
  order.tax_exempt = @quote.tax_exempt unless @quote.tax_exempt.nil?
end

#append_deliveries(order) ⇒ Object



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'app/services/quote/convert_to_order.rb', line 362

def append_deliveries(order)
  return if order.deliveries.present?

  # If our original quote delivery is not complete, then simply recalculate

  need_refresh = @quote.line_items.size != @quote.deliveries.sum { |d| d.line_items.size }
  ErrorReporting.warning("Quote line items do not match its deliveries while converting quote: #{@quote.reference_number}, ID: #{@quote.id}, refreshing deliveries before conversion to order!") if need_refresh

  if need_refresh || @quote.deliveries.any? { |dq| dq.shipping_costs.empty? }
    # Full refresh for our order
    logger.info 'Empty shipping cost, refreshing deliveries'
    order.refresh_deliveries_quoting(true)
    logger.debug("Refresh Deliveries complete", delivery_count: @quote.deliveries.size)
  else # Iterate and copy
    @quote.deliveries.each do |dq|
      new_dq_attrs = dq.attributes.dup.symbolize_keys.except(:id, :quote_id, :selected_shipping_cost_id)
      logger.debug("Copying delivery", delivery_id: dq.id, description: dq.delivery_description)
      ndq = order.deliveries.create! new_dq_attrs
      # Clone shipping costs
      dq.shipping_costs.each do |osc|
        logger.debug("Adding shipping cost", shipping_cost_id: osc.id)
        nsc = osc.dup
        ndq.shipping_costs << nsc
        # nsc.save!
      end
      # Update shipping line in order
      orig_shipping_line = dq.line_items.find(&:is_shipping?)
      # Remap our lines to the new delivery quotes
      order.line_items.active_lines.select { |li| li.original_delivery_id == dq.id }.each do |li|
        if li.is_shipping?
          existing_shipping_option_id = orig_shipping_line&.shipping_cost&.shipping_option_id
          li.shipping_cost = ndq.shipping_costs.find { |sc| sc.shipping_option_id == existing_shipping_option_id }
        end
        li.delivery = ndq # remap
        li.save!
      end

      # Clone shipments
      dq.shipments.each do |shp|
        logger.info "Adding shipment #{shp.id} to new delivery"
        nshp = shp.dup
        nshp.container_code = nil # do this in case of quote pre-pack assigning a container code because duplicate container codes will prevent saving - Ramie
        ndq.shipments << nshp
      end
      # Ensure selected shipping cost is remapped to new delivery
      next if dq.selected_shipping_cost&.shipping_option.blank?

      ndq.reload
      # Match the cloned rate by description too — broker sub-carriers
      # (Freightquote) share one shipping_option, so option_id alone would
      # re-link an arbitrary sub-carrier clone.
      same_option = ndq.shipping_costs.select { |sc| sc.shipping_option_id == dq.selected_shipping_cost.shipping_option_id }
      ndq.selected_shipping_cost_id = (same_option.find { |sc| sc.description_override == dq.selected_shipping_cost.description_override } || same_option.first)&.id
      ndq.save
    end
  end
  order.deliveries.size
end

#append_quote_discounts(order, line_item_map) ⇒ Object



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'app/services/quote/convert_to_order.rb', line 290

def append_quote_discounts(order, line_item_map)
  # Build a discount_id map from quote discounts to order discounts
  # We need to save discounts immediately so they have IDs for line_discounts to reference
  discount_map = {}

  @quote.discounts.active.each do |d|
    logger.info "  * Copying discount id #{d.id}"
    if order.discounts.any? { |od| od.coupon_id == d.coupon_id }
      logger.info "   * Skipping discount because coupon #{d.coupon_id} already in target discounts"
    else # only add discounts if coupon not already in there
      # Push through the association rather than `itemizable =` + `save!`: the
      # `any?` above has already loaded order.discounts, and a bare save leaves
      # that target stale. The coupon engine's deferred-persistence path
      # searches the in-memory collection only
      # (Coupon::ItemizableDiscountCalculator#find_or_build_discount_from_coupon),
      # so it would miss this row, build a duplicate for the same coupon, and
      # index_discounts_unique_per_itemizable would reject the INSERT on the
      # closing save! in #convert (AppSignal #6649). `<<` saves it and registers
      # it in the loaded target.
      copy_discount = d.dup
      order.discounts << copy_discount
      discount_map[d.id] = copy_discount.id
      logger.info "   * Created discount id #{copy_discount.id} for coupon #{d.coupon_id}"
    end
  end

  # Now copy line_discounts and map them to the new order's line items
  # This is critical - without line_discounts, the discount amounts aren't actually applied to line items
  logger.info '  * Copying line_discounts to properly apply discount amounts'
  line_item_map.each do |quote_line_item_id, order_line_item|
    quote_line_item = @quote.line_items.find { |li| li.id == quote_line_item_id }
    next unless quote_line_item

    # The shipping machinery rebuilds shipping lines while we hold this map
    # (ShipQuotable#refresh_deliveries_quoting destroys them when it re-quotes,
    # Delivery::ApplySelectedShippingCost prunes the extras), so an entry can be
    # destroyed — and therefore frozen — by the time we get here. `save!` on it
    # raises FrozenError and takes the whole conversion down (AppSignal #6647).
    # Nothing is lost: a destroyed line holds no discounts, and #convert's
    # force_total_reset save recalculates the survivors' line_discounts.
    next if order_line_item.destroyed? || order_line_item.frozen?

    # Ensure the order line item is persisted before creating child records
    # This can happen if order.save! didn't cascade properly (e.g., existing order with validation quirks)
    order_line_item.save! unless order_line_item.persisted?

    quote_line_item.line_discounts.each do |ld|
      new_discount_id = discount_map[ld.discount_id]

      if new_discount_id.blank?
        # Fallback: find or create discount on order by coupon_id
        logger.info "   * Discount #{ld.discount_id} not found in map, looking up by coupon_id #{ld.coupon_id}"
        order_discount = order.discounts.find_by(coupon_id: ld.coupon_id)

        if order_discount.blank?
          logger.info "   * Creating missing discount for coupon #{ld.coupon_id}"
          # Through the association for the same reason as the copy above.
          order_discount = order.discounts.create!(coupon_id: ld.coupon_id, amount: 0)
        end
        new_discount_id = order_discount.id
      end

      logger.info "   * Creating line_discount: line_item #{order_line_item.id}, coupon #{ld.coupon_id}, amount #{ld.amount}, discount_id #{new_discount_id}"
      order_line_item.line_discounts.create!(
        amount: ld.amount,
        coupon_id: ld.coupon_id,
        discount_id: new_discount_id
      )
    end
  end
end

#append_quote_lines(order) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/services/quote/convert_to_order.rb', line 208

def append_quote_lines(order)
  # this will copy all lines from rooms
  line_item_map = {} # Track quote line item ID => new order line item mapping
  substitutions = successor_substitutions

  logger.tagged("ConvertToOrder:#{@quote.reference_number}/#{@quote.id}") do
    logger.debug("Converting quote to order",
                 quote_id: @quote.id,
                 delivery_count: @quote.deliveries.size,
                 line_item_count: @quote.line_items.size,
                 substitutions: substitutions.size)
    @quote.line_items.parents_only.each do |li|
      logger.tagged("Line:#{li.id}") do
        li_attrs = li.attributes.dup.symbolize_keys
        logger.debug("Processing line item", line_item_id: li.id, sku: li.sku)
        li_attrs = li_attrs.except(:id, :resource_type, :resource_id,
                                   :delivery_id, :tax_total, :tax_rate,
                                   :tax_type, :resource_tax_rate_id,
                                   :original_delivery_id, :children_count, :parent_id)
        li_attrs[:original_delivery_id] = li[:delivery_id] # Super important!
        li_attrs[:delivery_id] = nil
        # FIX: Ensure discounted_price is set if it was incorrectly left at 0
        # This handles legacy line items created before the set_default_pricing fix
        li_attrs[:discounted_price] = li_attrs[:price] if li_attrs[:discounted_price].to_f.zero? && li_attrs[:price].to_f.positive?

        # Successor substitution: when a quoted item has been discontinued
        # since the quote was issued, swap to the successor's catalog_item
        # in the same catalog while keeping the quoted price.
        if (sub = substitutions[li.id])
          successor = Item.find(sub[:item_id])
          logger.info("Substituting discontinued #{li.item.sku}#{successor.sku}; preserving price=#{li_attrs[:price]} discounted_price=#{li_attrs[:discounted_price]}")
          li_attrs[:catalog_item_id] = sub[:catalog_item_id]
          li_attrs[:store_item_id]   = sub[:store_item_id]
          li_attrs[:item_id]         = sub[:item_id]
          substitute_room_lines(li, sub)
        end

        logger.debug("Created new line item", original_id: li.id)
        new_li = order.line_items.build li_attrs
        line_item_map[li.id] = new_li # Track the mapping for discount copying
      end
    end
  end

  line_item_map # Return the mapping so discounts can be properly linked
end

#blocked_discontinued_skusObject

Discontinued quote-line items whose item has no successor — OR whose
successor isn't present in the same catalog. These block conversion;
everything else is auto-substituted via #successor_substitutions.



47
48
49
# File 'app/services/quote/convert_to_order.rb', line 47

def blocked_discontinued_skus
  discontinued_lines.reject { |li| substitutable?(li) }.map { |li| li.item.sku }.uniq
end

#can_convert?Boolean

Returns:

  • (Boolean)


28
29
30
31
# File 'app/services/quote/convert_to_order.rb', line 28

def can_convert?
  run_pre_check
  @errors.blank?
end

#clear_shipping_address(order) ⇒ Object



421
422
423
424
425
426
427
428
429
430
431
# File 'app/services/quote/convert_to_order.rb', line 421

def clear_shipping_address(order)
  # remove shipping lines
  order.line_items.active_lines.each do |li|
    # must do this first because we are in before_save context, even a destroyed line item will fail the validation on order save
    li.delivery_id = nil
    li.destroy if li.is_shipping?
  end
  order.shipping_address_id = nil
  order.recalculate_shipping = false
  order.do_not_detect_shipping = true
end

#convert(existing_order = nil, txid = nil) ⇒ Object

Raises:



112
113
114
115
116
117
118
119
120
121
122
123
124
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'app/services/quote/convert_to_order.rb', line 112

def convert(existing_order = nil, txid = nil)
  logger.info "To Order method called for quote #{@quote.id}, txid: #{txid}"

  raise QuoteUnpurchasable, @errors.to_sentence.capitalize unless can_convert?

  order = existing_order || Order.new

  order.transaction do
    # This will save our petunias if a double submit happens from the same link, database will enforce Referential integrity
    order.txid = txid

    append_core_data(order)

    order.do_not_set_totals = true # Don't do any fancy calculation yet, need our discounts still

    order.save!

    line_item_map = append_quote_lines(order)

    order.do_not_set_totals = true # Don't do any fancy calculation yet

    order.save!

    existing_rc_ids = order.room_configuration_ids.to_set
    new_rooms = @quote.room_configurations.reject { |rc| existing_rc_ids.include?(rc.id) }
    order.room_configurations += new_rooms if new_rooms.any?

    # deliveries in quoting
    logger.debug "  * quote has #{@quote.deliveries.count} deliveries via count and #{@quote.deliveries.length} via length"
    logger.debug "  * @quote.deliveries.any?: #{@quote.deliveries.any?}, @quote.deliveries.present?: #{@quote.deliveries.present?}"
    if @quote.deliveries.any? && @quote.shipping_address.present?
      append_deliveries(order)
    else
      logger.info '  * quote has no delivery quotes, removing shipping address and shipping lines if any'
      clear_shipping_address(order)
    end

    append_quote_discounts(order, line_item_map)
    order.do_not_set_totals = nil # Reset from earlier
    order.force_total_reset = true # Definitely
    order.do_not_detect_shipping = true # Will have been done previously already
    order.recalculate_shipping = false

    # Copy over the min profit margin from the quote
    order.min_profit_markup = @quote.min_profit_markup

    yield(order) if block_given?
    order.save!

    order.commit_line_items(3.working.days.since(Date.current))

    @quote.complete! unless @quote.complete?
  end

  order
end

#run_pre_checkObject

Todo, add other conditions here



34
35
36
37
38
39
40
41
42
# File 'app/services/quote/convert_to_order.rb', line 34

def run_pre_check
  @errors = []
  blockers = blocked_discontinued_skus
  @errors << "Quote contains discontinued items with no successor (#{blockers.join(', ')})" if blockers.present?
  @errors << 'Quote must be in a complete state' unless quote.complete?
  @errors << "Quote has already been converted and has active orders (#{quote.orders.active.pluck(:reference_number).join(', ')})" if quote.orders.active.present?
  @errors += self.class.check_quote_line_items_room_integrity(@quote, item_map: successor_item_map)
  @errors
end

#substitute_room_lines(quote_line, sub) ⇒ Object

Move the room's own line onto the successor along with the order copy.
RoomConfiguration#synchronize_lines matches lines by catalog_item_id, so a
room line left on the discontinued catalog_item would not recognise the
substituted order line when #convert adds the room to the order: it would
destroy it (losing the delivery link and arming the frozen-instance traps
behind AppSignal #6647/#6649) and rebuild the discontinued item in its
place, silently undoing the substitution.

Identity columns only — the room line keeps its own pricing, mirroring how
the order copy keeps the quoted price. update_columns on purpose: LineItem
save callbacks would reprice against the successor's catalog entry.
Runs inside #convert's transaction, so a failed conversion rolls it back.



267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'app/services/quote/convert_to_order.rb', line 267

def substitute_room_lines(quote_line, sub)
  return if quote_line.room_configuration_id.blank?

  LineItem.where(resource_type: 'RoomConfiguration',
                 resource_id: quote_line.room_configuration_id,
                 catalog_item_id: quote_line.catalog_item_id).find_each do |room_line|
    logger.info("Substituting room line #{room_line.id} → catalog_item #{sub[:catalog_item_id]} (was #{room_line.catalog_item_id})")
    room_line.update_columns(catalog_item_id: sub[:catalog_item_id],
                             store_item_id: sub[:store_item_id],
                             item_id: sub[:item_id],
                             updated_at: Time.current)
  end
end

#successor_item_mapObject

old item_id => successor item_id for every line #append_quote_lines will
substitute. Feeds the room-integrity normalization in #run_pre_check.



283
284
285
286
287
288
# File 'app/services/quote/convert_to_order.rb', line 283

def successor_item_map
  successor_substitutions.each_with_object({}) do |(line_id, sub), map|
    old_line = discontinued_lines.find { |li| li.id == line_id }
    map[old_line.item_id] = sub[:item_id] if old_line
  end
end

#successor_substitutionsObject

Map of quote_line_item.id → substitution metadata for every
discontinued line whose item has a successor that's listed in the
same catalog. append_quote_lines uses this to swap
catalog_item_id / store_item_id / item_id on the new order
line while preserving the quoted price.



56
57
58
59
60
61
62
# File 'app/services/quote/convert_to_order.rb', line 56

def successor_substitutions
  @successor_substitutions ||= discontinued_lines.each_with_object({}) do |li, h|
    next unless (sub = substitution_for(li))

    h[li.id] = sub
  end
end