Как слить два дерева значений в одно: копирование, группировка, свёртка
Содержание
Готового метода «объединить два дерева» у объекта ДеревоЗначений в 1С 8.3 нет. Два дерева значений сливают своим кодом, и выбор способа зависит от задачи. Нужно просто дописать ветки второго дерева к первому — хватит рекурсивного копирования строк. Нужно, чтобы одинаковые группы склеились, а суммы сложились, — пишут рекурсию с поиском совпадений по ключу. Деревья большие и уровней много — проще разложить их в таблицу значений, свернуть и собрать дерево обратно запросом с ИТОГИ.
Все примеры ниже работают на сервере: в тонком клиенте типа ДеревоЗначений нет. Код проверяйте на копии данных, а не на дереве, которое уже показывается пользователю в форме.
Что нужно знать о дереве значений перед слиянием
Дерево состоит из двух частей: набора колонок Дерево.Колонки и коллекции строк верхнего уровня Дерево.Строки. У каждой строки есть своя коллекция Строки — это её подчинённые строки. Так и строится иерархия.
Три свойства объекта определяют, как писать код объединения:
- 🔹 Строку нельзя перенести из одного дерева в другое. Метод
Строки.Добавить()создаёт новую пустую строку, а значения в неё переносят отдельно. - 🔹 Колонки у деревьев общие для всех уровней. Если колонки
Сумманет в приёмнике, присваиваниеСтрока.Сумма = ...упадёт с ошибкойПоле объекта не обнаружено (Сумма). - 🔹 Индексов у дерева нет. У таблицы значений есть
Индексы, у дерева — нет. ПоискСтроки.Найти()иСтроки.НайтиСтроки()перебирает строки подряд.
Для работы понадобятся несколько методов. Все они есть в синтакс-помощнике конфигуратора:
| Что нужно | Метод или свойство |
|---|---|
| Добавить строку на уровень | Строки.Добавить() |
| Перенести значения колонок с одноимёнными именами | ЗаполнитьЗначенияСвойств(Приемник, Источник) |
| Найти строку по значению одной колонки | Строки.Найти(Значение, "ИмяКолонки", Ложь) |
| Найти строки по нескольким колонкам | Строки.НайтиСтроки(Структура, Ложь) |
| Узнать родителя и уровень | Строка.Родитель, Строка.Уровень() |
| Посчитать итог по колонке | Строки.Итог("Сумма", Истина) |
ЗаполнитьЗначенияСвойств переносит только значения колонок. Подчинённые строки она не копирует — их обходят рекурсией.
Приводим колонки к одному составу
Перед любым способом слияния сверьте колонки. Если во втором дереве есть колонка, которой нет в первом, добавьте её с тем же типом:
Процедура ДобавитьНедостающиеКолонки(ДеревоПриемник, ДеревоИсточник)
Для Каждого Колонка Из ДеревоИсточник.Колонки Цикл
Если ДеревоПриемник.Колонки.Найти(Колонка.Имя) = Неопределено Тогда
ДеревоПриемник.Колонки.Добавить(Колонка.Имя, Колонка.ТипЗначения, Колонка.Заголовок);
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Обратная ситуация безопасна: лишние колонки приёмника в новых строках просто останутся пустыми.
Простое объединение двух деревьев: дописываем строки верхнего уровня
Самый короткий вариант — пройти по строкам верхнего уровня второго дерева и добавить их в первое:
Для Каждого СтрокаИсточник Из Дерево2.Строки Цикл
НоваяСтрока = Дерево1.Строки.Добавить();
ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаИсточник);
КонецЦикла;
Код рабочий, но переносит только первый уровень. Группы второго дерева приедут без содержимого: у новых строк коллекция Строки пустая. Такой вариант годится только для плоских деревьев, где вложенности нет вообще, — например, когда дерево используется как список с флажками.
Если дерево многоуровневое, нужна рекурсия.
Рекурсивное копирование: переносим ветки со всей вложенностью
Процедура вызывает сама себя для каждой строки, у которой есть подчинённые. Глубина вложенности при этом может быть любой:
Процедура СкопироватьСтроки(СтрокиПриемник, СтрокиИсточник)
Для Каждого СтрокаИсточник Из СтрокиИсточник Цикл
НоваяСтрока = СтрокиПриемник.Добавить();
ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаИсточник);
Если СтрокаИсточник.Строки.Количество() > 0 Тогда
СкопироватьСтроки(НоваяСтрока.Строки, СтрокаИсточник.Строки);
КонецЕсли;
КонецЦикла;
КонецПроцедуры
// Вызов
ДобавитьНедостающиеКолонки(Дерево1, Дерево2);
СкопироватьСтроки(Дерево1.Строки, Дерево2.Строки);
Первым параметром передаётся коллекция, куда пишем, вторым — откуда читаем. Поэтому ту же процедуру можно вызвать не для корня, а для конкретной ветки: СкопироватьСтроки(СтрокаГруппы.Строки, Дерево2.Строки) вложит всё второе дерево внутрь одной группы первого.
Чтобы получить третье дерево и не трогать исходные, создайте пустое Новый ДеревоЗначений, скопируйте в него колонки и вызовите процедуру дважды: сначала для первого дерева, потом для второго.
Не передавайте одно и то же дерево и как приёмник, и как источник. Цикл Для Каждого будет обходить строки, которые сам же добавляет, и не закончится. Если нужно «удвоить» дерево, сначала скопируйте его в отдельный объект.
Результат такого копирования — два набора веток подряд. Группа «Молочка» из первого дерева и группа «Молочка» из второго останутся двумя разными строками. Если нужно склеить их в одну, переходите к следующему способу.
Слияние с группировкой и суммированием одинаковых строк
Типовая задача: два дерева остатков или продаж с одинаковой структурой «Группа → Номенклатура». В результате каждая группа и каждый товар должны встречаться один раз, а количество и сумма — сложиться.
Алгоритм на каждом уровне такой:
- Взять строку источника и найти в приёмнике строку с тем же ключом.
- Не нашлась — добавить новую и заполнить значениями.
- Нашлась — прибавить ресурсы (
Количество,Сумма) к найденной строке. - Повторить то же для подчинённых строк этой пары.
Ключ на разных уровнях обычно разный. В дереве из запроса с ИТОГИ ПО Группа у строк группы заполнена колонка Группа, а Номенклатура пустая. У детальных строк ключ — Номенклатура. Поэтому имена ключевых колонок передаются массивом по уровням:
Процедура СлитьСГруппировкой(СтрокиПриемник, СтрокиИсточник, КлючиУровней, Ресурсы, Уровень = 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, "ДеревоОстатков");
КонецПроцедуры
Колонки у реквизита формы задаются в конфигураторе. Если во втором дереве есть колонка, которой у реквизита нет, ЗначениеВРеквизитФормы её не перенесёт — добавьте реквизит формы заранее.
Рекурсию можно выполнить и на клиенте, заменив Строки на ПолучитьЭлементы(). Это оправдано только для пары десятков строк: каждое изменение данных формы обходится дороже серверной работы с ДеревоЗначений.
Частые ошибки при слиянии деревьев значений
Колонки.Найти().Неопределено, или из запроса пришёл NULL. Задайте колонке тип Новый ОписаниеТипов("Число") или оберните поле в запросе в ЕСТЬNULL(..., 0).&НаСервере.Истина и нашёл строку на другом уровне. Ищите только на текущем уровне: третий параметр Найти — Ложь.Частые вопросы
Можно ли объединить деревья с разным набором колонок?
ЗаполнитьЗначенияСвойств перенесёт только совпадающие по имени, остальные останутся пустыми.Как объединить три и больше деревьев?
Сохранится ли порядок строк после слияния?
Строки.Сортировать() с параметром Истина, чтобы отсортировать и подчинённые уровни.Как получить итог по всему объединённому дереву?
Дерево.Строки.Итог("Сумма", Ложь), если в группах уже лежат суммы, или Итог("Сумма", Истина) по дереву, где групповые строки пустые. Иначе группы и товары посчитаются дважды.Где можно использовать эти процедуры?
&НаСервере, в общем серверном модуле и во внешней обработке. Для повторного использования удобнее вынести их в общий модуль.