При разработке обработок группового пересчета, копирования или переноса данных между документами в системе 1С:Предприятие разработчики часто совершают классическую логическую ошибку. Она заключается в некорректном совмещении выборки строк табличной части из запроса и процесса создания новых документов-копий. В результате вместо одного скопированного документа с заполненной табличной частью создается множество дубликатов документов, в каждом из которых строки заполнены одинаковыми или неверными значениями.
В этой статье мы подробно разберем, почему возникает эта ошибка, проанализируем исходный некорректный алгоритм, а также рассмотрим два правильных архитектурных решения. Кроме того, мы затронем крайне важную сопутствующую тему — как правильно работать с необязательными (пустыми) фильтрами в запросах 1С, так как советы на форумах по этому поводу часто бывают ошибочными и вредными для производительности базы данных.
Рассмотрим фрагмент кода, который изначально использовался для копирования документов МАВ_БюджетнаяОперация:
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
ДокументКопия = Выборка.Ссылка.Скопировать();
Для Каждого ТЧ ИЗ ДокументКопия.ТЧОсновная Цикл
ТЧ.Сумма = Выборка.Сумма;
КонецЦикла;
ДокументКопия.Записать(РежимЗаписиДокумента.Проведение);
КонецЦикла;
Выясним причину, по которой этот код работает некорректно. Запрос обращается непосредственно к табличной части документа: ИЗ Документ.МАВ_БюджетнаяОперация.ТЧОсновная КАК ТЧ. Такая выборка возвращает плоскую таблицу, где каждая запись — это отдельная строка табличной части конкретного документа.
Представим ситуацию: в нашей базе данных есть один документ-источник, в табличной части которого содержится три строки с разными суммами (например, 100, 200 и 300 рублей). Запрос вернет ровно три записи выборки. В каждой из этих трех записей поле Выборка.Ссылка будет указывать на один и тот же исходный документ.
Проследим за пошаговым выполнением цикла Пока Выборка.Следующий():
Выборка.Ссылка.Скопировать(). Создается первый новый документ-копия, в который автоматически переносятся все три строки исходного документа. Далее запускается внутренний цикл Для Каждого ТЧ ИЗ ДокументКопия.ТЧОсновная. Этот цикл обходит все три строки нового документа и записывает в них сумму из текущей выборки — то есть 100 рублей в каждую строку. Документ записывается и проводится.Выборка.Ссылка.Скопировать(). Создается второй документ-копия (опять со всеми тремя исходными строками). Внутренний цикл записывает во все три строки сумму 200 рублей. Второй документ записывается и проводится.В результате вместо одного нового документа с суммами 100, 200 и 300 мы получили три разных документа, в каждом из которых суммы затерты одинаковыми значениями. Это классическая логическая ошибка, связанная с непониманием структуры данных, возвращаемой запросом по табличным частям.
Если задача состоит в том, чтобы просто скопировать документы и пересчитать показатели их табличной части (например, умножить количество и сумму на определенный коэффициент), нам вообще не нужно делать запрос к табличной части. Намного проще и быстрее выбрать запросом только уникальные ссылки на сами документы-источники, а все расчеты произвести непосредственно в оперативной памяти.
Посмотрим на пример правильной реализации этого подхода:
// Шаг 1. Формируем запрос только по шапке документов
Запрос = Новый Запрос;
Запрос.УстановитьПараметр("ДатаНач", ДатаНач);
Запрос.УстановитьПараметр("ДатаКон", ДатаКон);
Запрос.УстановитьПараметр("Сценарий", Сценарий);
Запрос.УстановитьПараметр("СтатьяОборотов", СтатьяОборотов);
Запрос.УстановитьПараметр("Подразделение", Подразделение);
Запрос.Текст =
"ВЫБРАТЬ
| МАВ_БюджетнаяОперация.Ссылка КАК Ссылка
|ИЗ
| Документ.МАВ_БюджетнаяОперация КАК МАВ_БюджетнаяОперация
|ГДЕ
| МАВ_БюджетнаяОперация.ПериодПланирования МЕЖДУ &ДатаНач И &ДатаКон
| И МАВ_БюджетнаяОперация.Проведен = ИСТИНА
| И МАВ_БюджетнаяОперация.Сценарий = &Сценарий
| И МАВ_БюджетнаяОперация.СтатьяОборотовБюджета = &СтатьяОборотов
| И МАВ_БюджетнаяОперация.Подразделение = &Подразделение";
Выборка = Запрос.Formulir().Выполнить().Выбрать();
// Шаг 2. Обходим документы и производим копирование и пересчет
Пока Выборка.Следующий() Цикл
// Копируем документ ОДИН раз
ДокументКопия = Выборка.Ссылка.Скопировать();
// Перебираем строки ТЧ скопированного документа прямо в памяти
Для Каждого СтрокаТЧ Из ДокументКопия.ТЧОсновная Цикл
СтрокаТЧ.Количество = СтрокаТЧ.Количество * Коэффициент;
СтрокаТЧ.Сумма = СтрокаТЧ.Сумма * Коэффициент;
КонецЦикла;
// Заполняем новые реквизиты шапки
ДокументКопия.Дата = ТекущаяДата();
Если ЗначениеЗаполнено(ДатаНовая) Тогда
ДокументКопия.ПериодПланирования = ДатаНовая;
КонецЕсли;
ДокументКопия.Сценарий = СценарийНовый;
// Безопасная запись с обработкой исключений
Попытка
ДокументКопия.Записать(РежимЗаписиДокумента.Проведение);
Сообщить("Успешно создан и проведен документ: " + ДокументКопия.Ссылка);
Исключение
Сообщить("Не удалось провести документ " + ДокументКопия.Ссылка + ". Ошибка: " + ОписаниеОшибки());
КонецПопытки;
КонецЦикла;
Этот способ является наиболее предпочтительным с точки зрения производительности и чистоты кода. Мы совершаем минимальное количество чтений из базы данных и не нагружаем процессор СУБД избыточными соединениями таблиц.
Бывают ситуации, когда расчет новых сумм или других показателей табличной части невозможно выполнить простым умножением на коэффициент в памяти. Например, если новые значения рассчитываются сложным запросом, объединяющим данные из различных регистров сведений или накопления. В таком случае нам действительно необходимо получать данные табличной части из запроса.
Чтобы избежать дублирования документов, нам потребуется использовать обход выборки по группировкам с помощью конструкции ИТОГИ ПО Ссылка. Это позволит сгруппировать строки по документам-владельцам.
Разберем этот метод по шагам:
Запрос = Новый Запрос;
Запрос.УстановитьПараметр("ДатаНач", ДатаНач);
Запрос.УстановитьПараметр("ДатаКон", ДатаКон);
Запрос.УстановитьПараметр("Сценарий", Сценарий);
Запрос.УстановитьПараметр("СтатьяОборотов", СтатьяОборотов);
Запрос.УстановитьПараметр("Подразделение", Подразделение);
Запрос.УстановитьПараметр("Коэффициент", Коэффициент);
// Добавляем НомерСтроки для точного позиционирования и ИТОГИ ПО Ссылка
Запрос.Текст =
"ВЫБРАТЬ
| ТЧ.Ссылка КАК Ссылка,
| ТЧ.НомерСтроки КАК НомерСтроки,
| ТЧ.Количество * &Коэффициент КАК Количество,
| ТЧ.Сумма * &Коэффициент КАК Сумма
|ИЗ
| Документ.МАВ_БюджетнаяОперация.ТЧОсновная КАК ТЧ
|ГДЕ
| ТЧ.Ссылка.ПериодПланирования МЕЖДУ &ДатаНач И &ДатаКон
| И ТЧ.Ссылка.Проведен = ИСТИНА
| И ТЧ.Ссылка.Сценарий = &Сценарий
| И ТЧ.Ссылка.СтатьяОборотовБюджета = &СтатьяОборотов
| И ТЧ.Ссылка.Подразделение = &Подразделение
|ИТОГИ ПО
| Ссылка";
// Обходим результат запроса по группировкам (сначала только документы)
ВыборкаДокументов = Запрос.Выполнить().Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
Пока ВыборкаДокументов.Следующий() Цикл
// Копируем документ ОДИН раз для всей группы строк
ДокументКопия = ВыборкаДокументов.Ссылка.Скопировать();
// Получаем подчиненную выборку по детальным записям (строкам ТЧ конкретного документа)
ВыборкаСтрок = ВыборкаДокументов.Выбрать();
Пока ВыборкаСтрок.Следующий() Цикл
// Индексация строк в ТЧ на сервере начинается с 0, а НомерСтроки в БД — с 1.
ИндексСтроки = ВыборкаСтрок.НомерСтроки - 1;
// Проверяем, существует ли строка с таким индексом в скопированном документе
Если ИндексСтроки < ДокументКопия.ТЧОсновная.Количество() Тогда
СтрокаТЧ = ДокументКопия.ТЧОсновная[ИндексСтроки];
СтрокаТЧ.Количество = ВыборкаСтрок.Количество;
СтрокаТЧ.Сумма = ВыборкаСтрок.Сумма;
КонецЕсли;
КонецЦикла;
// Заполнение реквизитов шапки
ДокументКопия.Дата = ТекущаяДата();
Если ЗначениеЗаполнено(ДатаНовая) Тогда
ДокументКопия.ПериодПланирования = ДатаНовая;
КонецЕсли;
ДокументКопия.Сценарий = СценарийНовый;
Попытка
ДокументКопия.Записать(РежимЗаписиДокумента.Проведение);
Сообщить("Успешно перенесен документ: " + ДокументКопия.Ссылка);
Исключение
Сообщить("Ошибка проведения документа: " + ОписаниеОшибки());
КонецПопытки;
КонецЦикла;
Использование свойства НомерСтроки и вычисление ИндексСтроки = ВыборкаСтрок.НомерСтроки - 1 гарантирует, что измененные данные запишутся строго в те строки скопированного документа, из которых они были получены в исходном документе.
В процессе обсуждения на форуме участники затронули еще одну важную проблему — работу с необязательными фильтрами (когда пользователь может не указать конкретную статью оборотов или подразделение, и в этом случае система должна выбрать данные по всем аналитикам без ограничений).
Участники предложили следующее решение: если параметр не заполнен, передавать в него пустую ссылку:
Если ЗначениеЗаполнено(СтатьяОборотов) Тогда
Запрос.УстановитьПараметр("СтатьяОборотов", СтатьяОборотов);
Иначе
Запрос.УстановитьПараметр("СтатьяОборотов", Справочники.СтатьиОборотовБюджета.ПустаяСсылка());
КонецЕсли;
Внимание! Это грубейшая логическая ошибка. Если вы передадите пустую ссылку в качестве параметра, условие запроса МАВ_БюджетнаяОперация.СтатьяОборотовБюджета = &СтатьяОборотов преобразуется СУБД в проверку на равенство пустой ссылке. Запрос вернет только те документы, у которых статья оборотов действительно не заполнена в базе данных. Документы с заполненными статьями в выборку не попадут, хотя пользователь ожидал получить все документы без фильтрации.
Рассмотрим два правильных способа реализации необязательных фильтров в 1С.
Самый производительный способ — добавлять строчку фильтра в текст запроса только тогда, когда параметр реально заполнен. В этом случае СУБД строит оптимальный план запроса и не выполняет лишних проверок.
ТекстУсловия = "";
Если ЗначениеЗаполнено(СтатьяОборотов) Тогда
ТекстУсловия = " И МАВ_БюджетнаяОперация.СтатьяОборотовБюджета = &СтатьяОборотов";
КонецЕсли;
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| МАВ_БюджетнаяОперация.Ссылка КАК Ссылка
|ИЗ
| Документ.МАВ_БюджетнаяОперация КАК МАВ_БюджетнаяОперация
|ГДЕ
| МАВ_БюджетнаяОперация.ПериодПланирования МЕЖДУ &ДатаНач И &ДатаКон
| И МАВ_БюджетнаяОперация.Проведен = ИСТИНА" + ТекстУсловия;
Запрос.УстановитьПараметр("ДатаНач", ДатаНач);
Запрос.УстановитьПараметр("ДатаКон", ДатаКон);
Если ЗначениеЗаполнено(СтатьяОборотов) Тогда
Запрос.УстановитьПараметр("СтатьяОборотов", СтатьяОборотов);
КонецЕсли;
Если вы по какой-то причине не хотите собирать текст запроса динамически, можно прописать логику отключения фильтра непосредственно в условиях секции ГДЕ:
ГДЕ
МАВ_БюджетнаяОперация.ПериодПланирования МЕЖДУ &ДатаНач И &ДатаКон
И МАВ_БюджетнаяОперация.Проведен = ИСТИНА
И (&ОтключитьФильтрСтатьи ИЛИ МАВ_БюджетнаяОперация.СтатьяОборотовБюджета = &СтатьяОборотов)
При заполнении параметров мы передаем булево значение в &ОтключитьФильтрСтатьи:
Запрос.УстановитьПараметр("СтатьяОборотов", СтатьяОборотов);
Запрос.УстановитьПараметр("ОтключитьФильтрСтатьи", Не ЗначениеЗаполнено(СтатьяОборотов));
В этом сценарии, если статья не заполнена, параметр &ОтключитьФильтрСтатьи принимает значение ИСТИНА. Первое условие в скобках СУБД выполняет моментально, и проверка равенства конкретной статье опускается, что позволяет выбрать абсолютно все записи.
При обработке сотен или тысяч документов крайне важно выполнять операции в транзакционном блоке. Если в процессе копирования пятидесятого документа возникнет непредвиденная ошибка (например, не заполнена обязательная аналитика), без транзакции система успеет записать первые сорок девять документов в базу данных, а на пятидесятом процесс прервется. Это приведет к ситуации "полураспада" данных, когда часть документов скопирована, а часть — нет.
Рассмотрим, как правильно обернуть процедуру копирования в транзакцию:
НачатьТранзакцию();
Попытка
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
ДокументКопия = Выборка.Ссылка.Скопировать();
Для Каждого СтрокаТЧ Из ДокументКопия.ТЧОсновная Цикл
СтрокаТЧ.Сумма = СтрокаТЧ.Сумма * Коэффициент;
КонецЦикла;
ДокументКопия.Дата = ТекущаяДата();
ДокументКопия.Записать(РежимЗаписиДокумента.Проведение);
КонецЦикла;
// Если все документы обработаны без ошибок, фиксируем транзакцию
ЗафиксироватьТранзакцию();
Сообщить("Все документы успешно скопированы и проведены!");
Исключение
// В случае любой ошибки откатываем базу к исходному состоянию
ОтменитьТранзакцию();
Сообщить("Произошла критическая ошибка! Изменения отменены. Описание: " + ОписаниеОшибки());
КонецПопытки;
Использование транзакций гарантирует целостность базы данных: либо все новые документы будут успешно созданы, либо ни один из них не появится в системе, что позволит легко исправить ошибку и запустить обработку заново без ручной чистки базы от дубликатов.