solidus_admin_insights 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +54 -0
  3. data/Gemfile +2 -0
  4. data/LICENSE +26 -0
  5. data/README.md +71 -0
  6. data/Rakefile +21 -0
  7. data/app/assets/javascripts/spree/backend/jquery.tablesorter.min.js +4 -0
  8. data/app/assets/javascripts/spree/backend/solidus_admin_insights.js +2 -0
  9. data/app/assets/javascripts/spree/backend/solidus_admin_insights/paginator.js +128 -0
  10. data/app/assets/javascripts/spree/backend/solidus_admin_insights/report_loader.js +265 -0
  11. data/app/assets/javascripts/spree/backend/solidus_admin_insights/searcher.js +72 -0
  12. data/app/assets/javascripts/spree/backend/solidus_admin_insights/table_sorter.js +47 -0
  13. data/app/assets/javascripts/spree/backend/tmpl.js +87 -0
  14. data/app/assets/javascripts/spree/frontend/solidus_admin_insights.js +2 -0
  15. data/app/assets/stylesheets/spree/backend/override_pdf.css +71 -0
  16. data/app/assets/stylesheets/spree/backend/solidus_admin_insights.css +132 -0
  17. data/app/assets/stylesheets/spree/frontend/solidus_admin_insights.css +4 -0
  18. data/app/controllers/spree/admin/insights_controller.rb +103 -0
  19. data/app/helpers/spree/admin/base_helper_decorator.rb +17 -0
  20. data/app/models/spree/app_configuration_decorator.rb +3 -0
  21. data/app/models/spree/product_decorator.rb +3 -0
  22. data/app/models/spree/promotion_action_decorator.rb +3 -0
  23. data/app/models/spree/return_authorization_decorator.rb +4 -0
  24. data/app/permissions/spree/permission_sets/insight_display.rb +9 -0
  25. data/app/reports/spree/best_selling_products_report.rb +42 -0
  26. data/app/reports/spree/cart_additions_report.rb +36 -0
  27. data/app/reports/spree/cart_removals_report.rb +36 -0
  28. data/app/reports/spree/cart_updations_report.rb +40 -0
  29. data/app/reports/spree/payment_method_transactions_conversion_rate_report.rb +74 -0
  30. data/app/reports/spree/payment_method_transactions_conversion_rate_report/payment_method_state_distribution_chart.rb +39 -0
  31. data/app/reports/spree/payment_method_transactions_report.rb +60 -0
  32. data/app/reports/spree/payment_method_transactions_report/payment_method_revenue_distribution_chart.rb +36 -0
  33. data/app/reports/spree/product_views_report.rb +46 -0
  34. data/app/reports/spree/product_views_to_cart_additions_report.rb +53 -0
  35. data/app/reports/spree/product_views_to_purchases_report.rb +54 -0
  36. data/app/reports/spree/promotional_cost_report.rb +84 -0
  37. data/app/reports/spree/promotional_cost_report/promotional_cost_chart.rb +37 -0
  38. data/app/reports/spree/promotional_cost_report/usage_count_chart.rb +41 -0
  39. data/app/reports/spree/report.rb +131 -0
  40. data/app/reports/spree/report/chart.rb +11 -0
  41. data/app/reports/spree/report/configuration.rb +40 -0
  42. data/app/reports/spree/report/date_slicer.rb +61 -0
  43. data/app/reports/spree/report/observation.rb +49 -0
  44. data/app/reports/spree/report/query_fragments.rb +45 -0
  45. data/app/reports/spree/report/query_time_scale.rb +19 -0
  46. data/app/reports/spree/report/result.rb +100 -0
  47. data/app/reports/spree/report/timed_observation.rb +47 -0
  48. data/app/reports/spree/report/timed_result.rb +48 -0
  49. data/app/reports/spree/returned_products_report.rb +37 -0
  50. data/app/reports/spree/sales_performance_report.rb +107 -0
  51. data/app/reports/spree/sales_performance_report/profit_loss_chart.rb +37 -0
  52. data/app/reports/spree/sales_performance_report/profit_loss_percent_chart.rb +36 -0
  53. data/app/reports/spree/sales_performance_report/sale_cost_price_chart.rb +48 -0
  54. data/app/reports/spree/sales_tax_report.rb +64 -0
  55. data/app/reports/spree/sales_tax_report/monthly_sales_tax_comparison_chart.rb +39 -0
  56. data/app/reports/spree/shipping_cost_report.rb +89 -0
  57. data/app/reports/spree/shipping_cost_report/shipping_cost_distribution_chart.rb +38 -0
  58. data/app/reports/spree/trending_search_report.rb +50 -0
  59. data/app/reports/spree/trending_search_report/frequency_distribution_pie_chart.rb +41 -0
  60. data/app/reports/spree/unique_purchases_report.rb +39 -0
  61. data/app/reports/spree/user_pool_report.rb +66 -0
  62. data/app/reports/spree/user_pool_report/distribution_column_chart.rb +65 -0
  63. data/app/reports/spree/users_not_converted_report.rb +48 -0
  64. data/app/reports/spree/users_who_recently_purchased_report.rb +69 -0
  65. data/app/services/spree/report_generation_service.rb +27 -0
  66. data/app/views/spree/admin/insights/_chart.html.erb +4 -0
  67. data/app/views/spree/admin/insights/download.pdf.erb +27 -0
  68. data/app/views/spree/admin/insights/index.html.erb +82 -0
  69. data/app/views/spree/admin/insights/search/_product_views_search.html.erb +13 -0
  70. data/app/views/spree/admin/insights/search/_search_form.html.erb +39 -0
  71. data/app/views/spree/admin/insights/search/_trending_searches_search.html.erb +13 -0
  72. data/app/views/spree/admin/insights/search/_users_not_converted_search.html.erb +13 -0
  73. data/app/views/spree/admin/insights/search/_users_who_have_not_purchased_recently_search.html.erb +13 -0
  74. data/app/views/spree/admin/insights/search/_users_who_recently_purchased_search.html.erb +13 -0
  75. data/app/views/spree/admin/shared/_insights_side_menu.html.erb +5 -0
  76. data/app/views/spree/admin/shared/sub_menu/_insight.html.erb +7 -0
  77. data/app/views/spree/admin/templates/insights/_paginator.template +11 -0
  78. data/app/views/spree/admin/templates/insights/_search.template +76 -0
  79. data/app/views/spree/admin/templates/insights/_show.template +49 -0
  80. data/app/views/spree/layouts/pdf.html.erb +9 -0
  81. data/bin/rails +7 -0
  82. data/config/initializers/add_to_sidebar.rb +14 -0
  83. data/config/initializers/assets.rb +1 -0
  84. data/config/initializers/mime_types.rb +2 -0
  85. data/config/initializers/wicked_pdf.rb +21 -0
  86. data/config/locales/en.yml +167 -0
  87. data/config/routes.rb +6 -0
  88. data/lib/generators/solidus_admin_insights/install/install_generator.rb +36 -0
  89. data/lib/generators/solidus_admin_insights/install/solidus_admin_insights.rb +22 -0
  90. data/lib/solidus_admin_insights.rb +14 -0
  91. data/lib/solidus_admin_insights/engine.rb +27 -0
  92. data/lib/solidus_admin_insights/factories.rb +6 -0
  93. data/solidus_admin_insights.gemspec +42 -0
  94. data/spec/spec_helper.rb +93 -0
  95. metadata +419 -0
@@ -0,0 +1,61 @@
1
+ module Spree::Report::DateSlicer
2
+ def self.slice_into(start_date, end_date, time_scale, klass)
3
+ case time_scale
4
+ when :hourly
5
+ slice_hours_into(start_date, end_date, klass)
6
+ when :daily
7
+ slice_days_into(start_date, end_date, klass)
8
+ when :monthly
9
+ slice_months_into(start_date, end_date, klass)
10
+ when :yearly
11
+ slice_years_into(start_date, end_date, klass)
12
+ end
13
+ end
14
+
15
+ def self.slice_hours_into(start_date, end_date, klass)
16
+ current_date = start_date
17
+ slices = []
18
+ while current_date < end_date
19
+ slices << (0..23).collect do |hour|
20
+ obj = klass.new
21
+ obj.date = current_date
22
+ obj.hour = hour
23
+ obj
24
+ end
25
+ current_date = current_date.next_day
26
+ end
27
+ slices.flatten
28
+ end
29
+
30
+ def self.slice_days_into(start_date, end_date, klass)
31
+ current_date = start_date
32
+ slices = []
33
+ while current_date < end_date
34
+ obj = klass.new
35
+ obj.date = current_date
36
+ slices << obj
37
+ current_date = current_date.next_day
38
+ end
39
+ slices
40
+ end
41
+
42
+ def self.slice_months_into(start_date, end_date, klass)
43
+ current_date = start_date
44
+ slices = []
45
+ while current_date < end_date
46
+ obj = klass.new
47
+ obj.date = current_date
48
+ slices << obj
49
+ current_date = current_date.end_of_month.next_day
50
+ end
51
+ slices
52
+ end
53
+
54
+ def self.slice_years_into(start_date, end_date, klass)
55
+ (start_date.year..end_date.year).collect do |year|
56
+ obj = klass.new
57
+ obj.date = Date.new(year).end_of_year
58
+ obj
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ class Spree::Report::Observation
2
+
3
+ def initialize
4
+ set_defaults
5
+ end
6
+
7
+ class << self
8
+ def observation_fields(records)
9
+ case records
10
+ when Hash
11
+ build_from_hash(records)
12
+ else
13
+ build_from_list(records)
14
+ end
15
+ end
16
+
17
+ def build_from_hash(records)
18
+ build_from_list(records.keys)
19
+
20
+ define_method :set_defaults do
21
+ records.keys.each do |key|
22
+ self.send("#{ key }=", records[key])
23
+ end
24
+ end
25
+ end
26
+
27
+ def build_from_list(records)
28
+ attr_accessor *records
29
+
30
+ define_method :populate do |result|
31
+ records.each do |record|
32
+ record_name = record.to_s
33
+ self.send("#{ record }=", result[record_name]) if result[record_name]
34
+ end
35
+ end
36
+
37
+ define_method :set_defaults do
38
+ end
39
+
40
+ define_method :observations_to_h do
41
+ records.inject({}) { |acc, record| acc[record] = self.send(record); acc }
42
+ end
43
+ end
44
+ end
45
+
46
+ def to_h
47
+ observations_to_h
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ module Spree::Report::QueryFragments
2
+ def self.from_subquery(subquery, as: 'results')
3
+ Arel::SelectManager.new(Arel.sql("(#{subquery.to_sql}) as #{ as }"))
4
+ end
5
+
6
+ def self.from_join(subquery1, subquery2, join_expr)
7
+ Arel::SelectManager.new(Arel.sql("((#{ subquery1.to_sql }) as q1 JOIN (#{ subquery2.to_sql }) as q2 ON #{ join_expr })"))
8
+ end
9
+
10
+ def self.from_union(subquery1, subquery2, as: 'results')
11
+ Arel::SelectManager.new(Arel.sql("((#{ subquery1.to_sql }) UNION (#{ subquery2.to_sql })) as #{ as }"))
12
+ end
13
+
14
+ def self.year(column, as='year')
15
+ extract_from_date(:year, column, as)
16
+ end
17
+
18
+ def self.month(column, as='month')
19
+ extract_from_date(:month, column, as)
20
+ end
21
+
22
+ def self.week(column, as='week')
23
+ extract_from_date(:week, column, as)
24
+ end
25
+
26
+ def self.day(column, as='day')
27
+ extract_from_date(:day, column, as)
28
+ end
29
+
30
+ def self.hour(column, as='hour')
31
+ extract_from_date(:hour, column, as)
32
+ end
33
+
34
+ def self.extract_from_date(part, column, as)
35
+ "EXTRACT(#{ part } from #{ column }) AS #{ as }"
36
+ end
37
+
38
+ def self.if_null(val, default_val)
39
+ Arel::Nodes::NamedFunction.new('COALESCE', [val, default_val])
40
+ end
41
+
42
+ def self.sum(node)
43
+ Arel::Nodes::NamedFunction.new('SUM', [node])
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ module Spree::Report::QueryTimeScale
2
+ def self.select(time_scale, time_scale_on)
3
+ db_col_name = time_scale_on.present? ? "#{ time_scale_on }.created_at" : "created_at"
4
+ time_scale_columns(time_scale).collect { |time_scale_column| ::Spree::Report::QueryFragments.public_send(time_scale_column, db_col_name) }
5
+ end
6
+
7
+ def self.time_scale_columns(time_scale)
8
+ case time_scale
9
+ when :hourly
10
+ [:day, :hour]
11
+ when :daily
12
+ [:month, :day]
13
+ when :monthly
14
+ [:year, :month]
15
+ when :yearly
16
+ [:year]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,100 @@
1
+ module Spree
2
+ class Report
3
+ class Result
4
+ attr_accessor :start_date, :end_date, :time_scale, :report
5
+ attr_reader :observations
6
+
7
+ def initialize
8
+ yield self
9
+ build_report_observations
10
+ end
11
+
12
+ def build_report_observations
13
+ query_results
14
+ populate_observations
15
+ end
16
+
17
+ def query_results
18
+ @results = report.get_results
19
+ end
20
+
21
+ def populate_observations
22
+ @observations = @results.collect do |result|
23
+ _observation = self.class::Observation.new
24
+ _observation.populate(result)
25
+ _observation
26
+ end
27
+ end
28
+
29
+
30
+ def to_h
31
+ {
32
+ deeplink: report.deeplink_properties,
33
+ total_pages: report.total_pages,
34
+ per_page: report.records_per_page,
35
+ pagination_required: report.pagination_required?,
36
+ headers: headers,
37
+ search_attributes: search_attributes,
38
+ stats: observations.collect(&:to_h),
39
+ chart_json: chart_json
40
+ }
41
+ end
42
+
43
+ def chart_json
44
+ {
45
+ chart: false,
46
+ charts: []
47
+ }
48
+ end
49
+
50
+ def self.charts(*report_charts)
51
+ define_method :chart_json do
52
+ {
53
+ chart: true,
54
+ charts: report_charts.collect { |report_chart| report_chart.new(self).to_h }.flatten
55
+ }
56
+ end
57
+ end
58
+
59
+ def search_attributes
60
+ report.class::SEARCH_ATTRIBUTES.transform_values { |value| value.to_s.humanize }
61
+ end
62
+
63
+ def total_pages # O indexed
64
+ if report.pagination_required?
65
+ total_pages = report.total_records / report.records_per_page
66
+ if report.total_records % report.records_per_page == 0
67
+ total_pages -= 1
68
+ end
69
+ total_pages
70
+ end
71
+ end
72
+
73
+ def headers
74
+ report.class::HEADERS.keys.collect do |header|
75
+ header_description = {
76
+ name: Spree.t(header.to_sym, scope: [:insight, report.name]),
77
+ value: header,
78
+ type: report.class::HEADERS[header],
79
+ sortable: header.in?(report.class::SORTABLE_ATTRIBUTES)
80
+ }
81
+ header_description[:sorted] = report.sort_direction if report.header_sorted?(header)
82
+ header_description
83
+ end
84
+ end
85
+
86
+ def time_dimension
87
+ case time_scale
88
+ when :hourly
89
+ :hour_name
90
+ when :daily
91
+ :day_name
92
+ when :monthly
93
+ :month_name
94
+ when :yearly
95
+ :year
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,47 @@
1
+ class Spree::Report::TimedObservation < Spree::Report::Observation
2
+
3
+ extend Forwardable
4
+
5
+ attr_accessor :date, :hour, :reportable_keys
6
+
7
+ def_delegators :date, :day, :month, :year
8
+
9
+ def initialize
10
+ super
11
+ self.hour = 0
12
+ end
13
+
14
+ def describes?(result, time_scale)
15
+ case time_scale
16
+ when :hourly
17
+ result['hour'] == hour && result['day'] == day
18
+ when :daily
19
+ result['day'] == day && result['month'] == month
20
+ when :monthly
21
+ result['month'] == month && result['year'] == year
22
+ when :yearly
23
+ result['year'] == year
24
+ end
25
+ end
26
+
27
+ def month_name
28
+ Date::MONTHNAMES[month]
29
+ end
30
+
31
+ def hour_name
32
+ if hour == 23
33
+ return "23:00 - 00:00"
34
+ else
35
+ return "#{ hour }:00 - #{ hour + 1 }:00"
36
+ end
37
+ end
38
+
39
+ def day_name
40
+ "#{ day } #{ month_name }"
41
+ end
42
+
43
+ def to_h
44
+ super.merge({day_name: day_name, month_name: month_name, year: year, hour_name: hour_name})
45
+ end
46
+
47
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ class Report
3
+ class TimedResult < Result
4
+
5
+ def build_report_observations
6
+ query_results
7
+ build_empty_observations
8
+ populate_observations
9
+ end
10
+
11
+ def build_empty_observations
12
+ @observations = Spree::Report::DateSlicer.slice_into(start_date, end_date, time_scale, self.class::Observation)
13
+ end
14
+
15
+ def populate_observations
16
+ observation_iter = @observations.each
17
+ current_observation = @observations.present? ? observation_iter.next : nil
18
+ @results.each do |result|
19
+ if current_observation.present?
20
+ begin
21
+ until current_observation.describes? result, time_scale
22
+ current_observation = observation_iter.next
23
+ end
24
+
25
+ current_observation.populate(result)
26
+ current_observation = observation_iter.next
27
+ rescue StopIteration
28
+ break
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def headers
35
+ [time_headers] + super
36
+ end
37
+
38
+ private def time_headers
39
+ {
40
+ name: Spree.t(time_dimension, scope: [:admin]),
41
+ value: time_dimension,
42
+ type: :string,
43
+ sortable: false
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ class ReturnedProductsReport < Spree::Report
3
+ DEFAULT_SORTABLE_ATTRIBUTE = :product_name
4
+ HEADERS = { sku: :string, product_name: :string, return_count: :integer }
5
+ SEARCH_ATTRIBUTES = { start_date: :product_returned_from, end_date: :product_returned_till }
6
+ SORTABLE_ATTRIBUTES = [:product_name, :sku, :return_count]
7
+
8
+ deeplink product_name: { template: %Q{<a href="/admin/products/{%# o.product_slug %}" target="_blank">{%# o.product_name %}</a>} }
9
+
10
+ class Result < Spree::Report::Result
11
+ class Observation < Spree::Report::Observation
12
+ observation_fields [:sku, :product_name, :return_count, :product_slug]
13
+
14
+ def sku
15
+ @sku.presence || @product_name
16
+ end
17
+ end
18
+ end
19
+
20
+ def report_query
21
+ Spree::ReturnAuthorization
22
+ .joins(:return_items)
23
+ .joins(:inventory_units)
24
+ .joins(:variants)
25
+ .joins(:products)
26
+ .where(spree_return_items: { created_at: reporting_period })
27
+ .group('spree_variants.id', 'spree_products.name', 'spree_products.slug', 'spree_variants.sku')
28
+ .select(
29
+ 'spree_products.name as product_name',
30
+ 'spree_products.slug as product_slug',
31
+ 'spree_variants.sku as sku',
32
+ 'COUNT(spree_variants.id) as return_count'
33
+ )
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,107 @@
1
+ module Spree
2
+ class SalesPerformanceReport < Spree::Report
3
+ HEADERS = { sale_price: :integer, cost_price: :integer, promotion_discount: :integer, profit_loss: :integer, profit_loss_percent: :integer }
4
+ SEARCH_ATTRIBUTES = { start_date: :orders_created_from, end_date: :orders_created_till }
5
+ SORTABLE_ATTRIBUTES = []
6
+
7
+ class Result < Spree::Report::TimedResult
8
+ charts ProfitLossChart, ProfitLossPercentChart, SaleCostPriceChart
9
+
10
+ class Observation < Spree::Report::TimedObservation
11
+ observation_fields cost_price: 0, sale_price: 0, profit_loss: 0, profit_loss_percent: 0, promotion_discount: 0
12
+
13
+ def cost_price
14
+ @cost_price.to_f
15
+ end
16
+
17
+ def sale_price
18
+ @sale_price.to_f
19
+ end
20
+
21
+ def profit_loss
22
+ @profit_loss.to_f
23
+ end
24
+
25
+ def profit_loss_percent
26
+ return (profit_loss * 100 / cost_price).round(2) unless cost_price.zero?
27
+ 0.0
28
+ end
29
+
30
+ def promotion_discount
31
+ @promotion_discount.to_f
32
+ end
33
+ end
34
+
35
+ end
36
+
37
+ private def report_query
38
+ Spree::Report::QueryFragments
39
+ .from_union(order_with_line_items_grouped_by_time, promotions_grouped_by_time)
40
+ .group(*time_scale_columns_to_s)
41
+ .order(*time_scale_columns)
42
+ .project(
43
+ *time_scale_columns,
44
+ 'SUM(sale_price) as sale_price',
45
+ 'SUM(cost_price) as cost_price',
46
+ 'SUM(profit_loss) as profit_loss',
47
+ 'SUM(promotion_discount) as promotion_discount'
48
+ )
49
+ end
50
+
51
+ private def promotions_grouped_by_time
52
+ Spree::Report::QueryFragments
53
+ .from_subquery(promotion_adjustments_with_time)
54
+ .group(*time_scale_columns_to_s, 'sale_price', 'cost_price')
55
+ .order(*time_scale_columns)
56
+ .project(
57
+ *time_scale_columns,
58
+ '0 as sale_price',
59
+ '0 as cost_price',
60
+ 'SUM(promotion_discount) * -1 as profit_loss',
61
+ 'SUM(promotion_discount) as promotion_discount'
62
+ )
63
+ end
64
+
65
+ private def promotion_adjustments_with_time
66
+ Spree::Adjustment
67
+ .promotion
68
+ .where(created_at: reporting_period)
69
+ .select(
70
+ 'abs(amount) as promotion_discount',
71
+ *time_scale_selects('spree_adjustments')
72
+ )
73
+ end
74
+
75
+ private def order_with_line_items_grouped_by_time
76
+ order_with_line_items_ar = Arel::Table.new(:order_with_line_items)
77
+ zero = Arel::Nodes.build_quoted(0.0)
78
+ Spree::Report::QueryFragments
79
+ .from_subquery(order_with_line_items, as: :order_with_line_items)
80
+ .group(*time_scale_columns_to_s)
81
+ .order(*time_scale_columns)
82
+ .project(
83
+ *time_scale_columns,
84
+ Spree::Report::QueryFragments.if_null(Spree::Report::QueryFragments.sum(order_with_line_items_ar[:sale_price]), zero).as('sale_price'),
85
+ Spree::Report::QueryFragments.if_null(Spree::Report::QueryFragments.sum(order_with_line_items_ar[:cost_price]), zero).as('cost_price'),
86
+ Spree::Report::QueryFragments.if_null(Spree::Report::QueryFragments.sum(order_with_line_items_ar[:profit_loss]), zero).as('profit_loss'),
87
+ '0 as promotion_discount'
88
+ )
89
+ end
90
+
91
+ private def order_with_line_items
92
+ line_item_ar = Spree::LineItem.arel_table
93
+ Spree::Order
94
+ .where.not(completed_at: nil)
95
+ .where(created_at: reporting_period)
96
+ .joins(:line_items)
97
+ .group('spree_orders.id', *time_scale_columns_to_s)
98
+ .select(
99
+ *time_scale_selects('spree_orders'),
100
+ "spree_orders.item_total as sale_price",
101
+ "SUM(#{ Spree::Report::QueryFragments.if_null(line_item_ar[:cost_price], line_item_ar[:price]).to_sql } * spree_line_items.quantity) as cost_price",
102
+ "(spree_orders.item_total - SUM(#{ Spree::Report::QueryFragments.if_null(line_item_ar[:cost_price], line_item_ar[:price]).to_sql } * spree_line_items.quantity)) as profit_loss"
103
+ )
104
+ end
105
+
106
+ end
107
+ end