diff --git a/CLAUDE.md b/CLAUDE.md index 66e660f..d5abd0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ - `TestData/cases.tsv` と `v1-*.png` は 1.x(.NET Framework 版)の出力で、シンボル列と画像がこれとピクセル単位で一致することを確認している。1.x がマニュアルと異なっていたケース(補足ルール対象など)は除いてあり、それらは `SupplementaryRuleTests` でマニュアルの「バーコード・データ」を期待値にしている。出力が変わる修正をした場合は、マニュアルに照らして意図した変更か確認してから期待値を更新する。 - `cases.tsv` はタブ区切り(郵便番号・住所・シンボル列・画像ファイル名)。住所が空の行があるので、シェルで加工するときは連続するタブがまとめられないよう注意する。 - System.Drawing のテストは Windows 以外ではスキップされる。 +- `TestData/ken_all_sample.csv` は utf_ken_all.csv の形式で手書きしたテスト用データ(実データの抜粋ではない)。実際の郵便番号データ(約12万件)はリポジトリに含めない。 ## コード規約 diff --git a/README.md b/README.md index 8985b49..c7bf575 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,28 @@ CustomerBarCode code = BarCode.Create("1000013", "東京都千代田区霞が関 ただしこの場合は住所全体を住所Bとして扱うため、町域名に「丁目・番・ノ」などの前の漢数字を含む住所(一番町、八丁堀、三ノ輪 など)では、町域名の漢数字も抜き出されて誤ったバーコードになります。住所A・住所Bに分けて渡すことをおすすめします。 +### 郵便番号データを使う +日本郵便の[郵便番号データ(1レコード1行、UTF-8形式)](https://www.post.japanpost.jp/zipcode/dl/utf-zip.html)を読み込むと、郵便番号の町域名で住所を住所A・住所Bに自動で分割できます。住所全体を渡しても、町域名の漢数字(一番町、八丁堀など)を抜き出さなくなります。 + +データは同梱していないので、`utf_ken_all.zip` をダウンロードして指定してください(zip のままでも、展開した `utf_ken_all.csv` でも読み込めます)。データは毎月更新されます。 + +```csharp +// 読み込みには時間がかかるので、一度読み込んだものを使い回す +ZipCodeDictionary dictionary = ZipCodeDictionary.Load(@"c:\data\utf_ken_all.zip"); + +CustomerBarCode code = BarCode.Create("1020082", "東京都千代田区一番町5-3", dictionary); +// code.AddressNumber → "5-3"(郵便番号データなしでは "1-5-3") +``` + +- `ZipCodeDictionary` は破棄(`Dispose`)不要です。`Load(パス)` はファイルを読み終えると閉じます。`Load(Stream)` は渡された Stream を閉じないので、呼び出し側で破棄してください。 +- 読み込んだデータはメモリ上に保持します(全国分で約 22MB)。 +- 都道府県名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」の違いは区別しません。 +- 町域名が一致せず分割できない場合は、住所全体から抜き出します(郵便番号データなしと同じ)。分割できるかどうかは `dictionary.TrySplitAddress()` で確認できます。 +- 代表の郵便番号(町域名が「以下に掲載がない場合」)は町域名がわからないため、分割しません。 +- `IsRepresentativeZipCode` は郵便番号データに基づいて判定します。郵便番号データにない番号(郵便番号データより新しい番号など)は、下2けたで判定します。 +- 従来の Shift_JIS 形式(KEN_ALL.CSV)は、長い町域名が複数行に分かれているため対象外です。 +- 大口事業所の個別郵便番号は郵便番号データに含まれないため、分割できません。 + ### バーコードの内容 ```csharp string zip = code.ZipCode; // "1000013" @@ -48,7 +70,7 @@ bool rep = code.IsRepresentativeZipCode; // 代表の郵便番号か `AddressNumber` は 13 けたで切り捨てる前の値です。バーコードに含まれるのは先頭 13 けた分です。 -代表の郵便番号(郵便番号簿の「以下に掲載がない場合」の番号)の郵便物には、カスタマバーコードを付けないことになっています([マニュアル p18](https://www.post.japanpost.jp/zipcode/zipmanual/p18.html))。`IsRepresentativeZipCode` は、マニュアルの「原則として下2けたが 00」に従って判定します。 +代表の郵便番号(郵便番号簿の「以下に掲載がない場合」の番号)の郵便物には、カスタマバーコードを付けないことになっています([マニュアル p18](https://www.post.japanpost.jp/zipcode/zipmanual/p18.html))。`IsRepresentativeZipCode` は、郵便番号データを使わない場合はマニュアルの「原則として下2けたが 00」に従って判定する目安です。正確に判定するには郵便番号データを使ってください。 ### 印刷用の実寸 [マニュアル p12](https://www.post.japanpost.jp/zipcode/zipmanual/p12.html) の寸法(10 ポイントのときバー幅 0.6mm、バーピッチ 1.2mm、ロングバー 3.6mm)で出力します。大きさは 8〜11.5 ポイントで指定でき、省略時は 10 ポイントです。 diff --git a/ZipConvertCustomerBarCode.Tests/SupplementaryRuleTests.cs b/ZipConvertCustomerBarCode.Tests/SupplementaryRuleTests.cs index 2bdc7f2..5628cef 100644 --- a/ZipConvertCustomerBarCode.Tests/SupplementaryRuleTests.cs +++ b/ZipConvertCustomerBarCode.Tests/SupplementaryRuleTests.cs @@ -76,12 +76,19 @@ public class SupplementaryRuleTests Assert.Equal("1-2", ToBarCodeData(BarCode.Create("1100011", "東京都台東区三ノ輪", "1丁目2"))); } - [Fact] - public void 住所Bが住所Aで始まる場合は住所Aを取り除く() + [Theory] + [InlineData("1040032", "東京都中央区八丁堀", "東京都中央区八丁堀2丁目1-1", "2-1-1")] + // 空白、全角・半角、ヶ と ケ の違いは区別しない + [InlineData("1020082", "東京都千代田区一番町", "東京都 千代田区一番町5-3", "5-3")] + [InlineData("1020082", " 東京都千代田区一番町 ", "東京都千代田区一番町5-3", "5-3")] + [InlineData("2730102", "千葉県鎌ケ谷市右京塚", "千葉県鎌ヶ谷市右京塚東3丁目-20-5", "3-20-5")] + // 住所Aが空 + [InlineData("1040032", "", "2丁目1-1", "2-1-1")] + public void 住所Bが住所Aで始まる場合は住所Aを取り除く(string zipCode, string townAddress, string blockAddress, string expected) { - var code = BarCode.Create("1040032", "東京都中央区八丁堀", "東京都中央区八丁堀2丁目1-1"); + var code = BarCode.Create(zipCode, townAddress, blockAddress); - Assert.Equal("2-1-1", ToBarCodeData(code)); + Assert.Equal(expected, ToBarCodeData(code)); } [Fact] @@ -97,7 +104,7 @@ public class SupplementaryRuleTests public void 住所Aと住所Bがnullなら例外() { Assert.Throws(() => BarCode.Create("1020082", null!, "5-3")); - Assert.Throws(() => BarCode.Create("1020082", "東京都千代田区一番町", null!)); + Assert.Throws(() => BarCode.Create("1020082", "東京都千代田区一番町", (string)null!)); } // 住所表示番号のシンボルをバーコード・データの文字列に戻す(CC4 の埋め草は除く) diff --git a/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv b/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv new file mode 100644 index 0000000..6870a3e --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv @@ -0,0 +1,16 @@ +13101,"100 ","1000000","トウキョウト","チヨダク","イカニケイサイガナイバアイ","東京都","千代田区","以下に掲載がない場合",0,0,0,0,0,0 +13101,"100 ","1000013","トウキョウト","チヨダク","カスミガセキ","東京都","千代田区","霞が関",0,0,1,0,0,0 +13101,"102 ","1020082","トウキョウト","チヨダク","イチバンチョウ","東京都","千代田区","一番町",0,0,0,0,0,0 +13102,"104 ","1040032","トウキョウト","チュウオウク","ハッチョウボリ","東京都","中央区","八丁堀",0,0,1,0,0,0 +13106,"110 ","1100011","トウキョウト","タイトウク","ミノワ","東京都","台東区","三ノ輪",0,0,1,0,0,0 +01103,"060 ","0600906","ホッカイドウ","サッポロシヒガシク","キタ6ジョウヒガシ(1-7チョウメ)","北海道","札幌市東区","北六条東(1~7丁目)",1,0,1,0,0,0 +01103,"065 ","0650006","ホッカイドウ","サッポロシヒガシク","キタ6ジョウヒガシ(8-20チョウメ)","北海道","札幌市東区","北六条東(8~20丁目)",1,0,1,0,0,0 +12103,"263 ","2630023","チバケン","チバシイナゲク","ミドリチョウ","千葉県","千葉市稲毛区","緑町",0,0,1,0,0,0 +12224,"273 ","2730102","チバケン","カマガヤシ","ウキョウヅカ","千葉県","鎌ケ谷市","右京塚",0,0,0,0,0,0 +47356,"90136","9013601","オキナワケン","シマジリグントナキソン","トナキソンイチエン","沖縄県","島尻郡渡名喜村","渡名喜村一円",0,0,0,0,0,0 +08546,"30604","3060433","イバラキケン","サシマグンサカイマチ","サカイマチノツギニバンチガクルバアイ","茨城県","猿島郡境町","境町の次に番地がくる場合",0,0,0,0,0,0 +25443,"52223","5220341","シガケン","イヌカミグンタガチョウ","イチエン","滋賀県","犬上郡多賀町","一円",0,0,0,0,0,0 +99999,"999 ","9999901","テストケン","テストシ","ホンチョウ","テスト県","テスト市","本町",0,0,0,1,0,0 +99999,"999 ","9999901","テストケン","テストシ","ホンチョウキタ","テスト県","テスト市","本町北",0,0,0,1,0,0 +99999,"999 ","9999902","テストケン","テストシ","ニシ2ジョウ","テスト県","テスト市","西2条",0,0,1,0,0,0 +03202,"02702","0270203","イワテケン","ミヤコシ","ツガルイシ","岩手県","宮古市","津軽石",0,1,0,0,0,0 diff --git a/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs b/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs new file mode 100644 index 0000000..756164e --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs @@ -0,0 +1,206 @@ +using System.IO.Compression; +using System.Text; + +namespace ZipConvertCustomerBarCode.Tests +{ + // TestData/ken_all_sample.csv は utf_ken_all.csv の形式で書いたテスト用のデータ + // (実在の住所をもとにしているが、内容は郵便番号データと一致しない場合がある。999で始まる番号は架空) + public class ZipCodeDictionaryTests + { + private static readonly ZipCodeDictionary Dictionary = ZipCodeDictionary.Load(TestCases.DataPath("ken_all_sample.csv")); + + [Fact] + public void CSVファイルを読み込める() + { + Assert.Equal(15, Dictionary.Count); + } + + [Fact] + public void zipファイルのまま読み込める() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".zip"); + try + { + using (var archive = ZipFile.Open(path, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(TestCases.DataPath("ken_all_sample.csv"), "utf_ken_all.csv"); + } + + Assert.Equal(15, ZipCodeDictionary.Load(path).Count); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Streamから読み込める() + { + using var stream = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); + + Assert.Equal(15, ZipCodeDictionary.Load(stream).Count); + // 渡された Stream は閉じない + Assert.True(stream.CanRead); + } + + [Fact] + public void 列数が違えば例外() + { + var csv = "13101,\"100 \",\"1000013\",\"東京都\",\"千代田区\",\"霞が関\"\r\n"; + + var e = Assert.Throws(() => Load(Encoding.UTF8.GetBytes(csv))); + Assert.Contains("1行目", e.Message); + } + + [Fact] + public void UTF8でなければ例外() + { + // Shift_JIS の「東京都」 + var sjis = new byte[] { 0x93, 0x8C, 0x8B, 0x9E, 0x93, 0x73 }; + var line = Encoding.ASCII.GetBytes("13101,\"100 \",\"1000013\",\"x\",\"x\",\"x\",\"") + .Concat(sjis) + .Concat(Encoding.ASCII.GetBytes("\",\"x\",\"x\",0,0,1,0,0,0\r\n")) + .ToArray(); + + Assert.Throws(() => Load(line)); + } + + [Fact] + public void 郵便番号が7桁の数値でなければ例外() + { + var csv = "13101,\"100 \",\"100001\",\"x\",\"x\",\"x\",\"東京都\",\"千代田区\",\"霞が関\",0,0,1,0,0,0\r\n"; + + Assert.Throws(() => Load(Encoding.UTF8.GetBytes(csv))); + } + + [Theory] + [InlineData("1000000", true)] + [InlineData("100-0013", false)] + // 郵便番号データにない(事業所の個別番号など) + [InlineData("1008798", null)] + public void 代表の郵便番号を判定できる(string zipCode, bool? expected) + { + Assert.Equal(expected, Dictionary.IsRepresentative(zipCode)); + } + + [Fact] + public void 郵便番号の町域を取得できる() + { + Assert.True(Dictionary.Contains("1000-013")); + Assert.False(Dictionary.Contains("1008798")); + + var entry = Assert.Single(Dictionary.Find("1000013")); + Assert.Equal(("1000013", "東京都", "千代田区", "霞が関"), (entry.ZipCode, entry.Prefecture, entry.City, entry.Town)); + Assert.Equal(new[] { "本町", "本町北" }, Dictionary.Find("9999901").Select(x => x.Town)); + Assert.Empty(Dictionary.Find("1008798")); + } + + [Theory] + [InlineData("1020082", "東京都千代田区一番町5-3", "東京都千代田区一番町", "5-3")] + // 都道府県名の省略 + [InlineData("1020082", "千代田区一番町5-3", "千代田区一番町", "5-3")] + // 空白 + [InlineData("1020082", "東京都 千代田区 一番町 5-3", "東京都 千代田区 一番町", " 5-3")] + // ヶ と ケ + [InlineData("2730102", "千葉県鎌ヶ谷市右京塚 東3丁目-20-5", "千葉県鎌ヶ谷市右京塚", " 東3丁目-20-5")] + // 全角・半角 + [InlineData("9999902", "テスト県テスト市西2条3丁目", "テスト県テスト市西2条", "3丁目")] + // 町域名の括弧書き + [InlineData("0600906", "北海道札幌市東区北六条東4丁目 郵便センター6号館", "北海道札幌市東区北六条東", "4丁目 郵便センター6号館")] + // 町域名の前の「大字」「字」 + [InlineData("0270203", "岩手県宮古市大字津軽石第二地割原 郵便番号CD-1", "岩手県宮古市大字津軽石", "第二地割原 郵便番号CD-1")] + [InlineData("0270203", "宮古市字津軽石10", "宮古市字津軽石", "10")] + [InlineData("0270203", "岩手県宮古市津軽石10", "岩手県宮古市津軽石", "10")] + // ○○村一円 + [InlineData("9013601", "沖縄県島尻郡渡名喜村1917", "沖縄県島尻郡渡名喜村", "1917")] + // ○○町の次に番地がくる場合 + [InlineData("3060433", "茨城県猿島郡境町123", "茨城県猿島郡境町", "123")] + // 町域名が「一円」 + [InlineData("5220341", "滋賀県犬上郡多賀町一円12", "滋賀県犬上郡多賀町一円", "12")] + // 1つの郵便番号に複数の町域がある場合は長く一致した方 + [InlineData("9999901", "テスト県テスト市本町北1-2", "テスト県テスト市本町北", "1-2")] + [InlineData("9999901", "テスト市本町1-2", "テスト市本町", "1-2")] + public void 住所を町域名で分割できる(string zipCode, string address, string town, string block) + { + Assert.True(Dictionary.TrySplitAddress(zipCode, address, out var townAddress, out var blockAddress)); + Assert.Equal(town, townAddress); + Assert.Equal(block, blockAddress); + } + + [Theory] + // 郵便番号データにない + [InlineData("1008798", "東京都千代田区丸の内2-7-2")] + // 町域名が一致しない + [InlineData("1020082", "大阪府大阪市北区梅田1-1")] + // 代表の郵便番号(以下に掲載がない場合)は町域名がわからない + [InlineData("1000000", "東京都千代田区一番町5-3")] + public void 分割できない場合はfalse(string zipCode, string address) + { + Assert.False(Dictionary.TrySplitAddress(zipCode, address, out var townAddress, out var blockAddress)); + Assert.Null(townAddress); + Assert.Null(blockAddress); + } + + [Theory] + [InlineData("1020082", "東京都千代田区一番町5-3", "5-3")] + [InlineData("1040032", "東京都中央区八丁堀2丁目1-1", "2-1-1")] + [InlineData("1100011", "台東区三ノ輪1丁目2", "1-2")] + public void 郵便番号データを使うと町域名の漢数字を抜き出さない(string zipCode, string address, string expected) + { + Assert.Equal(expected, BarCode.Create(zipCode, address, Dictionary).AddressNumber); + // 郵便番号データなしでは町域名の漢数字も抜き出される + Assert.NotEqual(expected, BarCode.Create(zipCode, address).AddressNumber); + } + + [Fact] + public void 分割できない場合は住所全体から抜き出す() + { + var withDictionary = BarCode.Create("1020082", "大阪府大阪市北区梅田1-1", Dictionary); + var withoutDictionary = BarCode.Create("1020082", "大阪府大阪市北区梅田1-1"); + + Assert.Equal(withoutDictionary.Symbols, withDictionary.Symbols); + } + + [Theory] + [InlineData("1000000", true)] + [InlineData("1000013", false)] + // 郵便番号データにない場合(郵便番号データが古い場合など)は下2けたで判定する + [InlineData("3510100", true)] + [InlineData("1008798", false)] + public void 郵便番号データで代表の郵便番号を判定する(string zipCode, bool expected) + { + Assert.Equal(expected, BarCode.Create(zipCode, "1-1", Dictionary).IsRepresentativeZipCode); + } + + [Fact] + public void 代表の郵便番号では住所を分割しない() + { + var code = BarCode.Create("1000000", "東京都千代田区一番町5-3", Dictionary); + + Assert.Equal(BarCode.Create("1000000", "東京都千代田区一番町5-3").Symbols, code.Symbols); + Assert.True(code.IsRepresentativeZipCode); + } + + [Fact] + public void 郵便番号データなしでは下2けたで判定する() + { + Assert.True(BarCode.Create("3510100", "1-1").IsRepresentativeZipCode); + } + + [Fact] + public void 引数がnullなら例外() + { + Assert.Throws(() => ZipCodeDictionary.Load((string)null!)); + Assert.Throws(() => ZipCodeDictionary.Load((Stream)null!)); + Assert.Throws(() => BarCode.Create("1020082", "一番町5-3", (ZipCodeDictionary)null!)); + Assert.Throws(() => Dictionary.TrySplitAddress("1020082", null!, out _, out _)); + } + + private static ZipCodeDictionary Load(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + return ZipCodeDictionary.Load(stream); + } + } +} diff --git a/ZipConvertCustomerBarCode/AddressMatcher.cs b/ZipConvertCustomerBarCode/AddressMatcher.cs new file mode 100644 index 0000000..08e9f64 --- /dev/null +++ b/ZipConvertCustomerBarCode/AddressMatcher.cs @@ -0,0 +1,42 @@ +using System.Linq; + +namespace ZipConvertCustomerBarCode +{ + /// + /// 住所の前方一致 + /// 空白、全角・半角、「ヶ」と「ケ」の違いは区別しない + /// + internal static class AddressMatcher + { + /// + /// 住所の先頭が町域名までの住所と一致する場合、一致した部分の長さを返す + /// + /// 住所 + /// 町域名までの住所 + /// 一致した部分の長さ(住所の空白を含む)。一致しない場合は -1 + public static int MatchPrefix(string address, string prefix) + { + int i = 0; + foreach (var c in prefix.Where(x => !char.IsWhiteSpace(x))) + { + while (i < address.Length && char.IsWhiteSpace(address[i])) i++; + if (i >= address.Length || Normalize(address[i]) != Normalize(c)) return -1; + i++; + } + return i; + } + + /// + /// 照合用の文字の正規化 + /// + private static char Normalize(char c) + { + switch (c) + { + case 'ヶ': return 'ケ'; + case 'ヵ': return 'カ'; + default: return BarCode.ToHankaku(c); + } + } + } +} diff --git a/ZipConvertCustomerBarCode/BarCode.cs b/ZipConvertCustomerBarCode/BarCode.cs index 6a6b33f..871f774 100644 --- a/ZipConvertCustomerBarCode/BarCode.cs +++ b/ZipConvertCustomerBarCode/BarCode.cs @@ -88,6 +88,62 @@ public static CustomerBarCode Create(string zipCode, string address) if (address == null) throw new ArgumentNullException(nameof(address)); var zip = CheckPostCode(zipCode); + return CreateCore(zip, address, IsRepresentativeByRule(zip)); + } + + /// + /// 郵便番号データを使ってカスタマバーコードを生成する + /// + /// + /// 郵便番号データの町域名で住所を住所A(町域名まで)と住所B(町域名以降)に分割し、住所Bから住所表示番号を抜き出す。 + /// 分割できない場合(町域名の表記が一致しない場合など)は、住所全体を住所Bとして扱う。 + /// は郵便番号データに基づいて判定する。 + /// + /// 郵便番号(半角・全角数字7桁、ハイフンは可) + /// 住所 + /// 郵便番号データ + /// カスタマバーコード + /// 郵便番号が7桁の数値でない場合 + public static CustomerBarCode Create(string zipCode, string address, ZipCodeDictionary dictionary) + { + if (zipCode == null) throw new ArgumentNullException(nameof(zipCode)); + if (address == null) throw new ArgumentNullException(nameof(address)); + if (dictionary == null) throw new ArgumentNullException(nameof(dictionary)); + + var zip = CheckPostCode(zipCode); + var entries = dictionary.FindEntries(zip); + if (entries == null) + { + // 郵便番号データにない番号(事業所の個別番号、郵便番号データより新しい番号など)は + // 住所全体を住所Bとし、代表の郵便番号は下2けたで判定する + return CreateCore(zip, address, IsRepresentativeByRule(zip)); + } + + if (ZipCodeDictionary.TrySplitAddress(entries, address, out _, out var blockAddress)) + { + address = blockAddress; + } + return CreateCore(zip, address, entries.Any(x => x.IsRepresentative)); + } + + /// + /// 代表の郵便番号の判定(原則として下2けたが"00") + /// + /// 郵便番号(半角数字7桁) + private static bool IsRepresentativeByRule(string zip) + { + return zip.EndsWith("00", StringComparison.Ordinal); + } + + /// + /// カスタマバーコード生成 + /// + /// 郵便番号(半角数字7桁) + /// 住所B + /// 代表の郵便番号かどうか + /// カスタマバーコード + private static CustomerBarCode CreateCore(string zip, string address, bool isRepresentative) + { var addressNumber = ExtractAddressNumber(address); var customerCode = ConvertCCode(addressNumber); @@ -96,7 +152,7 @@ public static CustomerBarCode Create(string zipCode, string address) .Concat(zip.Select(x => x.ToString())) .Concat(customerCode) .Concat(new[] { CalcCheckDigit(zip.Select(x => x.ToString()), customerCode), endCode }); - return new CustomerBarCode(zip, addressNumber, symbols); + return new CustomerBarCode(zip, addressNumber, isRepresentative, symbols); } /// @@ -105,6 +161,7 @@ public static CustomerBarCode Create(string zipCode, string address) /// /// 住所表示番号は住所Bからのみ抜き出す。 /// 住所Bが住所Aで始まる場合(住所全体が渡された場合)は、住所Aの部分を取り除いてから抜き出す。 + /// 住所Aとの比較では、空白、全角・半角、「ヶ」と「ケ」の違いは区別しない。 /// http://www.post.japanpost.jp/zipcode/zipmanual/p17.html /// /// 郵便番号(半角・全角数字7桁、ハイフンは可) @@ -117,10 +174,10 @@ public static CustomerBarCode Create(string zipCode, string townAddress, string if (townAddress == null) throw new ArgumentNullException(nameof(townAddress)); if (blockAddress == null) throw new ArgumentNullException(nameof(blockAddress)); - var town = townAddress.Trim(); - if (town.Length > 0 && blockAddress.StartsWith(town, StringComparison.Ordinal)) + var end = AddressMatcher.MatchPrefix(blockAddress, townAddress); + if (end > 0) { - blockAddress = blockAddress.Substring(town.Length); + blockAddress = blockAddress.Substring(end); } return Create(zipCode, blockAddress); } @@ -131,10 +188,10 @@ public static CustomerBarCode Create(string zipCode, string townAddress, string /// /// 郵便番号 /// 郵便番号(半角数字7桁) - private static string CheckPostCode(string zipCode) + internal static string CheckPostCode(string zipCode) { // 全角→半角 - zipCode = string.Join("", zipCode.Select(n => (HankakuDic.ContainsKey(n) ? HankakuDic[n] : n))); + zipCode = string.Join("", zipCode.Select(ToHankaku)); // ハイフン除去 zipCode = zipCode.Replace("-", ""); @@ -148,6 +205,16 @@ private static string CheckPostCode(string zipCode) return zipCode; } + /// + /// 全角→半角(数字、アルファベット、空白、ハイフン) + /// + /// 文字 + /// 変換後の文字 + internal static char ToHankaku(char c) + { + return HankakuDic.TryGetValue(c, out var hankaku) ? hankaku : c; + } + /// /// 住所表示番号の抜き出し /// @@ -156,7 +223,7 @@ private static string CheckPostCode(string zipCode) private static string ExtractAddressNumber(string address) { // 全角→半角 - address = string.Join("", address.Select(n => (HankakuDic.ContainsKey(n) ? HankakuDic[n] : n))); + address = string.Join("", address.Select(ToHankaku)); // http://www.post.japanpost.jp/zipcode/zipmanual/p19.html // 1. まず、データ内にあるアルファベットの小文字は大文字に置き換えます。 diff --git a/ZipConvertCustomerBarCode/CustomerBarCode.cs b/ZipConvertCustomerBarCode/CustomerBarCode.cs index 9e97bd7..514b94a 100644 --- a/ZipConvertCustomerBarCode/CustomerBarCode.cs +++ b/ZipConvertCustomerBarCode/CustomerBarCode.cs @@ -37,10 +37,11 @@ public sealed class CustomerBarCode {"STC"," LD"},{"SPC","DL "} }; - internal CustomerBarCode(string zipCode, string addressNumber, IEnumerable symbols) + internal CustomerBarCode(string zipCode, string addressNumber, bool isRepresentativeZipCode, IEnumerable symbols) { ZipCode = zipCode; AddressNumber = addressNumber; + IsRepresentativeZipCode = isRepresentativeZipCode; // 呼び出し側から書き換えられないよう、コピーを読み取り専用で保持する Symbols = new ReadOnlyCollection(symbols.ToArray()); } @@ -57,14 +58,15 @@ internal CustomerBarCode(string zipCode, string addressNumber, IEnumerable - /// 代表の郵便番号かどうか(下2けたが"00") + /// 代表の郵便番号かどうか /// /// /// 代表の郵便番号(郵便番号簿の「以下に掲載がない場合」の番号)の郵便物には、カスタマバーコードを付けない。 - /// マニュアルでは代表の郵便番号は「原則として」下2けたが"00"とされているため、下2けたで判定している。 + /// 郵便番号データ()を渡して生成した場合は、郵便番号データに基づいて判定する。 + /// 渡さずに生成した場合は、マニュアルの「原則として下2けたが"00"」に従って判定する目安。 /// http://www.post.japanpost.jp/zipcode/zipmanual/p18.html /// - public bool IsRepresentativeZipCode => ZipCode.EndsWith("00", StringComparison.Ordinal); + public bool IsRepresentativeZipCode { get; } /// /// シンボル列 diff --git a/ZipConvertCustomerBarCode/ZipCodeDictionary.cs b/ZipConvertCustomerBarCode/ZipCodeDictionary.cs new file mode 100644 index 0000000..0bf32b1 --- /dev/null +++ b/ZipConvertCustomerBarCode/ZipCodeDictionary.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +namespace ZipConvertCustomerBarCode +{ + /// + /// 郵便番号データ + /// 日本郵便の「住所の郵便番号(1レコード1行、UTF-8形式)」(utf_ken_all.csv)を読み込む + /// + /// + /// データは同梱していないため、利用側でダウンロードして指定する。 + /// https://www.post.japanpost.jp/zipcode/dl/utf-zip.html + /// 従来の Shift_JIS 形式(KEN_ALL.CSV)は、町域名が複数レコードに分割されている場合があるため対象外。 + /// 大口事業所の個別郵便番号は含まれない。 + /// + public sealed class ZipCodeDictionary + { + // 列数 + private const int ColumnCount = 15; + + private readonly Dictionary entries; + + private ZipCodeDictionary(Dictionary entries) + { + this.entries = entries; + } + + /// + /// 郵便番号の件数 + /// + public int Count => entries.Count; + + /// + /// 郵便番号データを読み込む + /// + /// utf_ken_all.csv、またはそれを含む zip ファイル(utf_ken_all.zip)のパス + /// 郵便番号データ + /// 郵便番号データの形式が正しくない場合 + public static ZipCodeDictionary Load(string path) + { + if (path == null) throw new ArgumentNullException(nameof(path)); + + using (var file = File.OpenRead(path)) + { + if (!string.Equals(Path.GetExtension(path), ".zip", StringComparison.OrdinalIgnoreCase)) + { + return Load(file); + } + + using (var archive = new ZipArchive(file, ZipArchiveMode.Read)) + { + var csv = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException("zipファイルにCSVファイルがありません。"); + using (var stream = csv.Open()) + { + return Load(stream); + } + } + } + } + + /// + /// 郵便番号データを読み込む + /// + /// utf_ken_all.csv の内容(UTF-8)。読み込み後も閉じないため、呼び出し側で破棄する + /// 郵便番号データ + /// 郵便番号データの形式が正しくない場合 + public static ZipCodeDictionary Load(Stream stream) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + var entries = new Dictionary>(); + // 都道府県名・市区町村名は種類が少ないため、同じ文字列を共有してメモリを減らす + var names = new Dictionary(); + string Share(string name) + { + if (names.TryGetValue(name, out var shared)) return shared; + names.Add(name, name); + return name; + } + + using (var reader = new StreamReader(stream, new UTF8Encoding(false), true, 4096, true)) + { + string line; + int lineNumber = 0; + while ((line = reader.ReadLine()) != null) + { + lineNumber++; + if (line.Length == 0) continue; + + // 各項目は","区切りで、文字列項目は「"」で囲まれている(項目内に","はない) + var fields = line.Split(','); + if (fields.Length != ColumnCount) + { + throw InvalidFormat(lineNumber, "列数が15ではありません"); + } + // Shift_JIS のファイルを UTF-8 として読むと置換文字になる + if (line.IndexOf('�') >= 0) + { + throw InvalidFormat(lineNumber, "UTF-8 として読めない文字があります"); + } + + var zipCode = Unquote(fields[2]); + if (zipCode.Length != 7 || !zipCode.All(c => c >= '0' && c <= '9')) + { + throw InvalidFormat(lineNumber, "郵便番号が7桁の数値ではありません"); + } + + // 7:都道府県名 8:市区町村名 9:町域名(漢字) + var entry = new ZipCodeEntry(zipCode, Share(Unquote(fields[6])), Share(Unquote(fields[7])), Unquote(fields[8])); + if (!entries.TryGetValue(zipCode, out var list)) + { + list = new List(); + entries.Add(zipCode, list); + } + list.Add(entry); + } + } + return new ZipCodeDictionary(entries.ToDictionary(x => x.Key, x => x.Value.ToArray())); + } + + /// + /// 郵便番号データに含まれるかどうか + /// + /// 郵便番号(半角・全角数字7桁、ハイフンは可) + /// 郵便番号が7桁の数値でない場合 + public bool Contains(string zipCode) + { + if (zipCode == null) throw new ArgumentNullException(nameof(zipCode)); + return FindEntries(BarCode.CheckPostCode(zipCode)) != null; + } + + /// + /// 郵便番号の町域を返す + /// + /// 郵便番号(半角数字7桁) + /// 町域。郵便番号データにない場合は null + internal ZipCodeEntry[] FindEntries(string zip) + { + return entries.TryGetValue(zip, out var list) ? list : null; + } + + /// + /// 郵便番号の町域を返す(1つの郵便番号に複数の町域がある場合がある) + /// + /// 郵便番号(半角・全角数字7桁、ハイフンは可) + /// 町域(郵便番号データにない場合は空) + /// 郵便番号が7桁の数値でない場合 + public IReadOnlyList Find(string zipCode) + { + if (zipCode == null) throw new ArgumentNullException(nameof(zipCode)); + return Array.AsReadOnly(FindEntries(BarCode.CheckPostCode(zipCode)) ?? new ZipCodeEntry[0]); + } + + /// + /// 代表の郵便番号(町域名が「以下に掲載がない場合」)かどうか + /// + /// 郵便番号(半角・全角数字7桁、ハイフンは可) + /// 代表の郵便番号なら true。郵便番号データにない場合(事業所の個別番号など)は null + /// 郵便番号が7桁の数値でない場合 + public bool? IsRepresentative(string zipCode) + { + if (zipCode == null) throw new ArgumentNullException(nameof(zipCode)); + var list = FindEntries(BarCode.CheckPostCode(zipCode)); + if (list == null) return null; + return list.Any(x => x.IsRepresentative); + } + + /// + /// 郵便番号の町域名で、住所を住所A(町域名まで)と住所B(町域名以降)に分割する + /// + /// + /// 都道府県名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」の違いは区別しない。 + /// 町域名の括弧書き(「北六条東(1~7丁目)」の「(1~7丁目)」など)は除いて照合する。 + /// 代表の郵便番号(町域名が「以下に掲載がない場合」)は町域名がわからないため分割しない。 + /// + /// 郵便番号(半角・全角数字7桁、ハイフンは可) + /// 住所 + /// 住所A(分割できない場合は null) + /// 住所B(分割できない場合は null) + /// 分割できた場合は true。郵便番号データにない場合、代表の郵便番号の場合、町域名が一致しない場合は false + /// 郵便番号が7桁の数値でない場合 + public bool TrySplitAddress(string zipCode, string address, out string townAddress, out string blockAddress) + { + if (zipCode == null) throw new ArgumentNullException(nameof(zipCode)); + if (address == null) throw new ArgumentNullException(nameof(address)); + + var list = FindEntries(BarCode.CheckPostCode(zipCode)); + if (list == null) + { + townAddress = null; + blockAddress = null; + return false; + } + return TrySplitAddress(list, address, out townAddress, out blockAddress); + } + + /// + /// 町域名で住所を分割する + /// + /// 郵便番号の町域 + /// 住所 + /// 住所A(分割できない場合は null) + /// 住所B(分割できない場合は null) + /// 分割できた場合は true + internal static bool TrySplitAddress(ZipCodeEntry[] list, string address, out string townAddress, out string blockAddress) + { + townAddress = null; + blockAddress = null; + + // 最も長く一致した町域名で分割する + var end = list.SelectMany(x => x.GetTownAddressCandidates()) + .Select(x => AddressMatcher.MatchPrefix(address, x)) + .DefaultIfEmpty(-1) + .Max(); + if (end < 0) return false; + + townAddress = address.Substring(0, end).Trim(); + blockAddress = address.Substring(end); + return true; + } + + /// + /// 「"」で囲まれた項目から「"」を除く + /// + private static string Unquote(string field) + { + return field.Length >= 2 && field[0] == '"' && field[field.Length - 1] == '"' ? field.Substring(1, field.Length - 2) : field; + } + + /// + /// 形式エラー + /// + private static InvalidDataException InvalidFormat(int lineNumber, string reason) + { + return new InvalidDataException($"郵便番号データの{lineNumber}行目の形式が正しくありません({reason})。UTF-8形式の utf_ken_all.csv を指定してください。"); + } + } + + /// + /// 郵便番号データの1町域 + /// + public sealed class ZipCodeEntry + { + // 町域名の特殊な値 + // http://www.post.japanpost.jp/zipcode/dl/utf-readme.html + private const string NotListed = "以下に掲載がない場合"; + private const string FollowedByBlockNumber = "の次に番地がくる場合"; + private const string WholeCity = "一円"; + + internal ZipCodeEntry(string zipCode, string prefecture, string city, string town) + { + ZipCode = zipCode; + Prefecture = prefecture; + City = city; + Town = town; + } + + /// 郵便番号(半角数字7桁) + public string ZipCode { get; } + + /// 都道府県名 + public string Prefecture { get; } + + /// 市区町村名 + public string City { get; } + + /// 町域名(郵便番号データの表記のまま) + public string Town { get; } + + /// + /// 代表の郵便番号(町域名が「以下に掲載がない場合」)かどうか + /// + public bool IsRepresentative => Town == NotListed; + + /// + /// 照合用の町域名(特殊な値の場合は空、括弧書きは除く) + /// + internal string GetTownName() + { + // 「○○市の次に番地がくる場合」「○○町一円」は町域名がない + if (Town.EndsWith(FollowedByBlockNumber, StringComparison.Ordinal)) return ""; + // 「一円」という実在の町域名もあるため、市区町村名 + 一円 の場合のみ + if (Town.Length > WholeCity.Length && Town.EndsWith(WholeCity, StringComparison.Ordinal) + && City.EndsWith(Town.Substring(0, Town.Length - WholeCity.Length), StringComparison.Ordinal)) + { + return ""; + } + + // 「北六条東(1~7丁目)」などの括弧書きを除く + var paren = Town.IndexOf('('); + return paren >= 0 ? Town.Substring(0, paren) : Town; + } + + /// + /// 住所A(町域名までの住所)の候補 + /// 都道府県名ありとなし、町域名の前に「大字」「字」ありとなし(郵便番号データの町域名には付いていない) + /// + internal IEnumerable GetTownAddressCandidates() + { + // 「以下に掲載がない場合」は町域名がわからないため候補なし + if (IsRepresentative) yield break; + + var town = GetTownName(); + var towns = town.Length == 0 ? new[] { town } : new[] { town, "大字" + town, "字" + town }; + foreach (var t in towns) + { + yield return Prefecture + City + t; + yield return City + t; + } + } + } +}