业务应用程序的日本年号・法定节假日・结算日处理 —— 抗改元设计与 JapaneseCalendar・营业日计算实务

· · C#, .NET, 日本年号(和历), 法定节假日, 结算日, 日期时间处理, 业务应用程序, Windows 开发, 技术咨询

「报表的日期要用年号」「付款期限是20日结算、次月末付款,若月末是节假日则提前至前一个营业日」「汇总以会计年度为单位」——日本的业务应用程序标配了只在这个国家才通用的日期需求。而这个领域埋藏着与一般日期时间处理截然不同的陷阱。年号会随改元增加,法定节假日会随法规修订而变动,「月末」则因月份而长度不同。换句话说,这些全都是「日后会变动的规格」。

上一篇「业务应用程序的日期时间与时区」整理了以 UTC 存储的原则以及时区的处理方式。这一次作为续篇,将处理日本本地的三大需求——年号・法定节假日・结算日。从抗改元的设计、法定节假日不可写死在代码中的理由与数据化的方式,一直到考虑 AddMonths 进位规则的结算日计算,将通过代码示例与判断表逐一说明。

1. 先说结论

  • 内部处理与存储一律采用公历(格里高利历),年号只存在于显示的那一瞬间。 以年号字符串保存会导致三重问题:无法排序、改元时表示法混杂、以及混入不正确日期的风险。
  • 年号转换交给 JapaneseCalendar,而非自制对照表。年号的定义由操作系统端供给,因此改元时只要应用平台更新即可跟上。1 唯有留在应用程序中「平成」「令和」的硬编码字符串会被落下。
  • 法定节假日无法用公式判定。 春分・秋分是在前一年才确定,且法定节假日本身会因一般法规修订或特别措施法而变动。2 应以法定节假日主数据表+更新流程的方式设计,数据来源可使用内阁府公布的法定节假日 CSV。
  • 「月末」「营业日」「会计年度」应先在规格中定义,而非先写进代码。尤其 AddMonths 会自动对月末进行进位,因此结算日的计算原则上应每次都从基准日重新计算。3

以下先汇总各类数据的保存方式。

数据 保存方式 转换・判定的时机
日期本身(订单日期・付款期限) 公历的 DateOnly / DateTime(数据库为 date 类型)
年号表示(报表・画面) 不保存 仅在显示・打印瞬间以 JapaneseCalendar 转换
法定节假日 法定节假日主数据表(数据表或配置文件) 在营业日计算时参照主数据表
公司休业日(暑假・创立纪念日) 与法定节假日分开的休业日主数据表 同上(不与法定节假日混用)
结算日・付款条件 客户主数据表的属性(「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

若要接受年号输入,比起解析自由输入的文本字符串,设计成「用下拉框选择年号、年月日以数字输入」的界面更为安全。若无论如何都必须接受文本输入,请用 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年」的兼容性开关(Switch.System.Globalization.FormatJapaneseFirstYearAsANumber)。5 报表格式要求的究竟是「令和1年」还是「令和元年」,应在实现前作为规格予以确认。另外,默认情况下年号的年份范围检查是宽松的,因此像「平成33年」这种实际不存在的年份指定也会被接受并换算为实际日期(令和3年)。若要作为输入校验严格拦截,可通过 Switch.System.Globalization.EnforceJapaneseEraYearRanges 强制执行。5 究竟要维持宽松还是改为严格,取决于既有数据的脏污程度(该如何处理改元前打印・输入的「平成32年」)。

2.3 抗改元的代码,以及被落下的部分

从令和改元(2019年)的经验可以归纳出:平台会跟上,但应用程序自制的实现不会跟上。 年号的定义并未硬编码在 .NET 中,而是引用操作系统端所持有的年号信息,因此只要持续应用操作系统与 .NET 的更新,JapaneseCalendar 的转换・格式化就会跟上新年号。1 会被落下的是以下这类应用程序端的资产:

  • if (year >= 2019) era = "令和"; 这类自制的年号转换表・分支
  • 直接写在报表模板、Excel 单元格格式、打印表单上的年号
  • 以自定义规则处理「R」「H」等单字母年号缩写的代码(在与核心系统对接的格式中常见)
  • 仍以年号原样保存的数据本身

在估算改元应对改造成本时,应先对整个源代码与模板以「昭和・平成・令和・元年」进行机械式 grep 排查。从 VB6 或 Access 时代就在运行的业务应用程序,几乎必定埋有自制的转换表,因此这个话题常与「VB6 / Access 业务应用程序的延寿与迁移」中提到的存续判断一并处理。

2.4 潜藏的伏兵:Windows 区域设置

还有一个容易被忽略的陷阱。在 Windows 的区域设置中,用户可以将操作系统的日历类型更改为「年号」。由于 CultureInfo.CurrentCulture 默认会反映用户的设置更改(用户覆盖),6 在此设置下的设备,区域性默认的 DateTime.ToString() 会突然开始返回如「令和08/07/11」这类年号字符串。若代码是以区域性默认的 ToString() 将日期写入日志、CSV 或数据库,就会导致仅在该设备上,存储数据混入年号,破坏后续的解析处理。

对策与上一篇文章的结论相同:任何跨越边界(存储・通信・日志)的日期字符串,都必须以 CultureInfo.InvariantCulture 与明确格式输出。 区域性默认格式仅供画面显示使用。区域性对显示层整体的影响,也可参考「WinForms/WPF 应用程序的多语言化」。

3. 法定节假日 —— 不可写死在代码中的原因与主数据表运作

3.1 法定节假日「会变动」

法定节假日的判定绝不能写成 switch (月, 日)。法定节假日是依据国民节假日相关法律所制定,因法规修订与实务运作而多次变动过2 仅近年的实际案例就有:

年份 变动内容
2016 新设山之日(8月11日)
2019 因应改元,天皇诞生日从12月23日移到2月23日。2019年当年并不存在天皇诞生日
2019 因皇位继承的特例,5月1日(即位之日)等成为临时法定节假日・休息日
2020 体育之日改名为运动之日
2020・2021 依奥运特别措施法,海之日・运动之日・山之日仅在当年移动

此外,春分日与秋分日原本就没有写进法律的固定日期。 这是根据天文计算,于前一年才确定并公布的机制,因此确实掌握未来春分・秋分日期的唯一方法就是等待官方公布。2 「法定节假日每年都相同」这种前提的计算公式,原理上根本不成立。

3.2 数据来源与保存方式判断表

法定节假日的判定应以「数据+更新流程」来设计。以下比较主要的选项。

方式 内容 适合场景 注意事项
法定节假日主数据表(自行管理) 数据表中保存年月日与名称,通过管理界面更新 想与公司休业日一并运作的一般业务系统 将更新纳入业务流程(把每年一次的确认排入日历提醒)
导入内阁府 CSV 导入内阁府公布的法定节假日 CSV 以更新主数据表 想要主数据表更新的第一手数据来源时(推荐做法) 只涵盖已公布的期间,须留意文字编码
法定节假日库(NuGet 等) 以计算逻辑+内置数据进行判定 内部工具等无法维持更新流程的小规模用途 未来的法规修订需依赖库更新,须验证内置规则
硬编码 直接把日期写在源代码中 每次法规修订都需要对所有客户端重新分发

作为第一手数据来源,可使用内阁府在「国民节假日」页面公布的自昭和30年(1955年)至次年为止的法定节假日月日・名称 CSV2 导入时实务上须注意两点:(1) 公布的仅为已确定的部分,因此应在规格中决定查询后年以后的法定节假日时的行为(返回错误,或以法律为基础填入临时值并附上「临时」标记);(2) 该 CSV 的文字编码属于 Shift_JIS 系列,读取时须如「CSV 并非单纯的「文本文件」」中所述明确指定编码。

3.3 调休・国民休息日与营业日计算

除了日期清单之外,法定节假日相关法律还定义了两条衍生规则:调休(法定节假日若恰逢周日,则其后最接近的「非法定节假日之日」定为休息日)与国民休息日(前后两日皆为法定节假日所夹的工作日定为休息日)。2 内阁府 CSV 中也将这些以数据行形式包含在内,因此若采用 CSV 导入方式,只需参照主数据表即可。即使自行实现补全这项逻辑,由于其源自法律,相对稳定。

营业日计算应以「非周末・不在法定节假日主数据表中・不在公司休业日主数据表中」三项条件编写。

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;
    }
}

将法定节假日与公司休业日分成不同的主数据表是刻意的设计。「银行转账期限的判定只看法定节假日(自家公司的暑假与之无关)」「发货日的判定则两者都看」——依用途不同,需要参照的休息日集合也不同。若混在同一张主数据表中,日后要拆分的改造工程就会变成对既有数据的考古挖掘。

4. 结算日・付款条件・会计年度 —— 用计算正确处理「月末」

4.1 AddMonths 的进位规则

结算日计算的第一个陷阱是 AddMonthsDateTime.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日,此问题便会浮现。对策有两个:

  • 重复的日期不要以上一次的结果为基准,而是每次都从基准的定义(「每月末」「每月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

须注意不要轻率地将「会计年度」冗余保存到数据库中。若把只要有日期就能随时派生的值另外保存在一个字段中,一旦修正日期时忘记同步更新,就会产生日期与会计年度不一致的数据。若因汇总性能考虑无论如何都必须保存,请将其做成计算列(生成列),并禁止应用程序端直接写入。

4.4 测试 —— 先踩过「只在那一天」才会发作的错误

年号・法定节假日・结算日的错误,只会在特定日期发作。以上一篇文章介绍的通过 TimeProvider 注入当前时间(「业务应用程序的日期时间与时区」第7章)为前提,这个领域至少应测试以下五个日期:

  • 改元边界: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 机械式地找出。

发现时应怀疑的代码 可能发生什么问题 修正方式
"平成" "令和" 等年号字面量 下次改元时被落下 改用 JapaneseCalendarg 格式(2.3节)
法定节假日的日期字面量(如 5, 3 因法规修订・特别措施法而误判 改为参照法定节假日主数据表(3.2节)
无参数 ToString() 的保存・输出路径 在年号设置设备上,存储数据混入年号 InvariantCulture +明确格式(2.4节)
重复使用 上一日期.AddMonths(1) 通过2月后月末持续偏离 每次都从基准日重新计算(4.1节)
new DateTime(y, m, 31) 在30日以下的月份会抛出异常 DateTime.DaysInMonth 推导

5. 总结

年号・法定节假日・结算日,处理的全都是「日后会变动的规格」。因此设计原则也是共通的:会变动的东西不要写进代码,而是隔离到数据与转换层中——内部与存储统一为公历,年号仅作为显示用转换;法定节假日以主数据表+更新流程的方式管理;「月末」「营业日」「期限的节假日调整」则先以合同规格文档化,再以基准日为准进行计算。做到这一步,下一次改元、下一次法定节假日法规修订,对应用程序而言就只是「更新主数据表」与「应用操作系统更新」即可解决的事件。

本公司承接报表・结算相关日期处理的设计与实现、既有应用程序中埋藏的年号・法定节假日硬编码问题的排查与改造,以及包含结算日规格梳理在内的设计评审。即使还处于「不知道改元应对散落在哪里、有多少项」的阶段,我们也能提供帮助。

相关文章

相关咨询领域

小村软件有限责任公司(合同会社小村ソフト)承接符合日本商业惯例的业务应用程序日期处理(年号报表・营业日计算・结算日处理)的设计与实现,以及既有应用程序的改元应对・法定节假日应对的排查与改造。

参考链接

  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。关于将年号第一年格式化为「元年」的默认行为与还原为数字表示的 Switch.System.Globalization.FormatJapaneseFirstYearAsANumber 开关,以及年号的年份范围检查默认为宽松、可通过 Switch.System.Globalization.EnforceJapaneseEraYearRanges 强制严格化的说明。  2

  6. Microsoft Learn, CultureInfo.CurrentCulture Property。关于 CurrentCulture 默认会反映 Windows 用户的区域设置更改(用户覆盖),以及格式化默认值依赖此区域性的说明。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

数据库不可以直接用年号原样保存日期吗?
不建议这样做,理由有四个。第一,「平成9年」与「令和9年」这类年号字符串,一旦跨越改元的分界点,用字符串比较就会失去大小顺序,无法排序也无法做范围查询。第二,下一次改元发生的瞬间,已有数据与新数据的表示法就会混杂在一起。第三,容易混入「昭和64年1月10日」(实际不存在,1月8日起已是平成元年)这类不存在日期的脏数据,成为日后解析失败的定时炸弹。第四,.NET 的 JapaneseCalendar 只支持明治元年(1868年)9月8日之后的日期,更早的生日等数据若仍以年号形式保存,将无法正确处理。原则是内部处理与存储一律统一为公历(格里高利历),年号只在画面与报表输出的瞬间才转换。
下一次改元到来时,应用程序该如何应对?
年号的定义本身并未硬编码在 .NET 中,而是引用操作系统端提供的年号信息,因此只要持续应用操作系统与 .NET 的更新,「以新年号显示」这件事本身就会自动跟上。令和改元当时,应对的重点其实就是应用平台更新。真正的问题出在应用程序端的代码与数据:以字符串硬编码「平成」「令和」的分支、自行维护年号与公历对照表的转换函数、直接写在报表模板里的年号、打印表单上的「令和 __年」栏位,这些都不会自动跟进。务实的应对方式有四点:(1) 内部与存储一律统一为公历,让年号只用于显示;(2) 放弃自制年号转换逻辑,改用 JapaneseCalendar;(3) 对整个源代码以「昭和・平成・令和」进行机械式 grep 排查;(4) 准备假设未来改元场景的测试。
法定节假日不能用公式计算吗?为什么一定要以数据的方式保存?
完整的计算公式原理上做不出来,理由有三个。第一,春分日与秋分日是根据天文计算,经内阁会议在前一年才确定,并未以固定日期写入法律。第二,法定节假日会因法规修订而新增、移动或改名:实际上就有2016年新设山之日、2019年因改元使天皇诞生日从12月23日移到2月23日(**2019年当年并不存在天皇诞生日**)、体育之日改名为运动之日等案例。第三,2020年与2021年由于特别措施法,海之日・运动之日・山之日配合奥运仅在当年移动,说明「每年固定第三个星期一」这类规则本身也可能被临时覆盖。因此法定节假日的判定应设计为「法定节假日主数据表(数据)+更新流程」,计算逻辑只用于补充替代休假等辅助规则。
用了 AddMonths(1) 之后,付款期限偏离了月末。为什么?
因为 DateTime.AddMonths 的规格是:若计算结果落在该月不存在的日期,就会进位到该月的最后一天。对1月31日应用 AddMonths(1) 会得到2月28日(闰年则是29日),这个行为本身是正确的。但如果把这个经过进位的结果再应用一次 AddMonths,对2月28日应用 AddMonths(1) 会得到3月28日,之后就会一直偏离下去——「原本想要月末」却在不知不觉间变成了「28日」,并且持续错下去。对策有两个:重复计算时不要在上一次的结果上累加,而是每次都从基准日(合同日或「每月末」的定义)重新计算;「月末」不要用日期字面量,而是通过 DateTime.DaysInMonth 推导。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表