lucabook 0.2.21 → 0.2.26

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,9 +4,14 @@ require 'date'
4
4
  require 'json'
5
5
  require 'luca_book'
6
6
  require 'luca_support'
7
- #require 'luca_book/dict'
8
7
  require 'luca_record'
9
8
 
9
+ begin
10
+ require "luca_book/import_#{LucaSupport::CONFIG['country']}"
11
+ rescue LoadError => e
12
+ e.message
13
+ end
14
+
10
15
  module LucaBook
11
16
  class Import
12
17
  DEBIT_DEFAULT = '10XX'
@@ -18,7 +23,7 @@ module LucaBook
18
23
  @target_file = path
19
24
  # TODO: yaml need to be configurable
20
25
  @dict_name = dict
21
- @dict = LucaRecord::Dict.new("import-#{dict}.yaml")
26
+ @dict = LucaBook::Dict.new("import-#{dict}.yaml")
22
27
  @code_map = LucaRecord::Dict.reverse(LucaRecord::Dict.load('base.tsv'))
23
28
  @config = @dict.csv_config if dict
24
29
  end
@@ -30,13 +35,13 @@ module LucaBook
30
35
  # "debit" : [
31
36
  # {
32
37
  # "label": "savings accounts",
33
- # "value": 20000
38
+ # "amount": 20000
34
39
  # }
35
40
  # ],
36
41
  # "credit" : [
37
42
  # {
38
43
  # "label": "trade notes receivable",
39
- # "value": 20000
44
+ # "amount": 20000
40
45
  # }
41
46
  # ],
42
47
  # "note": "settlement for the last month trade"
@@ -45,8 +50,6 @@ module LucaBook
45
50
  #
46
51
  def self.import_json(io)
47
52
  JSON.parse(io).each do |d|
48
- validate(d)
49
-
50
53
  code_map = LucaRecord::Dict.reverse(LucaRecord::Dict.load('base.tsv'))
51
54
  d['debit'].each { |h| h['code'] = code_map.dig(h['label']) || DEBIT_DEFAULT }
52
55
  d['credit'].each { |h| h['code'] = code_map.dig(h['label']) || CREDIT_DEFAULT }
@@ -67,46 +70,41 @@ module LucaBook
67
70
  end
68
71
  end
69
72
 
70
- def self.validate(obj)
71
- raise 'NoDateKey' unless obj.key?('date')
72
- raise 'NoDebitKey' unless obj.key?('debit')
73
- raise 'NoDebitValue' if obj['debit'].empty?
74
- raise 'NoCreditKey' unless obj.key?('credit')
75
- raise 'NoCreditValue' if obj['credit'].empty?
76
- end
77
-
78
73
  private
79
74
 
80
- #
81
75
  # convert single entry data
82
76
  #
83
77
  def parse_single(row)
84
- value = row.dig(@config[:credit_value])&.empty? ? row[@config[:debit_value]] : row[@config[:credit_value]]
78
+ if (row.dig(@config[:credit_amount]) || []).empty?
79
+ amount = BigDecimal(row[@config[:debit_amount]])
80
+ debit = true
81
+ else
82
+ amount = BigDecimal(row[@config[:credit_amount]])
83
+ end
84
+ default_label = debit ? @config.dig(:default_debit) : @config.dig(:default_credit)
85
+ code, options = search_code(row[@config[:label]], default_label, amount)
86
+ counter_code = @code_map.dig(@config[:counter_label])
87
+ if options
88
+ x_customer = options[:'x-customer'] if options[:'x-customer']
89
+ data, data_c = tax_extension(code, counter_code, amount, options) if respond_to? :tax_extension
90
+ end
91
+ data ||= [{ 'code' => code, 'amount' => amount }]
92
+ data_c ||= [{ 'code' => counter_code, 'amount' => amount }]
85
93
  {}.tap do |d|
86
94
  d['date'] = parse_date(row)
87
- if row.dig(@config[:credit_value])&.empty?
88
- d['debit'] = [
89
- { 'code' => search_code(row[@config[:label]], @config.dig(:default_debit)) || DEBIT_DEFAULT }
90
- ]
91
- d['credit'] = [
92
- { 'code' => @code_map.dig(@config[:counter_label]) }
93
- ]
95
+ if debit
96
+ d['debit'] = data
97
+ d['credit'] = data_c
94
98
  else
95
- d['debit'] = [
96
- { 'code' => @code_map.dig(@config[:counter_label]) }
97
- ]
98
- d['credit'] = [
99
- { 'code' => search_code(row[@config[:label]], @config.dig(:default_credit)) || CREDIT_DEFAULT }
100
- ]
99
+ d['debit'] = data_c
100
+ d['credit'] = data
101
101
  end
102
- d['debit'][0]['value'] = value
103
- d['credit'][0]['value'] = value
104
102
  d['note'] = Array(@config[:note]).map{ |col| row[col] }.join(' ')
105
- d['x-editor'] = "LucaBook::Import/#{@dict_name}"
103
+ d['headers'] = { 'x-editor' => "LucaBook::Import/#{@dict_name}" }
104
+ d['headers']['x-customer'] = x_customer if x_customer
106
105
  end
107
106
  end
108
107
 
109
- #
110
108
  # convert double entry data
111
109
  #
112
110
  def parse_double(row)
@@ -114,19 +112,20 @@ module LucaBook
114
112
  d['date'] = parse_date(row)
115
113
  d['debit'] = {
116
114
  'code' => search_code(row[@config[:label]], @config.dig(:default_debit)) || DEBIT_DEFAULT,
117
- 'value' => row.dig(@config[:debit_value])
115
+ 'amount' => row.dig(@config[:debit_amount])
118
116
  }
119
117
  d['credit'] = {
120
118
  'code' => search_code(row[@config[:label]], @config.dig(:default_credit)) || CREDIT_DEFAULT,
121
- 'value' => row.dig(@config[:credit_value])
119
+ 'amount' => row.dig(@config[:credit_amount])
122
120
  }
123
121
  d['note'] = Array(@config[:note]).map{ |col| row[col] }.join(' ')
124
122
  d['x-editor'] = "LucaBook::Import/#{@dict_name}"
125
123
  end
126
124
  end
127
125
 
128
- def search_code(label, default_label)
129
- @code_map.dig(@dict.search(label, default_label))
126
+ def search_code(label, default_label, amount = nil)
127
+ label, options = @dict.search(label, default_label, amount)
128
+ [@code_map.dig(label), options]
130
129
  end
131
130
 
132
131
  def parse_date(row)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'luca_book'
5
+ require 'luca_support'
6
+
7
+ module LucaBook
8
+ class Import
9
+ # TODO: need to be separated into pluggable l10n module.
10
+ # TODO: gensen rate >1m yen.
11
+ # TODO: gensen & consumption `round()` rules need to be confirmed.
12
+ # Profit or Loss account should be specified as code1.
13
+ #
14
+ def tax_extension(code1, code2, amount, options)
15
+ return nil if options.nil? || options[:tax_options].nil?
16
+ return nil if !options[:tax_options].include?('jp-gensen') && !options[:tax_options].include?('jp-consumption')
17
+
18
+ gensen_rate = BigDecimal('0.1021')
19
+ consumption_rate = BigDecimal('0.1')
20
+ gensen_code = @code_map.dig(options[:gensen_label]) || @code_map.dig('預り金')
21
+ gensen_idx = /^[5-8B-G]/.match(code1) ? 1 : 0
22
+ consumption_idx = /^[A-G]/.match(code1) ? 0 : 1
23
+ consumption_code = @code_map.dig(options[:consumption_label])
24
+ consumption_code ||= /^[A]/.match(code1) ? @code_map.dig('仮受消費税等') : @code_map.dig('仮払消費税等')
25
+ if options[:tax_options].include?('jp-gensen') && options[:tax_options].include?('jp-consumption')
26
+ paid_rate = BigDecimal('1') + consumption_rate - gensen_rate
27
+ gensen_amount = (amount / paid_rate * gensen_rate).round
28
+ consumption_amount = (amount / paid_rate * consumption_rate).round
29
+ [].tap do |res|
30
+ res << [].tap do |res1|
31
+ amount1 = amount
32
+ amount1 -= consumption_amount if consumption_idx == 0
33
+ amount1 += gensen_amount if gensen_idx == 1
34
+ res1 << { 'code' => code1, 'amount' => amount1 }
35
+ res1 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 0
36
+ res1 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 0
37
+ end
38
+ res << [].tap do |res2|
39
+ amount2 = amount
40
+ amount2 -= consumption_amount if consumption_idx == 1
41
+ amount2 += gensen_amount if gensen_idx == 0
42
+ res2 << { 'code' => code2, 'amount' => amount2 }
43
+ res2 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 1
44
+ res2 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 1
45
+ end
46
+ end
47
+ elsif options[:tax_options].include?('jp-gensen')
48
+ paid_rate = BigDecimal('1') - gensen_rate
49
+ gensen_amount = (amount / paid_rate * gensen_rate).round
50
+ [].tap do |res|
51
+ res << [].tap do |res1|
52
+ amount1 = amount
53
+ amount1 += gensen_amount if gensen_idx == 1
54
+ res1 << { 'code' => code, 'amount' => amount1 }
55
+ res1 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 0
56
+ end
57
+ res << [].tap do |res2|
58
+ amount2 = amount
59
+ amount2 += gensen_amount if gensen_idx == 0
60
+ mount2 ||= amount
61
+ res2 << { 'code' => code2, 'amount' => amount2 }
62
+ res2 << { 'code' => gensen_code, 'amount' => gensen_amount } if gensen_idx == 1
63
+ end
64
+ end
65
+ elsif options[:tax_options].include?('jp-consumption')
66
+ paid_rate = BigDecimal('1') + consumption_rate - gensen_rate
67
+ consumption_amount = (amount / paid_rate * consumption_rate).round
68
+ res << [].tap do |res1|
69
+ amount1 = amount
70
+ amount1 -= consumption_amount if consumption_idx == 0
71
+ res1 << { 'code' => code1, 'amount' => amount1 }
72
+ res1 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 0
73
+ end
74
+ res << [].tap do |res2|
75
+ amount2 = amount
76
+ amount2 -= consumption_amount if consumption_idx == 1
77
+ res2 << { 'code' => code2, 'amount' => amount2 }
78
+ res2 << { 'code' => consumption_code, 'amount' => consumption_amount } if consumption_idx == 1
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,43 +1,90 @@
1
- #
2
- # manipulate files based on transaction date
3
- #
1
+ # frozen_string_literal: true
4
2
 
5
3
  require 'csv'
6
4
  require 'date'
7
5
  require 'luca_record'
8
6
 
9
- module LucaBook
7
+ module LucaBook #:nodoc:
8
+ # Journal has several annotations on headers:
9
+ #
10
+ # x-customer::
11
+ # Identifying customer.
12
+ # x-editor::
13
+ # Application name editing the journal.
14
+ # x-tax::
15
+ # For tracking tax related transaction.
16
+ #
10
17
  class Journal < LucaRecord::Base
18
+ ACCEPTED_HEADERS = ['x-customer', 'x-editor', 'x-tax']
11
19
  @dirname = 'journals'
12
20
 
13
21
  # create journal from hash
14
22
  #
15
- def self.create(d)
23
+ def self.create(dat)
24
+ d = LucaSupport::Code.keys_stringify(dat)
25
+ validate(d)
26
+ raise 'NoDateKey' unless d.key?('date')
27
+
16
28
  date = Date.parse(d['date'])
17
29
 
18
- debit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['debit'], 'value'))
19
- credit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['credit'], 'value'))
30
+ # TODO: need to sync filename & content. Limit code length for filename
31
+ # codes = (debit_code + credit_code).uniq
32
+ codes = nil
33
+
34
+ create_record(nil, date, codes) { |f| f.write journal2csv(d) }
35
+ end
36
+
37
+ # update journal with hash.
38
+ # If record not found with id, no record will be created.
39
+ #
40
+ def self.save(dat)
41
+ d = LucaSupport::Code.keys_stringify(dat)
42
+ raise 'record has no id.' if d['id'].nil?
43
+
44
+ validate(d)
45
+ parts = d['id'].split('/')
46
+ raise 'invalid ID' if parts.length != 2
47
+
48
+ codes = nil
49
+ open_records(@dirname, parts[0], parts[1], codes, 'w') { |f, _path| f.write journal2csv(d) }
50
+ end
51
+
52
+ # Convert journal object to TSV format.
53
+ #
54
+ def self.journal2csv(d)
55
+ debit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['debit'], 'amount'))
56
+ credit_amount = LucaSupport::Code.decimalize(serialize_on_key(d['credit'], 'amount'))
20
57
  raise 'BalanceUnmatch' if debit_amount.inject(:+) != credit_amount.inject(:+)
21
58
 
22
59
  debit_code = serialize_on_key(d['debit'], 'code')
23
60
  credit_code = serialize_on_key(d['credit'], 'code')
24
61
 
25
- # TODO: need to sync filename & content. Limit code length for filename
26
- # codes = (debit_code + credit_code).uniq
27
- codes = nil
28
- create_record!(date, codes) do |f|
62
+ csv = CSV.generate(String.new, col_sep: "\t", headers: false) do |f|
29
63
  f << debit_code
30
64
  f << LucaSupport::Code.readable(debit_amount)
31
65
  f << credit_code
32
66
  f << LucaSupport::Code.readable(credit_amount)
33
- ['x-customer', 'x-editor'].each do |x_header|
34
- f << [x_header, d[x_header]] if d.dig(x_header)
67
+ ACCEPTED_HEADERS.each do |x_header|
68
+ f << [x_header, d['headers'][x_header]] if d.dig('headers', x_header)
35
69
  end
36
70
  f << []
37
71
  f << [d.dig('note')]
38
72
  end
39
73
  end
40
74
 
75
+ # Set accepted header with key/value, update record if exists.
76
+ #
77
+ def self.add_header(journal_hash, key, val)
78
+ return journal_hash if val.nil?
79
+ return journal_hash unless ACCEPTED_HEADERS.include?(key)
80
+
81
+ journal_hash.tap do |o|
82
+ o[:headers] = {} unless o.dig(:headers)
83
+ o[:headers][key] = val
84
+ save o if o[:id]
85
+ end
86
+ end
87
+
41
88
  def self.update_codes(obj)
42
89
  debit_code = serialize_on_key(obj[:debit], :code)
43
90
  credit_code = serialize_on_key(obj[:credit], :code)
@@ -45,11 +92,19 @@ module LucaBook
45
92
  change_codes(obj[:id], codes)
46
93
  end
47
94
 
48
- # define new transaction ID & write data at once
49
- def self.create_record!(date_obj, codes = nil)
50
- create_record(nil, date_obj, codes) do |f|
51
- f.write CSV.generate('', col_sep: "\t", headers: false) { |c| yield(c) }
52
- end
95
+ def self.validate(obj)
96
+ raise 'NoDebitKey' unless obj.key?('debit')
97
+ raise 'NoCreditKey' unless obj.key?('credit')
98
+ debit_codes = serialize_on_key(obj['debit'], 'code').compact
99
+ debit_amount = serialize_on_key(obj['debit'], 'amount').compact
100
+ raise 'NoDebitCode' if debit_codes.empty?
101
+ raise 'NoDebitAmount' if debit_amount.empty?
102
+ raise 'UnmatchDebit' if debit_codes.length != debit_amount.length
103
+ credit_codes = serialize_on_key(obj['credit'], 'code').compact
104
+ credit_amount = serialize_on_key(obj['credit'], 'amount').compact
105
+ raise 'NoCreditCode' if credit_codes.empty?
106
+ raise 'NoCreditAmount' if credit_amount.empty?
107
+ raise 'UnmatchCredit' if credit_codes.length != credit_amount.length
53
108
  end
54
109
 
55
110
  # collect values on specified key
@@ -58,7 +113,21 @@ module LucaBook
58
113
  array_of_hash.map { |h| h[key] }
59
114
  end
60
115
 
61
- # override de-serializing journal format
116
+ # override de-serializing journal format. Sample format is:
117
+ #
118
+ # {
119
+ # id: '2021A/V001',
120
+ # headers: {
121
+ # 'x-customer' => 'Some Customer Co.'
122
+ # },
123
+ # debit: [
124
+ # { code: 'A12', amount: 1000 }
125
+ # ],
126
+ # credit: [
127
+ # { code: '311', amount: 1000 }
128
+ # ],
129
+ # note: 'note for each journal'
130
+ # }
62
131
  #
63
132
  def self.load_data(io, path)
64
133
  {}.tap do |record|
@@ -76,10 +145,16 @@ module LucaBook
76
145
  when 3
77
146
  line.each_with_index { |amount, j| record[:credit][j][:amount] = BigDecimal(amount.to_s) }
78
147
  else
79
- if body == false && line.empty?
80
- record[:note] ||= []
81
- body = true
82
- else
148
+ case body
149
+ when false
150
+ if line.empty?
151
+ record[:note] ||= []
152
+ body = true
153
+ else
154
+ record[:headers] ||= {}
155
+ record[:headers][line[0]] = line[1]
156
+ end
157
+ when true
83
158
  record[:note] << line.join(' ') if body
84
159
  end
85
160
  end
@@ -7,20 +7,22 @@ require 'luca_record'
7
7
  require 'luca_record/dict'
8
8
  require 'luca_book'
9
9
 
10
- # Journal List on specified term
11
- #
12
- module LucaBook
10
+ module LucaBook #:nodoc:
11
+ # Journal List on specified term
12
+ #
13
13
  class List < LucaBook::Journal
14
14
  @dirname = 'journals'
15
+ @@dict = LucaRecord::Dict.new('base.tsv')
16
+ attr_reader :data
15
17
 
16
18
  def initialize(data, start_date, code = nil)
17
19
  @data = data
18
20
  @code = code
19
21
  @start = start_date
20
- @dict = LucaRecord::Dict.load('base.tsv')
21
22
  end
22
23
 
23
24
  def self.term(from_year, from_month, to_year = from_year, to_month = from_month, code: nil, basedir: @dirname)
25
+ code = search_code(code) if code
24
26
  data = LucaBook::Journal.term(from_year, from_month, to_year, to_month, code).select do |dat|
25
27
  if code.nil?
26
28
  true
@@ -31,7 +33,17 @@ module LucaBook
31
33
  new data, Date.new(from_year.to_i, from_month.to_i, 1), code
32
34
  end
33
35
 
34
- def list_on_code
36
+ def self.add_header(from_year, from_month, to_year = from_year, to_month = from_month, code: nil, header_key: nil, header_val: nil)
37
+ return nil if code.nil?
38
+ return nil unless Journal::ACCEPTED_HEADERS.include?(header_key)
39
+
40
+ term(from_year, from_month, to_year, to_month, code: code)
41
+ .data.each do |journal|
42
+ Journal.add_header(journal, header_key, header_val)
43
+ end
44
+ end
45
+
46
+ def list_by_code
35
47
  calc_code
36
48
  convert_label
37
49
  @data = [code_header] + @data.map do |dat|
@@ -59,7 +71,7 @@ module LucaBook
59
71
  res['no'] = txid
60
72
  res['id'] = dat[:id]
61
73
  res['debit_code'] = dat[:debit].length == 1 ? dat[:debit][0][:code] : dat[:debit].map { |d| d[:code] }
62
- res['debit_amount'] = dat[:debit].inject(0) { |sum, d| sum + d[:amount] }
74
+ res['debit_amount'] = dat[:debit].inject(0) { |sum, d| sum + d[:amount] }
63
75
  res['credit_code'] = dat[:credit].length == 1 ? dat[:credit][0][:code] : dat[:credit].map { |d| d[:code] }
64
76
  res['credit_amount'] = dat[:credit].inject(0) { |sum, d| sum + d[:amount] }
65
77
  res['note'] = dat[:note]
@@ -74,8 +86,15 @@ module LucaBook
74
86
  end
75
87
  end
76
88
 
77
- def to_yaml
78
- YAML.dump(LucaSupport::Code.readable(@data)).tap { |data| puts data }
89
+ def self.search_code(code)
90
+ return code if @@dict.dig(code)
91
+
92
+ @@dict.search(code).tap do |new_code|
93
+ if new_code.nil?
94
+ puts "Search word is not matched with labels"
95
+ exit 1
96
+ end
97
+ end
79
98
  end
80
99
 
81
100
  private
@@ -83,7 +102,7 @@ module LucaBook
83
102
  def set_balance
84
103
  return BigDecimal('0') if @code.nil? || /^[A-H]/.match(@code)
85
104
 
86
- balance_dict = Dict.latest_balance
105
+ balance_dict = Dict.latest_balance(@start)
87
106
  start_balance = BigDecimal(balance_dict.dig(@code.to_s, :balance) || '0')
88
107
  start = Dict.issue_date(balance_dict)&.next_month
89
108
  last = @start.prev_month
@@ -113,20 +132,16 @@ module LucaBook
113
132
  def convert_label
114
133
  @data.each do |dat|
115
134
  if @code
116
- dat[:code] = "#{dat[:code]} #{@dict.dig(dat[:code], :label)}"
117
- dat[:counter_code] = dat[:counter_code].map { |counter| "#{counter} #{@dict.dig(counter, :label)}" }
135
+ dat[:code] = "#{dat[:code]} #{@@dict.dig(dat[:code], :label)}"
136
+ dat[:counter_code] = dat[:counter_code].map { |counter| "#{counter} #{@@dict.dig(counter, :label)}" }
118
137
  else
119
- dat[:debit].each { |debit| debit[:code] = "#{debit[:code]} #{@dict.dig(debit[:code], :label)}" }
120
- dat[:credit].each { |credit| credit[:code] = "#{credit[:code]} #{@dict.dig(credit[:code], :label)}" }
138
+ dat[:debit].each { |debit| debit[:code] = "#{debit[:code]} #{@@dict.dig(debit[:code], :label)}" }
139
+ dat[:credit].each { |credit| credit[:code] = "#{credit[:code]} #{@@dict.dig(credit[:code], :label)}" }
121
140
  end
122
141
  end
123
142
  self
124
143
  end
125
144
 
126
- def dict
127
- LucaBook::Dict::Data
128
- end
129
-
130
145
  def code_header
131
146
  {}.tap do |h|
132
147
  %w[code date no id diff balance counter_code note].each do |k|