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