Разработка на платформе 1С:Предприятие 7.7 часто ставит перед программистом задачи, которые в более современных версиях платформы (8.x) решаются парой кликов в конфигураторе. Одним из таких вызовов является необходимость логирования и ведения истории изменений реквизитов табличной части (ТЧ) документа. Например, когда менеджеры в процессе обработки заказа меняют статусы у конкретных строк (товаров) и нам требуется точно знать: кто, когда и на какой статус изменил строку, а также сколько времени строка находилась в предыдущем состоянии.
В этой статье мы подробно разберем несколько вариантов решения этой задачи, проанализируем их плюсы и минусы, выясним технические ограничения файлового (DBF) и SQL-режимов, а также предоставим готовые примеры кода для реализации.
Прежде чем перейти к выбору архитектуры хранения истории, давайте разберем ключевую техническую сложность платформы 1С 7.7. В отличие от 1С 8.x, где у каждой строки табличной части есть внутренний уникальный ключ связи, в «семерке» строки идентифицируются исключительно по их физическому номеру (НомерСтроки).
Проанализируем ситуацию: что произойдет, если пользователь удалит вторую строку из пяти? Третья строка автоматически станет второй, четвертая — третьей и так далее. Если мы привяжем историю изменений к физическому номеру строки, то вся накопленная история по бывшей третьей строке внезапно привяжется к новой строке, содержащей совершенно другой товар. Аналогичная проблема возникнет при банальной интерактивной сортировке табличной части.
Решение проблемы идентификации:
Чтобы избежать путаницы, нам необходимо добавить в табличную часть документа скрытый реквизит (например, ИД_Строки, тип: Строка, длина 10–12 символов). При добавлении новой строки в табличную часть (в предопределенной процедуре ПриВводеСтроки()) мы должны программно генерировать уникальный идентификатор и записывать его в эту колонку.
Посмотрим на пример генерации простого уникального идентификатора внутри модуля формы документа:
Процедура ПриВводеСтроки()
// Генерируем уникальный ИД на основе времени и случайного числа
ВремяСек = _GetPerformanceCounter();
СлучЧисло = рнд(1000); // вспомогательная функция генерации случайных чисел
ИД_Строки = СокрЛП(ТекущаяДата()) + "_" + СокрЛП(ВремяСек) + "_" + СокрЛП(СлучЧисло);
КонецПроцедуры
Теперь, когда у нас есть надежная и неизменяемая связь для каждой строки, мы можем безбоязненно проектировать архитектуру хранения логов изменений. Рассмотрим подробнее основные варианты реализации.
Если бизнес-процесс предполагает жестко фиксированное количество изменений статуса (например, «Заявка внесена» -> «Передана в сборку» -> «Собрана» -> «Отгружена»), то самым простым решением будет отказ от внешних таблиц логов. Вместо этого мы можем завести несколько специализированных реквизитов прямо в табличной части документа.
Что для этого нужно сделать:
Автор_Сборки (типа Справочник.Пользователи), Дата_Сборки (типа Дата), Время_Сборки (типа Строка).Плюсы метода:
Минусы метода:
Этот подход напоминает работу с регистрами сведений в 1С 8.х. Мы создаем справочник (например, ИсторияСтатусовСтрок), который будет хранить записи изменений. Есть два пути проектирования такого справочника:
Внутри справочника истории мы создаем следующие реквизиты:
ИД_Строки (Строка) — тот самый уникальный идентификатор строки, о котором мы говорили выше.Статус (Перечисление или Справочник) — новое значение статуса.ДатаИзменения (Дата).ВремяИзменения (Строка).Пользователь (Справочник.Пользователи).Давайте разберем по шагам, как фиксировать изменения в процедуре ПриЗаписи() документа. Нам необходимо сравнить то, что пользователь видит на экране, с тем, что уже сохранено в базе данных. Для этого мы используем обращение к базе данных через создание объекта документа в памяти:
Процедура ПриЗаписи()
// Создаем объект для чтения старого состояния документа из базы
СтарыйДок = СоздатьОбъект("Документ." + Вид());
Если СтарыйДок.НайтиДокумент(ТекущийДокумент()) = 1 Тогда
// Выгружаем текущую ТЧ документа (с экрана) в Таблицу Значений
ТЗ_Новая = СоздатьОбъект("ТаблицаЗначений");
ВыгрузитьТабличнуюЧасть(ТЗ_Новая);
// Выгружаем старую ТЧ документа (из базы данных)
ТЗ_Старая = СоздатьОбъект("ТаблицаЗначений");
СтарыйДок.ВыгрузитьТабличнуюЧасть(ТЗ_Старая);
// Перебираем новые строки и ищем изменения по ИД_Строки
ТЗ_Новая.ВыбратьСтроки();
Пока ТЗ_Новая.ПолучитьСтроку() = 1 Цикл
ИД = ТЗ_Новая.ИД_Строки;
НовыйСтатус = ТЗ_Новая.Статус;
СтрокаСтараяНом = 0;
Если ТЗ_Старая.НайтиЗначение(ИД, СтрокаСтараяНом, "ИД_Строки") = 1 Тогда
СтарыйСтатус = ТЗ_Старая.ПолучитьЗначение(СтрокаСтараяНом, "Статус");
// Если статус изменился — пишем в историю
Если НовыйСтатус <> СтарыйСтатус Тогда
ЗаписатьИсториюСтроки(ТекущийДокумент(), ИД, СтарыйСтатус, НовыйСтатус);
КонецЕсли;
Иначе
// Это абсолютно новая строка — фиксируем начальный статус
ЗаписатьИсториюСтроки(ТекущийДокумент(), ИД, "", НовыйСтатус);
КонецЕсли;
КонецЦикла;
КонецЕсли;
КонецПроцедуры
Процедура ЗаписатьИсториюСтроки(КонтДок, ИдентификаторСтроки, БылоСтатус, СталоСтатус)
СпрИстория = СоздатьОбъект("Справочник.ИсторияСтатусовСтрок");
СпрИстория.Новый();
СпрИстория.ДокументСсылка = КонтДок;
СпрИстория.ИД_Строки = ИдентификаторСтроки;
СпрИстория.СтатусБыло = БылоСтатус;
СпрИстория.СтатусСтало = СталоСтатус;
СпрИстория.ДатаИзменения = ТекущаяДата();
Час = 0; Мин = 0; Сек = 0;
ТекущееВремя(Час, Мин, Сек);
СпрИстория.ВремяИзменения = СокрЛП(Час) + ":" + СокрЛП(Мин) + ":" + СокрЛП(Сек);
СпрИстория.Пользователь = глПользователь; // глобальная переменная текущего пользователя
СпрИстория.Записать();
КонецПроцедуры
Опасность файлового DBF-режима при использовании справочников:
Если в вашей информационной базе ведется активный документооборот (более 100 документов данного типа в день, в каждом из которых по 20–50 строк), то справочник истории изменений будет разрастаться лавинообразно. За год работы количество записей может превысить несколько миллионов.
В файловой версии 1С 7.7 существует жесткое ограничение на размер одной таблицы DBF (не более 2 ГБ). Индексные файлы (.CDX) могут забиваться еще быстрее. Переполнение этого лимита гарантированно приведет к аварийной остановке системы или разрушению индексов базы данных. Поэтому использовать данный метод в чистом виде для высоконагруженных DBF-баз крайне не рекомендуется.
Чтобы не перегружать транзакционную базу данных 1С лишней информацией, которая требуется только периодически (для разбора спорных ситуаций или построения аналитических отчетов раз в месяц), мы можем организовать внешнее файловое хранилище логов. Данный метод особенно актуален для файловых баз DBF.
Суть метода заключается в сохранении сериализованных снимков документа (структуры его шапки и табличной части) в отдельные текстовые или XML-файлы на диске сервера при каждом сохранении документа.
Давайте подробно изучим код глобальной процедуры, которая создает структурированное файловое дерево логов в каталоге базы данных:
Процедура глРегистрацияИзменений(Конт, Проц="") Экспорт
Перем ТЗ, ТЧ, ТзПред, Предст;
// Проверяем флаг ведения логов из констант базы
Если Константа.ИспользоватьТотальныйКонтроль = Да Тогда
Попытка
Конт = Конт.ТекущийДокумент();
Исключение
Возврат; // Не удалось получить контекст документа
КонецПопытки;
Если ПустоеЗначение(Конт) = 1 Тогда
Возврат;
КонецЕсли;
ВидДок = Конт.Вид();
ИдДок = ЗначениеВСтрокуВнутр(Конт);
ИдДок = Число(Сред(ИдДок, Найти(ИдДок, " ")));
// Организуем структуру каталогов лога на диске
ИмяКаталога1 = Шаблон("[КаталогИБ()]Log\");
ИмяКаталога2 = Шаблон("[КаталогИБ()]Log\[ВидДок]\");
ИмяКаталога3 = Шаблон("[КаталогИБ()]Log\[ВидДок]\[ИдДок]\");
Если ФС.СуществуетФайл(ИмяКаталога1) = 0 Тогда
ФС.СоздатьКаталог(ИмяКаталога1);
КонецЕсли;
Если ФС.СуществуетФайл(ИмяКаталога2) = 0 Тогда
ФС.СоздатьКаталог(ИмяКаталога2);
КонецЕсли;
Если ФС.СуществуетФайл(ИмяКаталога3) = 0 Тогда
ФС.СоздатьКаталог(ИмяКаталога3);
КонецЕсли;
Мтд = Метаданные.Документ(ВидДок);
СзШ = СоздатьОбъект("СписокЗначений");
ТЗ = СоздатьОбъект("ТаблицаЗначений");
ТЗ.Выгрузить(ТзПред);
ТЗ.НоваяКолонка("Автор");
ТЗ.НоваяКолонка("Дата");
ТЗ.НоваяКолонка("Время");
ТЗ.НоваяКолонка("Шапка");
ТЗ.НоваяКолонка("ТЧ");
Нули = "00000";
ИмяФайла = ФС.НайтиПервыйФайл(ИмяКаталога3 + "*.TXT");
ПослФайл = ИмяФайла;
Пока ИмяФайла <> "" Цикл
ПослФайл = ИмяФайла;
ИмяФайла = ФС.НайтиСледующийФайл();
КонецЦикла;
Если (ПустоеЗначение(ПослФайл) = 0) И (Проц = "ПриОткрытии") Тогда
Возврат; // При открытии существующего файла перезапись не нужна
КонецЕсли;
Если ПослФайл = "" Тогда
ИмяФайла = Нули;
Иначе
// Автоматически инкрементируем номер лог-файла для документа
ИмяФайла = Прав(Нули + (Число(ПослФайл) + 1), 5);
КонецЕсли;
СзШ.Установить("ДатаДок", Конт.ДатаДок);
СзШ.Установить("НомерДок", Конт.НомерДок);
// Заполняем общие реквизиты документов
Для Ном = 1 По Метаданные.ОбщийРеквизитДокумента() Цикл
ИдРек = Метаданные.ОбщийРеквизитДокумента(Ном).Идентификатор;
ЗнРек = Конт.ПолучитьАтрибут(ИдРек);
СзШ.Установить(ИдРек, ЗнРек);
КонецЦикла;
// Заполняем реквизиты шапки документа
Для Ном = 1 По Мтд.РеквизитШапки() Цикл
ИдРек = Мтд.РеквизитШапки(Ном).Идентификатор;
ЗнРек = Конт.ПолучитьАтрибут(ИдРек);
СзШ.Установить(ИдРек, ЗнРек);
КонецЦикла;
// Формируем структуру колонок табличной части
СтрокаТЧ = "";
Для Ном = 1 По Мтд.РеквизитТабличнойЧасти() Цикл
ИдРек = Мтд.РеквизитТабличнойЧасти(Ном).Идентификатор;
СтрокаТЧ = СтрокаТЧ + ?(СтрокаТЧ = "", "", ",") + ИдРек;
КонецЦикла;
Конт.ВыгрузитьТабличнуюЧасть(ТЧ);
ЕстьИзменения = 0;
Если ПустоеЗначение(ПослФайл) = 0 Тогда
// Ранее документ уже редактировался, сравниваем текущую версию с предыдущим файлом
ЗначениеИзФайла(ИмяКаталога3 + ПослФайл, ТзПред);
ПредСзШ = ТзПред.ПолучитьЗначение(1, "Шапка");
ПредТЧ = ТзПред.ПолучитьЗначение(1, "ТЧ");
Если ТипЗначенияСтр(ПредТЧ) <> "ТаблицаЗначений" Тогда
ТЧ.Выгрузить(ПредТЧ);
ПредТЧ.УдалитьСтроки();
КонецЕсли;
// Проверяем изменение реквизитов шапки
Если ПредСзШ.Получить("ДатаДок") <> СзШ.Получить("ДатаДок") Тогда
ЕстьИзменения = 1;
КонецЕсли;
Если ПредСзШ.Получить("НомерДок") <> СзШ.Получить("НомерДок") Тогда
ЕстьИзменения = 1;
КонецЕсли;
Если ЕстьИзменения = 0 Тогда
Для Ном = 1 По СзШ.РазмерСписка() Цикл
ТекЗн = СзШ.ПолучитьЗначение(Ном, Предст);
Если ПредСзШ.Получить(Предст) <> ТекЗн Тогда
ЕстьИзменения = 1;
Прервать;
КонецЕсли;
КонецЦикла;
КонецЕсли;
// Быстрое сравнение табличных частей через свертку
Если (ЕстьИзменения = 0) И (ТЧ.КоличествоСтрок() <= 1000) Тогда
ПредТЧ.НоваяКолонка("_СравнениеТЗ");
ПредТЧ.Заполнить(-1, , , "_СравнениеТЗ");
ТЧ.НоваяКолонка("_СравнениеТЗ");
ТЧ.Заполнить(1, , , "_СравнениеТЗ");
КолСтр1 = ПредТЧ.КоличествоСтрок();
КолСтр2 = ТЧ.КоличествоСтрок();
Если КолСтр2 <> 0 Тогда
ПредТЧ.КоличествоСтрок(КолСтр1 + КолСтр2);
ПредТЧ.Заполнить(ТЧ, КолСтр1 + 1);
КонецЕсли;
ПредТЧ.Свернуть(СтрокаТЧ, "_СравнениеТЗ");
ПредТЧ.Сортировать("_СравнениеТЗ");
Если ПредТЧ.КоличествоСтрок() > 0 Тогда
Если (ПредТЧ.ПолучитьЗначение(1, "_СравнениеТЗ") <> 0) Или
(ПредТЧ.ПолучитьЗначение(ПредТЧ.КоличествоСтрок(), "_СравнениеТЗ") <> 0) Тогда
ЕстьИзменения = 1;
КонецЕсли;
КонецЕсли;
КонецЕсли;
Иначе
ЕстьИзменения = 1; // Самый первый снимок документа
КонецЕсли;
// Записываем снимок в файл, если обнаружены изменения
Если ЕстьИзменения = 1 Тогда
ТЗ.НоваяСтрока();
Если ПустоеЗначение(ПослФайл) = 1 Тогда
ТЗ.Автор = "Первоначальный_Снимок";
Else
ТЗ.Автор = ИмяПользователя();
КонецЕсли;
ТЗ.Дата = ТекущаяДата();
Час=0; Мин=0; Сек=0;
ТекущееВремя(Час, Мин, Сек);
ТЗ.Время = СокрЛП(Час) + ":" + СокрЛП(Мин) + ":" + СокрЛП(Сек);
ТЗ.Шапка = СзШ;
Попытка
ТЧ.УдалитьКолонку("_СравнениеТЗ");
Исключение
КонецПопытки;
ТЗ.ТЧ = ТЧ;
ЗначениеВФайл(ИмяКаталога3 + ИмяФайла + ".TXT", ТЗ);
КонецЕсли;
КонецЕсли;
КонецПроцедуры
Для активации логирования нам достаточно прописать вызовы этой процедуры в предопределенных функциях модуля формы интересующих нас документов:
Процедура ПриОткрытии()
глРегистрацияИзменений(Контекст, "ПриОткрытии");
КонецПроцедуры
Процедура ПриЗаписи()
глРегистрацияИзменений(Контекст, "ПриЗаписи");
КонецПроцедуры
Преимущества файлового логирования снимков:
Недостатки метода:
Если ваша конфигурация 1С работает под управлением MS SQL Server, то использование штатных объектов 1С для записи истории — это не самый эффективный путь. Платформа 1С 7.7 при записи любого элемента справочника накладывает транзакционные блокировки на всю таблицу, что в условиях многопользовательской работы может вызывать серьезные задержки и ошибки взаимной блокировки (Deadlocks).
Для таких систем идеальным решением является использование внешней компоненты 1С++. Мы можем создать в SQL-базе собственную таблицу, которая никак не зависит от метаданных 1С, и выполнять запись истории изменений напрямую с помощью быстрых SQL-запросов через объект ODBCRecordset.
Давайте разберем по шагам, как это реализовать.
Шаг 1: Создание независимых SQL-таблиц.
Для этого выполним скрипт создания таблиц в базе данных MS SQL (это можно сделать через Query Analyzer или программно при старте системы):
CREATE TABLE dbo.RowStatusHistory (
ID bigint IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED,
IDDoc char(9) NOT NULL, -- Ссылка на документ в формате 1С (36-ричный вид)
RowID varchar(50) NOT NULL, -- Уникальный ИД строки ТЧ
OldStatus varchar(100) NOT NULL,
NewStatus varchar(100) NOT NULL,
User1C varchar(50) NOT NULL,
ChangeDateTime datetime NOT NULL DEFAULT GETDATE()
);
CREATE INDEX IX_RowStatusHistory_IDDoc_RowID ON dbo.RowStatusHistory(IDDoc, RowID);
Шаг 2: Программная запись истории напрямую в СУБД.
Рассмотрим код процедуры, которая обновляет статус в самом документе и мгновенно регистрирует это событие в нашей SQL-таблице, минуя стандартный движок записи 1С:
Процедура ИзменитьСтатусСтрокиSQL(ДокумСсылка, УникальныйИДСтроки, СтарыйСтатус, НовыйСтатус) Экспорт
// Преобразуем ссылку на документ в внутренний 9-символьный IDDoc платформы
ЗначВнутр = ЗначениеВСтрокуВнутр(ДокумСсылка);
ИдДокВнутр = Сред(ЗначВнутр, Найти(ЗначВнутр, " ") + 1);
ИдДокВнутр = СтрЗаменить(ИдДокВнутр, "}", "");
ИдДокВнутр = СокрЛП(ИдДокВнутр);
// Создаем объект ODBCRecordset из внешней компоненты 1С++
Рекордсет = СоздатьОбъект("ODBCRecordset");
ТекстЗапроса = "
|INSERT INTO dbo.RowStatusHistory (IDDoc, RowID, OldStatus, NewStatus, User1C, ChangeDateTime)
|VALUES (:IDDoc, :RowID, :OldStatus, :NewStatus, :User, GETDATE())
|";
Рекордсет.УстановитьТекстовыйПараметр("IDDoc", ИдДокВнутр);
Рекордсет.УстановитьТекстовыйПараметр("RowID", УникальныйИДСтроки);
Рекордсет.УстановитьТекстовыйПараметр("OldStatus", СтарыйСтатус);
Рекордсет.УстановитьТекстовыйПараметр("NewStatus", НовыйСтатус);
Рекордсет.УстановитьТекстовыйПараметр("User", ИмяПользователя());
Рекордсет.Выполнить(ТекстЗапроса);
КонецПроцедуры
Плюсы использования прямого SQL-логирования:
Минусы метода:
1cpp.dll.Подведем итог нашему анализу архитектурных решений по версионированию табличной части в 1С 7.7:
.dbf. Если есть риск превышения лимитов СУБД — смело переходите на Вариант 3 (логирование в файлы).