Как правильно реализовать сдвиг записей вверх и вниз в 1С без искажения времени?

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

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

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

В чем заключается основная проблема и где ошибаются разработчики?

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

  1. Использование времени в качестве индекса порядка. Системное время (дата и время в 1С имеют точность до 1 секунды) предназначено для фиксации физического момента события, а не для задания пользовательского порядка отображения. Пытаясь «подогнать» время записей под желаемое визуальное расположение, мы искажаем реальные данные хронологии.
  2. Проблема «плотности» записей. Если у нас есть несколько записей на одно и то же время (например, три задачи, запланированные строго на 10:00:00), то при попытке сдвинуть одну из них нам приходится искусственно прибавлять или отнимать секунды. Если следом за ними идут задачи на 10:00:01 и 10:00:02, возникает цепная реакция: сдвиг одной записи лавинообразно вынуждает переписывать время у десятков последующих объектов. Это порождает рекурсию, которая грузит базу данных и может приводить к ошибкам превышения времени транзакции.
  3. Выход за границы дня. При активном использовании сдвигов на границах суток (например, в районе 23:59:50) задачи при смещении «вниз» могут незаметно уехать на следующий день. Для дневника это критично, так как запись визуально пропадает из текущего дня.

Рассмотрим более детально, как можно элегантно и надежно решить эту задачу в рамках платформы 1С.

Решение №1: Использование выделенного реквизита «Порядок» (Рекомендуемый подход)

Проанализируем наиболее красивый и архитектурно верный способ, который применяется в профессиональной разработке. Чтобы полностью избавиться от манипуляций со временем и исключить риски искажения данных, мы разделим понятия «Время события» и «Порядок отображения».

Для этого добавим в наш справочник Дневник (или регистр сведений) новый реквизит Порядок с типом Число (длина 10, точность 0, неотрицательное). Теперь сортировка записей в запросе динамического списка или отчета будет строиться по двум полям: сначала по дате, затем по порядку.

Запрос выборки данных для интерфейса примет следующий вид:


ВЫБРАТЬ
	Дневник.Ссылка КАК Задача,
	Дневник.Дата КАК Дата,
	Дневник.Порядок КАК Порядок
ИЗ
	Справочник.Дневник КАК Дневник
ГДЕ
	Дневник.Дата МЕЖДУ НАЧАЛОПЕРИОДА(&Дата, ДЕНЬ) И КОНЕЦПЕРИОДА(&Дата, ДЕНЬ)
УПОРЯДОЧИТЬ ПО
	Дата,
	Порядок

При таком подходе алгоритм создания новой записи становится очень простым: мы находим максимальное значение реквизита Порядок за текущий день и присваиваем новой задаче значение МаксПорядок + 1.

Давайте посмотрим на пример реализации процедуры сдвига при использовании реквизита Порядок. Нам больше не нужно перебирать всю таблицу значений и рекурсивно двигать все записи дня. Достаточно поменять местами значения порядка у текущей задачи и её ближайшего соседа сверху или снизу.

Разберем по шагам код процедуры сдвига:


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

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

Решение №2: Линейный алгоритм «разрежения» шкалы времени (без изменения метаданных)

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

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

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

Давайте посмотрим на практическую программную реализацию этого алгоритма:


Процедура СдвинутьЗаписьБезНовыхРеквизитов(ВыбраннаяЗадача, Направление) Экспорт
	// Направление: -1 = вверх, 1 = вниз
	
	ИсхДата = ВыбраннаяЗадача.Дата;
	НачалоДня = НачалоДня(ИсхДата);
	КонецДня = КонецДня(ИсхДата);
	
	// Шаг 1. Получаем все записи за день
	Запрос = Новый Запрос(
		"ВЫБРАТЬ
		|	Дневник.Ссылка КАК Задача,
		|	Дневник.Дата КАК Дата,
		|	Дневник.ТочноеВремя КАК ТочноеВремя,
		|	Дневник.БезВремени КАК БезВремени
		|ИЗ
		|	Справочник.Дневник КАК Дневник
		|ГДЕ
		|	Дневник.Дата МЕЖДУ &НачалоДня И &КонецДня
		|УПОРЯДОЧИТЬ ПО
		|	Дата"
	);
	Запрос.УстановитьПараметр("НачалоДня", НачалоДня);
	Запрос.УстановитьПараметр("КонецДня", КонецДня);
	
	ТЗ = Запрос.Выполнить().Выгрузить();
	ТЗ.Колонки.Добавить("НоваяДата", Новый ОписаниеТипов("Дата"));
	
	// Заполняем колонку с новыми датами исходными значениями
	Для Каждого СтрокаТЗ Из ТЗ Цикл
		Если СтрокаТЗ.БезВремени Тогда
			СтрокаТЗ.НоваяДата = НачалоДня; // Задачи без времени принудительно прижимаем к началу дня
		Иначе
			СтрокаТЗ.НоваяДата = СтрокаТЗ.Дата;
		EndЕсли;
	КонецЦикла;
	
	// Шаг 2. Линейное разрежение временной шкалы (убираем дублирование времени)
	ВсегоЗаписей = ТЗ.Количество();
	Для Инд = 1 По ВсегоЗаписей - 1 Цикл
		ПредСтрока = ТЗ[Инд - 1];
		ТекущаяСтрока = ТЗ[Инд];
		
		Если ТекущаяСтрока.НоваяДата <= ПредСтрока.НоваяДата Тогда
			// Если текущая запись не привязана к точному времени, смещаем ее вперед
			Если Не ТекущаяСтрока.ТочноеВремя Тогда
				ТекущаяСтрока.НоваяДата = ПредСтрока.НоваяДата + 1; // Добавляем 1 секунду
			КонецЕсли;
		КонецЕсли;
	КонецЦикла;
	
	// Проверим, не вышли ли мы за границу конца дня при разрежении вправо
	ПоследняяСтрока = ТЗ[ВсегоЗаписей - 1];
	Если ПоследняяСтрока.НоваяДата > КонецДня Тогда
		// Произошло переполнение суток, производим разрежение в обратную сторону (влево)
		Для Инд = ВсегоЗаписей - 1 Исход По 1 Шаг -1 Цикл
			СледСтрока = ТЗ[Инд];
			ТекущаяСтрока = ТЗ[Инд - 1];
			Если ТекущаяСтрока.НоваяДата >= СледСтрока.НоваяДата И Не ТекущаяСтрока.ТочноеВремя Тогда
				ТекущаяСтрока.НоваяДата = СледСтрока.НоваяДата - 1;
			КонецЕсли;
		КонецЦикла;
	КонецЕсли;
	
	// Шаг 3. Поиск нашей задачи и перестановка (Swap)
	ИндексЗадачи = Неопределено;
	Для Инд = 0 По ВсегоЗаписей - 1 Цикл
		Если ТЗ[Инд].Задача = ВыбраннаяЗадача Тогда
			ИндексЗадачи = Инд;
			Прервать;
		КонецЕсли;
	КонецЦикла;
	
	Если ИндексЗадачи = Неопределено Тогда
		Возврат; // Задача не найдена в текущей таблице дня
	КонецЕсли;
	
	НовыйИндекс = ИндексЗадачи + Направление;
	
	// Проверяем корректность выхода за границы коллекции
	Если НовыйИндекс >= 0 И НовыйИндекс < ВсегоЗаписей Тогда
		
		ТекущаяСтрока = ТЗ[ИндексЗадачи];
		СоседняяСтрока = ТЗ[НовыйИндекс];
		
		// Обмениваемся временными метками
		ВременнаяДата = ТекущаяСтрока.НоваяДата;
		ТекущаяСтрока.НоваяДата = СоседняяСтрока.НоваяДата;
		СоседняяСтрока.НоваяДата = ВременнаяДата;
		
		// Шаг 4. Записываем изменения в базу данных
		НачатьТранзакцию();
		Попытка
			Для Каждого СтрокаТЗ Из ТЗ Цикл
				// Записываем только те объекты, у которых реально изменилось время
				Если СтрокаТЗ.Дата <> СтрокаТЗ.НоваяДата Тогда
					ОбъектЗадачи = СтрокаТЗ.Задача.ПолучитьОбъект();
					ОбъектЗадачи.Дата = СтрокаТЗ.НоваяДата;
					ОбъектЗадачи.Записать();
				КонецЕсли;
			КонецЦикла;
			ЗафиксироватьТранзакцию();
		Исключение
			ОтменитьТранзакцию();
			ВызватьИсключение КраткоеПредставлениеОшибки(ИнформацияОбОшибке());
		КонецПопытки;
		
	КонецЕсли;
	
КонецПроцедуры

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

Рекомендации по оптимизации работы с базой данных

Независимо от выбранного вами метода, важно соблюдать правила оптимизации при записи данных в 1С:

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

← На главную