Почему один и тот же запрос выдает разные остатки в разных документах при проведении в 1С?

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

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

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

Причина расхождения остатков: Транзакционный контекст и движения регистра

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

Когда запускается проведение документа, платформа открывает транзакцию базы данных. Все операции, происходящие внутри этой транзакции, выполняются изолированно от других пользователей, но внутри самой транзакции все изменения данных видны сразу после их записи. В зависимости от настроек проведения документа (так называемая «старая» или «новая» методика), движения документа могут записываться в базу данных на разных этапах:

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

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

Критическая ошибка №1: Использование даты вместо границы времени

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

Запрос.УстановитьПараметр("НаДату", КонецДня(ДатаЗапросаостатков)+1);

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

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


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

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

Критическая ошибка №2: Вызов метода Записать() внутри проведения

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


Для каждого Строка из этотОбъект().Строки Цикл
    // Вычисления себестоимости...
КонецЦикла;
Записать(); // КАТАСТРОФА!

Вызов метода Записать() внутри обработчика ОбработкаПроведения категорически запрещен. Процедура проведения сама по себе является частью транзакции записи объекта. Принудительный вызов перезаписи объекта изнутри этой же транзакции приводит к:

Платформа 1С самостоятельно запишет документ и все его движения по завершении транзакции проведения, если параметр Отказ не будет установлен в значение Истина.

Критическая ошибка №3: Получение остатков и расчет себестоимости в цикле

Рассмотрим, как автор пытается рассчитать себестоимость списания товара:


РезультатЗапросаостатков = Запрос.Выполнить().Выгрузить(); // Выгружаем абсолютно все остатки по подразделению в ТЗ
Для каждого Строка из этотОбъект().Строки Цикл
    Отбор = Новый Структура;
    Отбор.Вставить("Товар", строка.Товар);
    Отбор.Вставить("НомерСерии", строка.НомерСерии);
    Отбор.Вставить("ПартияТовара", строка.ПартияТовара);
    ТЗСебестоимости = РезультатЗапросаостатков.Скопировать(отбор); // Копируем ТЗ в цикле!
    Строка.СуммаСебестоимости = строка.Количество * (ТЗСебестоимости.Итог("СуммаОстаток") / ТЗСебестоимости.Итог("КоличествоОстаток"));
КонецЦикла;

Это классический антипаттерн производительности. Если в табличной части документа будет 100 строк, а в регистре по подразделению числится 5000 позиций номенклатуры, система будет выполнять копирование и фильтрацию огромных таблиц значений в оперативной памяти 100 раз. При росте базы данных этот алгоритм приведет к критическому падению производительности.

Как делать правильно: Все расчеты должны выполняться непосредственно в запросе СУБД с использованием механизма временных таблиц. Мы должны передать список списываемых товаров во временную таблицу запроса, соединить ее с виртуальной таблицей остатков регистра и получить готовый результат для каждой строки документа за один один-единственный запрос.

Правильная архитектура списания остатков (Новая методика)

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

Ниже представлен шаблон процедуры ОбработкаПроведения, соответствующий стандартам «1С»:


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

Резюме по правильной работе с регистрами накопления

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

← На главную