double_entry-reporting 0.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.
@@ -0,0 +1,79 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class AggregateArray < Array
5
+ # An AggregateArray is awesome
6
+ # It is useful for making reports
7
+ # It is basically an array of aggregate results,
8
+ # representing a column of data in a report.
9
+ #
10
+ # For example, you could request all sales
11
+ # broken down by month and it would return an array of values
12
+ attr_reader :function, :account, :partner_account, :code, :filter, :range_type, :start, :finish, :currency
13
+
14
+ def initialize(function:, account:, code:, partner_account: nil, filter: nil, range_type: nil, start: nil, finish: nil)
15
+ @function = function.to_s
16
+ @account = account
17
+ @code = code
18
+ @partner_account = partner_account
19
+ @filter = filter
20
+ @range_type = range_type
21
+ @start = start
22
+ @finish = finish
23
+ @currency = DoubleEntry::Account.currency(account)
24
+
25
+ retrieve_aggregates
26
+ fill_in_missing_aggregates
27
+ populate_self
28
+ end
29
+
30
+ private
31
+
32
+ def populate_self
33
+ all_periods.each do |period|
34
+ self << @aggregates[period.key]
35
+ end
36
+ end
37
+
38
+ def fill_in_missing_aggregates
39
+ # some aggregates may not have been previously calculated, so we can request them now
40
+ # (this includes aggregates for the still-running period)
41
+ all_periods.each do |period|
42
+ unless @aggregates[period.key]
43
+ @aggregates[period.key] = Aggregate.formatted_amount(function: function, account: account, code: code,
44
+ range: period, partner_account: partner_account, filter: filter)
45
+ end
46
+ end
47
+ end
48
+
49
+ # get any previously calculated aggregates
50
+ def retrieve_aggregates
51
+ fail ArgumentError, "Invalid range type '#{range_type}'" unless %w(year month week day hour).include? range_type
52
+ scope = LineAggregate.
53
+ where(:function => function).
54
+ where(:range_type => 'normal').
55
+ where(:account => account.try(:to_s)).
56
+ where(:partner_account => partner_account.try(:to_s)).
57
+ where(:code => code.try(:to_s)).
58
+ where(:filter => filter.inspect).
59
+ where(LineAggregate.arel_table[range_type].not_eq(nil))
60
+ @aggregates = scope.each_with_object({}) do |result, hash|
61
+ hash[result.key] = formatted_amount(result.amount)
62
+ end
63
+ end
64
+
65
+ def all_periods
66
+ TimeRangeArray.make(range_type, start, finish)
67
+ end
68
+
69
+ def formatted_amount(amount)
70
+ amount ||= 0
71
+ if function == 'count'
72
+ amount
73
+ else
74
+ Money.new(amount, currency)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,42 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class DayRange < TimeRange
5
+ attr_reader :year, :week, :day
6
+
7
+ def initialize(options)
8
+ super options
9
+
10
+ @week = options[:week]
11
+ @day = options[:day]
12
+ week_range = WeekRange.new(options)
13
+
14
+ @start = week_range.start + (options[:day] - 1).days
15
+ @finish = @start.end_of_day
16
+ end
17
+
18
+ def self.from_time(time)
19
+ week_range = WeekRange.from_time(time)
20
+ DayRange.new(:year => week_range.year, :week => week_range.week, :day => time.wday == 0 ? 7 : time.wday)
21
+ end
22
+
23
+ def previous
24
+ DayRange.from_time(@start - 1.day)
25
+ end
26
+
27
+ def next
28
+ DayRange.from_time(@start + 1.day)
29
+ end
30
+
31
+ def ==(other)
32
+ week == other.week &&
33
+ year == other.year &&
34
+ day == other.day
35
+ end
36
+
37
+ def to_s
38
+ start.strftime('%Y, %a %b %d')
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,45 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class HourRange < TimeRange
5
+ attr_reader :year, :week, :day, :hour
6
+
7
+ def initialize(options)
8
+ super options
9
+
10
+ @week = options[:week]
11
+ @day = options[:day]
12
+ @hour = options[:hour]
13
+
14
+ day_range = DayRange.new(options)
15
+
16
+ @start = day_range.start + options[:hour].hours
17
+ @finish = @start.end_of_hour
18
+ end
19
+
20
+ def self.from_time(time)
21
+ day = DayRange.from_time(time)
22
+ HourRange.new :year => day.year, :week => day.week, :day => day.day, :hour => time.hour
23
+ end
24
+
25
+ def previous
26
+ HourRange.from_time(@start - 1.hour)
27
+ end
28
+
29
+ def next
30
+ HourRange.from_time(@start + 1.hour)
31
+ end
32
+
33
+ def ==(other)
34
+ week == other.week &&
35
+ year == other.year &&
36
+ day == other.day &&
37
+ hour == other.hour
38
+ end
39
+
40
+ def to_s
41
+ "#{start.hour}:00:00 - #{start.hour}:59:59"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineAggregate < ActiveRecord::Base
5
+ def self.aggregate(function:, account:, partner_account:, code:, range:, named_scopes:)
6
+ collection_filter = LineAggregateFilter.new(account: account, partner_account: partner_account,
7
+ code: code, range: range, filter_criteria: named_scopes)
8
+ collection = collection_filter.filter
9
+ collection.send(function, :amount)
10
+ end
11
+
12
+ def key
13
+ "#{year}:#{month}:#{week}:#{day}:#{hour}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,77 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineAggregateFilter
5
+
6
+ def initialize(account:, partner_account:, code:, range:, filter_criteria:)
7
+ @account = account
8
+ @partner_account = partner_account
9
+ @code = code
10
+ @range = range
11
+ @filter_criteria = filter_criteria || []
12
+ end
13
+
14
+ def filter
15
+ @collection ||= apply_filters
16
+ end
17
+
18
+ private
19
+
20
+ def apply_filters
21
+ collection = apply_filter_criteria.
22
+ where(:account => @account).
23
+ where(:created_at => @range.start..@range.finish)
24
+ collection = collection.where(:code => @code) if @code
25
+ collection = collection.where(:partner_account => @partner_account) if @partner_account
26
+
27
+ collection
28
+ end
29
+
30
+ # a lot of the trickier reports will use filters defined
31
+ # in filter_criteria to bring in data from other tables.
32
+ # For example:
33
+ #
34
+ # filter_criteria = [
35
+ # # an example of calling a named scope called with arguments
36
+ # {
37
+ # :scope => {
38
+ # :name => :ten_dollar_purchases_by_category,
39
+ # :arguments => [:cat_videos, :cat_pictures]
40
+ # }
41
+ # },
42
+ # # an example of calling a named scope with no arguments
43
+ # {
44
+ # :scope => {
45
+ # :name => :ten_dollar_purchases
46
+ # }
47
+ # },
48
+ # # an example of providing multiple metadatum criteria to filter on
49
+ # {
50
+ # :metadata => {
51
+ # :meme => :business_cat,
52
+ # :category => :fun_times,
53
+ # }
54
+ # }
55
+ # ]
56
+ def apply_filter_criteria
57
+ @filter_criteria.reduce(DoubleEntry::Line) do |collection, filter|
58
+ if filter[:scope].present?
59
+ filter_by_scope(collection, filter[:scope])
60
+ elsif filter[:metadata].present?
61
+ filter_by_metadata(collection, filter[:metadata])
62
+ else
63
+ collection
64
+ end
65
+ end
66
+ end
67
+
68
+ def filter_by_scope(collection, scope)
69
+ collection.public_send(scope[:name], *scope[:arguments])
70
+ end
71
+
72
+ def filter_by_metadata(collection, metadata)
73
+ DoubleEntry::Reporting::LineMetadataFilter.filter(collection: collection, metadata: metadata)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,33 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class LineMetadataFilter
5
+
6
+ def self.filter(collection:, metadata:)
7
+ table_alias_index = 0
8
+
9
+ metadata.reduce(collection) do |filtered_collection, (key, value)|
10
+ table_alias = "m#{table_alias_index}"
11
+ table_alias_index += 1
12
+
13
+ filtered_collection.
14
+ joins("INNER JOIN #{line_metadata_table} as #{table_alias} ON #{table_alias}.line_id = #{lines_table}.id").
15
+ where("#{table_alias}.key = ? AND #{table_alias}.value = ?", key, value)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def self.line_metadata_table
22
+ DoubleEntry::LineMetadata.table_name
23
+ end
24
+ private_class_method :line_metadata_table
25
+
26
+ def self.lines_table
27
+ DoubleEntry::Line.table_name
28
+ end
29
+ private_class_method :lines_table
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,94 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class MonthRange < TimeRange
5
+ class << self
6
+ def from_time(time)
7
+ new(:year => time.year, :month => time.month)
8
+ end
9
+
10
+ def current
11
+ from_time(Time.now)
12
+ end
13
+
14
+ # Obtain a sequence of MonthRanges from the given start to the current
15
+ # month.
16
+ #
17
+ # @option options :from [Time] Time of the first in the returned sequence
18
+ # of MonthRanges.
19
+ # @return [Array<MonthRange>]
20
+ def reportable_months(options = {})
21
+ month = options[:from] ? from_time(options[:from]) : earliest_month
22
+ last = current
23
+ [month].tap do |months|
24
+ while month != last
25
+ month = month.next
26
+ months << month
27
+ end
28
+ end
29
+ end
30
+
31
+ def earliest_month
32
+ from_time(Reporting.configuration.start_of_business)
33
+ end
34
+ end
35
+
36
+ attr_reader :year, :month
37
+
38
+ def initialize(options = {})
39
+ super options
40
+
41
+ if options.present?
42
+ @month = options[:month]
43
+
44
+ month_start = Time.local(year, options[:month], 1)
45
+ @start = month_start
46
+ @finish = month_start.end_of_month
47
+
48
+ @start = MonthRange.earliest_month.start if options[:range_type] == :all_time
49
+ end
50
+ end
51
+
52
+ def previous
53
+ if month <= 1
54
+ MonthRange.new :year => year - 1, :month => 12
55
+ else
56
+ MonthRange.new :year => year, :month => month - 1
57
+ end
58
+ end
59
+
60
+ def next
61
+ if month >= 12
62
+ MonthRange.new :year => year + 1, :month => 1
63
+ else
64
+ MonthRange.new :year => year, :month => month + 1
65
+ end
66
+ end
67
+
68
+ def beginning_of_financial_year
69
+ first_month_of_financial_year = Reporting.configuration.first_month_of_financial_year
70
+ year = (month >= first_month_of_financial_year) ? @year : (@year - 1)
71
+ MonthRange.new(:year => year, :month => first_month_of_financial_year)
72
+ end
73
+
74
+ alias_method :succ, :next
75
+
76
+ def <=>(other)
77
+ start <=> other.start
78
+ end
79
+
80
+ def ==(other)
81
+ month == other.month &&
82
+ year == other.year
83
+ end
84
+
85
+ def all_time
86
+ MonthRange.new(:year => year, :month => month, :range_type => :all_time)
87
+ end
88
+
89
+ def to_s
90
+ start.strftime('%Y, %b')
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+ module DoubleEntry
3
+ module Reporting
4
+ class TimeRange
5
+ attr_reader :start, :finish
6
+ attr_reader :year, :month, :week, :day, :hour, :range_type
7
+
8
+ def self.make(options = {})
9
+ @options = options
10
+ case
11
+ when options[:year] && options[:week] && options[:day] && options[:hour]
12
+ HourRange.new(options)
13
+ when options[:year] && options[:week] && options[:day]
14
+ DayRange.new(options)
15
+ when options[:year] && options[:week]
16
+ WeekRange.new(options)
17
+ when options[:year] && options[:month]
18
+ MonthRange.new(options)
19
+ when options[:year]
20
+ YearRange.new(options)
21
+ else
22
+ fail "Invalid range information #{options}"
23
+ end
24
+ end
25
+
26
+ def self.range_from_time_for_period(start_time, period_name)
27
+ case period_name
28
+ when 'month'
29
+ YearRange.from_time(start_time)
30
+ when 'week'
31
+ YearRange.from_time(start_time)
32
+ when 'day'
33
+ MonthRange.from_time(start_time)
34
+ when 'hour'
35
+ DayRange.from_time(start_time)
36
+ end
37
+ end
38
+
39
+ def include?(time)
40
+ time >= @start &&
41
+ time <= @finish
42
+ end
43
+
44
+ def initialize(options)
45
+ @year = options[:year]
46
+ @range_type = options[:range_type] || :normal
47
+ @month = @week = @day = @hour = nil
48
+ end
49
+
50
+ def key
51
+ "#{@year}:#{@month}:#{@week}:#{@day}:#{@hour}"
52
+ end
53
+
54
+ def human_readable_name
55
+ self.class.name.gsub('DoubleEntry::Reporting::', '').gsub('Range', '')
56
+ end
57
+ end
58
+ end
59
+ end