jp_address_complement 0.1.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.
Files changed (181) hide show
  1. checksums.yaml +7 -0
  2. data/.agent/rules/specify-rules.md +29 -0
  3. data/.agent/workflows/speckit.analyze.md +184 -0
  4. data/.agent/workflows/speckit.checklist.md +294 -0
  5. data/.agent/workflows/speckit.clarify.md +181 -0
  6. data/.agent/workflows/speckit.constitution.md +84 -0
  7. data/.agent/workflows/speckit.implement.md +135 -0
  8. data/.agent/workflows/speckit.plan.md +90 -0
  9. data/.agent/workflows/speckit.specify.md +258 -0
  10. data/.agent/workflows/speckit.tasks.md +137 -0
  11. data/.agent/workflows/speckit.taskstoissues.md +30 -0
  12. data/.claude/commands/speckit.analyze.md +184 -0
  13. data/.claude/commands/speckit.checklist.md +294 -0
  14. data/.claude/commands/speckit.clarify.md +181 -0
  15. data/.claude/commands/speckit.constitution.md +84 -0
  16. data/.claude/commands/speckit.implement.md +135 -0
  17. data/.claude/commands/speckit.plan.md +90 -0
  18. data/.claude/commands/speckit.specify.md +258 -0
  19. data/.claude/commands/speckit.tasks.md +137 -0
  20. data/.claude/commands/speckit.taskstoissues.md +30 -0
  21. data/.cursor/commands/speckit.analyze.md +184 -0
  22. data/.cursor/commands/speckit.checklist.md +294 -0
  23. data/.cursor/commands/speckit.clarify.md +181 -0
  24. data/.cursor/commands/speckit.constitution.md +84 -0
  25. data/.cursor/commands/speckit.implement.md +135 -0
  26. data/.cursor/commands/speckit.plan.md +90 -0
  27. data/.cursor/commands/speckit.specify.md +258 -0
  28. data/.cursor/commands/speckit.tasks.md +137 -0
  29. data/.cursor/commands/speckit.taskstoissues.md +30 -0
  30. data/.cursor/rules/specify-rules.mdc +32 -0
  31. data/.rubocop.yml +118 -0
  32. data/.specify/memory/constitution.md +130 -0
  33. data/.specify/scripts/bash/check-prerequisites.sh +166 -0
  34. data/.specify/scripts/bash/common.sh +156 -0
  35. data/.specify/scripts/bash/create-new-feature.sh +297 -0
  36. data/.specify/scripts/bash/setup-plan.sh +61 -0
  37. data/.specify/scripts/bash/update-agent-context.sh +810 -0
  38. data/.specify/templates/agent-file-template.md +28 -0
  39. data/.specify/templates/checklist-template.md +40 -0
  40. data/.specify/templates/constitution-template.md +50 -0
  41. data/.specify/templates/plan-template.md +104 -0
  42. data/.specify/templates/spec-template.md +115 -0
  43. data/.specify/templates/tasks-template.md +251 -0
  44. data/CHANGELOG.md +26 -0
  45. data/LICENSE +9 -0
  46. data/README.md +274 -0
  47. data/Rakefile +19 -0
  48. data/Steepfile +9 -0
  49. data/examples/rails/jp_address_complement_demo/.gitignore +15 -0
  50. data/examples/rails/jp_address_complement_demo/.ruby-version +1 -0
  51. data/examples/rails/jp_address_complement_demo/Gemfile +22 -0
  52. data/examples/rails/jp_address_complement_demo/Gemfile.lock +252 -0
  53. data/examples/rails/jp_address_complement_demo/README.md +57 -0
  54. data/examples/rails/jp_address_complement_demo/Rakefile +6 -0
  55. data/examples/rails/jp_address_complement_demo/app/assets/images/.keep +0 -0
  56. data/examples/rails/jp_address_complement_demo/app/assets/stylesheets/application.css +1 -0
  57. data/examples/rails/jp_address_complement_demo/app/controllers/addresses_controller.rb +59 -0
  58. data/examples/rails/jp_address_complement_demo/app/controllers/application_controller.rb +4 -0
  59. data/examples/rails/jp_address_complement_demo/app/controllers/concerns/.keep +0 -0
  60. data/examples/rails/jp_address_complement_demo/app/helpers/application_helper.rb +2 -0
  61. data/examples/rails/jp_address_complement_demo/app/models/application_record.rb +3 -0
  62. data/examples/rails/jp_address_complement_demo/app/models/concerns/.keep +0 -0
  63. data/examples/rails/jp_address_complement_demo/app/views/addresses/index.html.erb +22 -0
  64. data/examples/rails/jp_address_complement_demo/app/views/addresses/prefecture.html.erb +20 -0
  65. data/examples/rails/jp_address_complement_demo/app/views/addresses/prefix.html.erb +25 -0
  66. data/examples/rails/jp_address_complement_demo/app/views/addresses/reverse.html.erb +30 -0
  67. data/examples/rails/jp_address_complement_demo/app/views/addresses/validate.html.erb +23 -0
  68. data/examples/rails/jp_address_complement_demo/app/views/layouts/application.html.erb +40 -0
  69. data/examples/rails/jp_address_complement_demo/app/views/pwa/manifest.json.erb +22 -0
  70. data/examples/rails/jp_address_complement_demo/app/views/pwa/service-worker.js +26 -0
  71. data/examples/rails/jp_address_complement_demo/bin/ci +6 -0
  72. data/examples/rails/jp_address_complement_demo/bin/dev +2 -0
  73. data/examples/rails/jp_address_complement_demo/bin/rails +4 -0
  74. data/examples/rails/jp_address_complement_demo/bin/rake +4 -0
  75. data/examples/rails/jp_address_complement_demo/bin/setup +35 -0
  76. data/examples/rails/jp_address_complement_demo/config/application.rb +42 -0
  77. data/examples/rails/jp_address_complement_demo/config/boot.rb +3 -0
  78. data/examples/rails/jp_address_complement_demo/config/ci.rb +15 -0
  79. data/examples/rails/jp_address_complement_demo/config/credentials.yml.enc +1 -0
  80. data/examples/rails/jp_address_complement_demo/config/database.yml +31 -0
  81. data/examples/rails/jp_address_complement_demo/config/environment.rb +5 -0
  82. data/examples/rails/jp_address_complement_demo/config/environments/development.rb +54 -0
  83. data/examples/rails/jp_address_complement_demo/config/environments/production.rb +67 -0
  84. data/examples/rails/jp_address_complement_demo/config/environments/test.rb +42 -0
  85. data/examples/rails/jp_address_complement_demo/config/initializers/content_security_policy.rb +29 -0
  86. data/examples/rails/jp_address_complement_demo/config/initializers/filter_parameter_logging.rb +8 -0
  87. data/examples/rails/jp_address_complement_demo/config/initializers/inflections.rb +16 -0
  88. data/examples/rails/jp_address_complement_demo/config/locales/en.yml +31 -0
  89. data/examples/rails/jp_address_complement_demo/config/puma.rb +39 -0
  90. data/examples/rails/jp_address_complement_demo/config/routes.rb +19 -0
  91. data/examples/rails/jp_address_complement_demo/config.ru +6 -0
  92. data/examples/rails/jp_address_complement_demo/db/migrate/20260228083709_create_jp_address_complement_postal_codes.rb +44 -0
  93. data/examples/rails/jp_address_complement_demo/db/schema.rb +33 -0
  94. data/examples/rails/jp_address_complement_demo/db/seeds.rb +24 -0
  95. data/examples/rails/jp_address_complement_demo/lib/tasks/.keep +0 -0
  96. data/examples/rails/jp_address_complement_demo/log/.keep +0 -0
  97. data/examples/rails/jp_address_complement_demo/public/400.html +135 -0
  98. data/examples/rails/jp_address_complement_demo/public/404.html +135 -0
  99. data/examples/rails/jp_address_complement_demo/public/406-unsupported-browser.html +135 -0
  100. data/examples/rails/jp_address_complement_demo/public/422.html +135 -0
  101. data/examples/rails/jp_address_complement_demo/public/500.html +135 -0
  102. data/examples/rails/jp_address_complement_demo/public/icon.png +0 -0
  103. data/examples/rails/jp_address_complement_demo/public/icon.svg +3 -0
  104. data/examples/rails/jp_address_complement_demo/public/robots.txt +1 -0
  105. data/examples/rails/jp_address_complement_demo/script/.keep +0 -0
  106. data/examples/rails/jp_address_complement_demo/storage/.keep +0 -0
  107. data/examples/rails/jp_address_complement_demo/vendor/.keep +0 -0
  108. data/lib/generators/jp_address_complement/install_generator.rb +34 -0
  109. data/lib/generators/jp_address_complement/templates/create_jp_address_complement_postal_codes.rb.erb +45 -0
  110. data/lib/jp_address_complement/address_record.rb +36 -0
  111. data/lib/jp_address_complement/configuration.rb +21 -0
  112. data/lib/jp_address_complement/importers/csv_importer.rb +148 -0
  113. data/lib/jp_address_complement/ken_all_downloader.rb +122 -0
  114. data/lib/jp_address_complement/models/postal_code.rb +21 -0
  115. data/lib/jp_address_complement/normalizer.rb +77 -0
  116. data/lib/jp_address_complement/prefecture.rb +105 -0
  117. data/lib/jp_address_complement/railtie.rb +27 -0
  118. data/lib/jp_address_complement/repositories/active_record_postal_code_repository.rb +78 -0
  119. data/lib/jp_address_complement/repositories/csv_postal_code_repository.rb +200 -0
  120. data/lib/jp_address_complement/repositories/postal_code_repository.rb +36 -0
  121. data/lib/jp_address_complement/searcher.rb +85 -0
  122. data/lib/jp_address_complement/validators/address_validator.rb +41 -0
  123. data/lib/jp_address_complement/version.rb +6 -0
  124. data/lib/jp_address_complement.rb +129 -0
  125. data/lib/tasks/jp_address_complement.rake +32 -0
  126. data/rbs_collection.lock.yaml +380 -0
  127. data/rbs_collection.yaml +19 -0
  128. data/sig/generated/generators/jp_address_complement/install_generator.rbs +18 -0
  129. data/sig/generated/jp_address_complement/configuration.rbs +18 -0
  130. data/sig/generated/jp_address_complement/importers/csv_importer.rbs +78 -0
  131. data/sig/generated/jp_address_complement/ken_all_downloader.rbs +49 -0
  132. data/sig/generated/jp_address_complement/normalizer.rbs +37 -0
  133. data/sig/generated/jp_address_complement/prefecture.rbs +27 -0
  134. data/sig/generated/jp_address_complement/railtie.rbs +8 -0
  135. data/sig/generated/jp_address_complement/repositories/active_record_postal_code_repository.rbs +38 -0
  136. data/sig/generated/jp_address_complement/repositories/csv_postal_code_repository.rbs +100 -0
  137. data/sig/generated/jp_address_complement/repositories/postal_code_repository.rbs +29 -0
  138. data/sig/generated/jp_address_complement/searcher.rbs +43 -0
  139. data/sig/generated/jp_address_complement/validators/address_validator.rbs +24 -0
  140. data/sig/generated/jp_address_complement/version.rbs +5 -0
  141. data/sig/generated/jp_address_complement.rbs +84 -0
  142. data/sig/manual/address_record.rbs +40 -0
  143. data/sig/manual/gem_rubyzip.rbs +17 -0
  144. data/sig/manual/postal_code.rbs +9 -0
  145. data/sig/manual/stdlib_csv_invalid_encoding_error.rbs +5 -0
  146. data/sig/manual/stdlib_net_http.rbs +33 -0
  147. data/sig/manual/stdlib_openuri.rbs +9 -0
  148. data/sig/manual/stdlib_tmpdir.rbs +4 -0
  149. data/specs/001-jp-address-complement-gem/checklists/requirements.md +36 -0
  150. data/specs/001-jp-address-complement-gem/contracts/public-api.md +209 -0
  151. data/specs/001-jp-address-complement-gem/data-model.md +207 -0
  152. data/specs/001-jp-address-complement-gem/plan.md +124 -0
  153. data/specs/001-jp-address-complement-gem/quickstart.md +151 -0
  154. data/specs/001-jp-address-complement-gem/research.md +139 -0
  155. data/specs/001-jp-address-complement-gem/spec.md +153 -0
  156. data/specs/001-jp-address-complement-gem/tasks.md +279 -0
  157. data/specs/002-rbs-type-annotations/checklists/requirements.md +37 -0
  158. data/specs/002-rbs-type-annotations/contracts/rbs-public-api.md +116 -0
  159. data/specs/002-rbs-type-annotations/data-model.md +119 -0
  160. data/specs/002-rbs-type-annotations/plan.md +116 -0
  161. data/specs/002-rbs-type-annotations/quickstart.md +105 -0
  162. data/specs/002-rbs-type-annotations/research.md +173 -0
  163. data/specs/002-rbs-type-annotations/spec.md +125 -0
  164. data/specs/002-rbs-type-annotations/tasks.md +189 -0
  165. data/specs/003-csv-remove-obsolete/checklists/requirements.md +34 -0
  166. data/specs/003-csv-remove-obsolete/contracts/csv-import.md +41 -0
  167. data/specs/003-csv-remove-obsolete/data-model.md +47 -0
  168. data/specs/003-csv-remove-obsolete/plan.md +73 -0
  169. data/specs/003-csv-remove-obsolete/quickstart.md +40 -0
  170. data/specs/003-csv-remove-obsolete/research.md +71 -0
  171. data/specs/003-csv-remove-obsolete/spec.md +85 -0
  172. data/specs/003-csv-remove-obsolete/tasks.md +167 -0
  173. data/specs/004-prefecture-code-reverse-lookup/checklists/requirements.md +34 -0
  174. data/specs/004-prefecture-code-reverse-lookup/contracts/public-api-prefecture-and-reverse.md +122 -0
  175. data/specs/004-prefecture-code-reverse-lookup/data-model.md +81 -0
  176. data/specs/004-prefecture-code-reverse-lookup/plan.md +92 -0
  177. data/specs/004-prefecture-code-reverse-lookup/quickstart.md +91 -0
  178. data/specs/004-prefecture-code-reverse-lookup/research.md +62 -0
  179. data/specs/004-prefecture-code-reverse-lookup/spec.md +120 -0
  180. data/specs/004-prefecture-code-reverse-lookup/tasks.md +190 -0
  181. metadata +451 -0
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'fileutils'
5
+ require 'net/http'
6
+ require 'tempfile'
7
+ require 'tmpdir'
8
+ require 'uri'
9
+ require 'zip'
10
+
11
+ module JpAddressComplement
12
+ # 郵便番号データ(utf_ken_all.zip)を公式URLからダウンロードし、展開して CSV のパスを返す
13
+ class KenAllDownloader
14
+ # @rbs String
15
+ DEFAULT_URL = 'https://www.post.japanpost.jp/zipcode/dl/utf/zip/utf_ken_all.zip'
16
+ # @rbs String
17
+ CSV_FILENAME = 'utf_ken_all.csv'
18
+
19
+ class DownloadError < JpAddressComplement::Error; end
20
+
21
+ # @rbs (?String url) -> void
22
+ # @param url [String] ダウンロードする zip の URL(省略時は DEFAULT_URL)
23
+ def initialize(url = DEFAULT_URL)
24
+ @url = url
25
+ end
26
+
27
+ # zip をダウンロードして一時ディレクトリに展開し、KEN_ALL.CSV の絶対パスを返す。
28
+ # 呼び出し元でインポート完了まで一時ディレクトリは削除されない(プロセス終了時に OS が削除)。
29
+ # @rbs () -> String
30
+ # @return [String] 展開された KEN_ALL.CSV の絶対パス
31
+ # @raise [DownloadError] ダウンロード・展開・ファイルが見つからない場合
32
+ def download_and_extract
33
+ zip_path = download_zip
34
+ extract_zip(zip_path)
35
+ ensure
36
+ File.unlink(zip_path) if zip_path && File.exist?(zip_path)
37
+ end
38
+
39
+ private
40
+
41
+ # @rbs () -> String
42
+ def download_zip
43
+ tmp = Tempfile.new(['ken_all', '.zip'])
44
+ tmp.binmode
45
+ tmp.close
46
+ path = tmp.path or raise 'Tempfile#path is nil'
47
+ fetch_to_path(path)
48
+ path
49
+ rescue SocketError, Timeout::Error, Errno::ENOENT => e
50
+ raise DownloadError, "ダウンロードに失敗しました: #{e.message}"
51
+ end
52
+
53
+ # @rbs (String path) -> void
54
+ def fetch_to_path(path)
55
+ uri = URI.parse(@url)
56
+ host = uri.host or raise DownloadError, "URL に host がありません: #{@url}"
57
+ port = uri.port || (uri.scheme == 'https' ? 443 : 80)
58
+ use_ssl = uri.scheme == 'https'
59
+ Net::HTTP.start(host, port, use_ssl: use_ssl, read_timeout: 60, open_timeout: 10) do |http|
60
+ request = Net::HTTP::Get.new(uri)
61
+ http.request(request) { |response| write_response_to_path(response, path) }
62
+ end
63
+ end
64
+
65
+ # @rbs (Net::HTTPResponse response, String path) -> void
66
+ def write_response_to_path(response, path)
67
+ raise DownloadError, "HTTP error: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
68
+
69
+ File.open(path, 'wb') do |f|
70
+ if response.respond_to?(:body_io)
71
+ IO.copy_stream(response.body_io, f)
72
+ else
73
+ # Net::HTTPResponse#body は stdlib RBS で型が不足することがある
74
+ body = response.body # steep:ignore
75
+ f.write(body)
76
+ end
77
+ end
78
+ end
79
+
80
+ # @rbs (String zip_path) -> String
81
+ def extract_zip(zip_path)
82
+ tmpdir = Dir.mktmpdir('jp_address_complement_ken_all')
83
+ tmpdir = File.expand_path(tmpdir) # 相対の場合は絶対パスに正規化(realpath はシンボリックリンク解決で別パスになるため使わない)
84
+ csv_path = extract_zip_entries(zip_path, tmpdir)
85
+ csv_path ||= find_csv_in_dir(tmpdir)
86
+ raise DownloadError, "ZIP 内に #{CSV_FILENAME} が見つかりません" if csv_path.nil?
87
+
88
+ csv_path
89
+ rescue Zip::Error => e
90
+ raise DownloadError, "ZIP の展開に失敗しました: #{e.message}"
91
+ end
92
+
93
+ # @rbs (String zip_path, String tmpdir) -> String?
94
+ def extract_zip_entries(zip_path, tmpdir)
95
+ csv_path = nil
96
+ Zip::File.open(zip_path) do |zip_file|
97
+ Dir.chdir(tmpdir) do
98
+ zip_file.each do |entry|
99
+ name = entry.name.delete_prefix('/')
100
+ dir = File.dirname(name)
101
+ FileUtils.mkdir_p(dir) unless dir == '.'
102
+ # RubyZip::Entry#extract(dest_path = nil) を使い、既存ファイルがあればブロックで上書き許可する。
103
+ entry.extract(name) { true } # steep:ignore
104
+ dest = File.join(tmpdir, name)
105
+ csv_path = dest if entry_csv?(name, dest)
106
+ end
107
+ end
108
+ end
109
+ csv_path
110
+ end
111
+
112
+ # @rbs (String entry_name, String dest) -> bool
113
+ def entry_csv?(entry_name, dest)
114
+ File.basename(entry_name) == CSV_FILENAME && File.file?(dest)
115
+ end
116
+
117
+ # @rbs (String tmpdir) -> String?
118
+ def find_csv_in_dir(tmpdir)
119
+ Dir.glob(File.join(tmpdir, '**', CSV_FILENAME)).find { |p| File.file?(p) }
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: disabled — 継承元が base_record_class で可変のため型は sig/manual/ で手動管理
4
+ require 'active_record'
5
+
6
+ module JpAddressComplement
7
+ # 住所テーブルに対応する ActiveRecord モデル
8
+ # 継承元は JpAddressComplement.base_record_class(未設定時は ActiveRecord::Base)
9
+ # テーブル名は JpAddressComplement.postal_code_table_name(未設定時は 'jp_address_complement_postal_codes')。initializer で変更可能。
10
+ class PostalCode < base_record_class
11
+ # initializer がモデル読み込み後に実行されるため、参照のたびに設定を読む
12
+ def self.table_name
13
+ JpAddressComplement.postal_code_table_name
14
+ end
15
+
16
+ validates :postal_code, presence: true, format: { with: /\A\d{7}\z/ }
17
+ validates :pref_code, presence: true
18
+ validates :pref, presence: true
19
+ validates :city, presence: true
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module JpAddressComplement
5
+ # 郵便番号の正規化処理を担当するクラス
6
+ # 全角→半角変換・〒記号除去・ハイフン除去を行う
7
+ class Normalizer
8
+ # 半角数字以外の文字を除去するためのパターン
9
+ DIGIT_ONLY = /\A\d{7}\z/ #: Regexp
10
+ PREFIX_MIN_LENGTH = 4 #: Integer
11
+
12
+ # 町域から除去する「通常住所に含まれない」固定文字列(漢字・かな両方)
13
+ TOWN_DISPLAY_REMOVAL_STRINGS = [
14
+ '以下に掲載がない場合',
15
+ 'イカニケイサイガナイバアイ' # 以下に掲載がない場合(カナ)
16
+ ].freeze
17
+
18
+ class << self
19
+ # 郵便番号文字列を正規化して7桁の半角数字文字列を返す
20
+ # @rbs (String?) -> String?
21
+ # @param code [String, nil] 郵便番号文字列(ハイフン・全角・〒記号を自動除去)
22
+ # @return [String, nil] 正規化後の7桁郵便番号。不正な場合は nil
23
+ def normalize_postal_code(code)
24
+ return nil if code.blank?
25
+
26
+ normalized = normalize_string(code)
27
+ return nil unless normalized.match?(DIGIT_ONLY)
28
+
29
+ normalized
30
+ end
31
+
32
+ # 郵便番号プレフィックスを正規化する
33
+ # @rbs (String?) -> String?
34
+ # @param prefix [String, nil] 郵便番号の先頭部分(4桁以上)
35
+ # @return [String, nil] 正規化後の数字文字列。4桁未満または不正な場合は nil
36
+ def normalize_prefix(prefix)
37
+ return nil if prefix.blank?
38
+
39
+ normalized = normalize_string(prefix)
40
+ return nil if normalized.empty?
41
+ return nil unless normalized.match?(/\A\d+\z/)
42
+ return nil if normalized.length < PREFIX_MIN_LENGTH
43
+
44
+ normalized
45
+ end
46
+
47
+ # 町域文字列から「通常住所に含まれない情報」を除いた表示用文字列を返す
48
+ # 除去対象: 「以下に掲載がない場合」、全角括弧()で囲まれた部分全体
49
+ # @rbs (String?) -> String?
50
+ # @param town_str [String, nil] 町域(漢字)または町域カナ
51
+ # @return [String, nil] 除去後の文字列。nil または空になった場合は nil
52
+ def normalize_town_for_display(town_str)
53
+ return nil if town_str.nil?
54
+
55
+ s = town_str.to_s.strip
56
+ return nil if s.empty?
57
+
58
+ # 全角括弧()で囲まれた部分をすべて除去
59
+ s = s.gsub(/([^)]*)/, '')
60
+ TOWN_DISPLAY_REMOVAL_STRINGS.each { |rem| s = s.gsub(rem, '') }
61
+ s = s.strip
62
+ s.empty? ? nil : s
63
+ end
64
+
65
+ private
66
+
67
+ # @rbs (String) -> String
68
+ def normalize_string(str)
69
+ str
70
+ .tr('〒', '')
71
+ .tr('0-9', '0-9')
72
+ .tr('-ー-', '')
73
+ .strip
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module JpAddressComplement
5
+ # JIS X 0401 都道府県コード(01–47)⇔都道府県名の変換
6
+ module Prefecture
7
+ # JIS X 0401 都道府県コード → 都道府県名(正式名称)
8
+ CODE_TO_NAME = {
9
+ '01' => '北海道',
10
+ '02' => '青森県',
11
+ '03' => '岩手県',
12
+ '04' => '宮城県',
13
+ '05' => '秋田県',
14
+ '06' => '山形県',
15
+ '07' => '福島県',
16
+ '08' => '茨城県',
17
+ '09' => '栃木県',
18
+ '10' => '群馬県',
19
+ '11' => '埼玉県',
20
+ '12' => '千葉県',
21
+ '13' => '東京都',
22
+ '14' => '神奈川県',
23
+ '15' => '新潟県',
24
+ '16' => '富山県',
25
+ '17' => '石川県',
26
+ '18' => '福井県',
27
+ '19' => '山梨県',
28
+ '20' => '長野県',
29
+ '21' => '岐阜県',
30
+ '22' => '静岡県',
31
+ '23' => '愛知県',
32
+ '24' => '三重県',
33
+ '25' => '滋賀県',
34
+ '26' => '京都府',
35
+ '27' => '大阪府',
36
+ '28' => '兵庫県',
37
+ '29' => '奈良県',
38
+ '30' => '和歌山県',
39
+ '31' => '鳥取県',
40
+ '32' => '島根県',
41
+ '33' => '岡山県',
42
+ '34' => '広島県',
43
+ '35' => '山口県',
44
+ '36' => '徳島県',
45
+ '37' => '香川県',
46
+ '38' => '愛媛県',
47
+ '39' => '高知県',
48
+ '40' => '福岡県',
49
+ '41' => '佐賀県',
50
+ '42' => '長崎県',
51
+ '43' => '熊本県',
52
+ '44' => '大分県',
53
+ '45' => '宮崎県',
54
+ '46' => '鹿児島県',
55
+ '47' => '沖縄県'
56
+ }.freeze
57
+
58
+ NAME_TO_CODE = CODE_TO_NAME.invert.freeze
59
+
60
+ # 都道府県コードから都道府県名を返す
61
+ # @rbs (String | Integer | nil) -> String?
62
+ # @param code [String, Integer, nil] 都道府県コード(01–47)。数値またはゼロパディング文字列
63
+ # @return [String, nil] 都道府県名。該当なし・不正入力時は nil
64
+ def self.name_from_code(code)
65
+ return nil if code.nil?
66
+ return nil if code.is_a?(String) && code.strip.empty?
67
+
68
+ key = normalize_code(code)
69
+ return nil unless key
70
+ return nil if key.to_i < 1 || key.to_i > 47
71
+
72
+ CODE_TO_NAME[key]
73
+ end
74
+
75
+ # 都道府県名(正式名称)から都道府県コードを2桁文字列で返す
76
+ # @rbs (String?) -> String?
77
+ # @param name [String, nil] 都道府県の正式名称
78
+ # @return [String, nil] 2桁のコード(例: "13")。該当なし時は nil
79
+ def self.code_from_name(name)
80
+ return nil if name.nil?
81
+ return nil if name.is_a?(String) && name.strip.empty?
82
+
83
+ NAME_TO_CODE[name]
84
+ end
85
+
86
+ def self.normalize_code(code)
87
+ case code
88
+ when Integer
89
+ (1..47).cover?(code) ? format('%02d', code) : nil
90
+ when String
91
+ normalize_code_string(code.strip)
92
+ end
93
+ end
94
+ private_class_method :normalize_code
95
+
96
+ def self.normalize_code_string(stripped)
97
+ return nil if stripped.empty?
98
+ return nil unless stripped.match?(/\A\d{1,2}\z/)
99
+
100
+ n = stripped.to_i
101
+ (1..47).cover?(n) ? format('%02d', n) : nil
102
+ end
103
+ private_class_method :normalize_code_string
104
+ end
105
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'rails'
5
+
6
+ module JpAddressComplement
7
+ # Rails との統合を担う Railtie
8
+ # @rbs inherits Rails::Railtie
9
+ class Railtie < Rails::Railtie
10
+ railtie_name :jp_address_complement
11
+
12
+ rake_tasks do
13
+ load File.expand_path('../tasks/jp_address_complement.rake', __dir__.to_s)
14
+ end
15
+
16
+ initializer 'jp_address_complement.setup_repository' do # steep:ignore
17
+ require_relative 'repositories/active_record_postal_code_repository'
18
+ require_relative 'models/postal_code'
19
+ JpAddressComplement.configuration.repository ||=
20
+ Repositories::ActiveRecordPostalCodeRepository.new
21
+ end
22
+
23
+ generators do
24
+ require_relative '../generators/jp_address_complement/install_generator'
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative 'postal_code_repository'
5
+ require_relative '../address_record'
6
+
7
+ module JpAddressComplement
8
+ module Repositories
9
+ # ActiveRecord を使用した PostalCodeRepository の実装
10
+ # Rails 環境向けにバンドルされる標準実装
11
+ class ActiveRecordPostalCodeRepository < PostalCodeRepository
12
+ # @rbs (String code) -> Array[AddressRecord]
13
+ # @param code [String] 正規化済み7桁郵便番号
14
+ # @return [Array<AddressRecord>]
15
+ def find_by_code(code)
16
+ postal_code_model.where(postal_code: code).map { |ar| to_record(ar) } # steep:ignore
17
+ end
18
+
19
+ # @rbs (String prefix) -> Array[AddressRecord]
20
+ # @param prefix [String] 4桁以上の郵便番号プレフィックス
21
+ # @return [Array<AddressRecord>]
22
+ def find_by_prefix(prefix)
23
+ postal_code_model.where('postal_code LIKE ?', "#{prefix}%").map { |ar| to_record(ar) } # steep:ignore
24
+ end
25
+
26
+ # @rbs (pref: String?, city: String?, ?town: String?) -> Array[AddressRecord]
27
+ def find_postal_codes_by_address(pref:, city:, town: nil)
28
+ relation = address_relation(pref: pref, city: city, town: town)
29
+ return [] unless relation
30
+
31
+ relation.map { |ar| to_record(ar) } # steep:ignore
32
+ end
33
+
34
+ private
35
+
36
+ # @rbs (pref: String?, city: String?, ?town: String?) -> untyped
37
+ def address_relation(pref:, city:, town: nil)
38
+ return nil if pref.nil? || pref.to_s.strip.empty?
39
+ return nil if city.nil? || city.to_s.strip.empty?
40
+
41
+ relation = postal_code_model.where(pref: pref, city: city)
42
+ return relation if town.blank?
43
+
44
+ pattern = "#{escape_like(town.to_s.strip)}%"
45
+ relation.where('town LIKE ?', pattern)
46
+ end
47
+
48
+ # @rbs () -> singleton(PostalCode)
49
+ def postal_code_model
50
+ JpAddressComplement::PostalCode
51
+ end
52
+
53
+ # LIKE 句用に % _ \ をエスケープする
54
+ # @rbs (String) -> String
55
+ def escape_like(str)
56
+ str.gsub(/[%_\\]/) { "\\#{Regexp.last_match(0)}" }
57
+ end
58
+
59
+ # ActiveRecord の動的属性のため Steep の型が不足する。理由明記で抑制(research §7)
60
+ # @rbs (untyped postal_code_ar) -> AddressRecord
61
+ def to_record(postal_code_ar)
62
+ AddressRecord.new(
63
+ postal_code: postal_code_ar.postal_code,
64
+ pref_code: postal_code_ar.pref_code,
65
+ pref: postal_code_ar.pref,
66
+ city: postal_code_ar.city,
67
+ town: postal_code_ar.town,
68
+ kana_pref: postal_code_ar.kana_pref,
69
+ kana_city: postal_code_ar.kana_city,
70
+ kana_town: postal_code_ar.kana_town,
71
+ has_alias: postal_code_ar.has_alias,
72
+ is_partial: postal_code_ar.is_partial,
73
+ is_large_office: postal_code_ar.is_large_office
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # rbs_inline: enabled
4
+
5
+ require 'csv'
6
+ require_relative 'postal_code_repository'
7
+ require_relative '../address_record'
8
+
9
+ module JpAddressComplement
10
+ module Repositories
11
+ # UTF-8 版 KEN_ALL(utf_ken_all.csv)形式の CSV ファイルを直接読み込んで検索を行う Repository 実装
12
+ #
13
+ # - ActiveRecord や DB に依存せず、純粋に CSV から AddressRecord を構築する
14
+ # - 初回アクセス時に CSV 全体を読み込み、インメモリでインデックスを構築する(2回目以降はメモリ検索のみ)
15
+ # - CSV は日本郵便公式の KEN_ALL.CSV(UTF-8 版 utf_ken_all.csv)と同じ列構成を前提とする
16
+ #
17
+ # 典型的な利用例:
18
+ #
19
+ # require 'jp_address_complement/repositories/csv_postal_code_repository'
20
+ #
21
+ # JpAddressComplement.configure do |c|
22
+ # c.repository = JpAddressComplement::Repositories::CsvPostalCodeRepository.new('/path/to/utf_ken_all.csv')
23
+ # end
24
+ #
25
+ class CsvPostalCodeRepository < PostalCodeRepository
26
+ # 列インデックス(KEN_ALL.CSV 形式)
27
+ COL_PREF_CODE = 0 # : Integer
28
+ COL_POSTAL_CODE = 2 # : Integer
29
+ COL_KANA_PREF = 3 # : Integer
30
+ COL_KANA_CITY = 4 # : Integer
31
+ COL_KANA_TOWN = 5 # : Integer
32
+ COL_PREF = 6 # : Integer
33
+ COL_CITY = 7 # : Integer
34
+ COL_TOWN = 8 # : Integer
35
+ COL_IS_PARTIAL = 9 # : Integer
36
+ COL_HAS_ALIAS = 12 # : Integer
37
+ COL_IS_LARGE_OFFICE = 13 # : Integer
38
+
39
+ # @rbs (String csv_path) -> void
40
+ # @param csv_path [String] 読み込む KEN_ALL 形式 UTF-8 CSV のパス
41
+ def initialize(csv_path)
42
+ super()
43
+ @csv_path = csv_path
44
+ @loaded = false
45
+ @records = [] # : Array[AddressRecord]
46
+ @by_code = Hash.new { |h, k| h[k] = [] } # : Hash[String, Array[AddressRecord]]
47
+ end
48
+
49
+ # @rbs (String code) -> Array[AddressRecord]
50
+ def find_by_code(code)
51
+ ensure_loaded
52
+ @by_code[code] || []
53
+ end
54
+
55
+ # @rbs (String prefix) -> Array[AddressRecord]
56
+ def find_by_prefix(prefix)
57
+ ensure_loaded
58
+ @records.select { |r| r.postal_code.start_with?(prefix) }
59
+ end
60
+
61
+ # @rbs (pref: String?, city: String?, ?town: String?) -> Array[AddressRecord]
62
+ def find_postal_codes_by_address(pref:, city:, town: nil)
63
+ ensure_loaded
64
+ return [] if blank?(pref) || blank?(city)
65
+
66
+ town_query = town&.to_s&.strip
67
+ pref_s = pref.to_s
68
+ city_s = city.to_s
69
+ @records.select { |record| address_match?(record, pref_s, city_s, town_query) }
70
+ end
71
+
72
+ private
73
+
74
+ # @rbs () -> void
75
+ def ensure_loaded
76
+ return if @loaded
77
+
78
+ load_csv
79
+ @loaded = true
80
+ end
81
+
82
+ # @rbs () -> void
83
+ def load_csv
84
+ validate_csv_path!
85
+ @records.clear
86
+ @by_code.clear
87
+ each_csv_row { |row| append_record(row) }
88
+ rescue Errno::ENOENT
89
+ raise JpAddressComplement::Error, "CSV ファイルが見つかりません: #{@csv_path}"
90
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, CSV::InvalidEncodingError => e
91
+ raise JpAddressComplement::Error, "CSV のエンコーディング変換に失敗しました: #{e.message}"
92
+ end
93
+
94
+ # @rbs () -> void
95
+ def validate_csv_path!
96
+ raise JpAddressComplement::Error, 'CSV ファイルが指定されていません' if @csv_path.nil? || @csv_path.to_s.empty?
97
+ raise JpAddressComplement::Error, "CSV ファイルが見つかりません: #{@csv_path}" unless File.exist?(@csv_path)
98
+ end
99
+
100
+ # @rbs () { (Array[String?]) -> void } -> void
101
+ def each_csv_row(&)
102
+ CSV.foreach(@csv_path, encoding: 'UTF-8', &)
103
+ end
104
+
105
+ # @rbs (Array[String?] row) -> void
106
+ def append_record(row)
107
+ record = build_record_from_row(row)
108
+ return if record.nil?
109
+
110
+ @records << record
111
+ @by_code[record.postal_code] << record
112
+ end
113
+
114
+ # @rbs (AddressRecord record, String pref, String city, String? town_query) -> bool
115
+ def address_match?(record, pref, city, town_query)
116
+ return false unless record.pref == pref && record.city == city
117
+ return true if town_query.nil? || town_query.empty?
118
+
119
+ record.town.to_s.start_with?(town_query)
120
+ end
121
+
122
+ # @rbs (Array[String?] row) -> (AddressRecord | nil)
123
+ def build_record_from_row(row)
124
+ attrs = parse_row_attrs(row)
125
+ return nil unless attrs
126
+
127
+ AddressRecord.new(
128
+ postal_code: attrs[:postal_code],
129
+ pref_code: attrs[:pref_code],
130
+ pref: attrs[:pref],
131
+ city: attrs[:city],
132
+ town: attrs[:town],
133
+ kana_pref: attrs[:kana_pref],
134
+ kana_city: attrs[:kana_city],
135
+ kana_town: attrs[:kana_town],
136
+ has_alias: attrs[:has_alias],
137
+ is_partial: attrs[:is_partial],
138
+ is_large_office: attrs[:is_large_office]
139
+ )
140
+ end
141
+
142
+ # @rbs (Array[String?] row) -> (Hash[Symbol, untyped] | nil)
143
+ def parse_row_attrs(row)
144
+ required = extract_required_fields(row)
145
+ return nil unless required && valid_postal_code_format?(required[0])
146
+
147
+ build_row_attrs(row, required[0], required[1], required[2], required[3])
148
+ end
149
+
150
+ # @rbs (Array[String?] row) -> (Array[String] | nil)
151
+ def extract_required_fields(row)
152
+ postal_code = strip_cell(row[COL_POSTAL_CODE])
153
+ pref_code = strip_cell(row[COL_PREF_CODE])&.slice(0, 2)
154
+ pref = strip_cell(row[COL_PREF])
155
+ city = strip_cell(row[COL_CITY])
156
+ return nil if [postal_code, pref_code, pref, city].any?(&:nil?)
157
+
158
+ # 上記ガードで nil は除外済み。型を Array[String] に合わせるため to_s で明示
159
+ [postal_code.to_s, pref_code.to_s, pref.to_s, city.to_s]
160
+ end
161
+
162
+ # @rbs (String? cell) -> (String | nil)
163
+ def strip_cell(cell)
164
+ cell&.strip
165
+ end
166
+
167
+ # @rbs (String? postal_code) -> bool
168
+ def valid_postal_code_format?(postal_code)
169
+ !postal_code.nil? && postal_code.match?(/\A\d{7}\z/)
170
+ end
171
+
172
+ # @rbs (Array[String?], String, String, String, String) -> Hash[Symbol, untyped]
173
+ def build_row_attrs(row, postal_code, pref_code, pref, city)
174
+ {
175
+ postal_code: postal_code,
176
+ pref_code: pref_code,
177
+ pref: pref,
178
+ city: city,
179
+ town: row[COL_TOWN]&.strip,
180
+ kana_pref: row[COL_KANA_PREF]&.strip,
181
+ kana_city: row[COL_KANA_CITY]&.strip,
182
+ kana_town: row[COL_KANA_TOWN]&.strip,
183
+ has_alias: flag?(row[COL_HAS_ALIAS]),
184
+ is_partial: flag?(row[COL_IS_PARTIAL]),
185
+ is_large_office: flag?(row[COL_IS_LARGE_OFFICE])
186
+ }
187
+ end
188
+
189
+ # @rbs (String? cell) -> bool
190
+ def flag?(cell)
191
+ cell&.strip == '1'
192
+ end
193
+
194
+ # @rbs (untyped value) -> bool
195
+ def blank?(value)
196
+ value.nil? || value.to_s.strip.empty?
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module JpAddressComplement
5
+ module Repositories
6
+ # データアクセスを抽象化するインターフェース基底クラス
7
+ # 具体的な実装は ActiveRecordPostalCodeRepository またはユーザー定義アダプターで行う
8
+ class PostalCodeRepository
9
+ # 7桁郵便番号で完全一致検索する
10
+ # @rbs (String code) -> Array[AddressRecord]
11
+ # @param code [String] 正規化済み7桁郵便番号
12
+ # @return [Array<AddressRecord>]
13
+ def find_by_code(code)
14
+ raise NotImplementedError, "#{self.class}#find_by_code を実装してください"
15
+ end
16
+
17
+ # 郵便番号プレフィックスで前方一致検索する
18
+ # @rbs (String prefix) -> Array[AddressRecord]
19
+ # @param prefix [String] 4桁以上の郵便番号プレフィックス
20
+ # @return [Array<AddressRecord>]
21
+ def find_by_prefix(prefix)
22
+ raise NotImplementedError, "#{self.class}#find_by_prefix を実装してください"
23
+ end
24
+
25
+ # 都道府県・市区町村・町域で検索する(逆引き)。町域は前方一致。
26
+ # @rbs (pref: String?, city: String?, ?town: String?) -> Array[AddressRecord]
27
+ # @param pref [String] 都道府県名(正式名称)
28
+ # @param city [String] 市区町村名
29
+ # @param town [String, nil] 町域名。省略時は都道府県+市区町村のみ。指定時は前方一致で候補を返す
30
+ # @return [Array<AddressRecord>] 該当レコードの配列。該当なし・入力不十分時は []
31
+ def find_postal_codes_by_address(pref:, city:, town: nil)
32
+ raise NotImplementedError, "#{self.class}#find_postal_codes_by_address を実装してください"
33
+ end
34
+ end
35
+ end
36
+ end