Как правильно перебрать выборку запроса для контроля остатков и партионного списания по FIFO в 1С?

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

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

В этой статье мы подробно разберем, как правильно реализовать партионное списание с возможностью приоритетного списания конкретной партии, указанной в строке документа. Мы проанализируем типичные ошибки, связанные с декартовым произведением в запросах, разберем особенности работы сортировки при использовании итогов, а также напишем оптимизированный и безопасный код для процедуры ОбработкаПроведения.

---

Типичные ошибки при разработке партионного списания

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

1. Декартово произведение (Cartesian Product) при соединении таблиц

В исходном запросе автора финальное соединение временной таблицы документа (ВТ_ДокТч) и таблицы остатков (ВТ_ОстаткиПартий) выполнялось следующим образом:


ИЗ
    ВТ_ДокТч КАК ВТ_ДокТч
        ЛЕВОЕ СОЕДИНЕНИЕ ВТ_ОстаткиПартий КАК ВТ_ОстаткиПартий
        ПО ВТ_ДокТч.Номенклатура = ВТ_ОстаткиПартий.Номенклатура

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

2. Использование полей через точку в блоке упорядочивания

Автор использует конструкции вида ВТ_ОстаткиПартий.Номенклатура.Наименование и ВТ_ДокТч.Партия.МоментВремени в секции УПОРЯДОЧИТЬ ПО. Обращение к реквизитам через точку в запросах платформы 1С на уровне СУБД транслируется во вложенные операторы LEFT JOIN. На реальных базах данных при проведении документов это вызывает колоссальные задержки и блокировки таблиц.

3. Проблемы сортировки при использовании обхода по группировкам (Итоги)

Когда мы обходим результат запроса по группировкам (в данном случае первый уровень — Номенклатура, второй — Партия), в строках верхнего уровня (группировочных) все детальные поля, по которым не настроены агрегатные функции, принимают значение NULL. Из-за этого стандартная сортировка по детальным полям (например, по вычисляемому порядку списания) перестает работать так, как ожидает разработчик. Чтобы сортировка по итогам работала корректно, необходимо явно указывать агрегатные функции (МИНИМУМ или МАКСИМУМ) для сортируемых полей в секции ИТОГИ.

4. Отсутствие транзакционных блокировок

При списании остатков критически важно защитить данные от параллельного изменения другими пользователями. Если два пользователя одновременно проводят документы с одним и тем же товаром, они могут одновременно прочитать одинаковые остатки, что приведет к некорректному списанию в «минус» после завершения транзакций.

---

Правильный алгоритм партионного списания («Приоритетное FIFO»)

Давайте разберем по шагам, как построить логику, при которой:

  1. Сначала анализируется общая потребность в товаре по документу.
  2. Если товара в целом на складе не хватает — проведение отменяется с выводом понятного сообщения пользователю.
  3. Если товара достаточно — система начинает списание.
  4. В первую очередь списывается та партия, которую пользователь вручную указал в строке табличной части документа (если она заполнена и по ней есть остаток).
  5. Если указанной партии не хватает или она не была задана — остаток потребности распределяется по классическому FIFO (от старых партий к новым).

Для реализации этой схемы без дублирования данных мы разделим сбор потребностей и сбор остатков. Мы сформируем три таблицы:

---

Полный оптимизированный пример кода

Ниже представлен готовый к использованию, оптимизированный и безопасный код процедуры ОбработкаПроведения для модуля документа.


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

---

Разбор ключевых моментов оптимизации

Давайте подробно проанализируем, за счет чего этот код работает быстро и корректно:

---

Альтернативный вариант: Новая методика проведения

В сообщении 25 участники форума упоминают так называемую «новую» методику проведения документов, используемую в современных типовых конфигурациях 1С (таких как 1С:ERP или 1С:Управление торговлей). Рассмотрим ее отличия подробнее.

При «новой» методике логика следующая:

  1. Документ сразу записывает свои движения в регистр накопления (в «расход»), не проверяя остатки в базе данных.
  2. После записи всех движений в СУБД одним запросом анализируются остатки по регистру с отбором по измененным товарам.
  3. Если по какому-то товару остаток на складе ушел в отрицательное значение (стал меньше нуля), система откатывает транзакцию проведения и выводит пользователю ошибку.

Преимущества новой методики:

Ограничения новой методики для партионного учета:

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

← На главную