Задача расчета фактического периода действия записей с учетом их приоритетов — классическая задача для разработчиков 1С. В типовых конфигурациях (например, в «1С:Зарплата и управление персоналом») этот механизм реализован на уровне платформы через регистры расчета и механизм вытеснения. Однако в реальной практике часто возникает необходимость воспроизвести этот алгоритм вручную на чистом SQL/запросе 1С или в коде программного модуля. Это требуется при проектировании кастомных систем планирования, учете занятости оборудования, бронировании или разработке нетиповых подсистем KPI.
В этой статье мы подробно разберем, как решить задачу вытеснения пересекающихся периодов с разными приоритетами. Мы проанализируем несколько подходов: от простых запросов до высокопроизводительных алгоритмов в коде.
---
Рассмотрим подход, который предложил автор темы. Он базируется на «развертывании» каждого периода в набор отдельных дней, выборе победителя (максимального приоритета) для каждого дня и последующем «склеивании» смежных дней обратно в непрерывные интервалы. Этот метод идеально подходит для небольших интервалов времени (например, в пределах одного года для одного сотрудника).
Разберем алгоритм по шагам:
МАКСИМУМ(Приоритет)).Посмотрим на пример реализации первого этапа (получение плоской таблицы победителей по дням) в запросе 1С:
// Шаг 1. Получаем все дни расчетного периода (ВТ_Календарь)
// Шаг 2. Соединяем дни с периодами и выбираем максимальный приоритет на каждый день
ВЫБРАТЬ
Календарь.Дата КАК ДатаКалендаря,
МАКСИМУМ(ИсходныеПериоды.Приоритет) КАК МаксПриоритет
ПОМЕСТИТЬ ВТ_МаксимальныеПриоритеты
ИЗ
ВТ_Календарь КАК Календарь
ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_ИсходныеПериоды КАК ИсходныеПериоды
ПО Календарь.Дата МЕЖДУ ИсходныеПериоды.ДатаНачала И ИсходныеПериоды.ДатаОкончания
ГРУППИРОВАТЬ ПО
Календарь.Дата;
// Шаг 3. Определяем, какому именно периоду принадлежал этот максимальный приоритет
ВЫБРАТЬ
МаксПриоритеты.ДатаКалендаря,
Минимум(ИсходныеПериоды.СсылкаНаПериод) КАК ВыигравшийПериод
ПОМЕСТИТЬ ВТ_ДниСПобедителями
ИЗ
ВТ_МаксимальныеПриоритеты КАК МаксПриоритеты
ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_ИсходныеПериоды КАК ИсходныеПериоды
ПО МаксПриоритеты.ДатаКалендаря МЕЖДУ ИсходныеПериоды.ДатаНачала И ИсходныеПериоды.ДатаОкончания
И МаксПриоритеты.МаксПриоритет = ИсходныеПериоды.Приоритет
ГРУППИРОВАТЬ ПО
МаксПриоритеты.ДатаКалендаря;
Преимущества метода: Высокая наглядность, простота отладки промежуточных данных. Запрос легко писать и читать.
Недостатки: Низкая производительность на больших объемах. Если рассчитывается период в 5 лет для 10 000 сотрудников, промежуточная таблица разрастется до десятков миллионов строк, что приведет к перегрузке tempdb и зависанию СУБД.
---
Чтобы избежать генерации миллионов строк для каждого дня, проанализируем более профессиональный подход. Периоды меняют свое состояние только в точках своих границ (даты начала и окончания). Следовательно, нет необходимости проверять каждый день между ними.
Рассмотрим алгоритм оптимизированного запроса:
[Дата_i, Дата_i+1].Этот подход сложнее в реализации на языке запросов 1С, но работает в сотни раз быстрее на больших массивах данных, так как оперирует только реальными граничными датами.
---
Если нет жесткого требования выполнять расчет исключительно внутри СУБД, перенос логики в программный код модуля — отличное архитектурное решение. Это разгружает сервер баз данных и позволяет написать понятный код, который легко сопровождать.
Выясним логику эффективного алгоритма «нарезки интервалов» с временной сложностью $O(N^2)$, предложенного разработчиками на форуме:
Посмотрим на пример программной реализации этого алгоритма:
Функция ВытеснитьПериодыКодом(ИсходныеПериоды, ДатаНачалаИнтервала, ДатаОкончанияИнтервала) Экспорт
// ИсходныеПериоды - ТаблицаЗначений (Период, ДатаНачала, ДатаОкончания, Приоритет)
// 1. Собираем уникальные граничные даты
ГраницыДат = Новый ТаблицаЗначений;
ГраницыДат.Колонки.Добавить("Дата", Новый ОписаниеТипов("Дата"));
Для Каждого Стр Из ИсходныеПериоды Цикл
// Ограничиваем даты рамками глобального интервала
ГраницыДат.Добавить().Дата = Макс(Стр.ДатаНачала, ДатаНачалаИнтервала);
ГраницыДат.Добавить().Дата = Мин(Стр.ДатаОкончания, ДатаОкончанияИнтервала);
КонецЦикла;
ГраницыДат.Добавить().Дата = ДатаНачалаИнтервала;
ГраницыДат.Добавить().Дата = ДатаОкончанияИнтервала;
ГраницыДат.Свернуть("Дата");
ГраницыДат.Сортировать("Дата Возр");
// 2. Создаем элементарные микроинтервалы
Микроинтервалы = Новый ТаблицаЗначений;
Микроинтервалы.Колонки.Добавить("ДатаНачала", Новый ОписаниеТипов("Дата"));
Микроинтервалы.Колонки.Добавить("ДатаОкончания", Новый ОписаниеТипов("Дата"));
Микроинтервалы.Колонки.Добавить("ПериодПобедитель");
Микроинтервалы.Колонки.Добавить("Приоритет", Новый ОписаниеТипов("Число"));
Для Индекс = 0 По ГраницыДат.Количество() - 2 Цикл
НовыйИнтервал = Микроинтервалы.Добавить();
НовыйИнтервал.ДатаНачала = ГраницыДат[Индекс].Дата;
НовыйИнтервал.ДатаОкончания = ГраницыДат[Индекс + 1].Дата;
НовыйИнтервал.Приоритет = -1; // Пока не занят
КонецЦикла;
// 3. Сортируем исходные периоды по приоритету (по убыванию)
ИсходныеПериоды.Сортировать("Приоритет Убыв");
// 4. Распределяем микроинтервалы по приоритетам
Для Каждого ПериодСтрока Из ИсходныеПериоды Цикл
Для Каждого Интервал Из Микроинтервалы Цикл
// Проверяем, попадает ли микроинтервал в границы текущего периода
Если Интервал.ДатаНачала >= ПериодСтрока.ДатаНачала
И Интервал.ДатаОкончания <= ПериодСтрока.ДатаОкончания Тогда
// Если интервал еще пуст, отдаем его текущему периоду
Если Интервал.Приоритет = -1 Тогда
Интервал.ПериодПобедитель = ПериодСтрока.Период;
Интервал.Приоритет = ПериодСтрока.Приоритет;
КонецЕсли;
КонецЕсли;
КонецЦикла;
КонецЦикла;
// 5. Схлопываем смежные интервалы одного периода
Результат = Новый ТаблицаЗначений;
Результат.Колонки.Добавить("Период");
Результат.Колонки.Добавить("ДатаНачала", Новый ОписаниеТипов("Дата"));
Результат.Колонки.Добавить("ДатаОкончания", Новый ОписаниеТипов("Дата"));
ТекущийПобедитель = Неопределено;
НачалоТекущегоБлока = '00010101';
Для Каждого Интервал Из Микроинтервалы Цикл
Если Интервал.Приоритет = -1 Тогда
// Пустой промежуток, закрываем предыдущий период если он был
Если ТекущийПобедитель <> Неопределено Тогда
НоваяСтрока = Результат.Добавить();
НоваяСтрока.Период = ТекущийПобедитель;
НоваяСтрока.ДатаНачала = НачалоТекущегоБлока;
НоваяСтрока.ДатаОкончания = Интервал.ДатаНачала;
ТекущийПобедитель = Неопределено;
КонецЕсли;
Продолжить;
КонецЕсли;
Если ТекущийПобедитель = Неопределено Тогда
ТекущийПобедитель = Интервал.ПериодПобедитель;
НачалоТекущегоБлока = Интервал.ДатаНачала;
ИначеЕсли ТекущийПобедитель <> Интервал.ПериодПобедитель Тогда
// Период изменился, фиксируем прошлый
НоваяСтрока = Результат.Добавить();
НоваяСтрока.Период = ТекущийПобедитель;
НоваяСтрока.ДатаНачала = НачалоТекущегоБлока;
НоваяСтрока.ДатаОкончания = Интервал.ДатаНачала;
// Начинаем новый блок
ТекущийПобедитель = Интервал.ПериодПобедитель;
НачалоТекущегоБлока = Интервал.ДатаНачала;
КонецЕсли;
КонецЦикла;
// Записываем последний период
Если ТекущийПобедитель <> Неопределено Тогда
НоваяСтрока = Результат.Добавить();
НоваяСтрока.Период = ТекущийПобедитель;
НоваяСтрока.ДатаНачала = НачалоТекущегоБлока;
НоваяСтрока.ДатаОкончания = ДатаОкончанияИнтервала;
КонецЕсли;
Возврат Результат;
КонецФункции
---
Для систем с высокими требованиями к производительности (например, расчет тысяч сотрудников со множеством пересекающихся начислений и документов отсутствия) предыдущий алгоритм может показаться медленным из-за вложенного цикла сравнения. В Computer Science для таких задач разработан классический метод сканирующей линии со сложностью всего $O(N \log N)$.
Давайте разберем его концепт:
Использование этого алгоритма позволяет мгновенно выполнять вытеснение десятков тысяч периодов за миллисекунды, так как мы полностью избавляемся от вложенных циклов сравнения всех элементов друг с другом.
---
Подводя итоги, дадим несколько практических советов по выбору архитектуры решения: