Начнем с теории

Начнём с теории
Начнём с теории

В ФЗ-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 число.

Этому правилу следуют
  1. Класс Calendar в Java

  2. Подкласс dateutil.relativedelta Python

  3. Тип данных 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

Последний день невисокосного при начале периода в високосном году

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

А как оно в PostgreSQL?
А как оно в PostgreSQL?

С Oracle понятно, есть вариант решения, есть баги, есть тесты – можно пилить свою логику. А что делать тем, с кем случилось импортозамещение? И не простое, а на PostgreSQL.

Здесь нам поможет уже привычный Extension OraFce – в котором есть реализация Oracle.Add_months со всей своей спецификой.

Что, конечно, не отменяет возможности написать своё собственное решение, вытянуть его в микро/нано сервис и подвергнуть рефакторингу всю систему.

Итог
Итог

Мы посмотрели только вершину айсберга датской арифметики – работу с годовыми интервалами. Для месячных интервалов нюансов больше (вспоминаем Months_Between). И там одним простым костылем не обойтись.

В сухом остатке, датская арифметика требует от исполнителя скрупулезности в реализации и хорошего знания используемых методов языка программирования по работе с датами. Хитрость на этом пути – использование UT. Если забить на что-то из этого, то максимум через 4 года зелёное насекомое снова разбудит тебя ночью падающим продом )

Комментарии (0)