Срез последних на каждую дату — это задача, когда значение периодического регистра сведений нужно получить не на одну общую дату, а на свою дату для каждой строки: на дату документа, на годовщину приёма, на каждый день периода. Стандартная виртуальная таблица СрезПоследних принимает только одну дату, поэтому задачу решают либо «классическим» запросом через группировку по максимальному периоду, либо «интервальным» — превращая точечные записи регистра в интервалы «с даты по дату» и соединяясь с ними по условию МЕЖДУ.

Второй подход в 1С:ЗУП вырос в отдельный механизм — интервальные регистры: интервалы хранятся во вспомогательном регистре и обновляются при записи основного. Ниже разберём оба запроса на простом примере, посмотрим, как устроен механизм ЗУП, и сделаем собственный интервальный регистр.

Модельный пример и стандартные средства платформы

Возьмём периодический регистр сведений КадроваяИстория, подчинённый регистратору:

ПолеВидТип
ПериодстандартныйДата
РегистраторстандартныйДокументСсылка.ПеремещениеВПодразделение
СотрудникизмерениеСправочникСсылка.Сотрудники
ПодразделениересурсСправочникСсылка.Подразделения

У справочника Сотрудники есть реквизит ДатаРождения.

Пока дата одна, платформа даёт всё необходимое:

1С
МенеджерРегистра = РегистрыСведений.КадроваяИстория;
ОтборПоСотруднику = Новый Структура("Сотрудник", Сотрудник);

// Действующее значение на дату — последняя запись с периодом не позже даты.
ДанныеНаДату = МенеджерРегистра.ПолучитьПоследнее(ДатаСреза, ОтборПоСотруднику);
Подразделение = ДанныеНаДату.Подразделение;

// Срез по всем сотрудникам на одну дату.
ТаблицаСреза = МенеджерРегистра.СрезПоследних(ДатаСреза);

Метод Получить возвращает запись ровно на указанный период, ПолучитьПоследнее — действующую на дату. В запросах тот же смысл у виртуальной таблицы:

Запрос 1С
ВЫБРАТЬ
	КадроваяИсторияСрез.Сотрудник КАК Сотрудник,
	КадроваяИсторияСрез.Подразделение КАК Подразделение
ИЗ
	РегистрСведений.КадроваяИстория.СрезПоследних(&ДатаСреза, ) КАК КадроваяИсторияСрез

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

Классический вариант: максимум периода не позже даты

Идея: для каждой пары «сотрудник — дата среза» найти максимальный период записи регистра, не превышающий эту дату, и затем взять запись с этим периодом.

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

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

////////////////////////////////////////////////////////////////////////////////
// 3. Значение ресурса на найденный период.
ВЫБРАТЬ
	ПоследниеПериоды.Сотрудник КАК Сотрудник,
	ПоследниеПериоды.ДатаСреза КАК ДатаСреза,
	История.Подразделение КАК Подразделение
ИЗ
	ВТПоследниеПериоды КАК ПоследниеПериоды
		ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.КадроваяИстория КАК История
		ПО ПоследниеПериоды.Сотрудник = История.Сотрудник
			И ПоследниеПериоды.Период = История.Период

Важно, что соединение во втором пакете идёт и по сотруднику, и по условию на период: без равенства по измерению запрос вернёт чепуху. Если сотрудник родился 29 февраля, ДОБАВИТЬКДАТЕ в невисокосный год даст 28 февраля — это стоит учесть в требованиях. Сотрудники, у которых на дату ещё нет записей, во внутреннее соединение не попадут; если они нужны в результате, используйте левое соединение от ВТДатыСреза.

Интервальный вариант: превращаем записи в отрезки

Второй подход сначала строит таблицу интервалов: для каждой записи регистра — дата начала (её период) и дата окончания (период следующей записи того же сотрудника минус секунда). Дальше любой срез — это одно соединение по условию ДатаСреза МЕЖДУ ДатаНачала И ДатаОкончания.

Точечные записи периодического регистра превращаются в непрерывные интервалы
Точечные записи периодического регистра превращаются в непрерывные интервалы

Чтобы не тянуть всю историю, интервалы строим только для нужного окна &НачалоОкна — &КонецОкна, а состояние на начало окна берём срезом:

Запрос 1С
// 1. Состояние на начало окна плюс все изменения внутри окна.
ВЫБРАТЬ
	СрезНаНачало.Сотрудник КАК Сотрудник,
	СрезНаНачало.Период КАК Период,
	СрезНаНачало.Подразделение КАК Подразделение
ПОМЕСТИТЬ ВТИзменения
ИЗ
	РегистрСведений.КадроваяИстория.СрезПоследних(&НачалоОкна, ) КАК СрезНаНачало

ОБЪЕДИНИТЬ ВСЕ

ВЫБРАТЬ
	История.Сотрудник,
	История.Период,
	История.Подразделение
ИЗ
	РегистрСведений.КадроваяИстория КАК История
ГДЕ
	История.Период > &НачалоОкна
	И История.Период <= &КонецОкна
;

////////////////////////////////////////////////////////////////////////////////
// 2. Интервал действия каждого значения: до следующего изменения минус секунда.
ВЫБРАТЬ
	Начало.Сотрудник КАК Сотрудник,
	Начало.Подразделение КАК Подразделение,
	Начало.Период КАК ДатаНачала,
	ЕСТЬNULL(ДОБАВИТЬКДАТЕ(МИНИМУМ(Следующее.Период), СЕКУНДА, -1), &КонецОкна) КАК ДатаОкончания
ПОМЕСТИТЬ ВТИнтервалы
ИЗ
	ВТИзменения КАК Начало
		ЛЕВОЕ СОЕДИНЕНИЕ ВТИзменения КАК Следующее
		ПО Начало.Сотрудник = Следующее.Сотрудник
			И Начало.Период < Следующее.Период
СГРУППИРОВАТЬ ПО
	Начало.Сотрудник,
	Начало.Подразделение,
	Начало.Период
;

////////////////////////////////////////////////////////////////////////////////
// 3. Срез на индивидуальную дату — одно соединение по интервалу.
ВЫБРАТЬ
	Сотрудники.Ссылка КАК Сотрудник,
	Интервалы.Подразделение КАК Подразделение
ИЗ
	Справочник.Сотрудники КАК Сотрудники
		ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТИнтервалы КАК Интервалы
		ПО Сотрудники.Ссылка = Интервалы.Сотрудник
			И (ДОБАВИТЬКДАТЕ(Сотрудники.ДатаРождения, ГОД,
				ГОД(&ДатаГода) - ГОД(Сотрудники.ДатаРождения))
				МЕЖДУ Интервалы.ДатаНачала И Интервалы.ДатаОкончания)

Обратите внимание: первые два пакета никак не связаны с условием задачи. Они просто готовят таблицу интервалов — и её построение легко оформить как универсальный шаблон. Именно это наблюдение и легло в основу механизма ЗУП.

Как устроены интервальные регистры в ЗУП

В зарплатно-кадровых конфигурациях интервальная таблица не строится каждый раз, а хранится. К основному периодическому регистру добавляется вспомогательный регистр с суффиксом «Интервальный»:

  • непериодический и независимый;
  • с теми же неслужебными измерениями и ресурсами, что и основной;
  • со служебными полями интервала: дата начала, дата окончания, период исходной записи и год;
  • заполняется в обработчике ПриЗаписи набора записей основного регистра;
  • функции работы с ним описаны в модуле менеджера и в общих модулях библиотеки.

Прикладной код в ЗУП обычно не обращается к интервальным регистрам напрямую. Для выборки используются функции общего модуля ЗарплатаКадрыПериодическиеРегистры: ОписаниеФильтраДляСоздатьВТИмяРегистра описывает, по каким сотрудникам и датам нужны данные, а СоздатьВТИмяРегистра и родственные функции для среза кладут результат во временную таблицу менеджера. Набор и параметры этих функций менялись от версии к версии библиотеки, поэтому перед использованием откройте их описание в своей конфигурации.

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

Собственный интервальный регистр для своего регистра

Если задача среза на каждую дату постоянно возникает в вашей конфигурации — в торговле, производстве, учёте договоров, — механизм можно воспроизвести. Создаём регистр сведений КадроваяИсторияИнтервальный: непериодический, независимый, измерения Сотрудник, ДатаНачала, ДатаОкончания, ресурс Подразделение.

Основной регистр при записи обновляет вспомогательный интервальный регистр
Основной регистр при записи обновляет вспомогательный интервальный регистр

Модуль набора записей основного регистра запоминает сотрудников до и после записи и просит пересчитать их интервалы:

1С
Процедура ПередЗаписью(Отказ, Замещение)

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

КонецПроцедуры

Процедура ПриЗаписи(Отказ, Замещение)

	// Проверку ОбменДанными.Загрузка намеренно не делаем:
	// интервалы должны быть актуальны и после загрузки из обмена.
	Сотрудники = Выгрузить(, "Сотрудник").ВыгрузитьКолонку("Сотрудник");
	Для Каждого Сотрудник Из ДополнительныеСвойства.СотрудникиДоЗаписи Цикл
		Если Сотрудники.Найти(Сотрудник) = Неопределено Тогда
			Сотрудники.Добавить(Сотрудник);
		КонецЕсли;
	КонецЦикла;

	РегистрыСведений.КадроваяИсторияИнтервальный.ПерестроитьИнтервалы(Сотрудники);

КонецПроцедуры

В модуле менеджера интервального регистра — пересчёт по полной истории выбранных сотрудников:

1С
#Область ПрограммныйИнтерфейс

// Пересчитывает интервалы действия значений для указанных сотрудников.
//
// Параметры:
//  Сотрудники - Массив из СправочникСсылка.Сотрудники
//
Процедура ПерестроитьИнтервалы(Сотрудники) Экспорт

	Если Сотрудники.Количество() = 0 Тогда
		Возврат;
	КонецЕсли;

	Запрос = Новый Запрос;
	Запрос.УстановитьПараметр("Сотрудники", Сотрудники);
	Запрос.Текст =
		"ВЫБРАТЬ
		|	История.Сотрудник КАК Сотрудник,
		|	История.Период КАК Период,
		|	История.Подразделение КАК Подразделение
		|ПОМЕСТИТЬ ВТИстория
		|ИЗ
		|	РегистрСведений.КадроваяИстория КАК История
		|ГДЕ
		|	История.Сотрудник В (&Сотрудники)
		|;
		|
		|////////////////////////////////////////////////////////////////////////////////
		|ВЫБРАТЬ
		|	Начало.Сотрудник КАК Сотрудник,
		|	Начало.Подразделение КАК Подразделение,
		|	Начало.Период КАК ДатаНачала,
		|	ЕСТЬNULL(ДОБАВИТЬКДАТЕ(МИНИМУМ(Следующее.Период), СЕКУНДА, -1),
		|		ДАТАВРЕМЯ(3999, 12, 31, 23, 59, 59)) КАК ДатаОкончания
		|ИЗ
		|	ВТИстория КАК Начало
		|		ЛЕВОЕ СОЕДИНЕНИЕ ВТИстория КАК Следующее
		|		ПО Начало.Сотрудник = Следующее.Сотрудник
		|			И Начало.Период < Следующее.Период
		|СГРУППИРОВАТЬ ПО
		|	Начало.Сотрудник,
		|	Начало.Подразделение,
		|	Начало.Период";

	Интервалы = Запрос.Выполнить().Выгрузить();

	Для Каждого Сотрудник Из Сотрудники Цикл

		НаборЗаписей = СоздатьНаборЗаписей();
		НаборЗаписей.Отбор.Сотрудник.Установить(Сотрудник);

		Для Каждого СтрокаИнтервала Из Интервалы.НайтиСтроки(Новый Структура("Сотрудник", Сотрудник)) Цикл
			ЗаполнитьЗначенияСвойств(НаборЗаписей.Добавить(), СтрокаИнтервала);
		КонецЦикла;

		// Пустой набор с отбором удалит интервалы сотрудника, у которого не осталось истории.
		НаборЗаписей.Записать();

	КонецЦикла;

КонецПроцедуры

#КонецОбласти

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

Запрос 1С
ВЫБРАТЬ
	ДатыСреза.Сотрудник КАК Сотрудник,
	ДатыСреза.ДатаСреза КАК ДатаСреза,
	Интервалы.Подразделение КАК Подразделение
ИЗ
	ВТДатыСреза КАК ДатыСреза
		ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.КадроваяИсторияИнтервальный КАК Интервалы
		ПО ДатыСреза.Сотрудник = Интервалы.Сотрудник
			И (ДатыСреза.ДатаСреза МЕЖДУ Интервалы.ДатаНачала И Интервалы.ДатаОкончания)

Не забудьте первичное заполнение: для уже существующих данных вызовите ПерестроитьИнтервалы порциями по всем сотрудникам — например, обработчиком обновления.

Типичные ошибки

  • Срез в цикле. ПолучитьПоследнее или СрезПоследних для каждой строки — самая частая причина медленных отчётов по кадровым данным.
  • Нет равенства по измерениям при соединении по периоду в классическом варианте — результат перемешивается между сотрудниками.
  • Перекрытие интервалов. Если в регистре несколько записей на один период и сотрудника (разные регистраторы), интервалы задублируются. Это нужно исключить на уровне данных или правил записи.
  • Граница интервала. «Минус секунда» корректна, когда период хранится с точностью до секунды; для периодичности «день» и выше удобнее вычитать день.
  • Забытые удалённые записи. При отмене проведения сотрудник исчезает из нового набора — его надо запомнить в ПередЗаписью, иначе интервалы останутся старыми.

Если отчёт по кадровым или ценовым данным заметно тормозит, причину почти всегда видно в техжурнале — это типичная находка аудита производительности 1С. Собственный интервальный механизм в нетиповой конфигурации делаю в рамках доработки 1С.

Итог

  • СрезПоследних работает на одну дату; срез на индивидуальные даты требует отдельного приёма.
  • Классический вариант — максимум периода не позже даты с соединением по измерениям, затем выбор записи по найденному периоду.
  • Интервальный вариант превращает записи в отрезки «с — по» и решает срез одним соединением через МЕЖДУ.
  • В ЗУП интервалы хранятся во вспомогательных регистрах, а выборка идёт через функции ЗарплатаКадрыПериодическиеРегистры.
  • Для своей конфигурации интервальный регистр делается за пару объектов: пересчёт в ПриЗаписи основного набора и простое соединение в запросах.