При построении сложных отчетов, выводе спецификаций номенклатуры, калькуляций или древовидных структур перед разработчиками 1С часто встает задача формирования красивой иерархической нумерации строк. Нам необходимо получить классический многоуровневый вид номеров, например: 1, 1.1, 1.1.1, 1.1.2, 1.2, 2 и так далее.
В этой статье мы подробно разберем несколько эффективных способов реализации такой нумерации без использования Системы компоновки данных (СКД), рассмотрим их плюсы и минусы, а также затронем критически важную тему оптимизации производительности при работе с рекурсивными структурами данных в 1С.
Этот способ был предложен в обсуждении темы. Его суть заключается в создании глобальной или передаваемой коллекции типа Массив, которая выступает в роли стека (накопителя) номеров для каждого уровня вложенности нашей иерархии.
Проанализируем алгоритм работы этого механизма по шагам:
Посмотрим на практическую реализацию этого метода в коде:
// Объявляем массив-стек на самом верхнем уровне вызова
Перем НомераСтека;
Процедура ВывестиДеревоСпецификаций()
НомераСтека = Новый Массив;
ТабличныйДокумент = Новый ТабличныйДокумент;
// Начальный вызов рекурсивного обхода для корневых элементов
ОбходДереваРекурсивно(СпецификацияОсновная, 0);
КонецПроцедуры
Процедура ОбходДереваРекурсивно(ТекущаяСпецификация, Уровень)
// Шаг 1. Переходим на новый уровень - инициализируем счетчик этого уровня в стеке
НомераСтека.Добавить(0);
Выборка = ПолучитьДанныеСпецификации(ТекущаяСпецификация);
Пока Выборка.Следующий() Цикл
// Шаг 2. Инкрементируем счетчик на текущем уровне иерархии
ИндексТекущегоУровня = НомераСтека.ВГраница();
НомераСтека[ИндексТекущегоУровня] = НомераСтека[ИндексТекущегоУровня] + 1;
// Формируем текстовое представление номера, склеивая массив
СтрокаНомера = "";
Для Каждого ТекНомер Из НомераСтека Цикл
СтрокаНомера = СтрокаНомера + ?(ЗначениеЗаполнено(СтрокаНомера), ".", "") + ТекНомер;
КонецЦикла;
// Выводим данные в макет
ОбластьСтрока = Макет.ПолучитьОбласть("Строка");
ОбластьСтрока.Параметры.Номер = СтрокаНомера;
ОбластьСтрока.Параметры.Номенклатура = Выборка.Номенклатура;
ТабличныйДокумент.Вывести(ОбластьСтрока);
// Рекурсивный спуск на следующий уровень
ОбходДереваРекурсивно(Выборка.ПодчиненнаяСпецификация, Уровень + 1);
КонецЦикла;
// Шаг 4. Возвращаемся назад - удаляем счетчик текущего уровня из стека
НомераСтека.Удалить(НомераСтека.ВГраница());
КонецПроцедуры
Преимущества метода: Универсальность. Метод отлично работает как при рекурсивном обходе процедур, так и при последовательном обходе плоских таблиц, если грамотно отслеживать моменты изменения уровня вложенности.
Недостатки: Требуется ручной контроль состояния массива. При возникновении исключений, аварийном выходе из процедур или сложных условиях ветвления внутри цикла стек может "рассинхронизироваться", что приведет к неверной нумерации на последующих шагах вывода.
Существует более изящное решение, которое полностью избавляет нас от необходимости создавать глобальные переменные, массивы или коллекции и следить за их целостностью. Мы можем использовать стек самой платформы 1С, передавая сформированный номер родительского элемента прямо в параметрах рекурсивного метода.
Давайте выясним причину, почему этот метод считается более надежным. При рекурсивном вызове среда выполнения 1С изолирует локальные переменные каждого уровня вызова в системном стеке. Это означает, что родительский номер "замораживается" для текущей ветки и автоматически восстанавливается, когда выполнение дочерней ветки завершается. Нам не нужно ничего удалять или очищать вручную.
Рассмотрим по шагам схему реализации данного подхода:
Процедура ВыполнитьФормированиеОтчета()
ТабличныйДокумент = Новый ТабличныйДокумент;
Макет = ПолучитьМакет("Макет");
// Первичный вызов: передаем пустую строку в качестве родительского номера
ОбходДереваСпецификаций(Макет, ТабличныйДокумент, 0, "", СпецификацияОбхода);
КонецПроцедуры
Процедура ОбходДереваСпецификаций(Макет, ТабличныйДокумент, Уровень, РодительскийНомер, ТекущаяСпецификация)
// ... Инициализация запроса для получения комплектующих ...
// Для демонстрации опустим текст запроса, предполагая, что он выбирает комплектующие
Выборка = ЗапросПодборКомплектующих.Выполнить().Выбрать();
// Локальный счетчик текущей выборки в рамках данного вызова процедуры
НомерСтрокиВГруппе = 0;
Пока Выборка.Следующий() Цикл
НомерСтрокиВГруппе = НомерСтрокиВГруппе + 1;
// Формируем полный иерархический номер для текущей строки
Если ЗначениеЗаполнено(РодительскийНомер) Тогда
ТекущийНомер = РодительскийНомер + "." + НомерСтрокиВГруппе;
Иначе
ТекущийНомер = Строка(НомерСтрокиВГруппе);
КонецЕсли;
// Получаем область макета в зависимости от уровня
ИмяОбласти = ?(Уровень > 6, "Строка7", "Строка" + Уровень);
ОбластьСтрокаОсновное = Макет.ПолучитьОбласть(ИмяОбласти + "|ОсновноеСтрока" + Уровень);
// Заполняем параметры
ОбластьСтрокаОсновное.Параметры.Номер = ТекущийНомер;
ОбластьСтрокаОсновное.Параметры.Наименование = Выборка.Номенклатура;
ТабличныйДокумент.Вывести(ОбластьСтрокаОсновное);
// ... Логика вывода подчиненных колонок и картинок ...
// Рекурсивный вызов: передаем сформированный ТекущийНомер в качестве родительского
ТабличныйДокумент.НачатьГруппуСтрок();
ОбходДереваСпецификаций(Макет, ТабличныйДокумент, Уровень + 1, ТекущийНомер, Выборка.ПодчиненнаяСпецификация);
ТабличныйДокумент.ЗакончитьГруппуСтрок();
КонецЦикла;
КонецПроцедуры
Преимущества метода: Исключительная чистота кода. Отсутствуют побочные эффекты, связанные с изменением общего состояния. Локальный счетчик НомерСтрокиВГруппе всегда автоматически равен единице при входе в любую новую ветку, а РодительскийНомер гарантирует правильный префикс для всех дочерних элементов.
Иногда структура данных приходит к нам не в виде дерева, а в виде "плоской" таблицы значений, в которой у каждой строки уже заполнена колонка Уровень (число от 0 до N). В этом случае рекурсивный вызов нам не нужен, мы идем по таблице обычным линейным циклом. Для ведения нумерации в такой ситуации идеально подходит объект платформы Соответствие.
Проанализируем данный подход. Ключом в соответствии будет выступать числовой уровень вложенности, а значением — текущий порядковый номер строки на этом уровне.
Процедура ВывестиПлоскуюТаблицуСИерархией(ТаблицаДанных)
ТабличныйДокумент = Новый ТабличныйДокумент;
Макет = ПолучитьМакет("Макет");
// Соответствие для хранения текущего номера на каждом уровне
СчетчикиУровней = Новый Соответствие;
Для Каждого СтрокаДанных Из ТаблицаДанных Цикл
ТекущийУровень = СтрокаДанных.Уровень;
// Получаем текущее значение счетчика для данного уровня
ТекущийНомерНаУровне = СчетчикиУровней.Получить(ТекущийУровень);
Если ТекущийНомерНаУровне = Неопределено Тогда
ТекущийНомерНаУровне = 1;
Иначе
ТекущийНомерНаУровне = ТекущийНомерНаУровне + 1;
КонецЕсли;
// Записываем обновленный счетчик обратно в Соответствие
СчетчикиУровней.Вставить(ТекущийУровень, ТекущийНомерНаУровне);
// Все счетчики для уровней, которые глубже текущего, необходимо сбросить (удалить)
Для Ур = (ТекущийУровень + 1) По 10 Цикл // Предполагаем максимальный уровень вложенности 10
СчетчикиУровней.Удалить(Ур);
КонецЦикла;
// Собираем результирующий номер из номеров всех родительских уровней
СтрокаНомера = "";
Для Ур = 0 По ТекущийУровень Цикл
НомерНаУровне = СчетчикиУровней.Получить(Ур);
Если НомерНаУровне <> Неопределено Тогда
СтрокаНомера = СтрокаНомера + ?(ПустаяСтрока(СтрокаНомера), "", ".") + НомерНаУровне;
КонецЕсли;
КонецЦикла;
// Вывод строки в табличный документ
Область = Макет.ПолучитьОбласть("Строка");
Область.Параметры.Номер = СтрокаНомера;
Область.Параметры.Номенклатура = СтрокаДанных.Номенклатура;
ТабличныйДокумент.Вывести(Область);
КонецЦикла;
КонецПроцедуры
Обратим пристальное внимание на исходный код из Сообщения 0. Внутри рекурсивной процедуры ОбходДереваСпецификаций на каждой итерации цикла создается и выполняется новый SQL-запрос к СУБД: ЗапросПодборКомплектующих.Выполнить().
Это классический антипаттерн разработки "Запрос в цикле" (или "Запрос в рекурсии"). Если дерево спецификаций содержит хотя бы 50-100 элементов на разных уровнях, платформа совершит 50-100 отдельных обращений к СУБД. На реальных базах с высокой нагрузкой и глубокой вложенностью спецификаций это приведет к катастрофическому падению производительности и зависанию сеанса пользователя.
Как решить эту проблему правильно? Необходимо получить всю иерархическую структуру данных за один-единственный запрос к базе данных, выгрузить результат в коллекцию ДеревоЗначений и далее рекурсивно работать уже исключительно в оперативной памяти компьютера без повторных обращений к СУБД.
Проанализируем правильный шаблон архитектуры такого решения:
Процедура СформироватьОтчетБыстро()
// 1. Получаем все данные одним запросом с помощью ИТОГИ ... ПО ... ИЕРАРХИЯ
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| СпецификацииНоменклатурыИсходныеКомплектующие.Ссылка КАК Спецификация,
| СпецификацииНоменклатурыИсходныеКомплектующие.Номенклатура КАК Номенклатура,
| СпецификацииНоменклатурыИсходныеКомплектующие.Количество КАК Количество
|ИЗ
| Справочник.СпецификацииНоменклатуры.ИсходныеКомплектующие КАК СпецификацииНоменклатурыИсходныеКомплектующие
|ГДЕ
| СпецификацииНоменклатурыИсходныеКомплектующие.Ссылка В ИЕРАРХИИ(&КорневаяСпецификация)";
Запрос.УстановитьПараметр("КорневаяСпецификация", ОсновнаяСпецификация);
// Выгружаем данные в дерево значений
ДеревоДанных = Запрос.Выполнить().Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкамСИерархией);
// 2. Вызываем рекурсивный обход дерева значений в оперативной памяти
ТабличныйДокумент = Новый ТабличныйДокумент;
Макет = ПолучитьМакет("Макет");
ВывестиСтрокиДереваРекурсивно(ДеревоДанных.Строки, Макет, ТабличныйДокумент, 0, "");
КонецПроцедуры
Процедура ВывестиСтрокиДереваРекурсивно(СтрокиДерева, Макет, ТабличныйДокумент, Уровень, РодительскийНомер)
ЛокальныйСчетчик = 0;
Для Каждого СтрокаДерева Из СтрокиДерева Цикл
ЛокальныйСчетчик = ЛокальныйСчетчик + 1;
// Формируем красивый номер
ТекНомер = ?(ЗначениеЗаполнено(РодительскийНомер), РодительскийНомер + ".", "") + ЛокальныйСчетчик;
// Выводим данные в табличный документ
Область = Макет.ПолучитьОбласть("Строка" + Уровень);
Область.Параметры.Номер = ТекНомер;
Область.Параметры.Наименование = СтрокаДерева.Номенклатура;
ТабличныйДокумент.Вывести(Область);
// Если у текущей строки дерева есть подчиненные строки, уходим в рекурсию
Если СтрокаДерева.Строки.Количество() > 0 Тогда
ТабличныйДокумент.НачатьГруппуСтрок();
ВывестиСтрокиДереваРекурсивно(СтрокаДерева.Строки, Макет, ТабличныйДокумент, Уровень + 1, ТекНомер);
ТабличныйДокумент.ЗакончитьГруппуСтрок();
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Сравните скорость работы: один единственный запрос к СУБД вместо сотен мелких запросов. Данный подход гарантирует стабильную и быструю работу вашей обработки даже на огромных объемах производственных спецификаций.