diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e7f9b9a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shift_JIS のテスト用データは改行・文字コードを変換しない +ZipConvertCustomerBarCode.Tests/TestData/jigyosyo_sample.csv binary diff --git a/CLAUDE.md b/CLAUDE.md index d5abd0f..c5b67b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,21 +8,25 @@ ## 構成・ビルド -- ライブラリは 3 つ、すべて `netstandard2.0`(SDK スタイル)。ビルドは `dotnet build ZipConvertCustomerBarCode.sln`。 - - `ZipConvertCustomerBarCode`: コア。外部依存を追加しない(描画ライブラリに依存させないのが分割の目的)。 +- ライブラリは 3 つ(SDK スタイル)。ビルドは `dotnet build ZipConvertCustomerBarCode.sln`。 + - `ZipConvertCustomerBarCode`: コア。`netstandard2.0` と `net8.0`。外部依存を追加しない(描画ライブラリに依存させないのが分割の目的。`System.Memory` も入れない)。 + - `Span` などは `#if NET8_0_OR_GREATER` の中だけで使い、`#else` に `netstandard2.0` 用の処理を書く。`#if` は小さな範囲(メソッドや構造体の中)に閉じ込め、共通にできる部分は共通のメソッドにする。 + - `Span` を使うのは計測で効果があった箇所のみ(住所照合の正規化、CSV の読み込み)。`Span` を使わなくても両方で速くできる場合は、`#if` を使わず共通のコードにする。 - `ZipConvertCustomerBarCode.Drawing`: System.Drawing.Common による描画(Windows のみ)。 - `ZipConvertCustomerBarCode.SkiaSharp`: SkiaSharp による描画。名前空間は `SkiaSharp` との衝突を避けるため `ZipConvertCustomerBarCode.Skia`。 -- `netstandard2.0` の既定 C# は 7.3。`record` や null 許容参照型など新しい構文は使わない。 +- `netstandard2.0` の既定 C# は 7.3。`#if NET8_0_OR_GREATER` の外では `record` や null 許容参照型など新しい構文は使わない。 - バージョン・著作権表示は `Directory.Build.props` にまとめている。 - 公開メンバーには XML ドキュメントコメントを付ける(`GenerateDocumentationFile` 有効のため、ないと警告になる)。 ## テスト - `dotnet test` で実行(xUnit v3、net10.0)。`global.json` で Microsoft.Testing.Platform を指定しているため、`Microsoft.NET.Test.Sdk` や `xunit.runner.visualstudio` は追加しない。 +- コアは `#if` で処理が分かれるため、変更したら両方のビルドでテストする: `dotnet test`(net8.0 版)と `dotnet test -p:CoreTargetFramework=netstandard2.0`。`TargetFrameworkTests` が、指定したビルドが実際に使われていることを確認する。 - `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万件)はリポジトリに含めない。 +- `TestData/jigyosyo_sample.csv` は JIGYOSYO.CSV の形式で手書きしたテスト用データで、実データと同じく Shift_JIS・CRLF。編集するときは UTF-8 で書いて `iconv -f UTF-8 -t CP932` で変換する(`.gitattributes` で binary 指定)。 ## コード規約 diff --git a/README.md b/README.md index c7bf575..b476ccf 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [郵便局 - 郵便番号・バーコードマニュアル](http://www.post.japanpost.jp/zipcode/zipmanual/index.html) ## 構成 -すべて .NET Standard 2.0 のライブラリです。描画方法に合わせてパッケージを選んでください。 +.NET Standard 2.0 のライブラリです(コアは .NET 8 以降向けのビルドも含み、住所の照合や郵便番号データの読み込みでメモリの割り当てが少なくなります)。描画方法に合わせてパッケージを選んでください。 | プロジェクト | 依存 | 用途 | | --- | --- | --- | @@ -53,13 +53,65 @@ CustomerBarCode code = BarCode.Create("1020082", "東京都千代田区一番町 ``` - `ZipCodeDictionary` は破棄(`Dispose`)不要です。`Load(パス)` はファイルを読み終えると閉じます。`Load(Stream)` は渡された Stream を閉じないので、呼び出し側で破棄してください。 -- 読み込んだデータはメモリ上に保持します(全国分で約 22MB)。 -- 都道府県名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」の違いは区別しません。 +- 読み込んだデータはメモリ上に保持します(全国分で約 24MB、事業所の個別郵便番号データも読み込むと約 44MB)。 +- 都道府県名・郡名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、英字の大文字・小文字、「ヶ」と「ケ」、漢数字と算用数字(北六条 と 北6条)の違いは区別しません。 - 町域名が一致せず分割できない場合は、住所全体から抜き出します(郵便番号データなしと同じ)。分割できるかどうかは `dictionary.TrySplitAddress()` で確認できます。 - 代表の郵便番号(町域名が「以下に掲載がない場合」)は町域名がわからないため、分割しません。 - `IsRepresentativeZipCode` は郵便番号データに基づいて判定します。郵便番号データにない番号(郵便番号データより新しい番号など)は、下2けたで判定します。 - 従来の Shift_JIS 形式(KEN_ALL.CSV)は、長い町域名が複数行に分かれているため対象外です。 -- 大口事業所の個別郵便番号は郵便番号データに含まれないため、分割できません。 +### 事業所の個別郵便番号データを使う +日本郵便の[事業所の個別郵便番号データ](https://www.post.japanpost.jp/zipcode/dl/jigyosyo/index-zip.html)(`jigyosyo.zip`、Shift_JIS)も一緒に読み込むと、大口事業所・私書箱の個別郵便番号を扱えます。 + +```csharp +// Shift_JIS を使えるようにする(.NET Core / .NET 5 以降。.NET Framework では不要) +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + +ZipCodeDictionary dictionary = ZipCodeDictionary.Load(@"c:\data\utf_ken_all.zip", @"c:\data\jigyosyo.zip"); + +// 個別郵便番号のバーコードは、住所表示番号を CC4 で埋める +CustomerBarCode code = BarCode.Create("1008792", "東京都千代田区大手町2-3-1", dictionary); +// code.AddressNumber → "" +``` + +- [マニュアル p22](https://www.post.japanpost.jp/zipcode/zipmanual/p22.html) に従い、個別郵便番号あての郵便物は個別番号のみをバーコード化し、住所表示番号の 13 けたは CC4 で埋めます。事業所の個別郵便番号データを読み込んでいない場合は、住所から住所表示番号を抜き出してしまうので注意してください。 +- `dictionary.Find(郵便番号)` で、事業所名(`BusinessName`)、番地等(`Block`)、種類(`Kind`:大口事業所・私書箱)を取得できます。 +- `Encoding.RegisterProvider` を呼ばずに読み込むと `InvalidOperationException` になります。Shift_JIS を自分で用意する場合は `Load(Stream, Stream, Encoding)` を使ってください。 + +### 住所から郵便番号を探す +郵便番号データを使うと、住所から郵便番号を探せます。 + +```csharp +if (dictionary.TryGetZipCode("東京都千代田区一番町5-3", out var zipCode)) +{ + // zipCode → "1020082" + CustomerBarCode code = BarCode.Create(zipCode, "東京都千代田区一番町5-3", dictionary); +} + +// 候補をすべて取得する(郵便番号が1つに決まらない場合は複数) +IReadOnlyList candidates = dictionary.FindByAddress("北海道深川市音江町123"); + +// 事業所名も渡すと、大口事業所・私書箱の個別郵便番号を探せる(事業所の個別郵便番号データが必要) +dictionary.TryGetZipCode("東京都千代田区大手町2-3-1", "日本郵便株式会社", out var businessZipCode); // → "1008792" +``` + +- 都道府県名・市区町村名・町域名の順に照合し、最も長く一致した町域を返します。表記の違いは住所の分割と同じく区別しません。 +- 同じ町域名で郵便番号が複数ある場合は、郵便番号データの括弧書きで絞り込みます。 + - 超高層ビル(「霞が関ビル(10階)」など):住所の町域名より後ろにビル名があれば、その後の階(「10階」「10F」)で選びます。階がない場合、地下の場合、データにない階の場合は「(地階・階層不明)」、ビル名がなければ「(次のビルを除く)」などを使います。 + - 地区名(「音江町(稲田)」「音江町(国見23〜298番地)」など):町域名の直後(前に「字」があってもよい)の地区名で選びます。 + - 丁目・番地の範囲(「北六条東(1〜7丁目)」「常盤(1〜131番地)」など):町域名(または地区名)の直後の丁目・番地で選びます。範囲に当てはまらない場合は「(その他)」を使います。 +- 同名の市区町村(府中市など)は、都道府県名がなければ町域名まで一致した方を使います。 +- 町域名が一致しない場合は、マニュアルに従って代表の郵便番号(「以下に掲載がない場合」)を返します(`ZipCodeEntry.IsRepresentative` が `true`)。代表の郵便番号の郵便物にはカスタマバーコードを付けません。 +- 事業所名を渡した場合は、住所の市区町村にある事業所から事業所名が一致するもの(空白、全角・半角、英字の大文字・小文字、「株式会社」「(株)」などの違いは区別しない)を探します。一致しなければ、一方が他方を含む事業所のうち、住所が番地等まで一致するもの(支店名の省略など)を探します。同じ事業所名が複数あれば番地等まで一致するものを使い、見つからなければ住所だけで探します。 +- `TryGetZipCode` は郵便番号が 1 つに決まった場合だけ `true` を返します。決まらない場合は `FindByAddress` で候補を確認してください。 + +次の住所は郵便番号を決められないか、正しく探せません。 +- 京都市の通り名を含む住所(「烏丸通御池上る二条殿町」など)は、町域名の前に通り名があるため町域名が一致せず、代表の郵便番号になります。 +- 地区名が書かれていない住所や、郵便番号データの括弧書きを解釈できない町域は、候補が複数になります。 +- 1 つの事業所が複数の個別郵便番号を持つ場合は、候補が複数になります。 + +全国の郵便番号データ(2026年10月更新)で、データから組み立てた住所で試した結果です。実際の住所の書き方の揺れまでは確かめていません。 +- 町域(代表の郵便番号以外の 122,657 町域、「都道府県 + 市区町村 + 町域 + 番地」。超高層ビルは「…番地 + ビル名 + 階」、地区名は「町域 + 地区名 + 番地」):99.2% で正しい郵便番号に 1 つに決まり、0.7% は候補が複数で正しい郵便番号を含んでいました(残り 0.04% は、組み立てた住所の番地が同じ町域の別の郵便番号の範囲に入ったものなどで、その郵便番号が返ります)。 +- 事業所(22,420 件、「データの住所 + 事業所名」):99.6% で正しい個別郵便番号に 1 つに決まり、残りは候補が複数で正しい個別郵便番号を含んでいました(ほとんどは複数の個別郵便番号を持つ事業所)。 ### バーコードの内容 ```csharp @@ -132,7 +184,4 @@ foreach (var bar in code.GetBars()) | 1.x | 2.x | | --- | --- | | `BarCode.CreateImage(zip, address)` | `BarCode.Create(zip, address).ToBitmap()`(Drawing) | -| `BarCode.Export(zip, address, path)` | `BarCode.Create(zip, address).Save(path, ImageFormat.Gif)`(Drawing) | - -## 備考 -* 住所→郵便番号変換辞書検討中(郵便番号の入力が不要に) +| `BarCode.Export(zip, address, path)` | `BarCode.Create(zip, address).Save(path, ImageFormat.Gif)`(Drawing) | \ No newline at end of file diff --git a/ZipConvertCustomerBarCode.Tests/AddressToZipCodeTests.cs b/ZipConvertCustomerBarCode.Tests/AddressToZipCodeTests.cs new file mode 100644 index 0000000..2ebb74f --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/AddressToZipCodeTests.cs @@ -0,0 +1,138 @@ +namespace ZipConvertCustomerBarCode.Tests +{ + // 住所 → 郵便番号 + // TestData/ken_all_sample.csv はテスト用に書いたデータ(内容は郵便番号データと一致しない場合がある) + public class AddressToZipCodeTests + { + private static readonly ZipCodeDictionary Dictionary = ZipCodeDictionary.Load(TestCases.DataPath("ken_all_sample.csv")); + + [Theory] + [InlineData("東京都千代田区霞が関1丁目3番2号 郵便プラザ503室", "1000013")] + [InlineData("千代田区一番町5-3", "1020082")] + [InlineData("東京都 中央区 八丁堀 2丁目1-1", "1040032")] + // 「ヶ」と「ケ」 + [InlineData("千葉県鎌ヶ谷市右京塚東3丁目-20-5", "2730102")] + // 「大字」 + [InlineData("岩手県宮古市大字津軽石第二地割原", "0270203")] + // 丁目の範囲 + [InlineData("北海道札幌市東区北六条東4丁目 郵便センター6号館", "0600906")] + [InlineData("北海道札幌市東区北六条東8丁目", "0650006")] + // 町域名の漢数字を算用数字で書く + [InlineData("札幌市東区北6条東8丁目", "0650006")] + [InlineData("札幌市東区北六条東十丁目", "0650006")] + // 番地の範囲と「その他」 + [InlineData("北海道札幌市南区常盤131番地", "0050865")] + [InlineData("北海道札幌市南区常盤132番地", "0050863")] + [InlineData("北海道札幌市南区常盤", "0050863")] + // 丁目の範囲(地区名の町域もある) + [InlineData("深川市音江町2丁目3", "0741273")] + // 郡名の省略 + [InlineData("東神楽町ひじり野南1条1丁目", "0711503")] + [InlineData("北海道上川郡東神楽町ひじり野南一条1丁目", "0711503")] + // 同名の市区町村(都道府県名なし)は町域名まで一致した方 + [InlineData("府中市宮西町1-1", "1830022")] + // ○○村一円、○○町の次に番地がくる場合 + [InlineData("沖縄県島尻郡渡名喜村1917", "9013601")] + [InlineData("茨城県猿島郡境町123", "3060433")] + // 1つの郵便番号に複数の町域 + [InlineData("北海道テスト市本町北1-2", "9999901")] + // 町域名が一致しない場合は代表の郵便番号 + [InlineData("東京都千代田区存在しない町1-1", "1000000")] + public void 住所から郵便番号を探せる(string address, string expected) + { + Assert.True(Dictionary.TryGetZipCode(address, out var zipCode)); + Assert.Equal(expected, zipCode); + } + + [Fact] + public void 地区名が書かれていなければ決めない() + { + // どの地区(稲田、音江、国見)か住所からはわからない(123は「1~2丁目」の範囲外) + Assert.False(Dictionary.TryGetZipCode("深川市音江町123", out var zipCode)); + Assert.Null(zipCode); + Assert.Equal(new[] { "0741164", "0741272", "0741276", "0741277" }, Dictionary.FindByAddress("深川市音江町123").Select(x => x.ZipCode).Distinct().OrderBy(x => x)); + } + + [Theory] + [InlineData("深川市音江町稲田123", "0741276")] + [InlineData("深川市音江町字音江123", "0741277")] + // 地区名の後の数値は町域名の「1~2丁目」と比べない + [InlineData("深川市音江町稲田1", "0741276")] + // 地区名 + 番地の範囲、地区名 + その他 + [InlineData("深川市音江町国見100番地", "0741164")] + [InlineData("深川市音江町国見500番地", "0741272")] + [InlineData("深川市音江町国見", "0741272")] + public void 地区名で絞り込める(string address, string expected) + { + Assert.True(Dictionary.TryGetZipCode(address, out var zipCode)); + Assert.Equal(expected, zipCode); + } + + [Theory] + [InlineData("東京都千代田区霞が関3-2-5 霞が関ビル10階", "1006010")] + [InlineData("東京都千代田区霞が関3丁目2番5号 霞が関ビル 1F", "1006001")] + // 階がない、地下、データにない階は「(地階・階層不明)」 + [InlineData("東京都千代田区霞が関3-2-5 霞が関ビル", "1006090")] + [InlineData("東京都千代田区霞が関3-2-5 霞が関ビル地下1階", "1006090")] + [InlineData("東京都千代田区霞が関3-2-5 霞が関ビル99階", "1006090")] + // ビル名がなければ「(次のビルを除く)」 + [InlineData("東京都千代田区霞が関1-3-2", "1000013")] + public void 超高層ビルは階で絞り込める(string address, string expected) + { + Assert.True(Dictionary.TryGetZipCode(address, out var zipCode)); + Assert.Equal(expected, zipCode); + } + + [Theory] + // 英字の大文字・小文字、全角・半角は区別しない + [InlineData("東京都千代田区霞が関1-1-1 ABCタワー1階")] + [InlineData("東京都千代田区霞が関1-1-1 abcタワー1F")] + [InlineData("東京都千代田区霞が関1-1-1 abcタワー1f")] + public void ビル名の英字の大文字小文字は区別しない(string address) + { + Assert.True(Dictionary.TryGetZipCode(address, out var zipCode)); + Assert.Equal("1006101", zipCode); + } + + [Fact] + public void 同名の市区町村は町域名がより長く一致した方() + { + // 「テスト村一円」(北海道)は町域名なしで一致、「本町」(青森県)は町域名まで一致 + Assert.True(Dictionary.TryGetZipCode("テスト村本町1-2", out var zipCode)); + Assert.Equal("9999912", zipCode); + + // 町域名が一致しない場合は、町域名のない「テスト村一円」 + Assert.True(Dictionary.TryGetZipCode("テスト村北町1-2", out zipCode)); + Assert.Equal("9999911", zipCode); + } + + [Theory] + [InlineData("大阪府大阪市北区梅田1-1")] + [InlineData("")] + public void 見つからない場合は空(string address) + { + Assert.Empty(Dictionary.FindByAddress(address)); + Assert.False(Dictionary.TryGetZipCode(address, out var zipCode)); + Assert.Null(zipCode); + } + + [Fact] + public void 見つかった郵便番号でバーコードを生成できる() + { + const string address = "東京都千代田区一番町5-3"; + Assert.True(Dictionary.TryGetZipCode(address, out var zipCode)); + + var code = BarCode.Create(zipCode!, address, Dictionary); + + Assert.Equal("1020082", code.ZipCode); + Assert.Equal("5-3", code.AddressNumber); + } + + [Fact] + public void 引数がnullなら例外() + { + Assert.Throws(() => Dictionary.FindByAddress(null!)); + Assert.Throws(() => Dictionary.TryGetZipCode(null!, out _)); + } + } +} diff --git a/ZipConvertCustomerBarCode.Tests/BusinessZipCodeTests.cs b/ZipConvertCustomerBarCode.Tests/BusinessZipCodeTests.cs new file mode 100644 index 0000000..c4cff9e --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/BusinessZipCodeTests.cs @@ -0,0 +1,161 @@ +using System.Text; + +namespace ZipConvertCustomerBarCode.Tests +{ + // 事業所の個別郵便番号 + // TestData/jigyosyo_sample.csv は JIGYOSYO.CSV の形式(Shift_JIS)で書いたテスト用のデータ + public class BusinessZipCodeTests + { + private static readonly ZipCodeDictionary Dictionary = LoadWithBusinesses(); + + private static ZipCodeDictionary LoadWithBusinesses() + { + // Shift_JIS を使えるようにする(.NET Core / .NET 5 以降) + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return ZipCodeDictionary.Load(TestCases.DataPath("ken_all_sample.csv"), TestCases.DataPath("jigyosyo_sample.csv")); + } + + [Fact] + public void 事業所の個別郵便番号データを読み込める() + { + // 町域31件 + 事業所7件(廃止の1件は除く) + Assert.Equal(38, Dictionary.Count); + Assert.False(Dictionary.Contains("1008799")); + } + + [Fact] + public void Streamから読み込める() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + using var towns = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); + using var businesses = File.OpenRead(TestCases.DataPath("jigyosyo_sample.csv")); + + var dictionary = ZipCodeDictionary.Load(towns, businesses, Encoding.GetEncoding(932)); + + Assert.Equal(38, dictionary.Count); + Assert.True(businesses.CanRead); + } + + [Fact] + public void 個別郵便番号の事業所を取得できる() + { + var business = Assert.Single(Dictionary.Find("1008792")); + Assert.Equal(ZipCodeKind.Business, business.Kind); + Assert.True(business.IsIndividual); + Assert.Equal("日本郵便 株式会社", business.BusinessName); + Assert.Equal(("東京都", "千代田区", "大手町", "2丁目3-1"), (business.Prefecture, business.City, business.Town, business.Block)); + Assert.False(Dictionary.IsRepresentative("1008792")); + + Assert.Equal(ZipCodeKind.PostOfficeBox, Assert.Single(Dictionary.Find("1008644")).Kind); + Assert.False(Assert.Single(Dictionary.Find("1000013")).IsIndividual); + } + + [Theory] + [InlineData("東京都千代田区大手町2-3-1", "日本郵便株式会社", "1008792")] + // 空白、全角・半角、「株式会社」と「(株)」 + [InlineData("千代田区大手町2丁目3-1", "日本郵便(株)", "1008792")] + [InlineData("東京都千代田区大手町2-3-1", "日本郵便", "1008792")] + // 事業所名の一部 + [InlineData("栃木県宇都宮市清原工業団地20-2", "テスト工業", "3213292")] + // 同じ事業所名は番地等で選ぶ + [InlineData("栃木県宇都宮市清原工業団地22-2", "テスト工業株式会社宇都宮事業所", "3213294")] + [InlineData("東京都千代田区大手町1丁目7-2", "テスト新聞社", "1008077")] + // 番地等が最も長く一致した事業所(私書箱) + [InlineData("東京都千代田区大手町1丁目7-2(銀座郵便局私書箱第433号)", "テスト新聞社", "1008644")] + public void 住所と事業所名から個別郵便番号を探せる(string address, string businessName, string expected) + { + Assert.True(Dictionary.TryGetZipCode(address, businessName, out var zipCode)); + Assert.Equal(expected, zipCode); + } + + [Fact] + public void 複数の個別番号を持つ事業所は決めない() + { + Assert.False(Dictionary.TryGetZipCode("東京都千代田区大手町1丁目1-1", "テスト銀行", out var zipCode)); + Assert.Null(zipCode); + Assert.Equal(new[] { "1008701", "1008702" }, Dictionary.FindByAddress("東京都千代田区大手町1丁目1-1", "テスト銀行").Select(x => x.ZipCode)); + } + + [Fact] + public void 事業所名の一部の一致は番地等まで一致する場合のみ() + { + // 「テスト」は「テスト工業宇都宮事業所」に含まれるが、番地等が一致しないため別の事業所とみなす + Assert.DoesNotContain(Dictionary.FindByAddress("栃木県宇都宮市清原工業団地99-9", "テスト"), x => x.IsIndividual); + Assert.DoesNotContain(Dictionary.FindByAddress("東京都千代田区大手町3-3-3", "テスト"), x => x.IsIndividual); + + // 事業所名が一致する場合は、番地等が一致しなくても候補にする + Assert.Equal(new[] { "3213292", "3213294" }, + Dictionary.FindByAddress("栃木県宇都宮市清原工業団地99-9", "テスト工業株式会社宇都宮事業所").Select(x => x.ZipCode).OrderBy(x => x)); + } + + [Fact] + public void 事業所名の英字の大文字小文字は区別しない() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var csv = "13101,\"エヌテイーテイー\",\"NTTテスト 株式会社\",\"東京都\",\"千代田区\",\"大手町\",\"1丁目5-1\",\"1008116\",\"100 \",\"銀座\",0,0,0\r\n"; + using var towns = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); + using var businesses = new MemoryStream(Encoding.GetEncoding(932).GetBytes(csv)); + var dictionary = ZipCodeDictionary.Load(towns, businesses, Encoding.GetEncoding(932)); + + Assert.True(dictionary.TryGetZipCode("東京都千代田区大手町1-5-1", "ntt テスト", out var zipCode)); + Assert.Equal("1008116", zipCode); + } + + [Fact] + public void 事業所が見つからなければ住所だけで探す() + { + Assert.True(Dictionary.TryGetZipCode("東京都千代田区霞が関1-3-2", "存在しない会社", out var zipCode)); + Assert.Equal("1000013", zipCode); + + // 事業所の個別郵便番号データを読み込んでいない場合 + var townsOnly = ZipCodeDictionary.Load(TestCases.DataPath("ken_all_sample.csv")); + Assert.True(townsOnly.TryGetZipCode("東京都千代田区霞が関1-3-2", "日本郵便", out zipCode)); + Assert.Equal("1000013", zipCode); + } + + [Fact] + public void 住所だけでは個別郵便番号を返さない() + { + Assert.DoesNotContain(Dictionary.FindByAddress("東京都千代田区大手町2-3-1"), x => x.IsIndividual); + } + + [Fact] + public void 個別郵便番号のバーコードは住所表示番号をCC4で埋める() + { + // マニュアル p22: 個別番号のみをバーコード化し、住所表示番号の13けたは CC4 で埋める + var code = BarCode.Create("1008792", "東京都千代田区大手町2-3-1", Dictionary); + + Assert.Equal("", code.AddressNumber); + Assert.All(code.Symbols.Skip(8).Take(13), x => Assert.Equal("CC4", x)); + Assert.False(code.IsRepresentativeZipCode); + + // 私書箱も同じ + Assert.Equal("", BarCode.Create("1008644", "東京都千代田区大手町1丁目7-2", Dictionary).AddressNumber); + } + + [Fact] + public void 形式が正しくなければ例外() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + using var towns = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); + using var businesses = new MemoryStream(Encoding.ASCII.GetBytes("13101,\"x\",\"x\",\"x\"\r\n")); + + var e = Assert.Throws(() => ZipCodeDictionary.Load(towns, businesses, Encoding.GetEncoding(932))); + Assert.Contains("事業所の個別郵便番号データの1行目", e.Message); + } + + [Fact] + public void 文字コードが違えば例外() + { + // UTF-8 に変換した JIGYOSYO.CSV を Shift_JIS として読み込む + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var sjis = Encoding.GetEncoding(932); + var utf8 = Encoding.UTF8.GetBytes(sjis.GetString(File.ReadAllBytes(TestCases.DataPath("jigyosyo_sample.csv")))); + using var towns = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); + using var businesses = new MemoryStream(utf8); + + var e = Assert.Throws(() => ZipCodeDictionary.Load(towns, businesses, sjis)); + Assert.Contains("都道府県名", e.Message); + } + } +} diff --git a/ZipConvertCustomerBarCode.Tests/TargetFrameworkTests.cs b/ZipConvertCustomerBarCode.Tests/TargetFrameworkTests.cs new file mode 100644 index 0000000..67e6895 --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/TargetFrameworkTests.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using System.Runtime.Versioning; + +namespace ZipConvertCustomerBarCode.Tests +{ + public class TargetFrameworkTests + { + [Fact] + public void 指定したコアのビルドでテストしている() + { + // dotnet test -p:CoreTargetFramework=netstandard2.0 で指定したビルドが実際に使われているか + var expected = typeof(TargetFrameworkTests).Assembly.GetCustomAttributes() + .Single(x => x.Key == "CoreTargetFramework").Value; + var actual = typeof(BarCode).Assembly.GetCustomAttribute()!.FrameworkName; + + var expectedName = expected switch + { + "netstandard2.0" => ".NETStandard,Version=v2.0", + "net8.0" => ".NETCoreApp,Version=v8.0", + _ => throw new InvalidOperationException($"想定していない CoreTargetFramework: {expected}"), + }; + Assert.Equal(expectedName, actual); + } + } +} diff --git a/ZipConvertCustomerBarCode.Tests/TestData/jigyosyo_sample.csv b/ZipConvertCustomerBarCode.Tests/TestData/jigyosyo_sample.csv new file mode 100644 index 0000000..4acfa3d --- /dev/null +++ b/ZipConvertCustomerBarCode.Tests/TestData/jigyosyo_sample.csv @@ -0,0 +1,8 @@ +13101,"ÆÂÎßÝճËÞÝ ¶Ì޼·¶޲¼Ô","“ú–{—X•ց@Дޮ‰ïŽÐ","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚Q’š–ڂR�|‚P","1008792","100 ","‹â�À",0,0,0 +13101,"¶Ì޼·¶޲¼Ô ýļÝÌÞݼÔ","Дޮ‰ïŽЁ@ƒeƒXƒg�V•·ŽÐ","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚P’š–ڂV�|‚Q","1008077","100 ","‹â�À",0,0,0 +13101,"¶Ì޼·¶޲¼Ô ýļÝÌÞݼÔ","Дޮ‰ïŽЁ@ƒeƒXƒg�V•·ŽÐ","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚P’š–ڂV�|‚Q�i‹â�À—X•֋ǎ„�‘” ‘æ‚S‚R‚R�†�j","1008644","100 ","‹â�À",1,0,0 +13101,"ýķÞݺ³","ƒeƒXƒg‹â�s","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚P’š–ڂP�|‚P","1008701","100 ","‹â�À",0,1,0 +13101,"ýķÞݺ³","ƒeƒXƒg‹â�s","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚P’š–ڂP�|‚P","1008702","100 ","‹â�À",0,2,0 +13101,"ʲ¼¼޷Þֳ¼Ö","”pŽ~Ž–‹ƏŠ","“Œ‹ž“s","�ç‘ã“c‹æ","‘åŽ蒬","‚P’š–ڂP�|‚Q","1008799","100 ","‹â�À",0,0,5 +09201,"ýĺ³·Þֳ ¶Ì޼·¶޲¼Ô ³ÂÉÐԼ޷Þֳ¼Ö","ƒeƒXƒg�H‹Ɓ@Дޮ‰ïŽЁ@‰F“s‹{Ž–‹ƏŠ","“Ȗ،§","‰F“s‹{Žs","�´Œ´�H‹ƒc’n","‚Q‚O�|‚Q","3213292","321 ","‰F“s‹{“Œ",0,0,0 +09201,"ýĺ³·Þֳ ¶Ì޼·¶޲¼Ô ³ÂÉÐԼ޷Þֳ¼Ö","ƒeƒXƒg�H‹Ɓ@Дޮ‰ïŽЁ@‰F“s‹{Ž–‹ƏŠ","“Ȗ،§","‰F“s‹{Žs","�´Œ´�H‹ƒc’n","‚Q‚Q�|‚Q","3213294","321 ","‰F“s‹{“Œ",0,0,0 diff --git a/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv b/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv index 6870a3e..1d874b0 100644 --- a/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv +++ b/ZipConvertCustomerBarCode.Tests/TestData/ken_all_sample.csv @@ -1,5 +1,5 @@ 13101,"100 ","1000000","トウキョウト","チヨダク","イカニケイサイガナイバアイ","東京都","千代田区","以下に掲載がない場合",0,0,0,0,0,0 -13101,"100 ","1000013","トウキョウト","チヨダク","カスミガセキ","東京都","千代田区","霞が関",0,0,1,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 @@ -10,7 +10,23 @@ 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 +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 +01106,"005 ","0050865","ホッカイドウ","サッポロシミナミク","トキワ(1-131バンチ)","北海道","札幌市南区","常盤(1〜131番地)",1,0,0,0,0,0 +01106,"005 ","0050863","ホッカイドウ","サッポロシミナミク","トキワ(ソノタ)","北海道","札幌市南区","常盤(その他)",1,0,0,0,0,0 +01228,"074 ","0741273","ホッカイドウ","フカガワシ","オトエチョウ(1-2チョウメ)","北海道","深川市","音江町(1〜2丁目)",1,0,1,0,0,0 +01228,"074 ","0741276","ホッカイドウ","フカガワシ","オトエチョウ(イナダ)","北海道","深川市","音江町(稲田)",1,0,0,0,0,0 +01228,"074 ","0741277","ホッカイドウ","フカガワシ","オトエチョウ(オトエ)","北海道","深川市","音江町(音江)",1,0,0,0,0,0 +13206,"183 ","1830022","トウキョウト","フチュウシ","ミヤニシチョウ","東京都","府中市","宮西町",0,0,1,0,0,0 +34208,"726 ","7260000","ヒロシマケン","フチュウシ","イカニケイサイガナイバアイ","広島県","府中市","以下に掲載がない場合",0,0,0,0,0,0 +01458,"07115","0711503","ホッカイドウ","カミカワグンヒガシカグラチョウ","ヒジリノミナミ1ジョウ","北海道","上川郡東神楽町","ひじり野南一条",0,0,1,0,0,0 +01228,"074 ","0741164","ホッカイドウ","フカガワシ","オトエチョウ(クニミ23-298バンチ)","北海道","深川市","音江町(国見23〜298番地)",1,0,0,0,0,0 +01228,"074 ","0741272","ホッカイドウ","フカガワシ","オトエチョウ(クニミソノタ)","北海道","深川市","音江町(国見その他)",1,0,0,0,0,0 +13101,"100 ","1006090","トウキョウト","チヨダク","カスミガセキカスミガセキビル(チカイ・カイソウフメイ)","東京都","千代田区","霞が関霞が関ビル(地階・階層不明)",0,0,0,0,0,0 +13101,"100 ","1006001","トウキョウト","チヨダク","カスミガセキカスミガセキビル(1カイ)","東京都","千代田区","霞が関霞が関ビル(1階)",0,0,0,0,0,0 +13101,"100 ","1006010","トウキョウト","チヨダク","カスミガセキカスミガセキビル(10カイ)","東京都","千代田区","霞が関霞が関ビル(10階)",0,0,0,0,0,0 +01999,"999 ","9999911","ホッカイドウ","テストムラ","テストムライチエン","北海道","テスト村","テスト村一円",0,0,0,0,0,0 +02999,"999 ","9999912","アオモリケン","テストムラ","ホンチョウ","青森県","テスト村","本町",0,0,0,0,0,0 +13101,"100 ","1006101","トウキョウト","チヨダク","カスミガセキABCタワー(1カイ)","東京都","千代田区","霞が関ABCタワー(1階)",0,0,0,0,0,0 diff --git a/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs b/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs index 756164e..a639ee5 100644 --- a/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs +++ b/ZipConvertCustomerBarCode.Tests/ZipCodeDictionaryTests.cs @@ -12,7 +12,7 @@ public class ZipCodeDictionaryTests [Fact] public void CSVファイルを読み込める() { - Assert.Equal(15, Dictionary.Count); + Assert.Equal(31, Dictionary.Count); } [Fact] @@ -26,7 +26,7 @@ public class ZipCodeDictionaryTests archive.CreateEntryFromFile(TestCases.DataPath("ken_all_sample.csv"), "utf_ken_all.csv"); } - Assert.Equal(15, ZipCodeDictionary.Load(path).Count); + Assert.Equal(31, ZipCodeDictionary.Load(path).Count); } finally { @@ -39,7 +39,7 @@ public class ZipCodeDictionaryTests { using var stream = File.OpenRead(TestCases.DataPath("ken_all_sample.csv")); - Assert.Equal(15, ZipCodeDictionary.Load(stream).Count); + Assert.Equal(31, ZipCodeDictionary.Load(stream).Count); // 渡された Stream は閉じない Assert.True(stream.CanRead); } @@ -91,7 +91,7 @@ public class ZipCodeDictionaryTests 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(("1000013", "東京都", "千代田区", "霞が関(次のビルを除く)"), (entry.ZipCode, entry.Prefecture, entry.City, entry.Town)); Assert.Equal(new[] { "本町", "本町北" }, Dictionary.Find("9999901").Select(x => x.Town)); Assert.Empty(Dictionary.Find("1008798")); } @@ -105,7 +105,11 @@ public class ZipCodeDictionaryTests // ヶ と ケ [InlineData("2730102", "千葉県鎌ヶ谷市右京塚 東3丁目-20-5", "千葉県鎌ヶ谷市右京塚", " 東3丁目-20-5")] // 全角・半角 - [InlineData("9999902", "テスト県テスト市西2条3丁目", "テスト県テスト市西2条", "3丁目")] + [InlineData("9999902", "北海道テスト市西2条3丁目", "北海道テスト市西2条", "3丁目")] + // 漢数字と算用数字 + [InlineData("0650006", "札幌市東区北6条東8丁目", "札幌市東区北6条東", "8丁目")] + // 郡名の省略 + [InlineData("0711503", "東神楽町ひじり野南一条1丁目", "東神楽町ひじり野南一条", "1丁目")] // 町域名の括弧書き [InlineData("0600906", "北海道札幌市東区北六条東4丁目 郵便センター6号館", "北海道札幌市東区北六条東", "4丁目 郵便センター6号館")] // 町域名の前の「大字」「字」 @@ -119,7 +123,7 @@ public class ZipCodeDictionaryTests // 町域名が「一円」 [InlineData("5220341", "滋賀県犬上郡多賀町一円12", "滋賀県犬上郡多賀町一円", "12")] // 1つの郵便番号に複数の町域がある場合は長く一致した方 - [InlineData("9999901", "テスト県テスト市本町北1-2", "テスト県テスト市本町北", "1-2")] + [InlineData("9999901", "北海道テスト市本町北1-2", "北海道テスト市本町北", "1-2")] [InlineData("9999901", "テスト市本町1-2", "テスト市本町", "1-2")] public void 住所を町域名で分割できる(string zipCode, string address, string town, string block) { diff --git a/ZipConvertCustomerBarCode.Tests/ZipConvertCustomerBarCode.Tests.csproj b/ZipConvertCustomerBarCode.Tests/ZipConvertCustomerBarCode.Tests.csproj index ab89d7e..bc1b47e 100644 --- a/ZipConvertCustomerBarCode.Tests/ZipConvertCustomerBarCode.Tests.csproj +++ b/ZipConvertCustomerBarCode.Tests/ZipConvertCustomerBarCode.Tests.csproj @@ -13,8 +13,21 @@ + + + net8.0 + + + + + + + - + diff --git a/ZipConvertCustomerBarCode/AddressMatcher.cs b/ZipConvertCustomerBarCode/AddressMatcher.cs index 08e9f64..0744507 100644 --- a/ZipConvertCustomerBarCode/AddressMatcher.cs +++ b/ZipConvertCustomerBarCode/AddressMatcher.cs @@ -1,42 +1,282 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; namespace ZipConvertCustomerBarCode { /// - /// 住所の前方一致 - /// 空白、全角・半角、「ヶ」と「ケ」の違いは区別しない + /// 住所の照合 + /// 空白、全角・半角、英字の大文字・小文字、「ヶ」と「ケ」、漢数字と算用数字(六条 と 6条)の違いは区別しない /// - internal static class AddressMatcher + internal sealed class AddressMatcher { + // 漢数字(BarCode.KanjiToNumber で変換できるもの) + private static readonly string KanjiNumerals = BarCode.KanjiNumerals; + + // 階数(地下は除く) + private static readonly Regex FloorRegex = new Regex(@"\G(?[0-9]+)(階|[Ff])"); + + // 照合用に正規化した住所と、正規化後の各文字に対応する元の住所の位置(その文字の直後) + private readonly string normalized; + private readonly int[] ends; + /// - /// 住所の先頭が町域名までの住所と一致する場合、一致した部分の長さを返す + /// 照合する住所を正規化する /// /// 住所 + public AddressMatcher(string address) + { + var sb = new StringBuilder(address.Length); + var list = new List(address.Length); + Normalize(address, sb, list); + normalized = sb.ToString(); + ends = list.ToArray(); + } + + /// + /// 住所の先頭が町域名までの住所と一致する場合、一致した部分の長さを返す + /// /// 町域名までの住所 - /// 一致した部分の長さ(住所の空白を含む)。一致しない場合は -1 - public static int MatchPrefix(string address, string prefix) + /// 一致した部分の長さ(元の住所での長さ)。一致しない場合は -1 + public int MatchPrefix(string prefix) + { + return MatchPrefix(prefix, 0); + } + + /// + /// 住所の指定位置以降が町域名と一致する場合、一致した部分の終わりの位置を返す + /// + /// 町域名など + /// 照合を始める位置(元の住所での位置) + /// 一致した部分の終わり(元の住所での位置)。一致しない場合は -1 + public int MatchPrefix(string prefix, int start) + { +#if NET8_0_OR_GREATER + // 正規化した文字列を作らず、スタック上で正規化して比べる(漢数字は1文字が最大4文字になる) + var size = prefix.Length * 4; + Span buffer = size <= 256 ? stackalloc char[size] : new char[size]; + return MatchNormalizedPrefix(buffer.Slice(0, NormalizeTo(prefix, buffer)), start); +#else + return MatchNormalizedPrefix(Normalize(prefix), start); +#endif + } + + /// + /// 住所の指定位置以降が、正規化済みの町域名などと一致する場合、一致した部分の終わりの位置を返す + /// + /// 正規化済みの町域名など() + /// 照合を始める位置(元の住所での位置) + /// 一致した部分の終わり(元の住所での位置)。一致しない場合は -1 + public int MatchNormalizedPrefix(string target, int start) + { +#if NET8_0_OR_GREATER + return MatchNormalizedPrefix(target.AsSpan(), start); +#else + var offset = NormalizedIndex(start); + if (target.Length == 0) return start; + if (normalized.Length - offset < target.Length || string.CompareOrdinal(normalized, offset, target, 0, target.Length) != 0) return -1; + return MatchEnd(offset, target.Length); +#endif + } + +#if NET8_0_OR_GREATER + /// + /// 住所の指定位置以降が、正規化済みの町域名などと一致する場合、一致した部分の終わりの位置を返す + /// + /// 正規化済みの町域名など + /// 照合を始める位置(元の住所での位置) + /// 一致した部分の終わり(元の住所での位置)。一致しない場合は -1 + public int MatchNormalizedPrefix(ReadOnlySpan target, int start) + { + var offset = NormalizedIndex(start); + if (target.Length == 0) return start; + if (!normalized.AsSpan(offset).StartsWith(target, StringComparison.Ordinal)) return -1; + return MatchEnd(offset, target.Length); + } +#endif + + /// + /// 正規化後の住所で一致した部分の終わりを、元の住所での位置に変換する + /// + /// 一致した部分の開始位置(正規化後) + /// 一致した部分の長さ(正規化後) + /// 一致した部分の終わり(元の住所での位置)。漢数字の途中で終わる場合は -1 + private int MatchEnd(int offset, int length) + { + // 漢数字の途中で終わる場合(「二十」の「2」だけが一致した場合など)は一致としない + var last = offset + length - 1; + if (last + 1 < normalized.Length && ends[last + 1] == ends[last]) return -1; + return ends[last]; + } + + /// + /// 住所の指定位置以降で、正規化済みの文字列(ビル名など)が現れる場合、その終わりの位置を返す + /// + /// 正規化済みの文字列() + /// 探し始める位置(元の住所での位置) + /// 見つかった文字列の終わり(元の住所での位置)。見つからない場合は -1 + public int IndexOfNormalized(string target, int start) + { + if (target.Length == 0) return -1; + var index = normalized.IndexOf(target, NormalizedIndex(start), StringComparison.Ordinal); + return index < 0 ? -1 : ends[index + target.Length - 1]; + } + + /// + /// 指定位置(空白は除く)から始まる階数を返す(「10階」「10F」) + /// + /// 位置(元の住所での位置) + /// 階数。地下(「地下1階」「B1F」)や、階数が書かれていない場合は null + public int? FloorAt(int start) + { + var match = FloorRegex.Match(normalized, NormalizedIndex(start)); + if (!match.Success) return null; + return int.TryParse(match.Groups["floor"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var floor) ? floor : (int?)null; + } + + /// + /// 指定位置(空白は除く)から始まる数値を返す + /// + /// 位置(元の住所での位置) + /// 数値。指定位置が数値で始まらない場合は null + public int? LeadingNumber(int start) + { + int i = NormalizedIndex(start); + if (i >= normalized.Length || !IsDigit(normalized[i])) return null; + + long value = 0; + for (; i < normalized.Length && IsDigit(normalized[i]); i++) + { + value = value * 10 + (normalized[i] - '0'); + if (value > int.MaxValue) return null; + } + return (int)value; + } + + /// + /// 住所を照合用に正規化する + /// + /// 住所 + /// 正規化した住所 + public static string Normalize(string value) + { + var sb = new StringBuilder(value.Length); + Normalize(value, sb, null); + return sb.ToString(); + } + + /// + /// 住所を照合用に正規化する + /// + /// 住所 + /// 正規化した住所の出力先 + /// 正規化後の各文字に対応する元の位置(その文字の直後)の出力先 + private static void Normalize(string value, StringBuilder sb, List ends) { int i = 0; - foreach (var c in prefix.Where(x => !char.IsWhiteSpace(x))) + while (i < value.Length) { - while (i < address.Length && char.IsWhiteSpace(address[i])) i++; - if (i >= address.Length || Normalize(address[i]) != Normalize(c)) return -1; + var c = value[i]; + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + // 漢数字の並びは算用数字に変換する + var kanjiEnd = KanjiRunEnd(value, i); + if (kanjiEnd > i) + { + foreach (var d in BarCode.KanjiToNumber(value, i, kanjiEnd - i)) + { + sb.Append(d); + ends?.Add(kanjiEnd); + } + i = kanjiEnd; + continue; + } + + sb.Append(NormalizeChar(c)); + ends?.Add(i + 1); i++; } + } + +#if NET8_0_OR_GREATER + /// + /// 住所を照合用に正規化して書き込む( の文字列を作らない版) + /// + /// 住所 + /// 書き込み先(住所の長さの4倍あれば足りる) + /// 書き込んだ文字数 + private static int NormalizeTo(string value, Span destination) + { + int i = 0, written = 0; + while (i < value.Length) + { + var c = value[i]; + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + // 漢数字の並びは算用数字に変換する + var kanjiEnd = KanjiRunEnd(value, i); + if (kanjiEnd > i) + { + written += BarCode.WriteKanjiNumber(value, i, kanjiEnd - i, destination.Slice(written)); + i = kanjiEnd; + continue; + } + + destination[written++] = NormalizeChar(c); + i++; + } + return written; + } +#endif + + /// + /// 指定位置から漢数字が続く場合、その終わりの位置を返す + /// + /// 漢数字の並びの終わりの位置。漢数字で始まらない場合は指定位置 + private static int KanjiRunEnd(string value, int start) + { + int end = start; + while (end < value.Length && KanjiNumerals.IndexOf(value[end]) >= 0) end++; + return end; + } + + /// + /// 元の住所での位置を正規化後の位置に変換する + /// + private int NormalizedIndex(int start) + { + int i = 0; + while (i < ends.Length && ends[i] <= start) i++; return i; } /// - /// 照合用の文字の正規化 + /// 照合用の文字の正規化(全角→半角、英字は大文字、「ヶ」→「ケ」) /// - private static char Normalize(char c) + private static char NormalizeChar(char c) { switch (c) { case 'ヶ': return 'ケ'; case 'ヵ': return 'カ'; - default: return BarCode.ToHankaku(c); + default: + // 全角英字は ToHankaku で半角になるため、大文字にするのは半角の a~z のみ + // (char.ToUpperInvariant は住所の分割で1件あたり約1μs遅くなるため使わない) + var hankaku = BarCode.ToHankaku(c); + return hankaku >= 'a' && hankaku <= 'z' ? (char)(hankaku - 'a' + 'A') : hankaku; } } + + private static bool IsDigit(char c) => c >= '0' && c <= '9'; } } diff --git a/ZipConvertCustomerBarCode/BarCode.cs b/ZipConvertCustomerBarCode/BarCode.cs index 871f774..a738ca7 100644 --- a/ZipConvertCustomerBarCode/BarCode.cs +++ b/ZipConvertCustomerBarCode/BarCode.cs @@ -66,8 +66,11 @@ public static class BarCode // 漢数字(位取りなし) private static readonly string KanjiDigits = "〇一二三四五六七八九"; + // 漢数字(位取りを含む) + internal static readonly string KanjiNumerals = KanjiDigits + "十百千"; + // 特定文字の前にある漢数字 - private static readonly Regex KanjiNumberRegex = new Regex("[〇一二三四五六七八九十百千]+(?=丁目|丁|番地|番|号|地割|線|の|ノ)"); + private static readonly Regex KanjiNumberRegex = new Regex("[" + KanjiNumerals + "]+(?=丁目|丁|番地|番|号|地割|線|の|ノ)"); /// /// カスタマバーコードを生成する @@ -98,6 +101,8 @@ public static CustomerBarCode Create(string zipCode, string address) /// 郵便番号データの町域名で住所を住所A(町域名まで)と住所B(町域名以降)に分割し、住所Bから住所表示番号を抜き出す。 /// 分割できない場合(町域名の表記が一致しない場合など)は、住所全体を住所Bとして扱う。 /// は郵便番号データに基づいて判定する。 + /// 大口事業所・私書箱の個別郵便番号(事業所の個別郵便番号データを読み込んだ場合)は、マニュアルに従い + /// 個別番号のみをバーコード化し、住所表示番号は抜き出さない(13けたすべて CC4)。 /// /// 郵便番号(半角・全角数字7桁、ハイフンは可) /// 住所 @@ -119,6 +124,13 @@ public static CustomerBarCode Create(string zipCode, string address, ZipCodeDict return CreateCore(zip, address, IsRepresentativeByRule(zip)); } + // 大口事業所・私書箱の個別郵便番号は、個別番号のみをバーコード化し、住所表示番号の13けたは CC4 で埋める + // http://www.post.japanpost.jp/zipcode/zipmanual/p22.html + if (entries.Any(x => x.IsIndividual)) + { + return CreateCore(zip, "", false); + } + if (ZipCodeDictionary.TrySplitAddress(entries, address, out _, out var blockAddress)) { address = blockAddress; @@ -161,7 +173,7 @@ private static CustomerBarCode CreateCore(string zip, string address, bool isRep /// /// 住所表示番号は住所Bからのみ抜き出す。 /// 住所Bが住所Aで始まる場合(住所全体が渡された場合)は、住所Aの部分を取り除いてから抜き出す。 - /// 住所Aとの比較では、空白、全角・半角、「ヶ」と「ケ」の違いは区別しない。 + /// 住所Aとの比較では、空白、全角・半角、「ヶ」と「ケ」、漢数字と算用数字の違いは区別しない。 /// http://www.post.japanpost.jp/zipcode/zipmanual/p17.html /// /// 郵便番号(半角・全角数字7桁、ハイフンは可) @@ -174,7 +186,7 @@ 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 end = AddressMatcher.MatchPrefix(blockAddress, townAddress); + var end = new AddressMatcher(blockAddress).MatchPrefix(townAddress); if (end > 0) { blockAddress = blockAddress.Substring(end); @@ -190,19 +202,24 @@ public static CustomerBarCode Create(string zipCode, string townAddress, string /// 郵便番号(半角数字7桁) internal static string CheckPostCode(string zipCode) { - // 全角→半角 - zipCode = string.Join("", zipCode.Select(ToHankaku)); - - // ハイフン除去 - zipCode = zipCode.Replace("-", ""); - - // \d は全角以外の Unicode 数字にも、\Z は末尾の改行にもマッチするため使わない - if (!Regex.IsMatch(zipCode, @"\A[0-9]{7}\z")) + // 全角→半角にし、ハイフンを除いて半角数字(0~9 のみ。全角以外の Unicode 数字や改行は不可)が7桁であること + var digits = new char[7]; + var count = 0; + foreach (var c in zipCode) { - throw new ArgumentException("郵便番号は7桁の数値で入力してください。"); + var hankaku = ToHankaku(c); + if (hankaku == '-') continue; + if (hankaku < '0' || hankaku > '9' || count == digits.Length) throw InvalidPostCode(); + digits[count++] = hankaku; } + if (count != digits.Length) throw InvalidPostCode(); - return zipCode; + return new string(digits); + } + + private static ArgumentException InvalidPostCode() + { + return new ArgumentException("郵便番号は7桁の数値で入力してください。"); } /// @@ -259,16 +276,78 @@ private static string ExtractAddressNumber(string address) /// /// 漢数字 /// 算用数字 - private static string KanjiToNumber(string kanji) + internal static string KanjiToNumber(string kanji) + { + return KanjiToNumber(kanji, 0, kanji.Length); + } + + /// + /// 文字列の一部の漢数字を算用数字に変換する + /// + /// 文字列 + /// 漢数字の開始位置 + /// 漢数字の長さ + /// 算用数字 + internal static string KanjiToNumber(string value, int start, int length) + { + if (!HasKanjiUnit(value, start, length)) + { + var digits = new char[length]; + for (int i = 0; i < length; i++) digits[i] = KanjiDigit(value[start + i]); + return new string(digits); + } + return KanjiValue(value, start, length).ToString(CultureInfo.InvariantCulture); + } + +#if NET8_0_OR_GREATER + /// + /// 文字列の一部の漢数字を算用数字に変換して書き込む + /// + /// 文字列 + /// 漢数字の開始位置 + /// 漢数字の長さ + /// 書き込み先(漢数字の長さの4倍あれば足りる) + /// 書き込んだ文字数 + internal static int WriteKanjiNumber(string value, int start, int length, Span destination) { - if (kanji.IndexOfAny(new[] { '十', '百', '千' }) < 0) + if (!HasKanjiUnit(value, start, length)) { - return string.Concat(kanji.Select(c => KanjiDigits.IndexOf(c))); + for (int i = 0; i < length; i++) destination[i] = KanjiDigit(value[start + i]); + return length; } + KanjiValue(value, start, length).TryFormat(destination, out var written, default, CultureInfo.InvariantCulture); + return written; + } +#endif + // 位取りの漢数字 + private static readonly char[] KanjiUnits = { '十', '百', '千' }; + + /// + /// 位取り(十、百、千)を含むかどうか(含まない場合は「二〇五」のように1文字ずつ数字にする) + /// + private static bool HasKanjiUnit(string value, int start, int length) + { + return value.IndexOfAny(KanjiUnits, start, length) >= 0; + } + + /// + /// 位取りなしの漢数字1文字を算用数字にする + /// + private static char KanjiDigit(char c) + { + return (char)('0' + KanjiDigits.IndexOf(c)); + } + + /// + /// 位取りを含む漢数字(「十一」「千二百三十四」)の値 + /// + private static int KanjiValue(string value, int start, int length) + { int total = 0, current = 0; - foreach (var c in kanji) + for (int i = start; i < start + length; i++) { + var c = value[i]; var digit = KanjiDigits.IndexOf(c); if (digit >= 0) { @@ -279,7 +358,7 @@ private static string KanjiToNumber(string kanji) total += (current == 0 ? 1 : current) * unit; current = 0; } - return (total + current).ToString(CultureInfo.InvariantCulture); + return total + current; } /// diff --git a/ZipConvertCustomerBarCode/ZipCodeDictionary.cs b/ZipConvertCustomerBarCode/ZipCodeDictionary.cs index 0bf32b1..f57b329 100644 --- a/ZipConvertCustomerBarCode/ZipCodeDictionary.cs +++ b/ZipConvertCustomerBarCode/ZipCodeDictionary.cs @@ -9,28 +9,65 @@ namespace ZipConvertCustomerBarCode { /// /// 郵便番号データ - /// 日本郵便の「住所の郵便番号(1レコード1行、UTF-8形式)」(utf_ken_all.csv)を読み込む + /// 日本郵便の「住所の郵便番号(1レコード1行、UTF-8形式)」(utf_ken_all.csv)と、 + /// 「事業所の個別郵便番号」(JIGYOSYO.CSV)を読み込む /// /// /// データは同梱していないため、利用側でダウンロードして指定する。 /// https://www.post.japanpost.jp/zipcode/dl/utf-zip.html - /// 従来の Shift_JIS 形式(KEN_ALL.CSV)は、町域名が複数レコードに分割されている場合があるため対象外。 - /// 大口事業所の個別郵便番号は含まれない。 + /// https://www.post.japanpost.jp/zipcode/dl/jigyosyo/index-zip.html + /// 住所の郵便番号の従来の Shift_JIS 形式(KEN_ALL.CSV)は、町域名が複数レコードに分割されている場合があるため対象外。 /// public sealed class ZipCodeDictionary { // 列数 - private const int ColumnCount = 15; + private const int TownColumnCount = 15; + private const int BusinessColumnCount = 13; + // 町域名の前の「大字」「字」(郵便番号データの町域名には付いていない) + private static readonly string[] TownPrefixes = { "大字", "字" }; + + // 都道府県名(文字コードが違うファイルを読み込んでいないかの確認に使う) + private static readonly HashSet Prefectures = new HashSet + { + "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", + "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", + "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", + "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", + "鳥取県", "島根県", "岡山県", "広島県", "山口県", + "徳島県", "香川県", "愛媛県", "高知県", + "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県" + }; + + // 郵便番号 → 町域・事業所 private readonly Dictionary entries; + // 市区町村 + private readonly City[] cities; + + // 市区町村ごとの事業所 + private readonly Dictionary businesses; + private ZipCodeDictionary(Dictionary entries) { this.entries = entries; + var all = entries.Values.SelectMany(x => x).ToList(); + // 事業所だけがある市区町村も、事業所を探せるように含める + cities = all.GroupBy(CityKey) + .Select(x => new City(x.Key, x.Where(e => !e.IsIndividual).ToArray(), x.First().GetCityCandidates().Select(AddressMatcher.Normalize).ToArray())) + .ToArray(); + businesses = all.Where(x => x.IsIndividual) + .GroupBy(CityKey) + .ToDictionary(x => x.Key, x => x.ToArray()); + + foreach (var city in cities) + { + SetBuildingTowns(city.Towns); + } } /// - /// 郵便番号の件数 + /// 郵便番号の件数(事業所の個別郵便番号を含む) /// public int Count => entries.Count; @@ -43,24 +80,28 @@ private ZipCodeDictionary(Dictionary entries) public static ZipCodeDictionary Load(string path) { if (path == null) throw new ArgumentNullException(nameof(path)); + return OpenCsv(path, stream => Load(stream)); + } - using (var file = File.OpenRead(path)) - { - if (!string.Equals(Path.GetExtension(path), ".zip", StringComparison.OrdinalIgnoreCase)) - { - return Load(file); - } + /// + /// 郵便番号データと、事業所の個別郵便番号データを読み込む + /// + /// + /// 事業所の個別郵便番号データは Shift_JIS のため、.NET Core / .NET 5 以降では + /// 事前に Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) を呼び出す。 + /// + /// utf_ken_all.csv、またはそれを含む zip ファイル(utf_ken_all.zip)のパス + /// JIGYOSYO.CSV、またはそれを含む zip ファイル(jigyosyo.zip)のパス + /// 郵便番号データ + /// 郵便番号データの形式が正しくない場合 + /// Shift_JIS が使えない場合 + public static ZipCodeDictionary Load(string path, string businessPath) + { + if (path == null) throw new ArgumentNullException(nameof(path)); + if (businessPath == null) throw new ArgumentNullException(nameof(businessPath)); - 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); - } - } - } + var encoding = GetShiftJis(); + return OpenCsv(path, stream => OpenCsv(businessPath, businessStream => Load(stream, businessStream, encoding))); } /// @@ -73,54 +114,29 @@ 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 reader = new EntryReader(); + reader.ReadTowns(stream); + return reader.Create(); + } - var zipCode = Unquote(fields[2]); - if (zipCode.Length != 7 || !zipCode.All(c => c >= '0' && c <= '9')) - { - throw InvalidFormat(lineNumber, "郵便番号が7桁の数値ではありません"); - } + /// + /// 郵便番号データと、事業所の個別郵便番号データを読み込む + /// + /// utf_ken_all.csv の内容(UTF-8)。読み込み後も閉じないため、呼び出し側で破棄する + /// JIGYOSYO.CSV の内容。読み込み後も閉じないため、呼び出し側で破棄する + /// JIGYOSYO.CSV の文字コード(Shift_JIS) + /// 郵便番号データ + /// 郵便番号データの形式が正しくない場合 + public static ZipCodeDictionary Load(Stream stream, Stream businessStream, Encoding businessEncoding) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (businessStream == null) throw new ArgumentNullException(nameof(businessStream)); + if (businessEncoding == null) throw new ArgumentNullException(nameof(businessEncoding)); - // 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())); + var reader = new EntryReader(); + reader.ReadTowns(stream); + reader.ReadBusinesses(businessStream, businessEncoding); + return reader.Create(); } /// @@ -135,20 +151,20 @@ public bool Contains(string zipCode) } /// - /// 郵便番号の町域を返す + /// 郵便番号の町域・事業所を返す /// /// 郵便番号(半角数字7桁) - /// 町域。郵便番号データにない場合は null + /// 町域・事業所。郵便番号データにない場合は null internal ZipCodeEntry[] FindEntries(string zip) { return entries.TryGetValue(zip, out var list) ? list : null; } /// - /// 郵便番号の町域を返す(1つの郵便番号に複数の町域がある場合がある) + /// 郵便番号の町域・事業所を返す(1つの郵便番号に複数の町域がある場合がある) /// /// 郵便番号(半角・全角数字7桁、ハイフンは可) - /// 町域(郵便番号データにない場合は空) + /// 町域・事業所(郵便番号データにない場合は空) /// 郵便番号が7桁の数値でない場合 public IReadOnlyList Find(string zipCode) { @@ -160,7 +176,7 @@ public IReadOnlyList Find(string zipCode) /// 代表の郵便番号(町域名が「以下に掲載がない場合」)かどうか /// /// 郵便番号(半角・全角数字7桁、ハイフンは可) - /// 代表の郵便番号なら true。郵便番号データにない場合(事業所の個別番号など)は null + /// 代表の郵便番号なら true。郵便番号データにない場合は null /// 郵便番号が7桁の数値でない場合 public bool? IsRepresentative(string zipCode) { @@ -174,7 +190,7 @@ public IReadOnlyList Find(string zipCode) /// 郵便番号の町域名で、住所を住所A(町域名まで)と住所B(町域名以降)に分割する /// /// - /// 都道府県名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」の違いは区別しない。 + /// 都道府県名・郡名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」、漢数字と算用数字の違いは区別しない。 /// 町域名の括弧書き(「北六条東(1~7丁目)」の「(1~7丁目)」など)は除いて照合する。 /// 代表の郵便番号(町域名が「以下に掲載がない場合」)は町域名がわからないため分割しない。 /// @@ -213,10 +229,18 @@ internal static bool TrySplitAddress(ZipCodeEntry[] list, string address, out st blockAddress = null; // 最も長く一致した町域名で分割する - var end = list.SelectMany(x => x.GetTownAddressCandidates()) - .Select(x => AddressMatcher.MatchPrefix(address, x)) - .DefaultIfEmpty(-1) - .Max(); + // (候補の文字列をつなげて作らないよう、都道府県名・市区町村名・町域名の順に照合する) + var matcher = new AddressMatcher(address); + var end = -1; + foreach (var entry in list) + { + // 「以下に掲載がない場合」は町域名がわからないため分割しない + if (entry.IsRepresentative) continue; + + var cityEnd = MatchCityName(entry, matcher); + if (cityEnd < 0) continue; + end = Math.Max(end, MatchTownName(entry, matcher, cityEnd, TownStarts(matcher, cityEnd))); + } if (end < 0) return false; townAddress = address.Substring(0, end).Trim(); @@ -225,93 +249,619 @@ internal static bool TrySplitAddress(ZipCodeEntry[] list, string address, out st } /// - /// 「"」で囲まれた項目から「"」を除く + /// 住所から郵便番号の候補を探す /// - private static string Unquote(string field) + /// + /// 都道府県名・市区町村名・町域名の順に照合し、最も長く一致した町域を返す。 + /// 都道府県名や郡名の省略、町域名の前の「大字」「字」の有無、空白、全角・半角、「ヶ」と「ケ」、漢数字と算用数字の違いは区別しない。 + /// 同じ町域名で郵便番号が複数ある場合は、町域名の括弧書きで絞り込む。 + /// 超高層ビルは住所中のビル名と階(「10階」「10F」)、地区名は町域名の直後の地区名、 + /// 丁目・番地の範囲(「1~7丁目」「1~500番地」など)は町域名(または地区名)の直後の丁目・番地で絞り込む。 + /// 市区町村名は一致したが町域名が一致しない場合は、「○○町一円」などの町域、なければ代表の郵便番号(以下に掲載がない場合)を返す。 + /// 大口事業所・私書箱の個別郵便番号は返さない(事業所名を指定する を使う)。 + /// + /// 住所 + /// 候補の町域(見つからない場合は空)。郵便番号が1つに決まらない場合は複数 + public IReadOnlyList FindByAddress(string address) { - return field.Length >= 2 && field[0] == '"' && field[field.Length - 1] == '"' ? field.Substring(1, field.Length - 2) : field; + if (address == null) throw new ArgumentNullException(nameof(address)); + + var matcher = new AddressMatcher(address); + var (matchedCities, cityEnd) = MatchCities(matcher); + var townStarts = TownStarts(matcher, cityEnd); + + // 同名の市区町村が複数ある場合は、町域名がより長く一致した市区町村を優先する + // (町域名が一致しない市区町村は代表の郵便番号) + var found = matchedCities.Select(x => FindTown(x.Towns, matcher, cityEnd, townStarts)).ToList(); + var townMatched = Longest(found, x => x.TownEnd, out _); + return (townMatched.Count > 0 ? townMatched : found).SelectMany(x => x.Entries).ToList().AsReadOnly(); } /// - /// 形式エラー + /// 住所と事業所名から郵便番号の候補を探す /// - private static InvalidDataException InvalidFormat(int lineNumber, string reason) + /// + /// 住所の市区町村にある事業所(大口事業所・私書箱の個別郵便番号)から、事業所名が一致するものを探す。 + /// 事業所名の空白、全角・半角、英字の大文字・小文字、「株式会社」「(株)」などの違いは区別しない。 + /// 同じ事業所名が複数ある場合は、番地等まで一致する事業所、なければ町域名まで一致する事業所を優先する。 + /// 事業所名が一致しない場合は、一方が他方を含む事業所のうち、住所が番地等まで一致するものを探す(支店名の省略など)。 + /// 事業所が見つからない場合は、住所だけで探す()。 + /// 事業所の個別郵便番号データを読み込んでいない場合も、住所だけで探す。 + /// + /// 住所 + /// 事業所名 + /// 候補の事業所または町域(見つからない場合は空)。郵便番号が1つに決まらない場合は複数 + public IReadOnlyList FindByAddress(string address, string businessName) { - return new InvalidDataException($"郵便番号データの{lineNumber}行目の形式が正しくありません({reason})。UTF-8形式の utf_ken_all.csv を指定してください。"); + if (address == null) throw new ArgumentNullException(nameof(address)); + if (businessName == null) throw new ArgumentNullException(nameof(businessName)); + + var name = ZipCodeEntry.NormalizeBusinessName(businessName); + if (name.Length > 0) + { + var matcher = new AddressMatcher(address); + var (matchedCities, cityEnd) = MatchCities(matcher); + var townStarts = TownStarts(matcher, cityEnd); + var candidates = matchedCities + .SelectMany(x => businesses.TryGetValue(x.Key, out var list) ? list : Enumerable.Empty()) + .ToList(); + + // 事業所名が一致する事業所 + var exact = candidates.Where(x => x.NormalizedBusinessName == name).ToList(); + if (exact.Count > 0) return SelectBusinesses(exact, matcher, cityEnd, townStarts, false).AsReadOnly(); + + // 一方が他方を含む事業所(「田中」と「田中貴金属工業」のような別の事業所を除くため、住所が番地等まで一致するもののみ) + var partial = candidates.Where(x => x.NormalizedBusinessName.Length > 0 + && (x.NormalizedBusinessName.Contains(name) || name.Contains(x.NormalizedBusinessName))).ToList(); + var selected = SelectBusinesses(partial, matcher, cityEnd, townStarts, true); + if (selected.Count > 0) return selected.AsReadOnly(); + } + return FindByAddress(address); } - } - /// - /// 郵便番号データの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 = "一円"; + /// + /// 事業所名が一致した事業所を、住所の町域名・番地等で絞り込む + /// + /// 事業所名が一致した事業所 + /// 住所 + /// 住所の市区町村名の終わりの位置 + /// 町域名の照合を始める位置 + /// 番地等まで一致する事業所のみを返すかどうか + /// 候補の事業所 + private static List SelectBusinesses(List hits, AddressMatcher matcher, int cityEnd, int[] townStarts, bool requireBlock) + { + var townHits = hits.Select(x => (Entry: x, End: MatchTownName(x, matcher, cityEnd, townStarts))).Where(x => x.End >= 0).ToList(); + + // 番地等が最も長く一致した事業所(「1丁目7-2」より「1丁目7-2(銀座郵便局私書箱第433号)」) + var blockHits = Longest(townHits.Where(x => x.Entry.Block.Length > 0), x => matcher.MatchPrefix(x.Entry.Block, x.End), out _); + if (blockHits.Count > 0) return blockHits.Select(x => x.Entry).ToList(); + if (requireBlock) return new List(); - internal ZipCodeEntry(string zipCode, string prefecture, string city, string town) + // 番地等が一致しなければ町域名まで一致する事業所、それもなければ事業所名が一致したすべて + return townHits.Count > 0 ? townHits.Select(x => x.Entry).ToList() : hits; + } + + /// + /// 住所から郵便番号を探す + /// + /// 住所 + /// 郵便番号(半角数字7桁)。見つからない場合や1つに決まらない場合は null + /// 郵便番号が1つに決まった場合は true + public bool TryGetZipCode(string address, out string zipCode) + { + return TryGetSingleZipCode(FindByAddress(address), out zipCode); + } + + /// + /// 住所と事業所名から郵便番号を探す + /// + /// 住所 + /// 事業所名 + /// 郵便番号(半角数字7桁)。見つからない場合や1つに決まらない場合は null + /// 郵便番号が1つに決まった場合は true + public bool TryGetZipCode(string address, string businessName, out string zipCode) + { + return TryGetSingleZipCode(FindByAddress(address, businessName), out zipCode); + } + + /// + /// 郵便番号が1つに決まる場合はその郵便番号 + /// + private static bool TryGetSingleZipCode(IEnumerable found, out string zipCode) + { + var zipCodes = found.Select(x => x.ZipCode).Distinct().ToList(); + zipCode = zipCodes.Count == 1 ? zipCodes[0] : null; + return zipCode != null; + } + + /// + /// 市区町村名が最も長く一致した市区町村(同名の市区町村は都道府県名がなければ複数) + /// + /// 住所 + /// 市区町村と、住所の市区町村名の終わりの位置 + private (List Cities, int End) MatchCities(AddressMatcher matcher) { - ZipCode = zipCode; - Prefecture = prefecture; - City = city; - Town = town; + var matched = Longest(cities, city => + { + // 市区町村の数(約1,900)だけ繰り返すため、LINQ は使わない + var end = -1; + foreach (var candidate in city.Candidates) + { + end = Math.Max(end, matcher.MatchNormalizedPrefix(candidate, 0)); + } + return end; + }, out var bestEnd); + return (matched, bestEnd); } - /// 郵便番号(半角数字7桁) - public string ZipCode { get; } + /// + /// 住所の先頭が市区町村名まで(都道府県名ありとなし、郡名ありとなし)と一致する場合、一致した部分の終わりの位置を返す + /// + /// 町域・事業所 + /// 住所 + /// 一致した部分の終わりの位置。一致しない場合は -1 + private static int MatchCityName(ZipCodeEntry entry, AddressMatcher matcher) + { + var prefectureEnd = matcher.MatchPrefix(entry.Prefecture, 0); + var end = -1; + foreach (var start in prefectureEnd >= 0 ? new[] { 0, prefectureEnd } : new[] { 0 }) + { + end = Math.Max(end, matcher.MatchPrefix(entry.City, start)); + if (entry.CityWithoutDistrict != null) end = Math.Max(end, matcher.MatchPrefix(entry.CityWithoutDistrict, start)); + } + return end; + } - /// 都道府県名 - public string Prefecture { get; } + /// + /// 町域名(地区名)の照合を始める位置(後ろに「大字」「字」がある場合はその後ろも) + /// + /// 住所 + /// 位置 + /// 照合を始める位置 + private static int[] TownStarts(AddressMatcher matcher, int position) + { + var starts = new List { position }; + foreach (var prefix in TownPrefixes) + { + var start = matcher.MatchNormalizedPrefix(prefix, position); + if (start >= 0) starts.Add(start); + } + return starts.ToArray(); + } - /// 市区町村名 - public string City { get; } + /// + /// 照合を始める位置のいずれかから正規化済みの文字列が一致する場合、一致した部分の終わりの位置を返す + /// + /// 住所 + /// 正規化済みの町域名・地区名 + /// 照合を始める位置 + /// 一致した部分の終わりの位置。一致しない場合は -1 + private static int MatchFrom(AddressMatcher matcher, string target, int[] starts) + { + var end = -1; + foreach (var start in starts) + { + end = Math.Max(end, matcher.MatchNormalizedPrefix(target, start)); + } + return end; + } - /// 町域名(郵便番号データの表記のまま) - public string Town { get; } + /// + /// 住所の市区町村名の後が町域名と一致する場合、一致した部分の終わりの位置を返す + /// + /// 町域・事業所 + /// 住所 + /// 住所の市区町村名の終わりの位置 + /// 町域名の照合を始める位置 + /// 一致した部分の終わりの位置。一致しない場合は -1 + private static int MatchTownName(ZipCodeEntry entry, AddressMatcher matcher, int cityEnd, int[] townStarts) + { + var town = entry.NormalizedTownName; + return town.Length == 0 ? cityEnd : MatchFrom(matcher, town, townStarts); + } /// - /// 代表の郵便番号(町域名が「以下に掲載がない場合」)かどうか + /// 市区町村の中から町域を探す + /// + /// 市区町村の町域 + /// 住所 + /// 住所の市区町村名の終わりの位置 + /// 町域名の照合を始める位置 + /// 候補の町域と、住所の町域名の終わりの位置(町域名が一致せず代表の郵便番号の場合は -1) + private static (IEnumerable Entries, int TownEnd) FindTown(ZipCodeEntry[] city, AddressMatcher matcher, int cityEnd, int[] townStarts) + { + // 町域名が最も長く一致した町域 + var matched = Longest(city.Where(x => !x.IsRepresentative), x => MatchTownName(x, matcher, cityEnd, townStarts), out var townEnd); + + // 町域名が一致しない場合は代表の郵便番号 + if (matched.Count == 0) return (city.Where(x => x.IsRepresentative), -1); + + return (SelectInTown(matched, matcher, townEnd), townEnd); + } + + /// + /// 一致した部分の終わりの位置が最も大きいもの(同じ位置のものはすべて)を返す /// - public bool IsRepresentative => Town == NotListed; + /// 候補 + /// 一致した部分の終わりの位置(一致しない場合は負) + /// 最も大きい終わりの位置(一致するものがない場合は -1) + /// 最も長く一致したもの + private static List Longest(IEnumerable items, Func getEnd, out int bestEnd) + { + bestEnd = -1; + var result = new List(); + foreach (var item in items) + { + var end = getEnd(item); + if (end < 0 || end < bestEnd) continue; + if (end > bestEnd) + { + bestEnd = end; + result.Clear(); + } + result.Add(item); + } + return result; + } /// - /// 照合用の町域名(特殊な値の場合は空、括弧書きは除く) + /// 同じ町域名の町域を、括弧書き(超高層ビルの階、地区名、丁目・番地の範囲)で絞り込む /// - internal string GetTownName() + /// 町域名が一致した町域 + /// 住所 + /// 住所の町域名の終わりの位置 + /// 候補の町域 + private static IEnumerable SelectInTown(List matched, AddressMatcher matcher, int townEnd) { - // 「○○市の次に番地がくる場合」「○○町一円」は町域名がない - 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)) + if (matched.Select(x => x.ZipCode).Distinct().Count() <= 1) return matched; + + // 超高層ビル: 住所の町域名より後ろにビル名があれば、その後の階で選ぶ + var buildings = matched.Where(x => x.IsBuilding).ToList(); + if (buildings.Count > 0) { - return ""; + var building = buildings.GroupBy(x => x.NormalizedBuildingName) + .Select(x => (Entries: x.ToList(), End: matcher.IndexOfNormalized(x.Key, townEnd))) + .Where(x => x.End >= 0) + .OrderByDescending(x => x.Entries[0].NormalizedBuildingName.Length) + .FirstOrDefault(); + if (building.Entries != null) + { + // 階が書かれていない場合や地下の場合は「(地階・階層不明)」 + var floor = matcher.FloorAt(building.End); + var byFloor = building.Entries.Where(x => floor != null && x.Floor == floor).ToList(); + if (byFloor.Count > 0) return byFloor; + var unknownFloor = building.Entries.Where(x => x.IsUnknownFloor).ToList(); + return unknownFloor.Count > 0 ? unknownFloor : building.Entries; + } + + // ビル名がなければビル以外(「(次のビルを除く)」など) + var others = matched.Where(x => !x.IsBuilding).ToList(); + if (others.Count == 0) return matched; + matched = others; + } + + // 地区名: 町域名の直後(前に「大字」「字」があってもよい)の地区名で選ぶ + var areas = matched.Where(x => x.NormalizedArea != null).ToList(); + if (areas.Count > 0) + { + var areaStarts = TownStarts(matcher, townEnd); + var areaMatched = Longest(areas, x => MatchFrom(matcher, x.NormalizedArea, areaStarts), out var bestAreaEnd); + if (areaMatched.Count > 0) return SelectByBlockNumber(areaMatched, matcher.LeadingNumber(bestAreaEnd)); + + // 地区名が書かれていない場合は、地区名のない町域の範囲・「その他」で選ぶ + var withoutArea = matched.Where(x => x.NormalizedArea == null).ToList(); + var number = matcher.LeadingNumber(townEnd); + var inRange = withoutArea.Where(x => number != null && x.BlockRange != null && x.BlockRange.Contains(number.Value)).ToList(); + if (inRange.Count > 0) return inRange; + var defaults = withoutArea.Where(x => x.IsOthers).ToList(); + if (defaults.Count > 0) return defaults; + // 決められないため、範囲に含まれない町域を除いてすべて返す + return matched.Where(x => x.NormalizedArea != null || x.BlockRange == null); } - // 「北六条東(1~7丁目)」などの括弧書きを除く - var paren = Town.IndexOf('('); - return paren >= 0 ? Town.Substring(0, paren) : Town; + // 町域名の直後が数値(丁目・番地)の場合のみ、括弧書きの範囲と比べる + return SelectByBlockNumber(matched, matcher.LeadingNumber(townEnd)); + } + + /// + /// 町域名の括弧書きの丁目・番地の範囲で絞り込む + /// + /// 町域名(地区名)が一致した町域 + /// 住所の町域名(地区名)の直後の数値(丁目または番地) + /// 候補の町域 + private static IEnumerable SelectByBlockNumber(List matched, int? number) + { + if (matched.Select(x => x.ZipCode).Distinct().Count() <= 1) return matched; + + // 括弧書きの範囲(「1~7丁目」など)に含まれる町域 + var inRange = number == null ? new List() : matched.Where(x => x.BlockRange != null && x.BlockRange.Contains(number.Value)).ToList(); + if (inRange.Count > 0) return inRange; + + // 範囲に含まれない場合は、範囲のない町域(「その他」「次のビルを除く」、括弧書きなしなど) + var rest = matched.Where(x => x.BlockRange == null).ToList(); + // 範囲以外の解釈できない括弧書きがある場合は決められないため、それらを含めて返す + return rest.Count > 0 ? rest : matched; } /// - /// 住所A(町域名までの住所)の候補 - /// 都道府県名ありとなし、町域名の前に「大字」「字」ありとなし(郵便番号データの町域名には付いていない) + /// 超高層ビルの町域名(「霞が関霞が関ビル」の「霞が関」)を、同じ市区町村の町域名から探して設定する /// - internal IEnumerable GetTownAddressCandidates() + /// 市区町村の町域 + private static void SetBuildingTowns(ZipCodeEntry[] city) { - // 「以下に掲載がない場合」は町域名がわからないため候補なし - if (IsRepresentative) yield break; + var floors = city.Where(x => x.HasFloor).ToList(); + if (floors.Count == 0) return; + + var towns = city.Where(x => !x.HasFloor && !x.IsRepresentative) + .Select(x => x.GetTownNameWithBuilding()) + .Where(x => x.Length > 0) + .Distinct() + .ToList(); + foreach (var entry in floors) + { + var name = entry.GetTownNameWithBuilding(); + var town = towns.Where(x => x.Length < name.Length && name.StartsWith(x, StringComparison.Ordinal)) + .OrderByDescending(x => x.Length) + .FirstOrDefault(); + entry.SetBuildingTown(town); + } + } + + /// + /// 市区町村のキー + /// + private static string CityKey(ZipCodeEntry entry) => entry.Prefecture + "\t" + entry.City; + + /// + /// 市区町村(都道府県名・市区町村名が同じ町域) + /// + private sealed class City + { + public City(string key, ZipCodeEntry[] towns, string[] candidates) + { + Key = key; + Towns = towns; + Candidates = candidates; + } + + /// 都道府県名と市区町村名 + public string Key { get; } + + /// 町域(事業所は含まない) + public ZipCodeEntry[] Towns { get; } + + /// 照合用に正規化した、市区町村名までの住所の候補 + public string[] Candidates { get; } + } + + /// + /// CSVファイル(zip ファイルの場合は中の最初のCSVファイル)を開く + /// + private static T OpenCsv(string path, Func read) + { + using (var file = File.OpenRead(path)) + { + if (!string.Equals(Path.GetExtension(path), ".zip", StringComparison.OrdinalIgnoreCase)) + { + return read(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 read(stream); + } + } + } + } + + /// + /// Shift_JIS + /// + private static Encoding GetShiftJis() + { + try + { + return Encoding.GetEncoding(932); + } + catch (Exception e) when (e is ArgumentException || e is NotSupportedException) + { + throw new InvalidOperationException( + "事業所の個別郵便番号データ(Shift_JIS)を読み込めません。.NET Core / .NET 5 以降では、事前に Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) を呼び出してください。", e); + } + } + + /// + /// 郵便番号データの読み込み + /// + private sealed class EntryReader + { + private readonly Dictionary> entries = new Dictionary>(); + + // 都道府県名・市区町村名は種類が少ないため、同じ文字列を共有してメモリを減らす + private readonly Dictionary names = new Dictionary(); + + /// + /// utf_ken_all.csv を読み込む + /// + public void ReadTowns(Stream stream) + { + const string name = "郵便番号データ"; + const string expected = "UTF-8形式の utf_ken_all.csv"; + ReadLines(stream, new UTF8Encoding(false), (fields, lineNumber, line) => + { + if (fields.Count != TownColumnCount) throw InvalidFormat(name, lineNumber, "列数が15ではありません", expected); + // Shift_JIS のファイルを UTF-8 として読むと置換文字になる + if (line.IndexOf((char)0xFFFD) >= 0) throw InvalidFormat(name, lineNumber, "UTF-8 として読めない文字があります", expected); + + var zipCode = CheckZipCode(fields.Get(2), name, lineNumber, expected); + // 7:都道府県名 8:市区町村名 9:町域名(漢字) 12:丁目を有する町域 + var prefecture = CheckPrefecture(fields.Get(6), name, lineNumber, expected); + Add(new ZipCodeEntry(zipCode, prefecture, Share(fields.Get(7)), fields.Get(8), fields.Is(11, "1"))); + }); + } + + /// + /// JIGYOSYO.CSV を読み込む + /// + public void ReadBusinesses(Stream stream, Encoding encoding) + { + const string name = "事業所の個別郵便番号データ"; + const string expected = "JIGYOSYO.CSV"; + ReadLines(stream, encoding, (fields, lineNumber, line) => + { + if (fields.Count != BusinessColumnCount) throw InvalidFormat(name, lineNumber, "列数が13ではありません", expected); + + // 13:修正コード(5は廃止) + if (fields.Is(12, "5")) return; + + var zipCode = CheckZipCode(fields.Get(7), name, lineNumber, expected); + // 11:個別番号の種別(0:大口事業所 1:私書箱) + ZipCodeKind kind; + if (fields.Is(10, "0")) kind = ZipCodeKind.Business; + else if (fields.Is(10, "1")) kind = ZipCodeKind.PostOfficeBox; + else throw InvalidFormat(name, lineNumber, "個別番号の種別が0か1ではありません", expected); + + // 3:大口事業所名(漢字) 4:都道府県名 5:市区町村名 6:町域名 7:小字名、丁目、番地等 + // 文字コードが違う(UTF-8 に変換したファイルなど)と、置換文字にならずに別の文字に化けるため、都道府県名で確認する + var prefecture = CheckPrefecture(fields.Get(3), name, lineNumber, expected); + Add(new ZipCodeEntry(zipCode, prefecture, Share(fields.Get(4)), fields.Get(5), fields.Get(6), fields.Get(2), kind)); + }); + } + + /// + /// 郵便番号データを作る + /// + public ZipCodeDictionary Create() + { + return new ZipCodeDictionary(entries.ToDictionary(x => x.Key, x => x.Value.ToArray())); + } + + private void Add(ZipCodeEntry entry) + { + if (!entries.TryGetValue(entry.ZipCode, out var list)) + { + list = new List(); + entries.Add(entry.ZipCode, list); + } + list.Add(entry); + } + + private string Share(string value) + { + if (names.TryGetValue(value, out var shared)) return shared; + names.Add(value, value); + return value; + } + + /// + /// CSVを1行ずつ読み込む(各項目は","区切りで、文字列項目は「"」で囲まれている。項目内に","はない) + /// + private static void ReadLines(Stream stream, Encoding encoding, Action read) + { +#if NET8_0_OR_GREATER + // 区切り位置は行ごとに使い回す(列数が多すぎる場合を検出できるよう、最大の列数 + 1) + var ranges = new Range[Math.Max(TownColumnCount, BusinessColumnCount) + 1]; +#endif + using (var reader = new StreamReader(stream, encoding, false, 4096, true)) + { + string line; + int lineNumber = 0; + while ((line = reader.ReadLine()) != null) + { + lineNumber++; + if (line.Length == 0) continue; +#if NET8_0_OR_GREATER + read(new CsvFields(line, ranges), lineNumber, line); +#else + read(new CsvFields(line), lineNumber, line); +#endif + } + } + } + + private string CheckPrefecture(string prefecture, string name, int lineNumber, string expected) + { + if (!Prefectures.Contains(prefecture)) + { + throw InvalidFormat(name, lineNumber, "都道府県名が正しくありません。文字コードが違う可能性があります", expected); + } + return Share(prefecture); + } + + private static string CheckZipCode(string zipCode, string name, int lineNumber, string expected) + { + if (zipCode.Length != 7 || !zipCode.All(c => c >= '0' && c <= '9')) + { + throw InvalidFormat(name, lineNumber, "郵便番号が7桁の数値ではありません", expected); + } + return zipCode; + } + + /// + /// 形式エラー + /// + private static InvalidDataException InvalidFormat(string name, int lineNumber, string reason, string expected) + { + return new InvalidDataException($"{name}の{lineNumber}行目の形式が正しくありません({reason})。{expected} を指定してください。"); + } + } + + /// + /// CSVの1行の項目(各項目は","区切りで、文字列項目は「"」で囲まれている。項目内に","はない) + /// + private readonly struct CsvFields + { +#if NET8_0_OR_GREATER + // 使う項目だけを文字列にするため、行は区切り位置だけを求めておく + private readonly string line; + private readonly Range[] ranges; + + public CsvFields(string line, Range[] ranges) + { + this.line = line; + this.ranges = ranges; + Count = line.AsSpan().Split(ranges, ','); + } + + /// 項目数(最大の列数を超える場合は最大の列数 + 1) + public int Count { get; } + + /// 項目(「"」は除く) + public string Get(int index) => Unquote(line.AsSpan()[ranges[index]]).ToString(); + + /// 項目(「"」は除く)が指定した値かどうか + public bool Is(int index, string value) => Unquote(line.AsSpan()[ranges[index]]).SequenceEqual(value); + + private static ReadOnlySpan Unquote(ReadOnlySpan field) + { + return field.Length >= 2 && field[0] == '"' && field[field.Length - 1] == '"' ? field.Slice(1, field.Length - 2) : field; + } +#else + private readonly string[] fields; + + public CsvFields(string line) + { + fields = line.Split(','); + } + + /// 項目数 + public int Count => fields.Length; + + /// 項目(「"」は除く) + public string Get(int index) => Unquote(fields[index]); + + /// 項目(「"」は除く)が指定した値かどうか + public bool Is(int index, string value) => Get(index) == value; - var town = GetTownName(); - var towns = town.Length == 0 ? new[] { town } : new[] { town, "大字" + town, "字" + town }; - foreach (var t in towns) + private static string Unquote(string field) { - yield return Prefecture + City + t; - yield return City + t; + return field.Length >= 2 && field[0] == '"' && field[field.Length - 1] == '"' ? field.Substring(1, field.Length - 2) : field; } +#endif } } } diff --git a/ZipConvertCustomerBarCode/ZipCodeEntry.cs b/ZipConvertCustomerBarCode/ZipCodeEntry.cs new file mode 100644 index 0000000..441d505 --- /dev/null +++ b/ZipConvertCustomerBarCode/ZipCodeEntry.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace ZipConvertCustomerBarCode +{ + /// + /// 郵便番号の種類 + /// + public enum ZipCodeKind + { + /// 町域の郵便番号 + Town, + + /// 大口事業所の個別郵便番号 + Business, + + /// 私書箱の個別郵便番号 + PostOfficeBox + } + + /// + /// 郵便番号データの1町域、または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 = "一円"; + + // 郡名を含む市区町村名(「上川郡東神楽町」など。「大和郡山市」は対象外) + private static readonly Regex DistrictRegex = new Regex("^.+?郡(?.+[町村])$"); + + // 事業所名の照合で区別しない会社の種類 + private static readonly string[] CompanyTypes = { "株式会社", "有限会社", "合同会社", "合資会社", "合名会社", "(株)", "(株)", "㈱", "(有)", "(有)", "㈲" }; + + // 括弧書き: 超高層ビルの階(「1階」) + private static readonly Regex FloorRegex = new Regex("^(?[0-9]+)階$"); + + // 括弧書き: 地区名、地区名 + 番地の範囲、地区名 + その他(「稲田」「国見23~298番地」「国見その他」) + private static readonly Regex AreaRegex = new Regex("^(?[^0-9、・〜~]+?)(?その他|[0-9].*)?$"); + + /// + /// 町域を生成する + /// + internal ZipCodeEntry(string zipCode, string prefecture, string city, string town, bool hasChome) + { + ZipCode = zipCode; + Prefecture = prefecture; + City = city; + Town = town; + Kind = ZipCodeKind.Town; + ParseParenthesis(hasChome); + } + + /// + /// 事業所を生成する + /// + internal ZipCodeEntry(string zipCode, string prefecture, string city, string town, string block, string businessName, ZipCodeKind kind) + { + ZipCode = zipCode; + Prefecture = prefecture; + City = city; + Town = town; + Block = block; + BusinessName = businessName; + Kind = kind; + NormalizedBusinessName = NormalizeBusinessName(businessName); + } + + /// 郵便番号(半角数字7桁) + public string ZipCode { get; } + + /// 都道府県名 + public string Prefecture { get; } + + /// 市区町村名 + public string City { get; } + + /// 町域名(郵便番号データの表記のまま) + public string Town { get; } + + /// 郵便番号の種類 + public ZipCodeKind Kind { get; } + + /// 事業所名(大口事業所・私書箱の個別郵便番号の場合。町域の場合は null) + public string BusinessName { get; } + + /// 小字名、丁目、番地等(大口事業所・私書箱の個別郵便番号の場合。町域の場合は null) + public string Block { get; } + + /// + /// 代表の郵便番号(町域名が「以下に掲載がない場合」)かどうか + /// + public bool IsRepresentative => Kind == ZipCodeKind.Town && Town == NotListed; + + /// + /// 大口事業所・私書箱の個別郵便番号かどうか + /// + public bool IsIndividual => Kind != ZipCodeKind.Town; + + /// + /// 町域名の括弧書きの丁目・番地の範囲(「1~7丁目」など)。範囲でない場合は null + /// + internal BlockNumberRange BlockRange { get; private set; } + + /// + /// 超高層ビルの階(「霞が関ビル(1階)」の1)。階でない場合は null + /// + internal int? Floor { get; private set; } + + /// + /// 超高層ビルの「(地階・階層不明)」かどうか + /// + internal bool IsUnknownFloor { get; private set; } + + /// + /// 超高層ビルかどうか(ビル名がわかった場合のみ) + /// + internal bool IsBuilding => NormalizedBuildingName != null; + + /// + /// 照合用に正規化したビル名(「霞が関霞が関ビル」の「霞が関ビル」) + /// + internal string NormalizedBuildingName { get; private set; } + + /// + /// 照合用に正規化した地区名(「音江町(稲田)」の「稲田」)。地区名でない場合は null + /// + internal string NormalizedArea { get; private set; } + + /// + /// 括弧書きが「その他」「次のビルを除く」など(ほかに当てはまらない場合の郵便番号)かどうか + /// + internal bool IsOthers { get; private set; } + + /// + /// 照合用に正規化した事業所名 + /// + internal string NormalizedBusinessName { get; } + + // ビルの町域名(「霞が関霞が関ビル」の「霞が関」) + private string buildingTownName; + + /// + /// 照合用の町域名(特殊な値の場合は空、括弧書きは除く。超高層ビルはビル名を除く) + /// + internal string GetTownName() + { + if (buildingTownName != null) return buildingTownName; + return GetTownNameWithBuilding(); + } + + /// + /// 町域名(特殊な値の場合は空、括弧書きは除く。超高層ビルはビル名を含む) + /// + internal string GetTownNameWithBuilding() + { + if (IsIndividual) return Town; + + // 「○○市の次に番地がくる場合」「○○町一円」は町域名がない + 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; + } + + // 照合用に正規化した町域名(住所から郵便番号を探すときに初めて作る) + private string normalizedTownName; + + /// + /// 照合用に正規化した町域名( を正規化したもの) + /// + internal string NormalizedTownName => normalizedTownName ?? (normalizedTownName = AddressMatcher.Normalize(GetTownName())); + + /// + /// 超高層ビルの町域名とビル名を設定する + /// + /// 町域名(「霞が関」)。同じ市区町村にない場合は null + internal void SetBuildingTown(string townName) + { + if (townName == null) + { + // ビル名がわからない場合は、解釈できない括弧書きとして扱う + Floor = null; + IsUnknownFloor = false; + return; + } + buildingTownName = townName; + NormalizedBuildingName = AddressMatcher.Normalize(GetTownNameWithBuilding().Substring(townName.Length)); + } + + /// + /// 超高層ビルの階の括弧書きかどうか(ビル名を設定する前) + /// + internal bool HasFloor => Floor != null || IsUnknownFloor; + + /// + /// 町域名の括弧書きを解析する + /// + /// 丁目を有する町域かどうか + private void ParseParenthesis(bool hasChome) + { + var start = Town.IndexOf('('); + var end = Town.LastIndexOf(')'); + if (start < 0 || end <= start) return; + var paren = string.Concat(Town.Substring(start + 1, end - start - 1).Select(BarCode.ToHankaku)); + + if (paren == "地階・階層不明") + { + IsUnknownFloor = true; + return; + } + var floor = FloorRegex.Match(paren); + if (floor.Success) + { + Floor = int.Parse(floor.Groups["floor"].Value, CultureInfo.InvariantCulture); + return; + } + + var range = BlockNumberRange.Parse(paren); + if (range != null) + { + BlockRange = AcceptRange(range, hasChome); + return; + } + + if (paren == "その他" || paren.Contains("除く")) + { + IsOthers = true; + return; + } + + var area = AreaRegex.Match(paren); + if (!area.Success) return; + var name = area.Groups["name"].Value; + if (name.StartsWith("大字", StringComparison.Ordinal)) name = name.Substring(2); + else if (name.StartsWith("字", StringComparison.Ordinal)) name = name.Substring(1); + if (name.Length == 0 || name.Contains("その他") || name.Contains("除く") || name.Contains("階") || name.Contains("(")) return; + + var tail = area.Groups["tail"].Value; + if (tail == "その他") + { + IsOthers = true; + } + else if (tail.Length > 0) + { + var areaRange = BlockNumberRange.Parse(tail); + if (areaRange == null) return; + BlockRange = AcceptRange(areaRange, hasChome); + } + NormalizedArea = AddressMatcher.Normalize(name); + } + + /// + /// 丁目を有する町域の番地の範囲は、住所の最初の数値(丁目)と比べられないため扱わない + /// + private static BlockNumberRange AcceptRange(BlockNumberRange range, bool hasChome) + { + return range.IsChome || !hasChome ? range : null; + } + + /// + /// 照合用の事業所名の正規化(空白、全角・半角、「株式会社」「(株)」などの違いは区別しない) + /// + /// 事業所名 + /// 正規化した事業所名 + internal static string NormalizeBusinessName(string name) + { + var normalized = AddressMatcher.Normalize(name); + foreach (var word in CompanyTypes) + { + normalized = normalized.Replace(word, ""); + } + return normalized; + } + + // 郡名を除いた市区町村名(初めて使うときに作る。郡名がない場合は空) + private string cityWithoutDistrict; + + /// + /// 郡名を除いた市区町村名(「上川郡東神楽町」の「東神楽町」)。郡名がない場合は null + /// + internal string CityWithoutDistrict + { + get + { + if (cityWithoutDistrict == null) + { + var district = DistrictRegex.Match(City); + cityWithoutDistrict = district.Success ? district.Groups["name"].Value : ""; + } + return cityWithoutDistrict.Length > 0 ? cityWithoutDistrict : null; + } + } + + /// + /// 市区町村名までの住所の候補 + /// 都道府県名ありとなし、郡名ありとなし + /// + internal IEnumerable GetCityCandidates() + { + yield return Prefecture + City; + yield return City; + + if (CityWithoutDistrict != null) + { + yield return Prefecture + CityWithoutDistrict; + yield return CityWithoutDistrict; + } + } + } + + /// + /// 町域名の括弧書きの丁目・番地の範囲(「1~7丁目」「1・3丁目」「1~500番地」「100番地以上」など) + /// + internal sealed class BlockNumberRange + { + // 数値または数値の範囲を「、」「・」で列挙し、最後に単位 + private static readonly Regex RangeRegex = new Regex(@"^(?[0-9]+([〜~][0-9]+)?([、・][0-9]+([〜~][0-9]+)?)*)(?丁目|番地|番)(?以上|以下)?$"); + + private readonly (int From, int To)[] ranges; + + private BlockNumberRange((int From, int To)[] ranges, bool isChome) + { + this.ranges = ranges; + IsChome = isChome; + } + + /// + /// 単位が丁目かどうか(丁目でなければ番地) + /// + public bool IsChome { get; } + + /// + /// 範囲に含まれるかどうか + /// + public bool Contains(int number) => ranges.Any(x => x.From <= number && number <= x.To); + + /// + /// 括弧書きの中身を解析する + /// + /// 括弧書きの中身 + /// 範囲。範囲として解析できない場合は null + public static BlockNumberRange Parse(string text) + { + var match = RangeRegex.Match(string.Concat(text.Select(BarCode.ToHankaku))); + if (!match.Success) return null; + + var items = match.Groups["items"].Value.Split('、', '・').Select(item => + { + var bounds = item.Split('〜', '~').Select(x => int.Parse(x, CultureInfo.InvariantCulture)).ToArray(); + return (From: bounds[0], To: bounds[bounds.Length - 1]); + }).ToArray(); + + switch (match.Groups["compare"].Value) + { + case "以上": + if (items.Length != 1) return null; + items[0].To = int.MaxValue; + break; + case "以下": + if (items.Length != 1) return null; + items[0].From = 0; + break; + } + return new BlockNumberRange(items, match.Groups["unit"].Value == "丁目"); + } + } +} diff --git a/ZipConvertCustomerBarCode/ZipConvertCustomerBarCode.csproj b/ZipConvertCustomerBarCode/ZipConvertCustomerBarCode.csproj index b151451..cea720f 100644 --- a/ZipConvertCustomerBarCode/ZipConvertCustomerBarCode.csproj +++ b/ZipConvertCustomerBarCode/ZipConvertCustomerBarCode.csproj @@ -1,7 +1,8 @@  - netstandard2.0 + + netstandard2.0;net8.0 郵便番号・住所から日本郵便のカスタマバーコードを生成する(描画ライブラリ非依存) true