diff --git a/Gemfile b/Gemfile index d6a7356ea4..24f9d76959 100644 --- a/Gemfile +++ b/Gemfile @@ -32,8 +32,9 @@ gem 'dfc_provider', path: './engines/dfc_provider' gem "order_management", path: "./engines/order_management" gem 'web', path: './engines/web' -gem 'activerecord-postgresql-adapter' -gem 'pg', '~> 1.2.3' +gem "activerecord-postgresql-adapter" +gem "arel-helpers", "~> 2.12" +gem "pg", "~> 1.2.3" gem 'acts_as_list', '1.0.4' gem 'cancancan', '~> 1.15.0' @@ -96,6 +97,7 @@ gem 'wkhtmltopdf-binary' gem 'immigrant' gem 'roo', '~> 2.8.3' +gem 'spreadsheet_architect' gem 'whenever', require: false @@ -168,6 +170,7 @@ end group :development do gem 'debugger-linecache' gem 'foreman' + gem 'listen' gem 'pry', '~> 0.13.0' gem 'pry-byebug', '~> 3.9.0' gem 'rubocop' diff --git a/Gemfile.lock b/Gemfile.lock index 1dc028cdbe..06a9170f4e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,6 +150,8 @@ GEM railties (>= 3, < 7) angularjs-file-upload-rails (2.4.1) angularjs-rails (1.8.0) + arel-helpers (2.12.1) + activerecord (>= 3.1.0, < 7) ast (2.4.2) awesome_nested_set (3.4.0) activerecord (>= 4.0.0, < 7.0) @@ -159,6 +161,9 @@ GEM aws-sdk-v1 (1.67.0) json (~> 1.4) nokogiri (~> 1) + axlsx_styler (1.1.0) + activesupport (>= 3.1) + caxlsx (>= 2.0.2) bcrypt (3.1.16) bigdecimal (3.0.2) bindex (0.8.1) @@ -184,6 +189,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + caxlsx (3.1.1) + htmlentities (~> 4.3, >= 4.3.4) + marcel (~> 1.0) + nokogiri (~> 1.10, >= 1.10.4) + rubyzip (>= 1.3.0, < 3) childprocess (4.1.0) chronic (0.10.2) chunky_png (1.4.0) @@ -255,6 +265,7 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) + dry-inflector (0.2.1) e2mmap (0.1.0) erubi (1.10.0) et-orbi (1.2.4) @@ -334,6 +345,7 @@ GEM hashery (2.1.2) highline (2.0.3) hiredis (0.6.3) + htmlentities (4.3.4) i18n (1.8.10) concurrent-ruby (~> 1.0) i18n-js (3.9.0) @@ -361,6 +373,9 @@ GEM letter_opener (1.7.0) launchy (~> 2.2) libv8-node (15.14.0.1) + listen (3.7.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -509,6 +524,10 @@ GEM roadie-rails (2.2.0) railties (>= 5.1, < 6.2) roadie (>= 3.1, < 5.0) + rodf (1.1.1) + builder (>= 3.0) + dry-inflector (~> 0.1) + rubyzip (>= 1.0) roo (2.8.3) nokogiri (~> 1) rubyzip (>= 1.3.0, < 3.0.0) @@ -601,6 +620,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.3) + spreadsheet_architect (4.2.0) + axlsx_styler (>= 1.0.0, < 2) + caxlsx (>= 2.0.2, < 4) + rodf (>= 1.0.0, < 2) spring (3.0.0) spring-commands-rspec (1.0.4) spring (>= 0.9.1) @@ -694,6 +717,7 @@ DEPENDENCIES angular_rails_csrf angularjs-file-upload-rails (~> 2.4.1) angularjs-rails (= 1.8.0) + arel-helpers (~> 2.12) awesome_nested_set awesome_print aws-sdk (= 1.67.0) @@ -747,6 +771,7 @@ DEPENDENCIES jwt (~> 2.3) knapsack letter_opener (>= 1.4.1) + listen mimemagic (> 0.3.5) mini_racer (= 0.4.0) monetize (~> 1.11) @@ -790,6 +815,7 @@ DEPENDENCIES sidekiq sidekiq-scheduler simplecov + spreadsheet_architect spring spring-commands-rspec state_machines-activerecord diff --git a/app/assets/stylesheets/admin/reports.scss b/app/assets/stylesheets/admin/reports.scss index a338c10f7a..24c0411708 100644 --- a/app/assets/stylesheets/admin/reports.scss +++ b/app/assets/stylesheets/admin/reports.scss @@ -15,3 +15,17 @@ .customer-names-tip { margin-top: 1em; } + +.rendering-options { + select { + display: block; + float: left; + } + + .inline-checkbox { + line-height: 2.5em; + margin-left: 1em; + display: block; + float: left; + } +} diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb new file mode 100644 index 0000000000..79e5176792 --- /dev/null +++ b/app/controllers/admin/reports_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Admin + class ReportsController < Spree::Admin::BaseController + include ReportsActions + helper ReportsHelper + + before_action :authorize_report + + def show + render_report && return if ransack_params.blank? + + @report = report_class.new(spree_current_user, ransack_params, report_options) + + if export_spreadsheet? + export_report + else + render_report + end + end + + private + + def export_report + render report_format.to_sym => @report.public_send("to_#{report_format}"), + :filename => report_filename + end + + def render_report + assign_view_data + load_form_options + + render report_type + end + + def assign_view_data + @report_type = report_type + @report_subtype = report_subtype || report_loader.default_report_subtype + @report_subtypes = report_class.report_subtypes.map do |subtype| + [t("packing.#{subtype}_report", scope: i18n_scope), subtype] + end + end + + def load_form_options + return unless form_options_required? + + form_options = Reporting::FrontendData.new(spree_current_user) + + @distributors = form_options.distributors.to_a + @suppliers = form_options.suppliers.to_a + @order_cycles = form_options.order_cycles.to_a + end + end +end diff --git a/app/controllers/api/v0/reports_controller.rb b/app/controllers/api/v0/reports_controller.rb new file mode 100644 index 0000000000..b6cc0facc9 --- /dev/null +++ b/app/controllers/api/v0/reports_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Api + module V0 + class ReportsController < Api::V0::BaseController + include ReportsActions + + rescue_from ::Reporting::Errors::Base, with: :render_error + + before_action :validate_report, :authorize_report, :validate_query + + def show + @report = report_class.new(current_api_user, ransack_params, report_options) + + render_report + end + + private + + def render_report + render json: { data: @report.as_json } + end + + def render_error(error) + render json: { error: error.message }, status: :unprocessable_entity + end + + def validate_report + raise ::Reporting::Errors::NoReportType if report_type.blank? + raise ::Reporting::Errors::ReportNotFound if report_class.blank? + end + + def validate_query + raise ::Reporting::Errors::MissingQueryParams if ransack_params.blank? + end + end + end +end diff --git a/app/controllers/concerns/reports_actions.rb b/app/controllers/concerns/reports_actions.rb new file mode 100644 index 0000000000..7ff6e0c461 --- /dev/null +++ b/app/controllers/concerns/reports_actions.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module ReportsActions + extend ActiveSupport::Concern + + private + + def authorize_report + authorize! report_type&.to_sym, :report + end + + def report_class + return if report_type.blank? + + report_loader.report_class + end + + def report_loader + @report_loader ||= ::Reporting::ReportLoader.new(report_type, report_subtype) + end + + def report_type + params[:report_type] + end + + def report_subtype + params[:report_subtype] + end + + def ransack_params + raw_params[:q] + end + + def report_options + raw_params[:options] + end + + def report_format + params[:report_format] + end + + def export_spreadsheet? + ['xlsx', 'ods', 'csv'].include?(report_format) + end + + def form_options_required? + [:packing, :customers, :products_and_inventory, :order_cycle_management]. + include? report_type.to_sym + end + + def report_filename + "#{report_type || action_name}_#{file_timestamp}.#{report_format}" + end + + def file_timestamp + Time.zone.now.strftime("%Y%m%d") + end + + def i18n_scope + 'admin.reports' + end +end diff --git a/app/controllers/spree/admin/reports_controller.rb b/app/controllers/spree/admin/reports_controller.rb index 807b04160c..de57236dbf 100644 --- a/app/controllers/spree/admin/reports_controller.rb +++ b/app/controllers/spree/admin/reports_controller.rb @@ -11,7 +11,6 @@ require 'open_food_network/order_grouper' require 'open_food_network/customers_report' require 'open_food_network/users_and_enterprises_report' require 'open_food_network/order_cycle_management_report' -require 'open_food_network/packing_report' require 'open_food_network/sales_tax_report' require 'open_food_network/xero_invoices_report' require 'open_food_network/payments_report' @@ -21,6 +20,7 @@ module Spree module Admin class ReportsController < Spree::Admin::BaseController include Spree::ReportsHelper + helper ::ReportsHelper ORDER_MANAGEMENT_ENGINE_REPORTS = [ :bulk_coop, @@ -31,8 +31,8 @@ module Spree before_action :cache_search_state # Fetches user's distributors, suppliers and order_cycles - before_action :load_data, - only: [:customers, :products_and_inventory, :order_cycle_management, :packing] + before_action :load_basic_data, only: [:customers, :products_and_inventory, :order_cycle_management] + before_action :load_associated_data, only: [:orders_and_fulfillment] respond_to :html @@ -69,22 +69,6 @@ module Spree "order_cycle_management_#{timestamp}.csv") end - def packing - raw_params[:q] ||= {} - - @report_types = report_types[:packing] - @report_type = params[:report_type] - - # Add distributors/suppliers distributing/supplying products I distribute/supply - add_appropriate_distributors_and_suppliers - - # -- Build Report with Order Grouper - @report = OpenFoodNetwork::PackingReport.new spree_current_user, raw_params, render_content? - @table = order_grouper_table - - render_report(@report.header, @table, params[:csv], "packing_#{timestamp}.csv") - end - def orders_and_distributors @report = OpenFoodNetwork::OrderAndDistributorReport.new spree_current_user, raw_params, @@ -119,11 +103,6 @@ module Spree def orders_and_fulfillment raw_params[:q] ||= orders_and_fulfillment_default_filters - # Add distributors/suppliers distributing/supplying products I distribute/supply - add_appropriate_distributors_and_suppliers - - @order_cycles = my_order_cycles - @report_types = report_types[:orders_and_fulfillment] @report_type = params[:report_type] @@ -218,13 +197,12 @@ module Spree # Rendering HTML is the default. end - def add_appropriate_distributors_and_suppliers - # -- Prepare Form Options - permissions = OpenFoodNetwork::Permissions.new(spree_current_user) - # My distributors and any distributors distributing products I supply - @distributors = permissions.visible_enterprises_for_order_reports.is_distributor - # My suppliers and any suppliers supplying products I distribute - @suppliers = permissions.visible_enterprises_for_order_reports.is_primary_producer + def load_associated_data + form_options = Reporting::FrontendData.new(spree_current_user) + + @distributors = form_options.distributors + @suppliers = form_options.suppliers + @order_cycles = form_options.order_cycles end def csv_report(header, table) @@ -234,7 +212,7 @@ module Spree end end - def load_data + def load_basic_data @distributors = my_distributors @suppliers = my_suppliers | suppliers_of_products_distributed_by(@distributors) @order_cycles = my_order_cycles @@ -310,7 +288,7 @@ module Spree spree.public_send("#{report}_admin_reports_url".to_sym) end rescue NoMethodError - url_for([:new, :admin, :reports, report.to_s.singularize]) + main_app.admin_reports_url(report_type: report) end # List of reports that have been moved to the Order Management engine diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb new file mode 100644 index 0000000000..bfcc4232db --- /dev/null +++ b/app/helpers/reports_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ReportsHelper + def report_order_cycle_options(order_cycles) + order_cycles.map do |oc| + orders_open_at = oc.orders_open_at&.to_s(:short) || 'NA' + orders_close_at = oc.orders_close_at&.to_s(:short) || 'NA' + ["#{oc.name}   (#{orders_open_at} - #{orders_close_at})".html_safe, oc.id] + end + end + + def report_subtypes(report) + Reporting::ReportLoader.new(report).report_subtypes + end +end diff --git a/app/helpers/spree/reports_helper.rb b/app/helpers/spree/reports_helper.rb index 6e640ee27c..a06cb9fade 100644 --- a/app/helpers/spree/reports_helper.rb +++ b/app/helpers/spree/reports_helper.rb @@ -4,14 +4,6 @@ require 'spree/money' module Spree module ReportsHelper - def report_order_cycle_options(order_cycles) - order_cycles.map do |oc| - orders_open_at = oc.orders_open_at&.to_s(:short) || 'NA' - orders_close_at = oc.orders_close_at&.to_s(:short) || 'NA' - ["#{oc.name}   (#{orders_open_at} - #{orders_close_at})".html_safe, oc.id] - end - end - def report_payment_method_options(orders) orders.map do |order| payment_method = order.payments.first&.payment_method diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 4149333756..d36b82d6f7 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -5,6 +5,9 @@ class ApplicationRecord < ActiveRecord::Base include Spree::Core::Permalinks include Spree::Preferences::Preferable include Searchable + include ArelHelpers::ArelTable + include ArelHelpers::Aliases + include ArelHelpers::JoinAssociation self.abstract_class = true end diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index c7e8de5aaf..9f26613095 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -239,6 +239,7 @@ module Spree can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing], Spree::Admin::ReportsController + can [:admin, :show, :packing], :report add_bulk_coop_abilities add_enterprise_fee_summary_abilities end diff --git a/app/views/admin/reports/_date_range_form.html.haml b/app/views/admin/reports/_date_range_form.html.haml new file mode 100644 index 0000000000..39c43e63d6 --- /dev/null +++ b/app/views/admin/reports/_date_range_form.html.haml @@ -0,0 +1,9 @@ +.row.date-range-filter + = label_tag nil, t(:date_range) + %br + = label_tag nil, t(:start), :class => 'inline' + = text_field_tag "q[completed_at_gt]", params.dig(:q, :completed_at_gt), :class => 'datetimepicker datepicker-from' + %span.range-divider + %i.icon-arrow-right + = text_field_tag "q[completed_at_lt]", params.dig(:q, :completed_at_lt), :class => 'datetimepicker datepicker-to' + = label_tag nil, t(:end), :class => 'inline' diff --git a/app/views/admin/reports/_rendering_options.html.haml b/app/views/admin/reports/_rendering_options.html.haml new file mode 100644 index 0000000000..0967df8999 --- /dev/null +++ b/app/views/admin/reports/_rendering_options.html.haml @@ -0,0 +1,8 @@ +.row.rendering-options + = label_tag :report_format, t(".generate_report") + %br + = select_tag :report_format, options_for_select({t('.on_screen') => '', t('.csv_spreadsheet') => 'csv', t('.excel_spreadsheet') => 'xlsx', t('.openoffice_spreadsheet') => 'ods'}) + + -#.inline-checkbox + -# = check_box_tag "options[exclude_summaries]", true, params[:options].andand[:exclude_summaries] + -# = label_tag t(".hide_summary_rows") diff --git a/app/views/admin/reports/_table.html.haml b/app/views/admin/reports/_table.html.haml new file mode 100644 index 0000000000..edbc2f1a8f --- /dev/null +++ b/app/views/admin/reports/_table.html.haml @@ -0,0 +1,20 @@ +- if params[:q].present? + %table.report__table{id: id} + %thead + %tr + - @report.table_headers.each do |heading| + %th + = t("admin.reports.table.headings.#{heading}") + %tbody + - @report.table_rows.each do |row| + - if row + %tr + - row.each do |cell| + %td + = cell + - if @report.table_rows.empty? + %tr + %td{colspan: @report.table_headers.count}= t(:none) +- else + %p.report__message + = t(".select_and_search", option: msg_option.upcase) diff --git a/app/views/admin/reports/packing.html.haml b/app/views/admin/reports/packing.html.haml new file mode 100644 index 0000000000..3bd3f85604 --- /dev/null +++ b/app/views/admin/reports/packing.html.haml @@ -0,0 +1,31 @@ += form_tag main_app.admin_reports_path, report_type: 'packing' do + = render partial: 'date_range_form' + + .row + .alpha.two.columns= label_tag nil, t(:report_hubs) + .omega.fourteen.columns + = collection_select("q", "distributor_id_in", @distributors, :id, :name, {selected: params.dig(:q, :distributor_id_in)}, {class: "select2 fullwidth", multiple: true}) + + .row + .alpha.two.columns= label_tag nil, t(:report_producers) + .omega.fourteen.columns + = select_tag("q[supplier_id_in]", options_from_collection_for_select(@suppliers, :id, :name, params.dig(:q, :supplier_id_in)), {class: "select2 fullwidth", multiple: true}) + + .row + .alpha.two.columns= label_tag nil, t(:report_customers_cycle) + .omega.fourteen.columns + = select_tag("q[order_cycle_id_in]", options_for_select(report_order_cycle_options(@order_cycles), params.dig(:q, :order_cycle_id_in)), {class: "select2 fullwidth", multiple: true}) + + .row + .alpha.two.columns= label_tag nil, t(:report_type) + .omega.fourteen.columns + = select_tag(:report_subtype, options_for_select(@report_subtypes, @report_subtype)) + + = render partial: "rendering_options" + + .row + = button t(:search) + += render partial: "spree/admin/reports/customer_names_message" + += render "table", id: "listing_orders", msg_option: t(:search) diff --git a/app/views/spree/admin/reports/_packing_description.html.haml b/app/views/spree/admin/reports/_packing_description.html.haml index 3d7855cb89..d91e291909 100644 --- a/app/views/spree/admin/reports/_packing_description.html.haml +++ b/app/views/spree/admin/reports/_packing_description.html.haml @@ -1,4 +1,5 @@ %ul{style: "margin-left: 12pt"} - - report_types.each do |report_type| + - report_subtypes("packing").each do |report_subtype| %li - = link_to report_type[0], "#{packing_admin_reports_url}?report_type=#{report_type[1]}" + = link_to t("admin.reports.packing.#{report_subtype}_report"), + main_app.admin_reports_url(report_type: 'packing', report_subtype: report_subtype) diff --git a/app/views/spree/admin/reports/packing.html.haml b/app/views/spree/admin/reports/packing.html.haml deleted file mode 100644 index 1955310c77..0000000000 --- a/app/views/spree/admin/reports/packing.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -= form_for @report.search, :url => spree.packing_admin_reports_path do |f| - = render 'date_range_form', f: f - - .row - .alpha.two.columns= label_tag nil, t(:report_hubs) - .omega.fourteen.columns= f.collection_select(:distributor_id_in, @distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) - - .row - .alpha.two.columns= label_tag nil, t(:report_producers) - .omega.fourteen.columns= select_tag(:supplier_id_in, options_from_collection_for_select(@suppliers, :id, :name, params[:supplier_id_in]), {class: "select2 fullwidth", multiple: true}) - - .row - .alpha.two.columns= label_tag nil, t(:report_customers_cycle) - .omega.fourteen.columns - = f.select(:order_cycle_id_in, report_order_cycle_options(@order_cycles), {selected: params.dig(:q, :order_cycle_id_in)}, {class: "select2 fullwidth", multiple: true}) - - .row - .alpha.two.columns= label_tag nil, t(:report_type) - .omega.fourteen.columns= select_tag(:report_type, options_for_select(@report_types, @report_type)) - - .row - = check_box_tag :csv - = label_tag :csv, t(:report_customers_csv) - - .row - = button t(:search) - -= render partial: "customer_names_message" - -= render "table", id: "listing_orders", msg_option: t(:search) diff --git a/config/application.rb b/config/application.rb index bbc6975b5c..3273f812d1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -152,6 +152,20 @@ module Openfoodnetwork #{config.root}/app/jobs ) + initializer "ofn.reports" do |app| + module ::Reporting; end + loader = Zeitwerk::Loader.new + loader.push_dir("#{Rails.root}/lib/reporting", namespace: ::Reporting) + loader.enable_reloading + loader.setup + loader.eager_load + + if Rails.env.development? + require 'listen' + Listen.to("lib/reporting") { loader.reload }.start + end + end + config.paths["config/routes.rb"] = %w( config/routes/api.rb config/routes.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index c84c009ce4..a2f2bddb1a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1191,11 +1191,40 @@ en: xero_invoices: name: Xero Invoices description: Invoices for import into Xero - packing: - name: Packing Reports enterprise_fee_summary: name: "Enterprise Fee Summary" description: "Summary of Enterprise Fees collected" + errors: + no_report_type: "Please specify a report type" + report_not_found: "Report not found" + missing_ransack_params: "Please supply Ransack search params in the request" + hidden_field: "< Hidden >" + summary_row: + total: "TOTAL" + table: + select_and_search: "Select filters and click on %{option} to access your data." + headings: + hub: "Hub" + customer_code: "Code" + first_name: "First Name" + last_name: "Last Name" + supplier: "Supplier" + product: "Product" + variant: "Variant" + quantity: "Quantity" + is_temperature_controlled: "TempControlled?" + temp_controlled: "TempControlled?" + rendering_options: + generate_report: "Generate report:" + on_screen: "On screen" + csv_spreadsheet: "CSV Spreadsheet" + excel_spreadsheet: "Excel Spreadsheet" + openoffice_spreadsheet: "OpenOffice Spreadsheet" + hide_summary_rows: "Hide summary Rows" + packing: + name: "Packing Reports" + customer_report: "Pack By Customer" + supplier_report: "Pack By Supplier" subscriptions: index: title: "Subscriptions" diff --git a/config/routes/admin.rb b/config/routes/admin.rb index d0a8a18662..954b2861d4 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -114,5 +114,7 @@ Openfoodnetwork::Application.routes.draw do put :cancel, on: :member, format: :json put :resume, on: :member, format: :json end + + match '/reports/:report_type(/:report_subtype)', to: 'reports#show', via: [:get, :post], as: :reports end end diff --git a/config/routes/api.rb b/config/routes/api.rb index 69f41549f2..120563ce75 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -77,6 +77,9 @@ Openfoodnetwork::Application.routes.draw do end end end + + get '/reports/:report_type(/:report_subtype)', to: 'reports#show', + constraints: lambda { |_| Flipper.enabled?(:api_reports) } end match '*path', to: redirect(path: "/api/v0/%{path}"), via: :all, constraints: { path: /(?!v[0-9]).+/ } diff --git a/config/routes/spree.rb b/config/routes/spree.rb index f93a869170..461a03d742 100644 --- a/config/routes/spree.rb +++ b/config/routes/spree.rb @@ -33,7 +33,6 @@ Spree::Core::Engine.routes.draw do match '/admin/reports/orders_and_distributors' => 'admin/reports#orders_and_distributors', :as => "orders_and_distributors_admin_reports", :via => [:get, :post] match '/admin/reports/order_cycle_management' => 'admin/reports#order_cycle_management', :as => "order_cycle_management_admin_reports", :via => [:get, :post] - match '/admin/reports/packing' => 'admin/reports#packing', :as => "packing_admin_reports", :via => [:get, :post] match '/admin/reports/group_buys' => 'admin/reports#group_buys', :as => "group_buys_admin_reports", :via => [:get, :post] match '/admin/reports/bulk_coop' => 'admin/reports#bulk_coop', :as => "bulk_coop_admin_reports", :via => [:get, :post] match '/admin/reports/payments' => 'admin/reports#payments', :as => "payments_admin_reports", :via => [:get, :post] @@ -127,7 +126,7 @@ Spree::Core::Engine.routes.draw do end end - resources :reports + resources :reports, only: :index resources :users do member do diff --git a/lib/open_food_network/packing_report.rb b/lib/open_food_network/packing_report.rb deleted file mode 100644 index 663eae4385..0000000000 --- a/lib/open_food_network/packing_report.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -require "open_food_network/reports/line_items" - -module OpenFoodNetwork - class PackingReport - attr_reader :params - - def initialize(user, params = {}, render_table = false) - @params = params - @user = user - @render_table = render_table - end - - def header - if is_by_customer? - [ - I18n.t(:report_header_hub), - I18n.t(:report_header_code), - I18n.t(:report_header_first_name), - I18n.t(:report_header_last_name), - I18n.t(:report_header_supplier), - I18n.t(:report_header_product), - I18n.t(:report_header_variant), - I18n.t(:report_header_quantity), - I18n.t(:report_header_temp_controlled), - ] - else - [ - I18n.t(:report_header_hub), - I18n.t(:report_header_supplier), - I18n.t(:report_header_code), - I18n.t(:report_header_first_name), - I18n.t(:report_header_last_name), - I18n.t(:report_header_product), - I18n.t(:report_header_variant), - I18n.t(:report_header_quantity), - I18n.t(:report_header_temp_controlled), - ] - end - end - - def search - report_line_items.orders - end - - def table_items - return [] unless @render_table - - report_line_items.list(line_item_includes) - end - - def rules - if is_by_customer? - [ - { group_by: proc { |line_item| line_item.order.distributor }, - sort_by: proc { |distributor| distributor.name } }, - { group_by: proc { |line_item| line_item.order }, - sort_by: proc { |order| order.bill_address.lastname.downcase }, - summary_columns: [proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| I18n.t('admin.reports.total_items') }, - proc { |_line_items| "" }, - proc { |line_items| line_items.to_a.sum(&:quantity) }, - proc { |_line_items| "" }] }, - { group_by: proc { |line_item| line_item.product.supplier }, - sort_by: proc { |supplier| supplier.name } }, - { group_by: proc { |line_item| line_item.product }, - sort_by: proc { |product| product.name } }, - { group_by: proc { |line_item| line_item.full_name }, - sort_by: proc { |full_name| full_name } } - ] - else - [{ group_by: proc { |line_item| line_item.order.distributor }, - sort_by: proc { |distributor| distributor.name } }, - { group_by: proc { |line_item| line_item.product.supplier }, - sort_by: proc { |supplier| supplier.name }, - summary_columns: [proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| "" }, - proc { |_line_items| I18n.t('admin.reports.total_items') }, - proc { |_line_items| "" }, - proc { |line_items| line_items.to_a.sum(&:quantity) }, - proc { |_line_items| "" }] }, - { group_by: proc { |line_item| line_item.product }, - sort_by: proc { |product| product.name } }, - { group_by: proc { |line_item| line_item.full_name }, - sort_by: proc { |full_name| full_name } }, - { group_by: proc { |line_item| line_item.order }, - sort_by: proc { |order| order.bill_address.lastname.downcase } }] - end - end - - def columns - if is_by_customer? - [proc { |line_items| line_items.first.order.distributor.name }, - proc { |line_items| customer_code(line_items.first.order) }, - proc { |line_items| line_items.first.order.bill_address.firstname }, - proc { |line_items| line_items.first.order.bill_address.lastname }, - proc { |line_items| line_items.first.product.supplier.name }, - proc { |line_items| line_items.first.product.name }, - proc { |line_items| line_items.first.full_name }, - proc { |line_items| line_items.to_a.sum(&:quantity) }, - proc { |line_items| is_temperature_controlled?(line_items.first) }] - else - [ - proc { |line_items| line_items.first.order.distributor.name }, - proc { |line_items| line_items.first.product.supplier.name }, - proc { |line_items| customer_code(line_items.first.order) }, - proc { |line_items| line_items.first.order.bill_address.firstname }, - proc { |line_items| line_items.first.order.bill_address.lastname }, - proc { |line_items| line_items.first.product.name }, - proc { |line_items| line_items.first.full_name }, - proc { |line_items| line_items.to_a.sum(&:quantity) }, - proc { |line_items| is_temperature_controlled?(line_items.first) } - ] - end - end - - private - - def line_item_includes - [{ option_values: :option_type, - order: [:bill_address, :distributor, :customer], - variant: { product: [:supplier, :shipping_category] } }] - end - - def order_permissions - return @order_permissions unless @order_permissions.nil? - - @order_permissions = ::Permissions::Order.new(@user, @params[:q]) - end - - def is_temperature_controlled?(line_item) - if line_item.product.shipping_category&.temperature_controlled - "Yes" - else - "No" - end - end - - def is_by_customer? - params[:report_type] == "pack_by_customer" - end - - def customer_code(order) - customer = order.customer - customer.nil? ? "" : customer.code - end - - def report_line_items - @report_line_items ||= Reports::LineItems.new(order_permissions, @params) - end - end -end diff --git a/lib/reporting/errors.rb b/lib/reporting/errors.rb new file mode 100644 index 0000000000..d00a95618e --- /dev/null +++ b/lib/reporting/errors.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Reporting + module Errors + class Base < StandardError + def i18n_error_scope + 'admin.reports.errors' + end + end + + class NoReportType < Base + def message + I18n.t('no_report_type', scope: i18n_error_scope) + end + end + + class ReportNotFound < Base + def message + I18n.t('report_not_found', scope: i18n_error_scope) + end + end + + class MissingQueryParams < Base + def message + I18n.t('missing_ransack_params', scope: i18n_error_scope) + end + end + end +end diff --git a/lib/reporting/frontend_data.rb b/lib/reporting/frontend_data.rb new file mode 100644 index 0000000000..aad748b1ff --- /dev/null +++ b/lib/reporting/frontend_data.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Reporting + class FrontendData + def initialize(current_user) + @current_user = current_user + end + + def distributors + permissions.visible_enterprises_for_order_reports.is_distributor. + select("enterprises.id, enterprises.name") + end + + def suppliers + permissions.visible_enterprises_for_order_reports.is_primary_producer. + select("enterprises.id, enterprises.name") + end + + def order_cycles + OrderCycle. + active_or_complete. + visible_by(current_user). + order('order_cycles.orders_close_at DESC') + end + + private + + attr_reader :current_user + + def permissions + @permissions ||= OpenFoodNetwork::Permissions.new(current_user) + end + end +end diff --git a/lib/reporting/queries/joins.rb b/lib/reporting/queries/joins.rb new file mode 100644 index 0000000000..88690d2ac7 --- /dev/null +++ b/lib/reporting/queries/joins.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Reporting + module Queries + module Joins + def joins_order + reflect query.join(association(Spree::LineItem, :order)) + end + + def joins_order_distributor + reflect query.join(association(Spree::Order, :distributor, distributor_alias)) + end + + def joins_variant + reflect query.join(association(Spree::LineItem, :variant)) + end + + def joins_variant_product + reflect query.join(association(Spree::Variant, :product)) + end + + def joins_product_supplier + reflect query.join(association(Spree::Product, :supplier, supplier_alias)) + end + + def joins_product_shipping_category + reflect query.join(association(Spree::Product, :shipping_category)) + end + + def joins_order_and_distributor + reflect query. + join(association(Spree::LineItem, :order)). + join(association(Spree::Order, :distributor, distributor_alias)) + end + + def joins_order_customer + reflect query.join(association(Spree::Order, :customer)) + end + + def joins_order_bill_address + reflect query.join(association(Spree::Order, :bill_address, bill_address_alias)) + end + + def join_line_item_option_values + reflect query. + join(association(Spree::LineItem, :option_values)). + join(association(Spree::OptionValuesLineItem, :option_value) + ) + end + end + end +end diff --git a/lib/reporting/queries/query_builder.rb b/lib/reporting/queries/query_builder.rb new file mode 100644 index 0000000000..c7cdbc021f --- /dev/null +++ b/lib/reporting/queries/query_builder.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Reporting + module Queries + class QueryBuilder < QueryInterface + include Joins + include Tables + + attr_reader :grouping_fields + + def initialize(model, grouping_fields = []) + @grouping_fields = instance_exec(&grouping_fields) + + super model.arel_table + end + + def selecting(lambda) + fields = instance_exec(&lambda).map{ |key, value| value.public_send(:as, key.to_s) } + + reflect query.project(*fields) + end + + def scoped_to_orders(orders_relation) + reflect query.where( + line_item_table[:order_id].in(Arel.sql(orders_relation.to_sql)) + ) + end + + def with_managed_orders(orders_relation) + reflect query. + outer_join(managed_orders_alias). + on( + managed_orders_alias[:id].eq(line_item_table[:order_id]). + and(managed_orders_alias[:distributor_id].in(Arel.sql(orders_relation.to_sql))) + ) + end + + def grouped_in_sets(group_sets) + reflect query.group(*instance_exec(&group_sets)) + end + + def ordered_by(ordering_fields) + reflect query.order(*instance_exec(&ordering_fields)) + end + + def masked(field, message = nil, mask_rule = nil) + Case.new. + when(mask_rule || default_mask_rule). + then(field). + else(quoted(message || I18n.t("hidden_field", scope: i18n_scope))) + end + + def distinct_results(fields = nil) + return reflect query.distinct if fields.blank? + + reflect query.distinct_on(fields) + end + + def variant_full_name + display_name = variant_table[:display_name] + display_as = variant_table[:display_as] + options_text = option_value_table[:presentation] + + unit_to_display = coalesce(nullify_empty_strings(display_as), options_text) + combined_description = sql_concat(display_name, raw("' ('"), unit_to_display, raw("')'")) + + Case.new. + when(nullify_empty_strings(display_name).eq(nil)).then(unit_to_display). + when(nullify_empty_strings(unit_to_display).not_eq(nil)).then(combined_description). + else(display_name) + end + + private + + def default_mask_rule + line_item_table[:order_id].in(raw("#{managed_orders_alias.name}.id")). + or(distributor_alias[:show_customer_names_to_suppliers].eq(true)) + end + + def summary_row_title + I18n.t("total_items", scope: i18n_scope) + end + + def i18n_scope + "admin.reports" + end + end + end +end diff --git a/lib/reporting/queries/query_interface.rb b/lib/reporting/queries/query_interface.rb new file mode 100644 index 0000000000..b2b26b5f27 --- /dev/null +++ b/lib/reporting/queries/query_interface.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "arel-helpers" + +module Reporting + module Queries + class QueryInterface < ::ArelHelpers::QueryBuilder + include Arel::Nodes + + def coalesce(field, default = 0) + NamedFunction.new("COALESCE", [field, default]) + end + + def sum_values(field, default = 0) + NamedFunction.new("SUM", [coalesce(field, default)]) + end + + def sum_grouped(field, default = 0) + Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(field.sum) + end + + def sum_new(field, default = 0) + Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(sum_values(field)) + end + + def round(field, places: 2) + NamedFunction.new("ROUND", [field, places]) + end + + def association(base_class, association, alias_node = nil, join_type = InnerJoin) + options = alias_node.present? ? { aliases: [alias_node] } : {} + + Arel.sql(base_class.join_association(association, join_type, options).first.to_sql) + end + + def arel_join(join) + Arel.sql(join.first.to_sql) + end + + def join_source(join_association) + join_association[0].right + end + + def default_value(field) + field.maximum + end + + def default_blank(field) + Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(empty_string) + end + + def default_string(field, string) + Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(quoted(string)) + end + + def default_summary(field) + Case.new(sql_grouping(grouping_fields)).when(0).then(field.maximum).else(empty_string) + end + + def boolean_blank(field, true_string = I18n.t(:yes), false_string = I18n.t(:no)) + Case.new(sql_grouping(grouping_fields)).when(0). + then(pretty_boolean(field, true_string, false_string).maximum). + else(empty_string) + end + + def pretty_boolean(field, true_string, false_string) + Case.new(field).when(true). + then(Arel.sql("'#{true_string}'")). + else(Arel.sql("'#{false_string}'")) + end + + def cast(field, type) + NamedFunction.new("CAST", [field.as(type)]) + end + + def null_if(field, nullif) + NamedFunction.new("NULLIF", [field, nullif]) + end + + def parenthesise(args) + Grouping.new(args) + end + + def nullify_empty_strings(field) + null_if(field, empty_string) + end + + def empty_string + raw("''") + end + + def sql_concat(*args) + NamedFunction.new("CONCAT", args) + end + + def raw(string) + SqlLiteral.new(string) + end + + def quoted(string) + Quoted.new(string) + end + + def sql_grouping(groupings = grouping_fields) + NamedFunction.new("GROUPING", [groupings]) + end + + def grouping_sets(groupings) + GroupingSet.new(groupings) + end + + def sql_case(expression) + Case.new(expression) + end + + def rollup(groupings) + RollUp.new(groupings) + end + + def raw_result + ActiveRecord::Base.connection.exec_query(query.to_sql) + end + end + end +end diff --git a/lib/reporting/queries/tables.rb b/lib/reporting/queries/tables.rb new file mode 100644 index 0000000000..a6843706f4 --- /dev/null +++ b/lib/reporting/queries/tables.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Reporting + module Queries + module Tables + def order_table + Spree::Order.arel_table + end + + def line_item_table + Spree::LineItem.arel_table + end + + def product_table + Spree::Product.arel_table + end + + def variant_table + Spree::Variant.arel_table + end + + def customer_table + ::Customer.arel_table + end + + def distributor_alias + Enterprise.arel_table.alias(:order_distributor) + end + + def supplier_alias + Enterprise.arel_table.alias(:product_supplier) + end + + def bill_address_alias + Spree::Address.arel_table.alias(:bill_address) + end + + def managed_orders_alias + Spree::Order.arel_table.alias(:managed_orders) + end + + def option_value_table + Spree::OptionValue.arel_table + end + + def shipping_category_table + Spree::ShippingCategory.arel_table + end + end + end +end diff --git a/lib/reporting/report_loader.rb b/lib/reporting/report_loader.rb new file mode 100644 index 0000000000..b40f03b4ca --- /dev/null +++ b/lib/reporting/report_loader.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Reporting + class ReportLoader + delegate :report_subtypes, to: :base_class + + def initialize(report_type, report_subtype = nil) + @report_type = report_type + @report_subtype = report_subtype + end + + def report_class + "#{report_module}::#{report_subtype_class}".constantize + rescue NameError + raise Reporting::Errors::ReportNotFound + end + + def default_report_subtype + report_subtypes.first || "base" + end + + private + + attr_reader :report_type, :report_subtype + + def report_module + "Reporting::Reports::#{report_type.camelize}" + end + + def report_subtype_class + (report_subtype || default_report_subtype).camelize + end + + def base_class + "#{report_module}::Base".constantize + rescue NameError + raise Reporting::Errors::ReportNotFound + end + end +end diff --git a/lib/reporting/report_renderer.rb b/lib/reporting/report_renderer.rb new file mode 100644 index 0000000000..b9027ae60d --- /dev/null +++ b/lib/reporting/report_renderer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spreadsheet_architect' + +module Reporting + class ReportRenderer + def initialize(report) + @report = report + end + + def table_headers + @report.report_data.columns + end + + def table_rows + @report.report_data.rows + end + + def as_json + @report.report_data.as_json + end + + def as_arrays + @as_arrays ||= [table_headers] + table_rows + end + + def to_csv + SpreadsheetArchitect.to_csv(headers: table_headers, data: table_rows) + end + + def to_ods + SpreadsheetArchitect.to_ods(headers: table_headers, data: table_rows) + end + + def to_xlsx + SpreadsheetArchitect.to_xlsx(headers: table_headers, data: table_rows) + end + end +end diff --git a/lib/reporting/report_template.rb b/lib/reporting/report_template.rb new file mode 100644 index 0000000000..f7cb7a7d99 --- /dev/null +++ b/lib/reporting/report_template.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Reporting + class ReportTemplate + delegate :as_json, :as_arrays, :table_headers, :table_rows, + :to_csv, :to_xlsx, :to_ods, :to_json, to: :renderer + + attr_reader :options + + SUBTYPES = [] + + def self.report_subtypes + self::SUBTYPES + end + + def initialize(current_user, ransack_params, options = {}) + @current_user = current_user + @ransack_params = ransack_params.with_indifferent_access + @options = ( options || {} ).with_indifferent_access + end + + def report_data + @report_data ||= report_query.raw_result + end + + private + + attr_reader :current_user, :ransack_params + + def renderer + @renderer ||= ReportRenderer.new(self) + end + + def scoped_orders_relation + visible_orders_relation.ransack(ransack_params).result + end + + def visible_orders_relation + ::Permissions::Order.new(current_user). + visible_orders.complete.not_state(:canceled). + select(:id).distinct + end + + def managed_orders_relation + ::Enterprise.managed_by(current_user).select(:id).distinct + end + + def i18n_scope + "admin.reports" + end + end +end diff --git a/lib/reporting/reports/packing/base.rb b/lib/reporting/reports/packing/base.rb new file mode 100644 index 0000000000..5968ba7a9b --- /dev/null +++ b/lib/reporting/reports/packing/base.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Reporting + module Reports + module Packing + class Base < ReportTemplate + SUBTYPES = ["customer", "supplier"] + + def primary_model + Spree::LineItem + end + + def report_query + Queries::QueryBuilder.new(primary_model, grouping_fields). + scoped_to_orders(scoped_orders_relation). + with_managed_orders(managed_orders_relation). + joins_order_and_distributor. + joins_order_customer. + joins_order_bill_address. + joins_variant. + joins_variant_product. + joins_product_supplier. + joins_product_shipping_category. + join_line_item_option_values. + selecting(select_fields). + grouped_in_sets(group_sets). + ordered_by(ordering_fields) + end + + def grouping_fields + lambda do + [ + order_table[:id], + line_item_table[:id] + ] + end + end + end + end + end +end diff --git a/lib/reporting/reports/packing/customer.rb b/lib/reporting/reports/packing/customer.rb new file mode 100644 index 0000000000..f12b83a3b5 --- /dev/null +++ b/lib/reporting/reports/packing/customer.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Reporting + module Reports + module Packing + class Customer < Base + def select_fields + lambda do + { + hub: default_blank(distributor_alias[:name]), + customer_code: default_blank(masked(customer_table[:code])), + first_name: default_blank(masked(bill_address_alias[:firstname])), + last_name: default_blank(masked(bill_address_alias[:lastname])), + supplier: default_blank(supplier_alias[:name]), + product: default_string(product_table[:name], summary_row_title), + variant: default_blank(variant_full_name), + quantity: sum_values(line_item_table[:quantity]), + temp_controlled: boolean_blank(shipping_category_table[:temperature_controlled]), + } + end + end + + def ordering_fields + lambda do + [ + distributor_alias[:name], + bill_address_alias[:lastname], + order_table[:id], + sql_grouping(grouping_fields), + Arel.sql("supplier"), + Arel.sql("product"), + Arel.sql("variant"), + ] + end + end + + def group_sets + lambda do + [ + distributor_alias[:name], + bill_address_alias[:lastname], + grouping_sets([parenthesise(order_table[:id]), parenthesise(grouping_fields)]) + ] + end + end + end + end + end +end diff --git a/lib/reporting/reports/packing/supplier.rb b/lib/reporting/reports/packing/supplier.rb new file mode 100644 index 0000000000..fa173afce8 --- /dev/null +++ b/lib/reporting/reports/packing/supplier.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Reporting + module Reports + module Packing + class Supplier < Base + def select_fields + lambda do + { + hub: default_blank(distributor_alias[:name]), + supplier: default_blank(supplier_alias[:name]), + customer_code: default_blank(customer_table[:code]), + first_name: default_blank(masked(bill_address_alias[:firstname])), + last_name: default_blank(masked(bill_address_alias[:lastname])), + product: default_string(product_table[:name], summary_row_title), + variant: default_blank(variant_full_name), + quantity: sum_values(line_item_table[:quantity]), + temp_controlled: boolean_blank(shipping_category_table[:temperature_controlled]), + } + end + end + + def group_sets + lambda do + [ + distributor_alias[:name], + supplier_alias[:name], + grouping_sets([parenthesise(supplier_alias[:name]), parenthesise(grouping_fields)]) + ] + end + end + + def ordering_fields + lambda do + [ + distributor_alias[:name], + supplier_alias[:name], + sql_grouping(grouping_fields), + Arel.sql("product"), + Arel.sql("variant"), + Arel.sql("last_name") + ] + end + end + end + end + end +end diff --git a/spec/controllers/api/v0/reports/packing_report_spec.rb b/spec/controllers/api/v0/reports/packing_report_spec.rb new file mode 100644 index 0000000000..30bd855e1a --- /dev/null +++ b/spec/controllers/api/v0/reports/packing_report_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Api::V0::ReportsController, type: :controller do + let(:params) { + { + report_type: 'packing', + q: { created_at_lt: Time.zone.now } + } + } + + before do + allow(controller).to receive(:spree_current_user) { current_user } + order.finalize! + end + + describe "packing report" do + context "as an enterprise user with full order permissions (distributor)" do + let!(:distributor) { create(:distributor_enterprise) } + let!(:order) { create(:completed_order_with_totals, distributor: distributor) } + let(:current_user) { distributor.owner } + + it "renders results" do + api_get :show, params + + expect(response.status).to eq 200 + expect(json_response[:data]).to match_array report_output(order, "distributor") + end + end + + context "as an enterprise user with partial order permissions (supplier with P-OC)" do + let!(:order) { create(:completed_order_with_totals) } + let(:supplier) { order.line_items.first.product.supplier } + let(:current_user) { supplier.owner } + let!(:perms) { + create(:enterprise_relationship, parent: supplier, child: order.distributor, + permissions_list: [:add_to_order_cycle]) + } + + it "renders results" do + api_get :show, params + + expect(response.status).to eq 200 + expect(json_response[:data]).to match_array report_output(order, "supplier") + end + end + end + + private + + def report_output(order, user_type) + results = [] + + order.line_items.each do |line_item| + results << __send__("#{user_type}_report_row", line_item) + end + + results << summary_row(order) + end + + def distributor_report_row(line_item) + { + "hub" => line_item.order.distributor.name, + "customer_code" => line_item.order.customer&.code, + "first_name" => line_item.order.bill_address.firstname, + "last_name" => line_item.order.bill_address.lastname, + "supplier" => line_item.product.supplier.name, + "product" => line_item.product.name, + "variant" => line_item.full_name, + "quantity" => line_item.quantity, + "temp_controlled" => + line_item.product.shipping_category&.temperature_controlled ? I18n.t(:yes) : I18n.t(:no) + } + end + + def supplier_report_row(line_item) + { + "hub" => line_item.order.distributor.name, + "customer_code" => I18n.t("hidden_field", scope: i18n_scope), + "first_name" => I18n.t("hidden_field", scope: i18n_scope), + "last_name" => I18n.t("hidden_field", scope: i18n_scope), + "supplier" => line_item.product.supplier.name, + "product" => line_item.product.name, + "variant" => line_item.full_name, + "quantity" => line_item.quantity, + "temp_controlled" => + line_item.product.shipping_category&.temperature_controlled ? I18n.t(:yes) : I18n.t(:no) + } + end + + def summary_row(order) + { + "hub" => "", + "customer_code" => "", + "first_name" => "", + "last_name" => "", + "supplier" => "", + "product" => I18n.t("total_items", scope: i18n_scope), + "variant" => "", + "quantity" => order.line_items.sum(&:quantity), + "temp_controlled" => "", + } + end + + def i18n_scope + "admin.reports" + end +end diff --git a/spec/controllers/api/v0/reports_controller_spec.rb b/spec/controllers/api/v0/reports_controller_spec.rb new file mode 100644 index 0000000000..23ddfabf21 --- /dev/null +++ b/spec/controllers/api/v0/reports_controller_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Api::V0::ReportsController, type: :controller do + let(:enterprise_user) { create(:user, enterprises: create(:enterprise)) } + let(:params) { + { + report_type: 'packing', + q: { created_at_lt: Time.zone.now } + } + } + + before do + allow(controller).to receive(:spree_current_user) { current_user } + end + + describe "fetching reports" do + context "when the user is not authenticated" do + let(:current_user) { nil } + + it "returns unauthorised response" do + api_get :show, params + + assert_unauthorized! + end + end + + context "when the user has no enterprises" do + let(:current_user) { create(:user) } + + it "returns unauthorised response" do + api_get :show, params + + assert_unauthorized! + end + end + + context "when no report type is given" do + let(:current_user) { enterprise_user } + + it "returns an error" do + api_get :show, q: { example: 'test' } + + expect(response.status).to eq 422 + expect(json_response["error"]).to eq I18n.t('errors.no_report_type', scope: i18n_scope) + end + end + + context "given a report type that doesn't exist" do + let(:current_user) { enterprise_user } + + it "returns an error" do + api_get :show, report_type: "xxxxxx", q: { example: 'test' } + + expect(response.status).to eq 422 + expect(json_response["error"]).to eq I18n.t('errors.report_not_found', scope: i18n_scope) + end + end + + context "with no query params provided" do + let(:current_user) { enterprise_user } + + it "returns an error" do + api_get :show, report_type: "packing" + + expect(response.status).to eq 422 + expect(json_response["error"]).to eq( + I18n.t('errors.missing_ransack_params', scope: i18n_scope) + ) + end + end + end + + private + + def i18n_scope + "admin.reports" + end +end diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index b714f7337e..f18383f2db 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -91,83 +91,6 @@ describe ' end end - describe "Packing reports" do - before do - login_as_admin_and_visit spree.admin_reports_path - end - - let(:bill_address1) { create(:address, lastname: "MULLER") } - let(:bill_address2) { create(:address, lastname: "Mistery") } - let(:distributor_address) { - create(:address, address1: "distributor address", city: 'The Shire', zipcode: "1234") - } - let(:distributor) { create(:distributor_enterprise, address: distributor_address) } - let(:order1) { create(:order, distributor: distributor, bill_address: bill_address1) } - let(:order2) { create(:order, distributor: distributor, bill_address: bill_address2) } - let(:supplier) { create(:supplier_enterprise, name: "Supplier") } - let(:product_1) { create(:simple_product, name: "Product 1", supplier: supplier ) } - let(:variant_1) { create(:variant, product: product_1, unit_description: "Big") } - let(:variant_2) { create(:variant, product: product_1, unit_description: "Small") } - let(:product_2) { create(:simple_product, name: "Product 2", supplier: supplier) } - - before do - Timecop.travel(Time.zone.local(2013, 4, 25, 14, 0, 0)) { order1.finalize! } - Timecop.travel(Time.zone.local(2013, 4, 25, 15, 0, 0)) { order2.finalize! } - - create(:line_item_with_shipment, variant: variant_1, quantity: 1, order: order1) - create(:line_item_with_shipment, variant: variant_2, quantity: 3, order: order1) - create(:line_item_with_shipment, variant: product_2.master, quantity: 3, order: order2) - end - - it "Pack By Customer" do - click_link "Pack By Customer" - fill_in 'q_completed_at_gt', with: '2013-04-25 13:00:00' - fill_in 'q_completed_at_lt', with: '2013-04-25 16:00:00' - # select 'Pack By Customer', from: 'report_type' - click_button 'Search' - - rows = find("table#listing_orders").all("thead tr") - table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - expect(table.sort).to eq([ - ["Hub", "Code", "First Name", "Last Name", "Supplier", "Product", "Variant", "Quantity", - "TempControlled?"] - ].sort) - expect(page).to have_selector 'table#listing_orders tbody tr', count: 5 # Totals row per order - end - - it "Alphabetically Sorted Pack by Customer" do - click_link "Pack By Customer" - click_button 'Search' - - rows = find("table#listing_orders").all("tr") - table = rows.map { |r| r.all("th,td").map { |c| c.text.strip }[3] } - expect(table).to eq([ - "Last Name", - order2.bill_address.lastname, - "", - order1.bill_address.lastname, - order1.bill_address.lastname, - "" - ]) - end - - it "Pack By Supplier" do - click_link "Pack By Supplier" - fill_in 'q_completed_at_gt', with: '2013-04-25 13:00:00' - fill_in 'q_completed_at_lt', with: '2013-04-25 16:00:00' - # select 'Pack By Customer', from: 'report_type' - click_button 'Search' - - rows = find("table#listing_orders").all("thead tr") - table = rows.map { |r| r.all("th").map { |c| c.text.strip } } - expect(table.sort).to eq([ - ["Hub", "Supplier", "Code", "First Name", "Last Name", "Product", "Variant", "Quantity", - "TempControlled?"] - ].sort) - expect(all('table#listing_orders tbody tr').count).to eq(4) # Totals row per supplier - end - end - it "orders and distributors report" do login_as_admin_and_visit spree.admin_reports_path click_link 'Orders And Distributors' diff --git a/spec/lib/open_food_network/packing_report_spec.rb b/spec/lib/open_food_network/packing_report_spec.rb deleted file mode 100644 index baa282826b..0000000000 --- a/spec/lib/open_food_network/packing_report_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'open_food_network/packing_report' - -include AuthenticationHelper - -module OpenFoodNetwork - describe PackingReport do - describe "fetching orders" do - let(:distributor) { create(:distributor_enterprise) } - let(:order_cycle) { create(:simple_order_cycle) } - let(:order) { - create(:order, completed_at: 1.day.ago, order_cycle: order_cycle, distributor: distributor) - } - let(:line_item) { build(:line_item_with_shipment) } - - before { order.line_items << line_item } - - context "as a site admin" do - let(:user) { create(:admin_user) } - subject { PackingReport.new user, {}, true } - - it "fetches completed orders" do - order2 = create(:order) - order2.line_items << build(:line_item) - expect(subject.table_items).to eq([line_item]) - end - - it "does not show cancelled orders" do - order2 = create(:order, state: "canceled", completed_at: 1.day.ago) - order2.line_items << build(:line_item_with_shipment) - expect(subject.table_items).to eq([line_item]) - end - end - - context "as a manager of a supplier" do - let!(:user) { create(:user) } - subject { PackingReport.new user, {}, true } - - let(:supplier) { create(:supplier_enterprise) } - - before do - supplier.enterprise_roles.create!(user: user) - end - - context "that has granted P-OC to the distributor" do - let(:order2) { - create(:order, distributor: distributor, completed_at: 1.day.ago, - bill_address: create(:address), ship_address: create(:address)) - } - let(:line_item2) { - build(:line_item_with_shipment, product: create(:simple_product, supplier: supplier)) - } - - before do - order2.line_items << line_item2 - create(:enterprise_relationship, parent: supplier, child: distributor, - permissions_list: [:add_to_order_cycle]) - end - - it "shows line items supplied by my producers, with names hidden" do - expect(subject.table_items).to eq([line_item2]) - expect(subject.table_items.first.order.bill_address.firstname).to eq("HIDDEN") - end - - context "where the distributor allows suppliers to see customer names" do - before do - distributor.update_columns show_customer_names_to_suppliers: true - end - - it "shows line items supplied by my producers, with names shown" do - expect(subject.table_items).to eq([line_item2]) - expect(subject.table_items.first.order.bill_address.firstname). - to eq(order2.bill_address.firstname) - end - end - end - - context "that has not granted P-OC to the distributor" do - let(:order2) { - create(:order, distributor: distributor, completed_at: 1.day.ago, - bill_address: create(:address), ship_address: create(:address)) - } - let(:line_item2) { - build(:line_item_with_shipment, product: create(:simple_product, supplier: supplier)) - } - - before do - order2.line_items << line_item2 - end - - it "does not show line items supplied by my producers" do - expect(subject.table_items).to eq([]) - end - - context "where the distributor allows suppliers to see customer names" do - before do - distributor.show_customer_names_to_suppliers = true - end - - it "does not show line items supplied by my producers" do - expect(subject.table_items).to eq([]) - end - end - end - end - - context "as a manager of a distributor" do - let!(:user) { create(:user) } - subject { PackingReport.new user, {}, true } - - before do - distributor.enterprise_roles.create!(user: user) - end - - it "only shows line items distributed by enterprises managed by the current user" do - distributor2 = create(:distributor_enterprise) - distributor2.enterprise_roles.create!(user: create(:user)) - order2 = create(:order, distributor: distributor2, completed_at: 1.day.ago) - order2.line_items << build(:line_item_with_shipment) - expect(subject.table_items).to eq([line_item]) - end - - it "only shows the selected order cycle" do - order_cycle2 = create(:simple_order_cycle) - order2 = create(:order, distributor: distributor, order_cycle: order_cycle2) - order2.line_items << build(:line_item) - allow(subject).to receive(:params).and_return(order_cycle_id_in: order_cycle.id) - expect(subject.table_items).to eq([line_item]) - end - end - end - end -end diff --git a/spec/lib/reports/packing/packing_report_spec.rb b/spec/lib/reports/packing/packing_report_spec.rb new file mode 100644 index 0000000000..89a706e783 --- /dev/null +++ b/spec/lib/reports/packing/packing_report_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Packing Reports" do + include AuthenticationHelper + + describe "fetching orders" do + let(:distributor) { create(:distributor_enterprise) } + let(:order_cycle) { create(:simple_order_cycle) } + let(:order) { + create(:completed_order_with_totals, order_cycle: order_cycle, distributor: distributor, + line_items_count: 0) + } + let(:line_item) { build(:line_item_with_shipment) } + let(:user) { create(:admin_user) } + let(:params) { {} } + + let(:report_data) { subject.report_data.as_json } + let(:report_contents) { subject.report_data.rows.flatten } + let(:row_count) { subject.report_data.rows.count } + + subject { Reporting::Reports::Packing::Customer.new user, params } + + before do + order.line_items << line_item + order.finalize! + end + + context "as a site admin" do + let(:cancelled_order) { create(:completed_order_with_totals, line_items_count: 0) } + let(:line_item2) { build(:line_item_with_shipment) } + + before do + cancelled_order.line_items << line_item2 + cancelled_order.finalize! + cancelled_order.cancel! + end + + it "fetches line items for completed orders" do + expect(report_contents).to include line_item.product.name + end + + it "does not fetch line items for cancelled orders" do + expect(report_contents).to_not include line_item2.product.name + end + end + + context "as a manager of a supplier" do + let!(:user) { create(:user) } + let(:supplier) { create(:supplier_enterprise) } + let(:order2) { + create(:completed_order_with_totals, distributor: distributor, + bill_address: create(:address), + ship_address: create(:address)) + } + let(:line_item2) { + build(:line_item_with_shipment, product: create(:simple_product, supplier: supplier)) + } + + before do + order2.line_items << line_item2 + order2.finalize! + supplier.enterprise_roles.create!(user: user) + end + + context "which has not granted P-OC to the distributor" do + it "does not show line items supplied by my producers" do + expect(row_count).to eq 0 + end + end + + context "which has granted P-OC to the distributor" do + before do + create(:enterprise_relationship, parent: supplier, child: distributor, + permissions_list: [:add_to_order_cycle]) + end + + it "shows line items supplied by my producers, with names hidden" do + expect(report_contents).to include line_item2.product.name + expect(report_data.first["first_name"]).to eq( + I18n.t('admin.reports.hidden_field') + ) + end + + context "where the distributor allows suppliers to see customer names" do + before do + distributor.update_columns show_customer_names_to_suppliers: true + end + + it "shows line items supplied by my producers, with names shown" do + expect(report_data.first["first_name"]).to eq(order2.bill_address.firstname) + end + end + end + end + + context "as a manager of a distributor" do + let!(:user) { create(:user) } + let(:distributor2) { create(:distributor_enterprise) } + let(:order3) { + create(:completed_order_with_totals, distributor: distributor2, + line_items_count: 0) + } + let(:line_item3) { build(:line_item_with_shipment) } + + before do + order3.line_items << line_item3 + order3.finalize! + distributor.enterprise_roles.create!(user: user) + end + + it "only shows line items distributed by enterprises managed by the current user" do + expect(report_contents).to include line_item.product.name + expect(report_contents).to_not include line_item3.product.name + end + + context "filtering by order cycle" do + let(:order_cycle2) { create(:simple_order_cycle) } + let(:order4) { + create(:completed_order_with_totals, distributor: distributor, order_cycle: order_cycle2, + line_items_count: 0) + } + let(:line_item4) { build(:line_item_with_shipment) } + let(:params) { { order_cycle_id_in: order_cycle.id } } + + before do + order4.line_items << line_item4 + order4.finalize! + end + + it "only shows results from the selected order cycle" do + expect(report_contents).to include line_item.product.name + expect(report_contents).to_not include line_item4.product.name + end + end + end + + describe "ordering and grouping" do + let(:distributor2) { create(:distributor_enterprise) } + let(:order2) { + create(:completed_order_with_totals, order_cycle: order_cycle, distributor: distributor2, + line_items_count: 2) + } + + before do + order2.finalize! + end + + it "groups and orders by distributor and order" do + expect(subject.report_data.rows.map(&:first)).to eq( + [order.distributor.name, "", order2.distributor.name, order2.distributor.name, ""] + ) + end + end + end +end diff --git a/spec/lib/reports/report_loader_spec.rb b/spec/lib/reports/report_loader_spec.rb new file mode 100644 index 0000000000..5341ae641f --- /dev/null +++ b/spec/lib/reports/report_loader_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Reporting + module Reports + module Bananas + class Base; end + class Green; end + class Yellow; end + end + end +end + +describe Reporting::ReportLoader do + let(:service) { Reporting::ReportLoader.new(*arguments) } + let(:report_base_class) { Reporting::Reports::Bananas::Base } + let(:report_subtypes) { ["green", "yellow"] } + + before do + allow(report_base_class).to receive(:report_subtypes).and_return(report_subtypes) + end + + describe "#report_class" do + describe "given report type and subtype" do + let(:arguments) { ["bananas", "yellow"] } + + it "returns a report class when given type and subtype" do + expect(service.report_class).to eq Reporting::Reports::Bananas::Yellow + end + end + + describe "given report type only" do + context "when the report has multiple subtypes" do + let(:arguments) { ["bananas"] } + + it "returns first listed report type" do + expect(service.report_class).to eq Reporting::Reports::Bananas::Green + end + end + + context "when the report has no subtypes" do + let(:arguments) { ["bananas"] } + let(:report_subtypes) { [] } + + it "returns base class" do + expect(service.report_class).to eq Reporting::Reports::Bananas::Base + end + end + + context "given a report type that does not exist" do + let(:arguments) { ["apples"] } + let(:report_subtypes) { [] } + + it "raises an error" do + expect{ service.report_class }.to raise_error(Reporting::Errors::ReportNotFound) + end + end + end + end + + describe "#default_report_subtype" do + context "when the report has multiple subtypes" do + let(:arguments) { ["bananas"] } + + it "returns the first report type" do + expect(service.default_report_subtype).to eq report_base_class.report_subtypes.first + end + end + + context "when the report has no subtypes" do + let(:arguments) { ["bananas"] } + let(:report_subtypes) { [] } + + it "returns base" do + expect(service.default_report_subtype).to eq "base" + end + end + + context "given a report type that does not exist" do + let(:arguments) { ["apples"] } + let(:report_subtypes) { [] } + + it "raises an error" do + expect{ service.report_class }.to raise_error(Reporting::Errors::ReportNotFound) + end + end + end + + describe "#report_subtypes" do + context "when the report has multiple subtypes" do + let(:arguments) { ["bananas"] } + + it "returns a list of report subtypes for a given report" do + expect(service.report_subtypes).to eq report_subtypes + end + end + + context "when the report has no subtypes" do + let(:arguments) { ["bananas"] } + let(:report_subtypes) { [] } + + it "returns an empty array" do + expect(service.report_subtypes).to eq [] + end + end + end +end diff --git a/spec/lib/reports/report_renderer_spec.rb b/spec/lib/reports/report_renderer_spec.rb new file mode 100644 index 0000000000..2377c96950 --- /dev/null +++ b/spec/lib/reports/report_renderer_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Reporting::ReportRenderer do + let(:data) { + [ + { "id" => 1, "name" => "carrots", "quantity" => 3 }, + { "id" => 2, "name" => "onions", "quantity" => 6 } + ] + } + let(:report_data) { ActiveRecord::Result.new(data.first.keys, data.map(&:values)) } + let(:report) { OpenStruct.new(report_data: report_data) + } + let(:service) { described_class.new(report) } + + describe "#table_headers" do + it "returns the report's table headers" do + expect(service.table_headers).to eq ["id", "name", "quantity"] + end + end + + describe "#table_rows" do + it "returns the report's table rows" do + expect(service.table_rows).to eq [ + [1, "carrots", 3], + [2, "onions", 6] + ] + end + end + + describe "#as_json" do + it "returns the report's data as hashes" do + expect(service.as_json).to eq data.as_json + end + end + + describe "#as_arrays" do + it "returns the report's data as arrays" do + expect(service.as_arrays).to eq [ + ["id", "name", "quantity"], + [1, "carrots", 3], + [2, "onions", 6] + ] + end + end + + describe "exporting to different formats" do + let(:spreadsheet_architect) { SpreadsheetArchitect } + before do + allow(spreadsheet_architect).to receive(:to_csv) {} + allow(spreadsheet_architect).to receive(:to_ods) {} + allow(spreadsheet_architect).to receive(:to_xlsx) {} + end + + describe "#to_csv" do + it "exports as csv" do + service.to_csv + + expect(spreadsheet_architect).to have_received(:to_csv). + with(headers: service.table_headers, data: service.table_rows) + end + end + + describe "#to_ods" do + it "exports as ods" do + service.to_ods + + expect(spreadsheet_architect).to have_received(:to_ods). + with(headers: service.table_headers, data: service.table_rows) + end + end + + describe "#to_xslx" do + it "exports as xlsx" do + service.to_xlsx + + expect(spreadsheet_architect).to have_received(:to_xlsx). + with(headers: service.table_headers, data: service.table_rows) + end + end + end +end diff --git a/spec/system/admin/reports/packing_report_spec.rb b/spec/system/admin/reports/packing_report_spec.rb index 217c944351..dcd5660641 100644 --- a/spec/system/admin/reports/packing_report_spec.rb +++ b/spec/system/admin/reports/packing_report_spec.rb @@ -6,38 +6,130 @@ describe "Packing Reports", js: true do include AuthenticationHelper include WebHelper - let(:distributor) { create(:distributor_enterprise) } - let(:oc) { create(:simple_order_cycle) } - let(:order) { create(:order, completed_at: 1.day.ago, order_cycle: oc, distributor: distributor) } - let(:li1) { build(:line_item_with_shipment) } - let(:li2) { build(:line_item_with_shipment) } + describe "Packing reports" do + before do + login_as_admin + visit spree.admin_reports_path + end - before do - order.line_items << li1 - order.line_items << li2 - login_as_admin + let(:bill_address1) { create(:address, lastname: "MULLER") } + let(:bill_address2) { create(:address, lastname: "Mistery") } + let(:distributor_address) { + create(:address, address1: "distributor address", city: 'The Shire', zipcode: "1234") + } + let(:distributor) { create(:distributor_enterprise, address: distributor_address) } + let(:order1) { + create(:completed_order_with_totals, line_items_count: 0, distributor: distributor, + bill_address: bill_address1) + } + let(:order2) { + create(:completed_order_with_totals, line_items_count: 0, distributor: distributor, + bill_address: bill_address2) + } + let(:supplier) { create(:supplier_enterprise, name: "Supplier") } + let(:product1) { create(:simple_product, name: "Product 1", supplier: supplier ) } + let(:variant1) { create(:variant, product: product1, unit_description: "Big") } + let(:variant2) { create(:variant, product: product1, unit_description: "Small") } + let(:product2) { create(:simple_product, name: "Product 2", supplier: supplier) } + + before do + Timecop.travel(Time.zone.local(2013, 4, 25, 14, 0, 0)) { order1.finalize! } + Timecop.travel(Time.zone.local(2013, 4, 25, 15, 0, 0)) { order2.finalize! } + + create(:line_item_with_shipment, variant: variant1, quantity: 1, order: order1) + create(:line_item_with_shipment, variant: variant2, quantity: 3, order: order1) + create(:line_item_with_shipment, variant: product2.master, quantity: 3, order: order2) + end + + describe "Pack By Customer" do + it "displays the report" do + click_link "Pack By Customer" + fill_in 'q_completed_at_gt', with: '2013-04-25 13:00:00' + fill_in 'q_completed_at_lt', with: '2013-04-25 16:00:00' + click_button 'Search' + + rows = find("table#listing_orders").all("thead tr") + table = rows.map { |r| r.all("th").map { |c| c.text.strip } } + expect(table).to eq([ + ["Hub", "Code", "First Name", "Last Name", "Supplier", + "Product", "Variant", "Quantity", "TempControlled?"].map(&:upcase) + ]) + expect(page).to have_selector 'table#listing_orders tbody tr', count: 5 # Totals row per order + end + + it "sorts alphabetically" do + click_link "Pack By Customer" + click_button 'Search' + + rows = find("table#listing_orders").all("tr") + table = rows.map { |r| r.all("th,td").map { |c| c.text.strip }[3] } + expect(table).to eq([ + "LAST NAME", + order2.bill_address.lastname, + "", + order1.bill_address.lastname, + order1.bill_address.lastname, + "" + ]) + end + end + + describe "Pack By Supplier" do + it "displays the report" do + click_link "Pack By Supplier" + fill_in 'q_completed_at_gt', with: '2013-04-25 13:00:00' + fill_in 'q_completed_at_lt', with: '2013-04-25 16:00:00' + click_button 'Search' + + rows = find("table#listing_orders").all("thead tr") + table = rows.map { |r| r.all("th").map { |c| c.text.strip } } + expect(table).to eq([ + ["Hub", "Supplier", "Code", "First Name", "Last Name", + "Product", "Variant", "Quantity", "TempControlled?"].map(&:upcase) + ]) + expect(all('table#listing_orders tbody tr').count).to eq(4) # Totals row per supplier + end + end end - describe "viewing a report" do - context "when an associated variant has been soft-deleted" do - it "shows line items" do - li1.variant.delete + describe "With soft-deleted variants" do + let(:distributor) { create(:distributor_enterprise) } + let(:oc) { create(:simple_order_cycle) } + let(:order) { + create(:completed_order_with_totals, line_items_count: 0, completed_at: 1.day.ago, + order_cycle: oc, distributor: distributor) + } + let(:li1) { build(:line_item_with_shipment) } + let(:li2) { build(:line_item_with_shipment) } - visit spree.admin_reports_path + before do + order.line_items << li1 + order.line_items << li2 + order.finalize! + login_as_admin + end - click_on I18n.t("admin.reports.packing.name") - select oc.name, from: "q_order_cycle_id_in" + describe "viewing a report" do + context "when an associated variant has been soft-deleted" do + it "shows line items" do + li1.variant.delete - find('#q_completed_at_gt').click - select_date(Time.zone.today - 1.day) + visit spree.admin_reports_path - find('#q_completed_at_lt').click - select_date(Time.zone.today) + click_on I18n.t("admin.reports.packing.name") + select oc.name, from: "q_order_cycle_id_in" - find("button[type='submit']").click + find('#q_completed_at_gt').click + select_date(Time.zone.today - 1.day) - expect(page).to have_content li1.product.name - expect(page).to have_content li2.product.name + find('#q_completed_at_lt').click + select_date(Time.zone.today) + + find("button[type='submit']").click + + expect(page).to have_content li1.product.name + expect(page).to have_content li2.product.name + end end end end