Несколько месяцев назад я опубликовал плагин к Moment.js позволяющий рассчитать: сколько это N рабочих дней от сегодня в календарных днях? какая дата будет спустя N рабочих дней от заданной даты? сколько рабочих дней в заданном диапазоне? Возможность сконфигурировать рабочие дни и исключения в виде праздников — имеется.

Плагин можно найти на github: https://github.com/andruhon/moment-weekday-calc

Плагин можно установить через bower и npm:
bower install moment-weekday-calc

npm install moment-weekday-calc

Плагин добавляет несколько функций в Moment.js:
  • int weekdayCalc — считает сколько «рабочих» дней в заданном диапазоне
  • date addWorkdays — находит дату спустя N «рабочих» (пн-пт) дней
  • int workdaysToCalendarDays — конвертирует рабочие дни в календарные
  • date addWeekdaysFromSet — добавляет дни из заданного множества к заданной дате
  • int weekdaysFromSetToCalendarDays — конвертирует дни из заданного множетсва в календартные дни

Каждая из функций доступна с префиксом iso, такие функции используют множество рабочих дней начинающееся с понедельника (1-7), функции без префикса используют американский формат начинающийся с воскресенья (0-6).

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

Использование:
Привожу примеры только для функций с префиксом iso.

Сколько пятниц между 14 и 23 февраля?
moment('14 Feb 2014').isoWeekdayCalc('23 Feb 2014',[5]); //2  
(в данном случае начало диапазона берётся из объекта moment, из которого мы вызываем функцию)

Сколько рабочих дней, без учёта праздников в с 1 апреля 2015 года по 31 марта 2016?
moment().isoWeekdayCalc('1 Apr 2015','31 Mar 2016',[1,2,3,4,5]); //262  
(здесь объект moment не содержит даты, поэтому начальная дата задаётся в качестве первого аргумента)

А если учесть пару праздников?
moment().isoWeekdayCalc('1 Apr 2015','31 Mar 2016',[1,2,3,4,5],['6 Apr 2015','7 Apr 2015']); //260 

Вызов с объектом:
moment().isoWeekdayCalc({  
  rangeStart: '1 Apr 2015',  
  rangeEnd: '31 Mar 2016',  
  weekdays: [1,2,3,4,5],  
  exclusions: ['6 Apr 2015','7 Apr 2015']  
}) //260

Что за дата будет спустя 5 рабочих дней после 2 февраля, если работать без выходных?
moment('2015-02-02').isoAddWeekdaysFromSet(5, [1,2,3,4,5,7]); //2015-02-08

5 рабочих дней после 4 мая, с учётом 9го мая?
moment('2015-05-04').isoAddWeekdaysFromSet({  
  'workdays': 5,  
  'weekdays': [1,2,3,4,5,6],  
  'exclusions': ['2015-05-09']  
}); //2015-05-11 

11 рабочих дней после 10 октября в календарных днях, рабочие дни — среда-воскресенье:
moment('2015-10-05').isoWeekdaysFromSetToCalendarDays(11, [3,4,5,6,7], ['2015-10-15']) //17

Подробнее в README на гитхабе.

Заранее спасибо за здравую критику. Надеюсь, что плагин будет кому-нибудь полезен.

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


  1. vintage
    21.09.2015 03:26

    Думаю тут не помешала бы интеграция с одним из плагинов для диапазонов: http://momentjs.com/docs/#/plugins/range/


    1. Andruhon
      21.09.2015 04:14

      Буду благодарен, если создадите запрос на githubе: github.com/andruhon/moment-weekday-calc/issues


  1. MaximChistov
    21.09.2015 09:00
    +1

    Такая же штука на питоне :)

    Код
    #!/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/bin/python3.4
    # -*- coding: utf-8 -*-
    __author__ = 'admin'
    
    api_url = "http://basicdata.ru/api/json/calend/"
    from functools import lru_cache
    import calendar
    import datetime
    import urllib.request
    import json
    
    
    def flatten(a):
        if isinstance(a, list):
            for b in a:
                for x in flatten(b):
                    yield x
        else:
            yield a
    
    
    # Генерирует расписание на заданный год по умолчанию
    # Все дни с понедельника по пятницу отмечаются рабочими
    # Все субботы и воскресенья - выходными
    def generate_default_calendar(year):
        return group_by_month(
            map(lambda x: (x[0], x[1] < 6),
                map(lambda x: (x[0], x[1] + 1),
                    filter(lambda x: x[0] > 0,
                           flatten(calendar.Calendar.yeardays2calendar(calendar.Calendar(), year))
                           )
                    )
                )
        )
    
    
    # Разделяет дни на группы по месяцам
    def group_by_month_inner(items):
        month = []
        for day, flag in items:
            if month and month[-1][0] > day:
                # new month starting
                yield month
                month = []
            month.append((day, flag))
        if month:
            yield month
    
    
    def group_by_month(items):
        return list(group_by_month_inner(items))
    
    
    # Загружает дни-исключения
    @lru_cache(maxsize=None)
    def load_exceptions(apiurl, year):
        return json.loads(urllib.request.urlopen(apiurl).read().decode('utf8'))["data"][str(year)]
    
    
    # Меняет значения структуры по умолчанию для дней-исключений
    
    def apply_exceptions(months, exc):
        i = 0
        for m in months:
            newm = []
            i += 1
            for d in m:
                if str(i) in exc and str(d[0]) in exc[str(i)]:
                    d = (d[0], exc[str(i)][str(d[0])]["isWorking"] != 2)
                newm.append(d)
            yield newm
    
    
    # Удаляет все выходные, конвертирует кортежи в простые дни месяца
    def filter_holidays(months):
        for m in months:
            yield list(
                map(
                    lambda x: x[0],
                    filter(
                        lambda x: x[1],
                        m
                    )
                )
            )
    
    
    # Получает все рабочие дни за определенные месяц/год в виде массива. Если указать месяц, вернет только его
    def get_workdays(year=None, month=None):
        if year is None and month is None:
            year = datetime.datetime.now().year
            month = datetime.datetime.now().month
        if month is None:
            return list(filter_holidays(apply_exceptions(generate_default_calendar(year), load_exceptions(api_url, year))))
        else:
            return get_workdays(year)[month - 1]
    
    
    # Считает кол-в рабочих дней в году/месяце
    @lru_cache(maxsize=None)
    def count_workdays(year=None, month=None):
        if month is None and year is None:
            return len(get_workdays())
        elif month is not None:
            return len(get_workdays(year, month))
        else:
            return sum(list(map(lambda x: len(x), get_workdays(year))), 0)
    
    @lru_cache(maxsize=None)
    def _get_expected_hours(year, month, day):
        return len(list(filter(lambda x: x < day, get_workdays()))) * 8
    # Возвращает сколько часов ты уже должен был отработать
    def get_expected_hours():
        return _get_expected_hours(datetime.datetime.now().year, datetime.datetime.now().month, datetime.datetime.now().day)
    
    
    # Считает заработанные деньги исходя из зарплаты и кол-ва отработанны часов
    @lru_cache(maxsize=None)
    def earned(salary, hours):
        return hours / (count_workdays(datetime.datetime.now().year, datetime.datetime.now().month) * 8) * salary
    
    
    def print_earned_with_stats(hours, salary=None):
        if salary is None:
            salary = 50000
        real = earned(salary, hours)
        expected = earned(salary, get_expected_hours())
        print("Earned: ", real, " Expected: ", expected)
        if real > expected:
            print("Well done, you've already earned extra ", real - expected, " money -", hours - get_expected_hours(),
                  " extra hours worked")
        elif real < expected:
            print("You should work extra ", get_expected_hours() - hours, " hours to catch schedule")
        else:
            print("Going on schedule!")
    
    if __name__ == '__main__':
        import sys
        _salary = None
        _hours = None
        if len(sys.argv) > 1:
            _hours = int(sys.argv[1])
        if len(sys.argv) > 2:
            _salary = int(sys.argv[2])
        if _hours is None:
            _hours = 8
        print_earned_with_stats(_hours, _salary)
    
    


    1. Andruhon
      21.09.2015 09:10
      +1

      А чего не на гитхабе? Вещь то годная.


      1. MaximChistov
        21.09.2015 09:13

        Пока нету времени на такое, учебу с работой и то еле совмещаю)))


  1. MaximChistov
    21.09.2015 09:03

    Вот вам сразу реквест — хотите, чтобы ваш плагин использовали — грузите исключения сами :)
    Советую basicdata.ru/api/json/calend Описание формата: basicdata.ru/api/calend


    1. Andruhon
      21.09.2015 09:09

      Спасибо. Стран слишком много в мире — надо будет подумать, как это сделать универсально.


      1. MaximChistov
        21.09.2015 09:15

        Ну тут все просто — конечный результат вам от любого api нужен один — чтобы оно выдавало дни-исключения. Соответственно, можно из любого формата апи приводить к этому, универсальному, и работать с ним. Тогда использующему ваш модуль в очередной стране надо будет только найти апи для нее и реализовать функцию конвертации


        1. Andruhon
          21.09.2015 09:20

          Быть может заранее заготовить пакеты для стран, которые можно будет установить отдельно? Ну, допустим moment-weekday-calculator-public-holidays-ru и т.д. Как считаете?


          1. MaximChistov
            21.09.2015 09:21

            ну в принципе имеет смысл, вряд ли большинство будет с ним работать более чем в одной стране.


            1. Andruhon
              21.09.2015 09:24

              Ок. Спасибо. Почешу репу, как время опять будет.


              1. WorksIsGone
                21.09.2015 11:13
                +1

                Я порекомендовал глянуть в сторону ics.
                Парсится слёту, один вопрос — не забыть раз в год утянуть свежий список.

                Мне нужен был датский, первый из интернетов —
                www.officeholidays.com/ics/ics_country.php?tbl_country=Denmark
                На самом деле, это серьёзный вопрос, кому доверять, и не стоит брать вот так вот первый попавшийся.


                1. Andruhon
                  21.09.2015 11:47

                  Спасибо.


      1. MaximChistov
        21.09.2015 09:18

        Кстати еще я не уверен, но вы похоже не учли вариант, что иногда праздничные дни делают рабочими(или если рабочий день в список исключений положить, он станет выходным?)


        1. Andruhon
          21.09.2015 09:23

          Тут всё конкретно — плагин принимает исключения для рабочих дней. Если выпадает на выходные, то просто ничего не меняет. На данный момент это остаётся на откуп разработчику — считать «mondaized» этот выходной или нет.


          1. maximw
            22.09.2015 11:09

            По-хорошему нужны два списка исключений: для рабочих и для выходных дней. Иногда рабочие дни переносят на выходной.


            1. Andruhon
              22.09.2015 11:51

              Сделаю, если кто-то запросит и найдётся на это время.


      1. freakru
        21.09.2015 12:13

        В некоторых странах еще и по отдельным провинциям/землям разные праздники. А есть и плавающие праздники, зависящие от даты пасхи.


    1. sergeyZ
      21.09.2015 13:25

      Будьте осторожны при использовании этого API! Там неправильные данные, например basicdata.ru говорит, что 20 февраля 2015 — сокращенный на 1 час рабочий день, а на самом деле это не так.


      1. MaximChistov
        21.09.2015 15:01

        Ну сокращенные на 1 час я не смотрел, а по кол-ву рабочих/нерабочих дней у них все правильно высчитывалось(сокращенные за рабочий считал)


        1. sergeyZ
          21.09.2015 15:07

          Да, проблема может быть, если мы будем считать не количество рабочих дней, а количество рабочих часов. На всякий случай предупредил, сам чуть не наступил на эти грабли.