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

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

更新履歴(3件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
月末締めの計算がなぜ安全かを具体例で説明しました(取引日から毎回組み立て直しており、支払期日も年月しか読まずに日を作り直しているため)。祝日マスタのDDL例と暫定フラグの運用、内閣府CSVの取り込みコード、`AppContext`スイッチの設定方法3通り、和暦の緩和と厳格の対比表(厳格にしても年の範囲に収まる日付は弾けないこと)を追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589993)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「業務アプリの和暦・祝日・締め日処理 ── 改元に強い設計とJapaneseCalendar・営業日計算の実務」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589993 https://staging.comcomponent.com/blog/japanese-calendar-holidays-closing-date-guide/

DOI(最新版)
10.5281/zenodo.21589993
DOI(この版)
10.5281/zenodo.21732974

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

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

1. まず結論

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

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

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

この記事の知識マップ

日本の業務アプリに特有の和暦・祝日・締め日は、いずれも「あとから変わる仕様」であり、コードではなくデータと変換層に隔離して設計するべきである。和暦はJapaneseCalendarに変換を任せて内部は西暦で統一し、改元への追従はOS更新に依存する一方、自前の元号ハードコードや和暦文字列での保存は改元のたびに取り残される。祝日は春分・秋分の確定時期や法改正・特別措置法で変わるため計算式では判定できず、内閣府CSVを取り込む祝日マスタと更新運用で持つ。締め日計算はAddMonthsの月末丸め仕様を理解したうえで、前回結果への累積を避け、基準日とDateTime.DaysInMonthから都度計算することでずれを防げる。

和暦・祝日・締め日処理の知識マップJapaneseCalendarによる和暦変換、内閣府CSVを使う祝日マスタ運用、AddMonthsの丸め仕様を踏まえた締め日計算という3つの日本特有の日付処理と、それぞれの落とし穴の関係を示す図利用する前提とするで構成できるで構成できる軽減する用いるのは非推奨原因になり得る原因になり得る防止する推奨される対応用いるのは非推奨利用する前提とする利用する利用するで構成できる利用する利用する利用する利用する利用する原因になり得る用いるのは非推奨利用するJapaneseCalendarクラス祝日マスタ締め日計算元号(和暦)Windows UpdateFormatJapaneseFirstYearAsANumberスイッチEnforceJapaneseEraYearRangesスイッチ存在しない和暦日付元号のハードコード和暦文字列での保存CultureInfo.CurrentCultureのユーザーオーバーライド保存データへの和暦混入CultureInfo.InvariantCulture祝日判定祝日のハードコード内閣府祝日CSV振替休日国民の休日暫定フラグ運用営業日判定(BusinessDayCalendar)会社休業日マスタAddMonthsの月末丸め仕様DateTime.DaysInMonthAddMonthsの累積(前回結果への加算)締め日のずれ(月末の連鎖崩れ)年度

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全24件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

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にするのが安全です。画面としては次の並びで足ります。

[元号 ▼]  [ 8 ] 年  [ 7 ] 月  [ 11 ] 日        西暦: 2026-07-11
  • 元号 ── コンボボックス。既定値は現在の元号。過去データを入力する画面なら、扱う範囲の元号だけを候補に出します。
  • 年・月・日 ── 数値入力。年の上限は元号によって違うので、選ばれた元号に応じて入力可能な範囲を切り替えます。
  • 西暦のプレビュー ── 変換後の西暦をその場で表示します。次に述べる「昭和64年1月10日」のような組み合わせに入力者が気づけるのは、この表示があるときだけです。
  • 保持する値 ── 画面が持つのは西暦です。和暦の3項目は入力の道具にすぎません。

どうしても文字列で受けるなら 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年」をどう扱うか)で決めることになります。

これらのスイッチは、コードからではなく構成ファイルで設定します。5 .NET (Core)/.NET 5以降はランタイム構成の configProperties に書きます。

{
  "runtimeOptions": {
    "configProperties": {
      "Switch.System.Globalization.FormatJapaneseFirstYearAsANumber": true,
      "Switch.System.Globalization.EnforceJapaneseEraYearRanges": true
    }
  }
}

プロジェクトで管理する場合は、runtimeconfig.template.jsonconfigProperties の部分を書いておくと、ビルド時に生成される <アプリ名>.runtimeconfig.json へ反映されます。.NET Framework 4.6以降は app.configAppContextSwitchOverrides です。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <runtime>
    <AppContextSwitchOverrides value="Switch.System.Globalization.EnforceJapaneseEraYearRanges=true" />
  </runtime>
</configuration>

.NET Framework 4.5.2以前を保守している場合は、HKEY_LOCAL_MACHINE\Software\Microsoft\.NETFramework\AppContext に同名のエントリを REG_SZ で置く方式になります。5

なお、このスイッチで何が防げて何が防げないかは、具体例で押さえておかないと判断を誤ります。公式ドキュメントには、緩和のまま「昭和65年1月9日」を指定すると平成2年1月9日(西暦1990年1月9日)として扱われ、厳格化すると「有効な値は1〜64です」という ArgumentOutOfRangeException になる、という例が載っています。5 この規則をそのまま当てはめると次のようになります。

和暦の指定 既定(緩和) 厳格化した場合
平成33年◯月◯日 受理され、令和3年に相当する日付になる ArgumentOutOfRangeException
昭和65年1月9日 受理され、1990-01-09(平成2年1月9日)になる ArgumentOutOfRangeException(昭和の有効年は1〜64)
昭和64年1月10日 受理され、1989-01-10になる 通ってしまう(年としては1〜64の範囲内)

3行目が要注意です。昭和は64年まで存在するので、年の範囲チェックだけでは「昭和64年1月10日」を弾けません。昭和は1989年1月7日で終わっているのに、検査されるのは年の値だけだからです。2.1で触れた「昭和64年1月10日」のような汚れたデータは、まさにこの隙間から入ります。改元をまたぐ月の日付まで厳密に検証したいなら、スイッチに頼らず、元号ごとの開始日・終了日を持って日付単位で照合する検証を自前で書くことになります。

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は「ただのテキスト」ではない」で書いたとおりエンコーディングを明示することです。

マスタ側のかたちも具体化しておきます。祝日は「日付とその名称」以上のものではないので、テーブルは素直で構いません。増やしておきたいのは暫定フラグと取り込み元の2列です。

-- 祝日マスタ。振替休日・国民の休日も1行として持つ
CREATE TABLE public_holidays (
    holiday_date   date          NOT NULL PRIMARY KEY,  -- 1日1行。日付そのものが主キー
    holiday_name   nvarchar(64)  NOT NULL,              -- 内閣府CSVの名称をそのまま格納('元日' '休日' など)
    is_provisional bit           NOT NULL,              -- 1 = 暫定(公表前を法律ベースで先埋めした行)
    source         nvarchar(32)  NOT NULL,              -- 'cao-csv' / 'manual' など取り込み元
    updated_at     datetime2(0)  NOT NULL
);

-- 会社休業日マスタ。祝日とは分けて持つ(理由は3.3節の末尾)
CREATE TABLE company_holidays (
    holiday_date date          NOT NULL PRIMARY KEY,
    reason       nvarchar(64)  NOT NULL,   -- '夏季休業' '創立記念日' など
    updated_at   datetime2(0)  NOT NULL
);

is_provisional が要点です。内閣府CSVに入っているのは確定分だけなので、それより先の年を扱う必要があるシステムでは、法律のルールから機械的に生成した行を is_provisional = 1 で入れておき、公表され次第 0 の行で上書きする運用にします。こうしておけば、「この支払期日の判定に暫定の祝日が混じっているか」を後から説明できます。逆に、暫定行を許さない業務(振込期日の確定など)では、呼び出し側で is_provisional = 0 に絞ればよく、テーブルを分ける必要がありません。

取り込み側は次のような形になります。内閣府CSVは1行目がヘッダーで、日付が 1955/1/1 のようにゼロ埋めされないので、書式を固定してパースします。

using System.Globalization;
using System.Text;

// 内閣府「国民の祝日について」で公開されている syukujitsu.csv を読む。
// 1行目: 国民の祝日・休日月日,国民の祝日・休日名称
// 2行目以降: 1955/1/1,元日 ── 文字コードはShift_JIS(CP932)
public static List<(DateOnly Date, string Name)> ReadCabinetOfficeHolidayCsv(string path)
{
    // .NET(Core)/.NET 5以降はこの登録がないと GetEncoding が例外になる(CSV記事4.2節)
    Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
    var sjis = Encoding.GetEncoding("shift_jis");

    var holidays = new List<(DateOnly, string)>();
    var isFirstLine = true;

    foreach (var line in File.ReadLines(path, sjis))
    {
        if (isFirstLine) { isFirstLine = false; continue; }   // ヘッダー行を捨てる
        if (string.IsNullOrWhiteSpace(line)) continue;

        var columns = line.Split(',');
        if (columns.Length < 2) continue;

        // 「1955/1/1」形式。カルチャ既定に任せず書式を固定する(2.4節と同じ理由)
        var date = DateOnly.ParseExact(columns[0].Trim(), "yyyy/M/d", CultureInfo.InvariantCulture);
        holidays.Add((date, columns[1].Trim()));
    }
    return holidays;
}

読み込んだ行を public_holidaysis_provisional = 0 / source = 'cao-csv' で反映し、同じ日付に暫定行があれば上書きする、というのが更新処理の中身です。振替休日・国民の休日は、名称「休日」の行としてCSVに最初から入っています。2

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);  // 「休日なら前営業日」の契約の場合
    }
}

4.1で AddMonths を警告した直後に、その AddMonths を2か所で使っています。矛盾ではないので、なぜここでは安全なのかを補足します。4.1で危険だったのは、丸められた結果をそのまま次の計算の入力にすることでした。上のコードはどちらもそうなっていません。

  • GetClosingDate は、毎回取引日からその月の締め日を組み立て直しています。AddMonths(1) を使うのは「当月の締め日を過ぎていたので翌月の締め日にする」という1回きりで、前回の戻り値を使い回してはいません。しかも closingDay = 31(月末締め)の場合、1月31日から作った締め日に AddMonths(1) すると2月28日になりますが、これは丸めの結果が「月末締め」の意味とちょうど一致するケースです。つまりここでは丸めを打ち消すのではなく、丸めを利用しています。
  • GetPaymentDueDateAddMonths(1) の結果から年と月しか読んでいません。日は DateTime.DaysInMonth で作り直しているので、丸めで日が28日になっていても結果に影響しません。1月31日の締め日なら「2月の末日」として2月28日、8月31日の締め日なら「9月の末日」として9月30日が導かれます。

要するに、AddMonths を「年月を1つ進める」用途に限定し、日は基準の定義(毎月末・毎月20日)から必ず作り直すなら安全に使えます。禁じ手は「前回の結果に足し続ける」ことのほうです。

コードより重要なのは、その手前の仕様の確定です。「月末締め」の月末は暦日か営業日か。期日が休日のとき前倒しか後ろ倒しか(支払う側は前倒し、受け取る側の入金予定は後ろ倒し、と立場で慣行が違います)。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 6

  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 3 4 5

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

ブログ一覧に戻る