Каждый разработчик 1С хотя бы раз сталкивался с ситуацией, когда написанный код «зацикливается», намертво вешая сеанс пользователя или перегружая сервер СУБД. В такой ситуации первая мысль часто падает на ошибку в конструкции самого цикла Пока ... Цикл. Однако в среде 1С:Предприятие корни этой проблемы почти всегда уходят гораздо глубже — в неоптимально написанный запрос или некорректно выстроенную логику обхода выборки.
В этой статье мы подробно разберем реальный кейс, когда обработка по автоматическому созданию и перезаписи документов Приходный кассовый ордер (ПКО) на основании Отчетов о розничных продажах (ОРП) уходит в бесконечный цикл. Мы проанализируем неочевидные ошибки исходного кода, исправим опасные антипаттерны производительности и напишем чистый, быстрый и отказоустойчивый алгоритм.
Рассмотрим ситуацию, с которой столкнулся автор темы. При выполнении обработки система начинает бесконечно перезаписывать одни и те же документы, выводя в окно сообщений бесконечный лог о перезаписи. На первый взгляд, цикл обхода выборки выглядит стандартно:
Пока ВыборкаДетальныеЗаписи.Следующий() Цикл
// Логика поиска, изменения и записи ПКО...
КонецЦикла;
Почему же он не останавливается? Проблема кроется в самом запросе, а именно — в получении аналитики (субконто) из табличной части документа. Давайте детально разберем каждую ошибку исходного кода и найдем пути их решения.
---
Самая главная ошибка, из-за которой выборка запроса раздулась до гигантских масштабов, находится в секции ИЗ исходного запроса:
ИЗ
Документ.ПриходныйКассовыйОрдер КАК ПриходныйКассовыйОрдер
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ОтчетОРозничныхПродажах КАК ОтчетОРозничныхПродажах
ПО ПриходныйКассовыйОрдер.ДокументОснование = ОтчетОРозничныхПродажах.Ссылка,
Документ.ОтчетОРозничныхПродажах.Товары КАК ОтчетОРозничныхПродажахТовары
Обратите внимание на запятую перед таблицей табличной части Документ.ОтчетОРозничныхПродажах.Товары. Перечисление таблиц через запятую без указания явного условия соединения (ПО...) заставляет СУБД выполнять декартово произведение (Cross Join).
В результате каждая строка таблицы ПКО умножается на абсолютно все строки табличной части товаров всех ОРП, присутствующих в базе данных. Если в базе было 1 000 документов ПКО и всего 10 000 строк в товарах, результатом запроса станут 10 000 000 строк! Программа не зависает в бесконечном цикле — она просто добросовестно пытается обработать миллионы фиктивных дублирующихся записей.
Решение: Связывать табличную часть необходимо строго через оператор ЛЕВОЕ СОЕДИНЕНИЕ по ключу связи Ссылка:
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ОтчетОРозничныхПродажах.Товары КАК ОтчетОРозничныхПродажахТовары
ПО ОтчетОРозничныхПродажахТовары.Ссылка = ОтчетОРозничныхПродажах.Ссылка
В первоначальном запросе автора соединение начинается от таблицы ПКО к ОРП:
Документ.ПриходныйКассовыйОрдер КАК ПриходныйКассовыйОрдер
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ОтчетОРозничныхПродажах КАК ОтчетОРозничныхПродажах
При такой структуре левого соединения мы получим только те ОРП, для которых уже созданы документы ПКО. Но цель обработки — найти ОРП, для которых ПКО еще нет, и создать их! Новые документы никогда не создадутся, так как левое соединение от ПКО отсечет все отчеты о продажах без связанных кассовых ордеров.
Решение: Ведущей (левой) таблицей в запросе должен выступать документ-основание — Документ.ОтчетОРозничныхПродажах, а ПКО должен присоединяться к нему справа.
Внутри цикла обхода выборки мы видим следующую строку:
ЕстьПКО = Документы.ПриходныйКассовыйОрдер.НайтиПоРеквизиту("ДокументОснование", ВыборкаДетальныеЗаписи.Ссылка);
Использование метода НайтиПоРеквизиту внутри цикла — это грубейшая ошибка производительности. На каждом шаге цикла система выполняет отдельный неявный запрос к базе данных. Если документов в выборке много, обработка будет работать крайне медленно.
Решение: Ссылку на существующий ПКО необходимо получать сразу в основном запросе пакета через левое соединение. Это позволит полностью избавиться от поиска в цикле.
В коде автора вызывается метод получения объекта:
СуществующийДокумент = ЕстьПКО.ПолучитьОбъект();
Если ПКО не будет найден, метод НайтиПоРеквизиту вернет пустую ссылку. Попытка получить объект от пустой ссылки приведет к значению Неопределено. На следующем шаге при вызове СуществующийДокумент.Заполнить(...) программа аварийно завершит работу с ошибкой: «Значение не является значением объектного типа».
Решение: Получать объект только после проверки заполненности ссылки с помощью функции ЗначениеЗаполнено(), либо опираться на данные, заранее отобранные запросом.
Даже если мы правильно свяжем табличную часть Товары по ссылке, у одного ОРП может быть много строк в табличной части. Это приведет к тому, что документ ОРП продублируется в выборке столько раз, сколько товаров в нем указано. Цикл будет обрабатывать и записывать один и тот же ПКО многократно.
Решение: Чтобы избежать дублирования документов, запрос необходимо обязательно сгруппировать по ссылке документа ОРП, а для получения субконто использовать агрегатную функцию (например, МАКСИМУМ или МИНИМУМ).
---
Разберем по шагам, как построить надежный алгоритм без лишней нагрузки на систему.
Спроектируем запрос так, чтобы он сразу решал все наши задачи: отдавал только уникальные документы ОРП за период, показывал связь с уже существующими ПКО (если они есть) и возвращал нужное значение аналитики (субконто) без дублирования строк.
Используем группировку СГРУППИРОВАТЬ ПО и агрегатную функцию МАКСИМУМ для поля Субконто:
ВЫБРАТЬ
ОтчетОРозничныхПродажах.Ссылка КАК СсылкаОРП,
ОтчетОРозничныхПродажах.Дата КАК ДатаОРП,
МАКСИМУМ(ОтчетОРозничныхПродажахТовары.Субконто) КАК Субконто,
ПриходныйКассовыйОрдер.Ссылка КАК СсылкаПКО,
ПриходныйКассовыйОрдер.ПометкаУдаления КАК ПКОПометкаУдаления
ИЗ
Документ.ОтчетОРозничныхПродажах КАК ОтчетОРозничныхПродажах
// Получаем субконто из табличной части без дублирования документов
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ОтчетОРозничныхПродажах.Товары КАК ОтчетОРозничныхПродажахТовары
ПО ОтчетОРозничныхПродажахТовары.Ссылка = ОтчетОРозничныхПродажах.Ссылка
// Сразу проверяем наличие существующего ПКО
ЛЕВОЕ СОЕДИНЕНИЕ Документ.ПриходныйКассовыйОрдер КАК ПриходныйКассовыйОрдер
ПО ПриходныйКассовыйОрдер.ДокументОснование = ОтчетОРозничныхПродажах.Ссылка
ГДЕ
ОтчетОРозничныхПродажах.Дата МЕЖДУ &ДатаНачала И &ДатаОкончания
СГРУППИРОВАТЬ ПО
ОтчетОРозничныхПродажах.Ссылка,
ОтчетОРозничныхПродажах.Дата,
ПриходныйКассовыйОрдер.Ссылка,
ПриходныйКассовыйОрдер.ПометкаУдаления
В самом цикле нам больше не нужно выполнять поиск. Мы просто проверяем заполненность поля СсылкаПКО, полученного из запроса:
СсылкаПКО заполнена — документ уже существует. Мы либо перезаписываем его (если включен соответствующий флаг на форме), либо пропускаем.СсылкаПКО пустая — создаем новый документ ПКО, заполняем его на основании ОРП и проводим.Также важно учесть синтаксическую неточность из исходного кода: свойство ЭтотОбъект доступно только внутри модуля самого объекта. При обращении из внешней обработки для вывода сообщений следует использовать свойство Ссылка.
---
Проанализируем готовое оптимизированное решение, которое можно перенести в свою обработку:
Процедура КнопкаВыполнитьНажатие(Кнопка)
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ОтчетОРозничныхПродажах.Ссылка КАК СсылкаОРП,
| ОтчетОРозничныхПродажах.Дата КАК ДатаОРП,
| МАКСИМУМ(ОтчетОРозничныхПродажахТовары.Субконто) КАК Субконто,
| ПриходныйКассовыйОрдер.Ссылка КАК СсылкаПКО,
| ПриходныйКассовыйОрдер.ПометкаУдаления КАК ПКОПометкаУдаления
|ИЗ
| Документ.ОтчетОРозничныхПродажах КАК ОтчетОРозничныхПродажах
|
| ЛЕВОЕ СОЕДИНЕНИЕ Документ.ОтчетОРозничныхПродажах.Товары КАК ОтчетОРозничныхПродажахТовары
| ПО ОтчетОРозничныхПродажахТовары.Ссылка = ОтчетОРозничныхПродажах.Ссылка
|
| ЛЕВОЕ СОЕДИНЕНИЕ Документ.ПриходныйКассовыйОрдер КАК ПриходныйКассовыйОрдер
| ПО ПриходныйКассовыйОрдер.ДокументОснование = ОтчетОРозничныхПродажах.Ссылка
|ГДЕ
| ОтчетОРозничныхПродажах.Дата МЕЖДУ &ДатаНачала И &ДатаОкончания
|
|СГРУППИРОВАТЬ ПО
| ОтчетОРозничныхПродажах.Ссылка,
| ОтчетОРозничныхПродажах.Дата,
| ПриходныйКассовыйОрдер.Ссылка,
| ПриходныйКассовыйОрдер.ПометкаУдаления";
Запрос.УстановитьПараметр("ДатаНачала", ДатаНачала);
Запрос.УстановитьПараметр("ДатаОкончания", ДатаОкончания);
РезультатЗапроса = Запрос.Выполнить();
Выборка = РезультатЗапроса.Выбрать();
Пока Выборка.Следующий() Цикл
// Проверяем, существует ли уже ПКО для данного ОРП
Если ЗначениеЗаполнено(Выборка.СсылкаПКО) Тогда
// Если документ помечен на удаление, сообщаем и пропускаем его
Если Выборка.ПКОПометкаУдаления Тогда
Сообщить("Документ " + Выборка.СсылкаПКО + " помечен на удаление. Пропускаем.");
Продолжить;
КонецЕсли;
// Проверяем настройку перезаписи на форме управления
Если ЭлементыФормы.БезПерезаписиДокумента.Значение = Ложь Тогда
Попытка
ОбъектПКО = Выборка.СсылкаПКО.ПолучитьОбъект();
ОбъектПКО.Заполнить(Выборка.СсылкаОРП);
// Корректируем реквизиты при необходимости
ОбъектПКО.Дата = Выборка.ДатаОРП;
ОбъектПКО.ПринятоОт = Выборка.Субконто; // Заполнение аналитики
ОбъектПКО.Записать(РежимЗаписиДокумента.Проведение);
Сообщить("Документ " + ОбъектПКО.Ссылка + " успешно перезаписан и проведен.");
Исключение
Сообщить("Не удалось перезаписать документ " + Выборка.СсылкаПКО + ". Ошибка: " + ОписаниеОшибки());
КонецПопытки;
Иначе
Сообщить("Документ " + Выборка.СсылкаПКО + " уже существует. Перезапись отключена.");
КонецЕсли;
Иначе
// Если ПКО не найден — создаем новый документ
Попытка
НовыйПКО = Документы.ПриходныйКассовыйОрдер.СоздатьДокумент();
НовыйПКО.Заполнить(Выборка.СсылкаОРП);
НовыйПКО.Дата = Выборка.ДатаОРП;
НовыйПКО.ПринятоОт = Выборка.Субконто;
НовыйПКО.Записать(РежимЗаписиДокумента.Проведение);
Сообщить("Создан новый документ: " + НовыйПКО.Ссылка);
Исключение
Сообщить("Ошибка при создании ПКО для " + Выборка.СсылкаОРП + ". Текст ошибки: " + ОписаниеОшибки());
КонецПопытки;
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Резюме: Мы полностью устранили декартово произведение, избавились от тяжелых запросов в цикле с использованием метода НайтиПоРеквизиту, добавили безопасные проверки заполненности объектов СУБД и предотвратили дублирование обработки документов ОРП. Теперь алгоритм работает стабильно, предсказуемо и не вызывает зависаний системы.