Class: Quote
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Quote
- Includes:
- Memery, Models::Auditable, Models::CouponDateOverridable, Models::Itemizable, Models::LegacyRateRequest, Models::Lineage, Models::MultiRoom, Models::Notable, Models::Pickable, Models::Profitable, Models::ShipQuotable, Models::TaxableResource, PgSearch::Model
- Defined in:
- app/models/quote.rb
Overview
== Schema Information
Table name: quotes
Database name: primary
id :integer not null, primary key
ancestors_count :integer
bill_shipping_to_customer :boolean
children_count :integer
complete_datetime :datetime
currency :string(255)
descendants_count :integer
disable_auto_coupon :boolean default(FALSE), not null
discount :decimal(10, 2)
expiration_date :date
expiration_notice_sent :boolean default(FALSE), not null
first_view_date :datetime
hidden :boolean default(FALSE), not null
hold_for_transmission :boolean default(FALSE), not null
jde_b_ab :integer
jde_s_ab :integer
line_offset :decimal(10, 2)
line_total :decimal(10, 2)
line_total_discounted :decimal(10, 2)
ltl_freight :boolean
ltl_freight_guaranteed :boolean
max_discount_override :decimal(4, 2)
min_profit_markup :integer default(0)
override_coupon_date :date
override_coupon_date_without_limits :boolean default(FALSE), not null
override_line_lock :boolean default(FALSE), not null
position :integer default(100)
pricing_program_description :string(255)
pricing_program_discount :integer
priority :string(255)
quote_type :string(2)
recalculate_discounts :boolean default(FALSE), not null
recalculate_shipping :boolean default(TRUE), not null
reference_number :string(255) not null
requires_upgrade :boolean
retrieving_shipping_costs :boolean
saturday_delivery :boolean default(FALSE), not null
shipping_cost :decimal(10, 2)
shipping_coupon :decimal(10, 2)
shipping_issue_alerted :boolean
shipping_method :string(255)
ships_economy :boolean default(FALSE), not null
signature_confirmation :boolean default(FALSE), not null
single_origin :boolean default(FALSE), not null
state :string(255)
suffix :string(255)
tax_exempt :boolean default(FALSE), not null
tax_offset :decimal(10, 2)
tax_total :decimal(10, 2)
taxable_total :decimal(12, 2)
total :decimal(10, 2)
uploads_count :integer default(0)
created_at :datetime
updated_at :datetime
billing_address_id :integer
buying_group_id :integer
creator_id :integer
legacy_proto_iq_room_id :integer
opportunity_id :integer
parent_id :integer
resource_tax_rate_id :integer
rma_id :integer
shipping_address_id :integer
updater_id :integer
visit_id :bigint
Indexes
idx_quotes_reference_number_trgm_gist (reference_number) USING gist
idx_quotes_rma_id (rma_id)
index_quotes_on_legacy_proto_iq_room_id (legacy_proto_iq_room_id)
index_quotes_on_parent_id (parent_id)
index_quotes_on_reference_number (reference_number) UNIQUE
index_quotes_on_suffix (suffix) USING gin
index_quotes_on_visit_id (visit_id) WHERE (visit_id IS NOT NULL) USING hash
quotes_aasm_state_index (state)
quotes_opportunity_id_index (opportunity_id)
quotes_shipping_address_id_idx (shipping_address_id)
Foreign Keys
fk_rails_... (shipping_address_id => addresses.id)
fk_rails_... (visit_id => visits.id) ON DELETE => nullify
Defined Under Namespace
Classes: AssignLargeOppActivity, CombinedPdfGenerator, ConvertToOrder, Copier, LetterPdfGenerator, Mover, QuoteCompletedHandler, Reviser
Constant Summary collapse
- SALES_QUOTE =
Sales quote.
'SQ'- MARKETING_QUOTE =
Marketing quote.
'MQ'- TECH_QUOTE =
Tech quote.
'TQ'- INSTANT_QUOTE =
Instant quote.
'IQ'- QUOTE_TYPES =
Recognised quote types.
%w[SQ MQ TQ IQ].freeze
- REFERENCE_NUMBER_PATTERN =
Regex pattern matching reference number.
Regexp.new("^(#{QUOTE_TYPES.join('|')})(\\d+)(-R\\d+)?$", Regexp::IGNORECASE)
- CORK_MIN_QTY =
Cork min qty.
1- MIN_INSTALL_SQ_FT =
Minimum install sq ft.
88- CUSTOMER_CANCELABLE_STATES =
Recognised customer cancelable states.
%i[pending].freeze
- CANCELABLE_STATES =
Recognised cancelable states.
CUSTOMER_CANCELABLE_STATES + %i[pending_project_details awaiting_completed_installation_plans awaiting_transmission]
- CLOSED_STATES =
Recognised closed states.
%i[complete cancelled].freeze
- CAN_REQUEST_PRE_PACK_STATES =
Recognised can request pre pack states.
%i[pending pre_pack awaiting_transmission profit_review].freeze
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#do_not_detect_shipping ⇒ Object
Returns the value of attribute do_not_detect_shipping.
-
#do_not_set_totals ⇒ Object
Returns the value of attribute do_not_set_totals.
- #max_discount_override ⇒ Object readonly
- #opportunity_id ⇒ Object readonly
- #suffix ⇒ Object readonly
-
#validate_opportunity_and_prepared_for ⇒ Object
Returns the value of attribute validate_opportunity_and_prepared_for.
Attributes included from Models::Profitable
Attributes included from Models::Itemizable
#force_total_reset, #total_reset
Delegated Instance Attributes collapse
-
#all_participants ⇒ Object
Alias for Opportunity#all_participants.
-
#billing_address ⇒ Object
Alias for Customer#billing_address.
-
#can_convert? ⇒ Object
Alias for Convert_to_order_service#can_convert?.
-
#catalog ⇒ Object
Alias for Customer#catalog.
-
#company ⇒ Object
Alias for Customer#company.
-
#contact ⇒ Object
Alias for Opportunity#contact.
-
#customer ⇒ Object
Alias for Opportunity#customer.
-
#installation_country_iso ⇒ Object
Alias for Opportunity#installation_country_iso.
-
#installation_country_iso3 ⇒ Object
Alias for Opportunity#installation_country_iso3.
-
#installation_postal_code ⇒ Object
Alias for Opportunity#installation_postal_code.
-
#installation_state_code ⇒ Object
Alias for Opportunity#installation_state_code.
-
#local_sales_rep ⇒ Object
Alias for Customer#local_sales_rep.
-
#primary_sales_rep ⇒ Object
Alias for Customer#primary_sales_rep.
-
#secondary_sales_rep ⇒ Object
Alias for Customer#secondary_sales_rep.
-
#store ⇒ Object
Alias for Customer#store.
Belongs to collapse
Methods included from Models::Auditable
Methods included from Models::ShipQuotable
Methods included from Models::TaxableResource
Methods included from Models::Itemizable
Has many collapse
- #active_orders ⇒ ActiveRecord::Relation<Order>
- #activities ⇒ ActiveRecord::Relation<Activity>
- #communications ⇒ ActiveRecord::Relation<Communication>
- #linked_support_cases ⇒ ActiveRecord::Relation<LinkedSupportCase>
- #material_alerts ⇒ ActiveRecord::Relation<MaterialAlert>
- #messaging_logs ⇒ ActiveRecord::Relation<MessagingLog>
- #orders ⇒ ActiveRecord::Relation<Order>
- #uploads ⇒ ActiveRecord::Relation<Upload>
Methods included from Models::ShipQuotable
Methods included from Models::Pickable
Methods included from Models::Itemizable
Has and belongs to many collapse
- #contact_points ⇒ ActiveRecord::Relation<ContactPoint>
- #support_cases ⇒ ActiveRecord::Relation<SupportCase>
Methods included from Models::MultiRoom
Class Method Summary collapse
-
.active ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are active.
-
.auto_quote_from_room(room, options = {}) ⇒ Quote
One-shot factory used by the legacy "quote-from-room" entry point — constructs a single-origin quote attached to the given RoomConfiguration's opportunity, saves it twice (once to get an id so discounts can attach, once to recompute), then fires the requested state event (defaulting to
complete). -
.by_reference_number ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are by reference number.
-
.completed_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are completed quotes.
-
.contains_coupon_ids ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are contains coupon ids.
-
.contains_item_ids ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are contains item ids.
-
.count_by_incomplete_state_by_rep(rep_id = nil, sales_rep_type_id_label = 'primary_sales_rep_id') ⇒ Hash{String=>Integer}
Counts open quotes grouped by state, optionally constrained to one rep.
-
.formatted_resources_query(quotes) ⇒ ActiveRecord::Relation<Quote>
Lightweight projection used by the global "resource picker" search — returns just the columns the autocomplete needs (ref, id, created_at, state, opportunity name) to keep the payload small.
-
.ignore_fixture_attributes ⇒ Array<String>
Columns to skip when comparing fixture-loaded quotes — the cached rate-request XML blobs are timestamped and would otherwise cause spurious test diffs.
-
.in_quoting ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are in quoting.
-
.last_quote_update_cache_key ⇒ String
Cache key derived from the most-recently-updated quote in the whole table — used to invalidate index-page fragment caches the moment any quote changes.
-
.last_revisions ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are last revisions.
-
.like_lookup ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are like lookup.
-
.lookup ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are lookup.
-
.most_recent_first ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are most recent first.
-
.not_converted ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are not converted.
-
.open_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are open quotes.
-
.pending ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are pending.
-
.sales_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are sales quotes.
-
.states_for_select ⇒ Array<Array(String, String)>
State-machine states formatted for a Rails
selecthelper —[[human_name, machine_value], …]. -
.with_contact_point_category ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with contact point category.
-
.with_email ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with email.
-
.with_line_items ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with line items.
-
.without_quofus ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are without quofus.
Instance Method Summary collapse
-
#addendums ⇒ Array<Upload>
PDF uploads that should be appended to the customer-facing quote bundle — e.g.
-
#address_book ⇒ Array<Address>
Address picker source for the quote's "ship to" field — the customer's home store warehouse first (for will-call quotes), then every address on the customer record.
-
#adjust_status_based_on_room_status(rc) ⇒ void
Synchronises the quote's state machine with a child RoomConfiguration that just changed status.
-
#all_activities ⇒ ActiveRecord::Relation<Activity>
Every Activity linked to this quote or its parent Opportunity — what the CRM "activities" tab shows.
-
#all_orders_sold? ⇒ Boolean
Whether every order spawned from this quote has been invoiced — used as the guard for closing out the quote once shipping is done.
-
#all_suggested_items ⇒ Hash{Symbol=>Object}
Aggregate of recommended add-ons for this quote: goods grouped by room configuration, services, a deduplicated goods list, and the SKUs of those unique goods.
-
#all_support_cases ⇒ ActiveRecord::Relation<SupportCase>
Support cases linked to this quote (directly via the HABTM and transitively through its room configurations), ordered newest case-number first for the CRM sidebar.
-
#applies_for_smartinstall ⇒ Boolean
Whether this quote qualifies for SmartInstall — WarmlyYours's in-house installation service for homeowners.
-
#applies_for_smartsupport ⇒ Boolean
Whether this quote qualifies for SmartSupport — the dealer/trade-pro version of the in-house service program (we send an installer to support the trade pro on site).
-
#apply_tier2_pricing? ⇒ Boolean
Whether or not to apply the tier2 pricing (customer discount) by default.
-
#available_contact_points ⇒ Array<ContactPoint>
Every ContactPoint reachable through this quote's opportunity (primary party, customer, and every opportunity participant), deduped — used to populate the recipient picker on the quote transmission form.
-
#available_contact_points_grouped_for_select ⇒ Hash{String=>Array<Array(String, Integer)>}
#available_contact_points reshaped into Rails grouped-select input:
{ "Person Full Name" => [["detail (category)", id], …] }so the transmission form renders an<optgroup>per party. -
#best_contact_point ⇒ ContactPoint?
Best contact point we can send the quote to — falls through quote → contact → customer until something transmittable (email/fax) turns up.
-
#build_activity ⇒ Activity
Builds (but does not save) a new Activity attached to this quote with the quote's primary party already populated.
-
#calculate_expiration_date ⇒ Date
Computed expiration date for the quote — the soonest of every attached coupon's expiration and a default 60-day window from
created_at. -
#can_be_moved? ⇒ Boolean
Whether this quote can be reassigned to a different opportunity.
-
#can_be_transmitted? ⇒ Boolean
Whether the customer-facing transmission flow is allowed in this state.
-
#cancelable? ⇒ Boolean
State-machine guard for the
cancelevent — the quote can be cancelled as long as no surviving order points at it. -
#check_pre_pack ⇒ void
after_savecallback that auto-transitions the quote intopre_packwhen it's awaiting transmission and the warehouse signals that estimated packaging needs to happen first (see #stop_for_pre_pack?). -
#convert_to_order_service ⇒ Quote::ConvertToOrder
Memoised ConvertToOrder service for this quote.
-
#country ⇒ String?
ISO country of the Store this quote was placed against — used by tax / shipping / addendum logic to branch on US vs CA vs other.
-
#crm_link ⇒ String
Internal CRM URL for this quote's show page.
-
#currency_symbol ⇒ String
Currency glyph (
$,€,£, …) for this quote, derived from the quote'scurrencyISO code via themoneygem. -
#deep_dup ⇒ Quote
Deep-clones the quote and its non-shipping parent LineItems, plus the contact-points join, but resets identity / lifecycle columns (state goes back to
pending, tax fields drop, parent_id and delivery_id are stripped) so the duplicate can be saved as a fresh draft. -
#dependents(limit: 5) ⇒ Hash{Symbol=>ActiveRecord::Relation}
Quote dependents that block destruction — orders, child revisions, and customer communications.
-
#editing_locked? ⇒ Boolean
True once the quote is in a terminal state — used by the form/controller layer to read-only the line item rows so a finalised or cancelled quote can't be edited in place.
-
#effective_date_for_coupon ⇒ Date
Date used by the coupon engine to evaluate "is this coupon currently valid?" against
start_date/end_datewindows. -
#exclude_manually_initiated_event?(_event) ⇒ false
State-machine helper —
Models::Auditablehook used to suppress certain manually-initiated events from PaperTrail's audit trail. -
#extra_plans_file_name(with_extension = true) ⇒ String
Filename for the secondary "installation plans" PDF that ships alongside the main quote — same naming convention as #file_name with a
_planssuffix so attachments don't collide. -
#file_name(with_extension = true) ⇒ String
Filename used when the customer-facing quote PDF is downloaded or attached to an email — reference-number-stamped so a customer with multiple revisions can tell them apart.
-
#get_next_reference_number ⇒ String
Pulls the next quote reference number from the
quotes_reference_numbers_seqPostgres sequence and prefixes it with the quote-type code (e.g.SQ,MQ,TQ). -
#goods_product_line_ids ⇒ Array<Integer>
Distinct primary product-line ids represented by the goods on this quote.
-
#has_discounts? ⇒ Boolean
True when at least one line item carries a discount — drives the "savings" badge on the customer-facing PDF and unlocks the discount-detail rows.
-
#has_no_instant_quoting_rooms ⇒ true
Validation stub left over from the deprecated Instant Quote (IQ) system — kept registered as a
validate :has_no_instant_quoting_roomsso removing it is a separate, easy-to-revert change. -
#has_onsite_smartsupport? ⇒ Boolean
Whether on-site SmartSupport is available — install is within the service-distance radius from the WarmlyYours HQ.
-
#has_pre_pack_deliveries? ⇒ Boolean
True when the quote itself is in
pre_packor any of its deliveries is in pre-pack. -
#has_selected_heated_items? ⇒ Boolean
Whether the quote actually contains items eligible for the smart-services program — branches on customer type so we look at the right side of the eligibility data.
-
#installation_is_within_range? ⇒ Boolean
True when #service_distance is non-nil and inside the
SMART_SERVICES_MAX_DISTANCEradius (currently 100 mi from HQ). -
#insulation_sq_ft ⇒ Integer
Total square footage of underlayment / insulation across every indoor room on the quote — used by the cork/insulation cross-sell logic and by the catalog to suggest the right underlayment quantity.
-
#is_first_quote? ⇒ Boolean
True when this is the original quote rather than a revision — i.e.
-
#is_revision? ⇒ Boolean
True when this quote was spawned via #deep_dup from an earlier quote — the inverse of #is_first_quote?.
-
#is_sales_quote? ⇒ Boolean
True for the standard customer-facing sales quote (
SQ) — the main quote-type in production. -
#is_smart_service_quote? ⇒ Boolean
True when every non-shipping line on the quote is a smart-service SKU — used to switch the PDF / fulfillment path to the services-only template.
-
#lookup_link ⇒ String
Public-facing URL for the customer's "look up your quote" page (warmlyyours.com /quote-lookup).
-
#main_rep ⇒ Employee?
Primary rep this quote "belongs to" for commission and CRM ownership.
-
#name ⇒ String
Short human label like
Quote #SQ12345. -
#ok_to_customer_cancel? ⇒ Boolean
Whether the customer is allowed to self-cancel from the public quote-lookup page — only when the quote is still pending and has no surviving orders attached.
-
#ok_to_delete? ⇒ Boolean
True when #dependents is empty across the board — the precondition for the destroy button.
-
#open_activities_counter ⇒ Integer
Count of CRM follow-ups still open against this quote (or its opportunity), filtered to the activities that show by default in the rep's queue.
-
#participants_options_for_select ⇒ Array<Array(String, Integer)>?
Opportunity participants formatted for a Rails
selecthelper —[[full_name, party_id], …]. -
#post_communication_queued_hook(_params = {}) ⇒ void
Hook fired by the communication subsystem the moment a transmission email/fax is enqueued — finalises the quote if every other gate has already cleared.
-
#post_communication_sent_hook(_params = {}) ⇒ void
Hook fired once the transmission has actually been sent by the provider — finalises the quote if every other gate has cleared.
-
#pre_packable? ⇒ Boolean
Whether the warehouse can run an estimated-packaging pre-pack on this quote — at least one delivery must have a shipment that isn't already packed or pre-packed.
-
#prefix_for_reference_number ⇒ String
Fallback prefix used when #quote_type hasn't been chosen yet — derives the two-letter code from the parent Opportunity's type (so a sales opp gives
SQ, marketing oppMQ, etc.). - #prepared_for_name ⇒ String
-
#prevent_recalculate_shipping? ⇒ Boolean
Guard used by Models::ShipQuotable to skip live ShipEngine rate quoting — already-finalised quotes don't requote, and the flag also blocks re-entrancy while a rate request is in flight.
-
#pricing_program_description ⇒ String
Human label for the pricing program in effect on this quote — the tier2 coupon title when the customer is on a dealer/contractor tier, otherwise
"MSRP/Catalog". -
#pricing_program_discount ⇒ Integer
Percentage discount the active tier2 pricing program applies to goods, or
0when the quote is on plain MSRP. - #primary_party ⇒ Party
-
#ready_to_complete? ⇒ Boolean
Coarser readiness check than #will_complete? — only requires that no room is in a half-resolved state.
-
#recipient_email ⇒ String?
Email address the quote should be sent to — prefers an explicit quote-level contact-point email, then the primary party's email, then the customer's.
-
#recipient_name ⇒ String?
Best display name for the person this quote is addressed to — walks #primary_party → shipping address → customer until it finds one.
-
#reference_number_with_name ⇒ String
Reference number plus the rep-supplied free-text suffix (the rep uses this slot for the project name).
-
#reference_number_with_opp_name ⇒ String
Reference number with the parent Opportunity's name appended — used in dropdowns where the rep needs to disambiguate between multiple quotes on the same opportunity.
-
#reference_number_with_opp_name_when_specified ⇒ String
#reference_number_with_opp_name but skips the appended job name when the opportunity has a placeholder/unknown name (so we don't render "SQ12345 Job for Customer XYZ" — just
SQ12345). -
#request_plans_or_complete ⇒ void
State-machine driver that nudges the quote forward (or back) based on its current room-configuration mix and line-item presence.
-
#reset_discount_on_ships_economy_change ⇒ Boolean?
protected
after_savecallback: when the rep flips a quote between economy and standard ground shipping, drop any previously-applied shipping discount so the next pricing pass recalculates against the new shipping basis. -
#schedule_follow_up(follow_up_task_type: nil) ⇒ Activity?
Creates (or replaces) a quote-centric follow-up activity on the opportunity so a rep is reminded to chase the customer.
-
#selection_name ⇒ String
Label used by the global Ransack/select2 lookup widget when this quote is offered as a search result.
-
#send_profit_review_notification ⇒ void
Notifies internal staff (margin desk / pricing) that this quote has entered
profit_reviewso they can review the pricing exception. -
#sender ⇒ Employee?
Rep treated as the "from" on outbound communications and notifications about this quote.
-
#service_distance ⇒ Float?
Driving distance (in miles) from the WarmlyYours Lake Zurich HQ to the installation postal code on this quote — the gating measure for SmartInstall / SmartSupport eligibility (see
SMART_SERVICES_MAX_DISTANCE). -
#set_currency ⇒ String?
Pulls the currency from the customer's home Store and writes it onto the quote so prices print in the right denomination.
- #set_default_quote_type ⇒ Object protected
- #set_expiration_date ⇒ Object protected
- #set_min_profit_markup ⇒ Object protected
-
#set_opportunity_value ⇒ void
after_commithook that recomputes the parent Opportunity's rolled-up dollar value when this quote's totals change. -
#set_priority(save = true) ⇒ String, true
Computes the highest urgency among the quote's room configurations and writes it onto
priority(urgent,rush,standard, etc.). -
#set_reference_number ⇒ String
before_createcallback that stamps #reference_number from the Postgres sequence unless the caller (e.g. data import) has already supplied one. -
#shipping_address_nil_or_valid? ⇒ Boolean
True when the quote either has no shipping address or has one that passes its own validations — used as a precondition before transitioning to states that require shippable goods.
-
#smartsupport_info ⇒ Hash?
SmartSupport metadata bundle (line items eligible for the program, estimated visit time, etc.) when the install location is within service range; nil otherwise.
-
#sms_enabled_numbers ⇒ Array<String>
Distinct SMS-capable numbers for every participant on this quote's opportunity, formatted for the SMS gateway.
-
#sms_messages ⇒ ActiveRecord::Relation<SmsMessage>
Two-way SMS history for #sms_enabled_numbers — the message thread shown on the quote sidebar.
-
#sold? ⇒ Boolean
True once at least one non-cart, non-cancelled Order exists against this quote — i.e.
-
#special_ordering_instructions ⇒ String?
Free-text "special instructions" stored on the billing-address party — printed on the warehouse pick ticket when present (e.g. "no liftgate", "call before delivery").
-
#stop_for_pre_pack? ⇒ Boolean
State-machine guard — true when transmission must wait for the warehouse to provide an estimated-packaging quote.
-
#stop_for_profit_review? ⇒ Boolean
State-machine guard — true when transmission must pause for margin desk review.
-
#tier2_program_pricing ⇒ Discount?
The tier2 (Discount) row representing the customer's contractor/dealer pricing program on this quote.
-
#tier2_program_pricing_coupon ⇒ Coupon?
The Coupon that powers #tier2_program_pricing, when there is one — used by #pricing_program_description and #pricing_program_discount to label the program.
-
#to_liquid ⇒ Liquid::QuoteDrop
Liquid drop wrapper used when this quote is rendered into the transmission email/SMS templates — exposes only the safe-for-template accessors via Liquid::QuoteDrop.
-
#to_order(order = nil, txid = nil) ⇒ Array<Order>
Convert this quote into one or more Orders via the ConvertToOrder service.
-
#to_s ⇒ String
Display string used by Rails helpers and inspectors —
"Quote # SQ12345 - Smith Master Bath". -
#total_amps ⇒ Float
Sum of every room's calculated electrical load, in amps.
-
#track_profit? ⇒ Boolean
Whether this quote should run through the profit-margin gate — only sales quotes with at least one profitable line item are subject to the profit-review state.
-
#tracking_email_address ⇒ String
Encrypted plus-addressed inbox alias used as the
Reply-Toon quote emails — replies bounce back into Heatwave and are auto-attached to this quote's communication thread. -
#versions_for_audit_trail(_params = {}) ⇒ ActiveRecord::Relation<RecordVersion>
PaperTrail versions for the quote and every record that hangs off it — its line items, deliveries, and discounts — pulled in a single query via JSONB
reference_datalookup. -
#will_complete? ⇒ Boolean
State-machine guard — true when the quote has every gate cleared for a
completetransition: at least one line item, valid shipping address (or none), all rooms settled, and profit margins met. -
#will_installation_plans_complete? ⇒ Boolean
State-machine guard for the
installation_plans_completeevent — rooms must exist and all be settled, and at least one line item must be on the quote. -
#will_pending_project_details? ⇒ Boolean
State-machine guard for the
pending_project_detailsevent — we're either coming from awaiting-design, or there are no line items but at least one draft room. -
#will_ready_for_design? ⇒ Boolean
State-machine guard for the
ready_for_designevent — at least one room exists and at least one is currently in design. -
#will_ready_to_transmit? ⇒ Boolean
State-machine guard for the
ready_to_transmitevent — line items exist, shipping is valid, margins are met, and the warehouse isn't asking for a pre-pack first. -
#zones ⇒ nil
Placeholder for a planned "zones" concept (groups of rooms controlled by a single thermostat).
Methods included from Models::CouponDateOverridable
Methods included from Models::LegacyRateRequest
#last_shipping_rate_request_result, #last_shipping_rate_request_result=
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods included from Models::Profitable
#default_sales_markup, #profit_margins_met?, #profitable_line_items, #profitable_status, #profitable_total_discounted, #profitable_total_estimated_cost, #profitable_total_estimated_line_cost, #profitable_total_profit, #profitable_total_profit_margin, #profitable_total_profit_markup, #validate_min_profit_markup?
Methods included from Models::Lineage
#ancestors, #ancestors_ids, #children_and_roots, #descendants, #descendants_ids, #ensure_non_recursive_lineage, #family_members, #generate_full_name, #generate_full_name_array, #lineage, #lineage_array, #lineage_simple, #root, #root_id, #self_ancestors_and_descendants, #self_ancestors_and_descendants_ids, #self_and_ancestors, #self_and_ancestors_ids, #self_and_children, #self_and_descendants, #self_and_descendants_ids, #self_and_siblings, #self_and_siblings_ids, #siblings, #siblings_ids
Methods included from Models::MultiRoom
#add_line_items_to_all_rooms, #add_lines_for_room_configuration, #all_rooms_complete?, #all_rooms_complete_or_cancelled?, #all_rooms_complete_or_cancelled_or_draft?, #all_rooms_in_design?, #all_rooms_ppd?, #any_room_ppd?, #any_rooms_in_design?, #get_operating_costs, #get_recommended_materials, #heated_sq_ft, #prioritize_room, #remove_line_items_from_all_rooms, #remove_lines_for_room_configuration, #replace_line_items_in_all_rooms, #suggested_items, #suggested_services, #synchronization_targets
Methods included from Models::ShipQuotable
#carrier, #chosen_shipping_method, #days_commitment, #determine_origin_address, #everything_in_stock?, #friendly_shipping_method, #is_drop_ship?, #is_override?, #is_warehouse_pickup?, #line_items_grouped_by_deliveries_quoting, #line_items_match_deliveries_if_any, #need_to_pre_pack_reasons, #need_to_recalculate_shipping, #one_time_shipping_address, #one_time_shipping_address=, #qualifies_for_cod?, #refresh_deliveries_quoting, #reset_deliveries_shipping_option, #reset_shipping, #retrieve_friendly_shipping_method, #retrieve_shipping_costs, #ship_quoted, #ship_weight, #shipping_non_db_default_but_db_customer?, #shipping_signature_confirmation_non_db_default_but_db_customer?, #shipping_via_wy_but_has_shipping_account?, #ships_economy_package?, #ships_freight?, #ships_freight_but_address_not_freight_ready?, #should_ship_freight?, #should_ship_freight_but_address_not_freight_ready?, #validate_deliveries
Methods included from Models::ShipMeasurable
#cartons_total, #crates_total, #pallets_total, #ship_freight_class_from_shipments, #ship_volume_from_shipments, #ship_volume_from_shipments_in_cubic_feet, #ship_weight_from_shipments, #shipment_set, #shipments_for_measure
Methods included from Models::Pickable
#all_my_publications, #append_suggested_items, #append_suggested_materials, #control_capacity, #determine_catalog_for_picking, #determine_skus_to_filter, #discounted_shipping_total, #electrical_heating_elements, #fix_catalog, #get_material_alerts, #has_custom_products?, #invalidate_material_alerts!, #line_items_grouped_by_room_configuration, #meets_custom_products_threshold?, #pricing_program_discount_factor, #purge_empty_line_items, #soft_recalc, #subtotal, #subtotal_after_trade_discount_without_shipping, #subtotal_discounted_without_shipping, #subtotal_msrp_without_shipping, #synchronize_lines, #total_amps_by_product_line, #total_coverage_by_product_line, #total_linear_feet_by_product_line, #total_spec_by_product_line, #total_watts_by_product_line, #underlayments
Methods included from Models::TaxableResource
#apply_tax_rate_to_line_items, #build_tax_params, #calculate_tax_for_all_lines, #copy_tax_rate, #effective_date, #get_rates_for_line, #get_tax_rate, #manual_rate_goods, #manual_rate_services, #manual_rate_shipping, #origin_address, #refresh_tax_rate, #resource_not_taxable?, #set_initial_tax_rate, #should_refresh_tax_rate?, #state_code, #state_code_sym, #taxes_grouped_by_rate, #taxes_grouped_by_type
Methods included from Models::Itemizable
#add_line_item, #additional_items, #assign_sequence, #billing_entity, #breakdown_of_prices, #calculate_actual_insured_value, #calculate_discounts, #calculate_shipping_cost, #coupon_search, #customer_applied_coupons, #customer_can_apply_coupon?, #discounts_changed?, #discounts_grouped_by_coupon, #discounts_subtotal, #effective_discount, #effective_shipping_discount, #has_kits?, #has_kits_or_serial_numbers?, #has_serial_numbers?, #is_credit_order?, #line_items_requiring_serial_number, #line_items_with_counters, #line_total_plus_tax, #perform_db_total, #purge_empty_quoting_deliveries, #purge_shipping_when_no_other_lines, #remove_line_item, #require_total_reset?, #reset_discount, #set_for_recalc, #set_signature_confirmation_on_shipping_address_change, #set_totals, #shipping_conditions_changed?, #shipping_discounted, #shipping_method_changed?, #should_recalculate_shipping?, #smartinstall_data, #smartsupport_data, #subtotal_cogs, #sync_shipping_line, #total_cogs
Methods included from Models::Notable
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#do_not_detect_shipping ⇒ Object
Returns the value of attribute do_not_detect_shipping.
164 165 166 |
# File 'app/models/quote.rb', line 164 def do_not_detect_shipping @do_not_detect_shipping end |
#do_not_set_totals ⇒ Object
Returns the value of attribute do_not_set_totals.
164 165 166 |
# File 'app/models/quote.rb', line 164 def do_not_set_totals @do_not_set_totals end |
#max_discount_override ⇒ Object (readonly)
206 |
# File 'app/models/quote.rb', line 206 validates :max_discount_override, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, allow_nil: true } |
#opportunity_id ⇒ Object (readonly)
203 |
# File 'app/models/quote.rb', line 203 validates :opportunity_id, presence: { if: :validate_opportunity_and_prepared_for } |
#suffix ⇒ Object (readonly)
204 |
# File 'app/models/quote.rb', line 204 validates :suffix, length: { maximum: 150 } |
#validate_opportunity_and_prepared_for ⇒ Object
Returns the value of attribute validate_opportunity_and_prepared_for.
164 165 166 |
# File 'app/models/quote.rb', line 164 def validate_opportunity_and_prepared_for @validate_opportunity_and_prepared_for end |
Class Method Details
.active ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are active. Active Record Scope
217 |
# File 'app/models/quote.rb', line 217 scope :active, -> { where.not(state: 'cancelled') } |
.auto_quote_from_room(room, options = {}) ⇒ Quote
One-shot factory used by the legacy "quote-from-room" entry
point — constructs a single-origin quote attached to the given
RoomConfiguration's opportunity, saves it twice (once to get
an id so discounts can attach, once to recompute), then fires
the requested state event (defaulting to complete).
1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 |
# File 'app/models/quote.rb', line 1303 def self.auto_quote_from_room(room, = {}) force_single_origin = [:force_single_origin] || true do_not_set_shipping_address = [:do_not_set_shipping_address] || false target_state_event = [:state_event] || 'complete' q = Quote.new(opportunity_id: room.opportunity_id) q.shipping_address_id = room.customer.shipping_address_id if !room.installation_postal_code && (do_not_set_shipping_address == false) && room.customer && room.customer.shipping_address.present? q.single_origin = true if force_single_origin q.room_configuration_ids = [room.id] q.do_not_detect_shipping = true # First save the quote WITHOUT calculating discounts to get an ID # Discounts require itemizable_id to be set, which only exists after the quote is persisted q.save # Now that the quote has an ID, trigger discount calculation (tier2 pricing, etc.) # The quote must be persisted before discounts can be created if q.persisted? q.recalculate_discounts = true q.force_total_reset = true q.do_not_detect_shipping = true # Still skip shipping detection q.save end # Now apply the target state event (typically 'complete') if q.persisted? && target_state_event.present? && q.respond_to?("#{target_state_event}!") q.do_not_set_totals = true # Discounts already calculated, don't recalculate q.send("#{target_state_event}!") end q end |
.by_reference_number ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are by reference number. Active Record Scope
213 |
# File 'app/models/quote.rb', line 213 scope :by_reference_number, -> { order(:reference_number).reverse_order } |
.completed_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are completed quotes. Active Record Scope
209 |
# File 'app/models/quote.rb', line 209 scope :completed_quotes, -> { where(state: 'complete') } |
.contains_coupon_ids ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are contains coupon ids. Active Record Scope
216 |
# File 'app/models/quote.rb', line 216 scope :contains_coupon_ids, ->(coupon_ids) { where("EXISTS (select discounts.id from discounts where discounts.coupon_id IN (?) and discounts.itemizable_type = 'Quote' and discounts.itemizable_id = quotes.id)", coupon_ids) } |
.contains_item_ids ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are contains item ids. Active Record Scope
215 |
# File 'app/models/quote.rb', line 215 scope :contains_item_ids, ->(item_ids) { where("EXISTS (select 1 from line_items li where li.resource_id = quotes.id and li.resource_type = 'Quote' and li.item_id IN (?))", item_ids) } |
.count_by_incomplete_state_by_rep(rep_id = nil, sales_rep_type_id_label = 'primary_sales_rep_id') ⇒ Hash{String=>Integer}
Counts open quotes grouped by state, optionally constrained to
one rep. The sales_rep_type_id_label parameter lets the caller
ask for primary, secondary, or local rep counts.
1179 1180 1181 1182 1183 1184 1185 |
# File 'app/models/quote.rb', line 1179 def self.count_by_incomplete_state_by_rep(rep_id = nil, sales_rep_type_id_label = 'primary_sales_rep_id') if rep_id.nil? count(:state, conditions: ["state not in ('complete','cancelled')"], group: :state) else count(:state, conditions: ["state not in ('complete','cancelled') and #{sales_rep_type_id_label} = ?", rep_id], group: :state) end end |
.formatted_resources_query(quotes) ⇒ ActiveRecord::Relation<Quote>
Lightweight projection used by the global "resource picker"
search — returns just the columns the autocomplete needs (ref,
id, created_at, state, opportunity name) to keep the payload
small.
557 558 559 |
# File 'app/models/quote.rb', line 557 def self.formatted_resources_query(quotes) quotes.select('quotes.reference_number,quotes.id,quotes.created_at,quotes.state,opportunities.name as opportunity_name').joins(:opportunity).order(reference_number: :desc) end |
.ignore_fixture_attributes ⇒ Array<String>
Columns to skip when comparing fixture-loaded quotes — the cached
rate-request XML blobs are timestamped and would otherwise cause
spurious test diffs.
536 537 538 |
# File 'app/models/quote.rb', line 536 def self.ignore_fixture_attributes %w[last_rate_request_xml last_rate_response_xml] end |
.in_quoting ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are in quoting. Active Record Scope
219 |
# File 'app/models/quote.rb', line 219 scope :in_quoting, -> { where(state: %w[awaiting_completed_installation_plans pre_pack awaiting_transmission]) } |
.last_quote_update_cache_key ⇒ String
Cache key derived from the most-recently-updated quote in the
whole table — used to invalidate index-page fragment caches the
moment any quote changes.
545 546 547 |
# File 'app/models/quote.rb', line 545 def self.last_quote_update_cache_key Quote.select('id,updated_at').order(:updated_at).reverse_order.first.cache_key end |
.last_revisions ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are last revisions. Active Record Scope
214 |
# File 'app/models/quote.rb', line 214 scope :last_revisions, -> { where("NOT EXISTS (select id from quotes q2 where q2.parent_id = quotes.id and quotes.state <> 'cancelled')") } |
.like_lookup ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are like lookup. Active Record Scope
221 |
# File 'app/models/quote.rb', line 221 scope :like_lookup, ->(q) { where('quotes.reference_number ILIKE :term', { term: "%#{q}%" }) } |
.lookup ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are lookup. Active Record Scope
220 |
# File 'app/models/quote.rb', line 220 scope :lookup, ->(q) { where('quotes.reference_number ILIKE :term', { term: q }) } |
.most_recent_first ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are most recent first. Active Record Scope
212 |
# File 'app/models/quote.rb', line 212 scope :most_recent_first, -> { order(:created_at).reverse_order } |
.not_converted ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are not converted. Active Record Scope
218 |
# File 'app/models/quote.rb', line 218 scope :not_converted, -> { where("not exists(select 1 from orders where quote_id = quotes.id and orders.state = 'invoiced')") } |
.open_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are open quotes. Active Record Scope
208 |
# File 'app/models/quote.rb', line 208 scope :open_quotes, -> { where.not(state: %w[complete cancelled]) } |
.pending ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are pending. Active Record Scope
210 |
# File 'app/models/quote.rb', line 210 scope :pending, -> { where(state: 'pending') } |
.sales_quotes ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are sales quotes. Active Record Scope
224 |
# File 'app/models/quote.rb', line 224 scope :sales_quotes, -> { where(quote_type: SALES_QUOTE) } |
.states_for_select ⇒ Array<Array(String, String)>
State-machine states formatted for a Rails select helper —
[[human_name, machine_value], …]. Used by the CRM filter
dropdowns.
527 528 529 |
# File 'app/models/quote.rb', line 527 def self.states_for_select state_machines[:state].states.map { |s| [s.human_name, s.value] } end |
.with_contact_point_category ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with contact point category. Active Record Scope
222 |
# File 'app/models/quote.rb', line 222 scope :with_contact_point_category, ->(category) { where('exists(select 1 from contact_points_quotes cq inner join contact_points cp on cp.id = cq.contact_point_id and cq.quote_id = quotes.id where cp.category = ?)', category) } |
.with_email ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with email. Active Record Scope
223 |
# File 'app/models/quote.rb', line 223 scope :with_email, -> { with_contact_point_category(ContactPoint::EMAIL) } |
.with_line_items ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are with line items. Active Record Scope
211 |
# File 'app/models/quote.rb', line 211 scope :with_line_items, -> { includes(line_items: { catalog_item: { store_item: :item } }) } |
.without_quofus ⇒ ActiveRecord::Relation<Quote>
A relation of Quotes that are without quofus. Active Record Scope
225 226 227 228 229 230 231 232 233 234 |
# File 'app/models/quote.rb', line 225 scope :without_quofus, -> { where(%{ not exists( select 1 from activities a where ( (a.resource_id = quotes.opportunity_id and a.resource_type = 'Opportunity') or (a.resource_id = quotes.id and a.resource_type = 'Quote') ) and a.activity_type_id IN (?) ) }, ActivityTypeConstants::QUOFUS_IDS) } |
Instance Method Details
#active_orders ⇒ ActiveRecord::Relation<Order>
181 |
# File 'app/models/quote.rb', line 181 has_many :active_orders, -> { where(Order[:state].not_in(%w[cancelled fraudulent])) }, class_name: 'Order' |
#activities ⇒ ActiveRecord::Relation<Activity>
183 |
# File 'app/models/quote.rb', line 183 has_many :activities, as: :resource, dependent: :nullify |
#addendums ⇒ Array<Upload>
PDF uploads that should be appended to the customer-facing quote
bundle — e.g. seasonal promotions, terms-and-conditions revisions,
warranty inserts. Only publications tagged for-quote AND either
the current Month-YYYY tag or permanent are returned, so an
April quote doesn't ship March's flyer.
816 817 818 819 820 821 822 823 |
# File 'app/models/quote.rb', line 816 def addendums current_month = Date.current.strftime('%B-%Y') # Use with_all_tags to require BOTH 'for-quote' AND the time-based tag (AND logic) publications = Item.publications.('for-quote', current_month.downcase) .or(Item.publications.('for-quote', 'permanent')) list = publications.map(&:upload) list.uniq.compact end |
#address_book ⇒ Array<Address>
Address picker source for the quote's "ship to" field — the
customer's home store warehouse first (for will-call quotes),
then every address on the customer record.
1460 1461 1462 |
# File 'app/models/quote.rb', line 1460 def address_book [customer.store.warehouse_address] + customer.addresses end |
#adjust_status_based_on_room_status(rc) ⇒ void
This method returns an undefined value.
Synchronises the quote's state machine with a child
RoomConfiguration that just changed status. Invoked from the
room state-machine after_transition so a room going
complete/cancelled marches the quote toward
installation_plans_complete, a room dropping back to draft
parks the quote in pending_project_details, and a room entering
design pushes the quote to ready_for_design.
1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 |
# File 'app/models/quote.rb', line 1018 def adjust_status_based_on_room_status(rc) Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status: self.state: #{state}, rc: #{rc.reference_number}, #{rc.id}, rc state: #{rc.state}, DateTime: #{Time.current}" self.do_not_set_totals = true if rc.complete? || rc.cancelled? Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, BEFORE installation_plans_complete: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}" res = reload.installation_plans_complete unless complete? Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, AFTER installation_plans_complete: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}, result of installation_plans_complete: #{res}" elsif rc.draft? pending_project_details if can_pending_project_details? && awaiting_completed_installation_plans? elsif rc.in_design? Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, BEFORE ready_for_design: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}" res = ready_for_design Rails.logger.info "Quote Ref and ID #{reference_number}, #{id}, quote.adjust_status_based_on_room_status, AFTER ready_for_design: self.state: #{state}, rc: #{rc.reference_number}, rc state: #{rc.state}, result of ready_for_design: #{res}" end end |
#all_activities ⇒ ActiveRecord::Relation<Activity>
Every Activity linked to this quote or its parent
Opportunity — what the CRM "activities" tab shows. Reaches
through opportunity so quote and opp activities aren't split
across two tabs.
1451 1452 1453 |
# File 'app/models/quote.rb', line 1451 def all_activities opportunity.linked_activities end |
#all_orders_sold? ⇒ Boolean
Whether every order spawned from this quote has been invoiced —
used as the guard for closing out the quote once shipping is
done.
694 695 696 |
# File 'app/models/quote.rb', line 694 def all_orders_sold? orders.any? && orders.all?(&:invoiced?) end |
#all_participants ⇒ Object
Alias for Opportunity#all_participants
720 |
# File 'app/models/quote.rb', line 720 delegate :all_participants, to: :opportunity |
#all_suggested_items ⇒ Hash{Symbol=>Object}
Aggregate of recommended add-ons for this quote: goods grouped by
room configuration, services, a deduplicated goods list, and the
SKUs of those unique goods. Drives the "you might also need…"
cross-sell panel on the quote builder and quote PDF.
746 747 748 749 750 751 752 753 754 755 756 757 |
# File 'app/models/quote.rb', line 746 def all_suggested_items goods = suggested_items services = suggested_services unique_goods = [] goods.each do |_rc, lines| lines.each do |li| unique_goods << li unless unique_goods.any? { |si| si.item == li.item } end end unique_goods_skus = unique_goods.compact.uniq.map(&:sku) { goods:, services:, unique_goods:, unique_goods_skus: } end |
#all_support_cases ⇒ ActiveRecord::Relation<SupportCase>
Support cases linked to this quote (directly via the HABTM and
transitively through its room configurations), ordered newest
case-number first for the CRM sidebar.
488 489 490 491 492 |
# File 'app/models/quote.rb', line 488 def all_support_cases support_case_ids linked_support_cases.ids SupportCase.where(id: support_case_ids).order(Arel.sql('support_cases.case_number desc')) end |
#applies_for_smartinstall ⇒ Boolean
Whether this quote qualifies for SmartInstall — WarmlyYours's
in-house installation service for homeowners. Currently disabled
(return false) pending the smart-services refactor; the
original geofence/role/heated-items check is preserved below
the early return for when it's reactivated.
1490 1491 1492 1493 1494 1495 |
# File 'app/models/quote.rb', line 1490 def applies_for_smartinstall return false # added by Roman Aug 19th until service refactor return true if installation_is_within_range? && customer.is_homeowner? && has_selected_heated_items? false end |
#applies_for_smartsupport ⇒ Boolean
Whether this quote qualifies for SmartSupport — the
dealer/trade-pro version of the in-house service program (we send
an installer to support the trade pro on site).
1502 1503 1504 1505 1506 |
# File 'app/models/quote.rb', line 1502 def applies_for_smartsupport return true if customer.is_dealer_or_trade_pro_for_locator? && has_selected_heated_items? false end |
#apply_tier2_pricing? ⇒ Boolean
Whether or not to apply the tier2 pricing (customer discount) by default
469 470 471 |
# File 'app/models/quote.rb', line 469 def apply_tier2_pricing? is_sales_quote? end |
#available_contact_points ⇒ Array<ContactPoint>
Every ContactPoint reachable through this quote's opportunity
(primary party, customer, and every opportunity participant),
deduped — used to populate the recipient picker on the quote
transmission form.
849 850 851 852 853 854 |
# File 'app/models/quote.rb', line 849 def available_contact_points cps = opportunity.primary_party.contact_points.order(:position).to_a cps += opportunity.customer.contact_points.order(:position).to_a cps += opportunity.opportunity_participants.map { |op| op.party.contact_points.order(:position).to_a }.flatten cps.uniq end |
#available_contact_points_grouped_for_select ⇒ Hash{String=>Array<Array(String, Integer)>}
#available_contact_points reshaped into Rails grouped-select
input: { "Person Full Name" => [["detail (category)", id], …] }
so the transmission form renders an <optgroup> per party.
861 862 863 864 865 866 |
# File 'app/models/quote.rb', line 861 def available_contact_points_grouped_for_select available_contact_points.each_with_object({}) do |cp, hsh| hsh[cp.party.full_name] ||= [] hsh[cp.party.full_name] << ["#{cp.detail} (#{cp.category})", cp.id] end end |
#best_contact_point ⇒ ContactPoint?
Best contact point we can send the quote to — falls through quote
→ contact → customer until something transmittable (email/fax)
turns up. Used as the default to: when an agent clicks "Send"
without picking a recipient.
831 832 833 834 835 836 837 838 839 840 841 |
# File 'app/models/quote.rb', line 831 def best_contact_point contact_points.transmittable.first || (begin contact.contact_points.transmittable.first rescue StandardError nil end) || (begin customer.contact_points.transmittable.first rescue StandardError nil end) end |
#billing_address ⇒ Object
Alias for Customer#billing_address
171 |
# File 'app/models/quote.rb', line 171 delegate :billing_address, :company, to: :customer |
#build_activity ⇒ Activity
Builds (but does not save) a new Activity attached to this quote
with the quote's primary party already populated. Used by the CRM
activity-form helpers to seed a new activity without round-tripping
through nested form params.
479 480 481 |
# File 'app/models/quote.rb', line 479 def build_activity activities.build resource: self, party: primary_party end |
#buying_group ⇒ BuyingGroup
176 |
# File 'app/models/quote.rb', line 176 belongs_to :buying_group, optional: true |
#calculate_expiration_date ⇒ Date
Computed expiration date for the quote — the soonest of every
attached coupon's expiration and a default 60-day window from
created_at. Persisted by the #set_expiration_date before-update
callback so the customer sees a stable "valid through" date on
the PDF.
949 950 951 952 953 954 955 956 |
# File 'app/models/quote.rb', line 949 def calculate_expiration_date expirations = [] + (begin coupons.filter_map(&:expiration_date) rescue StandardError [] end) + [created_at + 60.days] expirations.min.to_date end |
#can_be_moved? ⇒ Boolean
Whether this quote can be reassigned to a different opportunity.
Locked once the quote has at least one non-cart, non-cancelled
Order so we don't orphan invoiced sales from their opportunity.
499 500 501 |
# File 'app/models/quote.rb', line 499 def can_be_moved? orders.active.non_carts.blank? end |
#can_be_transmitted? ⇒ Boolean
Whether the customer-facing transmission flow is allowed in this
state. Awaiting-transmission and pending-project-details quotes
can be sent for review; complete quotes can be re-sent for the
confirmation copy.
675 676 677 |
# File 'app/models/quote.rb', line 675 def can_be_transmitted? awaiting_transmission? || pending_project_details? || complete? end |
#can_convert? ⇒ Object
Alias for Convert_to_order_service#can_convert?
1093 |
# File 'app/models/quote.rb', line 1093 delegate :can_convert?, to: :convert_to_order_service |
#cancelable? ⇒ Boolean
State-machine guard for the cancel event — the quote can be
cancelled as long as no surviving order points at it.
1158 1159 1160 |
# File 'app/models/quote.rb', line 1158 def cancelable? orders.not_cancelled.empty? end |
#catalog ⇒ Object
Alias for Customer#catalog
170 |
# File 'app/models/quote.rb', line 170 delegate :catalog, to: :customer |
#check_pre_pack ⇒ void
This method returns an undefined value.
after_save callback that auto-transitions the quote into
pre_pack when it's awaiting transmission and the warehouse
signals that estimated packaging needs to happen first (see
#stop_for_pre_pack?). Skipped when every delivery is already
in pre-pack.
1647 1648 1649 |
# File 'app/models/quote.rb', line 1647 def check_pre_pack request_estimated_packaging if awaiting_transmission? && can_request_estimated_packaging? && !pre_pack? && !deliveries.all?(&:pre_pack?) && stop_for_pre_pack? end |
#communications ⇒ ActiveRecord::Relation<Communication>
184 |
# File 'app/models/quote.rb', line 184 has_many :communications, as: :resource, dependent: :nullify |
#company ⇒ Object
Alias for Customer#company
171 |
# File 'app/models/quote.rb', line 171 delegate :billing_address, :company, to: :customer |
#contact ⇒ Object
Alias for Opportunity#contact
168 |
# File 'app/models/quote.rb', line 168 delegate :customer, :contact, to: :opportunity |
#contact_points ⇒ ActiveRecord::Relation<ContactPoint>
189 |
# File 'app/models/quote.rb', line 189 has_and_belongs_to_many :contact_points, inverse_of: :quotes |
#convert_to_order_service ⇒ Quote::ConvertToOrder
Memoised ConvertToOrder service for this quote. The
service encapsulates the logic to turn a complete quote into one
or more Orders (one per delivery / origin) — view and controller
code calls quote.convert_to_order_service.can_convert? and
quote.to_order rather than instantiating the service themselves.
1089 1090 1091 |
# File 'app/models/quote.rb', line 1089 def convert_to_order_service @convert_to_order_service ||= Quote::ConvertToOrder.new(self) end |
#country ⇒ String?
ISO country of the Store this quote was placed against — used
by tax / shipping / addendum logic to branch on US vs CA vs other.
Swallows nil-chain errors and returns nil rather than raising.
1665 1666 1667 1668 1669 |
# File 'app/models/quote.rb', line 1665 def country customer.catalog.store.country rescue StandardError nil end |
#crm_link ⇒ String
Internal CRM URL for this quote's show page. Shared Activity,
Communication, and notification helpers call this to produce the
"open in CRM" link without each having to know the routes module.
1078 1079 1080 |
# File 'app/models/quote.rb', line 1078 def crm_link UrlHelper.instance.quote_path(self) end |
#currency_symbol ⇒ String
Currency glyph ($, €, £, …) for this quote, derived from the
quote's currency ISO code via the money gem. Used wherever the
quote PDF / CRM views need to print prices.
938 939 940 |
# File 'app/models/quote.rb', line 938 def currency_symbol Money::Currency.new(currency).symbol end |
#customer ⇒ Object
Alias for Opportunity#customer
168 |
# File 'app/models/quote.rb', line 168 delegate :customer, :contact, to: :opportunity |
#deep_dup ⇒ Quote
Deep-clones the quote and its non-shipping parent LineItems, plus
the contact-points join, but resets identity / lifecycle columns
(state goes back to pending, tax fields drop, parent_id and
delivery_id are stripped) so the duplicate can be saved as a
fresh draft. Used by "Revise quote" CRM action and by the
quote-revision workflow.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'app/models/quote.rb', line 129 def deep_dup deep_clone(include: :contact_points) do |original, copy| next unless copy.is_a?(Quote) copy.state = 'pending' original.line_items.parents_only.non_shipping.each do |li| li_attrs = li.attributes.dup.symbolize_keys 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] li_attrs[:discounted_price] = li_attrs[:price] if li_attrs[:discounted_price].to_f.zero? && li_attrs[:price].to_f.positive? copy.line_items.new(li_attrs) end end end |
#dependents(limit: 5) ⇒ Hash{Symbol=>ActiveRecord::Relation}
Quote dependents that block destruction — orders, child
revisions, and customer communications. Returned as a hash for
the "cannot delete" UI to enumerate why.
A quote with communications to a customer cannot be deleted.
A quote used as a parent of another quote is also restricted
(clear the children first). A quote with orders cannot be
deleted.
1129 1130 1131 1132 1133 1134 1135 |
# File 'app/models/quote.rb', line 1129 def dependents(limit: 5) { orders: orders.limit(limit), children: children.limit(limit), communications: communications.limit(limit) } end |
#editing_locked? ⇒ Boolean
True once the quote is in a terminal state — used by the
form/controller layer to read-only the line item rows so a
finalised or cancelled quote can't be edited in place.
929 930 931 |
# File 'app/models/quote.rb', line 929 def editing_locked? cancelled? || complete? end |
#effective_date_for_coupon ⇒ Date
Date used by the coupon engine to evaluate "is this coupon
currently valid?" against start_date/end_date windows. Prefers
an explicit override_coupon_date, then the date the quote was
completed, then created — falling back to today for brand-new
in-memory quotes. Keeps coupon evaluation stable after the quote
is finalised even if the coupon's window later shifts.
569 570 571 |
# File 'app/models/quote.rb', line 569 def effective_date_for_coupon override_coupon_date.presence || complete_datetime.try(:to_date) || created_at.try(:to_date) || Date.current end |
#exclude_manually_initiated_event?(_event) ⇒ false
State-machine helper — Models::Auditable hook used to suppress
certain manually-initiated events from PaperTrail's audit trail.
Quotes don't suppress anything, so this always returns false.
518 519 520 |
# File 'app/models/quote.rb', line 518 def exclude_manually_initiated_event?(_event) false end |
#extra_plans_file_name(with_extension = true) ⇒ String
Filename for the secondary "installation plans" PDF that ships
alongside the main quote — same naming convention as #file_name
with a _plans suffix so attachments don't collide.
775 776 777 |
# File 'app/models/quote.rb', line 775 def extra_plans_file_name(with_extension = true) "quote_#{reference_number}_plans#{'.pdf' if with_extension}" end |
#file_name(with_extension = true) ⇒ String
Filename used when the customer-facing quote PDF is downloaded or
attached to an email — reference-number-stamped so a customer with
multiple revisions can tell them apart.
765 766 767 |
# File 'app/models/quote.rb', line 765 def file_name(with_extension = true) "quote_#{reference_number}#{'.pdf' if with_extension}" end |
#get_next_reference_number ⇒ String
Pulls the next quote reference number from the
quotes_reference_numbers_seq Postgres sequence and prefixes it
with the quote-type code (e.g. SQ, MQ, TQ). Sequence-based
so concurrent quote creates can't collide.
1363 1364 1365 1366 1367 1368 1369 1370 |
# File 'app/models/quote.rb', line 1363 def get_next_reference_number ref_base = begin self.class.lease_connection.execute("SELECT nextval('quotes_reference_numbers_seq') AS reference_number").first['reference_number'] rescue StandardError nil end "#{quote_type}#{ref_base}" end |
#goods_product_line_ids ⇒ Array<Integer>
Distinct primary product-line ids represented by the goods on this
quote. Drives the "what kind of project is this?" routing in the
CRM (heated floors vs snow melt vs towel warmers) and the addendum
selection for the customer-facing PDF.
464 465 466 |
# File 'app/models/quote.rb', line 464 def goods_product_line_ids line_items.goods.joins(:item).where.not(Item[:primary_product_line_id].eq(nil)).pluck(Arel.sql('distinct items.primary_product_line_id')) end |
#has_discounts? ⇒ Boolean
True when at least one line item carries a discount — drives the
"savings" badge on the customer-facing PDF and unlocks the
discount-detail rows.
805 806 807 |
# File 'app/models/quote.rb', line 805 def has_discounts? line_items.any?(&:is_discounted?) end |
#has_no_instant_quoting_rooms ⇒ true
Validation stub left over from the deprecated Instant Quote (IQ)
system — kept registered as a validate :has_no_instant_quoting_rooms
so removing it is a separate, easy-to-revert change.
1395 1396 1397 1398 |
# File 'app/models/quote.rb', line 1395 def has_no_instant_quoting_rooms # Legacy IQ validation - no longer needed after IQ system deprecated true end |
#has_onsite_smartsupport? ⇒ Boolean
Whether on-site SmartSupport is available — install is within the
service-distance radius from the WarmlyYours HQ.
1523 1524 1525 1526 1527 |
# File 'app/models/quote.rb', line 1523 def has_onsite_smartsupport? return true if service_distance.present? && (service_distance < SMART_SERVICES_MAX_DISTANCE) false end |
#has_pre_pack_deliveries? ⇒ Boolean
True when the quote itself is in pre_pack or any of its
deliveries is in pre-pack. Used by the warehouse dashboard.
1618 1619 1620 |
# File 'app/models/quote.rb', line 1618 def has_pre_pack_deliveries? pre_pack? || deliveries.any?(&:pre_pack?) end |
#has_selected_heated_items? ⇒ Boolean
Whether the quote actually contains items eligible for the
smart-services program — branches on customer type so we look at
the right side of the eligibility data.
1534 1535 1536 1537 1538 1539 1540 1541 1542 |
# File 'app/models/quote.rb', line 1534 def has_selected_heated_items? if customer.is_homeowner? smartinstall_data.present? elsif customer.is_dealer_or_trade_pro_for_locator? smartsupport_info.present? else false end end |
#installation_country_iso ⇒ Object
Alias for Opportunity#installation_country_iso
173 |
# File 'app/models/quote.rb', line 173 delegate :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity |
#installation_country_iso3 ⇒ Object
Alias for Opportunity#installation_country_iso3
173 |
# File 'app/models/quote.rb', line 173 delegate :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity |
#installation_is_within_range? ⇒ Boolean
True when #service_distance is non-nil and inside the
SMART_SERVICES_MAX_DISTANCE radius (currently 100 mi from HQ).
1548 1549 1550 |
# File 'app/models/quote.rb', line 1548 def installation_is_within_range? service_distance.present? && (service_distance <= SMART_SERVICES_MAX_DISTANCE) # within 100 miles from office end |
#installation_postal_code ⇒ Object
Alias for Opportunity#installation_postal_code
173 |
# File 'app/models/quote.rb', line 173 delegate :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity |
#installation_state_code ⇒ Object
Alias for Opportunity#installation_state_code
173 |
# File 'app/models/quote.rb', line 173 delegate :installation_postal_code, :installation_state_code, :installation_country_iso, :installation_country_iso3, to: :opportunity |
#insulation_sq_ft ⇒ Integer
Total square footage of underlayment / insulation across every
indoor room on the quote — used by the cork/insulation
cross-sell logic and by the catalog to suggest the right
underlayment quantity. Outdoor rooms (snow-melt) are excluded
because they don't take indoor underlayment.
1245 1246 1247 1248 1249 1250 1251 |
# File 'app/models/quote.rb', line 1245 def insulation_sq_ft sqft = 0 room_configurations.each do |rc| sqft += rc.insulation_surface.to_i if rc.room_type&.is_indoor? && (rc.insulation_surface.to_i > 0) end sqft end |
#is_first_quote? ⇒ Boolean
True when this is the original quote rather than a revision —
i.e. nothing pointed at as parent_id.
1226 1227 1228 |
# File 'app/models/quote.rb', line 1226 def is_first_quote? parent_id.nil? end |
#is_revision? ⇒ Boolean
True when this quote was spawned via #deep_dup from an earlier
quote — the inverse of #is_first_quote?.
1234 1235 1236 |
# File 'app/models/quote.rb', line 1234 def is_revision? parent_id.present? end |
#is_sales_quote? ⇒ Boolean
True for the standard customer-facing sales quote (SQ) —
the main quote-type in production. Marketing (MQ) and tech
(TQ) quotes return false.
1218 1219 1220 |
# File 'app/models/quote.rb', line 1218 def is_sales_quote? quote_type == SALES_QUOTE end |
#is_smart_service_quote? ⇒ Boolean
True when every non-shipping line on the quote is a smart-service
SKU — used to switch the PDF / fulfillment path to the
services-only template.
1557 1558 1559 |
# File 'app/models/quote.rb', line 1557 def is_smart_service_quote? line_items.non_shipping.all?(&:is_smart_service?) end |
#linked_support_cases ⇒ ActiveRecord::Relation<LinkedSupportCase>
186 |
# File 'app/models/quote.rb', line 186 has_many :linked_support_cases, through: :room_configurations, source: :support_cases |
#local_sales_rep ⇒ Object
Alias for Customer#local_sales_rep
172 |
# File 'app/models/quote.rb', line 172 delegate :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer |
#lookup_link ⇒ String
Public-facing URL for the customer's "look up your quote" page
(warmlyyours.com /quote-lookup). Embedded in transmission emails
so the customer can pull up their quote without an account.
1656 1657 1658 |
# File 'app/models/quote.rb', line 1656 def lookup_link "https://#{WEB_HOSTNAME}/quote-lookup?quote_id=#{id}" end |
#main_rep ⇒ Employee?
Primary rep this quote "belongs to" for commission and CRM
ownership. Aliases #primary_sales_rep so view code can call a
consistent name across Quote, Order, and Opportunity.
883 884 885 |
# File 'app/models/quote.rb', line 883 def main_rep primary_sales_rep end |
#material_alerts ⇒ ActiveRecord::Relation<MaterialAlert>
187 |
# File 'app/models/quote.rb', line 187 has_many :material_alerts, dependent: :destroy |
#messaging_logs ⇒ ActiveRecord::Relation<MessagingLog>
185 |
# File 'app/models/quote.rb', line 185 has_many :messaging_logs, dependent: :destroy, as: :resource |
#name ⇒ String
Short human label like Quote #SQ12345. Used in notifications,
mailers, and audit-trail rows where reference number alone needs
the word "Quote" in front of it.
1049 1050 1051 |
# File 'app/models/quote.rb', line 1049 def name "Quote ##{reference_number}" end |
#ok_to_customer_cancel? ⇒ Boolean
Whether the customer is allowed to self-cancel from the public
quote-lookup page — only when the quote is still pending and has
no surviving orders attached.
1150 1151 1152 |
# File 'app/models/quote.rb', line 1150 def ok_to_customer_cancel? CUSTOMER_CANCELABLE_STATES.include?(state.to_sym) && orders.not_cancelled.empty? end |
#ok_to_delete? ⇒ Boolean
True when #dependents is empty across the board — the precondition
for the destroy button.
1141 1142 1143 |
# File 'app/models/quote.rb', line 1141 def ok_to_delete? dependents.values.flatten.empty? end |
#open_activities_counter ⇒ Integer
Count of CRM follow-ups still open against this quote (or its
opportunity), filtered to the activities that show by default in
the rep's queue. Drives the badge on the quote sidebar.
1595 1596 1597 |
# File 'app/models/quote.rb', line 1595 def open_activities_counter all_activities.open_activities.visible_by_default.size end |
#opportunity ⇒ Opportunity
175 |
# File 'app/models/quote.rb', line 175 belongs_to :opportunity, optional: true |
#participants_options_for_select ⇒ Array<Array(String, Integer)>?
Opportunity participants formatted for a Rails select helper —
[[full_name, party_id], …]. Drives the "additional participants"
multi-select on the quote form.
1469 1470 1471 |
# File 'app/models/quote.rb', line 1469 def opportunity&.opportunity_participants&.map { |scp| [scp.party.full_name, scp.party_id] } end |
#post_communication_queued_hook(_params = {}) ⇒ void
This method returns an undefined value.
Hook fired by the communication subsystem the moment a transmission
email/fax is enqueued — finalises the quote if every other gate has
already cleared. Called by the Communication observer after the
outbound message is queued for delivery.
704 705 706 707 |
# File 'app/models/quote.rb', line 704 def post_communication_queued_hook(_params = {}) # Handles post transmission complete if can_complete? end |
#post_communication_sent_hook(_params = {}) ⇒ void
This method returns an undefined value.
Hook fired once the transmission has actually been sent by the
provider — finalises the quote if every other gate has cleared.
Mirror of #post_communication_queued_hook for providers that
emit a separate "sent" callback.
715 716 717 718 |
# File 'app/models/quote.rb', line 715 def post_communication_sent_hook(_params = {}) # Handles post transmission complete if can_complete? end |
#pre_packable? ⇒ Boolean
Whether the warehouse can run an estimated-packaging pre-pack on
this quote — at least one delivery must have a shipment that
isn't already packed or pre-packed.
1167 1168 1169 |
# File 'app/models/quote.rb', line 1167 def pre_packable? deliveries.any? && deliveries.any? { |d| d.shipments.any? { |s| !s.packed_or_pre_packed? } } end |
#prefix_for_reference_number ⇒ String
Fallback prefix used when #quote_type hasn't been chosen yet —
derives the two-letter code from the parent Opportunity's type
(so a sales opp gives SQ, marketing opp MQ, etc.).
1386 1387 1388 |
# File 'app/models/quote.rb', line 1386 def prefix_for_reference_number "#{opportunity.opportunity_type}Q" end |
#prepared_for_name ⇒ String
1192 1193 1194 |
# File 'app/models/quote.rb', line 1192 def prepared_for_name (contact || customer).name end |
#prevent_recalculate_shipping? ⇒ Boolean
Guard used by Models::ShipQuotable to skip live ShipEngine rate
quoting — already-finalised quotes don't requote, and the flag
also blocks re-entrancy while a rate request is in flight.
1405 1406 1407 |
# File 'app/models/quote.rb', line 1405 def prevent_recalculate_shipping? %i[complete cancelled].include?(state.to_sym) || retrieving_shipping_costs? end |
#pricing_program_description ⇒ String
Human label for the pricing program in effect on this quote — the
tier2 coupon title when the customer is on a dealer/contractor
tier, otherwise "MSRP/Catalog". Surfaced on the quote PDF and
on the CRM "pricing" badge.
1432 1433 1434 |
# File 'app/models/quote.rb', line 1432 def pricing_program_description tier2_program_pricing_coupon.try(:title) || 'MSRP/Catalog' end |
#pricing_program_discount ⇒ Integer
Percentage discount the active tier2 pricing program applies to
goods, or 0 when the quote is on plain MSRP. Companion to
#pricing_program_description.
1441 1442 1443 |
# File 'app/models/quote.rb', line 1441 def pricing_program_discount (tier2_program_pricing_coupon.try(:amount_goods).presence || 0).to_i end |
#primary_party ⇒ Party
509 510 511 |
# File 'app/models/quote.rb', line 509 def primary_party contact || customer end |
#primary_sales_rep ⇒ Object
Alias for Customer#primary_sales_rep
172 |
# File 'app/models/quote.rb', line 172 delegate :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer |
#ready_to_complete? ⇒ Boolean
Coarser readiness check than #will_complete? — only requires
that no room is in a half-resolved state. Used by the UI to grey
out the "Send to Customer" button before margins/etc are
validated.
785 786 787 788 789 |
# File 'app/models/quote.rb', line 785 def ready_to_complete? # here we want good shipping address (allow nil if instant_quoting), and any cancelled rooms to be deleted or resolved before we can get a complete and convertable quote # shipping_address_nil_or_valid? && all_rooms_complete_or_cancelled_or_draft? end |
#recipient_email ⇒ String?
Email address the quote should be sent to — prefers an explicit
quote-level contact-point email, then the primary party's email,
then the customer's. Used as the default to: on transmission.
620 621 622 623 624 |
# File 'app/models/quote.rb', line 620 def recipient_email contact_points.emails.first&.detail || primary_party&.email || customer&.email end |
#recipient_name ⇒ String?
Best display name for the person this quote is addressed to —
walks #primary_party → shipping address → customer until it
finds one. Used as the salutation on the quote PDF and in email.
611 612 613 |
# File 'app/models/quote.rb', line 611 def recipient_name primary_party&.name || shipping_address&.person_name || customer&.name end |
#reference_number_with_name ⇒ String
Reference number plus the rep-supplied free-text suffix (the rep
uses this slot for the project name). Format: SQ12345 - Smith Master Bath. Used as the display label in CRM lists and on the
quote PDF header.
1040 1041 1042 |
# File 'app/models/quote.rb', line 1040 def reference_number_with_name [reference_number, suffix].compact.join(' - ') end |
#reference_number_with_opp_name ⇒ String
Reference number with the parent Opportunity's name appended —
used in dropdowns where the rep needs to disambiguate between
multiple quotes on the same opportunity.
1058 1059 1060 |
# File 'app/models/quote.rb', line 1058 def reference_number_with_opp_name "#{reference_number} #{opportunity.try(:name)}" end |
#reference_number_with_opp_name_when_specified ⇒ String
#reference_number_with_opp_name but skips the appended job name
when the opportunity has a placeholder/unknown name (so we don't
render "SQ12345 Job for Customer XYZ" — just SQ12345).
1067 1068 1069 1070 1071 |
# File 'app/models/quote.rb', line 1067 def reference_number_with_opp_name_when_specified s = reference_number.to_s s += " #{opportunity.name}" unless opportunity.nil? || opportunity.unknown_job_name? s end |
#request_plans_or_complete ⇒ void
This method returns an undefined value.
State-machine driver that nudges the quote forward (or back) based
on its current room-configuration mix and line-item presence.
Called from the rooms controller after a room edit/destroy so the
quote auto-advances to awaiting_transmission,
awaiting_completed_installation_plans, or
pending_project_details without the rep having to click a state
button. No-op once the quote is complete or cancelled.
896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 |
# File 'app/models/quote.rb', line 896 def request_plans_or_complete return if complete? || cancelled? # Let's see if this is still necessary, as far as i can find it's only on quotes_controller # enter_rooms and room destroy that # this is called and the room association is already up to date at that point # room_configurations.each(&:reload) self.do_not_set_totals = true log_prefix = "Quote(#{id}):request_plans_or_complete" # If no room but line items, let's go straight to awaiting transmission if room_configurations.empty? && line_items.present? logger.info "#{log_prefix} no rooms but line present. attempting ready to transmit" ready_to_transmit! if can_ready_to_transmit? # If all rooms are complete then set quote to be awaiting_transmission elsif can_installation_plans_complete? logger.info "#{log_prefix} installation plan can complete, trying" installation_plans_complete! elsif can_ready_for_design? logger.info "#{log_prefix} can ready for design, trying" ready_for_design! elsif can_pending_project_details? logger.info "#{log_prefix} no line items and nothing else happening, go back to draft" pending_project_details! else logger.info "#{log_prefix} reverting to draft instant quoting rooms" # Legacy IQ cleanup removed - no longer needed after IQ system deprecated end end |
#reset_discount_on_ships_economy_change ⇒ Boolean? (protected)
after_save callback: when the rep flips a quote between economy
and standard ground shipping, drop any previously-applied
shipping discount so the next pricing pass recalculates against
the new shipping basis. Item-level pricing is left alone.
1700 1701 1702 1703 1704 1705 |
# File 'app/models/quote.rb', line 1700 def reset_discount_on_ships_economy_change return unless saved_change_to_ships_economy? reset_discount(reset_item_pricing: false) true end |
#schedule_follow_up(follow_up_task_type: nil) ⇒ Activity?
Creates (or replaces) a quote-centric follow-up activity on the
opportunity so a rep is reminded to chase the customer. Cancels
any existing opp-level follow-ups first, and skips entirely for
anonymous Quote-Builder guests with no email/phone.
1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 |
# File 'app/models/quote.rb', line 1344 def schedule_follow_up(follow_up_task_type: nil) # Don't create follow-up activities for guest customers without contact info # These are anonymous Quote Builder users who haven't provided email/phone return if customer.guest? && !opportunity.emailable? && !opportunity.voice_callable? return if opportunity.activities.quofu.open_activities.present? # cancel oppfu follow ups opportunity.cancel_oppfu # recreate a quofu centric follow up opportunity.create_follow_up_activity(follow_up_task_type:) end |
#secondary_sales_rep ⇒ Object
Alias for Customer#secondary_sales_rep
172 |
# File 'app/models/quote.rb', line 172 delegate :primary_sales_rep, :secondary_sales_rep, :local_sales_rep, to: :customer |
#selection_name ⇒ String
Label used by the global Ransack/select2 lookup widget when this
quote is offered as a search result. Aliases
#reference_number_with_name.
1274 1275 1276 |
# File 'app/models/quote.rb', line 1274 def selection_name reference_number_with_name end |
#send_profit_review_notification ⇒ void
This method returns an undefined value.
Notifies internal staff (margin desk / pricing) that this quote
has entered profit_review so they can review the pricing
exception. Wired up as an after_transition from the state
machine.
1479 1480 1481 |
# File 'app/models/quote.rb', line 1479 def send_profit_review_notification OrdersMailer.quote_profit_review_notification(self).deliver_later end |
#sender ⇒ Employee?
Rep treated as the "from" on outbound communications and
notifications about this quote. Falls through customer-attached
primary rep → opportunity-attached primary rep → customer's
primary rep so an orphan quote without a directly-assigned rep
still has a sender.
1203 1204 1205 1206 1207 1208 1209 1210 1211 |
# File 'app/models/quote.rb', line 1203 def sender if primary_sales_rep primary_sales_rep elsif opportunity.primary_sales_rep opportunity.primary_sales_rep elsif customer.primary_sales_rep customer.primary_sales_rep end end |
#service_distance ⇒ Float?
Driving distance (in miles) from the WarmlyYours Lake Zurich HQ
to the installation postal code on this quote — the gating
measure for SmartInstall / SmartSupport eligibility (see
SMART_SERVICES_MAX_DISTANCE).
1567 1568 1569 1570 |
# File 'app/models/quote.rb', line 1567 def service_distance zip_code = opportunity.installation_postal_code SmartServicesController.helpers.calculate_distance_from_lz(zip_code) end |
#set_currency ⇒ String?
Pulls the currency from the customer's home Store and writes it
onto the quote so prices print in the right denomination. Used
when a quote is created on behalf of a customer whose store
differs from the agent's default.
977 978 979 980 981 982 983 984 |
# File 'app/models/quote.rb', line 977 def set_currency store_currency = begin opportunity.customer.store.currency rescue StandardError nil end self.currency = store_currency if store_currency end |
#set_default_quote_type ⇒ Object (protected)
1687 1688 1689 1690 1691 1692 |
# File 'app/models/quote.rb', line 1687 def set_default_quote_type self.quote_type ||= prefix_for_reference_number return unless quote_type_changed? && reference_number.present? self.reference_number = "#{quote_type}#{reference_number[2..50]}" end |
#set_expiration_date ⇒ Object (protected)
1673 1674 1675 1676 1677 |
# File 'app/models/quote.rb', line 1673 def set_expiration_date return if complete? self.expiration_date = calculate_expiration_date end |
#set_min_profit_markup ⇒ Object (protected)
1679 1680 1681 1682 1683 1684 1685 |
# File 'app/models/quote.rb', line 1679 def set_min_profit_markup self.min_profit_markup = if quote_type == 'SQ' default_sales_markup else 0 end end |
#set_opportunity_value ⇒ void
This method returns an undefined value.
after_commit hook that recomputes the parent Opportunity's
rolled-up dollar value when this quote's totals change.
1257 1258 1259 |
# File 'app/models/quote.rb', line 1257 def set_opportunity_value opportunity&.calculate_value end |
#set_priority(save = true) ⇒ String, true
Computes the highest urgency among the quote's room
configurations and writes it onto priority (urgent, rush,
standard, etc.). Drives badge colour in the rep queue.
995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 |
# File 'app/models/quote.rb', line 995 def set_priority(save = true) highest_priority = begin RoomConfiguration::PRIORITIES[room_configurations.map(&:priority_urgency_factor).min] rescue StandardError 'standard' end if save update_attribute(:priority, highest_priority) else self.priority = highest_priority end end |
#set_reference_number ⇒ String
before_create callback that stamps #reference_number from the
Postgres sequence unless the caller (e.g. data import) has already
supplied one.
1377 1378 1379 |
# File 'app/models/quote.rb', line 1377 def set_reference_number self.reference_number ||= get_next_reference_number end |
#shipping_address_nil_or_valid? ⇒ Boolean
True when the quote either has no shipping address or has one
that passes its own validations — used as a precondition before
transitioning to states that require shippable goods.
796 797 798 |
# File 'app/models/quote.rb', line 796 def shipping_address_nil_or_valid? shipping_address.nil? || shipping_address.valid? end |
#smartsupport_info ⇒ Hash?
SmartSupport metadata bundle (line items eligible for the program,
estimated visit time, etc.) when the install location is within
service range; nil otherwise.
1513 1514 1515 1516 1517 |
# File 'app/models/quote.rb', line 1513 def smartsupport_info return nil if service_distance.nil? smartsupport_data(service_distance) end |
#sms_enabled_numbers ⇒ Array<String>
Distinct SMS-capable numbers for every participant on this quote's
opportunity, formatted for the SMS gateway. Drives the recipient
list when the rep texts an update from the quote page.
727 728 729 |
# File 'app/models/quote.rb', line 727 def sms_enabled_numbers ContactPoint.joins(:party).merge(all_participants).sms_numbers.order(:detail).map(&:formatted_for_sms).uniq end |
#sms_messages ⇒ ActiveRecord::Relation<SmsMessage>
Two-way SMS history for #sms_enabled_numbers — the message
thread shown on the quote sidebar.
735 736 737 |
# File 'app/models/quote.rb', line 735 def SmsMessage.for_numbers(sms_enabled_numbers) end |
#sold? ⇒ Boolean
True once at least one non-cart, non-cancelled Order exists
against this quote — i.e. the customer accepted and we
transmitted.
1114 1115 1116 |
# File 'app/models/quote.rb', line 1114 def sold? orders.non_carts.active.present? end |
#special_ordering_instructions ⇒ String?
Free-text "special instructions" stored on the billing-address
party — printed on the warehouse pick ticket when present (e.g.
"no liftgate", "call before delivery"). Swallows nil-chain errors
for headless test fixtures.
964 965 966 967 968 |
# File 'app/models/quote.rb', line 964 def special_ordering_instructions customer.billing_address.party.ordering_instructions rescue StandardError nil end |
#stop_for_pre_pack? ⇒ Boolean
State-machine guard — true when transmission must wait for the
warehouse to provide an estimated-packaging quote. Skipped for
e-commerce, no-shipping-address, empty-line, or already-packed
cases. The list of reasons (need_to_pre_pack_reasons) keeps the
rep apprised of why the quote is parked.
1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 |
# File 'app/models/quote.rb', line 1629 def stop_for_pre_pack? return false unless can_request_estimated_packaging? return false if shipping_address.nil? return false if customer.is_e_commerce_misc? return false if line_items.empty? return false if room_configurations.present? && !room_configurations.all?(&:transmittable_and_settled?) return false if deliveries.all? { |d| (d.shipments.packed.any? && d.all_lines_allocated_to_shipments?) || d.is_smart_service? } need_to_pre_pack_reasons.any? end |
#stop_for_profit_review? ⇒ Boolean
State-machine guard — true when transmission must pause for
margin desk review. E-commerce miscellaneous customers are
exempt; rooms-mid-design and empty-line-item quotes don't trigger
it either.
1605 1606 1607 1608 1609 1610 1611 1612 |
# File 'app/models/quote.rb', line 1605 def stop_for_profit_review? # Etailer orders are immune return false if customer.is_e_commerce_misc? return false if line_items.empty? return false if room_configurations.present? && !room_configurations.all?(&:transmittable?) !profit_margins_met? end |
#store ⇒ Object
Alias for Customer#store
169 |
# File 'app/models/quote.rb', line 169 delegate :store, to: :customer |
#support_cases ⇒ ActiveRecord::Relation<SupportCase>
190 |
# File 'app/models/quote.rb', line 190 has_and_belongs_to_many :support_cases |
#tier2_program_pricing ⇒ Discount?
The tier2 (Discount) row representing the customer's
contractor/dealer pricing program on this quote.
1413 1414 1415 |
# File 'app/models/quote.rb', line 1413 def tier2_program_pricing discounts.tier2.first end |
#tier2_program_pricing_coupon ⇒ Coupon?
The Coupon that powers #tier2_program_pricing, when there is
one — used by #pricing_program_description and
#pricing_program_discount to label the program.
1422 1423 1424 |
# File 'app/models/quote.rb', line 1422 def tier2_program_pricing_coupon tier2_program_pricing&.coupon end |
#to_liquid ⇒ Liquid::QuoteDrop
Liquid drop wrapper used when this quote is rendered into the
transmission email/SMS templates — exposes only the safe-for-template
accessors via Liquid::QuoteDrop.
454 455 456 |
# File 'app/models/quote.rb', line 454 def to_liquid Liquid::QuoteDrop.new self end |
#to_order(order = nil, txid = nil) ⇒ Array<Order>
Convert this quote into one or more Orders via the
ConvertToOrder service. Optionally seeds with an
existing order (e.g. a cart already created in the e-commerce
flow) and a transaction id used to dedupe webhook retries.
1105 1106 1107 |
# File 'app/models/quote.rb', line 1105 def to_order(order = nil, txid = nil) convert_to_order_service.convert(order, txid) end |
#to_s ⇒ String
Display string used by Rails helpers and inspectors —
"Quote # SQ12345 - Smith Master Bath".
1265 1266 1267 |
# File 'app/models/quote.rb', line 1265 def to_s "Quote # #{reference_number_with_name}" end |
#total_amps ⇒ Float
Sum of every room's calculated electrical load, in amps. Used by
the controls/breaker recommendation engine.
1585 1586 1587 |
# File 'app/models/quote.rb', line 1585 def total_amps room_configurations.to_a.sum { |rc| rc.calculate_total_amps.round(2) }&.round(2) end |
#track_profit? ⇒ Boolean
Whether this quote should run through the profit-margin gate —
only sales quotes with at least one profitable line item are
subject to the profit-review state.
1577 1578 1579 |
# File 'app/models/quote.rb', line 1577 def track_profit? profitable_line_items.present? && is_sales_quote? end |
#tracking_email_address ⇒ String
Encrypted plus-addressed inbox alias used as the Reply-To on
quote emails — replies bounce back into Heatwave and are
auto-attached to this quote's communication thread.
1283 1284 1285 1286 1287 1288 |
# File 'app/models/quote.rb', line 1283 def tracking_email_address domain = Rails.application.config.x.email_domain prefix = 'quo' encrypted_id = Encryption.encrypt_string(id.to_s) "#{prefix}+id#{encrypted_id}@#{domain}" end |
#uploads ⇒ ActiveRecord::Relation<Upload>
182 |
# File 'app/models/quote.rb', line 182 has_many :uploads, as: :resource, dependent: :destroy |
#versions_for_audit_trail(_params = {}) ⇒ ActiveRecord::Relation<RecordVersion>
PaperTrail versions for the quote and every record that hangs off
it — its line items, deliveries, and discounts — pulled in a
single query via JSONB reference_data lookup. Powers the
quote's "audit trail" tab in the CRM.
579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 |
# File 'app/models/quote.rb', line 579 def versions_for_audit_trail(_params = {}) query_sql = %q{ (item_type = 'Quote' AND item_id = :id) OR ( item_type = 'LineItem' AND reference_data @> '{"resource_type": "Quote"}' AND reference_data @> :resource_id_json ) OR ( item_type = 'Delivery' AND reference_data @> :quote_id_json ) OR ( item_type = 'Discount' AND reference_data @> :itemizable_id_json AND reference_data @> '{"itemizable_type": "Quote"}' ) } RecordVersion.where( query_sql, id:, resource_id_json: { resource_id: id }.to_json, quote_id_json: { quote_id: id }.to_json, itemizable_id_json: { itemizable_id: id }.to_json ) end |
#will_complete? ⇒ Boolean
State-machine guard — true when the quote has every gate cleared
for a complete transition: at least one line item, valid
shipping address (or none), all rooms settled, and profit margins
met.
632 633 634 635 636 637 |
# File 'app/models/quote.rb', line 632 def will_complete? line_items.any? && shipping_address_nil_or_valid? && all_rooms_complete_or_cancelled_or_draft? && profit_margins_met? end |
#will_installation_plans_complete? ⇒ Boolean
State-machine guard for the installation_plans_complete event —
rooms must exist and all be settled, and at least one line item
must be on the quote.
644 645 646 |
# File 'app/models/quote.rb', line 644 def will_installation_plans_complete? room_configurations.exists? && all_rooms_complete_or_cancelled_or_draft? && line_items.present? end |
#will_pending_project_details? ⇒ Boolean
State-machine guard for the pending_project_details event —
we're either coming from awaiting-design, or there are no line
items but at least one draft room.
684 685 686 687 |
# File 'app/models/quote.rb', line 684 def will_pending_project_details? awaiting_completed_installation_plans? || (line_items.empty? && room_configurations.any?(&:draft?)) end |
#will_ready_for_design? ⇒ Boolean
State-machine guard for the ready_for_design event — at least
one room exists and at least one is currently in design.
652 653 654 |
# File 'app/models/quote.rb', line 652 def will_ready_for_design? room_configurations.exists? && any_rooms_in_design? end |
#will_ready_to_transmit? ⇒ Boolean
State-machine guard for the ready_to_transmit event — line
items exist, shipping is valid, margins are met, and the warehouse
isn't asking for a pre-pack first.
661 662 663 664 665 666 667 |
# File 'app/models/quote.rb', line 661 def will_ready_to_transmit? line_items.any? && shipping_address_nil_or_valid? && profit_margins_met? && !stop_for_pre_pack? && (room_configurations.empty? || room_configurations.all?(&:transmittable?)) end |
#zones ⇒ nil
Placeholder for a planned "zones" concept (groups of rooms
controlled by a single thermostat). Not implemented — left as a
stub because the schema and view code expect the method to exist.
873 874 875 876 |
# File 'app/models/quote.rb', line 873 def zones # Zones are combinations of room configurations which are controlled together and regular rooms # Ramie: I'd say this is an unfortunate choice of name. we already have a well developed concept of room zones, rectangular areas within a room that are heateded areas, along with the model, methods and etc. end |