В разработке на платформе 1С предприятие часто возникает классическая задача: получить не просто последнее актуальное значение из периодического регистра сведений (то есть стандартный срез последних), а сразу два последних значения — актуальное и предшествующее ему. Такая необходимость регулярно встречается в различных прикладных задачах. Например, когда нужно вывести историю изменения цен номенклатуры для сравнения старой и новой цены, проанализировать динамику изменения окладов или должностей сотрудников, либо отследить историю изменения курсов валют.
Сложность этой задачи в рамках 1С заключается в технических ограничениях языка запросов платформы. В отличие от стандартного SQL, в языке запросов 1С отсутствуют аналитические (оконные) функции, такие как ROW_NUMBER(), LAG(), LEAD() и конструкции OVER (PARTITION BY...), которые позволили бы решить эту задачу в один простой прием. Разработчикам приходится эмулировать этот функционал другими путями. В этой статье мы подробно разберем несколько практических способов реализации этой задачи, проанализируем их плюсы, минусы и влияние на производительность СУБД.
Этот способ является наиболее надежным, предсказуемым и производительным при работе с большими объемами данных. СУБД отлично оптимизирует такие запросы, они эффективно используют индексы таблиц и не вызывают избыточных блокировок данных.
Идея метода заключается в пошаговом получении срезов:
Проанализируем этот алгоритм на примере регистра сведений КадроваяИсторияСотрудников. Нам нужно получить последнее и предпоследнее подразделение, в котором работал сотрудник:
// Шаг 1. Получаем период последней записи (актуальный срез)
ВЫБРАТЬ
КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
МАКСИМУМ(КадроваяИсторияСотрудников.Период) КАК Период
ПОМЕСТИТЬ ВТ_СрезПоследних
ИЗ
РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
ГДЕ
КадроваяИсторияСотрудников.Период <= &Период
СГРУППИРОВАТЬ ПО
КадроваяИсторияСотрудников.Сотрудник
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 2. Получаем период предпоследней записи
ВЫБРАТЬ
КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
МАКСИМУМ(КадроваяИсторияСотрудников.Период) КАК Период
ПОМЕСТИТЬ ВТ_СрезПредпоследних
ИЗ
ВТ_СрезПоследних КАК ВТ_СрезПоследних
ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
ПО ВТ_СрезПоследних.Сотрудник = КадроваяИсторияСотрудников.Сотрудник
И ВТ_СрезПоследних.Период > КадроваяИсторияСотрудников.Период
СГРУППИРОВАТЬ ПО
КадроваяИсторияСотрудников.Сотрудник
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 3. Соединяем периоды с физической таблицей для получения данных
ВЫБРАТЬ
КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
ВЫБОР
КОГДА НЕ ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL
ТОГДА КадроваяИсторияСотрудников.Подразделение
ИНАЧЕ NULL
КОНЕЦ КАК ПоследнееПодразделение,
ВЫБОР
КОГДА НЕ ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL
ТОГДА КадроваяИсторияСотрудников.Период
ИНАЧЕ NULL
КОНЕЦ КАК ПериодПоследнегоПодразделения,
ВЫБОР
КОГДА НЕ ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL
ТОГДА КадроваяИсторияСотрудников.Подразделение
ИНАЧЕ NULL
КОНЕЦ КАК ПредпоследнееПодразделение,
ВЫБОР
КОГДА НЕ ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL
ТОГДА КадроваяИсторияСотрудников.Период
ИНАЧЕ NULL
КОНЕЦ КАК ПериодПредпоследнегоПодразделения
ИЗ
РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
ЛЕВОЕ СОЕДИНЕНИЕ ВТ_СрезПоследних КАК ВТ_СрезПоследних
ПО КадроваяИсторияСотрудников.Сотрудник = ВТ_СрезПоследних.Сотрудник
И КадроваяИсторияСотрудников.Период = ВТ_СрезПоследних.Period
ЛЕВОЕ СОЕДИНЕНИЕ ВТ_СрезПредпоследних КАК ВТ_СрезПредпоследних
ПО КадроваяИсторияСотрудников.Сотрудник = ВТ_СрезПредпоследних.Сотрудник
И КадроваяИсторияСотрудников.Период = ВТ_СрезПредпоследних.Период
ГДЕ
НЕ (ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL И ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL)
Преимущества метода: Высокая и стабильная скорость работы даже на таблицах с миллионами записей. Оптимизаторы СУБД (MS SQL, PostgreSQL) отлично строят план запроса для таких конструкций, эффективно задействуя индексы по измерениям и периоду.
Недостатки метода: Текст запроса получается объемным, требует аккуратности при написании связей и усложняет чтение кода.
Этот подход привлекает разработчиков своей лаконичностью. Код выглядит просто и изящно, однако он таит в себе скрытую угрозу для производительности всей системы.
Разберем пример запроса, выбирающего две последние цены для каждой номенклатуры:
ВЫБРАТЬ
ЦеныНоменклатуры.Номенклатура КАК Номенклатура,
ЦеныНоменклатуры.ТипЦен КАК ТипЦен,
ЦеныНоменклатуры.Период КАК Период,
ЦеныНоменклатуры.Цена КАК Цена
ИЗ
РегистрСведений.ЦеныНоменклатуры КАК ЦеныНоменклатуры
ГДЕ
ЦеныНоменклатуры.Период В
(ВЫБРАТЬ ПЕРВЫЕ 2
РегистрВложенный.Период
ИЗ
РегистрСведений.ЦеныНоменклатуры КАК РегистрВложенный
ГДЕ
РегистрВложенный.Номенклатура = ЦеныНоменклатуры.Номенклатура
И РегистрВложенный.ТипЦен = ЦеныНоменклатуры.ТипЦен
УПОРЯДОЧИТЬ ПО
РегистрВложенный.Период УБЫВ)
УПОРЯДОЧИТЬ ПО
Номенклатура,
ТипЦен,
Период УБЫВ
Выясним причину, почему этот способ крайне не рекомендуется использовать в рабочих базах данных (особенно высоконагруженных). Проблема заключается в том, что данный подзапрос является коррелированным (зависимым). Это означает, что для каждой строки из основной таблицы ЦеныНоменклатуры СУБД будет вынуждена выполнять внутренний подзапрос с поиском первых двух записей и сортировкой по убыванию периода.
Если в таблице регистра содержится всего несколько сотен строк, разница будет незаметна. Но если записей десятки тысяч или миллионы, этот запрос гарантированно приведет к критическому падению производительности, "зависанию" СУБД и возникновению избыточных транзакционных блокировок. Особенно сильно это проявляется на СУБД PostgreSQL, оптимизатор которой не всегда может преобразовать такие вложенные циклы в эффективное соединение.
Интересный альтернативный подход, который задействует механизмы встроенных виртуальных таблиц 1С. Метод позволяет получить предпоследнее значение, исключив из выборки результат первого среза последних.
Разберем этот алгоритм по шагам:
СрезПоследних на нужную дату. Получаем актуальные записи и помещаем их во временную таблицу ВТ_Срез1.СрезПоследних, но в параметрах этой виртуальной таблицы (в секции условий) накладываем фильтр, который исключает записи, попавшие в ВТ_Срез1. Мы сверяем комбинации ключевых полей и периодов.Посмотрим на пример реализации данного метода:
// Шаг 1. Получаем первый (актуальный) срез последних
ВЫБРАТЬ
СрезПоследних1.Номенклатура КАК Номенклатура,
СрезПоследних1.Период КАК Период,
СрезПоследних1.Цена КАК Цена
ПОМЕСТИТЬ ВТ_Срез1
ИЗ
РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период, ) КАК СрезПоследних1
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 2. Получаем второй срез последних, исключая данные первого среза
ВЫБРАТЬ
СрезПоследних2.Номенклатура КАК Номенклатура,
СрезПоследних2.Период КАК Период,
СрезПоследних2.Цена КАК Цена
ПОМЕСТИТЬ ВТ_Срез2
ИЗ
РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период,
НЕ (Номенклатура, Период) В (ВЫБРАТЬ ВТ_Срез1.Номенклатура, ВТ_Срез1.Период ИЗ ВТ_Срез1)) КАК СрезПоследних2
;
////////////////////////////////////////////////////////////////////////////////
// Шаг 3. Соединяем результаты для получения итоговой строки с двумя ценами
ВЫБРАТЬ
ВТ_Срез1.Номенклатура КАК Номенклатура,
ВТ_Срез1.Цена КАК ТекущаяЦена,
ВТ_Срез1.Период КАК ПериодТекущейЦены,
ВТ_Срез2.Цена КАК ПредыдущаяЦена,
ВТ_Срез2.Период КАК ПериодПредыдущейЦены
ИЗ
ВТ_Срез1 КАК ВТ_Срез1
ЛЕВОЕ СОЕДИНЕНИЕ ВТ_Срез2 КАК ВТ_Срез2
ПО ВТ_Срез1.Номенклатура = ВТ_Срез2.Номенклатура
Этот метод работает быстрее ручных группировок, так как платформа 1С на уровне СУБД генерирует подзапросы к физическим таблицам с использованием оптимизированных алгоритмов построения среза последних. Однако, стоит учитывать, что конструкция НЕ IN (ВЫБРАТЬ...) также может замедлить работу на СУБД PostgreSQL, если объем исключаемых записей во временной таблице очень велик.
Если задача получения текущего и предыдущего значения выполняется в системе непрерывно (например, при заполнении каждой строки в табличной части документов продаж нам крайне важно быстро выводить старую цену для контроля наценки), то постоянное выполнение тяжелых запросов с соединениями перегрузит СУБД. В этом случае лучшим выходом будет изменение самой структуры хранения данных — денормализация регистра.
Реализуем этот подход по шагам:
ПредыдущееЗначение (или ПредыдущаяЦена).ПередЗаписью или ПриЗаписи. При записи новой строки в регистр мы программно считываем текущее актуальное значение из базы данных и автоматически записываем его в ресурс ПредыдущееЗначение новой строки.Ниже представлен пример кода, который можно разместить в модуле набора записей регистра сведений для автоматического заполнения предыдущего значения:
Процедура ПередЗаписью(Отказ, Замещение)
Для Каждого СтрокаЗаписи Из ЭтотОбъект Цикл
// Получаем последнее значение, которое было в базе до текущей записи
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ ПЕРВЫЕ 1
| Цены.Цена КАК Цена
|ИЗ
| РегистрСведений.ЦеныНоменклатуры КАК Цены
|ГДЕ
| Цены.Номенклатура = &Номенклатура
| И Цены.ТипЦен = &ТипЦен
| И Цены.Период < &Период
|УПОРЯДОЧИТЬ ПО
| Цены.Период УБЫВ";
Запрос.УстановитьПараметр("Номенклатура", СтрокаЗаписи.Номенклатура);
Запрос.УстановитьПараметр("ТипЦен", СтрокаЗаписи.ТипЦен);
Запрос.УстановитьПараметр("Период", СтрокаЗаписи.Период);
Выборка = Запрос.Выполнить().Выбрать();
Если Выборка.Следующий() Тогда
СтрокаЗаписи.ПредыдущаяЦена = Выборка.Цена;
Иначе
СтрокаЗаписи.ПредыдущаяЦена = 0;
EndIf;
КонецЦикла;
КонецПроцедуры
Благодаря такому подходу, сложность получения двух последних значений сводится к минимуму. Нам больше не нужно строить тяжелые пакеты запросов и соединять временные таблицы. Достаточно выполнить один простой запрос к стандартной виртуальной таблице СрезПоследних. В результате в одной строке нам вернутся сразу оба значения:
ВЫБРАТЬ
СрезПоследних.Номенклатура КАК Номенклатура,
СрезПоследних.Цена КАК АктуальнаяЦена,
СрезПоследних.ПредыдущаяЦена КАК ПредыдущаяЦена
ИЗ
РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период, ) КАК СрезПоследних
Преимущества: Скорость выборки возрастает в разы, нагрузка на СУБД сводится практически к нулю (сложность запроса $O(1)$). Запрос к виртуальной таблице работает мгновенно.
Недостатки: Избыточность хранения информации (тратится больше дискового пространства), а также необходимость пересчета исторических данных, если они вносятся задним числом или загружаются напрямую в базу в обход объектной техники 1С.
Если задача получения двух последних записей стоит перед нами в контексте построения отчета, то перегружать SQL-запрос сложной логикой вообще нет необходимости. В Системе компоновки данных предусмотрены мощные встроенные функции для анализа смещения строк.
Мы можем использовать функцию СКД ВычислитьВыражение(). С ее помощью можно обратиться к значению поля из предыдущей строки группировки отчета. Для этого в схеме компоновки данных на вкладке "Вычисляемые поля" мы добавляем новое поле и в колонке "Выражение" пишем следующую конструкцию:
ВычислитьВыражение("Цена", "Период", "Группировка", "Предыдущая")
Этот способ позволяет выводить в отчете детальные записи, где в каждой строке рядом с текущим значением будет автоматически отображаться предыдущее, без написания сложных многоуровневых запросов к базе данных. СКД выполнит эти вычисления на локальном уровне (в оперативной памяти компьютера клиента или сервера приложений), разгружая сервер СУБД.
Подведем итог и определим, какой из инструментов лучше всего использовать в различных практических ситуациях: