Class: Quote::ConvertToOrder
- Inherits:
-
Object
- Object
- Quote::ConvertToOrder
- Defined in:
- app/services/quote/convert_to_order.rb
Overview
Service object: convert to order.
Defined Under Namespace
Classes: QuoteUnpurchasable
Instance Attribute Summary collapse
-
#errors ⇒ Object
readonly
Returns the value of attribute errors.
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#options ⇒ Object
readonly
Returns the value of attribute options.
-
#quote ⇒ Object
readonly
Returns the value of attribute quote.
Class Method Summary collapse
- .can_convert?(quote) ⇒ Boolean
- .can_convert_errors?(quote) ⇒ Boolean
-
.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.
Instance Method Summary collapse
- #append_core_data(order) ⇒ Object
- #append_deliveries(order) ⇒ Object
- #append_quote_discounts(order, line_item_map) ⇒ Object
- #append_quote_lines(order) ⇒ Object
-
#blocked_discontinued_skus ⇒ Object
Discontinued quote-line items whose item has no successor — OR whose successor isn't present in the same catalog.
- #can_convert? ⇒ Boolean
- #clear_shipping_address(order) ⇒ Object
- #convert(existing_order = nil, txid = nil) ⇒ Object
-
#initialize(quote, options = {}) ⇒ ConvertToOrder
constructor
A new instance of ConvertToOrder.
-
#run_pre_check ⇒ Object
Todo, add other conditions here.
-
#successor_substitutions ⇒ Object
Map of
quote_line_item.id→ substitution metadata for every discontinued line whose item has a successor that's listed in the same catalog.
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, = {}) @quote = quote @options = @logger = [:logger] || Rails.logger @errors = [] end |
Instance Attribute Details
#errors ⇒ Object (readonly)
Returns the value of attribute errors.
5 6 7 |
# File 'app/services/quote/convert_to_order.rb', line 5 def errors @errors end |
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
5 6 7 |
# File 'app/services/quote/convert_to_order.rb', line 5 def logger @logger end |
#options ⇒ Object (readonly)
Returns the value of attribute options.
5 6 7 |
# File 'app/services/quote/convert_to_order.rb', line 5 def @options end |
#quote ⇒ Object (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
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
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. ||= @quote. 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_skus ⇒ Object
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
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
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_check ⇒ Object
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_substitutions ⇒ Object
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 |