業務アプリの和暦・祝日・締め日処理 ── 改元に強い設計とJapaneseCalendar・営業日計算の実務

· · C#, .NET, 和暦, 祝日, 締め日, 日時処理, 業務アプリ, Windows開発, 技術相談

「帳票の日付は和暦で」「支払期日は20日締め翌月末払い、末日が休日なら前営業日」「集計は年度単位で」──日本の業務アプリには、この国でしか通用しない日付要件が標準装備されています。そしてこの領域には、一般的な日時処理とは別種の罠が埋まっています。元号は改元で増え、祝日は法改正で動き、「月末」は月によって長さが違う。つまり、どれも「あとから変わる仕様」なのです。

前回の「業務アプリの日時とタイムゾーン」では、UTC保存の原則とタイムゾーンの扱いを整理しました。今回はその続編として、日本ローカルの三大要件──和暦・祝日・締め日──を扱います。改元対応の設計、祝日をハードコードしてはいけない理由とデータでの持ち方、AddMonths の丸め仕様を踏まえた締め日計算まで、実装例と判断表で見ていきます。

1. まず結論

  • 内部処理と保存は西暦(グレゴリオ暦)、和暦は表示の瞬間だけ。和暦文字列での保存は、ソート不能・改元での表記混在・不正日付の混入という三重苦になります。
  • 和暦変換は自前の対応表ではなく JapaneseCalendar に任せます。元号の定義はOS側から供給されるため、改元時はプラットフォーム更新で追従します。1 アプリに残した「平成」「令和」のハードコードだけが取り残されます。
  • 祝日は計算式では判定できません。春分・秋分は前年に確定し、法改正や特別措置法で祝日自体が動くためです。2 祝日マスタ+更新運用で設計し、データソースには内閣府公表の祝日CSVが使えます。
  • 「月末」「営業日」「年度」はコードの前に仕様で定義するもの。特に AddMonths は月末を自動で丸めるため、締め日計算は基準日から都度計算が原則です。3

データの種類ごとの持ち方を先にまとめます。

データ 持ち方 変換・判定のタイミング
日付そのもの(受注日・支払期日) 西暦の DateOnly / DateTime(DB は date 型)
和暦表記(帳票・画面) 持たない 表示・印字の瞬間に JapaneseCalendar で変換
祝日 祝日マスタ(テーブル or 設定ファイル) 営業日計算時にマスタ参照
会社休業日(夏季休業・創立記念日) 祝日マスタとは別の休業日マスタ 同上(祝日と混ぜない)
締め日・支払サイト 取引先マスタの属性(「20日締め翌月末払い」) 請求処理時に基準日から計算
年度 持たない(導出値) 表示・集計時に日付から計算

2. 和暦 ── 表示だけの存在にする

2.1 なぜ内部を西暦に統一するのか

和暦は人間向けの表記であって、計算に向いた表現ではありません。「平成9年」と「令和9年」は文字列比較では順序が決められず、元号をまたいだ期間計算には結局西暦への変換が要ります。さらに和暦は改元のたびに体系が増えるので、和暦のまま保存されたデータは改元のたびに新旧表記が混在していきます。「昭和64年1月10日」(実在しない。1月8日から平成)のような不正データの混入も、和暦入力を生のまま保存するシステムの定番トラブルです。

もうひとつ見落とされがちな制約として、.NETの JapaneseCalendar がサポートするのは明治元年(1868年)9月8日以降です。4 それより前の日付──たとえば戸籍由来の古い生年月日──は和暦のままでは正しく扱えません。内部を西暦のグレゴリオ暦に統一しておけば、これらの問題はすべて「表示層だけの話」に閉じ込められます。

2.2 JapaneseCalendarでの表示と入力

.NETで複数の元号(era)を扱えるカレンダーは JapaneseCalendar(と JapaneseLunisolarCalendar)だけで、書式指定子 g が元号名に対応します。1 和暦で表示する最小コードはこうなります。

using System.Globalization;

var jaJpWareki = new CultureInfo("ja-JP");
jaJpWareki.DateTimeFormat.Calendar = new JapaneseCalendar();

var date = new DateOnly(2026, 7, 11);
Console.WriteLine(date.ToString("ggy年M月d日", jaJpWareki));  // 令和8年7月11日
Console.WriteLine(date.ToString("ggyy/MM/dd", jaJpWareki));   // 令和08/07/11

和暦の入力を受ける場合は、自由入力の文字列パースではなく「元号をコンボボックスで選ばせ、年月日は数値で受ける」UIにするのが安全です。どうしても文字列で受けるなら ParseExact で書式を固定します。

var input = "令和8年7月11日";
var parsed = DateTime.ParseExact(input, "ggy年M月d日", jaJpWareki);
Console.WriteLine(parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));  // 2026-07-11

元年の表記には注意が必要です。改元対応の更新が適用された環境では、各元号の1年目は既定で「令和元年」のように元年と整形され、数字の「令和1年」に戻すための互換スイッチ(Switch.System.Globalization.FormatJapaneseFirstYearAsANumber)が用意されています。5 帳票の様式が「令和1年」と「令和元年」のどちらを要求しているかは、実装前に仕様として確認してください。もう1つ、既定では元号の年範囲チェックは緩和(relaxed)されており、「平成33年」のような実在しない年の指定も受理されて実際の日付(令和3年)に換算されます。入力検証として厳格に弾きたい場合は Switch.System.Globalization.EnforceJapaneseEraYearRanges で強制できます。5 緩和のままにするか厳格にするかは、既存データの汚れ具合(改元前に印字・入力された「平成32年」をどう扱うか)で決めることになります。

2.3 改元に強いコードと、取り残されるもの

令和改元(2019年)の経験から言えるのは、プラットフォームは追従するが、アプリの自前実装は追従しないということです。元号の定義は.NETにハードコードされておらず、OS側が持つ元号情報を参照する仕組みになっているため、OSと.NETの更新を適用していれば JapaneseCalendar の変換・整形は新元号に追従します。1 取り残されるのは次のようなアプリ側の資産です。

  • if (year >= 2019) era = "令和"; のような自前の元号変換テーブル・分岐
  • 帳票テンプレート・Excelセル書式・印刷済み用紙に直接書かれた元号
  • 「R」「H」などの元号アルファベット1文字を独自ルールで扱うコード(基幹システムとの連携フォーマットに多い)
  • 和暦のまま保存されたデータそのもの

改元対応の改修見積もりでは、まずソースとテンプレート全体を「昭和・平成・令和・元年」で機械的にgrepして棚卸しします。VB6やAccessの時代から動いている業務アプリには自前変換テーブルがほぼ確実に埋まっているので、「VB6 / Access業務アプリの延命と移行」で書いた延命判断とセットで扱うことが多い論点です。

2.4 Windowsの地域設定という伏兵

見落とされやすい罠をもう1つ。Windowsの地域設定では、ユーザーがOSのカレンダーの種類を「和暦」に変更できますCultureInfo.CurrentCulture は既定でユーザーの設定変更(ユーザーオーバーライド)を反映するため、6 この設定の端末では、カルチャ既定の DateTime.ToString() が突然「令和08/07/11」のような和暦文字列を返すようになります。カルチャ既定の ToString() でログやCSVやDBに日付を書き出しているコードがあると、その端末でだけ保存データに和暦が混入し、後段のパースが壊れます。

対策は前回記事と同じ結論に帰着します。境界(保存・通信・ログ)を越える日付文字列は必ず CultureInfo.InvariantCulture と明示書式で書き出す。カルチャ既定の書式は画面表示専用です。カルチャが表示に与える影響全般は「WinForms/WPFアプリの多言語化」も参考になります。

3. 祝日 ── ハードコードしてはいけない理由とマスタ運用

3.1 祝日は「変わる」

祝日判定を switch (month, day) で書いてはいけません。祝日は国民の祝日に関する法律で定められており、法改正と運用でたびたび変わってきたからです。2 近年の実例だけでも次のとおりです。

変更
2016 山の日(8月11日)が新設される
2019 改元に伴い天皇誕生日が12月23日→2月23日へ。2019年には天皇誕生日が存在しない
2019 皇位継承に伴う特例で5月1日(即位の日)などが臨時の祝日・休日に
2020 体育の日がスポーツの日に改称
2020・2021 五輪特別措置法で海の日・スポーツの日・山の日がその年だけ移動

さらに、春分の日と秋分の日はそもそも法律に日付が書かれていません。天文計算に基づいて前年に確定・公表される仕組みなので、未来の春分・秋分を確実に知る方法は公表を待つ以外にないのです。2 「祝日は毎年同じ」という前提の計算式は、原理的に成立しません。

3.2 データソースと持ち方の判断表

祝日判定は「データ+更新運用」で設計します。主な選択肢を比較します。

方式 内容 向くケース 注意点
祝日マスタ(自前管理) テーブルに年月日と名称を持ち、管理画面で更新 会社休業日と一体で運用したい業務システム全般 更新を業務運用に組み込む(年1回の確認をカレンダー化)
内閣府CSVの取り込み 内閣府公表の祝日CSVを取り込んでマスタを更新 マスタ更新の一次ソースが欲しい場合(推奨) 公表済みの期間しか入っていない。文字コードに注意
祝日ライブラリ(NuGet等) 計算ロジック+収録データで判定 社内ツールなど更新運用を持てない小規模用途 将来の法改正はライブラリ更新頼み。収録ルールの検証が必要
ハードコード ソースに日付を直書き なし 法改正のたびに全クライアント再配布になる

一次ソースとしては、内閣府が「国民の祝日」ページで公表している昭和30年(1955年)から翌年分までの祝日月日・名称のCSVが使えます。2 取り込み時の実務的な注意は2つで、(1)公表されているのは確定分だけなので、翌々年以降の祝日を尋ねられたときの挙動(エラーにするか、法律ベースの暫定値で埋めて「暫定」フラグを付けるか)を仕様で決めておくこと、(2)このCSVの文字コードはShift_JIS系なので、読み込みは「CSVは「ただのテキスト」ではない」で書いたとおりエンコーディングを明示することです。

3.3 振替休日・国民の休日と営業日計算

祝日法には日付の一覧以外に2つの派生ルールがあります。振替休日(祝日が日曜日に当たるとき、その日後の最も近い「祝日でない日」を休日とする)と、国民の休日(前日と翌日が祝日に挟まれた平日を休日とする)です。2 内閣府CSVにはこれらも行として含まれているため、CSV取り込み方式ならマスタ参照だけで済みます。自前で計算補完する場合も、このロジックは法律由来なので比較的安定しています。

営業日計算は「土日でない・祝日マスタにない・会社休業日マスタにない」の3条件で書きます。

public sealed class BusinessDayCalendar
{
    private readonly HashSet<DateOnly> _publicHolidays;   // 祝日マスタ(振替休日含む)
    private readonly HashSet<DateOnly> _companyHolidays;  // 会社休業日マスタ

    public BusinessDayCalendar(IEnumerable<DateOnly> publicHolidays,
                               IEnumerable<DateOnly> companyHolidays)
    {
        _publicHolidays = [.. publicHolidays];
        _companyHolidays = [.. companyHolidays];
    }

    public bool IsBusinessDay(DateOnly d) =>
        d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)
        && !_publicHolidays.Contains(d)
        && !_companyHolidays.Contains(d);

    // 期日が休日なら「前営業日へ倒す」。後ろへ倒すかは契約仕様で決める
    public DateOnly ToPreviousBusinessDay(DateOnly d)
    {
        while (!IsBusinessDay(d)) d = d.AddDays(-1);
        return d;
    }
}

祝日と会社休業日を別のマスタに分けているのは意図的です。「銀行振込の期日判定は祝日だけ見る(自社の夏季休業は関係ない)」「出荷日の判定は両方見る」のように、用途によって参照する休日の集合が違うからです。1つのマスタに混ぜてしまうと、あとから分離する改修は保存済みデータの考古学になります。

4. 締め日・支払サイト・年度 ── 「月末」を計算で正しく扱う

4.1 AddMonthsの丸め仕様

締め日計算の最初の罠は AddMonths です。DateTime.AddMonths / DateOnly.AddMonths は、結果の月に存在しない日になる場合、その月の最終日に丸めます3

var jan31 = new DateOnly(2026, 1, 31);
var feb = jan31.AddMonths(1);   // 2026-02-28 ── 月末に丸められる(正しい)
var mar = feb.AddMonths(1);     // 2026-03-28 ── 「月末」のつもりが28日に化ける

丸め自体は正しい仕様ですが、丸めた結果に対してさらに AddMonths を重ねると「月末」という意図が失われます。毎月の請求スケジュールを「前回実行日+1か月」で組んでいると、2月を通過した瞬間から永久に28日実行に化ける、という形で顕在化します。対策は2つです。

  • 繰り返しの日付は前回結果からではなく、基準の定義(「毎月末」「毎月20日」)から都度計算する
  • 「月末」は日付リテラルではなく DateTime.DaysInMonth(y, m) から導出する

4.2 「20日締め翌月末払い」の実装

締め日と支払サイトは取引先ごとの属性です。「20日締め翌月末払い」をコードにすると次のようになります。

public static class PaymentTerms
{
    // 取引日 → 締め日(毎月20日締めの例)
    public static DateOnly GetClosingDate(DateOnly tradeDate, int closingDay)
    {
        var dayInMonth = Math.Min(closingDay, DateTime.DaysInMonth(tradeDate.Year, tradeDate.Month));
        var closing = new DateOnly(tradeDate.Year, tradeDate.Month, dayInMonth);
        return tradeDate <= closing ? closing : closing.AddMonths(1);
        // 注: closingDay=31(月末締め)は AddMonths の丸めで正しく翌月末になる
    }

    // 締め日 → 支払期日(翌月末払いの例)。休日調整は仕様(前倒し/後ろ倒し)を確認して適用
    public static DateOnly GetPaymentDueDate(DateOnly closingDate, BusinessDayCalendar calendar)
    {
        var nextMonth = closingDate.AddMonths(1);
        var endOfMonth = new DateOnly(nextMonth.Year, nextMonth.Month,
                                      DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month));
        return calendar.ToPreviousBusinessDay(endOfMonth);  // 「休日なら前営業日」の契約の場合
    }
}

コードより重要なのは、その手前の仕様の確定です。「月末締め」の月末は暦日か営業日か。期日が休日のとき前倒しか後ろ倒しか(支払う側は前倒し、受け取る側の入金予定は後ろ倒し、と立場で慣行が違います)。29〜31日締めの契約は2月にどう解釈するか。──ここが文書化されていない場合、実装者のコードがそのまま「誰も合意していない仕様」になります。締め日まわりのバグ調査の多くは、コードの誤りではなく仕様の不在にたどり着きます。

4.3 年度と四半期

日本の業務アプリの「年度」はほぼ4月始まりです。年度は保存せず、日付からの導出値として扱います。

public static int GetFiscalYear(DateOnly d) => d.Month >= 4 ? d.Year : d.Year - 1;
// 2026-03-31 → 2025年度、2026-04-01 → 2026年度

public static int GetFiscalQuarter(DateOnly d) => ((d.Month + 8) % 12) / 3 + 1;
// 4-6月 → Q1、7-9月 → Q2、10-12月 → Q3、1-3月 → Q4

注意点は「年度」を安易にDBへ冗長保存しないことです。日付があれば常に導出できる値を別カラムに持つと、日付修正時の更新漏れで日付と年度が食い違うデータが生まれます。集計性能のためにどうしても持つ場合は、計算列(生成列)にしてアプリからの書き込みを禁じます。

4.4 テスト ── 「その日にだけ」動くバグを先に踏む

和暦・祝日・締め日のバグは、特定の日付でしか発症しません。前回記事で紹介した TimeProvider による現在時刻の注入(「業務アプリの日時とタイムゾーン」7章)を前提に、この領域で最低限テストしておきたい日付は次の5つです。

  • 改元境界: 1989-01-07/08(昭和→平成)、2019-04-30/05-01(平成→令和)の表示と、和暦入力の逆変換
  • 元年: 「令和元年」表記の帳票確認(2.2節)
  • うるう年: 2月29日を含む締め・支払サイト計算(2024年、2028年)
  • 月末の連鎖: 1月31日起点で12か月分の締め日を生成して月末が保たれるか(4.1節)
  • 年度境界: 3月31日と4月1日の年度・四半期判定

加えてレビュー時には、次のパターンをgrepで機械的に拾えます。

見つけたら疑うコード 何が起きうるか 直し方
"平成" "令和" などの元号リテラル 次の改元で取り残される JapaneseCalendar + g 書式へ(2.3節)
祝日の日付リテラル(5, 3 など) 法改正・特措法で誤判定 祝日マスタ参照へ(3.2節)
引数なし ToString() の保存・出力経路 和暦設定端末で保存データに和暦混入 InvariantCulture + 明示書式(2.4節)
前回日付.AddMonths(1) の繰り返し 2月通過後に月末がずれ続ける 基準日から都度計算(4.1節)
new DateTime(y, m, 31) 30日以下の月で例外 DateTime.DaysInMonth から導出

5. まとめ

和暦・祝日・締め日は、どれも「あとから変わる仕様」を相手にする処理です。だから設計の原則も共通しています。変わるものはコードに書かず、データと変換層に隔離する──内部と保存は西暦で統一して和暦は表示専用の変換にし、祝日はマスタと更新運用で持ち、「月末」「営業日」「期日の休日調整」は契約仕様として文書化してから基準日ベースで計算する。ここまでやれば、次の改元も、次の祝日法改正も、アプリにとっては「マスタの更新」と「OSの更新適用」で済むイベントになります。

当社では、帳票・請求まわりの日付処理の設計と実装、既存アプリに埋まった元号・祝日ハードコードの棚卸しと改修、締め日仕様の整理を含む設計レビューを扱っています。「改元対応がどこに何件あるか分からない」という段階からでもお手伝いできます。

関連記事

関連する相談領域

合同会社小村ソフトでは、日本の商習慣に沿った業務アプリの日付処理(和暦帳票・営業日計算・締め日処理)の設計と実装、既存アプリの改元対応・祝日対応の棚卸しと改修を扱っています。

参考リンク

  1. Microsoft Learn, Work with eras - .NET. JapaneseCalendarとJapaneseLunisolarCalendarが複数の元号(era)を認識する唯一のカレンダークラスであること、書式指定子「g」による元号の整形、元号情報がプラットフォーム側から供給されアプリのハードコードに依存しないことについて。  2 3

  2. 内閣府, 「国民の祝日」について. 国民の祝日に関する法律に基づく祝日の一覧、春分の日・秋分の日が前年に確定して公表されること、振替休日・国民の休日の規定、昭和30年から翌年までの祝日月日・名称のCSVの提供について。  2 3 4 5

  3. Microsoft Learn, DateTime.AddMonths(Int32) Method. 加算結果の日が結果の月の日数を超える場合、結果の月の最終日に調整されること(1月31日+1か月が2月28日/29日になる例)について。  2

  4. Microsoft Learn, JapaneseCalendar Class. JapaneseCalendarがサポートする日付範囲が明治元年(西暦1868年)9月8日以降であること、元号(era)の扱いについて。 

  5. Microsoft Learn, Work with calendars - .NET. 元号の1年目を「元年」と整形する既定動作と数字表記に戻すSwitch.System.Globalization.FormatJapaneseFirstYearAsANumberスイッチ、元号の年範囲チェックが既定で緩和されておりSwitch.System.Globalization.EnforceJapaneseEraYearRangesで厳格化できることについて。  2

  6. Microsoft Learn, CultureInfo.CurrentCulture Property. CurrentCultureが既定でWindowsのユーザーによる地域設定の変更(ユーザーオーバーライド)を反映すること、書式整形の既定値がこのカルチャに依存することについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

データベースに和暦のまま保存してはいけないのですか?
おすすめしません。理由は4つあります。第一に、和暦文字列は「平成9年」と「令和9年」のように元号をまたぐと文字列比較で大小関係が壊れ、ソートも範囲検索もできなくなります。第二に、次の改元が起きた瞬間、保存済みデータと新しいデータの表記が混在します。第三に、「昭和64年1月8日」のような実在しない日付(その日は既に平成元年)を含む汚れたデータが混入しやすく、パースが失敗する時限爆弾になります。第四に、.NETのJapaneseCalendarが扱えるのは明治元年(1868年)9月8日以降で、それより古い生年月日などは和暦のままでは正しく扱えません。内部処理と保存は西暦(グレゴリオ暦)で統一し、和暦は画面と帳票に出す瞬間だけ変換するのが原則です。
次の改元が来たら、アプリはどう備えておけばよいですか?
元号の定義自体は.NETにハードコードされておらず、OS側の元号情報を参照する仕組みになっているため、OSと.NETの更新を適用していれば「新しい元号での表示」自体は追従します。令和改元のときも、対応の中心はプラットフォーム更新の適用でした。問題はアプリ側のコードとデータです。「平成」「令和」を文字列でハードコードした分岐、元号と西暦の対応表を自前で持つ変換関数、帳票テンプレートに直接書かれた元号、印刷済み様式の「令和 年」欄などは自動では追従しません。備えとしては、(1)内部・保存を西暦で統一して和暦を表示専用にする、(2)自前の元号変換をやめてJapaneseCalendarに寄せる、(3)ソース全体を「昭和・平成・令和」でgrepして棚卸しする、(4)架空の元号切り替えを想定したテストを用意する、の4点が現実的です。
祝日は計算式で判定できないのですか?なぜデータで持つ必要があるのですか?
完全な計算式は原理的に作れません。理由は3つあります。第一に、春分の日と秋分の日は天文計算に基づいて閣議を経て前年に確定する仕組みで、法律に固定日付として書かれていません。第二に、祝日は法改正で追加・移動・改称されます。実際に2016年の山の日新設、2019年の天皇誕生日の12月23日から2月23日への移動(2019年は天皇誕生日なし)、体育の日からスポーツの日への改称がありました。第三に、2020年・2021年には特別措置法で海の日・スポーツの日・山の日が五輪に合わせて移動しており、「毎年第3月曜日」のようなルール自体が臨時に上書きされることがあります。したがって祝日判定は「祝日マスタ(データ)+更新運用」で設計し、計算ロジックは振替休日などの補助に留めるのが正解です。
AddMonths(1)を使ったら支払期日が月末からずれました。なぜですか?
DateTime.AddMonthsは、計算結果の月に存在しない日になる場合、その月の最終日へ丸める仕様だからです。1月31日にAddMonths(1)すると2月28日(うるう年は29日)になります。これ自体は正しい動作ですが、丸めた結果をさらに翌月へ持ち越すと、2月28日にAddMonths(1)した結果は3月28日となり、「月末のつもりが28日に化けた」まま以後ずっとずれ続けます。対策は、繰り返し計算では前回の結果に足すのではなく必ず基準日(契約日や「毎月末」という定義)から都度計算すること、「月末」を日付リテラルではなくDateTime.DaysInMonthで導出することの2点です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る