Merge pull request #8145 from Matt-Yorkley/reports-query-interface

Reports Query Interface
This commit is contained in:
Matt-Yorkley
2021-11-05 10:06:52 +00:00
committed by GitHub
43 changed files with 1652 additions and 474 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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} &nbsp; (#{orders_open_at} - #{orders_close_at})".html_safe, oc.id]
end
end
def report_subtypes(report)
Reporting::ReportLoader.new(report).report_subtypes
end
end

View File

@@ -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} &nbsp; (#{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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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]).+/ }

View File

@@ -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

View File

@@ -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

29
lib/reporting/errors.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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