Как получить срез двух последних записей в регистре сведений 1С?

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

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

Сложность этой задачи в рамках 1С заключается в технических ограничениях языка запросов платформы. В отличие от стандартного SQL, в языке запросов 1С отсутствуют аналитические (оконные) функции, такие как ROW_NUMBER(), LAG(), LEAD() и конструкции OVER (PARTITION BY...), которые позволили бы решить эту задачу в один простой прием. Разработчикам приходится эмулировать этот функционал другими путями. В этой статье мы подробно разберем несколько практических способов реализации этой задачи, проанализируем их плюсы, минусы и влияние на производительность СУБД.

Метод 1. Использование временных таблиц с последовательным поиском максимума (Рекомендуемый)

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

Идея метода заключается в пошаговом получении срезов:

  1. Сначала мы выбираем максимальный период для каждого ключевого разреза (например, для сотрудника или номенклатуры) на требуемую дату. Это дает нам период последней (актуальной) записи. Результат мы сохраняем в первую временную таблицу.
  2. Затем мы находим максимальный период из оставшихся записей регистра, которые строго меньше полученного на первом шаге периода. Это дает нам период предпоследней записи. Этот результат мы сохраняем во вторую временную таблицу.
  3. В финальном запросе пакета мы соединяем обе временные таблицы с физической таблицей регистра сведений для получения самих значений (ресурсов регистра).

Проанализируем этот алгоритм на примере регистра сведений КадроваяИсторияСотрудников. Нам нужно получить последнее и предпоследнее подразделение, в котором работал сотрудник:


// Шаг 1. Получаем период последней записи (актуальный срез)
ВЫБРАТЬ
    КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
    МАКСИМУМ(КадроваяИсторияСотрудников.Период) КАК Период
ПОМЕСТИТЬ ВТ_СрезПоследних
ИЗ
    РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
ГДЕ
    КадроваяИсторияСотрудников.Период <= &Период
СГРУППИРОВАТЬ ПО
    КадроваяИсторияСотрудников.Сотрудник
;

////////////////////////////////////////////////////////////////////////////////
// Шаг 2. Получаем период предпоследней записи
ВЫБРАТЬ
    КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
    МАКСИМУМ(КадроваяИсторияСотрудников.Период) КАК Период
ПОМЕСТИТЬ ВТ_СрезПредпоследних
ИЗ
    ВТ_СрезПоследних КАК ВТ_СрезПоследних
    ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
        ПО ВТ_СрезПоследних.Сотрудник = КадроваяИсторияСотрудников.Сотрудник
        И ВТ_СрезПоследних.Период > КадроваяИсторияСотрудников.Период
СГРУППИРОВАТЬ ПО
    КадроваяИсторияСотрудников.Сотрудник
;

////////////////////////////////////////////////////////////////////////////////
// Шаг 3. Соединяем периоды с физической таблицей для получения данных
ВЫБРАТЬ
    КадроваяИсторияСотрудников.Сотрудник КАК Сотрудник,
    ВЫБОР
        КОГДА НЕ ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL
            ТОГДА КадроваяИсторияСотрудников.Подразделение
        ИНАЧЕ NULL
    КОНЕЦ КАК ПоследнееПодразделение,
    ВЫБОР
        КОГДА НЕ ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL
            ТОГДА КадроваяИсторияСотрудников.Период
        ИНАЧЕ NULL
    КОНЕЦ КАК ПериодПоследнегоПодразделения,
    ВЫБОР
        КОГДА НЕ ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL
            ТОГДА КадроваяИсторияСотрудников.Подразделение
        ИНАЧЕ NULL
    КОНЕЦ КАК ПредпоследнееПодразделение,
    ВЫБОР
        КОГДА НЕ ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL
            ТОГДА КадроваяИсторияСотрудников.Период
        ИНАЧЕ NULL
    КОНЕЦ КАК ПериодПредпоследнегоПодразделения
ИЗ
    РегистрСведений.КадроваяИсторияСотрудников КАК КадроваяИсторияСотрудников
    ЛЕВОЕ СОЕДИНЕНИЕ ВТ_СрезПоследних КАК ВТ_СрезПоследних
        ПО КадроваяИсторияСотрудников.Сотрудник = ВТ_СрезПоследних.Сотрудник
        И КадроваяИсторияСотрудников.Период = ВТ_СрезПоследних.Period
    ЛЕВОЕ СОЕДИНЕНИЕ ВТ_СрезПредпоследних КАК ВТ_СрезПредпоследних
        ПО КадроваяИсторияСотрудников.Сотрудник = ВТ_СрезПредпоследних.Сотрудник
        И КадроваяИсторияСотрудников.Период = ВТ_СрезПредпоследних.Период
ГДЕ
    НЕ (ВТ_СрезПоследних.Сотрудник ЕСТЬ NULL И ВТ_СрезПредпоследних.Сотрудник ЕСТЬ NULL)

Преимущества метода: Высокая и стабильная скорость работы даже на таблицах с миллионами записей. Оптимизаторы СУБД (MS SQL, PostgreSQL) отлично строят план запроса для таких конструкций, эффективно задействуя индексы по измерениям и периоду.

Недостатки метода: Текст запроса получается объемным, требует аккуратности при написании связей и усложняет чтение кода.

Метод 2. Использование конструкции "ПЕРВЫЕ 2" в коррелированном подзапросе

Этот подход привлекает разработчиков своей лаконичностью. Код выглядит просто и изящно, однако он таит в себе скрытую угрозу для производительности всей системы.

Разберем пример запроса, выбирающего две последние цены для каждой номенклатуры:


ВЫБРАТЬ
    ЦеныНоменклатуры.Номенклатура КАК Номенклатура,
    ЦеныНоменклатуры.ТипЦен КАК ТипЦен,
    ЦеныНоменклатуры.Период КАК Период,
    ЦеныНоменклатуры.Цена КАК Цена
ИЗ
    РегистрСведений.ЦеныНоменклатуры КАК ЦеныНоменклатуры
ГДЕ
    ЦеныНоменклатуры.Период В
        (ВЫБРАТЬ ПЕРВЫЕ 2 
            РегистрВложенный.Период
        ИЗ
            РегистрСведений.ЦеныНоменклатуры КАК РегистрВложенный
        ГДЕ
            РегистрВложенный.Номенклатура = ЦеныНоменклатуры.Номенклатура
            И РегистрВложенный.ТипЦен = ЦеныНоменклатуры.ТипЦен
        УПОРЯДОЧИТЬ ПО
            РегистрВложенный.Период УБЫВ)
УПОРЯДОЧИТЬ ПО
    Номенклатура,
    ТипЦен,
    Период УБЫВ

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

Если в таблице регистра содержится всего несколько сотен строк, разница будет незаметна. Но если записей десятки тысяч или миллионы, этот запрос гарантированно приведет к критическому падению производительности, "зависанию" СУБД и возникновению избыточных транзакционных блокировок. Особенно сильно это проявляется на СУБД PostgreSQL, оптимизатор которой не всегда может преобразовать такие вложенные циклы в эффективное соединение.

Метод 3. Двойной срез последних с исключением результатов

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

Разберем этот алгоритм по шагам:

  1. Мы делаем первый запрос к виртуальной таблице СрезПоследних на нужную дату. Получаем актуальные записи и помещаем их во временную таблицу ВТ_Срез1.
  2. Для получения предпоследних значений мы пишем второй запрос к виртуальной таблице СрезПоследних, но в параметрах этой виртуальной таблицы (в секции условий) накладываем фильтр, который исключает записи, попавшие в ВТ_Срез1. Мы сверяем комбинации ключевых полей и периодов.
  3. Объединяем или связываем полученные таблицы в единый результат.

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


// Шаг 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, если объем исключаемых записей во временной таблице очень велик.

Метод 4. Денормализация данных (Архитектурное решение)

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

Реализуем этот подход по шагам:

  1. Добавим в структуру регистра сведений новый ресурс — ПредыдущееЗначение (или ПредыдущаяЦена).
  2. В модуле набора записей регистра сведений напишем обработчик события ПередЗаписью или ПриЗаписи. При записи новой строки в регистр мы программно считываем текущее актуальное значение из базы данных и автоматически записываем его в ресурс ПредыдущееЗначение новой строки.

Ниже представлен пример кода, который можно разместить в модуле набора записей регистра сведений для автоматического заполнения предыдущего значения:


Процедура ПередЗаписью(Отказ, Замещение)
    
    Для Каждого СтрокаЗаписи Из ЭтотОбъект Цикл
        
        // Получаем последнее значение, которое было в базе до текущей записи
        Запрос = Новый Запрос;
        Запрос.Текст = 
            "ВЫБРАТЬ ПЕРВЫЕ 1
            |    Цены.Цена КАК Цена
            |ИЗ
            |    РегистрСведений.ЦеныНоменклатуры КАК Цены
            |ГДЕ
            |    Цены.Номенклатура = &Номенклатура
            |    И Цены.ТипЦен = &ТипЦен
            |    И Цены.Период < &Период
            |УПОРЯДОЧИТЬ ПО
            |    Цены.Период УБЫВ";
            
        Запрос.УстановитьПараметр("Номенклатура", СтрокаЗаписи.Номенклатура);
        Запрос.УстановитьПараметр("ТипЦен", СтрокаЗаписи.ТипЦен);
        Запрос.УстановитьПараметр("Период", СтрокаЗаписи.Период);
        
        Выборка = Запрос.Выполнить().Выбрать();
        Если Выборка.Следующий() Тогда
            СтрокаЗаписи.ПредыдущаяЦена = Выборка.Цена;
        Иначе
            СтрокаЗаписи.ПредыдущаяЦена = 0;
        EndIf;
        
    КонецЦикла;
    
КонецПроцедуры

Благодаря такому подходу, сложность получения двух последних значений сводится к минимуму. Нам больше не нужно строить тяжелые пакеты запросов и соединять временные таблицы. Достаточно выполнить один простой запрос к стандартной виртуальной таблице СрезПоследних. В результате в одной строке нам вернутся сразу оба значения:


ВЫБРАТЬ
    СрезПоследних.Номенклатура КАК Номенклатура,
    СрезПоследних.Цена КАК АктуальнаяЦена,
    СрезПоследних.ПредыдущаяЦена КАК ПредыдущаяЦена
ИЗ
    РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период, ) КАК СрезПоследних

Преимущества: Скорость выборки возрастает в разы, нагрузка на СУБД сводится практически к нулю (сложность запроса $O(1)$). Запрос к виртуальной таблице работает мгновенно.

Недостатки: Избыточность хранения информации (тратится больше дискового пространства), а также необходимость пересчета исторических данных, если они вносятся задним числом или загружаются напрямую в базу в обход объектной техники 1С.

Метод 5. Использование возможностей Системы компоновки данных (СКД)

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

Мы можем использовать функцию СКД ВычислитьВыражение(). С ее помощью можно обратиться к значению поля из предыдущей строки группировки отчета. Для этого в схеме компоновки данных на вкладке "Вычисляемые поля" мы добавляем новое поле и в колонке "Выражение" пишем следующую конструкцию:

ВычислитьВыражение("Цена", "Период", "Группировка", "Предыдущая")

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

Резюме и рекомендации по выбору решения

Подведем итог и определим, какой из инструментов лучше всего использовать в различных практических ситуациях:

  1. Для решения повседневных задач программирования и построения сложных запросов внутри алгоритмов конфигурации используйте Метод 1 (Временные таблицы с поиском максимума). Это наиболее стабильный и отказоустойчивый вариант для СУБД.
  2. Для построения пользовательских отчетов и вывода истории изменений на печать всегда отдавайте приоритет Методу 5 (Функции СКД). Это сэкономит ваше время и ресурсы сервера СУБД.
  3. Если вы проектируете архитектуру крупной высоконагруженной системы (например, крупный ритейл со сложным ценообразованием или систему KPI для тысяч сотрудников), рассмотрите возможность внедрения Метода 4 (Денормализация данных). Это существенно повысит масштабируемость системы при критических нагрузках.
  4. Избегайте использования Метода 2 (Коррелированный подзапрос ПЕРВЫЕ 2) на любых реальных таблицах с данными. Данный подход допустим только в учебных целях или для быстрого тестирования на небольших базах разработки.
← На главную