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