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







