Реализация отката (Rollback) неудачного импорта товаров
Импорт с ошибкой в маппинге может переписать цены у тысяч товаров неправильными данными. Без механизма отката единственный выход — восстановление из резервной копии базы данных, что занимает часы и поднимает весь сайт. Rollback импорта возвращает каталог к состоянию «до» за минуты.
Стратегии отката
1. Snapshot перед импортом (надёжно, дорого по месту)
Перед импортом сохраняем снимок затронутых строк:
CREATE TABLE import_product_snapshots (
id bigserial PRIMARY KEY,
import_id int REFERENCES import_runs(id) ON DELETE CASCADE,
product_id int,
operation varchar(10), -- create | update (delete отдельно)
data_before jsonb, -- состояние ДО импорта (NULL для create)
created_at timestamptz DEFAULT now()
);
2. Soft delete для новых товаров (create)
Товары, созданные в ходе импорта, помечаются deleted_at при откате — не удаляются физически.
3. Changelog / Event Sourcing (для сложных каталогов)
Каждое изменение пишется как событие. Откат = применение обратных событий. Сложнее в реализации, но позволяет откатываться к любому моменту времени.
Сохранение снимка перед обработкой
class ImportSnapshotService
{
public function captureBeforeImport(int $importId, array $skus, int $sourceId): void
{
// Берём существующие данные товаров, которые будем изменять
$products = Product::whereIn('sku', $skus)
->where('source_id', $sourceId)
->get(['id', 'sku', 'name', 'price', 'qty', 'description',
'category_id', 'deleted_at', 'updated_at']);
$snapshots = $products->map(fn($p) => [
'import_id' => $importId,
'product_id' => $p->id,
'operation' => 'update',
'data_before' => json_encode($p->toArray()),
'created_at' => now()->toDateTimeString(),
])->all();
// Батч-вставка
foreach (array_chunk($snapshots, 1000) as $chunk) {
ImportProductSnapshot::insert($chunk);
}
}
public function captureNewProduct(int $importId, int $productId): void
{
ImportProductSnapshot::create([
'import_id' => $importId,
'product_id' => $productId,
'operation' => 'create',
'data_before' => null,
]);
}
}
Механизм отката
class ImportRollbackService
{
public function rollback(ImportRun $import): RollbackResult
{
if (!in_array($import->status, ['success', 'partial', 'failed'])) {
throw new \RuntimeException('Import is not in a rollbackable state');
}
if ($import->rolled_back_at) {
throw new \RuntimeException('Import already rolled back');
}
$restored = $deleted = 0;
DB::transaction(function () use ($import, &$restored, &$deleted) {
$snapshots = ImportProductSnapshot::where('import_id', $import->id)
->orderByDesc('id') // обратный порядок для зависимостей
->get();
foreach ($snapshots as $snapshot) {
if ($snapshot->operation === 'create') {
// Созданные товары — удаляем (soft)
Product::find($snapshot->product_id)?->delete();
$deleted++;
} else {
// Обновлённые товары — восстанавливаем предыдущее состояние
$before = json_decode($snapshot->data_before, true);
Product::where('id', $snapshot->product_id)->update($before);
$restored++;
}
}
$import->update([
'rolled_back_at' => now(),
'rolled_back_by' => auth()->id(),
'rollback_result' => compact('restored', 'deleted'),
]);
});
return new RollbackResult($restored, $deleted);
}
}
Всё выполняется в одной транзакции — либо откат полностью завершился, либо ничего не изменилось.
Инкрементальный откат для больших импортов
Откатывать 100 000 строк в одной транзакции — рискованно (долгий lock). Используем батчинг:
public function rollbackInBatches(ImportRun $import, int $batchSize = 1000): void
{
$totalSnapshots = ImportProductSnapshot::where('import_id', $import->id)->count();
$offset = 0;
while ($offset < $totalSnapshots) {
DB::transaction(function () use ($import, $batchSize, $offset) {
$snapshots = ImportProductSnapshot::where('import_id', $import->id)
->orderByDesc('id')
->skip($offset)
->take($batchSize)
->get();
foreach ($snapshots as $snapshot) {
$this->applySnapshot($snapshot);
}
});
$offset += $batchSize;
ImportRun::find($import->id)->increment('rollback_progress', $batchSize);
}
}
Условия применимости отката
Не каждый импорт можно откатить:
| Условие | Откат возможен? |
|---|---|
| Снимок сохранён полностью | Да |
| Прошло менее 7 дней | Да (политика хранения) |
| Импорт уже отменён | Нет |
| Поверх этого импорта был новый импорт | Частично (только незатронутые строки) |
| Физически удалённые товары (не soft delete) | Нет |
public function canRollback(ImportRun $import): bool
{
return !$import->rolled_back_at
&& $import->created_at->isAfter(now()->subDays(7))
&& ImportProductSnapshot::where('import_id', $import->id)->exists();
}
Каскадный откат связанных данных
Импорт затрагивает не только таблицу products. При откате нужно учесть:
private function applySnapshot(ImportProductSnapshot $snapshot): void
{
DB::transaction(function () use ($snapshot) {
if ($snapshot->operation === 'create') {
// Удаляем все связанные данные
ProductImage::where('product_id', $snapshot->product_id)->delete();
ProductSpec::where('product_id', $snapshot->product_id)->delete();
ProductFilterValue::where('product_id', $snapshot->product_id)->delete();
Product::find($snapshot->product_id)?->forceDelete();
} else {
$before = json_decode($snapshot->data_before, true);
Product::where('id', $snapshot->product_id)->update(
array_intersect_key($before, array_flip(['name', 'price', 'qty', 'description', 'category_id']))
);
}
});
}
Хранение снимков и TTL
Снимки занимают место. Политика хранения:
// Артефакты хранятся 7 дней после успешного импорта
$schedule->command('import:cleanup-snapshots --days=7')->daily();
ImportProductSnapshot::whereHas('run', fn($q) =>
$q->where('status', 'success')
->where('completed_at', '<', now()->subDays(7))
)->delete();
Уведомление после отката
ImportRolledBackNotification::send($import->triggeredBy, $import);
// Письмо: "Импорт #1847 откатан. Восстановлено: 4 312 товаров, удалено: 88."
Сроки реализации
- Снимок перед импортом (captureBeforeImport), базовый откат в транзакции — 2 дня
- Батчинг отката, каскад по связанным таблицам, проверка применимости — +1 день
- Admin UI (кнопка «Откатить», статус прогресса, TTL снимков) — +1 день







