В реальной торговой практике менеджеры и аналитики регулярно сталкиваются с необходимостью выявления так называемых «неликвидов» или слабооборачиваемых товаров. Это позиции, которые мертвым грузом лежат на складах, замораживая оборотные средства компании. Бизнес-логика задачи проста: нужно найти товары, объем продаж по которым за определенный период ничтожно мал по сравнению с накопленными остатками.
В рамках этой статьи мы подробно разберем, как спроектировать такой отчет в конфигурации «Управление торговлей 10.3» (на базе обычных форм платформы 1С:Предприятие), проанализируем критические логические ошибки, которые часто допускают начинающие разработчики, и построим надежный и быстрый запрос с использованием Системы компоновки данных (СКД).
Сформулируем исходные требования заказчика. Необходимо вывести в отчет список номенклатуры с показателями:
При этом в отчет должны попадать только те товары, по которым продажи за анализируемый период не превысили определенного процента (например, 10%) от разности между начальным и конечным остатком (или от начального запаса). Давайте выясним, почему прямолинейный подход к решению этой задачи в лоб приводит к получению недостоверных данных.
В процессе обсуждения на форумах часто предлагается использовать левое соединение виртуальных таблиц продаж и остатков. Давайте детально проанализируем пример такого запроса, предложенный одним из участников:
ВЫБРАТЬ
ТоварыНаСкладахОстаткиИОбороты.Номенклатура КАК Номенклатура,
ТоварыНаСкладахОстаткиИОбороты.КоличествоНачальныйОстаток КАК НачОст1,
ТоварыНаСкладахОстаткиИОбороты.КоличествоКонечныйОстаток КАК КонОст1
ПОМЕСТИТЬ ТаблПромеж
ИЗ
РегистрНакопления.Продажи.Обороты(&НачПериода, &КонПериода, , ) КАК ПродажиОбороты
ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(&НачПериода, &КонПериода, , , ) КАК ТоварыНаСкладахОстаткиИОбороты
ПО ПродажиОбороты.Номенклатура = ТоварыНаСкладахОстаткиИОбороты.Номенклатура
Этот запрос содержит сразу несколько критических уязвимостей, которые делают результирующий отчет абсолютно бесполезным для бизнеса.
В качестве ведущей (левой) таблицы разработчик выбрал таблицу РегистрНакопления.Продажи.Обороты. Это означает, что результатом запроса станут только те товары, по которым в выбранном периоде была зафиксирована хотя бы одна продажа.
Но подумаем логически: кто является главным неликвидом для компании? Это тот товар, который лежит на складе, но по которому продаж не было вообще (то есть продажи равны нулю). В предложенной схеме такие товары будут полностью отрезаны левым соединением! Они просто не попадут в результирующую выборку. Чтобы исправить эту ситуацию, соединение необходимо строить в обратную сторону — от остатков к продажам, либо использовать полное соединение таблиц.
Заказчик формулирует условие отбора так: «продажи не превысили 10% между разностью остатков на начало и на конец». Математически это выглядит как формула:
Продажи <= 0.1 * (НачальныйОстаток - КонечныйОстаток)
Давайте рассмотрим пограничные случаи, которые полностью ломают эту логику:
5 - 80 = -75. 10% от этого значения составят -7.5. Очевидно, что реальные продажи (25) не могут быть меньше отрицательного числа (-7.5). Товар отсечется из отчета, хотя он продавался крайне неэффективно по отношению к общему объему запаса.10 - 10 = 0). В результате формула отбора потребует, чтобы продажи не превышали 0. Товар автоматически пометится как неликвидный, хотя по нему шла стабильная торговля.Как правильно: В реальном бизнесе оборачиваемость или долю продаж считают по отношению либо к начальному остатку за анализируемый период, либо к среднему остатку. Формула среднего остатка рассчитывается как среднее арифметическое между начальным и конечным остатками: (НачальныйОстаток + КонечныйОстаток) / 2.
Если мы выбираем периодичность «Месяц» в виртуальной таблице остатков и оборотов, система возвращает нам записи по каждому месяцу отдельно. Из-за этого крайне сложно красиво сопоставить остатки и продажи в одной строке программным кодом. Если делать это через циклы (как предлагалось в Сообщении 6), отчет будет работать очень медленно на больших базах данных.
Для красивого решения этой проблемы мы должны использовать Систему компоновки данных (СКД) и ее встроенный механизм кросс-таблиц.
Обратите внимание на параметры &НачПериода и &КонПериода. Если пользователь указывает в качестве даты окончания периода, например, 31.12.2023, то без явного указания времени 1С преобразует этот параметр в начало дня: 31.12.2023 00:00:00.
В итоге все движения и продажи, совершенные в течение последнего дня периода, будут полностью проигнорированы. Чтобы этого избежать, в коде необходимо передавать параметр конца периода с использованием типа Граница или использовать функции СКД для приведения даты к концу дня.
Давайте построим правильный, производительный запрос, который соберет данные по остаткам и продажам без потери неликвидных позиций. Самым надежным способом в 1С является раздельный сбор остатков и продаж в промежуточные временные таблицы с их последующим объединением.
Рассмотрим текст правильного запроса:
// Шаг 1: Получаем остатки товаров за весь период
ВЫБРАТЬ
ТоварыНаСкладахОстатки.Номенклатура КАК Номенклатура,
ТоварыНаСкладахОстатки.КоличествоНачальныйОстаток КАК НачОст,
ТоварыНаСкладахОстатки.КоличествоКонечныйОстаток КАК КонОст,
0 КАК КоличествоПродано
ПОМЕСТИТЬ ВТ_Данные
ИЗ
РегистрНакопления.ТоварыНаСкладах.ОстаткиИОбороты(&НачПериода, &КонПериода, , , ) КАК ТоварыНаСкладахОстатки
ОБЪЕДИНИТЬ ВСЕ
// Шаг 2: Получаем продажи товаров за этот же период
ВЫБРАТЬ
ПродажиОбороты.Номенклатура,
0,
0,
ПродажиОбороты.КоличествоОборот
ИЗ
РегистрНакопления.Продажи.Обороты(&НачПериода, &КонПериода, , ) КАК ПродажиОбороты
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 3: Группируем полученные данные по номенклатуре и рассчитываем показатели
ВЫБРАТЬ
ВТ_Данные.Номенклатура КАК Номенклатура,
СУММА(ВТ_Данные.НачОст) КАК НачальныйОстаток,
СУММА(ВТ_Данные.КонОст) КАК КонечныйОстаток,
СУММА(ВТ_Данные.КоличествоПродано) КАК ВсегоПродано,
// Вычисляем процент продаж от начального остатка во избежание деления на ноль:
ВЫБОР
КОГДА СУММА(ВТ_Данные.НачОст) > 0
ТОГДА (СУММА(ВТ_Данные.КоличествоПродано) / СУММА(ВТ_Данные.НачОст)) * 100
ИНАЧЕ 0
КОНЕЦ КАК ПроцентПродаж
ИЗ
ВТ_Данные КАК ВТ_Данные
ГРУППИРОВАТЬ ПО
ВТ_Данные.Номенклатура
ИМЕЮЩИЕ
// Накладываем фильтр оборачиваемости (например, продажи не превысили 10%)
ВЫБОР
КОГДА СУММА(ВТ_Данные.НачОст) > 0
ТОГДА (СУММА(ВТ_Данные.КоличествоПродано) / СУММА(ВТ_Данные.НачОст)) * 100
ИНАЧЕ 0
КОНЕЦ <= &ДопустимыйПроцентПродаж
И СУММА(ВТ_Данные.НачОст) > 0 // Анализируем только те товары, которые реально были на складе
Давайте проанализируем преимущества этого запроса:
ОБЪЕДИНИТЬ ВСЕ мы полностью защищены от потери товаров, по которым остатки были, а продаж не было вообще. Они гарантированно попадут в результирующую выборку с нулевым показателем КоличествоПродано.ВЫБОР КОГДА защищает систему от фатальной ошибки деления на ноль, если по какой-то причине начальный остаток оказался пустым.ИМЕЮЩИЕ на уровне базы данных, что отсекает передачу лишних данных на клиентский компьютер и повышает скорость работы отчета.Для того чтобы вывести продажи помесячно в виде красивой таблицы, лучше всего отказаться от ручного формирования макета и использовать возможности СКД. Разберем алгоритм настройки:
ИЗ РегистрНакопления.Продажи.Обороты(&НачПериода, &КонПериода, Месяц, ) КАК ПродажиОбороты
ПродажиОбороты.Период КАК Период во временную таблицу и в итоговую группировку.
Номенклатура.Период (в свойствах группировки укажите тип дополнения «Месяц», чтобы пустые месяцы не выпадали из отчета).НачальныйОстаток, КонечныйОстаток и ВсегоПродано. Для полей остатков настройте правильный расчет итогов, выбрав в качестве функции расчета соответствующих ресурсов Максимум или настроив роль остатков в наборе данных, чтобы остатки не суммировались за каждый месяц, а отображали реальное значение на начало всего периода и на его конец.Для того чтобы отчет выводил правильные остатки на самую последнюю секунду выбранного пользователем периода, обязательно обрабатывайте параметр даты окончания в коде модуля объекта отчета перед его формированием:
Процедура ПриКомпоновкеРезультата(ДокументРезультат, ДанныеРасшифровки, СтандартнаяОбработка)
СтандартнаяОбработка = Ложь;
// Получаем настройки отчета
КомпоновщикНастроек.Настройки.ПараметрыДанных.УстановитьЗначениеПараметра("НачПериода", НачалоДня(КомпоновщикНастроек.Настройки.ПараметрыДанных.Найти("НачалоПериода").Значение));
// Формируем правильную границу для даты окончания периода
КонечнаяДата = КонецДня(КомпоновщикНастроек.Настройки.ПараметрыДанных.Найти("КонецПериода").Значение);
ГраницаКонца = Новый Граница(КонечнаяДата, ВидГраницы.Включая);
КомпоновщикНастроек.Настройки.ПараметрыДанных.УстановитьЗначениеПараметра("КонПериода", ГраницаКонца);
// Запускаем стандартный вывод отчета с новыми параметрами
КомпоновщикМакета = Новый КомпоновщикМакетаКомпоновкиДанных;
МакетКомпоновки = КомпоновщикМакета.Выполнить(СхемаКомпоновкиДанных, КомпоновщикНастроек.Настройки, ДанныеРасшифровки);
ПроцессорКомпоновки = Новый ПроцессорКомпоновкиДанных;
ПроцессорКомпоновки.Инициализировать(МакетКомпоновки, , ДанныеРасшифровки, Истина);
ПроцессорВывода = Новый ПроцессорВыводаРезультатаКомпоновкиДанныхВТабличныйДокумент;
ПроцессорВывода.УстановитьДокумент(ДокументРезультат);
ПроцессорВывода.Вывести(ПроцессорКомпоновки);
КонецПроцедуры
Использование класса Граница с флагом ВидГраницы.Включая гарантирует, что в выборку остатков попадут абсолютно все документы, проведенные вплоть до последней секунды указанного дня, что полностью исключает расхождения данных с типовыми ведомостями по товарам на складах.