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