Как слить два дерева значений в одно: копирование, группировка, свёртка

· 10 мин

Содержание

Готового метода «объединить два дерева» у объекта ДеревоЗначений в 1С 8.3 нет. Два дерева значений сливают своим кодом, и выбор способа зависит от задачи. Нужно просто дописать ветки второго дерева к первому — хватит рекурсивного копирования строк. Нужно, чтобы одинаковые группы склеились, а суммы сложились, — пишут рекурсию с поиском совпадений по ключу. Деревья большие и уровней много — проще разложить их в таблицу значений, свернуть и собрать дерево обратно запросом с ИТОГИ.

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

Что нужно знать о дереве значений перед слиянием

Дерево состоит из двух частей: набора колонок Дерево.Колонки и коллекции строк верхнего уровня Дерево.Строки. У каждой строки есть своя коллекция Строки — это её подчинённые строки. Так и строится иерархия.

Три свойства объекта определяют, как писать код объединения:

  • 🔹 Строку нельзя перенести из одного дерева в другое. Метод Строки.Добавить() создаёт новую пустую строку, а значения в неё переносят отдельно.
  • 🔹 Колонки у деревьев общие для всех уровней. Если колонки Сумма нет в приёмнике, присваивание Строка.Сумма = ... упадёт с ошибкой Поле объекта не обнаружено (Сумма).
  • 🔹 Индексов у дерева нет. У таблицы значений есть Индексы, у дерева — нет. Поиск Строки.Найти() и Строки.НайтиСтроки() перебирает строки подряд.

Для работы понадобятся несколько методов. Все они есть в синтакс-помощнике конфигуратора:

Что нужноМетод или свойство
Добавить строку на уровеньСтроки.Добавить()
Перенести значения колонок с одноимёнными именамиЗаполнитьЗначенияСвойств(Приемник, Источник)
Найти строку по значению одной колонкиСтроки.Найти(Значение, "ИмяКолонки", Ложь)
Найти строки по нескольким колонкамСтроки.НайтиСтроки(Структура, Ложь)
Узнать родителя и уровеньСтрока.Родитель, Строка.Уровень()
Посчитать итог по колонкеСтроки.Итог("Сумма", Истина)

ЗаполнитьЗначенияСвойств переносит только значения колонок. Подчинённые строки она не копирует — их обходят рекурсией.

Приводим колонки к одному составу

Перед любым способом слияния сверьте колонки. Если во втором дереве есть колонка, которой нет в первом, добавьте её с тем же типом:

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

Обратная ситуация безопасна: лишние колонки приёмника в новых строках просто останутся пустыми.

Простое объединение двух деревьев: дописываем строки верхнего уровня

Самый короткий вариант — пройти по строкам верхнего уровня второго дерева и добавить их в первое:

Для Каждого СтрокаИсточник Из Дерево2.Строки Цикл
    НоваяСтрока = Дерево1.Строки.Добавить();
    ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаИсточник);
КонецЦикла;

Код рабочий, но переносит только первый уровень. Группы второго дерева приедут без содержимого: у новых строк коллекция Строки пустая. Такой вариант годится только для плоских деревьев, где вложенности нет вообще, — например, когда дерево используется как список с флажками.

Если дерево многоуровневое, нужна рекурсия.

Рекурсивное копирование: переносим ветки со всей вложенностью

Процедура вызывает сама себя для каждой строки, у которой есть подчинённые. Глубина вложенности при этом может быть любой:

Процедура СкопироватьСтроки(СтрокиПриемник, СтрокиИсточник)
    Для Каждого СтрокаИсточник Из СтрокиИсточник Цикл
        НоваяСтрока = СтрокиПриемник.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаИсточник);
        Если СтрокаИсточник.Строки.Количество() > 0 Тогда
            СкопироватьСтроки(НоваяСтрока.Строки, СтрокаИсточник.Строки);
        КонецЕсли;
    КонецЦикла;
КонецПроцедуры

// Вызов
ДобавитьНедостающиеКолонки(Дерево1, Дерево2);
СкопироватьСтроки(Дерево1.Строки, Дерево2.Строки);

Первым параметром передаётся коллекция, куда пишем, вторым — откуда читаем. Поэтому ту же процедуру можно вызвать не для корня, а для конкретной ветки: СкопироватьСтроки(СтрокаГруппы.Строки, Дерево2.Строки) вложит всё второе дерево внутрь одной группы первого.

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

Не передавайте одно и то же дерево и как приёмник, и как источник. Цикл Для Каждого будет обходить строки, которые сам же добавляет, и не закончится. Если нужно «удвоить» дерево, сначала скопируйте его в отдельный объект.

Результат такого копирования — два набора веток подряд. Группа «Молочка» из первого дерева и группа «Молочка» из второго останутся двумя разными строками. Если нужно склеить их в одну, переходите к следующему способу.

Слияние с группировкой и суммированием одинаковых строк

Типовая задача: два дерева остатков или продаж с одинаковой структурой «Группа → Номенклатура». В результате каждая группа и каждый товар должны встречаться один раз, а количество и сумма — сложиться.

Алгоритм на каждом уровне такой:

  1. Взять строку источника и найти в приёмнике строку с тем же ключом.
  2. Не нашлась — добавить новую и заполнить значениями.
  3. Нашлась — прибавить ресурсы (Количество, Сумма) к найденной строке.
  4. Повторить то же для подчинённых строк этой пары.

Ключ на разных уровнях обычно разный. В дереве из запроса с ИТОГИ ПО Группа у строк группы заполнена колонка Группа, а Номенклатура пустая. У детальных строк ключ — Номенклатура. Поэтому имена ключевых колонок передаются массивом по уровням:

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

// Вызов
КлючиУровней = Новый Массив;
КлючиУровней.Добавить("Группа");
КлючиУровней.Добавить("Номенклатура");
Ресурсы = СтрРазделить("Количество,Сумма", ",", Ложь);
СлитьСГруппировкой(Дерево1.Строки, Дерево2.Строки, КлючиУровней, Ресурсы);

Почему итоги групп остаются верными: суммы складываются на каждом уровне. Если в первом дереве у группы было 100, а во втором 40, у объединённой группы станет 140 — ровно столько, сколько дадут её подчинённые строки после слияния. Пересчитывать итоги отдельно не нужно.

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

Если ключ состоит из нескольких колонок

Бывает, что строка определяется парой значений — например, Номенклатура и Характеристика. Метод Найти ищет только по одному значению, поэтому меняем его на НайтиСтроки:

Отбор = Новый Структура("Номенклатура, Характеристика");
ЗаполнитьЗначенияСвойств(Отбор, СтрокаИсточник);
Найденные = СтрокиПриемник.НайтиСтроки(Отбор, Ложь);
СтрокаПриемник = ?(Найденные.Количество() = 0, Неопределено, Найденные[0]);

Второй параметр Ложь у Найти и НайтиСтроки важен. С Истина поиск уйдёт в подчинённые строки, и товар может «прилипнуть» к чужой группе.

Ускоряем объединение больших деревьев

У рекурсии из прошлого раздела есть слабое место — Строки.Найти(). Он просматривает строки уровня подряд. Если в группе 5 000 товаров в одном дереве и 5 000 в другом, это до 25 миллионов сравнений на одну группу. На маленьких деревьях разница незаметна, на больших отчётах слияние растягивается на минуты.

Приём практика — построить для каждого уровня Соответствие «ключ → строка». Поиск в соответствии не перебирает элементы по одному, поэтому на больших объёмах выигрыш многократный:

Процедура СлитьБыстро(СтрокиПриемник, СтрокиИсточник, КлючиУровней, Ресурсы, Уровень = 0)
    ИмяКлюча = КлючиУровней[Мин(Уровень, КлючиУровней.ВГраница())];

    Карта = Новый Соответствие;
    Для Каждого Строка Из СтрокиПриемник Цикл
        Карта.Вставить(Строка[ИмяКлюча], Строка);
    КонецЦикла;

    Для Каждого СтрокаИсточник Из СтрокиИсточник Цикл
        СтрокаПриемник = Карта.Получить(СтрокаИсточник[ИмяКлюча]);
        Если СтрокаПриемник = Неопределено Тогда
            СтрокаПриемник = СтрокиПриемник.Добавить();
            ЗаполнитьЗначенияСвойств(СтрокаПриемник, СтрокаИсточник);
            Карта.Вставить(СтрокаИсточник[ИмяКлюча], СтрокаПриемник);
        Иначе
            Для Каждого ИмяРесурса Из Ресурсы Цикл
                СтрокаПриемник[ИмяРесурса] = СтрокаПриемник[ИмяРесурса] + СтрокаИсточник[ИмяРесурса];
            КонецЦикла;
        КонецЕсли;
        СлитьБыстро(СтрокаПриемник.Строки, СтрокаИсточник.Строки, КлючиУровней, Ресурсы, Уровень + 1);
    КонецЦикла;
КонецПроцедуры

Строка, добавленная из источника, сразу попадает в карту. Если во втором дереве один и тот же товар встречается дважды, вторая строка не создаст дубль, а прибавится к первой.

Ещё несколько приёмов, которые заметно экономят время:

  • ⚡ Сливайте до вывода в форму. Работайте с ДеревоЗначений на сервере и только потом передавайте результат в реквизит через ЗначениеВРеквизитФормы. Построчная работа с данными формы медленнее.
  • ⚡ Сначала выберите приёмником большее дерево. Тогда новых строк придётся добавлять меньше.
  • ⚡ Не вызывайте рекурсию для пустых веток. Проверка СтрокаИсточник.Строки.Количество() > 0 перед вызовом убирает тысячи лишних вызовов на листьях.
  • ⚡ Сортируйте один раз в конце. Дерево1.Строки.Сортировать("Группа, Номенклатура", Истина) после слияния дешевле, чем вставка строк «на своё место» через Вставить(Индекс).

Ключом соответствия хорошо работают ссылки, строки, числа и даты. Структуру или массив ключом делать нельзя: соответствие сравнит их не по содержимому. Для составного ключа склейте строку, например Строка(Номенклатура.УникальныйИдентификатор()) + "|" + Строка(Характеристика.УникальныйИдентификатор()), или постройте вложенные соответствия.

Объединение через таблицу значений и запрос

Когда деревья пришли из запросов, часто проще уйти от дерева совсем. Схема такая: выгрузить детальные строки обоих деревьев в одну таблицу значений, свернуть её и снова получить дерево запросом с ИТОГИ. Группировку и суммы платформа посчитает сама.

Шаг 1. Выгружаем листья в таблицу. Брать нужно только строки без подчинённых. Если взять и группы, их суммы попадут в таблицу второй раз.

Процедура ВыгрузитьЛистья(Строки, Таблица)
    Для Каждого Строка Из Строки Цикл
        Если Строка.Строки.Количество() = 0 Тогда
            ЗаполнитьЗначенияСвойств(Таблица.Добавить(), Строка);
        Иначе
            ВыгрузитьЛистья(Строка.Строки, Таблица);
        КонецЕсли;
    КонецЦикла;
КонецПроцедуры

Шаг 2. Готовим таблицу с типизированными колонками. Таблицу из шага 1 потом передадим в запрос параметром, а запрос принимает только таблицу с заданными типами колонок:

Таблица = Новый ТаблицаЗначений;
Таблица.Колонки.Добавить("Группа", Новый ОписаниеТипов("СправочникСсылка.Номенклатура"));
Таблица.Колонки.Добавить("Номенклатура", Новый ОписаниеТипов("СправочникСсылка.Номенклатура"));
Таблица.Колонки.Добавить("Количество", Новый ОписаниеТипов("Число"));
Таблица.Колонки.Добавить("Сумма", Новый ОписаниеТипов("Число"));

ВыгрузитьЛистья(Дерево1.Строки, Таблица);
ВыгрузитьЛистья(Дерево2.Строки, Таблица);
Таблица.Свернуть("Группа, Номенклатура", "Количество, Сумма");

Если дерево нужно плоским — например, для выгрузки в Excel, — на Свернуть можно остановиться.

Шаг 3. Собираем дерево обратно запросом. Пакетный запрос кладёт таблицу во временную и строит итоги по группе:

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

Плюс способа — итоги, сортировку и группировку по нескольким уровням делает запрос. Добавили уровень иерархии — дописали поле в ИТОГИ ПО, код обхода менять не нужно.

Какой способ выбрать

СпособКогда подходитОграничения
Дописать верхний уровеньПлоское дерево без вложенностиПодчинённые строки теряются
Рекурсивное копированиеНужны все ветки обоих деревьев как естьОдинаковые группы не склеиваются
Рекурсия с НайтиНебольшие деревья, нужны суммыМедленно на тысячах строк в группе
Рекурсия с СоответствиеБольшие деревья, своя структура уровнейКлюч должен быть простым значением
Таблица + запросДеревья из запросов, нужны итогиНужны типы колонок, только сервер

Объединение деревьев в управляемой форме

На форме дерево хранится как ДанныеФормыДерево. Свойств Строки и Колонки у него нет — подчинённые элементы получают через ПолучитьЭлементы(). Самый надёжный путь — серверная процедура:

&НаСервере
Процедура ОбъединитьДеревьяНаСервере()
    Дерево1 = РеквизитФормыВЗначение("ДеревоОстатков");
    Дерево2 = ПолучитьВторойНаборДанных(); // ваша функция, возвращающая ДеревоЗначений
    СкопироватьСтроки(Дерево1.Строки, Дерево2.Строки);
    ЗначениеВРеквизитФормы(Дерево1, "ДеревоОстатков");
КонецПроцедуры

Колонки у реквизита формы задаются в конфигураторе. Если во втором дереве есть колонка, которой у реквизита нет, ЗначениеВРеквизитФормы её не перенесёт — добавьте реквизит формы заранее.

Рекурсию можно выполнить и на клиенте, заменив Строки на ПолучитьЭлементы(). Это оправдано только для пары десятков строк: каждое изменение данных формы обходится дороже серверной работы с ДеревоЗначений.

Частые ошибки при слиянии деревьев значений

Диагностика: выберите симптом

Частые вопросы

Можно ли объединить деревья с разным набором колонок?
Да. Перед слиянием добавьте в приёмник колонки, которых в нём нет. ЗаполнитьЗначенияСвойств перенесёт только совпадающие по имени, остальные останутся пустыми.
Как объединить три и больше деревьев?
Вызовите процедуру слияния по очереди для каждого дерева с одним и тем же приёмником. Порядок важен только для порядка строк, суммы от него не зависят.
Сохранится ли порядок строк после слияния?
Строки приёмника остаются на своих местах, новые добавляются в конец уровня. Нужен другой порядок — вызовите Строки.Сортировать() с параметром Истина, чтобы отсортировать и подчинённые уровни.
Как получить итог по всему объединённому дереву?
Вызовите Дерево.Строки.Итог("Сумма", Ложь), если в группах уже лежат суммы, или Итог("Сумма", Истина) по дереву, где групповые строки пустые. Иначе группы и товары посчитаются дважды.
Где можно использовать эти процедуры?
В модуле формы с директивой &НаСервере, в общем серверном модуле и во внешней обработке. Для повторного использования удобнее вынести их в общий модуль.