sie-analysis 0.1.1
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.
- checksums.yaml +7 -0
- data/README.md +185 -0
- data/assets/header-vector.svg +1647 -0
- data/lib/sie_analysis/amounts.rb +51 -0
- data/lib/sie_analysis/analysis.rb +233 -0
- data/lib/sie_analysis/balance_sheets.rb +88 -0
- data/lib/sie_analysis/budgets.rb +39 -0
- data/lib/sie_analysis/dimension_statements.rb +83 -0
- data/lib/sie_analysis/groups.rb +28 -0
- data/lib/sie_analysis/income_statements.rb +109 -0
- data/lib/sie_analysis/parser.rb +251 -0
- data/lib/sie_analysis/sie_tokenizer.rb +105 -0
- data/lib/sie_analysis/version.rb +5 -0
- data/lib/sie_analysis.rb +20 -0
- metadata +71 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# Shared ledger amount presentation helpers for Analysis.
|
|
5
|
+
module Amounts
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Period keys are either account or [account, dimension_no, object_code].
|
|
9
|
+
def dimension_parts(key)
|
|
10
|
+
case key
|
|
11
|
+
when Array
|
|
12
|
+
account, dim, code = key
|
|
13
|
+
[account, dim, code]
|
|
14
|
+
else
|
|
15
|
+
[key, nil, nil]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def income_group?(group)
|
|
20
|
+
group && Groups::INCOME.any? { |item| item.equal?(group) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def present_amount(raw, kind)
|
|
24
|
+
kind == :revenue ? -raw : raw
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def present_balance(raw, kind)
|
|
28
|
+
kind == :equity_liability ? -raw : raw
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def round_money(value)
|
|
32
|
+
BigDecimal(value.to_s).round(2, BigDecimal::ROUND_HALF_UP)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def present_ledger_amount(raw, account_number)
|
|
36
|
+
group = Groups.for_account(account_number)
|
|
37
|
+
return round_money(raw) unless group
|
|
38
|
+
|
|
39
|
+
presented = if income_group?(group)
|
|
40
|
+
present_amount(raw, group.fetch(:kind))
|
|
41
|
+
else
|
|
42
|
+
present_balance(raw, group.fetch(:kind))
|
|
43
|
+
end
|
|
44
|
+
round_money(presented)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def raw_period_amount(account, month_hash)
|
|
48
|
+
month_hash.fetch(account, BigDecimal("0"))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SieAnalysis
|
|
6
|
+
# Reports for one parsed financial year. Monetary values are BigDecimal in SEK.
|
|
7
|
+
# Income and monthly summaries use period balances; daily summaries use vouchers.
|
|
8
|
+
class Analysis
|
|
9
|
+
include Amounts
|
|
10
|
+
include IncomeStatements
|
|
11
|
+
include DimensionStatements
|
|
12
|
+
include BalanceSheets
|
|
13
|
+
include Budgets
|
|
14
|
+
|
|
15
|
+
CURRENCY = "SEK"
|
|
16
|
+
DIMENSIONS = {
|
|
17
|
+
cost_center: 1,
|
|
18
|
+
project: 6
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# @param document [Parser::Parsed] a decoded and parsed SIE export
|
|
22
|
+
def initialize(document)
|
|
23
|
+
@parsed = document
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Summarizes all calendar months touched by the inclusive date range.
|
|
27
|
+
# @param from [Date] first date
|
|
28
|
+
# @param to [Date] last date
|
|
29
|
+
# @param dimension [Symbol, nil] :project or :cost_center
|
|
30
|
+
# @return [Hash] income statement, balance sheet, budget, monthly totals and notes
|
|
31
|
+
def overview(from:, to:, dimension: nil)
|
|
32
|
+
start_date, end_date = from, to
|
|
33
|
+
validate_period!(start_date, end_date)
|
|
34
|
+
months = month_keys(start_date, end_date)
|
|
35
|
+
notes = overview_notes(start_date, end_date)
|
|
36
|
+
dim = require_dimension!(dimension, available_dimensions)
|
|
37
|
+
income = income_statement(months, dim)
|
|
38
|
+
balance = balance_sheet(end_date, months, dim)
|
|
39
|
+
soft_balance_note!(balance, income, notes)
|
|
40
|
+
notes << "Balance sheet totals are omitted when grouping by #{dim}." if dim
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
period: { from: start_date, to: end_date },
|
|
44
|
+
currency: CURRENCY,
|
|
45
|
+
granularity: "monthly",
|
|
46
|
+
available_dimensions: available_dimensions,
|
|
47
|
+
income_statement: income,
|
|
48
|
+
budget: budget_overview(months, dim),
|
|
49
|
+
balance_sheet: balance,
|
|
50
|
+
highlights: highlights(income, balance),
|
|
51
|
+
notes:,
|
|
52
|
+
monthly_totals: monthly_totals(months, dim)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Lists non-zero monthly movements, optionally filtered by BAS group or object.
|
|
57
|
+
# @param month [String] YYYY-MM
|
|
58
|
+
# @param group_code [String, nil] report group, e.g. "revenue"
|
|
59
|
+
# @param account_number [Integer, nil] BAS account number
|
|
60
|
+
# @param dimension [Symbol, nil] :project or :cost_center
|
|
61
|
+
# @param dimension_code [String, nil] object code; requires dimension
|
|
62
|
+
# @return [Array<Hash>] account_number, account_name, movement and group/object fields
|
|
63
|
+
def account_movements(month:, group_code: nil, account_number: nil, dimension: nil, dimension_code: nil)
|
|
64
|
+
month_label = normalize_year_month(month)
|
|
65
|
+
dim = require_dimension!(dimension, available_dimensions)
|
|
66
|
+
raise ArgumentError, "dimension_code requires dimension" if dimension_code && !dim
|
|
67
|
+
accounts = account_rows_for_month(month_label.delete("-"), dim)
|
|
68
|
+
accounts.select! { |row| row.fetch(:group_code) == group_code } if group_code
|
|
69
|
+
accounts.select! { |row| row.fetch(:account_number) == Integer(account_number) } if account_number
|
|
70
|
+
accounts.select! { |row| row.fetch(:dimension_code) == dimension_code } if dimension_code
|
|
71
|
+
accounts
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns opening balance, movement and closing balance for exactly one period.
|
|
75
|
+
# @param account_number [Integer] BAS account number
|
|
76
|
+
# @param month [String, nil] YYYY-MM; mutually exclusive with on
|
|
77
|
+
# @param on [Date, nil] a single day; requires SIE 4
|
|
78
|
+
# @return [Hash] account fields, Date period boundaries and BigDecimal balances
|
|
79
|
+
def account_summary(account_number, month: nil, on: nil)
|
|
80
|
+
number = Integer(account_number)
|
|
81
|
+
raise ArgumentError, "Provide month or on, not both" if on && month
|
|
82
|
+
return day_account_summary(number, date_argument!(on)) if on
|
|
83
|
+
raise ArgumentError, "month or on is required" unless month
|
|
84
|
+
|
|
85
|
+
month_account_summary(number, month)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Lists vouchers with a non-zero net movement on the account; requires SIE 4.
|
|
89
|
+
# @param account_number [Integer] BAS account number
|
|
90
|
+
# @param from [Date] inclusive first voucher date
|
|
91
|
+
# @param to [Date] inclusive last voucher date
|
|
92
|
+
# @return [Array<Hash>] series, number, Date, description and BigDecimal movement
|
|
93
|
+
def vouchers_on_account(account_number, from:, to:)
|
|
94
|
+
start_day, end_day = from, to
|
|
95
|
+
require_transactions!
|
|
96
|
+
validate_period!(start_day, end_day)
|
|
97
|
+
number = Integer(account_number)
|
|
98
|
+
@parsed.vouchers.filter_map do |voucher|
|
|
99
|
+
next unless voucher.date.between?(start_day, end_day)
|
|
100
|
+
|
|
101
|
+
raw = voucher.lines.sum { |line| line.account == number ? line.amount : BigDecimal("0") }
|
|
102
|
+
next if raw.zero?
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
voucher_series: voucher.series,
|
|
106
|
+
voucher_number: voucher.number,
|
|
107
|
+
date: voucher.date,
|
|
108
|
+
description: voucher.description,
|
|
109
|
+
movement: present_ledger_amount(raw, number)
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<Symbol>] supported dimensions with objects declared in the export
|
|
115
|
+
def available_dimensions
|
|
116
|
+
dims = []
|
|
117
|
+
dims << :cost_center if @parsed.objects.keys.any? { |dim, _code| dim == 1 }
|
|
118
|
+
dims << :project if @parsed.objects.keys.any? { |dim, _code| dim == 6 }
|
|
119
|
+
dims
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def date_argument!(date)
|
|
125
|
+
raise ArgumentError, "Expected a Date" unless date.instance_of?(Date)
|
|
126
|
+
date
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_period!(first, last)
|
|
130
|
+
date_argument!(first)
|
|
131
|
+
date_argument!(last)
|
|
132
|
+
raise ArgumentError, "Start date must not be after end date" if first > last
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def require_transactions!
|
|
136
|
+
raise ArgumentError, "This analysis requires a SIE 4 export" unless @parsed.sie_type == 4
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def overview_notes(start_date, end_date)
|
|
140
|
+
notes = ["Amounts use monthly period balances, not day-level bookkeeping."]
|
|
141
|
+
return notes if whole_months?(start_date, end_date)
|
|
142
|
+
|
|
143
|
+
notes << "The requested dates are not full calendar months; monthly balances still cover complete months."
|
|
144
|
+
notes
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def month_account_summary(number, year_month)
|
|
148
|
+
month_label = normalize_year_month(year_month)
|
|
149
|
+
start_date = Date.strptime("#{month_label}-01", "%Y-%m-%d")
|
|
150
|
+
end_date = Date.new(start_date.year, start_date.month, -1)
|
|
151
|
+
month_key = month_label.delete("-")
|
|
152
|
+
raw_opening = raw_balance_before_month(number, month_key)
|
|
153
|
+
raw_period = raw_period_amount(number, @parsed.period_changes[month_key] || {})
|
|
154
|
+
build_account_summary(number, start_date, end_date, raw_opening, raw_period)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def day_account_summary(number, day)
|
|
158
|
+
require_transactions!
|
|
159
|
+
month_start = Date.new(day.year, day.month, 1)
|
|
160
|
+
raw_opening = raw_balance_before_month(number, day.strftime("%Y%m"))
|
|
161
|
+
raw_opening += raw_voucher_amount(number, month_start, day.prev_day) if day.day > 1
|
|
162
|
+
raw_period = raw_voucher_amount(number, day, day)
|
|
163
|
+
build_account_summary(number, day, day, raw_opening, raw_period)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_account_summary(number, start_date, end_date, raw_opening, raw_period)
|
|
167
|
+
{
|
|
168
|
+
account_number: number,
|
|
169
|
+
account_name: @parsed.accounts[number],
|
|
170
|
+
period: { from: start_date, to: end_date },
|
|
171
|
+
movement: present_ledger_amount(raw_period, number),
|
|
172
|
+
opening_balance: present_ledger_amount(raw_opening, number),
|
|
173
|
+
closing_balance: present_ledger_amount(raw_opening + raw_period, number)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def raw_balance_before_month(account, month_key)
|
|
178
|
+
opening = @parsed.opening_balances[account] || BigDecimal("0")
|
|
179
|
+
prior_months = @parsed.period_changes.keys.select { |key| key < month_key }
|
|
180
|
+
opening + prior_months.sum { |key| raw_period_amount(account, @parsed.period_changes[key] || {}) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def raw_voucher_amount(account, start_day, end_day)
|
|
184
|
+
return BigDecimal("0") if end_day < start_day
|
|
185
|
+
|
|
186
|
+
@parsed.vouchers.sum do |voucher|
|
|
187
|
+
next BigDecimal("0") unless voucher.date.between?(start_day, end_day)
|
|
188
|
+
|
|
189
|
+
voucher.lines.sum { |line| line.account == account ? line.amount : BigDecimal("0") }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def require_dimension!(dimension, available)
|
|
194
|
+
dim = normalize_dimension(dimension)
|
|
195
|
+
return dim unless dim
|
|
196
|
+
return dim if available.include?(dim)
|
|
197
|
+
|
|
198
|
+
raise ArgumentError, "The financial export has no #{dim.to_s.tr('_', ' ')} objects"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize_dimension(dimension)
|
|
202
|
+
return if dimension.nil?
|
|
203
|
+
raise ArgumentError, "Unsupported dimension" unless DIMENSIONS.key?(dimension)
|
|
204
|
+
|
|
205
|
+
dimension
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def normalize_year_month(value)
|
|
209
|
+
text = value.to_s
|
|
210
|
+
raise ArgumentError, "month must be YYYY-MM" unless text.match?(/\A\d{4}-\d{2}\z/)
|
|
211
|
+
|
|
212
|
+
Date.strptime("#{text}-01", "%Y-%m-%d")
|
|
213
|
+
text
|
|
214
|
+
rescue Date::Error
|
|
215
|
+
raise ArgumentError, "month must be YYYY-MM"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def month_keys(start_date, end_date)
|
|
219
|
+
cursor = Date.new(start_date.year, start_date.month, 1)
|
|
220
|
+
last = Date.new(end_date.year, end_date.month, 1)
|
|
221
|
+
keys = []
|
|
222
|
+
while cursor <= last
|
|
223
|
+
keys << cursor.strftime("%Y%m")
|
|
224
|
+
cursor = cursor.next_month
|
|
225
|
+
end
|
|
226
|
+
keys
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def whole_months?(start_date, end_date)
|
|
230
|
+
start_date.day == 1 && end_date == Date.new(end_date.year, end_date.month, -1)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# Balance-sheet builders and closing-balance helpers for Analysis.
|
|
5
|
+
module BalanceSheets
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def balance_sheet(end_date, months, dimension)
|
|
9
|
+
return dimension_balance_placeholder if dimension
|
|
10
|
+
|
|
11
|
+
groups = Groups::BALANCE.map { |group| balance_group_row(group, months) }
|
|
12
|
+
assets = group_amount(groups, "assets")
|
|
13
|
+
equity = group_amount(groups, "equity_and_liabilities")
|
|
14
|
+
{
|
|
15
|
+
as_of: end_date,
|
|
16
|
+
assets:,
|
|
17
|
+
equity_and_liabilities: equity,
|
|
18
|
+
cash_and_bank: round_money(cash_total(months)),
|
|
19
|
+
groups: groups.reject { |group| group.fetch(:amount).zero? && group.fetch(:accounts).empty? }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def group_amount(groups, code)
|
|
24
|
+
groups.find { |row| row[:code] == code }&.fetch(:amount) || BigDecimal("0")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cash_total(months)
|
|
28
|
+
balance_accounts_in(Groups::CASH_RANGE).sum do |number|
|
|
29
|
+
present_balance(closing_balance_for(number, months), :asset)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def balance_group_row(group, months)
|
|
34
|
+
rows = balance_accounts_in(group.fetch(:range)).filter_map do |number|
|
|
35
|
+
presented = present_balance(closing_balance_for(number, months), group.fetch(:kind))
|
|
36
|
+
next if presented.zero?
|
|
37
|
+
|
|
38
|
+
{ account_number: number, account_name: @parsed.accounts[number], amount: round_money(presented) }
|
|
39
|
+
end
|
|
40
|
+
{
|
|
41
|
+
code: group.fetch(:code),
|
|
42
|
+
label: group.fetch(:label),
|
|
43
|
+
amount: round_money(rows.sum { |row| row.fetch(:amount) }),
|
|
44
|
+
accounts: rows.sort_by { |row| row.fetch(:account_number) }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dimension_balance_placeholder
|
|
49
|
+
{ as_of: nil, assets: nil, equity_and_liabilities: nil, cash_and_bank: nil, groups: [] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def soft_balance_note!(balance, income, notes)
|
|
53
|
+
return if balance[:assets].nil? || balance[:equity_and_liabilities].nil?
|
|
54
|
+
|
|
55
|
+
gap = (balance.fetch(:assets) - balance.fetch(:equity_and_liabilities) - income.fetch(:result)).abs
|
|
56
|
+
return if gap < BigDecimal("0.05")
|
|
57
|
+
|
|
58
|
+
notes << "Balance sheet assets and equity/liabilities differ after including period result; common mid-year."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def closing_balance_for(account, months)
|
|
62
|
+
if @parsed.closing_balances.key?(account) && months_cover_year_end?(months)
|
|
63
|
+
return @parsed.closing_balances[account]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opening = @parsed.opening_balances[account] || BigDecimal("0")
|
|
67
|
+
through_end = @parsed.period_changes.keys.select { |month| month <= months.last }
|
|
68
|
+
opening + through_end.sum { |month| raw_period_amount(account, @parsed.period_changes[month]) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def months_cover_year_end?(months)
|
|
72
|
+
rar = @parsed.financial_years[0]
|
|
73
|
+
return false unless rar && rar[:to]
|
|
74
|
+
|
|
75
|
+
year_end = Date.strptime(rar[:to].to_s, "%Y%m%d")
|
|
76
|
+
months.include?(year_end.strftime("%Y%m"))
|
|
77
|
+
rescue Date::Error, TypeError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def balance_accounts_in(range)
|
|
82
|
+
(
|
|
83
|
+
@parsed.accounts.keys + @parsed.opening_balances.keys + @parsed.closing_balances.keys +
|
|
84
|
+
@parsed.period_changes.values.flat_map { |hash| hash.keys.map { |key| dimension_parts(key).first } }
|
|
85
|
+
).uniq.select { |number| range.cover?(number) }.sort
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# Compare only explicitly exported monthly income budgets with matching ledger rows.
|
|
5
|
+
module Budgets
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def budget_overview(months, dimension)
|
|
9
|
+
rows = months.flat_map do |month|
|
|
10
|
+
(@parsed.budgets[month] || {}).filter_map do |key, raw|
|
|
11
|
+
budget_row(month, key, raw, dimension)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
rows.sort_by! { |row| [row[:month], row[:dimension_code].to_s, row[:account_number]] }
|
|
15
|
+
{
|
|
16
|
+
status: rows.empty? ? "unavailable" : "available",
|
|
17
|
+
rows:
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def budget_row(month, key, raw, dimension)
|
|
22
|
+
account, dim, code = dimension_parts(key)
|
|
23
|
+
return unless dim == (dimension && Analysis::DIMENSIONS.fetch(dimension))
|
|
24
|
+
|
|
25
|
+
group = Groups.for_account(account)
|
|
26
|
+
return unless income_group?(group)
|
|
27
|
+
|
|
28
|
+
budget = round_money(present_amount(raw, group.fetch(:kind)))
|
|
29
|
+
actual_raw = (@parsed.period_changes[month] || {}).fetch(key, BigDecimal("0"))
|
|
30
|
+
actual = round_money(present_amount(actual_raw, group.fetch(:kind)))
|
|
31
|
+
{
|
|
32
|
+
month: "#{month[0, 4]}-#{month[4, 2]}",
|
|
33
|
+
account_number: account, account_name: @parsed.accounts[account],
|
|
34
|
+
dimension_code: code, dimension_label: code && (@parsed.objects[[dim, code]] || code),
|
|
35
|
+
budget:, actual:, variance: round_money(actual - budget)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# Cost-center / project income and account-row builders for Analysis.
|
|
5
|
+
module DimensionStatements
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def dimension_income_groups(months, dimension)
|
|
9
|
+
dim_no = Analysis::DIMENSIONS.fetch(dimension)
|
|
10
|
+
totals = accumulate_dimension_totals(months, dim_no)
|
|
11
|
+
groups = dimension_group_rows(totals, dim_no)
|
|
12
|
+
revenue = totals.values.sum { |values| values[:revenue] }
|
|
13
|
+
expenses = totals.values.sum { |values| values[:expenses] }
|
|
14
|
+
{
|
|
15
|
+
revenue: round_money(revenue),
|
|
16
|
+
expenses: round_money(expenses),
|
|
17
|
+
result: round_money(revenue - expenses),
|
|
18
|
+
groups:
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def accumulate_dimension_totals(months, dim_no)
|
|
23
|
+
totals = Hash.new { |hash, key| hash[key] = { revenue: BigDecimal("0"), expenses: BigDecimal("0"), accounts: Hash.new(BigDecimal("0")) } }
|
|
24
|
+
months.each do |month|
|
|
25
|
+
(@parsed.period_changes[month] || {}).each do |key, raw|
|
|
26
|
+
account, dim, code = dimension_parts(key)
|
|
27
|
+
next unless dim == dim_no && code
|
|
28
|
+
|
|
29
|
+
group = Groups.for_account(account)
|
|
30
|
+
next unless income_group?(group)
|
|
31
|
+
|
|
32
|
+
presented = present_amount(raw, group.fetch(:kind))
|
|
33
|
+
bucket = totals[code]
|
|
34
|
+
bucket[group.fetch(:kind) == :revenue ? :revenue : :expenses] += presented
|
|
35
|
+
bucket[:accounts][account] += presented
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
totals
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dimension_group_rows(totals, dim_no)
|
|
42
|
+
rows = totals.map do |code, values|
|
|
43
|
+
accounts = values[:accounts].filter_map do |number, amount|
|
|
44
|
+
next if amount.zero?
|
|
45
|
+
|
|
46
|
+
{ account_number: number, account_name: @parsed.accounts[number], amount: round_money(amount) }
|
|
47
|
+
end
|
|
48
|
+
{
|
|
49
|
+
code:,
|
|
50
|
+
label: @parsed.objects[[dim_no, code]] || code,
|
|
51
|
+
amount: round_money(values[:revenue] - values[:expenses]),
|
|
52
|
+
accounts: accounts.sort_by { |row| row.fetch(:account_number) }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
rows.sort_by { |group| group.fetch(:code) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def dimension_account_rows(month, dimension)
|
|
59
|
+
dim_no = Analysis::DIMENSIONS.fetch(dimension)
|
|
60
|
+
rows = []
|
|
61
|
+
(@parsed.period_changes[month] || {}).each do |key, raw|
|
|
62
|
+
account, dim, code = dimension_parts(key)
|
|
63
|
+
next unless dim == dim_no && code
|
|
64
|
+
|
|
65
|
+
group = Groups.for_account(account)
|
|
66
|
+
next unless group
|
|
67
|
+
|
|
68
|
+
presented = presented_period(raw, group)
|
|
69
|
+
next if presented.zero?
|
|
70
|
+
|
|
71
|
+
rows << {
|
|
72
|
+
account_number: account,
|
|
73
|
+
account_name: @parsed.accounts[account],
|
|
74
|
+
group_code: group.fetch(:code),
|
|
75
|
+
dimension_code: code,
|
|
76
|
+
movement: round_money(presented),
|
|
77
|
+
dimension_label: @parsed.objects[[dim_no, code]] || code
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
rows.sort_by { |row| [row.fetch(:dimension_code), row.fetch(:account_number)] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
module Groups
|
|
5
|
+
INCOME = [
|
|
6
|
+
{ code: "revenue", label: "Revenue", range: 3000..3999, kind: :revenue },
|
|
7
|
+
{ code: "cost_of_goods", label: "Cost of goods sold", range: 4000..4999, kind: :expense },
|
|
8
|
+
{ code: "external_costs", label: "External costs", range: 5000..6999, kind: :expense },
|
|
9
|
+
{ code: "personnel_costs", label: "Personnel costs", range: 7000..7699, kind: :expense },
|
|
10
|
+
{ code: "depreciation", label: "Depreciation", range: 7700..7899, kind: :expense },
|
|
11
|
+
{ code: "other_operating", label: "Other operating items", range: 7900..7999, kind: :expense },
|
|
12
|
+
{ code: "financial_income", label: "Financial income", range: 8000..8399, kind: :revenue },
|
|
13
|
+
{ code: "financial_expenses", label: "Financial expenses", range: 8400..8799, kind: :expense },
|
|
14
|
+
{ code: "appropriations_tax", label: "Appropriations and tax", range: 8800..8999, kind: :expense }
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
BALANCE = [
|
|
18
|
+
{ code: "assets", label: "Assets", range: 1000..1999, kind: :asset },
|
|
19
|
+
{ code: "equity_and_liabilities", label: "Equity and liabilities", range: 2000..2999, kind: :equity_liability }
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
CASH_RANGE = 1910..1999
|
|
23
|
+
|
|
24
|
+
def self.for_account(number)
|
|
25
|
+
(INCOME + BALANCE).find { |group| group.fetch(:range).cover?(number) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# BAS income-statement and undimensioned account-row builders for Analysis.
|
|
5
|
+
module IncomeStatements
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def income_statement(months, dimension)
|
|
9
|
+
dimension ? dimension_income_groups(months, dimension) : bas_income_groups(months)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def bas_income_groups(months)
|
|
13
|
+
groups = Groups::INCOME.map { |group| bas_group_row(months, group) }
|
|
14
|
+
revenue = sum_kind(groups, :revenue)
|
|
15
|
+
expenses = sum_kind(groups, :expense)
|
|
16
|
+
{
|
|
17
|
+
revenue: round_money(revenue),
|
|
18
|
+
expenses: round_money(expenses),
|
|
19
|
+
result: round_money(revenue - expenses),
|
|
20
|
+
groups: groups.reject { |group| group.fetch(:amount).zero? && group.fetch(:accounts).empty? }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bas_group_row(months, group)
|
|
25
|
+
rows = account_amounts_for_range(months, group.fetch(:range), group.fetch(:kind))
|
|
26
|
+
{
|
|
27
|
+
code: group.fetch(:code),
|
|
28
|
+
label: group.fetch(:label),
|
|
29
|
+
amount: round_money(rows.sum { |row| row.fetch(:amount) }),
|
|
30
|
+
accounts: rows.reject { |row| row.fetch(:amount).zero? }
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sum_kind(groups, kind)
|
|
35
|
+
selected = groups.select do |row|
|
|
36
|
+
Groups::INCOME.find { |definition| definition[:code] == row[:code] }[:kind] == kind
|
|
37
|
+
end
|
|
38
|
+
selected.sum { |row| row.fetch(:amount) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def account_rows_for_month(month, dimension)
|
|
42
|
+
return dimension_account_rows(month, dimension) if dimension
|
|
43
|
+
|
|
44
|
+
amounts = Hash.new(BigDecimal("0"))
|
|
45
|
+
(@parsed.period_changes[month] || {}).each do |key, raw|
|
|
46
|
+
account, dim, code = dimension_parts(key)
|
|
47
|
+
next if dim || code
|
|
48
|
+
|
|
49
|
+
group = Groups.for_account(account)
|
|
50
|
+
next unless group
|
|
51
|
+
|
|
52
|
+
amounts[account] += presented_period(raw, group)
|
|
53
|
+
end
|
|
54
|
+
amounts.filter_map { |number, amount| account_row(number, amount) unless amount.zero? }
|
|
55
|
+
.sort_by { |row| row.fetch(:account_number) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def presented_period(raw, group)
|
|
59
|
+
income_group?(group) ? present_amount(raw, group.fetch(:kind)) : present_balance(raw, group.fetch(:kind))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def account_row(number, amount)
|
|
63
|
+
group = Groups.for_account(number)
|
|
64
|
+
{
|
|
65
|
+
account_number: number,
|
|
66
|
+
account_name: @parsed.accounts[number],
|
|
67
|
+
group_code: group&.fetch(:code),
|
|
68
|
+
movement: round_money(amount),
|
|
69
|
+
dimension_code: nil, dimension_label: nil
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def account_amounts_for_range(months, range, kind)
|
|
74
|
+
totals = Hash.new(BigDecimal("0"))
|
|
75
|
+
months.each do |month|
|
|
76
|
+
(@parsed.period_changes[month] || {}).each do |key, raw|
|
|
77
|
+
account, dim, code = dimension_parts(key)
|
|
78
|
+
next if dim || code
|
|
79
|
+
next unless range.cover?(account)
|
|
80
|
+
|
|
81
|
+
totals[account] += present_amount(raw, kind)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
totals.map { |number, amount| { account_number: number, account_name: @parsed.accounts[number], amount: round_money(amount) } }
|
|
85
|
+
.sort_by { |row| row.fetch(:account_number) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def monthly_totals(months, dimension)
|
|
89
|
+
return if months.length <= 1
|
|
90
|
+
|
|
91
|
+
months.map do |month|
|
|
92
|
+
income = income_statement([month], dimension)
|
|
93
|
+
{
|
|
94
|
+
month: "#{month[0, 4]}-#{month[4, 2]}",
|
|
95
|
+
revenue: income.fetch(:revenue),
|
|
96
|
+
expenses: income.fetch(:expenses),
|
|
97
|
+
result: income.fetch(:result)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def highlights(income, balance)
|
|
103
|
+
revenue = income.fetch(:revenue)
|
|
104
|
+
result = income.fetch(:result)
|
|
105
|
+
margin = revenue.zero? ? nil : round_money((result / revenue) * 100)
|
|
106
|
+
{ result:, profit_margin_percent: margin, cash_and_bank: balance[:cash_and_bank] }.compact
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|