diff --git a/app/controllers/public_estimates_controller.rb b/app/controllers/public_estimates_controller.rb new file mode 100644 index 0000000000..2150038028 --- /dev/null +++ b/app/controllers/public_estimates_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class PublicEstimatesController < ApplicationController + + skip_before_action :authenticate_user! + + def show + @facilities = Facility.active.alphabetized + @facility = @facilities.find_by(id: params[:facility_id]) + @customer_type = customer_type + @customer_type_options = customer_type_options + @price_group = PriceGroup.for_public_estimate(@customer_type) + @products = @facility ? priced_products : Product.none + @estimate = build_estimate if @price_group && requested_quantities.any? + @total = @estimate.estimate_details.sum { |estimate_detail| estimate_detail.cost || 0 } if @estimate + end + + private + + def customer_types + Settings.public_estimates.customer_types.map(&:to_s) + end + + def customer_type + customer_types.include?(params[:customer_type]) ? params[:customer_type] : customer_types.first + end + + def customer_type_options + customer_types.map { |key| [t(".customer_types.#{key}"), key] } + end + + def priced_products + return Product.none if @price_group.blank? + + facility_products.where( + id: PricePolicy.current_for_date(Time.current).purchaseable + .where(price_group: @price_group).select(:product_id), + ) + end + + def facility_products + @facility.products.active.available_for_estimates.where.not(type: "Bundle").alphabetized + end + + def requested_quantities + @requested_quantities ||= permitted_product_values(:quantities).select { |_id, quantity| quantity.to_i.positive? } + end + + def requested_durations + @requested_durations ||= permitted_product_values(:durations) + end + + def permitted_product_values(key) + values = params[key] + return {} unless values.is_a?(ActionController::Parameters) + + values.permit(@products.map { |product| product.id.to_s }).to_h + end + + def build_estimate + estimate = Estimate.new(facility: @facility, price_group: @price_group) + + products = @products.where(id: requested_quantities.keys).index_by { |product| product.id.to_s } + + requested_quantities.each do |product_id, quantity| + product = products[product_id] + next if product.blank? + + estimate.estimate_details.build( + product:, + quantity: quantity.to_i, + duration: requested_durations[product_id].presence, + duration_unit: product.time_unit, + ) + end + + estimate.estimate_details.each(&:assign_price_policy_and_cost) + estimate + end + +end diff --git a/app/models/price_group.rb b/app/models/price_group.rb index bbd75a5018..e89bc1cdec 100644 --- a/app/models/price_group.rb +++ b/app/models/price_group.rb @@ -38,6 +38,13 @@ def self.external globals.find_by(name: Settings.price_group.name.external) end + def self.for_public_estimate(customer_type) + name = Settings.price_group.name.to_h[customer_type.to_s.to_sym] + return if name.blank? + + globals.find_by(name:) + end + def self.nonbillable base end diff --git a/app/views/public_estimates/show.html.haml b/app/views/public_estimates/show.html.haml new file mode 100644 index 0000000000..84ef28d361 --- /dev/null +++ b/app/views/public_estimates/show.html.haml @@ -0,0 +1,62 @@ += content_for :h1 do + = t(".title") + +%p= t(".intro") + += form_tag estimate_path, method: :get do + .hide-from-print + .inline-form-controls + %div + = label_tag :customer_type, t(".customer_type") + = select_tag :customer_type, options_for_select(@customer_type_options, @customer_type), class: "form-control", onchange: "this.form.submit();" + .margin_x + = label_tag :facility_id, Facility.model_name.human + = select_tag :facility_id, options_from_collection_for_select(@facilities, :id, :name, params[:facility_id]), include_blank: true, class: "form-control", onchange: "this.form.submit();" + + - if @facility.present? + %h3= t(".choose_products") + %table.table + %thead + %tr + %th= Product.model_name.human + %th= EstimateDetail.human_attribute_name(:quantity) + %th= EstimateDetail.human_attribute_name(:duration) + %tbody + - @products.each do |product| + %tr + %td= product.name + %td= number_field_tag "quantities[#{product.id}]", params.dig(:quantities, product.id.to_s), min: 0, style: "width: 6em;" + %td + - if product.time_unit.present? + = number_field_tag "durations[#{product.id}]", params.dig(:durations, product.id.to_s), min: 1, style: "width: 6em;" + = EstimateDetail.human_attribute_name("duration_unit.#{product.time_unit}", count: 2) + = submit_tag t(".calculate"), class: "btn btn-primary", name: nil + +- if @estimate.present? + %h3= t(".results") + .show-for-print + %p + %strong= "#{t('.customer_type')}:" + = t(".customer_types.#{@customer_type}") + %p + %strong= "#{Facility.model_name.human}:" + = @facility.name + %table.table.table-striped + %thead + %tr + %th= Product.model_name.human + %th= EstimateDetail.human_attribute_name(:quantity) + %th= EstimateDetail.human_attribute_name(:duration) + %th.text-right= EstimateDetail.human_attribute_name(:cost) + %tbody + - @estimate.estimate_details.map { |detail| EstimateDetailPresenter.new(detail) }.each do |estimate_detail| + %tr + %td= estimate_detail.product_display + %td= estimate_detail.quantity + %td= estimate_detail.duration_display + %td.text-right= estimate_detail.cost ? estimate_detail.cost_display : t(".no_public_rate") + .text-right + %strong= t(".total") + %span= number_to_currency(@total) + .hide-from-print + = button_tag t(".print"), type: "button", class: "btn btn-default", onclick: "window.print();" diff --git a/app/views/shared/_header.html.haml b/app/views/shared/_header.html.haml index 4bf1d32acf..fa2dde279e 100644 --- a/app/views/shared/_header.html.haml +++ b/app/views/shared/_header.html.haml @@ -15,6 +15,7 @@ - if session_user.nil? %ul.nav.navbar-nav.navbar-right.hide-from-print = render "/shared/support" + = render "/shared/public_estimate_link" %li= link_to t("pages.login"), :new_user_session - else -# collapsed at < 979px @@ -28,6 +29,7 @@ %li.navbar-text= "#{acting_user.full_name} (#{acting_user.username})" %li.divider-vertical = render "/shared/support" + = render "/shared/public_estimate_link" %li= link_to t("pages.cart"), :cart, class: "js--cart_count", data: { url: orders_cart_count_url } - else - if UserPreference.options_for(current_user).any? @@ -40,6 +42,7 @@ %li.divider-vertical -# .visible-with-nav is visible > 979px = render "/shared/support" + = render "/shared/public_estimate_link" %li.visible-with-nav= link_to t("pages.cart"), :cart, class: "js--cart_count", data: { url: orders_cart_count_url } %li.divider-vertical = render "shared/message_summary" diff --git a/app/views/shared/_public_estimate_link.html.haml b/app/views/shared/_public_estimate_link.html.haml new file mode 100644 index 0000000000..ddc361508a --- /dev/null +++ b/app/views/shared/_public_estimate_link.html.haml @@ -0,0 +1,2 @@ +- if SettingsHelper.feature_on?(:public_estimates) + %li= link_to t("pages.public_estimate"), estimate_path diff --git a/config/locales/en.models.yml b/config/locales/en.models.yml index fd1224feac..b4baf8e9b1 100644 --- a/config/locales/en.models.yml +++ b/config/locales/en.models.yml @@ -275,6 +275,9 @@ en: days: one: Day other: Days + mins: + one: Minute + other: Minutes schedule_rule: start_time: Start Time end_time: End Time diff --git a/config/locales/en.yml b/config/locales/en.yml index 7e9ea359e8..a64875f24a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -106,6 +106,7 @@ en: movable_transactions: My Movable Transactions notices: Notices support: Support + public_estimate: Get an Estimate affiliates: add: Add Affiliate @@ -115,6 +116,22 @@ en: confirm: "Really remove affiliate %{name}?" label: Remove + public_estimates: + show: + title: Estimate + intro: Estimate the cost of using our facilities. No account required. + customer_type: I am + customer_types: + base: Internal + external: External + cancer_center: Cancer Center + choose_products: Choose products + calculate: Calculate estimate + results: Estimated cost + no_public_rate: No public rate available + total: 'Total:' + print: Print estimate + bundle_products: new: head: Add Bundled Product diff --git a/config/routes.rb b/config/routes.rb index baec8a3858..09fa15d8a1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,10 @@ match "/users/password/reset", to: "user_password#reset", as: "reset_password", via: [:get, :post] end + if SettingsHelper.feature_on?(:public_estimates) && SettingsHelper.feature_on?(:show_estimates_option) + get "estimate", to: "public_estimates#show" + end + # root route root to: "public#index" diff --git a/config/settings.yml b/config/settings.yml index 1693b3ee86..5fab443643 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -10,6 +10,12 @@ price_group: external: 'External Rate' cancer_center: 'Cancer Center Rate' +public_estimates: + # Keys from price_group.name. Each school can override this list. + customer_types: + - base + - external + time_zone: "Central Time (US & Canada)" accounts: @@ -221,6 +227,7 @@ feature: granular_permissions: true kiosk_view: true show_estimates_option: true + public_estimates: false training_requests: true split_accounts: diff --git a/spec/models/estimate_detail_spec.rb b/spec/models/estimate_detail_spec.rb new file mode 100644 index 0000000000..3ae81df5ac --- /dev/null +++ b/spec/models/estimate_detail_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EstimateDetail do + let(:facility) { create(:setup_facility) } + let(:price_group) { facility.price_groups.first } + let!(:item) { create(:setup_item, facility:) } + let!(:item_price_policy) do + create(:item_price_policy, product: item, price_group:, unit_cost: 25, unit_subsidy: 5) + end + + describe "#assign_price_policy_and_cost without a user" do + let(:persisted_detail) do + estimate = create(:estimate, facility:, price_group:) + estimate.estimate_details.create!(product: item, quantity: 3) + end + + let(:anonymous_detail) do + estimate = Estimate.new(facility:, price_group:) + estimate.estimate_details.build(product: item, quantity: 3) + end + + it "resolves the price policy on an unsaved record" do + expect(anonymous_detail.assign_price_policy_and_cost).to be true + expect(anonymous_detail.price_policy).to eq(item_price_policy) + end + + it "computes the same cost as the persisted equivalent" do + anonymous_detail.assign_price_policy_and_cost + + expect(anonymous_detail.cost).to eq(persisted_detail.cost) + expect(anonymous_detail.cost).to eq(60) + end + + it "persists nothing" do + expect { anonymous_detail.assign_price_policy_and_cost }.not_to change(EstimateDetail, :count) + expect(anonymous_detail).not_to be_persisted + end + + it "returns false when no price policy matches the price group" do + other_item = create(:setup_item, facility:) + detail = Estimate.new(facility:, price_group:).estimate_details.build(product: other_item, quantity: 1) + + expect(detail.assign_price_policy_and_cost).to be false + end + end +end diff --git a/spec/models/price_group_spec.rb b/spec/models/price_group_spec.rb index 0d728dc1ca..eed068a0ab 100644 --- a/spec/models/price_group_spec.rb +++ b/spec/models/price_group_spec.rb @@ -241,4 +241,30 @@ end end + describe ".for_public_estimate" do + it "returns the group named by the matching price_group setting" do + expect(described_class.for_public_estimate("base")).to eq(described_class.base) + expect(described_class.for_public_estimate("external")).to eq(described_class.external) + end + + it "returns nil for a key with no configured name" do + expect(described_class.for_public_estimate("external_2")).to be_nil + expect(described_class.for_public_estimate("nonsense")).to be_nil + end + + context "when a school configures an extra key" do + let(:initial_name) { Settings.price_group.name.external_2 } + let!(:non_profit) do + Settings.price_group.name.external_2 = "External Non-Profit Rate" + described_class.setup_global(name: "External Non-Profit Rate", is_internal: false, display_order: 2) + end + + after { Settings.price_group.name.external_2 = initial_name } + + it "resolves that key to its group" do + expect(described_class.for_public_estimate("external_2")).to eq(non_profit) + end + end + end + end diff --git a/spec/requests/public_estimates_spec.rb b/spec/requests/public_estimates_spec.rb new file mode 100644 index 0000000000..1277de6fc6 --- /dev/null +++ b/spec/requests/public_estimates_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Public estimates" do + let(:facility) { create(:setup_facility) } + let!(:item) { create(:setup_item, facility:) } + let!(:internal_price_policy) do + create(:item_price_policy, product: item, price_group: PriceGroup.base, unit_cost: 10, unit_subsidy: 0) + end + let!(:external_price_policy) do + create(:item_price_policy, product: item, price_group: PriceGroup.external, unit_cost: 40, unit_subsidy: 0) + end + + context "when the feature is enabled", feature_setting: { public_estimates: true, reload_routes: true } do + it "is reachable without logging in" do + get "/estimate" + + expect(response).to have_http_status(:ok) + end + + it "lists the products of the selected facility" do + get "/estimate", params: { facility_id: facility.id } + + expect(response.body).to include(item.name) + end + + it "prices the estimate for an internal customer" do + get "/estimate", params: { + customer_type: "base", facility_id: facility.id, quantities: { item.id.to_s => "2" } + } + + expect(response.body).to include("$20.00") + end + + it "prices the estimate for an external customer" do + get "/estimate", params: { + customer_type: "external", facility_id: facility.id, quantities: { item.id.to_s => "2" } + } + + expect(response.body).to include("$80.00") + end + + it "excludes bundles, which have no price policies of their own" do + bundle = create(:bundle, facility:, bundle_products: [item]) + + get "/estimate", params: { facility_id: facility.id } + + expect(response.body).to_not include(bundle.name) + end + + it "prices the estimate with an extra configured customer type" do + initial_types = Settings.public_estimates.customer_types + Settings.public_estimates.customer_types = %w[base external cancer_center] + cancer_center = PriceGroup.setup_global(name: Settings.price_group.name.cancer_center, is_internal: false, display_order: 2) + create(:item_price_policy, product: item, price_group: cancer_center, unit_cost: 25, unit_subsidy: 0) + + get "/estimate", params: { + customer_type: "cancer_center", facility_id: facility.id, quantities: { item.id.to_s => "2" } + } + + expect(response.body).to include("$50.00") + ensure + Settings.public_estimates.customer_types = initial_types + end + + it "shows a print shortcut and the selected facility and customer type with the results" do + get "/estimate", params: { + customer_type: "external", facility_id: facility.id, quantities: { item.id.to_s => "1" } + } + + printed = response.parsed_body.at_css(".show-for-print").text + + expect(response.body).to include("window.print()") + expect(printed).to include(facility.name, "External") + end + + it "does not list a product with no rate for the selected price group" do + unpriced = create(:setup_item, facility:, name: "Unpriced Widget") + + get "/estimate", params: { customer_type: "base", facility_id: facility.id } + + expect(response.body).to include(item.name) + expect(response.body).to_not include(unpriced.name) + end + + it "ignores products with no quantity" do + get "/estimate", params: { + customer_type: "base", facility_id: facility.id, quantities: { item.id.to_s => "0" } + } + + expect(response.body).to_not include("Estimated cost") + end + end + + context "when the feature is disabled", feature_setting: { public_estimates: false, reload_routes: true } do + it "does not route" do + get "/estimate" + + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/spec/system/public_estimate_spec.rb b/spec/system/public_estimate_spec.rb new file mode 100644 index 0000000000..c01f6f8336 --- /dev/null +++ b/spec/system/public_estimate_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Building a public estimate", :js, feature_setting: { public_estimates: true, reload_routes: true } do + let(:facility) { create(:setup_facility) } + let!(:item) { create(:setup_item, facility:) } + let!(:price_policy) do + create(:item_price_policy, product: item, price_group: PriceGroup.base, unit_cost: 15, unit_subsidy: 0) + end + + it "prices products without logging in" do + visit root_path + + click_link "Get an Estimate" + + expect(page).to have_no_button("Calculate estimate") + + select "Internal", from: "customer_type" + select facility.name, from: "facility_id" + + fill_in "quantities[#{item.id}]", with: 4 + click_button "Calculate estimate" + + expect(page).to have_content("Estimated cost") + expect(page).to have_content("$60.00") + end +end