Реализация отката (Rollback) неудачного импорта товаров

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация отката (Rollback) неудачного импорта товаров
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация отката (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 день