Как исправить ошибки в процедуре партионного проведения документа в 1С?

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

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

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

Исходный код процедуры с ошибками

Для начала давайте внимательно посмотрим на код, который был предложен в первом сообщении темы. На первый взгляд, это классический алгоритм партионного списания по «старой» методике проведения (сначала проверяем остатки запросом, затем списываем):


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

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

Ошибка 1: Бесконечный цикл и некорректное списание партий

Выясним причину первой логической ошибки, на которую сразу обратили внимание участники форума в сообщениях 3 и 9. Во вложенном цикле Пока (ОсталосьСписать > 0) И ПолнаяВыборка.Следующий Цикл мы определяем объем списания из текущей партии:

КоличествоСписания = Мин(ОсталосьСписать, ПолнаяВыборка.КоличествоОстаток);

Однако в теле этого цикла значение переменной ОсталосьСписать никак не изменяется! Это приводит к двум катастрофическим последствиям:

  1. Если у товара несколько партий, то во всех последующих партиях программа будет пытаться списать исходное полное количество товара, а не оставшуюся часть. В результате спишется гораздо больше товара, чем требуется.
  2. Если на остатках достаточно товара, но он разбит по мелким партиям, цикл может стать бесконечным или привести к некорректным движениям по регистру, так как условие выхода из цикла ОсталосьСписать > 0 никогда не выполнится за счет уменьшения остатка.

Решение: В самом конце тела вложенного цикла необходимо уменьшать переменную ОсталосьСписать на фактически списанное количество товара:

ОсталосьСписать = ОсталосьСписать - КоличествоСписания;

Ошибка 2: Использование Даты вместо Момента Времени

Вторая явная ошибка, замеченная в Сообщении 2, связана со следующим кодом установки параметров запроса:

Запрос.УстановитьПараметр("Период", Дата);

Передача простой даты в параметры виртуальной таблицы остатков при проведении документов крайне нежелательна. Рассмотрим подробнее, почему:

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

Решение: Для корректного получения остатков на момент проведения документа всегда следует использовать объект МоментВремени или объект Граница (если требуется исключить или включить движения самого документа). Изменим установку параметра:


Запрос.УстановитьПараметр("Период", Новый Граница(МоментВремени(), ВидГраницы.Исключая));

Применение объекта Граница с видом Исключая позволяет получить чистые остатки на момент «прямо перед проведением» нашего документа, гарантируя, что его собственные движения не исказят общую картину остатков.

Ошибка 3: Отсутствие очистки старых движений при перепроведении

Эта архитектурная ошибка часто ускользает от внимания начинающих разработчиков. Если пользователь решит перепровести документ (например, изменив количество товара в табличной части с 10 на 5 штук), система выполнит запрос остатков. Но на момент выполнения запроса в базе данных все еще записаны старые движения этого документа (те самые 10 штук в расходе).

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

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


Движения.Остатки.Записывать = Истина;
Движения.Продажи.Записывать = Истина;

// Записываем пустые наборы движений для очистки старых записей в БД
Движения.Остатки.Записать();
Движения.Продажи.Записать();

Ошибка 4: Проблема «зависших копеек» при расчете стоимости

Посмотрим на формулу расчета стоимости списания партии в исходном коде:


Движение.Стоимость = (КоличествоСписания * ПолнаяВыборка.СтоимостьОстаток) / ПолнаяВыборка.КоличествоОстаток;

Представим ситуацию: на остатках числится 3 штуки товара общей стоимостью 100 рублей. Мы хотим списать все 3 штуки. По нашей формуле для каждой списываемой единицы (если они списываются частями или округляются в СУБД) может возникнуть погрешность деления. Но даже если мы списываем всю партию целиком одним движением, расчет по формуле вернет нам:

(3 * 100) / 3 = 100

А если списывается сначала 2 штуки, а потом 1 штука? В первом шаге спишется (2 * 100) / 3 = 66.67 рублей. Во втором шаге останется списать 1 штуку. Расчет будет: (1 * 33.33) / 1 = 33.33 рублей.

Однако при накоплении округлений в более сложных расчетах часто возникает ситуация, когда количество по партии списывается в ноль (КоличествоОстаток = 0), а на балансе регистра «зависает» остаток стоимости в размере 1 копейки (СтоимостьОстаток = 0.01). Это грубая ошибка ведения учета.

Решение: При списании последней части партии (когда списываемое количество в точности равно количеству остатка по этой партии) необходимо списывать всю оставшуюся стоимость партии целиком без всяких формул деления:


Если КоличествоСписания = ПолнаяВыборка.КоличествоОстаток Тогда
    Движение.Стоимость = ПолнаяВыборка.СтоимостьОстаток;
Иначе
    Движение.Стоимость = Окр((КоличествоСписания * ПолнаяВыборка.СтоимостьОстаток) / ПолнаяВыборка.КоличествоОстаток, 2);
КонецЕсли;

Ошибка 5: Опасность деления на ноль

В реальной базе данных из-за некорректных ручных корректировок или сбоев может возникнуть ситуация, когда количество по партии равно нулю (КоличествоОстаток = 0), но при этом на партии висит какая-то стоимость (СтоимостьОстаток > 0).

В этом случае строка кода:

КоличествоСписания = Мин(ОсталосьСписать, ПолнаяВыборка.КоличествоОстаток);

вернет нам 0 (так как минимум из ОсталосьСписать и 0 равен 0). Далее программа попытается выполнить расчет стоимости с делением на ПолнаяВыборка.КоличествоОстаток (то есть на 0). Это приведет к критической ошибке времени выполнения (Runtime Error) и аварийному завершению транзакции проведения для пользователя.

Решение: Обязательно добавляем проверку на неотрицательный и ненулевой остаток в начале цикла обхода партий:


Если ПолнаяВыборка.КоличествоОстаток <= 0 Тогда
    Продолжить;
КонецЕсли;

Ошибка 6: Отсутствие управляемых блокировок данных

В исходном коде полностью отсутствует управление блокировками транзакции. В условиях реальной многопользовательской работы это приведет к серьезным проблемам:

  1. Несогласованность данных (минусы на складах): Два пользователя одновременно проводят расходные накладные на один и тот же товар, которого на складе осталось всего 1 штука. Обе транзакции параллельно считывают остаток (он равен 1). Обе накладные успешно проводятся, списывая по 1 штуке. В результате в регистре остаток уходит в минус (-1), что недопустимо при партионном учете.
  2. Взаимные блокировки (Deadlocks): Без явного указания блокировок СУБД будет накладывать избыточные автоматические блокировки таблиц, что приведет к частым ошибкам ожидания блокировок у пользователей.

Решение: Перед выполнением запроса остатков необходимо явно заблокировать записи регистра накопления Остатки по списку номенклатуры, содержащейся в документе, используя объект БлокировкаДанных:


Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("РегистрНакопления.Остатки");
ЭлементБлокировки.УстановитьЗначение("Номенклатура", Ссылка); // Блокируем по товарам из табличной части документа
ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
ЭлементБлокировки.ИсточникДанных = "Товары"; // Указываем табличную часть как источник данных
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Номенклатура", "Номенклатура");
Блокировка.Заблокировать();

Ошибка 7: Неоптимальный запрос (Индексация и обращение через точку)

Проанализируем текст запроса с точки зрения производительности СУБД. В нем есть два неоптимальных момента:

  1. Отсутствие индексации во временной таблице: Временная таблица РасходнаяНакладная соединяется со справочником и таблицей остатков по полю Номенклатура. Согласно стандартам разработки 1С, все поля временных таблиц, участвующие в связях (соединениях), должны быть проиндексированы.
  2. Обращение к реквизитам через точку в упорядочивании: Строка УПОРЯДОЧИТЬ ПО ОстаткиНоменклатурыОстатки.Партия.Дата обращается к дате документа-партии через точку от ссылки. На уровне СУБД это вызывает неявное левое соединение (Implicit Join) с таблицами всех возможных типов документов, которые могут выступать в качестве регистратора (партии). Это колоссально снижает скорость выполнения запроса.

Решение: Добавим конструкцию ИНДЕКСИРОВАТЬ ПО для временной таблицы, а сортировку партий будем производить по полю Партия.МоментВремени, что работает значительно быстрее на уровне СУБД:


// В первом пакете запроса:
|СГРУППИРОВАТЬ ПО
|   РасходнаяНакладная.Номенклатура
|ИНДЕКСИРОВАТЬ ПО
|   Номенклатура
|;
...
// В блоке упорядочивания второго пакета:
|УПОРЯДОЧИТЬ ПО
|   ОстаткиНоменклатурыОстатки.Партия.МоментВремени

Переход на «Новую методику проведения»

В современной платформе 1С 8.3 для оперативного проведения документов настоятельно рекомендуется использовать «Новую методику проведения». Ее суть заключается в следующем:

  1. Мы не делаем предварительный запрос остатков и не блокируем записи до записи движений.
  2. Мы сразу записываем движения расхода документа в регистр накопления (происходит «быстрая» запись без проверок).
  3. Затем мы одним запросом проверяем, не ушли ли остатки в минус после записи наших движений.
  4. Если в регистре обнаруживается отрицательный остаток по какому-либо товару, мы выводим сообщение об ошибке, устанавливаем Отказ = Истина и транзакция автоматически откатывается СУБД.

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

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

Итоговый эталонный код процедуры проведения

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


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

Заключение

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

Использование этого шаблона кода гарантирует стабильную, быструю и корректную работу механизмов партионного учета в любой конфигурации на платформе 1С:Предприятие 8.3.

← На главную