Вычисляем дату с учётом рабочих дней на 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).
Прежде, чем реализовывать конвертер, следует определить правила, которым он будет подчиняться:
- фильтруем только те объекты, которые имеют значение атрибута
type_text
эквивалентное следующим: "Дополнительный / перенесенный выходной день", "Государственный праздник"; - дата должна быть представлена в формате "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))
Заключение
Решение подобных базовых задач очень позитивно складывается на опыте начинающих и продолжающих разработчиков.
Поделитесь, сталкивались ли вы с подобными задачами? Какое решение в итоге находили?