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