История изменений своей таблицы - версии, автор, откат
Храним историю правок своих данных: таблица версий, запись изменений при сохранении, показ истории записи, откат к прежней версии и чистка старого.
Что нужно знать заранее
История нужна там, где данные меняют люди, а не программа. Справочник цен, условия договоров и настройки тарифов рано или поздно порождают вопрос «кто и когда это поменял».
Хранить историю правок в проекте можно двумя разными способами. Построчно по изменённым полям - компактно и удобно для показа, снимком всей записи - проще в коде и надёжнее при откате.
Таблица истории растёт заметно быстрее, чем сами данные проекта. Таблица версий на активном справочнике за год становится в разы больше исходной, поэтому срок хранения задают сразу.
Шаги
- Решить, для каких именно сущностей проекта история правок действительно нужна.
- Завести таблицу версий с автором правки, её датой и изменёнными значениями полей.
- Записывать версию в момент сохранения сущности, а не отдельным вызовом позже.
- Показать историю записи в интерфейсе сотрудника списком с датами и авторами.
- Настроить чистку старых версий по сроку хранения, заранее согласованному с заказчиком.
Решение
Заводим таблицу версий:
class ItemHistoryTable extends \Bitrix\Main\ORM\Data\DataManager{ public static function getTableName(): string { return 'vendor_item_history'; }
public static function getMap(): array { return [ (new \Bitrix\Main\ORM\Fields\IntegerField('ID'))->configurePrimary(true)->configureAutocomplete(true), new \Bitrix\Main\ORM\Fields\IntegerField('ITEM_ID'), new \Bitrix\Main\ORM\Fields\StringField('FIELD_NAME'), new \Bitrix\Main\ORM\Fields\TextField('OLD_VALUE'), new \Bitrix\Main\ORM\Fields\TextField('NEW_VALUE'), new \Bitrix\Main\ORM\Fields\IntegerField('USER_ID'), (new \Bitrix\Main\ORM\Fields\DatetimeField('CHANGED_AT'))->configureDefaultValue(fn() => new \Bitrix\Main\Type\DateTime()), ]; }}Отдельная таблица истории совсем не мешает основным выборкам по данным. Данные остаются компактными, а история читается только там, где её действительно показывают сотруднику.
Записываем изменения при сохранении:
$before = ItemTable::getRow(['filter' => ['=ID' => $id]]);ItemTable::update($id, $fields);foreach ($fields as $name => $value) { if ((string)($before[$name] ?? '') === (string)$value) { continue; } // без изменений ItemHistoryTable::add(['ITEM_ID' => $id, 'FIELD_NAME' => $name, 'OLD_VALUE' => (string)($before[$name] ?? ''), 'NEW_VALUE' => (string)$value, 'USER_ID' => (int)$GLOBALS['USER']->GetID()]);}Сравнение старого и нового значения экономит место в разы. Без него история наполняется строками, где ничего не изменилось, и читать её становится невозможно.
Читаем историю записи:
$rows = ItemHistoryTable::getList(['filter' => ['=ITEM_ID' => $id], 'order' => ['CHANGED_AT' => 'DESC'], 'limit' => 50])->fetchAll();foreach ($rows as $row) { printf("%s %s: %s -> %s\n", $row['CHANGED_AT'], $row['FIELD_NAME'], $row['OLD_VALUE'], $row['NEW_VALUE']);}Откатываем поле к прежнему значению:
$last = ItemHistoryTable::getRow(['filter' => ['=ITEM_ID' => $id, '=FIELD_NAME' => 'PRICE'], 'order' => ['CHANGED_AT' => 'DESC']]);if ($last) { ItemTable::update($id, ['PRICE' => $last['OLD_VALUE']]); }// откат тоже записывают в историю: иначе следы правок теряютсяЧистим старые версии по расписанию:
$old = ItemHistoryTable::getList(['filter' => [ '<CHANGED_AT' => (new \Bitrix\Main\Type\DateTime())->add('-1 year')], 'select' => ['ID'], 'limit' => 1000])->fetchAll();foreach ($old as $row) { ItemHistoryTable::delete($row['ID']); }Типичные проблемы
Таблица истории стала больше самих данных.
Записываются все поля подряд, включая те, что не менялись при сохранении. Перед записью сравнивают старое и новое значение и пишут только различия.
В истории не видно, кто именно менял запись.
Автор правки не сохраняется, потому что фоновый скрипт работает без текущего пользователя. Для фоновых операций записывают служебного автора, а не оставляют поле пустым.
История ведётся, а откатить изменение невозможно.
Сохраняется только новое значение без прежнего, и вернуться к нему уже нельзя. Хранят обе величины сразу: и прежнее значение поля, и его новое.
Запись истории заметно замедлила сохранение.
На каждое поле выполняется отдельный запрос к базе данных внутри цикла. Изменения собирают в одну пачку и записывают их одним вызовом добавления строк.
После отката данные разошлись со связанными записями.
Возвращено значение поля, от которого зависят другие таблицы и денежные расчёты проекта. Откат значения делают осознанно и обязательно проверяют связанные данные вручную.
Частые вопросы
Хранить изменения по полям или снимком записи?
По полям компактнее и нагляднее для показа сотруднику. Снимок всей записи проще в коде и надёжнее, когда откат нужен целиком.
Где взять автора правки в фоновом задании?
Записывать служебного пользователя или ноль с пометкой об источнике правки. Пустое поле автора превращает историю в бесполезный список.
Сколько хранить историю?
Столько, сколько нужно бизнесу для разбора спорных ситуаций: обычно год. Срок согласуют с заказчиком и настраивают чистку сразу.
Подходит ли для этого журнал событий платформы?
Для отдельных действий - да, но он не хранит прежние значения полей. Для своих справочников удобнее отдельная таблица версий.
Нужно ли писать историю для данных обмена?
Обычно нет: их меняет программа, и объём истории будет огромным. Историю ведут там, где данные правят люди.
Смежное
- Свои таблицы на ORM - оглавление подтемы
- Своя таблица на ORM: сущность, запросы, изменение структуры - как заводят такие таблицы
- Объекты ORM: сущности, коллекции, отложенная загрузка - работа с записями через объекты
- Журнал событий: чтение, очистка, свои записи - штатный журнал действий пользователей
- Своя сущность от таблицы до админки: список, форма, права - где показывают историю сотруднику
- База растёт: журналы, статистика, старые данные и чистка - чистка разросшихся служебных таблиц
- Разовый скрипт на боевом: запуск, защита, журнал, откат - снимок данных перед массовой правкой
- Ядро D7 - устройство ядра целиком