Начнем с теории
В ФЗ-107 «Об исчислении времени» Статья 2 указано:
5) календарный год – период времени с 1 января по 31 декабря продолжительностью триста шестьдесят пять либо триста шестьдесят шесть (високосный год) календарных дней. Календарный год имеет порядковый номер в соответствии с григорианским календарем;
Логично и очевидно, когда рассматривается период с начала по конец года.
А вот сколько должно быть дней в интервале (365/366), который начинается не просто 1 января, а затрагивает февраль високосного года?
Самый близкий по смыслу ответ нашёлся в комментариях к Статье 16 ФЗ-229 «Об исполнительном производстве» (если у кого-то есть более точные данные, напишите в комментариях):
срок, исчисляемый годами, оканчивается в соответствующие месяц и день последнего года установленного срока (ч. 1);
срок, исчисляемый месяцами, оканчивается в соответствующий день последнего месяца установленного срока. Если окончание срока, исчисляемого месяцами, приходится на месяц, который соответствующего числа не имеет, то срок оканчивается в последний день этого месяца (ч. 2; например, месячный срок, исчисляемый с 29, 30 или 31 января, истечет 28 либо 29 февраля в високосный год);
Из написанного тоже не следует какая должна быть логика на граничных условиях. Есть только один вывод: если в новой дате нет необходимого дня, то берётся ближайший к нему предыдущий.
Отдельно обращаю внимание, что налоговый и бухгалтерский учет ведется по дням, а не месяцам/годам. Что делает проблему конвертации месяцев/лет в конечное количество дней ещё более важной.
Неутешительный вывод из вышенаписанного – не существует задокументированных методических правил датской арифметики в високосном году. Описан только один случай преобразования 29 февраля високосного года в 28 февраля не високосного.
Казалось бы, проблема не зависит от отрасли применения: високосный год он и в Африке високосный год.
И тут начинает портить жизнь многолетние договоры страхования – ведь за свой период действия они (договоры) могут неоднократно попадать на високосный год. И это ещё пол беды, когда клиенту (допустим) не важны даты начала/окончания периодов страхования договора.
Баги встают в очередь, когда необходимо правильно разбить интервалы страхования погодично (финансовый учет идёт в разрезе календарных лет) да еще и в разных системах (фронты, бэк, 1С, отчетность и т.п.). Мы ведь помним, что на фронтах клиент не выбирает дату окончания руками – он ставит количество месяцев/лет действия договора. То есть, системе необходимо каким-то образом преобразовать интервал в конечное число, которое и будет зафиксировано в учетной системе.
Вот, простой вопрос: если договор, длительностью год, начал действовать 28.02.2023, то, когда он закончится – 27.02.2024 или 28.02.2024?
По приведенной ранее выдержке из ФЗ, да и по тривиальной программерской логике, следует, что правильным будет 27 число.
Этому правилу следуют
Класс Calendar в Java
Подкласс dateutil.relativedelta Python
Тип данных Interval Postgres
Т.е. решение можно считать негласным стандартом )
Вариант 1 (дата окончания 27 февраля 2024)
Строим цепочку периодов для договора на 5 лет с датой начала 28 февраля не високосного года:
Начало периода | Конец периода | Количество дней | День месяца начала периода | День месяца конца периода |
28.02.2023 | 27.02.2024 | 365 | Последний | Пред-предпоследний |
28.02.2024 | 27.02.2025 | 366 | Предпоследний | Предпоследний |
28.02.2025 | 27.02.2026 | 365 | Последний | Предпоследний |
28.02.2026 | 27.02.2027 | 365 | Последний | Предпоследний |
28.02.2027 | 27.02.2028 | 365 | Последний | Пред-предпоследний |
Т.е. 2 периода страхования, затрагивающие високосный год, выбиваются из логики «Последний день – предпоследний день». И увеличенный период (366 дней) происходит только при пересечении 29 февраля.
Безобразно, но однообразно. И согласуется с логикой ФЗ.
Вариант 2 (дата окончания 28 февраля 2024)
Та же самая цепочка периодов теперь выглядит более однородно:
Начало периода | Конец периода | Количество дней | День месяца начала периода | День месяца конца периода |
28.02.2023 | 28.02.2024 | 366 | Последний | Предпоследний |
29.02.2024 | 27.02.2025 | 365 | Последний | Предпоследний |
28.02.2025 | 27.02.2026 | 365 | Последний | Предпоследний |
28.02.2026 | 27.02.2027 | 365 | Последний | Предпоследний |
28.02.2027 | 28.02.2028 | 366 | Последний | Предпоследний |
Выделяются снова те же два периода, соблюдая логику «Последний день – предпоследний день». Увеличение периода (366 дней) происходит уже в другом месте – расширяется интервал, предшествующий 29 февраля.
В итоге, встаёт методологический вопрос: а как правильно?
Если вы ограничены ТЗ до безусловного следования букве закона, вам остаётся только первый вариант (27 февраля).
Если вам не безразличен потребитель ваших услуг и его психическое спокойствие (объяснение с налоговой по поводу потерянного дня удовольствие малоприятное), то разумно воспользоваться вторым вариантом (28 февраля). Дополнительным поводом использовать вариант является уже реализованная в DB Oracle функция Add_months, учитывающая подобное поведение на границе високосного года.
Круто! У нас есть как минимум 2 варианта решения. И даже встроенный метод расчета. Можно с чистой совестью закодить и расслабиться… И словить уже другой баг, менее очевидный и более заковыристый: а какова же дата окончания договора, начинающегося 28.02.2024?
Используем Add_months DB Oracle и получаем удивительную картину:
Начало периода | Конец периода | Количество дней | День месяца начала периода | День месяца конца периода |
28.02.2024 | 27.02.2025 | 366 | Предпоследний | Предпоследний |
28.02.2025 | 27.02.2026 | 365 | Последний | Предпоследний |
28.02.2026 | 27.02.2027 | 365 | Последний | Предпоследний |
28.02.2027 | 28.02.2028 | 366 | Последний | Предпоследний |
29.02.2028 | 27.02.2029 | 365 | Последний | Предпоследний |
Спустя 4 года, дата начала периода сдвигается на 29.02.
Чем же руководствоваться при решении такого бага, а может целой фичи?
Вот здесь уже всерьез придётся включить голову.
Вариант решения «в лоб» – использовать логику «Предпоследний день месяца-пред-предпоследний день месяца» – порождает встречный вопрос. А до какой глубины от даты конца месяца использовать принцип «пред-пред-***»? До 27 дня, 26… 15 или вообще первого числа месяца?
А может вообще, количество дней в месяце должно быть фиксировано и равно 30? Что-то слишком сложно выходит.
Поэтому делаем вывод, что логика «Предпоследний день месяца-пред-предпоследний день месяца» здесь не применима и единственным исключением датской арифметики является «Последний день – предпоследний день».
Таким образом, корректировке подлежат последние 2 периода для обеспечения одной даты начала действия договора:
Начало периода | Конец периода | Количество дней | День месяца начала периода | День месяца конца периода |
28.02.2024 | 27.02.2025 | 366 | Предпоследний | Предпоследний |
28.02.2025 | 27.02.2026 | 365 | Последний | Предпоследний |
28.02.2026 | 27.02.2027 | 365 | Последний | Предпоследний |
28.02.2027 | 27.02.2028 | 365 | Последний | Пред-предпоследний |
28.02.2028 | 27.02.2029 | 366 | Предпоследний | Предпоследний |
Обращаю внимание, что в этом конкретном кейсе для обеспечения детерминистичности функции расчёта дат необходимо передавать ещё дату начала действия договора. Идеальный вариант, когда метод централизованно считает все периоды сразу, вместо рекурсивного вызова для каждого периода.
Пример кода, рассчитывающий периоды (автор концепта - Денис Грачев):
DECLARE
TYPE TPERIOD IS RECORD (
pbeg DATE,
pend DATE
);
vPeriod TPERIOD;
TYPE TPERIODTABLE IS TABLE OF TPERIOD;
FUNCTION CalcPeriods(pBegin IN DATE, pEnd IN DATE) RETURN TPERIODTABLE
IS
vResult TPERIODTABLE := TPERIODTABLE();
vBegin DATE := TRUNC(pBegin);
vEnd DATE := TRUNC(pEnd);
vPeriod TPERIOD;
vMonths NUMBER := GREATEST(months_between(vEnd, vBegin), 1);
-- Необходимо корректировать 1 день в феврале високосного года
NeedCorrectLeapYear BOOLEAN := (TO_CHAR(vBegin,'dd.mm') = '28.02') AND (TO_CHAR(vBegin,'mm') = TO_CHAR(vBegin + 1,'mm'));
BEGIN
FOR cMonth IN 1..vMonths
LOOP
IF MOD(cMonth - 1, 12) = 0 THEN
vPeriod.pbeg := add_months(vBegin, cMonth - 1);
vPeriod.pend := add_months(vPeriod.pbeg, 12) - 1;
IF vPeriod.pend > pEnd THEN
vPeriod.pend := pEnd;
end if;
-- Исправление ошибки разбивки периодов многолетних договоров
IF (NeedCorrectLeapYear AND (TO_CHAR(vPeriod.pend,'dd.mm') = '28.02'))
THEN
vPeriod.pend := vPeriod.pend - 1;
END IF;
vResult.extend;
vResult(vResult.last) := vPeriod;
END IF;
END LOOP;
RETURN vResult;
END CalcPeriods;
BEGIN
NULL;
END;
Что ж, приветствуем на сцене тип данных INTERVAL Oracle любезно падающий ошибкой ORA-01843 на простом запросе:
SQL> Select TO_DATE('29-FEB-2024','DD-MON-YYYY') + TO_YMINTERVAL('1-0') As Res From Dual;
ORA-01843: месяц неверен
На этом примере видно, что даже в рамках одного диалекта языка программирования, для функций работы с датами, может быть совершенно разная логика под капотом. Что уж говорить о не родственных языках, например JavaScript и SQL – построение непротиворечивого расчета дат потребует либо высокой компетенции в обоих языках, либо качественных Unit-тестов.
А теперь-то всё?
К сожалению, прямое и обратное преобразование периода в дату тоже не всегда однородно.
Например, Months_Between даёт разные значения в зависимости от месяцев, которые сравниваются (запрос приведен для части месяца, для случая годовых интервалов эту особенность игнорируют):
SQL> Select Months_between(TO_DATE('15.02.2023','DD.MM.YYYY'), TO_DATE('01.02.2023','DD.MM.YYYY')) As Res From Dual;
RES
----------
0,45161290
SQL> Select Months_between(TO_DATE('01.03.2023','DD.MM.YYYY'), TO_DATE('16.02.2023','DD.MM.YYYY')) As Res From Dual;
RES
----------
0,51612903
Для дочитавших до конца прикладываю годовые интервалы, которые портируются в Unit-тесты.
Таблица интервалов
Начало периода | Конец периода | Количество дней | Дата начала договора | Категория теста високосного года |
28.02.2023 | 28.02.2024 | 366 | 28.02.2023 | Последний день невисокосного |
29.02.2024 | 27.02.2025 | 365 | 28.02.2023 | Последний день високосного |
28.02.2025 | 27.02.2026 | 365 | 28.02.2023/24 | Последний день невисокосного |
28.02.2024 | 27.02.2025 | 366 | 28.02.2023/24 | Предпоследний день високосного |
28.02.2027 | 27.02.2028 | 365 | 28.02.2024 | Последний день невисокосного при начале периода в високосном году |
Внимательный читатель заметит, что первая и последняя строки таблицы дают различный результат, если не привязываться к дате начала действия договора. Так же, к обычным тестам на даты, целесообразно добавить ещё и тесты на непрерывность периодов.
С Oracle понятно, есть вариант решения, есть баги, есть тесты – можно пилить свою логику. А что делать тем, с кем случилось импортозамещение? И не простое, а на PostgreSQL.
Здесь нам поможет уже привычный Extension OraFce – в котором есть реализация Oracle.Add_months со всей своей спецификой.
Что, конечно, не отменяет возможности написать своё собственное решение, вытянуть его в микро/нано сервис и подвергнуть рефакторингу всю систему.
Мы посмотрели только вершину айсберга датской арифметики – работу с годовыми интервалами. Для месячных интервалов нюансов больше (вспоминаем Months_Between). И там одним простым костылем не обойтись.
В сухом остатке, датская арифметика требует от исполнителя скрупулезности в реализации и хорошего знания используемых методов языка программирования по работе с датами. Хитрость на этом пути – использование UT. Если забить на что-то из этого, то максимум через 4 года зелёное насекомое снова разбудит тебя ночью падающим продом )