lucabook 0.2.21 → 0.2.26

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.
@@ -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|