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