Class: Quote::ConvertToOrder

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

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.



6
7
8
9
10
11
# File 'app/services/quote/convert_to_order.rb', line 6

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.



2
3
4
# File 'app/services/quote/convert_to_order.rb', line 2

def errors
  @errors
end

#loggerObject (readonly)

Returns the value of attribute logger.



2
3
4
# File 'app/services/quote/convert_to_order.rb', line 2

def logger
  @logger
end

#optionsObject (readonly)

Returns the value of attribute options.



2
3
4
# File 'app/services/quote/convert_to_order.rb', line 2

def options
  @options
end

#quoteObject (readonly)

Returns the value of attribute quote.



2
3
4
# File 'app/services/quote/convert_to_order.rb', line 2

def quote
  @quote
end

Class Method Details

.can_convert?(quote) ⇒ Boolean

Returns:

  • (Boolean)


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

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

.can_convert_errors?(quote) ⇒ Boolean

Returns:

  • (Boolean)


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

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



38
39
40
41
42
43
44
45
46
47
# File 'app/services/quote/convert_to_order.rb', line 38

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



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

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? and @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



234
235
236
237
238
239
240
241
242
243
244
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
# File 'app/services/quote/convert_to_order.rb', line 234

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.detect(&: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.detect { |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 unless dq.selected_shipping_cost&.shipping_option.present?

      ndq.reload
      ndq.selected_shipping_cost_id = ndq.shipping_costs.detect { |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



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

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



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
171
172
173
174
175
176
177
# File 'app/services/quote/convert_to_order.rb', line 145

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

  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)
    @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
        if li_attrs[:discounted_price].to_f.zero? && li_attrs[:price].to_f.positive?
          li_attrs[:discounted_price] = li_attrs[:price]
        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

#can_convert?Boolean

Returns:

  • (Boolean)


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

def can_convert?
  run_pre_check
  @errors.blank?
end

#clear_shipping_address(order) ⇒ Object



289
290
291
292
293
294
295
296
297
298
299
# File 'app/services/quote/convert_to_order.rb', line 289

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:



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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'app/services/quote/convert_to_order.rb', line 49

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

  raise QuoteUnpurchasable.new(@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? and @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



27
28
29
30
31
32
33
34
35
# File 'app/services/quote/convert_to_order.rb', line 27

def run_pre_check
  @errors = []
  inactive_items = quote.line_items.parents_only.joins(:item, :catalog_item).merge(CatalogItem.inactive).distinct.pluck('items.sku')
  @errors << "Quote contains discontinued items (#{inactive_items.join(', ')})" if inactive_items.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