Как реализовать иерархическую нумерацию строк при выводе данных в 1С?

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

При построении сложных отчетов, выводе спецификаций номенклатуры, калькуляций или древовидных структур перед разработчиками 1С часто встает задача формирования красивой иерархической нумерации строк. Нам необходимо получить классический многоуровневый вид номеров, например: 1, 1.1, 1.1.1, 1.1.2, 1.2, 2 и так далее.

В этой статье мы подробно разберем несколько эффективных способов реализации такой нумерации без использования Системы компоновки данных (СКД), рассмотрим их плюсы и минусы, а также затронем критически важную тему оптимизации производительности при работе с рекурсивными структурами данных в 1С.

Метод 1. Использование массива в качестве стека уровней (Решение с форума)

Этот способ был предложен в обсуждении темы. Его суть заключается в создании глобальной или передаваемой коллекции типа Массив, которая выступает в роли стека (накопителя) номеров для каждого уровня вложенности нашей иерархии.

Проанализируем алгоритм работы этого механизма по шагам:

  1. При переходе на более глубокий уровень иерархии мы добавляем в конец массива новый элемент со значением 0.
  2. При получении каждой новой строки на текущем уровне мы увеличиваем значение последнего элемента массива на единицу.
  3. Для формирования полного номера мы "склеиваем" все элементы массива через точку в одну текстовую строку.
  4. При завершении обхода текущей ветки и возврате на предыдущий уровень (вверх по иерархии) мы удаляем последний элемент массива, тем самым очищая счетчик завершенного уровня.

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


// Объявляем массив-стек на самом верхнем уровне вызова
Перем НомераСтека;

Процедура ВывестиДеревоСпецификаций()
    НомераСтека = Новый Массив;
    ТабличныйДокумент = Новый ТабличныйДокумент;
    
    // Начальный вызов рекурсивного обхода для корневых элементов
    ОбходДереваРекурсивно(СпецификацияОсновная, 0);
КонецПроцедуры

Процедура ОбходДереваРекурсивно(ТекущаяСпецификация, Уровень)
    // Шаг 1. Переходим на новый уровень - инициализируем счетчик этого уровня в стеке
    НомераСтека.Добавить(0);
    
    Выборка = ПолучитьДанныеСпецификации(ТекущаяСпецификация);
    
    Пока Выборка.Следующий() Цикл
        // Шаг 2. Инкрементируем счетчик на текущем уровне иерархии
        ИндексТекущегоУровня = НомераСтека.ВГраница();
        НомераСтека[ИндексТекущегоУровня] = НомераСтека[ИндексТекущегоУровня] + 1;
        
        // Формируем текстовое представление номера, склеивая массив
        СтрокаНомера = "";
        Для Каждого ТекНомер Из НомераСтека Цикл
            СтрокаНомера = СтрокаНомера + ?(ЗначениеЗаполнено(СтрокаНомера), ".", "") + ТекНомер;
        КонецЦикла;
        
        // Выводим данные в макет
        ОбластьСтрока = Макет.ПолучитьОбласть("Строка");
        ОбластьСтрока.Параметры.Номер = СтрокаНомера;
        ОбластьСтрока.Параметры.Номенклатура = Выборка.Номенклатура;
        ТабличныйДокумент.Вывести(ОбластьСтрока);
        
        // Рекурсивный спуск на следующий уровень
        ОбходДереваРекурсивно(Выборка.ПодчиненнаяСпецификация, Уровень + 1);
    КонецЦикла;
    
    // Шаг 4. Возвращаемся назад - удаляем счетчик текущего уровня из стека
    НомераСтека.Удалить(НомераСтека.ВГраница());
КонецПроцедуры

Преимущества метода: Универсальность. Метод отлично работает как при рекурсивном обходе процедур, так и при последовательном обходе плоских таблиц, если грамотно отслеживать моменты изменения уровня вложенности.

Недостатки: Требуется ручной контроль состояния массива. При возникновении исключений, аварийном выходе из процедур или сложных условиях ветвления внутри цикла стек может "рассинхронизироваться", что приведет к неверной нумерации на последующих шагах вывода.

Метод 2. Передача номера через параметры рекурсии (Альтернативный чистый метод)

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

Давайте выясним причину, почему этот метод считается более надежным. При рекурсивном вызове среда выполнения 1С изолирует локальные переменные каждого уровня вызова в системном стеке. Это означает, что родительский номер "замораживается" для текущей ветки и автоматически восстанавливается, когда выполнение дочерней ветки завершается. Нам не нужно ничего удалять или очищать вручную.

Рассмотрим по шагам схему реализации данного подхода:


Процедура ВыполнитьФормированиеОтчета()
    ТабличныйДокумент = Новый ТабличныйДокумент;
    Макет = ПолучитьМакет("Макет");
    
    // Первичный вызов: передаем пустую строку в качестве родительского номера
    ОбходДереваСпецификаций(Макет, ТабличныйДокумент, 0, "", СпецификацияОбхода);
КонецПроцедуры

Процедура ОбходДереваСпецификаций(Макет, ТабличныйДокумент, Уровень, РодительскийНомер, ТекущаяСпецификация)
    
    // ... Инициализация запроса для получения комплектующих ...
    // Для демонстрации опустим текст запроса, предполагая, что он выбирает комплектующие
    Выборка = ЗапросПодборКомплектующих.Выполнить().Выбрать();
    
    // Локальный счетчик текущей выборки в рамках данного вызова процедуры
    НомерСтрокиВГруппе = 0;
    
    Пока Выборка.Следующий() Цикл
        НомерСтрокиВГруппе = НомерСтрокиВГруппе + 1;
        
        // Формируем полный иерархический номер для текущей строки
        Если ЗначениеЗаполнено(РодительскийНомер) Тогда
            ТекущийНомер = РодительскийНомер + "." + НомерСтрокиВГруппе;
        Иначе
            ТекущийНомер = Строка(НомерСтрокиВГруппе);
        КонецЕсли;
        
        // Получаем область макета в зависимости от уровня
        ИмяОбласти = ?(Уровень > 6, "Строка7", "Строка" + Уровень);
        ОбластьСтрокаОсновное = Макет.ПолучитьОбласть(ИмяОбласти + "|ОсновноеСтрока" + Уровень);
        
        // Заполняем параметры
        ОбластьСтрокаОсновное.Параметры.Номер = ТекущийНомер;
        ОбластьСтрокаОсновное.Параметры.Наименование = Выборка.Номенклатура;
        
        ТабличныйДокумент.Вывести(ОбластьСтрокаОсновное);
        
        // ... Логика вывода подчиненных колонок и картинок ...
        
        // Рекурсивный вызов: передаем сформированный ТекущийНомер в качестве родительского
        ТабличныйДокумент.НачатьГруппуСтрок();
        
        ОбходДереваСпецификаций(Макет, ТабличныйДокумент, Уровень + 1, ТекущийНомер, Выборка.ПодчиненнаяСпецификация);
        
        ТабличныйДокумент.ЗакончитьГруппуСтрок();        
    КонецЦикла;
КонецПроцедуры

Преимущества метода: Исключительная чистота кода. Отсутствуют побочные эффекты, связанные с изменением общего состояния. Локальный счетчик НомерСтрокиВГруппе всегда автоматически равен единице при входе в любую новую ветку, а РодительскийНомер гарантирует правильный префикс для всех дочерних элементов.

Метод 3. Использование коллекции "Соответствие" для последовательного обхода

Иногда структура данных приходит к нам не в виде дерева, а в виде "плоской" таблицы значений, в которой у каждой строки уже заполнена колонка Уровень (число от 0 до N). В этом случае рекурсивный вызов нам не нужен, мы идем по таблице обычным линейным циклом. Для ведения нумерации в такой ситуации идеально подходит объект платформы Соответствие.

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


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

Критическая оптимизация: избавляемся от антипаттерна "Запрос в цикле"

Обратим пристальное внимание на исходный код из Сообщения 0. Внутри рекурсивной процедуры ОбходДереваСпецификаций на каждой итерации цикла создается и выполняется новый SQL-запрос к СУБД: ЗапросПодборКомплектующих.Выполнить().

Это классический антипаттерн разработки "Запрос в цикле" (или "Запрос в рекурсии"). Если дерево спецификаций содержит хотя бы 50-100 элементов на разных уровнях, платформа совершит 50-100 отдельных обращений к СУБД. На реальных базах с высокой нагрузкой и глубокой вложенностью спецификаций это приведет к катастрофическому падению производительности и зависанию сеанса пользователя.

Как решить эту проблему правильно? Необходимо получить всю иерархическую структуру данных за один-единственный запрос к базе данных, выгрузить результат в коллекцию ДеревоЗначений и далее рекурсивно работать уже исключительно в оперативной памяти компьютера без повторных обращений к СУБД.

Проанализируем правильный шаблон архитектуры такого решения:


Процедура СформироватьОтчетБыстро()
    // 1. Получаем все данные одним запросом с помощью ИТОГИ ... ПО ... ИЕРАРХИЯ
    Запрос = Новый Запрос;
    Запрос.Текст = 
        "ВЫБРАТЬ
        |   СпецификацииНоменклатурыИсходныеКомплектующие.Ссылка КАК Спецификация,
        |   СпецификацииНоменклатурыИсходныеКомплектующие.Номенклатура КАК Номенклатура,
        |   СпецификацииНоменклатурыИсходныеКомплектующие.Количество КАК Количество
        |ИЗ
        |   Справочник.СпецификацииНоменклатуры.ИсходныеКомплектующие КАК СпецификацииНоменклатурыИсходныеКомплектующие
        |ГДЕ
        |   СпецификацииНоменклатурыИсходныеКомплектующие.Ссылка В ИЕРАРХИИ(&КорневаяСпецификация)";
        
    Запрос.УстановитьПараметр("КорневаяСпецификация", ОсновнаяСпецификация);
    
    // Выгружаем данные в дерево значений
    ДеревоДанных = Запрос.Выполнить().Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкамСИерархией);
    
    // 2. Вызываем рекурсивный обход дерева значений в оперативной памяти
    ТабличныйДокумент = Новый ТабличныйДокумент;
    Макет = ПолучитьМакет("Макет");
    
    ВывестиСтрокиДереваРекурсивно(ДеревоДанных.Строки, Макет, ТабличныйДокумент, 0, "");
КонецПроцедуры

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

Сравните скорость работы: один единственный запрос к СУБД вместо сотен мелких запросов. Данный подход гарантирует стабильную и быструю работу вашей обработки даже на огромных объемах производственных спецификаций.

← На главную