Как найти свободные периоды времени, не входящие в заданные интервалы в 1С

Программист 1С v8.3 (Управляемые формы) IT и автоматизация бизнеса
← На главную

В практике разработки на платформе 1С программисты часто сталкиваются с задачей распределения времени, планирования или анализа занятости ресурсов. Представьте классическую ситуацию: у вас есть глобальный исследуемый интервал (например, календарный год или период с начала тысячелетия по текущий день) и список «занятых» периодов внутри него (это могут быть периоды отпусков сотрудников, графики аренды оборудования или зафиксированные интервалы отгрузок). Наша цель — найти все свободные «окна» (или «дыры»), то есть периоды, которые не перекрываются ни одним из занятых интервалов.

В этой статье мы подробно разберем два принципиально разных, но крайне эффективных подхода к решению этой задачи: алгоритмический метод на встроенном языке (с использованием таблиц значений в оперативной памяти) и декларативный метод в запросе (с использованием математического инвертирования периодов).

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

Для наглядности будем использовать пример, озвученный автором темы: у нас есть глобальный период с 1 января 2000 года по 31 июля 2019 года. Внутри него зафиксированы два периода исключений:

  1. С 1 мая 2005 года по 30 ноября 2012 года.
  2. С 1 августа 2014 года по 29 февраля 2016 года.

В результате работы алгоритма мы должны получить три свободных интервала: с января 2000 по апрель 2005, с декабря 2012 по июль 2014 и с марта 2016 по июль 2019.

Выясним, как реализовать оба подхода максимально красиво и надежно.

---

Решение 1. Программный обход в памяти (Таблицы значений и Массивы)

Если все исходные данные уже выбраны в оперативную память и вам не требуется производить дополнительные тяжелые соединения с физическими таблицами базы данных, логичнее и быстрее выполнить расчет прямо в коде. Это исключит лишние транзакции и обращения к СУБД.

Рассмотрим базовый алгоритм, который предложил автор темы. Он основан на последовательном переборе отсортированных периодов-исключений и сдвиге курсора «текущей даты».

Давайте детально разберем исходный код этой функции:


Функция ЕжемесячныеПериоды(ТЗ, НД, КД)
    ТЗрез = Новый ТаблицаЗначений;
    ТЗрез.Колонки.Добавить("Начало");
    ТЗрез.Колонки.Добавить("Конец");
    
    КонПер = НД; // Инициализируем переменную для отслеживания конца предыдущего периода
    
    Для Каждого Стр Из ТЗ Цикл
        Если НД < Стр.НачалоПериода Тогда
            Строка = ТЗрез.Добавить();
            Строка.Начало = НД;
            Строка.Конец = КонецМесяца(ДобавитьМесяц(Стр.НачалоПериода, -1));
            НД = КонецМесяца(Стр.КонецПериода) + 1;
        ИначеЕсли НД < Стр.КонецПериода Тогда
            НД = НачалоМесяца(ДобавитьМесяц(Стр.КонецПериода, 1));
        КонецЕсли;
        КонПер = Стр.КонецПериода;
    КонецЦикла;
    
    Если КонПер < КД Тогда
        Строка = ТЗрез.Добавить();
        Строка.Начало = НачалоМесяца(ДобавитьМесяц(КонПер, 1));
        Строка.Конец = КонецМесяца(КД); 
    КонецЕсли;
    
    Возврат ТЗрез;
КонецФункции

Как работает этот код? Мы передаем в функцию таблицу исключений ТЗ (где колонками выступают НачалоПериода и КонецПериода), а также глобальные границы поиска — начало диапазона НД и конец диапазона КД.

В цикле мы сравниваем текущую точку начала свободного периода НД с началом очередного исключения. Если текущая точка строго меньше начала занятого интервала, значит, мы нашли свободное окно! Мы фиксируем его в результирующей таблице ТЗрез, определяя дату окончания как последний день предыдущего месяца перед началом исключения. После этого мы сдвигаем нашу рабочую точку НД на день, следующий за окончанием текущего исключения.

В завершение алгоритма мы проверяем: если после обработки всех исключений последняя зафиксированная дата окончания КонПер все еще меньше глобального конца периода КД, мы добавляем финальный «хвост» в результирующую таблицу.

Важное предупреждение: Данный код будет работать корректно только при соблюдении двух жестких условий:

  1. Исходная таблица ТЗ должна быть строго отсортирована по колонке НачалоПериода.
  2. В исходных данных не должно быть пересекающихся или накладывающихся друг на друга периодов (например, когда один период длится с мая по октябрь, а второй — с августа по декабрь того же года).

Если в вашей системе возможны пересечения исключений (что в реальной практике 1С происходит повсеместно — например, пересекающиеся больничные, командировки сотрудников или наложение скидок), этот алгоритм даст сбой. Проанализируем, как сделать решение абсолютно надежным с помощью предварительного сжатия интервалов.

---

Модификация алгоритма: Слияние пересекающихся интервалов (Merge Intervals)

Чтобы гарантировать стабильность программы при любых входящих данных, мы должны сначала «схлопнуть» все пересекающиеся и прилегающие друг к другу периоды исключений. Посмотрим на классический алгоритм слияния интервалов, адаптированный под встроенный язык 1С:


Функция ОбъединитьПересекающиесяПериоды(ИсходнаяТЗ)
    // 1. Создаем структуру для чистых периодов
    ЧистыеПериоды = Новый ТаблицаЗначений;
    ЧистыеПериоды.Колонки.Добавить("НачалоПериода", Новый ОписаниеТипов("Дата"));
    ЧистыеПериоды.Колонки.Добавить("КонецПериода", Новый ОписаниеТипов("Дата"));
    
    Если ИсходнаяТЗ.Количество() = 0 Тогда
        Возврат ЧистыеПериоды;
    КонецЕсли;
    
    // 2. Обязательно сортируем по возрастанию даты начала
    ИсходнаяТЗ.Сортировать("НачалоПериода Возр");
    
    // 3. Алгоритм слияния (сканирование)
    ТекущийПериод = ИсходнаяТЗ[0];
    НоваяСтрока = ЧистыеПериоды.Добавить();
    ЗаполнитьЗначенияСвойств(НоваяСтрока, ТекущийПериод);
    
    Для Индекс = 1 По ИсходнаяТЗ.Количество() - 1 Цикл
        СледующийПериод = ИсходнаяТЗ[Индекс];
        
        // Проверяем наложение (с учетом непрерывности, например, разница в 1 день)
        Если СледующийПериод.НачалоПериода <= (НоваяСтрока.КонецПериода + 86400) Тогда 
            // Периоды пересекаются или граничат друг с другом, расширяем границу окончания
            НоваяСтрока.КонецПериода = Макс(НоваяСтрока.КонецПериода, СледующийПериод.КонецПериода);
        Или
            // Пересечения нет, создаем новый чистый интервал
            НоваяСтрока = ЧистыеПериоды.Добавить();
            ЗаполнитьЗначенияСвойств(НоваяСтрока, СледующийПериод);
        КонецЕсли;
    КонецЦикла;
    
    Возврат ЧистыеПериоды;
КонецФункции

В этом коде число 86400 представляет собой количество секунд в сутках. Мы используем его, чтобы корректно объединять смежные периоды (например, если первый период заканчивается 30 апреля, а второй начинается 1 мая, они сольются в один общий непрерывный интервал).

Теперь, если мы сначала пропустим исходную таблицу через функцию ОбъединитьПересекающиесяПериоды, а затем передадим результат в нашу основную функцию поиска дыр, алгоритм отработает безупречно при любых входящих данных!

---

Решение 2. Инвертирование периодов в запросе

Если периодов много (сотни тысяч строк) или они хранятся в регистрах сведений базы данных, выгружать их все в память сервера приложений 1С крайне неэффективно. Намного правильнее переложить эту математическую задачу на плечи СУБД. Разберем элегантный декларативный метод инвертирования («выворачивания») периодов в запросе.

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

Проанализируем шаги построения такого запроса:

  1. Сбор всех критических точек (границ). Нам нужно собрать в одну временную таблицу все даты, в которых происходит смена статуса периода. Это:
    • Глобальное начало диапазона.
    • Каждая дата начала периода-исключения минус 1 день (так как свободный период закончится ровно накануне).
    • Каждая дата окончания периода-исключения плюс 1 день (так как свободный период начнется на следующий день).
    • Глобальный конец диапазона.
  2. Удаление дублей и фильтрация. Мы должны оставить только уникальные даты, которые лежат строго внутри нашего глобального интервала.
  3. Порядковая нумерация дат. Связывая таблицу дат саму с собой по условию Раньше.Дата < Даты.Дата, мы подсчитываем количество записей и присваиваем каждой дате ее уникальный порядковый номер (1, 2, 3, 4...).
  4. Группировка по парам. Мы делим порядковый номер нацело на 2. Даты под номерами 1 и 2 получат признак 1, даты 3 и 4 — признак 2, и так далее. Сгруппировав по этому признаку, мы получим наши искомые свободные интервалы как МИНИМУМ(Дата) и МАКСИМУМ(Дата) в каждой паре!

Посмотрим на практическую реализацию этого красивого математического метода в запросе 1С:


ВЫБРАТЬ
    &НачалоГлобальногоПериода КАК ДатаГраницы
ПОМЕСТИТЬ ВТ_ВсеГраницы

ОБЪЕДИНИТЬ ВСЕ

ВЫБРАТЬ
    &КонецГлобальногоПериода

ОБЪЕДИНИТЬ ВСЕ

ВЫБРАТЬ
    ДОБАВИТЬКДАТЕ(ИсходныеПериоды.НачалоПериода, ДЕНЬ, -1)
ИЗ
    РегистрСведений.Исключения КАК ИсходныеПериоды

ОБЪЕДИНИТЬ ВСЕ

ВЫБРАТЬ
    ДОБАВИТЬКДАТЕ(ИсходныеПериоды.КонецПериода, ДЕНЬ, 1)
ИЗ
    РегистрСведений.Исключения КАК ИсходныеПериоды
;

////////////////////////////////////////////////////////////////////////////////
// Отсекаем даты за пределами глобальных рамок и убираем дубликаты
ВЫБРАТЬ РАЗЛИЧНЫЕ
    ВТ_ВсеГраницы.ДатаГраницы КАК ДатаГраницы
ПОМЕСТИТЬ ВТ_ОчищенныеГраницы
ИЗ
    ВТ_ВсеГраницы КАК ВТ_ВсеГраницы
ГДЕ
    ВТ_ВсеGranicy.ДатаГраницы МЕЖДУ &НачалоГлобальногоПериода И &КонецГлобальногоПериода
;

////////////////////////////////////////////////////////////////////////////////
// Пронумеруем полученные даты по возрастанию
ВЫБРАТЬ
    Таб1.ДатаГраницы КАК ДатаГраницы,
    КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Таб2.ДатаГраницы) КАК ПорядковыйНомер
ПОМЕСТИТЬ ВТ_ПронумерованныеГраницы
ИЗ
    ВТ_ОчищенныеГраницы КАК Таб1
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_ОчищенныеГраницы КАК Таб2
        ПО Таб1.ДатаГраницы >= Таб2.ДатаГраницы

ГРУППИРОВАТЬ ПО
    Таб1.ДатаГраницы
;

////////////////////////////////////////////////////////////////////////////////
// Группируем по парам и получаем свободные интервалы ("дыры")
ВЫБРАТЬ
    МИНИМУМ(ВТ_ПронумерованныеГраницы.ДатаГраницы) КАК НачалоСвободногоПериода,
    МАКСИМУМ(ВТ_ПронумерованныеГраницы.ДатаГраницы) КАК КонецСвободногоПериода
ИЗ
    ВТ_ПронумерованныеГраницы КАК ВТ_ПронумерованныеГраницы

ГРУППИРОВАТЬ ПО
    ВЫРАЗИТЬ((ВТ_ПронумерованныеГраницы.ПорядковыйНомер - 1) / 2 КАК ЧИСЛО(10, 0))

Этот запрос поражает своей лаконичностью и скоростью выполнения на уровне СУБД. Он полностью автоматизирует расчет, не нагружая сервер приложений 1С циклами и условными переходами.

Однако помните: если ваши периоды исключений в базе данных могут пересекаться, этот запрос перед запуском инверсии также потребует предварительной подготовки данных — их «схлопывания» (объединения). Сделать это в запросе можно с помощью классического метода расчета нарастающего итога (интеграла дат) или метода сканирующей прямой (Scanline), где начала периодов прибавляют единицу к виртуальному счетчику активности, а концы — вычитают. Для эффективной обработки интервалов непосредственно в запросе можно использовать линейный алгоритм определения интервалов между записями.

---

Сравнительный анализ решений: когда и что использовать?

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

Программный метод (Таблицы значений в памяти):

Декларативный метод (Запрос):

Используйте описанные нами паттерны проектирования, всегда проверяйте исходные данные на предмет пересечения интервалов времени и выбирайте инструмент, максимально соответствующий нагрузочным требованиям вашей информационной системы!

← На главную