Японский календарь, праздники и даты закрытия в бизнес-приложениях — устойчивость к смене эры, JapaneseCalendar и расчёт рабочих дней

· Обновлено: · · C#, .NET, Японский календарь, Праздники, Дата закрытия, Обработка даты и времени, Бизнес-приложения, Разработка под Windows, Техническая консультация

История изменений (3 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Текст статьи исправлен, чтобы соответствовать японскому оригиналу.
Переведён термин 国民の休日. Утверждения статьи не менялись.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21620023)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Японский календарь, праздники и даты закрытия в бизнес-приложениях — устойчивость к смене эры, JapaneseCalendar и расчёт рабочих дней. KomuraSoft LLC. https://comcomponent.com/ru/blog/japanese-calendar-holidays-closing-date-guide/

DOI (зарегистрированный архив)
10.5281/zenodo.21620023
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21620024

«Дата на печатной форме — в японском календаре». «Оплата: закрытие 20-го числа, платёж до конца следующего месяца; если последний день — выходной, то предыдущий рабочий день». «Сводки — по финансовому году» — японские бизнес-приложения с самого начала несут требования к датам, которые имеют смысл только в этой стране. И здесь другой класс ловушек, не тот, что при обычной обработке даты и времени. Эры прибывают со сменой эпохи, праздники сдвигаются правками закона, а «конец месяца» в разных месяцах разной длины. То есть всё это — «спецификация, которая изменится позже».

В предыдущей статье «Дата, время и часовые пояса в бизнес-приложениях» мы разобрали принцип хранения в UTC и работу с часовыми поясами. Сейчас — продолжение: три главных локальных требования Японии — японский календарь, праздники и даты закрытия. Разберём дизайн, устойчивый к смене эры, почему праздники нельзя зашивать в код и как хранить их данными, и расчёт даты закрытия с учётом округления AddMonths — на примерах кода и таблицах решений.

1. Сначала выводы

  • Внутри и при хранении — григорианский календарь, японский календарь только в момент отображения. Хранение строками японского календаря даёт тройную проблему: нельзя сортировать, после смены эры смешиваются написания, проникают несуществующие даты.
  • Конвертацию японского календаря отдайте JapaneseCalendar, а не своей таблице соответствия. Определения эр приходят со стороны ОС, поэтому при смене эры достаточно обновить платформу.1 Позади остаются только зашитые в приложении строки «Хэйсэй» и «Рэйва».
  • Праздник формулой не определить. Дни весеннего и осеннего равноденствия утверждают только в предыдущем году, а сами праздники двигают правки закона и специальные законы.2 Проектируйте справочник праздников плюс процедуру обновления; как источник данных можно взять CSV праздников, который публикует Канцелярия Кабинета министров Японии (内閣府).
  • «Конец месяца», «рабочий день», «финансовый год» сначала определяют в спецификации, потом в коде. Особенно AddMonths сам округляет конец месяца, поэтому дату закрытия каждый раз считают от опорной даты.3

Сначала сводка, как хранить каждый вид данных.

Данные Как хранить Когда преобразовывать / определять
Сама дата (дата заказа, срок платежа) Григорианские DateOnly / DateTime (в БД — тип date) —
Написание по японскому календарю (печатные формы, экраны) Не хранить В момент показа и печати через JapaneseCalendar
Праздники Справочник праздников (таблица или файл настроек) Обращение к справочнику при расчёте рабочих дней
Корпоративные выходные (летние каникулы, день основания) Отдельный от праздников справочник выходных То же (с праздниками не смешивать)
Дата закрытия и условия оплаты Атрибут справочника контрагентов («закрытие 20-го, оплата до конца следующего месяца») Расчёт от опорной даты при выставлении счёта
Финансовый год Не хранить (выводимое значение) Считать из даты при показе и агрегации

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Японский календарь — оставить только для отображения

2.1 Зачем внутри унифицировать на григорианском календаре

Японский календарь — это написание для людей, а не представление, удобное для расчётов. У «Хэйсэй 9» и «Рэйва 9» при сравнении строк нет порядка, а любой расчёт периода через границу эры всё равно требует перевода в григорианский календарь. К тому же система эр расширяется с каждой сменой, поэтому данные, сохранённые в японском календаре, после каждого перехода копят смесь старого и нового написания. Проникновение некорректных данных вроде «Сёва 64 года, 10 января» (такой даты не было: с 8 января уже Хэйсэй) — типичная беда систем, которые сохраняют ввод в японском календаре как есть.

Есть ещё одно ограничение, которое часто пропускают: JapaneseCalendar в .NET поддерживает только даты начиная с 8 сентября 1-го года Мэйдзи (1868).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

Если нужно принять ввод в японском календаре, безопаснее не разбирать произвольную строку, а дать выбрать эру в выпадающем списке и принять год, месяц и день числами. На экране достаточно такого ряда:

[Эра ▼]  [ 8 ] 年  [ 7 ] 月  [ 11 ] 日        григорианская: 2026-07-11
  • Эра — выпадающий список. По умолчанию — текущая эра. На экране ввода прошлых данных показывайте только эры из обрабатываемого диапазона.
  • Год, месяц, день — числовой ввод. Верхняя граница года зависит от эры, поэтому допустимый диапазон переключайте по выбранной эре.
  • Предпросмотр григорианской даты — сразу показывайте результат преобразования. Комбинацию вроде «Сёва 64 года, 10 января» оператор заметит только если это поле есть.
  • Что хранить — экран держит григорианскую дату. Три поля японского календаря — только инструмент ввода.

Если всё же принимать строку, зафиксируйте формат через 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年» или «令和元年», зафиксируйте в спецификации до реализации. Ещё момент: по умолчанию проверка диапазона годов эры ослаблена (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
    }
  }
}

Если вести это в проекте, достаточно положить блок configProperties в runtimeconfig.template.json — при сборке он попадёт в генерируемый <имя_приложения>.runtimeconfig.json. Для .NET Framework 4.6 и новее это AppContextSwitchOverrides в app.config.

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

Если вы ещё сопровождаете .NET Framework 4.5.2 и старше, одноимённую запись кладут как REG_SZ в HKEY_LOCAL_MACHINE\Software\Microsoft\.NETFramework\AppContext.5

Что этот переключатель отсекает, а что нет, нужно закрепить на примерах, иначе решение будет неверным. В официальной документации есть пример: в ослабленном режиме «Сёва 65 года, 9 января» принимается как Хэйсэй 2 года, 9 января (1990-01-09), а при ужесточении выбрасывается ArgumentOutOfRangeException с текстом вроде «допустимые значения — от 1 до 64».5 Если применить то же правило, получается так:

Указание в японском календаре По умолчанию (ослаблено) При ужесточении
Хэйсэй 33 года, день месяц Принимается, становится датой, соответствующей Рэйва 3 ArgumentOutOfRangeException
Сёва 65 года, 9 января Принимается, становится 1990-01-09 (Хэйсэй 2 года, 9 января) ArgumentOutOfRangeException (у Сёва допустимы годы 1–64)
Сёва 64 года, 10 января Принимается, становится 1989-01-10 Проходит (год как таковой лежит в диапазоне 1–64)

Третья строка — главная. Сёва существует до 64-го года, поэтому проверки диапазона года недостаточно, чтобы отсечь «Сёва 64 года, 10 января». Сёва закончилась 7 января 1989 года, а проверяется только значение года. Именно через эту щель и пролезают «грязные» данные вроде «Сёва 64 года, 10 января» из раздела 2.1. Если нужно строго проверять и даты месяцев на границе смены эры, на переключатель полагаться нельзя: придётся самим сверять дату с началом и концом каждой эры.

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». Если код пишет даты в журнал, CSV или БД через культурный ToString() по умолчанию, японский календарь попадает в сохранённые данные только на этой машине и ломает последующий разбор.

Мера та же, что и в предыдущей статье. Строка даты, которая пересекает границу — хранение, обмен, журнал, — всегда пишется через CultureInfo.InvariantCulture и явный формат. Формат культуры по умолчанию — только для экрана. Как культура влияет на отображение в целом, см. также «Локализация приложений WinForms/WPF».

3. Праздники — почему их нельзя зашивать в код и как вести справочник

3.1 Праздники «меняются»

Не пишите определение праздника как switch (month, day). Праздники заданы Законом о национальных праздниках, и их не раз меняли правками закона и практикой применения.2 Только недавние примеры:

Год Изменение
2016 Учреждён День гор (11 августа)
2019 Из-за смены эры день рождения императора переносится с 23 декабря на 23 февраля. В 2019 году дня рождения императора не было
2019 По спецрежиму в связи с престолонаследием 1 мая (день восшествия на престол) и другие даты становятся временными праздниками и выходными
2020 День здоровья и спорта переименован в День спорта
2020 и 2021 По специальному олимпийскому закону День моря, День спорта и День гор сдвинуты только на эти годы

К тому же у Дня весеннего равноденствия и Дня осеннего равноденствия в законе вообще нет даты. Их утверждают и публикуют в предыдущем году по астрономическим расчётам, поэтому надёжно узнать будущее равноденствие можно только дождавшись публикации.2 Формула из предпосылки «праздники каждый год те же» принципиально не работает.

3.2 Таблица решений по источнику данных и способу хранения

Определение праздника проектируйте как «данные плюс процедура обновления». Сравнение основных вариантов:

Способ Содержание Когда подходит На что обратить внимание
Справочник праздников (своё ведение) В таблице дата и название, обновление через экран администрирования Бизнес-системы в целом, где его хотят вести вместе с корпоративными выходными Встроить обновление в эксплуатацию (ежегодную проверку поставить в календарь)
Импорт CSV Канцелярии Кабинета министров Импорт публикуемого 内閣府 CSV праздников и обновление справочника Когда нужен первичный источник для обновления справочника (рекомендуется) Есть только уже опубликованный период. Следите за кодировкой
Библиотека праздников (NuGet и т. п.) Определение через логику расчёта плюс встроенные данные Небольшие сценарии вроде внутренних инструментов, где процедуры обновления нет Будущие правки закона зависят от обновления библиотеки. Нужно проверять встроенные правила
Хардкод Даты прямо в исходнике Нет Каждая правка закона требует повторной раздачи всем клиентам

Как первичный источник можно взять CSV с датами и названиями праздников с 30-го года Сёва (1955) по следующий год, который 内閣府 публикует на странице «О национальных праздниках».2 При импорте два практических момента: (1) публикуют только уже утверждённый период, поэтому в спецификации заранее решите, что делать, если спросят праздник на год после следующего (ошибка или заполнение предварительным значением по правилам закона с флагом «предварительно»); (2) кодировка этого CSV — семейства Shift_JIS, поэтому при чтении кодировку указывают явно, как в статье «CSV — это не «просто текст»».

Форму справочника тоже стоит конкретизировать. Праздник — не больше чем «дата и её название», таблица может быть прямой. Имеет смысл добавить два столбца: флаг предварительности и источник.

-- Справочник праздников. Перенесённый выходной и народный выходной (国民の休日) тоже хранятся отдельной строкой
CREATE TABLE public_holidays (
    holiday_date   date          NOT NULL PRIMARY KEY,  -- одна строка на день; сама дата — первичный ключ
    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 内閣府 первая строка — заголовок, даты без ведущих нулей вроде 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 выбрасывает исключение (раздел 4.2 статьи про CSV)
    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_holidays с is_provisional = 0 / source = 'cao-csv' и перезаписывают предварительную строку на ту же дату, если она есть. Перенесённый выходной и народный выходной (国民の休日) уже входят в CSV строками с названием «休日».2

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

Первая ловушка расчёта даты закрытия — 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, замысел «конец месяца» теряется. На практике это всплывает, когда ежемесячный график счетов собирают как «прошлый запуск плюс месяц»: как только проходит февраль, выполнение навсегда садится на 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);  // для договора «если выходной — предыдущий рабочий день»
    }
}

Сразу после предупреждения про AddMonths в 4.1 этот AddMonths используется в двух местах. Противоречия нет, поэтому коротко — почему здесь это безопасно. Опасность в 4.1 была в том, чтобы скормить округлённый результат как вход следующего расчёта. В коде выше этого нет.

  • GetClosingDate каждый раз заново собирает дату закрытия этого месяца от даты операции. AddMonths(1) здесь однократный: «закрытие этого месяца уже прошло, взять закрытие следующего», предыдущее возвращаемое значение не переиспользуется. При closingDay = 31 (закрытие в конце месяца) AddMonths(1) к закрытию, собранному от 31 января, даёт 28 февраля — и это как раз случай, когда результат округления совпадает со смыслом «закрытие в конце месяца». Здесь округление не компенсируют, им пользуются.
  • GetPaymentDueDate из результата AddMonths(1) читает только год и месяц. День заново берёт из DateTime.DaysInMonth, поэтому даже если округление уехало на 28-е, на результат это не влияет. Для закрытия 31 января получается «последний день февраля» — 28 февраля; для закрытия 31 августа — «последний день сентября» — 30 сентября.

Итого: AddMonths безопасно использовать, если ограничить его сдвигом года и месяца на единицу, а день всегда заново собирать из определения опоры (конец каждого месяца, 20-е каждого месяца). Запрещённый приём — «продолжать прибавлять к предыдущему результату».

Важнее кода — зафиксировать спецификацию до него. При «закрытии в конце месяца» конец месяца — календарный день или рабочий? Если срок выпадает на выходной, сдвигать раньше или позже (у плательщика принято сдвигать раньше, у получателя ожидаемое поступление — позже; обычай зависит от стороны)? Как в феврале читать договор с закрытием на 29–31-е? Пока этого нет в документе, код разработчика становится «спецификацией, которую никто не согласовывал». Расследование ошибок вокруг даты закрытия чаще приводит не к ошибке в коде, а к отсутствующей спецификации.

4.3 Финансовый год и квартал

В японских бизнес-приложениях «финансовый год» почти всегда начинается в апреле. Его не хранят — считают выводимым из даты значением.

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;
// апрель–июнь → Q1, июль–сентябрь → Q2, октябрь–декабрь → Q3, январь–март → Q4

Следите, чтобы не хранить финансовый год в БД избыточно без нужды. Отдельный столбец для значения, которое всегда можно вывести из даты, при правке даты и забытом обновлении даёт расхождение даты и года. Если из-за производительности агрегации столбец всё же нужен, сделайте его вычисляемым (генерируемым) и запретите приложению в него писать.

4.4 Тесты — заранее наступить на баги, которые срабатывают «только в этот день»

Ошибки японского календаря, праздников и даты закрытия проявляются только в конкретные дни. Опираясь на внедрение текущего времени через TimeProvider из предыдущей статьи (глава 7 «Дата, время и часовые пояса в бизнес-приложениях»), минимум пять дат, которые стоит покрыть в этой области:

  • Граница смены эры: показ и обратное преобразование ввода около 1989-01-07/08 (Сёва → Хэйсэй) и 2019-04-30/05-01 (Хэйсэй → Рэйва)
  • Первый год эры: проверка бланка с написанием «令和元年» (раздел 2.2)
  • Високосный год: расчёт закрытия и условий оплаты, затрагивающий 29 февраля (2024, 2028)
  • Цепочка концов месяца: сгенерировать двенадцать дат закрытия от 31 января и проверить, что конец месяца сохраняется (раздел 4.1)
  • Граница финансового года: определение года и квартала на 31 марта и 1 апреля

Плюс на ревью следующие шаблоны можно механически вылавливать grep.

Если нашли — стоит насторожиться Что может случиться Как исправить
Литералы эры вроде "平成" "令和" Отстанет при следующей смене эры JapaneseCalendar + формат g (раздел 2.3)
Литералы дат праздников (5, 3 и т. п.) Неверное определение после правки закона или спецзакона Обращение к справочнику праздников (раздел 3.2)
Путь сохранения и вывода с ToString() без аргументов На машине с японским календарём в сохранённые данные попадёт японский календарь InvariantCulture + явный формат (раздел 2.4)
Повтор предыдущаяДата.AddMonths(1) После февраля конец месяца продолжает уезжать Каждый раз считать от опорной даты (раздел 4.1)
new DateTime(y, m, 31) Исключение в месяцах с 30 и менее днями Выводить через DateTime.DaysInMonth

5. Итог

Японский календарь, праздники и даты закрытия — всё это работа со «спецификацией, которая изменится позже». Поэтому и принцип проектирования общий. То, что меняется, в код не пишут, а изолируют в данных и слое преобразования — внутри и при хранении григорианский календарь, японский только как преобразование для отображения; праздники ведут справочником и процедурой обновления; «конец месяца», «рабочий день» и «сдвиг срока, если выходной» документируют как договорную спецификацию и считают от опорной даты. Тогда следующая смена эры и следующая правка закона о праздниках для приложения станут событиями уровня «обновить справочник» и «поставить обновление ОС».

Мы занимаемся проектированием и реализацией обработки дат вокруг печатных форм и счетов, инвентаризацией и доработкой зашитых в существующих приложениях эр и праздников, разбором спецификации даты закрытия в рамках проектного ревью. Можем помочь и с этапа, когда ещё неясно, где и сколько мест требуют доработки под смену эры.

Связанные статьи

Связанные направления консультаций

ООО «Komura Soft» (合同会社小村ソフト) занимается проектированием и реализацией обработки дат в бизнес-приложениях по японским деловым обычаям (печатные формы в японском календаре, расчёт рабочих дней, даты закрытия), а также инвентаризацией и доработкой поддержки смены эры и праздников в существующих приложениях.

Ссылки

  1. Microsoft Learn, Work with eras - .NET. О том, что JapaneseCalendar и JapaneseLunisolarCalendar — единственные классы календаря, распознающие несколько эр (era), о форматировании эры спецификатором «g» и о том, что сведения об эре поставляет платформа, а не хардкод приложения. ↩ ↩2 ↩3

  2. Канцелярия Кабинета министров Японии, «О национальных праздниках». О перечне праздников по Закону о национальных праздниках, о том, что День весеннего равноденствия и День осеннего равноденствия утверждают и публикуют в предыдущем году, о положениях о перенесённом выходном и народном выходном (国民の休日), а также о CSV с датами и названиями праздников с 30-го года Сёва по следующий год. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  3. Microsoft Learn, DateTime.AddMonths(Int32) Method. О том, что если день результата превышает число дней в получившемся месяце, результат корректируется до последнего дня этого месяца (пример: 31 января плюс месяц становится 28/29 февраля). ↩ ↩2

  4. Microsoft Learn, JapaneseCalendar Class. О диапазоне дат JapaneseCalendar — с 8 сентября 1-го года Мэйдзи (1868) — и об обработке эр (era). ↩

  5. Microsoft Learn, Work with calendars - .NET. О том, что первый год эры по умолчанию форматируется как «元年», о переключателе Switch.System.Globalization.FormatJapaneseFirstYearAsANumber, возвращающем числовое написание, и о том, что проверка диапазона годов эры по умолчанию ослаблена и ужесточается Switch.System.Globalization.EnforceJapaneseEraYearRanges. ↩ ↩2 ↩3 ↩4 ↩5

  6. Microsoft Learn, CultureInfo.CurrentCulture Property. О том, что CurrentCulture по умолчанию отражает изменение региональных параметров пользователем Windows (пользовательское переопределение), и о том, что форматирование по умолчанию зависит от этой культуры. ↩

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Сопровождение и модернизация ПО Windows

Инвентаризация зашитых в код названий эр и праздников в существующем приложении и доработка так, чтобы оно пережило следующую смену эры и изменение закона, относится к доработке и сопровождению существующего Windows-ПО.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Нельзя хранить в базе даты сразу в японском календаре?
Не рекомендуем. Причин четыре. Во-первых, строки японского календаря вроде «Хэйсэй 9» и «Рэйва 9» при сравнении как текста теряют порядок, как только пересекается граница эры: сортировка и поиск по диапазону перестают работать. Во-вторых, в момент следующей смены эры в уже сохранённых данных смешаются старое и новое написание. В-третьих, легко проникают «грязные» данные с датами, которых не было, — например «Сёва 64 года, 8 января» (этот день уже 1-й год Хэйсэй), — и разбор таких строк становится отложенным сбоем. В-четвёртых, JapaneseCalendar в .NET обрабатывает только даты начиная с 8 сентября 1-го года Мэйдзи (1868); более ранние, например старая дата рождения из семейного реестра, в японском календаре корректно не обрабатываются. Принцип такой: внутреннюю обработку и хранение унифицировать на григорианском календаре, а в японский календарь переводить только в момент показа на экране и печати.
Как приложению подготовиться к следующей смене эры?
Сами определения эр в .NET не зашиты: механизм берёт сведения об эре у операционной системы. Если ставить обновления ОС и .NET, «показ в новой эре» подхватывается сам. При переходе к Рэйва центр работы как раз и был в установке обновлений платформы. Проблема — код и данные приложения. Ветвления, где строками зашиты «Хэйсэй» и «Рэйва», самописные функции с собственной таблицей соответствия эры и григорианского года, эра прямо в шаблоне отчёта, поле «Рэйва год» на заранее напечатанном бланке — ничего из этого само не подстроится. На практике разумны четыре шага: (1) внутри и при хранении держать григорианский календарь, японский — только для отображения; (2) отказаться от своей конвертации эры и опереться на JapaneseCalendar; (3) пройтись grep по всему исходнику на «昭和・平成・令和» и составить список мест; (4) завести тесты с вымышленной сменой эры.
Праздники нельзя определить формулой? Зачем хранить их как данные?
Полную формулу построить принципиально нельзя. Причин три. Во-первых, День весеннего равноденствия и День осеннего равноденствия утверждаются через заседание Кабинета на основе астрономических расчётов только в предыдущем году и в законе не записаны как фиксированные даты. Во-вторых, праздники добавляют, переносят и переименовывают правками закона. Так появились День гор в 2016 году, перенос дня рождения императора с 23 декабря на 23 февраля в 2019-м (в 2019 году дня рождения императора не было) и переименование Дня здоровья и спорта в День спорта. В-третьих, в 2020 и 2021 годах специальным законом к Олимпиаде День моря, День спорта и День гор сдвинули только на эти годы: даже правило вроде «каждый год третий понедельник» могут временно перекрыть. Поэтому правильный дизайн — «справочник праздников плюс процедура обновления», а расчётную логику оставить вспомогательной, например для переноса выходного.
После AddMonths(1) срок платежа уехал с конца месяца. Почему?
Потому что DateTime.AddMonths по спецификации округляет результат до последнего дня получившегося месяца, если наивный расчёт дал бы день, которого в этом месяце нет. AddMonths(1) к 31 января даёт 28 февраля (в високосный год — 29-е). Само по себе это верное поведение. Но если округлённый результат снова скормить AddMonths, то AddMonths(1) к 28 февраля даёт 28 марта, и дальше «конец месяца» навсегда превращается в 28-е. Два приёма: в повторяющихся расчётах каждый раз считать от опорной даты (даты договора или определения «конец каждого месяца»), а не прибавлять к предыдущему результату; «конец месяца» выводить через DateTime.DaysInMonth, а не литералом даты.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог