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