Как исправить ошибки проведения: почему не формируются движения по регистру или записывается только одна строка

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

Разработка механизмов проведения документов по регистрам накопления (особенно при реализации партионного учета или расчета себестоимости) — это классическая задача, с которой сталкивается каждый начинающий программист 1С. Очень часто на начальном этапе код пишется «по аналогии», что приводит к трудноуловимым логическим ошибкам. В результате документ проводится без ошибок, но движения в регистре либо отсутствуют вовсе, либо записывается только одна (последняя) строка ТЧ, либо списывается только первый товар из списка.

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

Выясняем причины: почему движения не записываются или записывается только одна строка

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

Ошибка №1: Метод Записать() вызывается внутри цикла

Посмотрим на этот фрагмент кода:


Пока ВыборкаДетальныеЗаписи.Следующий() Цикл
    // ... расчет списания и заполнение полей движения ...
    Движение = Движения.Себестоимость.Добавить();
    // ...
    Движения.Записать(); // ОШИБКА!
КонецЕсли; // (в коде автора также перепутано закрытие условий)
КонецЦикла;

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

Как правильно: Никогда не вызывайте запись движений внутри цикла. Более того, если в начале процедуры вы явно указали Движения.Себестоимость.Записывать = Истина;, платформа 1С автоматически запишет все сформированные движения в конце транзакции проведения документа. Явный вызов метода Записать() в конце кода вообще не требуется — это лишь создает лишнюю нагрузку на базу данных.

Ошибка №2: Превращение ЛЕВОГО соединения во ВНУТРЕННЕЕ в запросе

Проанализируем второй запрос автора, где мы получаем остатки номенклатуры:


|ИЗ
|    ВТ КАК ВТ
|        ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.Себестоимость.Остатки(...) КАК ОстаткиНоменклатурыОстатки
|        ПО ВТ.Номенклатура = ОстаткиНоменклатурыОстатки.Номенклатура
|ГДЕ
|    ОстаткиНоменклатурыОстатки.Номенклатура.ВидНоменклатуры = &Материал

Здесь кроется опасная логическая ошибка. Использование конструкции ЛЕВОЕ СОЕДИНЕНИЕ предполагает, что мы хотим получить все строки из временной таблицы документа (ВТ), даже если по ним нет никаких остатков в регистре (в этом случае поля остатков будут заполнены значением NULL).

Однако в секции ГДЕ накладывается условие на поле таблицы остатков: ОстаткиНоменклатурыОстатки.Номенклатура.ВидНоменклатуры = &Материал. Если товара нет на складе, левое соединение вернет NULL. Попытка обратиться к свойству NULL.ВидНоменклатуры вернет Ложь при сравнении с параметром. В результате вся строка с этим товаром будет полностью исключена из финальной выборки запроса!

Последствие: Документ проведется «молча». Товар, по которому нет остатков, просто проигнорируется системой, программа не выдаст ошибку о нехватке товара на складе и не сформирует по нему никаких движений.

Как исправить: Фильтрацию по свойствам номенклатуры нужно накладывать на исходную таблицу документа (ВТ), а не на таблицу остатков:


|ГДЕ
|    ВТ.Номенклатура.ВидНоменклатуры = &Материал

Ошибка №3: Неверная группировка в табличной части

В первом запросе, где данные табличной части документа помещаются во временную таблицу, разработчик сгруппировал данные следующим образом:


|СГРУППИРОВАТЬ ПО
|    ОказаниеУслугиПереченьНоменклатуры.Номенклатура,
|    ОказаниеУслугиПереченьНоменклатуры.Количество,
|    ОказаниеУслугиПереченьНоменклатуры.Сумма

Это в корне неверно. Если пользователь введет в документ две разные строки с одной и той же номенклатурой (например, с разной ценой или просто случайно продублирует строку), они не свернутся. Временная таблица будет содержать дубли, что приведет к некорректному расчету списываемых партий.

Как исправить: Группировку нужно делать только по аналитике (Номенклатура), а числовые показатели (количественные и суммовые) — суммировать с помощью агрегатной функции СУММА():


|ВЫБРАТЬ
|    ОказаниеУслугиПереченьНоменклатуры.Номенклатура КАК Номенклатура,
|    СУММА(ОказаниеУслугиПереченьНоменклатуры.Количество) КАК КоличествоДок,
|    СУММА(ОказаниеУслугиПереченьНоменклатуры.Сумма) КАК Сумма
|...
|СГРУППИРОВАТЬ ПО
|    ОказаниеУслугиПереченьНоменклатуры.Номенклатура

Ошибка №4: Обход дерева итогов в плоском цикле

Когда программист добавляет в запрос конструкцию ИТОГИ ПО Номенклатура, результат запроса перестает быть плоской таблицей — он становится иерархическим деревом. На верхнем уровне (в группировке) мы видим общий требуемый объем списания и общие остатки по номенклатуре, а на подчиненном уровне (детальные записи) находятся конкретные партии.

Если обходить такую выборку стандартным «плоским» циклом Пока Выборка.Следующий(), то в цикле будут перемешаны как строки итогов (где поле Партия будет равно NULL), так и строки детальных записей. Это приводит к следующим багам:

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

Как правильно: Для обхода иерархического результата запроса с итогами нужно использовать строго два вложенных цикла:


// Выбираем группировки верхнего уровня (Номенклатура)
ВыборкаНоменклатура = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);

Пока ВыборкаНоменклатура.Следующий() Цикл
    
    ОсталосьСписать = ВыборкаНоменклатура.КоличествоДок;
    
    // Выбираем подчиненные детальные записи (Партии) для текущей номенклатуры
    ВыборкаПартии = ВыборкаНоменклатура.Выбрать();
    
    Пока ВыборкаПартии.Следующий() И ОсталосьСписать > 0 Цикл
        // Здесь происходит расчет и списание по конкретной партии
    КонецЦикла;
    
КонецЦикла;

Разбираем по шагам: готовое и правильное решение задачи

Давайте объединим все исправления в один красивый и оптимизированный алгоритм. Ниже представлен готовый код обработчика проведения документа ОказаниеУслуги, написанный по всем стандартам разработки платформы 1С.


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

Подведем итоги

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

  1. Никогда не записывайте движения внутри цикла. Набор записей должен наполняться в оперативной памяти и записываться платформой автоматически при завершении транзакции.
  2. Правильно обходите итоги запроса. Для этого всегда используются вложенные циклы с указанием параметра ОбходРезультатаЗапроса.ПоГруппировкам на верхнем уровне.
  3. Остерегайтесь неявного превращения левого соединения во внутреннее. Накладывайте условия ГДЕ на левую таблицу, либо переносите условия фильтрации правой таблицы в параметры виртуальной таблицы остатков.
← На главную