From 817f0942ddf42298028a63d1c271af420256f4ea Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Mon, 8 Jun 2020 17:42:39 +0200 Subject: [PATCH] Pull in reports POC work replacing Packing reports --- Gemfile | 1 + Gemfile.lock | 19 +++ app/assets/stylesheets/admin/reports.scss | 14 ++ app/controllers/admin/reports_controller.rb | 54 ++++++ app/controllers/api/v0/reports_controller.rb | 38 +++++ app/controllers/concerns/reports_actions.rb | 62 +++++++ .../spree/admin/reports_controller.rb | 44 ++--- app/helpers/reports_helper.rb | 15 ++ app/helpers/spree/reports_helper.rb | 8 - app/models/spree/ability.rb | 1 + .../admin/reports/_date_range_form.html.haml | 9 + .../reports/_rendering_options.html.haml | 8 + app/views/admin/reports/_table.html.haml | 20 +++ app/views/admin/reports/packing.html.haml | 31 ++++ .../reports/_packing_description.html.haml | 5 +- .../spree/admin/reports/packing.html.haml | 30 ---- config/application.rb | 8 + config/locales/en.yml | 32 +++- config/routes/admin.rb | 2 + config/routes/api.rb | 2 + config/routes/spree.rb | 3 +- lib/open_food_network/packing_report.rb | 160 ------------------ lib/reports/errors.rb | 29 ++++ lib/reports/frontend_data.rb | 34 ++++ lib/reports/packing/base.rb | 17 ++ lib/reports/packing/customer.rb | 9 + lib/reports/packing/supplier.rb | 9 + lib/reports/report_loader.rb | 40 +++++ lib/reports/report_renderer.rb | 67 ++++++++ lib/reports/report_template.rb | 42 +++++ .../api/v0/reports/packing_report_spec.rb | 119 +++++++++++++ .../api/v0/reports_controller_spec.rb | 80 +++++++++ .../open_food_network/packing_report_spec.rb | 135 --------------- .../reports/packing/packing_report_spec.rb | 112 ++++++++++++ spec/lib/reports/report_loader_spec.rb | 106 ++++++++++++ spec/lib/reports/report_renderer_spec.rb | 100 +++++++++++ .../admin/reports/packing_report_spec.rb | 112 +++++++++--- 37 files changed, 1182 insertions(+), 395 deletions(-) create mode 100644 app/controllers/admin/reports_controller.rb create mode 100644 app/controllers/api/v0/reports_controller.rb create mode 100644 app/controllers/concerns/reports_actions.rb create mode 100644 app/helpers/reports_helper.rb create mode 100644 app/views/admin/reports/_date_range_form.html.haml create mode 100644 app/views/admin/reports/_rendering_options.html.haml create mode 100644 app/views/admin/reports/_table.html.haml create mode 100644 app/views/admin/reports/packing.html.haml delete mode 100644 app/views/spree/admin/reports/packing.html.haml delete mode 100644 lib/open_food_network/packing_report.rb create mode 100644 lib/reports/errors.rb create mode 100644 lib/reports/frontend_data.rb create mode 100644 lib/reports/packing/base.rb create mode 100644 lib/reports/packing/customer.rb create mode 100644 lib/reports/packing/supplier.rb create mode 100644 lib/reports/report_loader.rb create mode 100644 lib/reports/report_renderer.rb create mode 100644 lib/reports/report_template.rb create mode 100644 spec/controllers/api/v0/reports/packing_report_spec.rb create mode 100644 spec/controllers/api/v0/reports_controller_spec.rb delete mode 100644 spec/lib/open_food_network/packing_report_spec.rb create mode 100644 spec/lib/reports/packing/packing_report_spec.rb create mode 100644 spec/lib/reports/report_loader_spec.rb create mode 100644 spec/lib/reports/report_renderer_spec.rb diff --git a/Gemfile b/Gemfile index d6a7356ea4..f3545175bf 100644 --- a/Gemfile +++ b/Gemfile @@ -96,6 +96,7 @@ gem 'wkhtmltopdf-binary' gem 'immigrant' gem 'roo', '~> 2.8.3' +gem 'spreadsheet_architect' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 1dc028cdbe..f59f6d0d25 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -159,6 +159,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 +187,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 +263,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 +343,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) @@ -509,6 +519,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 +615,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) @@ -790,6 +808,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..c9436136ef --- /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 = Reports::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..cfcb6c0d0d --- /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 Reports::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: @report.as_hashes + end + + def render_error(error) + render json: { error: error.message }, status: :unprocessable_entity + end + + def validate_report + raise Reports::Errors::NoReportType if report_type.blank? + raise Reports::Errors::ReportNotFound if report_class.blank? + end + + def validate_query + raise Reports::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..7916264bf1 --- /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 ||= Reports::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..ac8549c6eb 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 = Reports::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..174e9bd157 --- /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) + Reports::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/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..5f4f97f7fc --- /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..7ece91cf71 --- /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[: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..d8c6ad9abc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -152,6 +152,14 @@ module Openfoodnetwork #{config.root}/app/jobs ) + initializer "ofn.reports" do |_app| + module ::Reports; end + loader = Zeitwerk::Loader.new + loader.push_dir("#{Rails.root}/lib/reports", namespace: ::Reports) + loader.setup + loader.eager_load + 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 873ea1b811..a0163b465d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1191,11 +1191,39 @@ 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?" + 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..cb950c52ec 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -77,6 +77,8 @@ Openfoodnetwork::Application.routes.draw do end end end + + get '/reports/:report_type(/:report_subtype)', to: 'reports#show' 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/reports/errors.rb b/lib/reports/errors.rb new file mode 100644 index 0000000000..af729aab55 --- /dev/null +++ b/lib/reports/errors.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Reports + 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/reports/frontend_data.rb b/lib/reports/frontend_data.rb new file mode 100644 index 0000000000..8a90111ce6 --- /dev/null +++ b/lib/reports/frontend_data.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Reports + 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/reports/packing/base.rb b/lib/reports/packing/base.rb new file mode 100644 index 0000000000..eb6c93eb82 --- /dev/null +++ b/lib/reports/packing/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Reports + module Packing + class Base < ReportTemplate + SUBTYPES = ["customer", "supplier"] + + + + private + + def i18n_scope + "admin.reports" + end + end + end +end diff --git a/lib/reports/packing/customer.rb b/lib/reports/packing/customer.rb new file mode 100644 index 0000000000..49ae4c92a9 --- /dev/null +++ b/lib/reports/packing/customer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Reports + module Packing + class Customer < Base + + end + end +end diff --git a/lib/reports/packing/supplier.rb b/lib/reports/packing/supplier.rb new file mode 100644 index 0000000000..489f68f0cb --- /dev/null +++ b/lib/reports/packing/supplier.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Reports + module Packing + class Supplier < Base + + end + end +end diff --git a/lib/reports/report_loader.rb b/lib/reports/report_loader.rb new file mode 100644 index 0000000000..dc8dba6a6f --- /dev/null +++ b/lib/reports/report_loader.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Reports + 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 Reports::Errors::ReportNotFound + end + + def default_report_subtype + report_subtypes.first || "base" + end + + private + + attr_reader :report_type, :report_subtype + + def report_module + "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 Reports::Errors::ReportNotFound + end + end +end diff --git a/lib/reports/report_renderer.rb b/lib/reports/report_renderer.rb new file mode 100644 index 0000000000..d29e4e5e00 --- /dev/null +++ b/lib/reports/report_renderer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spreadsheet_architect' + +module Reports + class ReportRenderer + def initialize(report) + @report = report + end + + def table_headers + as_arrays.first + end + + def table_rows + as_arrays.drop(1) + end + + def as_hashes + report_rows + end + + def as_arrays + @as_arrays ||= rows_as_arrays + 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 + + private + + def report_rows + @report.report_rows + end + + def rows_as_arrays + report_array = [header_row] + + report_rows.each do |row| + report_array << row_or_summary(row) + end + + report_array + end + + def header_row + report_rows.first.keys - [:summary_row_title] + end + + def row_or_summary(row) + summary_row_title = row.delete :summary_row_title + row_values = row.values + row_values[0] = summary_row_title if summary_row_title + + row_values + end + end +end diff --git a/lib/reports/report_template.rb b/lib/reports/report_template.rb new file mode 100644 index 0000000000..dc0a5bd02a --- /dev/null +++ b/lib/reports/report_template.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Reports + class ReportTemplate + delegate :as_hashes, :as_arrays, :table_headers, :table_rows, + :to_csv, :to_xlsx, :to_ods, :to_json, to: :report_renderer + + attr_reader :options + attr_accessor :report_rows + + 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 + @report_rows = [] + + build_report + end + + def headers + report_rows.first&.keys || [] + end + + private + + attr_reader :current_user, :ransack_params + + def build_report + # TODO + end + + def report_renderer + @report_renderer ||= ReportRenderer.new(self) + 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..25bb8eba91 --- /dev/null +++ b/spec/controllers/api/v0/reports/packing_report_spec.rb @@ -0,0 +1,119 @@ +# 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 } + end + + describe "packing report" do + context "as a regular user" do + let(:current_user) { create(:user) } + + it "does not show reports" do + api_get :show, params + + assert_unauthorized! + end + end + + 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).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).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, + "is_temperature_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, + "is_temperature_controlled" => + line_item.product.shipping_category&.temperature_controlled ? I18n.t(:yes) : I18n.t(:no) + } + end + + def summary_row(order) + { + "summary_row_title" => I18n.t("summary_row.total", scope: i18n_scope), + "hub" => "", + "customer_code" => "", + "first_name" => "", + "last_name" => "", + "supplier" => "", + "product" => "", + "variant" => "", + "quantity" => order.line_items.sum(&:quantity), + "is_temperature_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/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..563d9bf030 --- /dev/null +++ b/spec/lib/reports/packing/packing_report_spec.rb @@ -0,0 +1,112 @@ +# 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(: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 { Reports::Packing::Customer.new user, {} } + + it "fetches completed orders" do + order2 = create(:order) + order2.line_items << build(:line_item) + expect(subject.collection).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.collection).to eq([line_item]) + end + end + + context "as a manager of a supplier" do + let!(:user) { create(:user) } + subject { Reports::Packing::Customer.new user, {} } + + 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.collection).to eq([line_item2]) + expect(subject.as_hashes.first[:first_name]).to eq( + I18n.t('admin.reports.hidden_field') + ) + 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.collection).to eq([]) + end + end + end + + context "as a manager of a distributor" do + let!(:user) { create(:user) } + subject { Reports::Packing::Customer.new user, {} } + + 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.collection).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.collection).to eq([line_item]) + 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..dd53b51ca0 --- /dev/null +++ b/spec/lib/reports/report_loader_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Reports + module Bananas + class Base; end + class Green; end + class Yellow; end + end +end + +describe Reports::ReportLoader do + let(:service) { Reports::ReportLoader.new(*arguments) } + let(:report_base_class) { 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 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 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 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(Reports::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(Reports::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..2865b67784 --- /dev/null +++ b/spec/lib/reports/report_renderer_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Reports::ReportRenderer do + let(:report_rows) { + [ + { id: 1, name: 'carrots', quantity: 3 }, + { id: 2, name: 'onions', quantity: 6 } + ] + } + let(:report) { OpenStruct.new(report_rows: report_rows) } + 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_hashes" do + it "returns the report's data as hashes" do + expect(service.as_hashes).to eq report_rows + 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 + + context "with summary rows" do + let(:report_rows) { + [ + { id: 1, name: 'carrots', quantity: 3 }, + { id: 2, name: 'onions', quantity: 6 }, + { id: nil, name: nil, quantity: 9, summary_row_title: "TOTAL" } + ] + } + + it "returns the report's data as arrays" do + expect(service.as_arrays).to eq [ + [:id, :name, :quantity], + [1, "carrots", 3], + [2, "onions", 6], + ["TOTAL", nil, 9] + ] + end + 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..3ca76997ef 100644 --- a/spec/system/admin/reports/packing_report_spec.rb +++ b/spec/system/admin/reports/packing_report_spec.rb @@ -6,38 +6,104 @@ 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: "Aman") } + let(:bill_address2) { create(:address, lastname: "Bman") } + 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 + + scenario "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).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 + + scenario "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).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 - 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(: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) } - visit spree.admin_reports_path + before do + order.line_items << li1 + order.line_items << li2 + 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