luca-jp 0.20.6 → 0.21.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfd6af4ff40f3d2562f04f71b59e02c944fec0fbc66dc18ae72d868bda2c5b82
4
- data.tar.gz: 42f779967ee6e22db9a488bebdfa1c123e8c1738532f78e3ed5201916d92ca8a
3
+ metadata.gz: 9b7d7394cf2864fb65e6b575cb233dbac9939ccaff36fcf27c78bc9feb1e6da7
4
+ data.tar.gz: 67663785b91e181476b8fa12209ba8bdd0cddae11916e58b7695729cf05c3ecd
5
5
  SHA512:
6
- metadata.gz: 9b7f6ceb960f5dee8eea66435f631567123e7d98757fbb07f607b20fea1725516a87f054f1f48e7173b7fe4d343c644d51f0b5b11904ee681db7cde43e80a9be
7
- data.tar.gz: 1e4929bef66fc8459ce622c75c590e49d1fdc5a96ac7d4d3e63543ba1971490f69bfd1fc62c469da03111a4973561579a11a76683f055c0b1ef749ce3e4fc9f8
6
+ metadata.gz: eccbca739cb53247f7e984ea6a84a87ec74cbf8796c0347dd763c8e61a83bd12bc3290968fae0d45a9e8633301e9f54057308dd0e1877c2190e467db11b64f47
7
+ data.tar.gz: 18e49a30a3a67b9fb2896a13d3f62fa75320c6f64a12654894f6f22ead17a13d1c9338c0902c92bd7370e2b648ed364292e28e220fe571c54058a47131dc4c26
data/exe/luca-jp CHANGED
@@ -145,7 +145,7 @@ when 'k', 'kyuyo', 'salary'
145
145
  end
146
146
  when 'n', /nenmats?u/, /nenmats?u-?chou?sei/
147
147
  OptionParser.new do |opt|
148
- opt.banner = 'Usage: luca-jp nenmatsu <zip-file-path> <profile id>'
148
+ opt.banner = 'Usage: luca-jp nenmatsu <import-path> [profile id]'
149
149
  args = opt.parse(ARGV)
150
150
  LucaCmd.check_dir('profiles', ext_conf: EXT_CONF) do
151
151
  LucaSalaryCmd::Import.profiles(args, params)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Luca
4
4
  module Jp
5
- VERSION = '0.20.6'
5
+ VERSION = '0.21.0'
6
6
  end
7
7
  end
@@ -1,38 +1,98 @@
1
1
  # frozen_string_literal: true
2
+ require 'fileutils'
2
3
  require 'luca_salary'
3
4
  require 'luca_support/const'
4
5
  require 'luca/jp/util'
5
- require 'zip'
6
-
7
6
  require 'rexml/document'
7
+ require 'zip'
8
8
 
9
9
  module LucaSalary
10
10
  class JpAdjustment < LucaSalary::Profile
11
11
 
12
12
  def self.import(path, id = nil, params = nil)
13
- s_profile = profiles(id)
14
- bulk_load(path).each do |o|
15
- s_profile['spouse']['name'] ||= o['spouse']['name']
16
- s_profile['spouse']['katakana'] ||= o['spouse']['kana']
17
- s_profile['spouse']['income'].merge!(o['spouse']['income'])
18
- if ! s_profile['family'].empty? || ! o['family'].empty?
19
- s_profile['family'] = merge_family(s_profile['family'].concat(o['family']))
13
+ tax_ids, names, s_profiles = create_index
14
+ bulk_load(path).each do |o, path|
15
+ s_profile = if id
16
+ find_profile(id).tap do |p|
17
+ if p.nil?
18
+ FileUtils.mkdir_p(path.parent / 'rejected')
19
+ FileUtils.move(path, path.parent / 'rejected')
20
+ raise "No entries found for ID: #{id}. abort..."
21
+ end
22
+ end
23
+ else
24
+ id = search_id(o, tax_ids, names)
25
+ if id.nil?
26
+ STDERR.puts "#{o['name']} record not found. skip..."
27
+ FileUtils.mkdir_p(path.parent / 'rejected')
28
+ FileUtils.move(path, path.parent / 'rejected')
29
+ next
30
+ end
31
+ s_profiles[id]
32
+ end
33
+ s_profile = update_profile(s_profile, o)
34
+ save(s_profile, 's_profiles')
35
+ end
36
+ end
37
+
38
+ def self.create_index
39
+ tax_ids = {}
40
+ names = {}
41
+ profiles = {}
42
+ all('profiles').each do |p|
43
+ profile = find_secure(p['id'], 'profiles')
44
+ if profile['tax_id']
45
+ tax_ids[profile['tax_id'].to_s] = p['id']
20
46
  end
47
+ key = [profile['name'].gsub(" ", "").strip, profile['birth_date'].to_s]
48
+ names[key] = p['id']
49
+ s_profile = find(p['id'], 's_profiles')
50
+ profiles[profile['id']] = s_profile
21
51
  end
22
- save(s_profile, 's_profiles')
52
+ [tax_ids, names, profiles]
23
53
  end
24
54
 
25
- def self.profiles(id = nil)
26
- if id
27
- list = id_completion(id, basedir: 's_profiles')
28
- id = if list.length > 1
29
- raise "#{list.length} entries found for ID: #{id}. abort..."
30
- else
31
- list.first
32
- end
33
- #merged = find_secure(id, 'profiles') # NOTE for content match by name, birth_date
34
- find(id, 's_profiles')
55
+ def self.find_profile(id_fragment)
56
+ list = id_completion(id_fragment, basedir: 's_profiles')
57
+ if list.length > 1
58
+ STDERR.puts "#{list.length} entries found for ID: #{id_fragment}. abort..."
59
+ return nil
35
60
  end
61
+
62
+ id = list.first
63
+ find(id, 's_profiles')
64
+ end
65
+
66
+ def self.update_profile(previous, imported)
67
+ previous['tax_id'] ||= imported['tax_id']
68
+ previous['name'] ||= imported['name']
69
+ previous['katakana'] ||= imported['kana']
70
+ previous['birth_date'] ||= imported['birth_date']
71
+
72
+ if imported['spouse'] && !imported['spouse'].empty?
73
+ if same_person?(previous['spouse'], imported['spouse'])
74
+ # NOTE imported['spouse']をベースにし、incomeだけマージ
75
+ income = (previous['spouse']['income'] || {}).merge(imported['spouse']['income'])
76
+ previous['spouse'] = imported['spouse']
77
+ previous['spouse']['income'] = income
78
+ else
79
+ previous['spouse'] = imported['spouse']
80
+ end
81
+ end
82
+
83
+ if !imported['family'].empty?
84
+ previous['family'] = imported['family'].map do |latest|
85
+ registered = previous['family'].find { |m| same_person?(m, latest) }
86
+ if registered
87
+ ['income', 'elderly', 'tokutei', 'nonresident', 'handicapped'].each do |k|
88
+ latest[k] = registered[k].merge(new_member[k]) if registered[k] && new_member[k]
89
+ latest[k] ||= registered[k]
90
+ end
91
+ end
92
+ latest
93
+ end
94
+ end
95
+ previous
36
96
  end
37
97
 
38
98
  # XMLタグ定義: https://www.nta.go.jp/users/gensen/oshirase/0019004-159.htm
@@ -40,8 +100,18 @@ module LucaSalary
40
100
  TAGS = {
41
101
  'NTAAPP001' => {
42
102
  year: 'xml001_B00020',
103
+ id: 'xml001_B00170', # マイナンバー
104
+ kana: 'xml001_B00150', # フリガナ
105
+ name: 'xml001_B00160', # 氏名
106
+ birth_date: {
107
+ root: 'xml001_B00230', # 生年月日
108
+ year: 'xml001_B00240', # 西暦
109
+ month: 'xml001_B00270', # 月
110
+ day: 'xml001_B00280' # 日
111
+ },
43
112
  family: {
44
113
  root: 'xml001_D00000', # 扶養親族情報繰り返し
114
+ id: 'xml001_D00040', # マイナンバー
45
115
  kana: 'xml001_D00020', # フリガナ
46
116
  name: 'xml001_D00030', # 氏名
47
117
  birth_date: {
@@ -64,6 +134,7 @@ module LucaSalary
64
134
  year: 'xml004_B00020',
65
135
  spouse: {
66
136
  root: 'xml004_D00000',
137
+ id: 'xml004_D00030', # マイナンバー
67
138
  kana: 'xml004_D00010', # フリガナ
68
139
  name: 'xml004_D00020', # 氏名
69
140
  income: 'xml004_D00260', # 配偶者の本年中の合計所得金額の見積額
@@ -74,7 +145,7 @@ module LucaSalary
74
145
  }
75
146
 
76
147
  def self.parse_xml(xml_set)
77
- h = { 'spouse' => { 'income' => {} }, 'family' => [] }
148
+ h = { 'spouse' => {}, 'family' => [] }
78
149
  xml_set.each do |xml|
79
150
  # ルート要素の属性から様式IDを取得
80
151
  form_id = xml.root.name
@@ -84,89 +155,143 @@ module LucaSalary
84
155
  year_node = xml.elements["//#{tags[:year]}"]
85
156
  year = year_node&.text&.to_i
86
157
 
87
- # NTAAPP001: 扶養控除等申告書
88
158
  if form_id == 'NTAAPP001'
89
- xml.elements.each("//#{tags[:family][:root]}") do |dep|
90
- f = {}
91
- f['name'] = dep.elements[tags[:family][:name]]&.text
92
- f['kana'] = dep.elements[tags[:family][:kana]]&.text
93
-
94
- bd_tags = tags[:family][:birth_date]
95
- bd_node = dep.elements[bd_tags[:root]]
96
- if bd_node
97
- y = bd_node.elements[bd_tags[:year]]&.text
98
- m = bd_node.elements[bd_tags[:month]]&.text
99
- d = bd_node.elements[bd_tags[:day]]&.text
100
- if y && m && d
101
- f['birth_date'] = Date.new(y.to_i, m.to_i, d.to_i)
102
- end
159
+ tags = TAGS[form_id]
160
+ h['tax_id'] ||= xml.elements["//#{tags[:id]}"]&.text
161
+ h['name'] ||= xml.elements["//#{tags[:name]}"]&.text
162
+ h['kana'] ||= xml.elements["//#{tags[:kana]}"]&.text
163
+ bd_tags = tags[:birth_date]
164
+ bd_node = xml.elements["//#{bd_tags[:root]}"]
165
+ if bd_node
166
+ y = bd_node.elements[bd_tags[:year]]&.text
167
+ m = bd_node.elements[bd_tags[:month]]&.text
168
+ d = bd_node.elements[bd_tags[:day]]&.text
169
+ if y && m && d
170
+ h['birth_date'] = Date.new(y.to_i, m.to_i, d.to_i)
103
171
  end
172
+ end
173
+ h['family'] << parse_family(xml, year)
174
+ end
104
175
 
105
- if year
106
- dep_income = dep.elements[tags[:family][:income]]&.text
107
- if dep_income && !dep_income.empty?
108
- f['income'] = { year => dep_income.to_i }
109
- end
176
+ if form_id == 'NTAAPP004' && year
177
+ h['spouse'] = parse_spouse(xml, year)
178
+ end
179
+ end
110
180
 
111
- elderly = dep.elements[tags[:family][:elderly]]&.text
112
- if elderly && (elderly == '1' || elderly == '2')
113
- f['elderly'] = { year => elderly.to_i }
114
- end
181
+ h['family'] = merge_family(h['family']) # 翌年の扶養控除等申告書との重複
182
+ h
183
+ end
115
184
 
116
- tokutei = dep.elements[tags[:family][:tokutei]]&.text
117
- if tokutei && (tokutei == '1' || tokutei == '2')
118
- f['tokutei'] = { year => tokutei.to_i }
119
- end
185
+ def self.search_id(query, tax_ids, names)
186
+ id = tax_ids[query['tax_id']] if query['tax_id']
187
+ return id if id
120
188
 
121
- nonresident = dep.elements[tags[:family][:nonresident]]&.text
122
- f['nonresident'] = { year => nonresident.to_i } if nonresident == '1'
189
+ key = [query['name'].gsub(" ", "").strip, query['birth_date'].to_s]
190
+ names[key]
191
+ end
123
192
 
124
- hc_tags = tags[:family][:handicapped]
125
- hc_node = dep.elements[hc_tags[:root]]
126
- if hc_node
127
- hc_type = hc_node.elements[hc_tags[:type]]&.text
128
- if hc_type && !hc_type.empty? && hc_type != '0'
129
- f['handicapped'] = { year => hc_type.to_i }
130
- end
131
- end
132
- end
193
+ def self.same_person?(p1, p2)
194
+ return false if p1.nil? || p2.nil? || p1.empty? || p2.empty?
195
+
196
+ if p1['tax_id'] && p2['tax_id']
197
+ return p1['tax_id'].to_s == p2['tax_id'].to_s
198
+ end
199
+
200
+ n1 = p1['name'].to_s.gsub(' ', '').strip
201
+ n2 = p2['name'].to_s.gsub(' ', '').strip
202
+ bd1 = p1['birth_date'].to_s
203
+ bd2 = p2['birth_date'].to_s
204
+
205
+ n1 == n2 && bd1 == bd2
206
+ end
207
+
208
+ # NTAAPP004: 配偶者控除等申告書
209
+ def self.parse_spouse(xml, year)
210
+ tags = TAGS['NTAAPP004']
211
+ spouse = xml.elements["//#{tags[:spouse][:root]}"]
212
+ return {} if ! spouse
213
+
214
+ { 'income' => {} }.tap do |h|
215
+ h['tax_id'] = spouse.elements[tags[:spouse][:id]]&.text
216
+ h['name'] = spouse.elements[tags[:spouse][:name]]&.text
217
+ h['kana'] = spouse.elements[tags[:spouse][:kana]]&.text
218
+
219
+ nonresident = spouse.elements[tags[:spouse][:nonresident]]&.text
220
+ if nonresident == '1'
221
+ h['nonresident'] = {} unless h['nonresident']
222
+ h['nonresident'][year] = nonresident.to_i
223
+ end
224
+
225
+ income = spouse.elements[tags[:spouse][:income]]&.text
226
+ h['income'][year] = income.to_i if income && !income.empty?
133
227
 
134
- h['family'] << f
228
+ elderly = spouse.elements[tags[:spouse][:elderly]]&.text
229
+ h['elderly'] = '1' if elderly == '1'
230
+ end
231
+ end
232
+
233
+ # NTAAPP001: 扶養控除等申告書
234
+ def self.parse_family(xml, year)
235
+ tags = TAGS['NTAAPP001']
236
+ h = []
237
+ xml.elements.each("//#{tags[:family][:root]}") do |dep|
238
+ f = {}
239
+ f['tax_id'] = dep.elements[tags[:family][:id]]&.text
240
+ f['name'] = dep.elements[tags[:family][:name]]&.text
241
+ f['kana'] = dep.elements[tags[:family][:kana]]&.text
242
+
243
+ bd_tags = tags[:family][:birth_date]
244
+ bd_node = dep.elements[bd_tags[:root]]
245
+ if bd_node
246
+ y = bd_node.elements[bd_tags[:year]]&.text
247
+ m = bd_node.elements[bd_tags[:month]]&.text
248
+ d = bd_node.elements[bd_tags[:day]]&.text
249
+ if y && m && d
250
+ f['birth_date'] = Date.new(y.to_i, m.to_i, d.to_i)
135
251
  end
136
252
  end
137
253
 
138
- # NTAAPP004: 配偶者控除等申告書
139
- if form_id == 'NTAAPP004'
140
- if year
141
- spouse = xml.elements["//#{tags[:spouse][:root]}"]
142
- if spouse
143
- h['spouse']['name'] = spouse.elements[tags[:spouse][:name]]&.text
144
- h['spouse']['kana'] = spouse.elements[tags[:spouse][:kana]]&.text
145
-
146
- nonresident = spouse.elements[tags[:spouse][:nonresident]]&.text
147
- if nonresident == '1'
148
- h['spouse']['nonresident'] = {} unless h['spouse']['nonresident']
149
- h['spouse']['nonresident'][year] = nonresident.to_i
150
- end
254
+ if year
255
+ dep_income = dep.elements[tags[:family][:income]]&.text
256
+ if dep_income && !dep_income.empty?
257
+ f['income'] = { year => dep_income.to_i }
258
+ end
259
+
260
+ elderly = dep.elements[tags[:family][:elderly]]&.text
261
+ if elderly && (elderly == '1' || elderly == '2')
262
+ f['elderly'] = { year => elderly.to_i }
263
+ end
264
+
265
+ tokutei = dep.elements[tags[:family][:tokutei]]&.text
266
+ if tokutei && (tokutei == '1' || tokutei == '2')
267
+ f['tokutei'] = { year => tokutei.to_i }
268
+ end
151
269
 
152
- income = spouse.elements[tags[:spouse][:income]]&.text
153
- h['spouse']['income'][year] = income.to_i if income && !income.empty?
270
+ nonresident = dep.elements[tags[:family][:nonresident]]&.text
271
+ f['nonresident'] = { year => nonresident.to_i } if nonresident == '1'
154
272
 
155
- elderly = spouse.elements[tags[:spouse][:elderly]]&.text
156
- h['spouse']['elderly'] = '1' if elderly == '1'
273
+ hc_tags = tags[:family][:handicapped]
274
+ hc_node = dep.elements[hc_tags[:root]]
275
+ if hc_node
276
+ hc_type = hc_node.elements[hc_tags[:type]]&.text
277
+ if hc_type && !hc_type.empty? && hc_type != '0'
278
+ f['handicapped'] = { year => hc_type.to_i }
157
279
  end
158
280
  end
159
281
  end
282
+ h << f
160
283
  end
161
-
162
- h['family'] = merge_family(h['family']) # 翌年の扶養控除等申告書との重複
163
284
  h
164
285
  end
165
286
 
166
287
  def self.merge_family(family_list)
167
288
  merged = {}
168
- family_list.each do |f|
169
- key = [f['name'].gsub(" ", "").strip, f['birth_date']]
289
+ family_list.flatten.each do |f|
290
+ key = if f['tax_id']
291
+ f['tax_id']
292
+ else
293
+ [f['name'].gsub(" ", "").strip, f['birth_date'].to_s]
294
+ end
170
295
  if merged.key?(key)
171
296
  target = merged[key]
172
297
 
@@ -203,21 +328,18 @@ module LucaSalary
203
328
  end
204
329
 
205
330
  if has_many
206
- # NOTE implement search by content logic
207
- raise "Multiple import is not supported yet."
208
-
209
331
  Dir.children(path).sort.map do |child|
210
332
  full_path = File.join(path, child)
211
333
  if File.directory?(full_path) || (File.file?(full_path) && File.extname(full_path).downcase == '.zip')
212
334
  data = load_xml_export(full_path)
213
- yield parse_xml(data) unless data.empty?
335
+ yield(parse_xml(data), Pathname(full_path)) unless data.empty?
214
336
  else
215
337
  nil
216
338
  end
217
339
  end.compact
218
340
  else
219
341
  data = load_xml_export(path)
220
- yield parse_xml(data) unless data.empty?
342
+ yield(parse_xml(data), Pathname(path)) unless data.empty?
221
343
  end
222
344
  end
223
345
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: luca-jp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.6
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuma Takahiro
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-29 00:00:00.000000000 Z
11
+ date: 2026-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lucabook