Разработка механизмов аудита и логирования изменений в документах — частая задача в практике программиста 1С. Особенно остро этот вопрос встает, когда руководству или главному бухгалтеру требуется видеть детальную историю: кто, когда и что именно изменил в табличной части (ТЧ) документа (например, в «Заказе покупателя»).
В этой статье мы подробно разберем, почему популярная попытка сохранять историю через регистры накопления является грубой ошибкой, как правильно спроектировать структуру хранения данных на базе регистра сведений и как использовать штатные механизмы Библиотеки Стандартных Подсистем (БСП) или платформы 1С для получения красивого отчета «в один клик».
Попытка использовать регистр накопления для фиксации изменений (как это показано в исходном вопросе автора темы) сталкивается с фундаментальным поведением платформы 1С:
Выяснив причину неудачи с регистрами накопления, перейдем к разбору правильных архитектурных решений.
Если мы хотим записывать историю изменений вручную, то оптимальным решением будет создание независимого периодического регистра сведений с периодичностью «По секундам».
Важно: регистр обязательно должен быть независимым (не подчиненным регистратору), чтобы запись данных происходила программно и не зависела от транзакций проведения документа.
Если в качестве измерения регистра использовать стандартный НомерСтроки, система аудита будет работать некорректно. Например, если пользователь удалит первую строку в документе из 10 строк, все последующие строки сместятся вверх (строка 2 станет строкой 1, строка 3 — строкой 2). При сравнении версий алгоритм решит, что изменились абсолютно все строки документа, хотя фактически была удалена лишь одна.
Правильное решение: внедрение уникального идентификатора строки. Добавим в табличную часть документа скрытый реквизит КлючСтроки (тип: УникальныйИдентификатор или Число).
Заполнение этого ключа можно реализовать при изменении табличной части или в событии ПередЗаписью документа:
Процедура ПередЗаписью(Отказ, РежимЗаписи, РежимПроведения)
// Заполняем уникальные ключи для новых строк
МаксимальныйКлюч = 0;
Для Каждого СтрокаТЧ Из Товары Цикл
Если СтрокаТЧ.КлючСтроки > МаксимальныйКлюч Тогда
МаксимальныйКлюч = СтрокаТЧ.КлючСтроки;
КонецЕсли;
КонецЦикла;
Для Каждого СтрокаТЧ Из Товары Цикл
Если СтрокаТЧ.КлючСтроки = 0 Тогда
МаксимальныйКлюч = МаксимальныйКлюч + 1;
СтрокаТЧ.КлючСтроки = МаксимальныйКлюч;
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Создадим независимый периодический регистр сведений со следующей структурой:
ДокументСсылка (тип: ДокументСсылка.ЗаказПокупателя)КлючСтроки (тип: Число или УникальныйИдентификатор)Номенклатура (тип: СправочникСсылка.Номенклатура)Количество (тип: Число)Цена (тип: Число)Сумма (тип: Число)Автор (тип: СправочникСсылка.Пользователи)ТипИзменения (тип: Строка или Перечисление: Добавлена, Изменена, Удалена)В событии документа ПередЗаписью мы должны сравнить текущие данные табличной части с теми, которые уже сохранены в базе данных, и записать в регистр только измененные строки.
Процедура ЗаписатьИсториюТЧ()
// Получаем старое состояние документа из базы данных
Если ЗначениеЗаполнено(Ссылка) Тогда
СтарыйОбъект = Ссылка.ПолучитьОбъект();
ТаблицаСтарая = СтарыйОбъект.Товары.Выгрузить();
Иначе
ТаблицаСтарая = Товары.СкопироватьКолонки();
КонецЕсли;
ТаблицаНовая = Товары.Выгрузить();
ДатаИзменения = ТекущаяДата();
ТекущийПользователь = ПараметрыСеанса.ТекущийПользователь;
НаборЗаписей = РегистрыСведений.ИсторияИзмененийТЧ.СоздатьНаборЗаписей();
// Проверяем измененные и новые строки
Для Каждого СтрокаНовая Из ТаблицаНовая Цикл
СтрокаСтарая = ТаблицаСтарая.Найти(СтрокаНовая.КлючСтроки, "КлючСтроки");
НужноЗаписать = Ложь;
ТипДействия = "Изменена";
Если СтрокаСтарая = Неопределено Тогда
НужноЗаписать = Истина;
ТипДействия = "Добавлена";
ИначеЕсли СтрокаНовая.Номенклатура <> СтрокаСтарая.Номенклатура
Или СтрокаНовая.Количество <> СтрокаСтарая.Количество
Или СтрокаНовая.Цена <> СтрокаСтарая.Цена Тогда
НужноЗаписать = Истина;
КонецЕсли;
Если НужноЗаписать Тогда
НоваяЗапись = НаборЗаписей.Добавить();
НоваяЗапись.Период = ДатаИзменения;
НоваяЗапись.ДокументСсылка = Ссылка;
НоваяЗапись.КлючСтроки = СтрокаНовая.КлючСтроки;
НоваяЗапись.Номенклатура = СтрокаНовая.Номенклатура;
НоваяЗапись.Количество = СтрокаНовая.Количество;
НоваяЗапись.Цена = СтрокаНовая.Цена;
НоваяЗапись.Сумма = СтрокаНовая.Сумма;
НоваяЗапись.Автор = ТекущийПользователь;
НоваяЗапись.ТипИзменения = ТипДействия;
КонецЕсли;
КонецЦикла;
// Проверяем удаленные строки
Для Каждого СтрокаСтарая Из ТаблицаСтарая Цикл
СтрокаНовая = ТаблицаНовая.Найти(СтрокаСтарая.КлючСтроки, "КлючСтроки");
Если СтрокаНовая = Неопределено Тогда
НоваяЗапись = НаборЗаписей.Добавить();
НоваяЗапись.Период = ДатаИзменения;
НоваяЗапись.ДокументСсылка = Ссылка;
НоваяЗапись.КлючСтроки = СтрокаСтарая.КлючСтроки;
НоваяЗапись.Номенклатура = СтрокаСтарая.Номенклатура;
НоваяЗапись.Количество = 0;
НоваяЗапись.Цена = 0;
НоваяЗапись.Сумма = 0;
НоваяЗапись.Автор = ТекущийПользователь;
НоваяЗапись.ТипИзменения = "Удалена";
EndIf;
КонецЦикла;
Если НаборЗаписей.Количество() > 0 Тогда
НаборЗаписей.Записать(Ложь); // Записать без замещения существующих за другие даты
КонецЕсли;
КонецПроцедуры
Если в вашей конфигурации (например, УТП, УПП или комплексных решениях на управляемых формах) уже включена стандартная подсистема «Версионирование объектов» из БСП, то изобретать собственные регистры не требуется. Все данные об изменениях уже хранятся в регистре сведений ВерсииОбъектов.
Мнение о том, что типовой отчет позволяет сравнивать только две версии, ошибочно. Ограничение присутствует лишь в стандартном интерфейсе. Программный доступ позволяет извлечь любые версии объекта и построить по ним одну общую плоскую таблицу.
Рассмотрим, как программно прочитать версии табличной части из хранилища значений:
Функция ПолучитьТаблицуИзменений(ДокументСсылка) Экспорт
ТаблицаРезультат = Новый ТаблицаЗначений;
ТаблицаРезультат.Колонки.Добавить("ДатаВерсии", Новый ОписаниеТипов("Дата"));
ТаблицаРезультат.Колонки.Добавить("Автор", Новый ОписаниеТипов("СправочникСсылка.Пользователи"));
ТаблицаРезультат.Колонки.Добавить("Номенклатура", Новый ОписаниеТипов("СправочникСсылка.Номенклатура"));
ТаблицаРезультат.Колонки.Добавить("Количество", Новый ОписаниеТипов("Число"));
ТаблицаРезультат.Колонки.Добавить("Цена", Новый ОписаниеТипов("Число"));
ТаблицаРезультат.Колонки.Добавить("Сумма", Новый ОписаниеТипов("Число"));
Запрос = Новый Запрос;
Запрос.УстановитьПараметр("Ref", ДокументСсылка);
Запрос.Текст =
"ВЫБРАТЬ
| ВерсииОбъектов.ДатаВерсии КАК ДатаВерсии,
| ВерсииОбъектов.АвторВерсии КАК Автор,
| ВерсииОбъектов.ВерсияОбъекта КАК ХранилищеВерсии
|ИЗ
| РегистрСведений.ВерсииОбъектов КАК ВерсииОбъектов
|ГДЕ
| ВерсииОбъектов.Объект = &Ref
|УПОРЯДОЧИТЬ ПО
| ДатаВерсии ВОЗР";
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
// Извлекаем объект документа из хранилища значений
ДвоичныеДанные = Выборка.ХранилищеВерсии.Получить();
Если ДвоичныеДанные = Неопределено Тогда
Продолжить;
КонецЕсли;
// Для БСП версии объектов обычно сжаты в FastInfoSet или XML
Попытка
ЧтениеFIS = Новый ЧтениеFastInfoSet;
ЧтениеFIS.УстановитьДвоичныеДанные(ДвоичныеДанные);
ОбъектВерсии = ПрочитатьXML(ЧтениеFIS);
Исключение
// Альтернативный вариант чтения, если используется обычный XML
Попытка
ИмяВременногоФайла = ПолучитьИмяВременногоФайла("xml");
ДвоичныеДанные.Записать(ИмяВременногоФайла);
ЧтениеXML = Новый ЧтениеXML;
ЧтениеXML.ОткрытьФайл(ИмяВременногоФайла);
ОбъектВерсии = ПрочитатьXML(ЧтениеXML);
ЧтениеXML.Закрыть();
УдалитьФайлы(ИмяВременногоФайла);
Исключение
Продолжить;
КонецПопытки;
КонецПопытки;
// Переносим строки ТЧ "Товары" в общую таблицу
Для Каждого СтрокаТовары Из ОбъектВерсии.Товары Цикл
НоваяСтрока = ТаблицаРезультат.Добавить();
НоваяСтрока.ДатаВерсии = Выборка.ДатаВерсии;
НоваяСтрока.Автор = Выборка.Автор;
НоваяСтрока.Номенклатура = СтрокаТовары.Номенклатура;
НоваяСтрока.Количество = СтрокаТовары.Количество;
НоваяСтрока.Цена = СтрокаТовары.Цена;
НоваяСтрока.Сумма = СтрокаТовары.Сумма;
КонецЦикла;
КонецЦикла;
Возврат ТаблицаРезультат;
КонецФункции
Полученную таблицу значений можно легко вывести на форму документа в виде дерева или плоского списка, сгруппировав по датам изменений. Это полностью решает задачу главного бухгалтера: «видеть все изменения по дням в один клик без ручного сопоставления версий».
Если вы работаете на платформе 1С 8.3.11 и выше, а режим совместимости конфигурации отключен или установлен в современное значение, рассмотрите использование встроенного механизма платформы «История данных» (Data History).
Преимущества этого подхода:
ИсторияДанных.ПолучитьРазницу().Если вы не хотите усложнять структуру базы данных и создавать миллионы записей в регистре для каждой позиции табличной части, можно использовать метод сериализации всей ТЧ в одну строку.
При этом подходе создается периодический независимый регистр сведений, где измерением является только сам Документ, а ресурсом — строка неограниченной длины ДанныеТЧ, куда записывается сериализованная в JSON табличная часть.
Посмотрим на пример свертки ТЧ в JSON перед записью:
Функция ПолучитьТЧВФорматеJSON(ДокументОбъект)
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ТаблицаТЧ = ДокументОбъект.Товары.Выгрузить();
// Оставляем только значимые для аудита колонки
Для Каждого Колонка Из ТаблицаТЧ.Колонки Цикл
ИмяКолонки = Колонка.Имя;
Если ИмяКолонки <> "Номенклатура"
И ИмяКолонки <> "Количество"
И ИмяКолонки <> "Цена"
И ИмяКолонки <> "Сумма" Тогда
// Удалять во время обхода коллекции нельзя, выгрузим только нужные
КонецЕсли;
КонецЦикла;
// Сериализуем данные
ЗаписатьJSON(ЗаписьJSON, ТаблицаТЧ);
СтрокаJSON = ЗаписьJSON.Закрыть();
Возврат СтрокаJSON;
КонецФункции
Плюсы метода: Одна версия изменений — это строго одна строка в регистре сведений, независимо от того, сколько позиций (10 или 1000) было в документе. При формировании отчета строки JSON быстро десериализуются в памяти обратно в таблицы с помощью метода ПрочитатьJSON().
Подводя итог анализу проблемы, выделим ключевые правила построения качественного аудита в 1С:
ВерсииОбъектов. Для отображения «удобного отчета» достаточно написать небольшую обработку, декодирующую XML/FIS-данные напрямую из хранилища значений регистра.КлючСтроки), чтобы избежать проблем со смещением номеров при удалении или сортировке строк.