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,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SieAnalysis
|
|
6
|
+
# Parses financial export types 2–4 into balances and (for type 4) voucher lines.
|
|
7
|
+
class Parser
|
|
8
|
+
Record = Data.define(:tag, :fields)
|
|
9
|
+
Voucher = Data.define(:series, :number, :date, :description, :lines)
|
|
10
|
+
Line = Data.define(:account, :amount)
|
|
11
|
+
|
|
12
|
+
# Parsed ledger: monetary values are BigDecimal; voucher dates are Date.
|
|
13
|
+
# Period keys are YYYYMM strings; object keys are [dimension number, code].
|
|
14
|
+
Parsed = Data.define(
|
|
15
|
+
:accounts, :objects, :opening_balances, :closing_balances, :results, :period_changes,
|
|
16
|
+
:financial_years, :vouchers, :budgets, :sie_type
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# @param text [String] export text already decoded to UTF-8
|
|
20
|
+
# @return [Parsed] recognized balances, objects, budgets and vouchers
|
|
21
|
+
# @raise [ParseError] invalid export type, financial year or recognized record
|
|
22
|
+
def self.parse(text)
|
|
23
|
+
new(text).parse
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param text [String] decoded SIE 2, 3 or 4 text
|
|
27
|
+
def initialize(text)
|
|
28
|
+
@text = text.to_s
|
|
29
|
+
@accounts = {}
|
|
30
|
+
@objects = {}
|
|
31
|
+
@opening = Hash.new(BigDecimal("0"))
|
|
32
|
+
@closing = Hash.new(BigDecimal("0"))
|
|
33
|
+
@results = Hash.new(BigDecimal("0"))
|
|
34
|
+
@periods = Hash.new { |hash, key| hash[key] = Hash.new(BigDecimal("0")) }
|
|
35
|
+
@budgets = Hash.new { |hash, key| hash[key] = Hash.new(BigDecimal("0")) }
|
|
36
|
+
@years = {}
|
|
37
|
+
@vouchers = []
|
|
38
|
+
@current_voucher = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Consumes this parser once. Use .parse for each separate export.
|
|
42
|
+
# @return [Parsed] parsed ledger
|
|
43
|
+
# @raise [ParseError] unreadable or invalid recognized records
|
|
44
|
+
def parse
|
|
45
|
+
each_record { |record| apply_record(record) }
|
|
46
|
+
finish_voucher!
|
|
47
|
+
raise ArgumentError unless [2, 3, 4].include?(@sie_type) && @years.key?(0)
|
|
48
|
+
@periods.default_proc = nil
|
|
49
|
+
@budgets.default_proc = nil
|
|
50
|
+
Parsed.new(
|
|
51
|
+
accounts: @accounts, objects: @objects, opening_balances: @opening, closing_balances: @closing,
|
|
52
|
+
results: @results, period_changes: @periods, financial_years: @years, vouchers: @vouchers,
|
|
53
|
+
budgets: @budgets, sie_type: @sie_type
|
|
54
|
+
)
|
|
55
|
+
rescue ParseError
|
|
56
|
+
raise
|
|
57
|
+
rescue StandardError
|
|
58
|
+
raise ParseError, "Unreadable financial export"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def each_record
|
|
64
|
+
@text.each_line do |line|
|
|
65
|
+
stripped = line.strip
|
|
66
|
+
next if stripped.empty? || !stripped.start_with?("#")
|
|
67
|
+
|
|
68
|
+
tokens = SieTokenizer.tokenize(stripped[1..])
|
|
69
|
+
next if tokens.empty?
|
|
70
|
+
|
|
71
|
+
yield Record.new(tag: tokens[0], fields: tokens[1..])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_record(record)
|
|
76
|
+
case record.tag
|
|
77
|
+
when "SIETYP"
|
|
78
|
+
raise ArgumentError if @sie_type
|
|
79
|
+
@sie_type = Integer(record.fields.fetch(0))
|
|
80
|
+
when "KONTO" then apply_account(record.fields)
|
|
81
|
+
when "OBJEKT" then apply_object(record.fields)
|
|
82
|
+
when "RAR" then apply_year(record.fields)
|
|
83
|
+
when "IB" then apply_balance!(@opening, record.fields)
|
|
84
|
+
when "UB" then apply_balance!(@closing, record.fields)
|
|
85
|
+
when "RES" then apply_balance!(@results, record.fields)
|
|
86
|
+
when "PSALDO" then apply_period!(record.fields)
|
|
87
|
+
when "PBUDGET" then apply_budget!(record.fields)
|
|
88
|
+
when "VER" then start_voucher!(record.fields)
|
|
89
|
+
when "TRANS" then apply_trans!(record.fields)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply_account(fields)
|
|
94
|
+
number = integer_field(fields[0])
|
|
95
|
+
@accounts[number] = unquote(fields[1]).to_s if number
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def apply_object(fields)
|
|
99
|
+
dim = integer_field(fields[0])
|
|
100
|
+
code = unquote(fields[1]).to_s
|
|
101
|
+
name = unquote(fields[2]).to_s
|
|
102
|
+
@objects[[dim, code]] = name if dim && !code.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def apply_year(fields)
|
|
106
|
+
year_no = integer_field(fields[0])
|
|
107
|
+
first, last = sie_date(fields[1]), sie_date(fields[2])
|
|
108
|
+
raise ArgumentError unless year_no && first && last && first <= last
|
|
109
|
+
raise ArgumentError if @years.key?(year_no)
|
|
110
|
+
@years[year_no] = { from: unquote(fields[1]), to: unquote(fields[2]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def apply_balance!(target, fields)
|
|
114
|
+
year = integer_field(fields[0])
|
|
115
|
+
raise ArgumentError unless year
|
|
116
|
+
return unless year.zero?
|
|
117
|
+
|
|
118
|
+
account = integer_field(fields[1])
|
|
119
|
+
amount = decimal_field(fields[2])
|
|
120
|
+
raise ArgumentError unless account&.positive? && amount&.finite?
|
|
121
|
+
|
|
122
|
+
target[account] += amount
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def apply_period!(fields, target = @periods)
|
|
126
|
+
year = integer_field(fields[0])
|
|
127
|
+
raise ArgumentError unless year
|
|
128
|
+
return unless year.zero?
|
|
129
|
+
|
|
130
|
+
period = fields[1].to_s
|
|
131
|
+
account = integer_field(fields[2])
|
|
132
|
+
object_field = fields[3].to_s
|
|
133
|
+
amount = object_field.start_with?("{") ? decimal_field(fields[4]) : decimal_field(fields[3])
|
|
134
|
+
raise ArgumentError unless period.match?(/\A\d{4}(0[1-9]|1[0-2])\z/) && account&.positive? && amount&.finite?
|
|
135
|
+
raise ArgumentError if object_field.start_with?("{") && !valid_budget_objects?(object_field)
|
|
136
|
+
|
|
137
|
+
refs = object_refs_from(object_field)
|
|
138
|
+
if refs.empty?
|
|
139
|
+
target[period][account] += amount
|
|
140
|
+
else
|
|
141
|
+
refs.each { |dim, code| target[period][[account, dim, code]] += amount }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def apply_budget!(fields)
|
|
146
|
+
year = integer_field(fields[0])
|
|
147
|
+
raise ArgumentError unless year
|
|
148
|
+
return unless year.zero?
|
|
149
|
+
|
|
150
|
+
period, account, objects, amount = fields.values_at(1, 2, 3, 4)
|
|
151
|
+
raise ArgumentError unless period.to_s.match?(/\A\d{4}(0[1-9]|1[0-2])\z/)
|
|
152
|
+
raise ArgumentError unless integer_field(account)&.positive? && decimal_field(amount)&.finite?
|
|
153
|
+
raise ArgumentError unless valid_budget_objects?(objects)
|
|
154
|
+
|
|
155
|
+
apply_period!(fields, @budgets)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def valid_budget_objects?(objects)
|
|
159
|
+
return false unless objects.to_s.start_with?("{") && objects.end_with?("}")
|
|
160
|
+
|
|
161
|
+
parts = SieTokenizer.tokenize(objects[1...-1])
|
|
162
|
+
parts.length.even? && parts.each_slice(2).all? do |dim, code|
|
|
163
|
+
integer_field(dim)&.positive? && !unquote(code).empty?
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def start_voucher!(fields)
|
|
168
|
+
finish_voucher!
|
|
169
|
+
date = sie_date(fields[2])
|
|
170
|
+
number = integer_field(fields[1])
|
|
171
|
+
raise ArgumentError unless date && number
|
|
172
|
+
|
|
173
|
+
@current_voucher = {
|
|
174
|
+
series: unquote(fields[0]).to_s,
|
|
175
|
+
number:,
|
|
176
|
+
date:,
|
|
177
|
+
description: unquote(fields[3]).to_s,
|
|
178
|
+
lines: []
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def apply_trans!(fields)
|
|
183
|
+
raise ArgumentError unless @current_voucher
|
|
184
|
+
|
|
185
|
+
account = integer_field(fields[0])
|
|
186
|
+
object_field = fields[1].to_s
|
|
187
|
+
amount = object_field.start_with?("{") ? decimal_field(fields[2]) : decimal_field(fields[1])
|
|
188
|
+
raise ArgumentError unless account&.positive? && amount&.finite?
|
|
189
|
+
|
|
190
|
+
raise ArgumentError if object_field.start_with?("{") && !valid_budget_objects?(object_field)
|
|
191
|
+
|
|
192
|
+
@current_voucher[:lines] << Line.new(account:, amount:)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def finish_voucher!
|
|
196
|
+
voucher = @current_voucher
|
|
197
|
+
@current_voucher = nil
|
|
198
|
+
return unless voucher && voucher[:lines].any?
|
|
199
|
+
|
|
200
|
+
@vouchers << Voucher.new(
|
|
201
|
+
series: voucher[:series], number: voucher[:number], date: voucher[:date],
|
|
202
|
+
description: voucher[:description], lines: voucher[:lines]
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def object_refs_from(field)
|
|
207
|
+
text = field.to_s
|
|
208
|
+
return [] unless text.start_with?("{") && text.end_with?("}")
|
|
209
|
+
|
|
210
|
+
inner = text[1...-1].strip
|
|
211
|
+
return [] if inner.empty?
|
|
212
|
+
|
|
213
|
+
parts = SieTokenizer.tokenize(inner)
|
|
214
|
+
parts.each_slice(2).filter_map do |dim_token, code_token|
|
|
215
|
+
dim = integer_field(dim_token)
|
|
216
|
+
code = unquote(code_token).to_s
|
|
217
|
+
[dim, code] if dim && !code.empty?
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sie_date(value)
|
|
222
|
+
text = unquote(value).to_s
|
|
223
|
+
return unless text.match?(/\A\d{8}\z/)
|
|
224
|
+
|
|
225
|
+
Date.strptime(text, "%Y%m%d")
|
|
226
|
+
rescue Date::Error
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def integer_field(value)
|
|
231
|
+
Integer(unquote(value))
|
|
232
|
+
rescue TypeError, ArgumentError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def decimal_field(value)
|
|
237
|
+
text = unquote(value).tr(",", ".")
|
|
238
|
+
return unless text.match?(/\A[+-]?\d+(?:\.\d+)?\z/)
|
|
239
|
+
BigDecimal(text)
|
|
240
|
+
rescue TypeError, ArgumentError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def unquote(value)
|
|
245
|
+
text = value.to_s
|
|
246
|
+
return text[1...-1] if text.start_with?('"') && text.end_with?('"') && text.length >= 2
|
|
247
|
+
|
|
248
|
+
text
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SieAnalysis
|
|
4
|
+
# Tokenizes one SIE record line, keeping quoted strings and {object lists} intact.
|
|
5
|
+
class SieTokenizer
|
|
6
|
+
def self.tokenize(line)
|
|
7
|
+
new(line).tokens
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(line)
|
|
11
|
+
@line = line
|
|
12
|
+
@tokens = []
|
|
13
|
+
@buffer = +""
|
|
14
|
+
@in_quotes = false
|
|
15
|
+
@brace_depth = 0
|
|
16
|
+
@index = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tokens
|
|
20
|
+
consume while @index < @line.length
|
|
21
|
+
flush_buffer
|
|
22
|
+
@tokens
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def consume
|
|
28
|
+
return append_escaped if escaped?
|
|
29
|
+
return toggle_quote if current == '"'
|
|
30
|
+
return handle_structure if !@in_quotes && structural?
|
|
31
|
+
|
|
32
|
+
@buffer << current
|
|
33
|
+
@index += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def current
|
|
37
|
+
@line[@index]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def escaped?
|
|
41
|
+
@in_quotes && current == "\\" && @index + 1 < @line.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def structural?
|
|
45
|
+
current == "{" || (current == "}" && @brace_depth.positive?) ||
|
|
46
|
+
(@brace_depth.zero? && current.match?(/\s/))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle_structure
|
|
50
|
+
return open_brace if current == "{"
|
|
51
|
+
return close_brace if current == "}"
|
|
52
|
+
|
|
53
|
+
split_token
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def append_escaped
|
|
57
|
+
@buffer << @line[@index + 1]
|
|
58
|
+
@index += 2
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def toggle_quote
|
|
62
|
+
if @in_quotes
|
|
63
|
+
close_quote
|
|
64
|
+
else
|
|
65
|
+
@in_quotes = true
|
|
66
|
+
@buffer << '"' if @brace_depth.positive?
|
|
67
|
+
end
|
|
68
|
+
@index += 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def close_quote
|
|
72
|
+
@in_quotes = false
|
|
73
|
+
if @brace_depth.positive?
|
|
74
|
+
@buffer << '"'
|
|
75
|
+
elsif @buffer.empty?
|
|
76
|
+
@tokens << ""
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def open_brace
|
|
81
|
+
@brace_depth += 1
|
|
82
|
+
@buffer << "{"
|
|
83
|
+
@index += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def close_brace
|
|
87
|
+
@brace_depth -= 1
|
|
88
|
+
@buffer << "}"
|
|
89
|
+
flush_buffer if @brace_depth.zero?
|
|
90
|
+
@index += 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def split_token
|
|
94
|
+
flush_buffer
|
|
95
|
+
@index += 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def flush_buffer
|
|
99
|
+
return if @buffer.empty?
|
|
100
|
+
|
|
101
|
+
@tokens << @buffer
|
|
102
|
+
@buffer = +""
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/sie_analysis.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require_relative "sie_analysis/version"
|
|
5
|
+
|
|
6
|
+
# Analysis of Swedish SIE financial exports.
|
|
7
|
+
module SieAnalysis
|
|
8
|
+
# A malformed or unsupported SIE export.
|
|
9
|
+
class ParseError < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "sie_analysis/groups"
|
|
13
|
+
require_relative "sie_analysis/sie_tokenizer"
|
|
14
|
+
require_relative "sie_analysis/parser"
|
|
15
|
+
require_relative "sie_analysis/amounts"
|
|
16
|
+
require_relative "sie_analysis/budgets"
|
|
17
|
+
require_relative "sie_analysis/income_statements"
|
|
18
|
+
require_relative "sie_analysis/dimension_statements"
|
|
19
|
+
require_relative "sie_analysis/balance_sheets"
|
|
20
|
+
require_relative "sie_analysis/analysis"
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sie-analysis
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Standout AB
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bigdecimal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '5'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '5'
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- README.md
|
|
37
|
+
- assets/header-vector.svg
|
|
38
|
+
- lib/sie_analysis.rb
|
|
39
|
+
- lib/sie_analysis/amounts.rb
|
|
40
|
+
- lib/sie_analysis/analysis.rb
|
|
41
|
+
- lib/sie_analysis/balance_sheets.rb
|
|
42
|
+
- lib/sie_analysis/budgets.rb
|
|
43
|
+
- lib/sie_analysis/dimension_statements.rb
|
|
44
|
+
- lib/sie_analysis/groups.rb
|
|
45
|
+
- lib/sie_analysis/income_statements.rb
|
|
46
|
+
- lib/sie_analysis/parser.rb
|
|
47
|
+
- lib/sie_analysis/sie_tokenizer.rb
|
|
48
|
+
- lib/sie_analysis/version.rb
|
|
49
|
+
homepage: https://github.com/standout/sie-analysis
|
|
50
|
+
licenses: []
|
|
51
|
+
metadata:
|
|
52
|
+
source_code_uri: https://github.com/standout/sie-analysis
|
|
53
|
+
allowed_push_host: https://rubygems.org
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.2'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.20
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Income statements, account movements and budget analysis for SIE exports
|
|
71
|
+
test_files: []
|