Как в запросе 1С или СКД получить предпоследнюю запись по каждому объекту?

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

При решении учетных задач нередко возникает необходимость сопоставить текущее и предыдущее состояние объекта: например, сравнить новый оклад сотрудника с его предыдущим тарифом, вывести изменение курса валюты относительно предшествующей даты или отследить динамику цен номенклатуры. В классическом SQL для этого применяются оконные функции LAG() или ROW_NUMBER() OVER (...), однако в языке запросов платформы 1С:Предприятие таких конструкций нет. Кроме того, язык запросов 1С не поддерживает конструкцию ВЫБРАТЬ ПЕРВЫЕ 1 внутри коррелированного подзапроса с отбором по полям внешней таблицы.

Разберем подробнее практические и производительные способы получения последней и предпоследней записи для каждого объекта.

Способ 1. Запрос на временных таблицах методом исключения максимума

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

  1. Получаем список уникальных объектов (например, сотрудников) и определяем для каждого максимальную дату среза с помощью функции МАКСИМУМ().
  2. Соединяем полученные максимальные даты с исходными данными, чтобы извлечь реквизиты последней записи (новый оклад).
  3. Формируем выборку всех записей, исключая из них последние. Для этого соединяем исходную таблицу с таблицей последних записей левым соединением и отбираем строки, где правая таблица вернула NULL.
  4. Из оставшихся строк снова находим максимум по дате для каждого объекта — эти даты гарантированно будут предпоследними в исходном наборе данных.
  5. Снова соединяем найденные предпоследние даты с исходной таблицей для получения реквизитов (старый оклад) и объединяем все в единый результат.

Посмотрим на пример текста запроса:


ВЫБРАТЬ РАЗЛИЧНЫЕ
	ВТ_Исходная.Сотрудник КАК Сотрудник
ПОМЕСТИТЬ ВТ_СписокСотрудников
ИЗ
	ВТ_Исходная КАК ВТ_Исходная
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
	ВТ_Исходная.Сотрудник КАК Сотрудник,
	МАКСИМУМ(ВТ_Исходная.Период) КАК Период
ПОМЕСТИТЬ ВТ_ПоследниеДаты
ИЗ
	ВТ_Исходная КАК ВТ_Исходная
СГРУППИРОВАТЬ ПО
	ВТ_Исходная.Сотрудник
ИНДЕКСИРОВАТЬ ПО
	Сотрудник,
	Период
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
	ВТ_Исходная.Сотрудник КАК Сотрудник,
	ВТ_Исходная.Тариф КАК Тариф,
	ВТ_Исходная.Период КАК Период
ПОМЕСТИТЬ ВТ_ПоследниеТарифы
ИЗ
	ВТ_ПоследниеДаты КАК ВТ_ПоследниеДаты
		ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_Исходная КАК ВТ_Исходная
		ПО ВТ_ПоследниеДаты.Сотрудник = ВТ_Исходная.Сотрудник
			И ВТ_ПоследниеДаты.Период = ВТ_Исходная.Период
ИНДЕКСИРОВАТЬ ПО
	Сотрудник,
	Период
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
	ВТ_Исходная.Сотрудник КАК Сотрудник,
	МАКСИМУМ(ВТ_Исходная.Период) КАК Период
ПОМЕСТИТЬ ВТ_ПредпоследниеДаты
ИЗ
	ВТ_Исходная КАК ВТ_Исходная
		ЛЕВОЕ СОЕДИНЕНИЕ ВТ_ПоследниеТарифы КАК ВТ_ПоследниеТарифы
		ПО ВТ_Исходная.Сотрудник = ВТ_ПоследниеТарифы.Сотрудник
			И ВТ_Исходная.Период = ВТ_ПоследниеТарифы.Период
ГДЕ
	ВТ_ПоследниеТарифы.Тариф ЕСТЬ NULL
СГРУППИРОВАТЬ ПО
	ВТ_Исходная.Сотрудник
ИНДЕКСИРОВАТЬ ПО
	Сотрудник,
	Период
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
	ВТ_Исходная.Сотрудник КАК Сотрудник,
	ВТ_Исходная.Тариф КАК Тариф,
	ВТ_Исходная.Период КАК Период
ПОМЕСТИТЬ ВТ_ПредпоследниеТарифы
ИЗ
	ВТ_ПредпоследниеДаты КАК ВТ_ПредпоследниеДаты
		ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_Исходная КАК ВТ_Исходная
		ПО ВТ_ПредпоследниеДаты.Сотрудник = ВТ_Исходная.Сотрудник
			И ВТ_ПредпоследниеДаты.Период = ВТ_Исходная.Период
ИНДЕКСИРОВАТЬ ПО
	Сотрудник
;

////////////////////////////////////////////////////////////////////////////////
ВЫБРАТЬ
	ВТ_СписокСотрудников.Сотрудник КАК Сотрудник,
	ЕСТЬNULL(ВТ_ПредпоследниеТарифы.Тариф, 0) КАК СтарыйТариф,
	ВТ_ПредпоследниеТарифы.Период КАК СтараяДата,
	ВТ_ПоследниеТарифы.Тариф КАК НовыйТариф,
	ВТ_ПоследниеТарифы.Период КАК НоваяДата
ИЗ
	ВТ_СписокСотрудников КАК ВТ_СписокСотрудников
		ЛЕВОЕ СОЕДИНЕНИЕ ВТ_ПредпоследниеТарифы КАК ВТ_ПредпоследниеТарифы
		ПО ВТ_СписокСотрудников.Сотрудник = ВТ_ПредпоследниеТарифы.Сотрудник
		ЛЕВОЕ СОЕДИНЕНИЕ ВТ_ПоследниеТарифы КАК ВТ_ПоследниеТарифы
		ПО ВТ_СписокСотрудников.Сотрудник = ВТ_ПоследниеТарифы.Сотрудник

Способ 2. Использование возможностей СКД (функция «ВычислитьВыражение»)

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

Разберем по шагам настройку схемы компоновки:

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

Способ 3. Двойной «СрезПоследних» для периодических регистров сведений

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

  1. Первым запросом получаем актуальный срез на текущую дату через РегистрСведений.КурсыВалют.СрезПоследних(&Период, ). В выборку обязательно включаем поле Период.
  2. Вторым шагом соединяем полученные данные с той же виртуальной таблицей СрезПоследних, но в параметре среза передаем дату, вычисленную как ДОБАВИТЬКДАТЕ(ПериодПоследнейЗаписи, СЕКУНДА, -1).

Этот способ работает быстро, так как платформа 1С использует встроенные оптимизированные механизмы получения срезов на стороне СУБД.

Способ 4. Самосоединение с подсчетом более поздних записей

Проанализируем универсальный способ нумерации строк средствами одного запроса. Метод удобен, когда нужно отобрать N-ю запись от конца:

  1. Соединяем исходную таблицу саму с собой левым соединением по условию: Т1.Сотрудник = Т2.Сотрудник И Т1.Период < Т2.Период.
  2. Группируем результат по полям таблицы Т1 и вычисляем количество: КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Т2.Период) КАК КоличествоБолееПоздних.
  3. Накладываем условие:
    • КоличествоБолееПоздних = 0 — текущая последняя запись.
    • КоличествоБолееПоздних = 1 — предпоследняя запись.

Способ 5. Обработка через ТаблицуЗначений на встроенном языке

Если данные уже выгружены в объект ТаблицаЗначений, программный обход часто выполняется быстрее сложных множественных соединений на стороне СУБД:


// Сортируем таблицу: сначала сотрудник, затем даты по убыванию
ТЗ_Данные.Сортировать("Сотрудник Возр, Период Убыв");

ТЗ_Результат = Новый ТаблицаЗначений;
ТЗ_Результат.Колонки.Добавить("Сотрудник");
ТЗ_Результат.Колонки.Добавить("НовыйТариф");
ТЗ_Результат.Колонки.Добавить("НоваяДата");
ТЗ_Результат.Колонки.Добавить("СтарыйТариф");
ТЗ_Результат.Колонки.Добавить("СтараяДата");

ТекущийСотрудник = Неопределено;
СчетчикЗаписей = 0;
СтрокаРезультата = Неопределено;

Для Каждого Стр Из ТЗ_Данные Цикл
	Если Стр.Сотрудник <> ТекущийСотрудник Тогда
		ТекущийСотрудник = Стр.Сотрудник;
		СчетчикЗаписей = 1;
		
		СтрокаРезультата = ТЗ_Результат.Добавить();
		СтрокаРезультата.Сотрудник = Стр.Сотрудник;
		СтрокаРезультата.НовыйТариф = Стр.Тариф;
		СтрокаРезультата.НоваяДата = Стр.Период;
	Иначе
		СчетчикЗаписей = СчетчикЗаписей + 1;
		Если СчетчикЗаписей = 2 Тогда
			СтрокаРезультата.СтарыйТариф = Стр.Тариф;
			СтрокаРезультата.СтараяДата = Стр.Период;
		КонецЕсли;
	КонецЕсли;
КонецЦикла;

Подводные камни и важные нюансы оптимизации

При реализации запросов обратите внимание на следующие моменты:

  1. Индексация во временных таблицах: При использовании Способа 1 обязательно указывайте директиву ИНДЕКСИРОВАТЬ ПО для полей связи (Сотрудник, Период). Без индексов СУБД будет выполнять полное сканирование (Table Scan), что приведет к замедлению работы на больших объемах данных.
  2. Обработка отсутствия предыдущих записей: Если по сотруднику была зарегистрирована всего одна запись за всю историю, предпоследнее значение вернет NULL. Всегда используйте ЛЕВОЕ СОЕДИНЕНИЕ и функцию ЕСТЬNULL(), чтобы такие объекты не исчезли из итоговой выборки.
  3. Коллизии дат: Если у одного объекта возможно появление двух записей с абсолютно одинаковым периодом (например, в пределах одного дня или секунды), соединение только по дате приведет к дублированию строк. В таких ситуациях в условия группировок и соединений необходимо добавлять дополнительное уникальное поле (например, Регистратор или НомерСтроки).
← На главную