Как запросом без цикла узнать, сколько дней товар был на складе в наличии?

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

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

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

Анализ неэффективного решения: почему запрос в цикле — это плохо?

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


ДнейВыборки = Окр((Объект.ДатаПродажОкончание - Объект.ДатаПродажНачало) / (60 * 60 * 24), 0);
Для Каждого Стр Из Объект.Товары Цикл
    ДнейНаСкладе = 0;
    Для СчДней = 0 По ДнейВыборки Цикл
        ЗапросОстатокСклада = Новый Запрос;
        ЗапросОстатокСклада.Текст = 
            "ВЫБРАТЬ
            |    ОстаткиНоменклатурыОстатки.Номенклатура,
            |    ОстаткиНоменклатурыОстатки.КоличествоОстаток
            |ИЗ
            |    РегистрНакопления.ОстаткиНоменклатуры.Остатки(&Период, ) КАК ОстаткиНоменклатурыОстатки
            |ГДЕ
            |    ОстаткиНоменклатурыОстатки.Номенклатура = &Номенклатура
            |    И ОстаткиНоменклатурыОстатки.Склад = &Склад";
        
        ЗапросОстатокСклада.УстановитьПараметр("Склад", Стр.Склад);
        ЗапросОстатокСклада.УстановитьПараметр("Номенклатура", Стр.Номенклатура);
        ЗапросОстатокСклада.УстановитьПараметр("Период", Объект.ДатаПродажНачало + СчДней * 60 * 60 * 24);
        
        ИтогЗапросОстаток = ЗапросОстатокСклада.Выполнить().Выгрузить();
        Если ИтогЗапросОстаток.Количество() > 0 Тогда
            Если ИтогЗапросОстаток[0].КоличествоОстаток > 0 Тогда
                ДнейНаСкладе = ДнейНаСкладе + 1; 
            КонецЕсли;     
        КонецЕсли;
    КонецЦикла;
    Стр.ДнейНаСкладе = ДнейНаСкладе;
КонецЦикла;

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

  1. Создание объекта запроса внутри цикла. Инициализация Новый Запрос и компиляция его текста на каждом шаге цикла расходуют колоссальное количество ресурсов процессора и оперативной памяти.
  2. N+1 обращений к базе данных. Если в табличной части 100 товаров, а период анализа составляет 30 дней, СУБД получит 3000 запросов! На реальной базе данных это вызовет длительное зависание сеанса пользователя и заблокирует работу других сотрудников.
  3. Проблема с пустым результатом. Если по товару на указанную секунду нет остатка (или он равен нулю), виртуальная таблица Остатки просто не вернет строку для этой номенклатуры. Метод Количество() вернет 0, и день не будет засчитан, даже если товар физически находился на складе, но по нему не было движений в этот конкретный день.
  4. Неверный расчет дат. Прибавление к дате секунд вида СчДней * 60 * 60 * 24 не учитывает переходы на летнее/зимнее время (в некоторых старых системах) или может смещать точку времени на границу суток, из-за чего остатки за день могут быть получены некорректно.

Для того чтобы решить эту задачу правильно, быстро и без использования циклов в коде 1С, мы должны перенести всю логику вычислений на сторону СУБД с помощью языка запросов.

Решение 1: Использование таблицы «ОстаткиИОбороты» с периодичностью «День»

Самый простой и логичный способ решить задачу одним запросом — использовать виртуальную таблицу РегистрНакопления.ОстаткиНоменклатуры.ОстаткиИОбороты. Главная хитрость здесь заключается в правильной настройке периодичности и параметров дополнения периодов.

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

Рассмотрим пример запроса, который решает эту задачу:


ВЫБРАТЬ
    ОстаткиИОбороты.Номенклатура КАК Номенклатура,
    ОстаткиИОбороты.Склад КАК Склад,
    КОЛИЧЕСТВО(РАЗЛИЧНЫЕ НАЧАЛОПЕРИОДА(ОстаткиИОбороты.Период, ДЕНЬ)) КАК ДнейВНаличии
ИЗ
    РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(
            &НачалоПериода,
            &КонецПериода,
            День,
            ДвиженияИГраницыПериода,
            Номенклатура В (&МассивНоменклатуры)
                И Склад = &Склад
    ) КАК ОстаткиИОбороты
ГДЕ
    ОстаткиИОбороты.КоличествоКонечныйОстаток > 0

СГРУППИРОВАТЬ ПО
    ОстаткиИОбороты.Номенклатура,
    ОстаткиИОбороты.Склад

Разберем по шагам, как работает этот запрос:

Решение 2: Расчет через интервалы отсутствия («метод от противного»)

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

В таких случаях применяется более производительный метод — расчет интервалов отсутствия товара. Мы определяем периоды, когда остаток товара был равен нулю, вычисляем их общую длительность в днях с помощью функции РАЗНОСТЬДАТ, а затем вычитаем полученный результат из общего количества дней в анализируемом периоде.

Проанализируем алгоритм реализации этого метода:

  1. Выбираем из регистра все уникальные даты, когда остаток товара становился равен нулю (происходило списание в ноль) или когда товар снова поступал на склад после отсутствия.
  2. С помощью соединения таблицы остатков самой с собой сопоставляем дату «обнуления» остатка с ближайшей следующей датой «поступления» товара. Таким образом, мы получаем четкие временные интервалы вида: [Товар отсутствовал с Даты_А по Дату_Б].
  3. Для каждого интервала рассчитываем его длительность в днях: РАЗНОСТЬДАТ(ДатаНачалаОтсутствия, ДатаОкончанияОтсутствия, ДЕНЬ).
  4. Суммируем все дни отсутствия по каждому товару.
  5. Вычитаем эту сумму из общей продолжительности периода отчета. Полученная разность и будет являться количеством дней фактического наличия товара на складе.

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

Решение 3: Динамическая генерация шкалы дат в запросе

Что делать, если по каким-то причинам виртуальная таблица с дополнением периодов не решает задачу (например, требуется связать остатки со сложным производственным календарем компании, исключив праздничные и выходные дни, когда продажи не велись)?

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

Посмотрим на пример реализации генератора дат в запросе:


ВЫБРАТЬ 0 КАК Цифра ПОМЕСТИТЬ Цифры
ОБЪЕДИНИТЬ ВЫБРАТЬ 1 ОБЪЕДИНИТЬ ВЫБРАТЬ 2 ОБЪЕДИНИТЬ ВЫБРАТЬ 3 
ОБЪЕДИНИТЬ ВЫБРАТЬ 4 ОБЪЕДИНИТЬ ВЫБРАТЬ 5 ОБЪЕДИНИТЬ ВЫБРАТЬ 6 
ОБЪЕДИНИТЬ ВЫБРАТЬ 7 ОБЪЕДИНИТЬ ВЫБРАТЬ 8 ОБЪЕДИНИТЬ ВЫБРАТЬ 9
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
    ДОБАВИТЬКДАТЕ(&НачалоПериода, ДЕНЬ, т1.Цифра + т2.Цифра * 10 + т3.Цифра * 100) КАК ПериодДень
ПОМЕСТИТЬ СеткаДат
ИЗ
    Цифры КАК т1
    CROSS JOIN Цифры КАК т2
    CROSS JOIN Цифры КАК т3
ГДЕ
    ДОБАВИТЬКДАТЕ(&НачалоПериода, ДЕНЬ, т1.Цифра + t2.Цифра * 10 + т3.Цифра * 100) <= &КонецПериода
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
    СеткаДат.ПериодДень КАК ПериодДень,
    Товары.Номенклатура КАК Номенклатура,
    ЕСТЬNULL(Остатки.КоличествоОстаток, 0) КАК Остаток
ПОМЕСТИТЬ ОстаткиПоВсемДатам
ИЗ
    СеткаДат КАК СеткаДат
    CROSS JOIN (ВЫБРАТЬ РАЗЛИЧНЫЕ Номенклатура ИЗ РегистрНакопления.ТоварыНаСкладах ГДЕ Склад = &Склад) КАК Товары
    ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.ТоварыНаСкладах.Остатки(&НачалоПериода, Склад = &Склад) КАК Остатки
        ПО СеткаДат.ПериодДень = НАЧАЛОПЕРИОДА(Остатки.Период, ДЕНЬ)
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
    ОстаткиПоВсемДатам.Номенклатура,
    КОЛИЧЕСТВО(ВЫБОР КОГДА ОстаткиПоВсемДатам.Остаток > 0 ТОГДА 1 КОНЕЦ) КАК ДнейВНаличии
ИЗ
    ОстаткиПоВсемДатам КАК ОстаткиПоВсемДатам
СГРУППИРОВАТЬ ПО
    ОстаткиПоВсемДатам.Номенклатура

Этот метод гарантирует абсолютную точность и позволяет накладывать любые дополнительные календари (например, соединяя таблицу СеткаДат с производственным календарем компании перед расчетом остатков).

Важные бизнес-нюансы при расчете остатков

При проектировании алгоритмов расчета дней в наличии обязательно нужно учитывать специфику движений товаров внутри одного дня (как справедливо заметил автор Сообщения 10):

Использование предложенных подходов позволит вам полностью отказаться от тяжелых циклов в кодовой базе 1С, переложить рутинные вычисления на оптимизированный движок СУБД и значительно ускорить работу ваших отчетов и обработок.

← На главную