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.



10
11
12
13
14
15
# File 'app/services/quote/convert_to_order.rb', line 10

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)


17
18
19
# File 'app/services/quote/convert_to_order.rb', line 17

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

.can_convert_errors?(quote) ⇒ Boolean

Returns:

  • (Boolean)


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

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

.check_quote_line_items_room_integrity(quote) ⇒ 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



92
93
94
95
96
97
98
99
100
101
# File 'app/services/quote/convert_to_order.rb', line 92

def self.check_quote_line_items_room_integrity(quote)
  errors = []
  quote.room_configurations.each do |room|
    room_line_items = room.line_items.order(:item_id, :quantity).pluck(:item_id, :quantity)
    quote_line_items = quote.line_items.where(room_configuration_id: room.id).order(:item_id, :quantity).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



160
161
162
163
164
165
166
167
168
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
# File 'app/services/quote/convert_to_order.rb', line 160

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



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
# File 'app/services/quote/convert_to_order.rb', line 300

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
      ndq.selected_shipping_cost_id = ndq.shipping_costs.find { |sc| sc.shipping_option_id == dq.selected_shipping_cost.shipping_option_id }&.id
      ndq.save
    end
  end
  order.deliveries.size
end

#append_quote_discounts(order, line_item_map) ⇒ Object



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'app/services/quote/convert_to_order.rb', line 245

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
      # Use dup to copy the discount, then save it immediately so it has an ID
      copy_discount = d.dup
      copy_discount.itemizable = order
      copy_discount.save!
      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

    # 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}"
          order_discount = Discount.create!(itemizable: order, 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



199
200
201
202
203
204
205
206
207
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
# File 'app/services/quote/convert_to_order.rb', line 199

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]
        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.



44
45
46
# File 'app/services/quote/convert_to_order.rb', line 44

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

#can_convert?Boolean

Returns:

  • (Boolean)


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

def can_convert?
  run_pre_check
  @errors.blank?
end

#clear_shipping_address(order) ⇒ Object



355
356
357
358
359
360
361
362
363
364
365
# File 'app/services/quote/convert_to_order.rb', line 355

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:



103
104
105
106
107
108
109
110
111
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
# File 'app/services/quote/convert_to_order.rb', line 103

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



31
32
33
34
35
36
37
38
39
# File 'app/services/quote/convert_to_order.rb', line 31

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)
  @errors
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.



53
54
55
56
57
58
59
# File 'app/services/quote/convert_to_order.rb', line 53

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