Как пересчитать скидки в табличной части и перепровести отобранные документы во внешней обработке 1С

Программист 1С v8.3 (Управляемые формы) Управленческий учет IT и автоматизация бизнеса
← На главную

При разработке внешних обработок в среде «1С:Предприятие» программисты часто сталкиваются с необходимостью массового изменения документов по определенным критериям. Типичный сценарий: отобрать документы за период, рассчитать по ним новые значения (например, скидки) и перепровести их. На этом пути новичков подстерегает множество неочевидных ошибок, связанных с контекстом выполнения кода, особенностями объектной модели 1С и производительностью системы.

В этой статье мы подробно разберем, как правильно реализовать подобный механизм, избежав критических ошибок, которые могут замедлить работу базы данных или привести к некорректной перезаписи информации.

Ошибка 1: Обращение к реквизитам табличной части как к реквизитам шапки

В самом начале работы часто возникает ошибка «Поле объекта не обнаружено», когда разработчик пытается обратиться к реквизиту напрямую через объект документа (например, ДокОбъект.Скидка). Выясним причину этой ошибки.

Документы в 1С имеют двухкомпонентную структуру данных:

  1. Шапка документа — содержит одиночные реквизиты (например, Организация, Контрагент, Договор, Дата).
  2. Табличные части — представляют собой подчиненные коллекции строк. Каждая строка табличной части содержит собственный набор реквизитов (например, Номенклатура, Количество, Цена, Сумма, Скидка).

Если реквизит Скидка или Сумма находится в табличной части (например, с именем ПереченьНоменклатуры), мы не можем обратиться к нему напрямую через объект документа. Нам необходимо организовать цикл по всем строкам этой табличной части. Посмотрим на пример реализации такого обхода:


Для Каждого СтрокаТЧ Из ДокОбъект.ПереченьНоменклатуры Цикл
    СтрокаТЧ.Скидка = НужнаяСкидка;
    // Выполняем пересчет суммы с учетом новой скидки
КонецЦикла;

Ошибка 2: Перекрытие имен переменных во вложенных циклах (Shadowing)

Проанализируем фрагмент кода, в котором часто совершают критическую ошибку при написании вложенных циклов:


Для каждого Стр Из Наименования Цикл // Внешний цикл
    // ... получение объекта документа ...
    Для Каждого Стр Из ДокОбъект.ПереченьНоменклатуры Цикл // Внутренний цикл с тем же именем переменной!
        Стр.Скидка = Скидка;
    КонецЦикла;
КонецЦикла;

Использование одной и той же переменной-итератора Стр во внешнем и во вложенном циклах приводит к переопределению переменной. Это вызывает непредсказуемое поведение алгоритма, ошибки принудительного приведения типов («значение ложь», «несоответствие типов») и может увести программу в бесконечный цикл.

Правило разработки: всегда давайте переменным циклов уникальные и осмысленные имена. Перепишем этот участок кода правильно:


Для Каждого СтрокаДокумента Из Наименования Цикл 
    Если СтрокаДокумента.Галочка Тогда
        ДокОбъект = СтрокаДокумента.Ссылка.ПолучитьОбъект();
        
        // Используем уникальное имя для итератора табличной части
        Для Каждого СтрокаТЧ Из ДокОбъект.ПереченьНоменклатуры Цикл 
            СтрокаТЧ.Скидка = ВычисленнаяСкидка;
        КонецЦикла;
        
        // Сохранение изменений...
    КонецЕсли;
КонецЦикла;

Ошибка 3: Избыточная и преждевременная запись объекта

Рассмотрим еще один частый антипаттерн, снижающий производительность системы в разы:


ДокОбъект = Стр.Ссылка.ПолучитьОбъект();
ДокОбъект.Записать(); // ЛИШНЯЯ ЗАПИСЬ!
// ... далее идет изменение табличной части ...
ДокОбъект.Записать(РежимЗаписиДокумента.Проведение);

Запись объекта базы данных — это транзакционная операция, требующая значительных ресурсов СУБД. Вызов метода Записать() сразу после получения объекта (до внесения изменений в его табличную часть) абсолютно бессмысленен. В этот момент данные в базе и в памяти идентичны. Нам необходимо выполнять запись ровно один раз — после того, как все изменения в табличную часть внесены.

Ошибка 4: Игнорирование первоначального статуса проведения документа

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

Правильным решением будет сохранение исходного статуса документа. Если документ был проведен, мы его перепроводим. Если он был просто записан — мы его перезаписываем без проведения.


РежимЗаписи = ?(ДокОбъект.Проведен, РежимЗаписиДокумента.Проведение, РежимЗаписиДокумента.Запись);
Попытка
    ДокОбъект.Записать(РежимЗаписи);
Исключение
    Сообщить("Не удалось записать документ " + ДокОбъект + " по причине: " + ОписаниеОшибки());
КонецПопытки;

Обратите внимание на использование конструкции Попытка...Исключение. Проведение документа может завершиться ошибкой (например, из-за нехватки остатков или закрытого периода). Без обработки исключений сбой на одном документе полностью прервет работу всей обработки.

Ошибка 5: Производительность и антипаттерн «Запрос в цикле»

В исходном решении функция расчета скидки РаботаСоСправочниками.РассчетСкидки(Клиент) вызывается внутри цикла по документам. Внутри этой функции каждый раз инициализируется и выполняется запрос к регистру накопления «Продажи».

Если пользователь выберет 500 документов, система выполнит 500 отдельных SQL-запросов к базе данных. Это классический антипаттерн, приводящий к критическому замедлению работы интерфейса.

Рассмотрим, как оптимизировать этот процесс методом пакетного расчета скидок:

  1. Перед началом цикла соберем всех уникальных клиентов из выбранных документов в массив.
  2. Одним запросом получим обороты продаж по всем этим клиентам.
  3. Поместим результаты расчета во временную таблицу соответствия (или структуру) в оперативной памяти.
  4. В цикле изменения документов будем быстро получать значение скидки из памяти без обращения к СУБД.

Посмотрим на пример реализации пакетного сбора данных:


// Шаг 1. Собираем список клиентов
МассивКлиентов = Новый Массив;
Для Каждого СтрокаДокумента Из Наименования Цикл
    Если СтрокаДокумента.Галочка Тогда
        // Ссылка.Клиент - получаем значение реквизита без обращения к объекту
        Если МассивКлиентов.Найти(СтрокаДокумента.Ссылка.Клиент) = Неопределено Тогда
            МассивКлиентов.Добавить(СтрокаДокумента.Ссылка.Клиент);
        КонецЕсли;
    КонецЕсли;
КонецЦикла;

// Шаг 2. Получаем скидки для всех клиентов одним запросом
ТаблицаСкидок = ПолучитьТаблицуСкидокКлиентов(МассивКлиентов); 
// Теперь у нас есть быстрый доступ к скидкам в памяти!

Ошибка 6: Обработка пустых результатов запроса и значений NULL

Разберем детально код функции расчета скидки. Часто разработчики забывают обработать ситуацию, когда у клиента нет истории продаж, или когда шкала скидок пуста.


Выборка = Запрос.Выполнить().Выбрать();
Выборка.Следующий();
ЗначениеПроцентаСкидки = Выборка.ПроцентСкидки;

Если запрос вернет пустой результат (например, оборот клиента равен 0, и ни одна строка скидок не подошла), метод Выборка.Следующий() вернет значение Ложь. Попытка обратиться к свойству Выборка.ПроцентСкидки вызовет системную ошибку выполнения.

Также важно помнить о значении NULL, которое может появиться в результате левых соединений таблиц или при расчете итогов. Любая математическая операция с NULL вернет NULL, что приведет к ошибкам при расчете сумм. Для защиты числовых данных в запросах всегда используйте функцию ЕСТЬNULL().

Исправим функцию расчета скидки с учетом стандартов разработки (и исправим орфографическую ошибку в названии функции — правильно писать «Расчет» с одной буквой «с»):


Функция РасчетСкидки(Клиент) Экспорт

    ДатаНачала = НачалоМесяца(ДобавитьМесяц(ТекущаяДата(), -1));
    ДатаОкончания = КонецМесяца(ДобавитьМесяц(ТекущаяДата(), -1));
    
    // Получаем обороты с защитой от NULL значений
    ЗапросОборотов = Новый Запрос;
    ЗапросОборотов.Текст = 
        "ВЫБРАТЬ
        |   ЕСТЬNULL(ПродажиОбороты.ВыручкаОборот, 0) КАК Выручка
        |ИЗ
        |   РегистрНакопления.Продажи.Обороты(&ДатаНачала, &ДатаОкончания, , Клиент = &Клиент) КАК ПродажиОбороты";
    
    ЗапросОборотов.УстановитьПараметр("ДатаНачала", ДатаНачала);
    ЗапросОборотов.УстановитьПараметр("ДатаОкончания", ДатаОкончания);
    ЗапросОборотов.УстановитьПараметр("Клиент", Клиент);
    
    РезультатОборотов = ЗапросОборотов.Выполнить();
    ЗначениеОборота = 0;
    
    Если Не РезультатОборотов.Пустой() Тогда
        ВыборкаОборотов = РезультатОборотов.Выбрать();
        ВыборкаОборотов.Следующий();
        ЗначениеОборота = ВыборкаОборотов.Выручка;
    КонецЕсли;
    
    // Запрос к шкале скидок
    ЗапросСкидок = Новый Запрос;
    ЗапросСкидок.Текст = 
        "ВЫБРАТЬ ПЕРВЫЕ 1
        |   ЕСТЬNULL(Скидки.ПроцентСкидки, 0) КАК ПроцентСкидки
        |ИЗ
        |   Справочник.Скидки КАК Скидки
        |ГДЕ
        |   Скидки.ОбъемЗакупки <= &ОЗ
        |УПОРЯДОЧИТЬ ПО
        |   Скидки.ОбъемЗакупки УБЫВ";
        
    ЗапросСкидок.УстановитьПараметр("ОЗ", ЗначениеОборота);
    
    РезультатСкидок = ЗапросСкидок.Выполнить();
    Если РезультатСкидок.Пустой() Тогда
        Возврат 0; // Скидка не предусмотрена
    КонецЕсли;
    
    ВыборкаСкидок = РезультатСкидок.Выбрать();
    ВыборкаСкидок.Следующий();
    
    Возврат ВыборкаСкидок.ПроцентСкидки;

КонецФункции

Итоговый алгоритм безопасной обработки документов

Объединим все разобранные лучшие практики в единый надежный алгоритм для нашей внешней обработки. Этот код работает на стороне сервера, не перегружает базу данных избыточными транзакциями, корректно обрабатывает типы данных и учитывает исходное состояние проведения документов.


&НаСервере
Процедура ПерерасчетСкидкиНаСервере()
    
    // Очищаем и подготавливаем транзакционные переменные
    Для Каждого СтрокаДокумента Из Наименования Цикл
        
        // Обрабатываем только те строки, которые пользователь отметил флажком
        Если СтрокаДокумента.Галочка Тогда
            
            // Получаем объект для редактирования
            ДокОбъект = СтрокаДокумента.Ссылка.ПолучитьОбъект();
            
            Если ДокОбъект = Неопределено Тогда
                Сообщить("Не удалось получить объект для документа: " + СтрокаДокумента.Ссылка);
                Продолжить;
            КонецЕсли;
            
            // Вычисляем скидку индивидуально для клиента документа
            ТекущаяСкидка = РаботаСоСправочниками.РасчетСкидки(ДокОбъект.Клиент);
            
            // Флаг для отслеживания реального изменения данных
            ДанныеИзменены = Ложь;
            
            // Пересчитываем табличную часть
            Для Каждого СтрокаТЧ Из ДокОбъект.ПереченьНоменклатуры Цикл
                
                Если СтрокаТЧ.Скидка <> ТекущаяСкидка Тогда
                    СтрокаТЧ.Скидка = ТекущаяСкидка;
                    ДанныеИзменены = Истина;
                КонецЕсли;
                
                // Пересчитываем сумму строки, предотвращая накопление погрешностей
                НоваяСумма = (СтрокаТЧ.Цена * СтрокаТЧ.Количество) * (100 - ТекущаяСкидка) / 100;
                Если СтрокаТЧ.Сумма <> НоваяСумма Тогда
                    СтрокаТЧ.Сумма = НоваяСумма;
                    ДанныеИзменены = Истина;
                КонецЕсли;
                
            КонецЦикла;
            
            // Записываем документ только если данные действительно изменились
            Если ДанныеИзменены Тогда
                
                // Определяем режим записи в зависимости от исходного состояния документа
                РежимЗаписи = ?(ДокОбъект.Проведен, РежимЗаписиДокумента.Проведение, РежимЗаписиДокумента.Запись);
                
                Попытка
                    ДокОбъект.Записать(РежимЗаписи);
                    Сообщить("Успешно обработан документ: " + ДокОбъект);
                Исключение
                    // Выводим ошибку, но не прерываем выполнение общего цикла
                    Сообщить("Ошибка проведения документа " + ДокОбъект + ": " + ОписаниеОшибки());
                КонецПопытки;
                
            КонецЕсли;
            
        КонецЕсли;
        
    КонецЦикла;
    
КонецПроцедуры

Применяя данные подходы, мы гарантируем стабильную и быструю работу нашей обработки даже на крупных базах данных с большим количеством документов.

← На главную