Перейти к содержимому

История изменений своей таблицы - версии, автор, откат

Храним историю правок своих данных: таблица версий, запись изменений при сохранении, показ истории записи, откат к прежней версии и чистка старого.

Что нужно знать заранее

История нужна там, где данные меняют люди, а не программа. Справочник цен, условия договоров и настройки тарифов рано или поздно порождают вопрос «кто и когда это поменял».

Хранить историю правок в проекте можно двумя разными способами. Построчно по изменённым полям - компактно и удобно для показа, снимком всей записи - проще в коде и надёжнее при откате.

Таблица истории растёт заметно быстрее, чем сами данные проекта. Таблица версий на активном справочнике за год становится в разы больше исходной, поэтому срок хранения задают сразу.

Шаги

  1. Решить, для каких именно сущностей проекта история правок действительно нужна.
  2. Завести таблицу версий с автором правки, её датой и изменёнными значениями полей.
  3. Записывать версию в момент сохранения сущности, а не отдельным вызовом позже.
  4. Показать историю записи в интерфейсе сотрудника списком с датами и авторами.
  5. Настроить чистку старых версий по сроку хранения, заранее согласованному с заказчиком.

Решение

Заводим таблицу версий:

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']); }

Типичные проблемы

Таблица истории стала больше самих данных.

Записываются все поля подряд, включая те, что не менялись при сохранении. Перед записью сравнивают старое и новое значение и пишут только различия.

В истории не видно, кто именно менял запись.

Автор правки не сохраняется, потому что фоновый скрипт работает без текущего пользователя. Для фоновых операций записывают служебного автора, а не оставляют поле пустым.

История ведётся, а откатить изменение невозможно.

Сохраняется только новое значение без прежнего, и вернуться к нему уже нельзя. Хранят обе величины сразу: и прежнее значение поля, и его новое.

Запись истории заметно замедлила сохранение.

На каждое поле выполняется отдельный запрос к базе данных внутри цикла. Изменения собирают в одну пачку и записывают их одним вызовом добавления строк.

После отката данные разошлись со связанными записями.

Возвращено значение поля, от которого зависят другие таблицы и денежные расчёты проекта. Откат значения делают осознанно и обязательно проверяют связанные данные вручную.

Частые вопросы

Хранить изменения по полям или снимком записи?

По полям компактнее и нагляднее для показа сотруднику. Снимок всей записи проще в коде и надёжнее, когда откат нужен целиком.

Где взять автора правки в фоновом задании?

Записывать служебного пользователя или ноль с пометкой об источнике правки. Пустое поле автора превращает историю в бесполезный список.

Сколько хранить историю?

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

Подходит ли для этого журнал событий платформы?

Для отдельных действий - да, но он не хранит прежние значения полей. Для своих справочников удобнее отдельная таблица версий.

Нужно ли писать историю для данных обмена?

Обычно нет: их меняет программа, и объём истории будет огромным. Историю ведут там, где данные правят люди.

Смежное

Первоисточник