В практике разработки на платформе 1С программисты часто сталкиваются с задачей распределения времени, планирования или анализа занятости ресурсов. Представьте классическую ситуацию: у вас есть глобальный исследуемый интервал (например, календарный год или период с начала тысячелетия по текущий день) и список «занятых» периодов внутри него (это могут быть периоды отпусков сотрудников, графики аренды оборудования или зафиксированные интервалы отгрузок). Наша цель — найти все свободные «окна» (или «дыры»), то есть периоды, которые не перекрываются ни одним из занятых интервалов.
В этой статье мы подробно разберем два принципиально разных, но крайне эффективных подхода к решению этой задачи: алгоритмический метод на встроенном языке (с использованием таблиц значений в оперативной памяти) и декларативный метод в запросе (с использованием математического инвертирования периодов).
Давайте вместе проанализируем обе методики, выявим их сильные стороны и научимся правильно обрабатывать сложные случаи, такие как пересекающиеся или накладывающиеся друг на друга интервалы занятости.
Для наглядности будем использовать пример, озвученный автором темы: у нас есть глобальный период с 1 января 2000 года по 31 июля 2019 года. Внутри него зафиксированы два периода исключений:
В результате работы алгоритма мы должны получить три свободных интервала: с января 2000 по апрель 2005, с декабря 2012 по июль 2014 и с марта 2016 по июль 2019.
Выясним, как реализовать оба подхода максимально красиво и надежно.
---
Если все исходные данные уже выбраны в оперативную память и вам не требуется производить дополнительные тяжелые соединения с физическими таблицами базы данных, логичнее и быстрее выполнить расчет прямо в коде. Это исключит лишние транзакции и обращения к СУБД.
Рассмотрим базовый алгоритм, который предложил автор темы. Он основан на последовательном переборе отсортированных периодов-исключений и сдвиге курсора «текущей даты».
Давайте детально разберем исходный код этой функции:
Функция ЕжемесячныеПериоды(ТЗ, НД, КД)
ТЗрез = Новый ТаблицаЗначений;
ТЗрез.Колонки.Добавить("Начало");
ТЗрез.Колонки.Добавить("Конец");
КонПер = НД; // Инициализируем переменную для отслеживания конца предыдущего периода
Для Каждого Стр Из ТЗ Цикл
Если НД < Стр.НачалоПериода Тогда
Строка = ТЗрез.Добавить();
Строка.Начало = НД;
Строка.Конец = КонецМесяца(ДобавитьМесяц(Стр.НачалоПериода, -1));
НД = КонецМесяца(Стр.КонецПериода) + 1;
ИначеЕсли НД < Стр.КонецПериода Тогда
НД = НачалоМесяца(ДобавитьМесяц(Стр.КонецПериода, 1));
КонецЕсли;
КонПер = Стр.КонецПериода;
КонецЦикла;
Если КонПер < КД Тогда
Строка = ТЗрез.Добавить();
Строка.Начало = НачалоМесяца(ДобавитьМесяц(КонПер, 1));
Строка.Конец = КонецМесяца(КД);
КонецЕсли;
Возврат ТЗрез;
КонецФункции
Как работает этот код? Мы передаем в функцию таблицу исключений ТЗ (где колонками выступают НачалоПериода и КонецПериода), а также глобальные границы поиска — начало диапазона НД и конец диапазона КД.
В цикле мы сравниваем текущую точку начала свободного периода НД с началом очередного исключения. Если текущая точка строго меньше начала занятого интервала, значит, мы нашли свободное окно! Мы фиксируем его в результирующей таблице ТЗрез, определяя дату окончания как последний день предыдущего месяца перед началом исключения. После этого мы сдвигаем нашу рабочую точку НД на день, следующий за окончанием текущего исключения.
В завершение алгоритма мы проверяем: если после обработки всех исключений последняя зафиксированная дата окончания КонПер все еще меньше глобального конца периода КД, мы добавляем финальный «хвост» в результирующую таблицу.
Важное предупреждение: Данный код будет работать корректно только при соблюдении двух жестких условий:
ТЗ должна быть строго отсортирована по колонке НачалоПериода.Если в вашей системе возможны пересечения исключений (что в реальной практике 1С происходит повсеместно — например, пересекающиеся больничные, командировки сотрудников или наложение скидок), этот алгоритм даст сбой. Проанализируем, как сделать решение абсолютно надежным с помощью предварительного сжатия интервалов.
---
Чтобы гарантировать стабильность программы при любых входящих данных, мы должны сначала «схлопнуть» все пересекающиеся и прилегающие друг к другу периоды исключений. Посмотрим на классический алгоритм слияния интервалов, адаптированный под встроенный язык 1С:
Функция ОбъединитьПересекающиесяПериоды(ИсходнаяТЗ)
// 1. Создаем структуру для чистых периодов
ЧистыеПериоды = Новый ТаблицаЗначений;
ЧистыеПериоды.Колонки.Добавить("НачалоПериода", Новый ОписаниеТипов("Дата"));
ЧистыеПериоды.Колонки.Добавить("КонецПериода", Новый ОписаниеТипов("Дата"));
Если ИсходнаяТЗ.Количество() = 0 Тогда
Возврат ЧистыеПериоды;
КонецЕсли;
// 2. Обязательно сортируем по возрастанию даты начала
ИсходнаяТЗ.Сортировать("НачалоПериода Возр");
// 3. Алгоритм слияния (сканирование)
ТекущийПериод = ИсходнаяТЗ[0];
НоваяСтрока = ЧистыеПериоды.Добавить();
ЗаполнитьЗначенияСвойств(НоваяСтрока, ТекущийПериод);
Для Индекс = 1 По ИсходнаяТЗ.Количество() - 1 Цикл
СледующийПериод = ИсходнаяТЗ[Индекс];
// Проверяем наложение (с учетом непрерывности, например, разница в 1 день)
Если СледующийПериод.НачалоПериода <= (НоваяСтрока.КонецПериода + 86400) Тогда
// Периоды пересекаются или граничат друг с другом, расширяем границу окончания
НоваяСтрока.КонецПериода = Макс(НоваяСтрока.КонецПериода, СледующийПериод.КонецПериода);
Или
// Пересечения нет, создаем новый чистый интервал
НоваяСтрока = ЧистыеПериоды.Добавить();
ЗаполнитьЗначенияСвойств(НоваяСтрока, СледующийПериод);
КонецЕсли;
КонецЦикла;
Возврат ЧистыеПериоды;
КонецФункции
В этом коде число 86400 представляет собой количество секунд в сутках. Мы используем его, чтобы корректно объединять смежные периоды (например, если первый период заканчивается 30 апреля, а второй начинается 1 мая, они сольются в один общий непрерывный интервал).
Теперь, если мы сначала пропустим исходную таблицу через функцию ОбъединитьПересекающиесяПериоды, а затем передадим результат в нашу основную функцию поиска дыр, алгоритм отработает безупречно при любых входящих данных!
---
Если периодов много (сотни тысяч строк) или они хранятся в регистрах сведений базы данных, выгружать их все в память сервера приложений 1С крайне неэффективно. Намного правильнее переложить эту математическую задачу на плечи СУБД. Разберем элегантный декларативный метод инвертирования («выворачивания») периодов в запросе.
Идея метода строится на выделении всех уникальных граничных дат переходов, их сквозной нумерации и последующем попарном объединении.
Проанализируем шаги построения такого запроса:
Раньше.Дата < Даты.Дата, мы подсчитываем количество записей и присваиваем каждой дате ее уникальный порядковый номер (1, 2, 3, 4...).МИНИМУМ(Дата) и МАКСИМУМ(Дата) в каждой паре!Посмотрим на практическую реализацию этого красивого математического метода в запросе 1С:
ВЫБРАТЬ
&НачалоГлобальногоПериода КАК ДатаГраницы
ПОМЕСТИТЬ ВТ_ВсеГраницы
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
&КонецГлобальногоПериода
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДОБАВИТЬКДАТЕ(ИсходныеПериоды.НачалоПериода, ДЕНЬ, -1)
ИЗ
РегистрСведений.Исключения КАК ИсходныеПериоды
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДОБАВИТЬКДАТЕ(ИсходныеПериоды.КонецПериода, ДЕНЬ, 1)
ИЗ
РегистрСведений.Исключения КАК ИсходныеПериоды
;
////////////////////////////////////////////////////////////////////////////////
// Отсекаем даты за пределами глобальных рамок и убираем дубликаты
ВЫБРАТЬ РАЗЛИЧНЫЕ
ВТ_ВсеГраницы.ДатаГраницы КАК ДатаГраницы
ПОМЕСТИТЬ ВТ_ОчищенныеГраницы
ИЗ
ВТ_ВсеГраницы КАК ВТ_ВсеГраницы
ГДЕ
ВТ_ВсеGranicy.ДатаГраницы МЕЖДУ &НачалоГлобальногоПериода И &КонецГлобальногоПериода
;
////////////////////////////////////////////////////////////////////////////////
// Пронумеруем полученные даты по возрастанию
ВЫБРАТЬ
Таб1.ДатаГраницы КАК ДатаГраницы,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Таб2.ДатаГраницы) КАК ПорядковыйНомер
ПОМЕСТИТЬ ВТ_ПронумерованныеГраницы
ИЗ
ВТ_ОчищенныеГраницы КАК Таб1
ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_ОчищенныеГраницы КАК Таб2
ПО Таб1.ДатаГраницы >= Таб2.ДатаГраницы
ГРУППИРОВАТЬ ПО
Таб1.ДатаГраницы
;
////////////////////////////////////////////////////////////////////////////////
// Группируем по парам и получаем свободные интервалы ("дыры")
ВЫБРАТЬ
МИНИМУМ(ВТ_ПронумерованныеГраницы.ДатаГраницы) КАК НачалоСвободногоПериода,
МАКСИМУМ(ВТ_ПронумерованныеГраницы.ДатаГраницы) КАК КонецСвободногоПериода
ИЗ
ВТ_ПронумерованныеГраницы КАК ВТ_ПронумерованныеГраницы
ГРУППИРОВАТЬ ПО
ВЫРАЗИТЬ((ВТ_ПронумерованныеГраницы.ПорядковыйНомер - 1) / 2 КАК ЧИСЛО(10, 0))
Этот запрос поражает своей лаконичностью и скоростью выполнения на уровне СУБД. Он полностью автоматизирует расчет, не нагружая сервер приложений 1С циклами и условными переходами.
Однако помните: если ваши периоды исключений в базе данных могут пересекаться, этот запрос перед запуском инверсии также потребует предварительной подготовки данных — их «схлопывания» (объединения). Сделать это в запросе можно с помощью классического метода расчета нарастающего итога (интеграла дат) или метода сканирующей прямой (Scanline), где начала периодов прибавляют единицу к виртуальному счетчику активности, а концы — вычитают. Для эффективной обработки интервалов непосредственно в запросе можно использовать линейный алгоритм определения интервалов между записями.
---
Давайте сопоставим оба подхода, чтобы вы всегда могли принять взвешенное архитектурное решение в своих проектах.
Программный метод (Таблицы значений в памяти):
Декларативный метод (Запрос):
Используйте описанные нами паттерны проектирования, всегда проверяйте исходные данные на предмет пересечения интервалов времени и выбирайте инструмент, максимально соответствующий нагрузочным требованиям вашей информационной системы!