Программирование
March 4

Вычисляем дату с учётом рабочих дней на python

Сегодня на работе столкнулся с задачей, когда надо было посчитать дату оплаты счёта, с учётом праздников и выходных (чтобы выводилась дата по условию "не позднее, чем через 5 рабочих дней). Ранее подобных вещей я не делал, поэтому пошёл гуглить.

Ищем готовое решение на python

На этапе ресёрча по задаче нашёл такое решение:

import datetime
import numpy as np

start = datetime.date(2022, 2, 15)
end = datetime.date(2022, 3, 16)

# include holidays in a list
days = np.busday_count(start, end, holidays=['2022-02-21'])
print('Number of business days is:', days)

На том же сайте было упоминание библиотеки holidays, чтобы проставить сразу корректный список всех праздников, относительно определённой страны. Принцип работы простой:

from datetime import date
import holidays

ru_holidays = holidays.RU()

date(2024, 3, 8) in ru_holidays # True
date(2024, 3, 7) in ru_holidays # False

К сожалению, нельзя сказать, что это решение покрывает все кейсы. К примеру, в 2024м году майские праздники в России по производственному календарю продлятся с 9 по 12 мая. Библиотека же, считает не так:

>>> date(2024, 5, 9) in ru_holidays
True
>>> date(2024, 5, 10) in ru_holidays
False

В этом примере, 9 мая ещё входит в список праздников, а вот 10 мая уже нет. Возможно, что numpy будет определять корректно?

>>> import datetime
>>> import numpy as np
>>> start = datetime.date(2024, 5, 6)
>>> end = datetime.date(2024, 5, 12)
>>> days = np.busday_count(start, end)
>>> days
5

Как видим, без параметра holidays, numpy считает, что вся неделя с 6 по 12 рабочая, кроме 2 выходных.

Вытащим содержимое всех праздников за 2024:

>>> holidays.RU(years=2024)
{datetime.date(2024, 1, 1): 'Новогодние каникулы', datetime.date(2024, 1, 2): 'Новогодние каникулы', datetime.date(2024, 1, 3): 'Новогодние каникулы', datetime.date(2024, 1, 4): 'Новогодние каникулы', datetime.date(2024, 1, 5): 'Новогодние каникулы', datetime.date(2024, 1, 6): 'Новогодние каникулы', datetime.date(2024, 1, 8): 'Новогодние каникулы', datetime.date(2024, 1, 7): 'Рождество Христово', datetime.date(2024, 2, 23): 'День защитника Отечества', datetime.date(2024, 3, 8): 'Международный женский день', datetime.date(2024, 5, 1): 'Праздник Весны и Труда', datetime.date(2024, 5, 9): 'День Победы', datetime.date(2024, 6, 12): 'День России', datetime.date(2024, 11, 4): 'День народного единства'}

Как видно, некоторые нерабочие дни тут не включены, поэтому такое решение не совсем подходит.

Ищем открытое API

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

Следующее, на что я наткнулся, было открытое API производственного календаря для России и Казахстана. Работа с ним максимально простая. К примеру, чтобы получить список всех "отклонений" от обычного календаря для России, достаточно выполнить GET-запрос по адресу: https://production-calendar.ru/get/ru/2024/json?compact=1. Результат полностью совпадает с производственным календарём на сайте КонсультантПлюс.

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

Мастерим решение

Я накидал простенькую блок-схему алгоритма поиска 5 рабочих дней, по которому буду определять конечную дату:

Представим это кодом:

import datetime
import numpy as np

# глобальные константы откуда-то из settings/django constance
HOLIDAYS = ['2024-03-08', ...]
BUSINESS_DAYS_DELTA = 5

def found_business_days_date(step = 5, _start_date = None):
  start_date = _start_date or datetime.datetime.now().date()
  end_date = start_date + datetime.timedelta(days=step)

  days = np.busday_count(start_date, end_date, holidays=HOLIDAYS)

  if days == BUSINESS_DAYS_DELTA:
    return end_date

  return found_business_days_date(step + 1, start_date)

Результат работы этой функции для сегодняшней даты (04.03.2024): 2024-03-12. Соответственно, функция работает корректно.

Осталось только добавить периодическую задачку для celery и конвертацию праздников в нужный формат.

Обновление константы HOLIDAYS

Для периодического обновления нам необходимо реализовать 2 функции — parse_holidays и convert_holidays_to_list.

Напишем первую:

import requests

def parse_holidays(url, logger):
  try:
    response = requests.get(url).json()
    return response

  except Exception as e:
    logger.error('[PARSE HOLIDAYS] API error:', e)

Пример упрощённый и не покрывает каждую отдельную ошибку, но реагирует на все ошибки в целом. В эту функцию поступает два аргумента: url и logger. Думаю, по их названиям понятно, для чего они предназначены. Я решил вынести url в аргументы, чтобы можно было его подменить на какой-то другой адрес извне (например, из переменных окружения или того же constance).

Прежде, чем реализовывать конвертер, следует определить правила, которым он будет подчиняться:

  1. фильтруем только те объекты, которые имеют значение атрибута type_text эквивалентное следующим: "Дополнительный / перенесенный выходной день", "Государственный праздник";
  2. дата должна быть представлена в формате "YYYY-MM-DD".

Для конвертации даты из одного формата в другой воспользуемся возможностями библиотеки datetime. К примеру, если мы хотим из даты в формате "DD.MM.YYYY" получить дату в формате "YYYY-MM-DD", мы можем сделать это следующим образом:

import datetime

datetime.datetime\
  .strptime("04.03.2024", "%d.%m.%Y")\
  .strftime("%Y-%m-%d") # 2024-03-04

Реализуем конвертер:

import datetime

def convert_holidays_to_list(holidays):
  # заведём константу для тех type_text, которые хотим включить
  INCLUDED_TYPE_TEXT = [
    "Дополнительный / перенесенный выходной день",
    "Государственный праздник",
  ]

  # заведём пустой список для результата
  converted_list = []

  # отфильтруем только нужные даты с помощью filter
  cleaned_holidays = list(filter(
    lambda holiday: holiday.get("type_text") in INCLUDED_TYPE_TEXT,
    holidays,
  ))

  # запишем сконвертированные в строковом виде в итоговый список
  for holiday in cleaned_holidays:
    converted_list.append(
      datetime.datetime\
        .strptime(holiday.get("date"), "%d.%m.%Y")\
        .strftime("%Y-%m-%d")
    )

  return converted_list

Теперь реализуем связку этих двух функций в периодической задачке:

from celery import app
from constance import config
from utils import parse_holidays, convert_holidays_to_list
from datetime import datetime

logger = getLogger(__name__)

@app.task
def update_holidays():
  current_year = datetime.now().date().year
  url = f"https://production-calendar.ru/get/ru/{current_year}/json?compact=1"

  holidays = parse_holidays(url, logger)

  if holidays:
    holidays_list = convert_holidays_to_list(holidays.get("days"))

    setattr(config, "HOLIDAYS", ','.join(holidays_list))

Заключение

Решение подобных базовых задач очень позитивно складывается на опыте начинающих и продолжающих разработчиков.

Поделитесь, сталкивались ли вы с подобными задачами? Какое решение в итоге находили?