luca-jp 0.20.5 → 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: c08c93c99340c765ef5a6f54e7d32a08da995925a42d9217b987e432b3a83cde
4
- data.tar.gz: b1bfd83cec6fc56050f7a70a0578de2942fcf74e539249faef30bc1da6f05ddb
3
+ metadata.gz: 9b7d7394cf2864fb65e6b575cb233dbac9939ccaff36fcf27c78bc9feb1e6da7
4
+ data.tar.gz: 67663785b91e181476b8fa12209ba8bdd0cddae11916e58b7695729cf05c3ecd
5
5
  SHA512:
6
- metadata.gz: 504a1bdfcccdf1e2b1881b44c928a8b62ab5cd640eb262d6082a9ff290c1d73184575540a9e661a0b88fd126fdeb397bc7ef56dc4f5ca268f5d42d1cd16c4d68
7
- data.tar.gz: cb95c8d8bca2d4aee9442f6253b7159ae9b8c24df12dd255d887de889a00312c894af39608772a38191f999db215d7a0846ad78c70ada84c755accfef632f1fc
6
+ metadata.gz: eccbca739cb53247f7e984ea6a84a87ec74cbf8796c0347dd763c8e61a83bd12bc3290968fae0d45a9e8633301e9f54057308dd0e1877c2190e467db11b64f47
7
+ data.tar.gz: 18e49a30a3a67b9fb2896a13d3f62fa75320c6f64a12654894f6f22ead17a13d1c9338c0902c92bd7370e2b648ed364292e28e220fe571c54058a47131dc4c26
data/exe/luca-jp CHANGED
@@ -4,6 +4,7 @@
4
4
  require 'optparse'
5
5
  require 'luca/jp'
6
6
  require 'luca_cmd'
7
+ require 'luca_salary/jp_adjustment'
7
8
  require 'luca_salary/jp_payreport'
8
9
  require 'luca_support/const'
9
10
  require 'yaml'
@@ -76,6 +77,13 @@ module LucaSalaryCmd
76
77
  puts LucaSalary::JpPayreport.export(args.first)
77
78
  end
78
79
  end
80
+
81
+ class Import
82
+ def self.profiles(args, params = nil)
83
+ path, id = args
84
+ puts LucaSalary::JpAdjustment.import(path, id, params)
85
+ end
86
+ end
79
87
  end
80
88
 
81
89
  #LucaRecord::Base.valid_project?
@@ -135,6 +143,14 @@ when 'k', 'kyuyo', 'salary'
135
143
  LucaSalaryCmd::Export.payreport(args)
136
144
  end
137
145
  end
146
+ when 'n', /nenmats?u/, /nenmats?u-?chou?sei/
147
+ OptionParser.new do |opt|
148
+ opt.banner = 'Usage: luca-jp nenmatsu <import-path> [profile id]'
149
+ args = opt.parse(ARGV)
150
+ LucaCmd.check_dir('profiles', ext_conf: EXT_CONF) do
151
+ LucaSalaryCmd::Import.profiles(args, params)
152
+ end
153
+ end
138
154
  when 'version'
139
155
  puts "luca-jp: version #{Luca::Jp::VERSION}"
140
156
  exit 0
@@ -146,6 +162,7 @@ else
146
162
  puts ' s[youhizei]: 消費税の計算'
147
163
  puts ' c[hihouzei]: 地方税の計算'
148
164
  puts ' k[yuyo]: 給与報告(所得税)の計算'
165
+ puts ' n[enmatsu]: 年末調整アプリのデータインポート'
149
166
  puts ' urikake: 勘定科目内訳明細書の売掛金リスト(CSV)'
150
167
  exit 1
151
168
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Luca
4
4
  module Jp
5
- VERSION = '0.20.5'
5
+ VERSION = '0.21.0'
6
6
  end
7
7
  end
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+ require 'fileutils'
3
+ require 'luca_salary'
4
+ require 'luca_support/const'
5
+ require 'luca/jp/util'
6
+ require 'rexml/document'
7
+ require 'zip'
8
+
9
+ module LucaSalary
10
+ class JpAdjustment < LucaSalary::Profile
11
+
12
+ def self.import(path, id = nil, params = nil)
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']
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
51
+ end
52
+ [tax_ids, names, profiles]
53
+ end
54
+
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
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
96
+ end
97
+
98
+ # XMLタグ定義: https://www.nta.go.jp/users/gensen/oshirase/0019004-159.htm
99
+ #
100
+ TAGS = {
101
+ 'NTAAPP001' => {
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
+ },
112
+ family: {
113
+ root: 'xml001_D00000', # 扶養親族情報繰り返し
114
+ id: 'xml001_D00040', # マイナンバー
115
+ kana: 'xml001_D00020', # フリガナ
116
+ name: 'xml001_D00030', # 氏名
117
+ birth_date: {
118
+ root: 'xml001_D00080', # 生年月日
119
+ year: 'xml001_D00090', # 西暦
120
+ month: 'xml001_D00120', # 月
121
+ day: 'xml001_D00130' # 日
122
+ },
123
+ income: 'xml001_D00160', # 本年中の所得の見積額
124
+ elderly: 'xml001_D00140', # 老人扶養親族
125
+ tokutei: 'xml001_D00150', # 特定扶養親族/特定親族
126
+ nonresident: 'xml001_D00170', # 非居住者である親族/控除対象外国外扶養親族
127
+ handicapped: {
128
+ root: 'xml001_D00290', # 障害者である事実
129
+ type: 'xml001_D00300' # 障害者区分
130
+ }
131
+ }
132
+ },
133
+ 'NTAAPP004' => {
134
+ year: 'xml004_B00020',
135
+ spouse: {
136
+ root: 'xml004_D00000',
137
+ id: 'xml004_D00030', # マイナンバー
138
+ kana: 'xml004_D00010', # フリガナ
139
+ name: 'xml004_D00020', # 氏名
140
+ income: 'xml004_D00260', # 配偶者の本年中の合計所得金額の見積額
141
+ elderly: 'xml004_D00160', # 老人控除対象配偶者 (1: 該当)
142
+ nonresident: 'xml004_D00170' # 非居住者である配偶者
143
+ }
144
+ }
145
+ }
146
+
147
+ def self.parse_xml(xml_set)
148
+ h = { 'spouse' => {}, 'family' => [] }
149
+ xml_set.each do |xml|
150
+ # ルート要素の属性から様式IDを取得
151
+ form_id = xml.root.name
152
+ tags = TAGS[form_id]
153
+ next unless tags
154
+
155
+ year_node = xml.elements["//#{tags[:year]}"]
156
+ year = year_node&.text&.to_i
157
+
158
+ if form_id == 'NTAAPP001'
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)
171
+ end
172
+ end
173
+ h['family'] << parse_family(xml, year)
174
+ end
175
+
176
+ if form_id == 'NTAAPP004' && year
177
+ h['spouse'] = parse_spouse(xml, year)
178
+ end
179
+ end
180
+
181
+ h['family'] = merge_family(h['family']) # 翌年の扶養控除等申告書との重複
182
+ h
183
+ end
184
+
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
188
+
189
+ key = [query['name'].gsub(" ", "").strip, query['birth_date'].to_s]
190
+ names[key]
191
+ end
192
+
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?
227
+
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)
251
+ end
252
+ end
253
+
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
269
+
270
+ nonresident = dep.elements[tags[:family][:nonresident]]&.text
271
+ f['nonresident'] = { year => nonresident.to_i } if nonresident == '1'
272
+
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 }
279
+ end
280
+ end
281
+ end
282
+ h << f
283
+ end
284
+ h
285
+ end
286
+
287
+ def self.merge_family(family_list)
288
+ merged = {}
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
295
+ if merged.key?(key)
296
+ target = merged[key]
297
+
298
+ ['income', 'elderly', 'tokutei', 'nonresident', 'handicapped'].each do |field|
299
+ if f[field]
300
+ target[field] ||= {}
301
+ target[field].merge!(f[field])
302
+ end
303
+ end
304
+ else
305
+ merged[key] = f
306
+ end
307
+ end
308
+ merged.values
309
+ end
310
+
311
+ def self.bulk_load(path)
312
+ return enum_for(:bulk_load, path) unless block_given?
313
+
314
+ has_many = if File.directory?(path)
315
+ children = Dir.children(path).map { |c| File.join(path, c) }
316
+ has_subdir = children.any? { |c| File.directory?(c) }
317
+ has_zip = children.any? { |c| File.file?(c) && File.extname(c).downcase == '.zip' }
318
+
319
+ has_subdir || has_zip
320
+ elsif File.file?(path)
321
+ if File.extname(path).downcase == '.zip'
322
+ false
323
+ else
324
+ raise "Unsupported file type: #{path}"
325
+ end
326
+ else
327
+ raise "Path not found: #{path}"
328
+ end
329
+
330
+ if has_many
331
+ Dir.children(path).sort.map do |child|
332
+ full_path = File.join(path, child)
333
+ if File.directory?(full_path) || (File.file?(full_path) && File.extname(full_path).downcase == '.zip')
334
+ data = load_xml_export(full_path)
335
+ yield(parse_xml(data), Pathname(full_path)) unless data.empty?
336
+ else
337
+ nil
338
+ end
339
+ end.compact
340
+ else
341
+ data = load_xml_export(path)
342
+ yield(parse_xml(data), Pathname(path)) unless data.empty?
343
+ end
344
+ end
345
+
346
+ # 年末調整アプリのエクスポートデータ読み込み(非暗号zipまたはzipを解凍したディレクトリをサポート)
347
+ def self.load_xml_export(path)
348
+ docs = []
349
+ if File.directory?(path)
350
+ Dir.children(path).sort.each do |child|
351
+ full_path = File.join(path, child)
352
+ next if File.directory?(full_path)
353
+
354
+ ext = File.extname(child).downcase
355
+ if ext == '.xml'
356
+ begin
357
+ docs << REXML::Document.new(File.read(full_path))
358
+ rescue StandardError => e
359
+ STDERR.puts "#{full_path}: #{e.message}"
360
+ end
361
+ elsif ext == '.zip'
362
+ # skip without warning
363
+ else
364
+ STDERR.puts full_path
365
+ end
366
+ end
367
+ return docs
368
+ end
369
+
370
+ if File.file?(path) && File.extname(path).downcase == '.zip'
371
+ Zip::File.open(path) do |zip|
372
+ zip.each do |entry|
373
+ next if entry.directory?
374
+
375
+ ext = File.extname(entry.name).downcase
376
+ if ext == '.xml'
377
+ begin
378
+ docs << REXML::Document.new(entry.get_input_stream.read)
379
+ rescue StandardError => e
380
+ STDERR.puts "#{entry.name}: #{e.message}"
381
+ end
382
+ elsif ext == '.zip'
383
+ # skip without warning
384
+ else
385
+ STDERR.puts entry.name
386
+ end
387
+ end
388
+ end
389
+ end
390
+ docs
391
+ end
392
+ end
393
+ end
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.5
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-22 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
@@ -80,6 +80,34 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: 0.3.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: rexml
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.4'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.4'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubyzip
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
83
111
  - !ruby/object:Gem::Dependency
84
112
  name: bundler
85
113
  requirement: !ruby/object:Gem::Requirement
@@ -185,6 +213,7 @@ files:
185
213
  - lib/luca_salary/jp.rb
186
214
  - lib/luca_salary/jp/insurance.rb
187
215
  - lib/luca_salary/jp/version.rb
216
+ - lib/luca_salary/jp_adjustment.rb
188
217
  - lib/luca_salary/jp_payreport.rb
189
218
  homepage: https://github.com/chumaltd/luca-jp
190
219
  licenses: