Как реализовать историю изменения реквизитов табличной части документа в 1С 7.7

Программист 1С v7.7 Управленческий учет IT и автоматизация бизнеса
← На главную

Разработка на платформе 1С:Предприятие 7.7 часто ставит перед программистом задачи, которые в более современных версиях платформы (8.x) решаются парой кликов в конфигураторе. Одним из таких вызовов является необходимость логирования и ведения истории изменений реквизитов табличной части (ТЧ) документа. Например, когда менеджеры в процессе обработки заказа меняют статусы у конкретных строк (товаров) и нам требуется точно знать: кто, когда и на какой статус изменил строку, а также сколько времени строка находилась в предыдущем состоянии.

В этой статье мы подробно разберем несколько вариантов решения этой задачи, проанализируем их плюсы и минусы, выясним технические ограничения файлового (DBF) и SQL-режимов, а также предоставим готовые примеры кода для реализации.

Главная проблема: уникальная идентификация строк табличной части

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

Проанализируем ситуацию: что произойдет, если пользователь удалит вторую строку из пяти? Третья строка автоматически станет второй, четвертая — третьей и так далее. Если мы привяжем историю изменений к физическому номеру строки, то вся накопленная история по бывшей третьей строке внезапно привяжется к новой строке, содержащей совершенно другой товар. Аналогичная проблема возникнет при банальной интерактивной сортировке табличной части.

Решение проблемы идентификации:

Чтобы избежать путаницы, нам необходимо добавить в табличную часть документа скрытый реквизит (например, ИД_Строки, тип: Строка, длина 10–12 символов). При добавлении новой строки в табличную часть (в предопределенной процедуре ПриВводеСтроки()) мы должны программно генерировать уникальный идентификатор и записывать его в эту колонку.

Посмотрим на пример генерации простого уникального идентификатора внутри модуля формы документа:


Процедура ПриВводеСтроки()
    // Генерируем уникальный ИД на основе времени и случайного числа
    ВремяСек = _GetPerformanceCounter();
    СлучЧисло = рнд(1000); // вспомогательная функция генерации случайных чисел
    ИД_Строки = СокрЛП(ТекущаяДата()) + "_" + СокрЛП(ВремяСек) + "_" + СокрЛП(СлучЧисло);
КонецПроцедуры

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


Вариант 1. Хранение истории в дополнительных реквизитах самой табличной части

Если бизнес-процесс предполагает жестко фиксированное количество изменений статуса (например, «Заявка внесена» -> «Передана в сборку» -> «Собрана» -> «Отгружена»), то самым простым решением будет отказ от внешних таблиц логов. Вместо этого мы можем завести несколько специализированных реквизитов прямо в табличной части документа.

Что для этого нужно сделать:

  1. Добавить в ТЧ документа реквизиты под каждый статус: Автор_Сборки (типа Справочник.Пользователи), Дата_Сборки (типа Дата), Время_Сборки (типа Строка).
  2. При интерактивном изменении статуса строки заполнять соответствующие поля.

Плюсы метода:

Минусы метода:


Вариант 2. Использование подчиненного справочника (Классический подход)

Этот подход напоминает работу с регистрами сведений в 1С 8.х. Мы создаем справочник (например, ИсторияСтатусовСтрок), который будет хранить записи изменений. Есть два пути проектирования такого справочника:

  1. Подчиненный справочник: Владельцем справочника истории назначается наш документ.
  2. Неподчиненный справочник: Ссылочная связь реализуется через обычный реквизит справочника с типом «Документ.ИмяДокумента».

Внутри справочника истории мы создаем следующие реквизиты:

Давайте разберем по шагам, как фиксировать изменения в процедуре ПриЗаписи() документа. Нам необходимо сравнить то, что пользователь видит на экране, с тем, что уже сохранено в базе данных. Для этого мы используем обращение к базе данных через создание объекта документа в памяти:


Процедура ПриЗаписи()
    // Создаем объект для чтения старого состояния документа из базы
    СтарыйДок = СоздатьОбъект("Документ." + Вид());
    Если СтарыйДок.НайтиДокумент(ТекущийДокумент()) = 1 Тогда
        // Выгружаем текущую ТЧ документа (с экрана) в Таблицу Значений
        ТЗ_Новая = СоздатьОбъект("ТаблицаЗначений");
        ВыгрузитьТабличнуюЧасть(ТЗ_Новая);
        
        // Выгружаем старую ТЧ документа (из базы данных)
        ТЗ_Старая = СоздатьОбъект("ТаблицаЗначений");
        СтарыйДок.ВыгрузитьТабличнуюЧасть(ТЗ_Старая);
        
        // Перебираем новые строки и ищем изменения по ИД_Строки
        ТЗ_Новая.ВыбратьСтроки();
        Пока ТЗ_Новая.ПолучитьСтроку() = 1 Цикл
            ИД = ТЗ_Новая.ИД_Строки;
            НовыйСтатус = ТЗ_Новая.Статус;
            
            СтрокаСтараяНом = 0;
            Если ТЗ_Старая.НайтиЗначение(ИД, СтрокаСтараяНом, "ИД_Строки") = 1 Тогда
                СтарыйСтатус = ТЗ_Старая.ПолучитьЗначение(СтрокаСтараяНом, "Статус");
                
                // Если статус изменился — пишем в историю
                Если НовыйСтатус <> СтарыйСтатус Тогда
                    ЗаписатьИсториюСтроки(ТекущийДокумент(), ИД, СтарыйСтатус, НовыйСтатус);
                КонецЕсли;
            Иначе
                // Это абсолютно новая строка — фиксируем начальный статус
                ЗаписатьИсториюСтроки(ТекущийДокумент(), ИД, "", НовыйСтатус);
            КонецЕсли;
        КонецЦикла;
    КонецЕсли;
КонецПроцедуры

Процедура ЗаписатьИсториюСтроки(КонтДок, ИдентификаторСтроки, БылоСтатус, СталоСтатус)
    СпрИстория = СоздатьОбъект("Справочник.ИсторияСтатусовСтрок");
    СпрИстория.Новый();
    СпрИстория.ДокументСсылка = КонтДок;
    СпрИстория.ИД_Строки = ИдентификаторСтроки;
    СпрИстория.СтатусБыло = БылоСтатус;
    СпрИстория.СтатусСтало = СталоСтатус;
    СпрИстория.ДатаИзменения = ТекущаяДата();
    
    Час = 0; Мин = 0; Сек = 0;
    ТекущееВремя(Час, Мин, Сек);
    СпрИстория.ВремяИзменения = СокрЛП(Час) + ":" + СокрЛП(Мин) + ":" + СокрЛП(Сек);
    СпрИстория.Пользователь = глПользователь; // глобальная переменная текущего пользователя
    СпрИстория.Записать();
КонецПроцедуры

Опасность файлового DBF-режима при использовании справочников:

Если в вашей информационной базе ведется активный документооборот (более 100 документов данного типа в день, в каждом из которых по 20–50 строк), то справочник истории изменений будет разрастаться лавинообразно. За год работы количество записей может превысить несколько миллионов.

В файловой версии 1С 7.7 существует жесткое ограничение на размер одной таблицы DBF (не более 2 ГБ). Индексные файлы (.CDX) могут забиваться еще быстрее. Переполнение этого лимита гарантированно приведет к аварийной остановке системы или разрушению индексов базы данных. Поэтому использовать данный метод в чистом виде для высоконагруженных DBF-баз крайне не рекомендуется.


Вариант 3. Вынос логирования во внешние файлы (Файловые снимки)

Чтобы не перегружать транзакционную базу данных 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", ТЗ);
        КонецЕсли;
    КонецЕсли;
КонецПроцедуры

Для активации логирования нам достаточно прописать вызовы этой процедуры в предопределенных функциях модуля формы интересующих нас документов:


Процедура ПриОткрытии()
    глРегистрацияИзменений(Контекст, "ПриОткрытии");
КонецПроцедуры

Процедура ПриЗаписи()
    глРегистрацияИзменений(Контекст, "ПриЗаписи");
КонецПроцедуры

Преимущества файлового логирования снимков:

Недостатки метода:


Вариант 4. Использование СУБД MS SQL Server и прямых запросов (Для SQL-версий 1С 7.7)

Если ваша конфигурация 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-логирования:

Минусы метода:


Резюме и рекомендации

Подведем итог нашему анализу архитектурных решений по версионированию табличной части в 1С 7.7:

← На главную