double_entry-reporting 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +75 -0
- data/LICENSE.md +19 -0
- data/README.md +71 -0
- data/lib/double_entry/reporting.rb +154 -0
- data/lib/double_entry/reporting/aggregate.rb +130 -0
- data/lib/double_entry/reporting/aggregate_array.rb +79 -0
- data/lib/double_entry/reporting/day_range.rb +42 -0
- data/lib/double_entry/reporting/hour_range.rb +45 -0
- data/lib/double_entry/reporting/line_aggregate.rb +17 -0
- data/lib/double_entry/reporting/line_aggregate_filter.rb +77 -0
- data/lib/double_entry/reporting/line_metadata_filter.rb +33 -0
- data/lib/double_entry/reporting/month_range.rb +94 -0
- data/lib/double_entry/reporting/time_range.rb +59 -0
- data/lib/double_entry/reporting/time_range_array.rb +49 -0
- data/lib/double_entry/reporting/version.rb +5 -0
- data/lib/double_entry/reporting/week_range.rb +107 -0
- data/lib/double_entry/reporting/year_range.rb +40 -0
- data/lib/generators/double_entry/reporting/install/install_generator.rb +29 -0
- data/lib/generators/double_entry/reporting/install/templates/migration.rb +25 -0
- metadata +290 -0
@@ -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
|