Реалізація превью імпорту товарів перед застосуванням
Превью імпорту — це можливість побачити, що саме зміниться в каталозі, перш ніж натиснути «Застосувати». Особливо важливо при ручних загрузках та при роботі з новими постачальниками: помилка в маппінгу колонок може переписати ціни неправильними значеннями у тисяч товарів.
Архітектура dry-run режиму
Вся система імпорту має підтримувати флаг $dryRun:
class ProductImportService
{
public function import(iterable $rows, ImportConfig $config, bool $dryRun = false): ImportPreview|ImportResult
{
$preview = new ImportPreview();
foreach ($rows as $line => $row) {
$sanitized = $this->sanitizer->sanitize($row);
$validated = $this->validator->validate($sanitized);
if (!$validated->valid) {
$preview->addError($line, $row['sku'] ?? '?', $validated->errors);
continue;
}
$diff = $this->computeDiff($validated->data, $config->sourceId);
$preview->addItem($line, $diff);
}
if ($dryRun) {
return $preview; // повертаємо тільки аналіз, ніщо не пишемо в БД
}
return $this->applyPreview($preview, $config);
}
}
Обчислення diff для кожного товару
class ImportDiffComputer
{
public function compute(array $newData, int $sourceId): ItemDiff
{
$existing = Product::where('sku', $newData['sku'])
->where('source_id', $sourceId)
->first();
if (!$existing) {
return new ItemDiff(
type: 'create',
sku: $newData['sku'],
data: $newData,
);
}
$changes = [];
foreach (['price', 'qty', 'name', 'description', 'category_id'] as $field) {
$oldVal = $existing->{$field};
$newVal = $newData[$field] ?? null;
if ((string) $oldVal !== (string) $newVal) {
$changes[$field] = ['old' => $oldVal, 'new' => $newVal];
}
}
if (empty($changes)) {
return new ItemDiff(type: 'unchanged', sku: $newData['sku']);
}
return new ItemDiff(
type: 'update',
sku: $newData['sku'],
changes: $changes,
);
}
}
Зберігання превью у тимчасовому сховищі
Превью не можна зберігати в пам'яті — файли великі. Використовуємо тимчасову таблицю або Redis:
Варіант з тимчасовою таблицею БД
CREATE TABLE import_previews (
id serial PRIMARY KEY,
session_token varchar(64) UNIQUE,
source_id int,
user_id int,
total_rows int,
create_count int,
update_count int,
unchanged_count int,
error_count int,
expires_at timestamptz DEFAULT now() + INTERVAL '2 hours',
created_at timestamptz DEFAULT now()
);
CREATE TABLE import_preview_items (
id bigserial PRIMARY KEY,
preview_id int REFERENCES import_previews(id) ON DELETE CASCADE,
line_number int,
sku varchar(100),
operation varchar(10), -- create | update | unchanged | error
changes jsonb,
errors jsonb
);
CREATE INDEX ipi_preview_op_idx ON import_preview_items (preview_id, operation);
Збереження превью
class ImportPreviewRepository
{
public function store(ImportPreview $preview, int $sourceId, int $userId): string
{
$token = bin2hex(random_bytes(32));
$record = ImportPreviewRecord::create([
'session_token' => $token,
'source_id' => $sourceId,
'user_id' => $userId,
'total_rows' => $preview->totalCount(),
'create_count' => $preview->countByType('create'),
'update_count' => $preview->countByType('update'),
'unchanged_count' => $preview->countByType('unchanged'),
'error_count' => $preview->countByType('error'),
]);
// Вставляємо items батчами
foreach (array_chunk($preview->items(), 1000) as $batch) {
ImportPreviewItem::insert(array_map(
fn($item) => [
'preview_id' => $record->id,
'line_number' => $item->line,
'sku' => $item->sku,
'operation' => $item->type,
'changes' => $item->changes ? json_encode($item->changes) : null,
'errors' => $item->errors ? json_encode($item->errors) : null,
],
$batch
));
}
return $token;
}
}
API для відображення превью в UI
class ImportPreviewController
{
// Зведення превью
public function summary(string $token): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)
->where('expires_at', '>', now())
->firstOrFail();
return response()->json([
'token' => $token,
'summary' => [
'create' => $preview->create_count,
'update' => $preview->update_count,
'unchanged' => $preview->unchanged_count,
'errors' => $preview->error_count,
'total' => $preview->total_rows,
],
'expires_at' => $preview->expires_at,
]);
}
// Деталі з пагінацією та фільтрацією
public function items(string $token, Request $request): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)->firstOrFail();
$items = ImportPreviewItem::where('preview_id', $preview->id)
->when($request->operation, fn($q, $op) => $q->where('operation', $op))
->when($request->search, fn($q, $s) => $q->where('sku', 'like', "%{$s}%"))
->orderBy('line_number')
->paginate(50);
return response()->json($items);
}
// Застосувати превью
public function apply(string $token): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)
->where('expires_at', '>', now())
->firstOrFail();
ApplyImportPreviewJob::dispatch($preview->id, auth()->id());
return response()->json(['status' => 'queued', 'import_id' => null]);
}
}
Відображення змін в UI
Для поля changes у JSON формат diff зручний для рендеринга:
{
"price": {"old": 4990, "new": 5490},
"qty": {"old": 15, "new": 0},
"name": {"old": "Товар А", "new": "Товар А Pro"}
}
У React-інтерфейсі:
const ChangeCell = ({ field, change }: { field: string; change: {old: any; new: any} }) => (
<span>
<del className="text-red-500">{change.old}</del>
{' → '}
<ins className="text-green-600">{change.new}</ins>
</span>
);
Часткове застосування превью
Оператор може зняти галочки з певних рядків перед застосуванням:
public function applyPartial(string $token, array $excludeSkus): void
{
$preview = ImportPreviewRecord::where('session_token', $token)->firstOrFail();
ImportPreviewItem::where('preview_id', $preview->id)
->whereIn('sku', $excludeSkus)
->update(['operation' => 'excluded']);
}
Очистка застарілих превью
$schedule->command('import:cleanup-previews')->hourly();
ImportPreviewRecord::where('expires_at', '<', now())->each(function ($record) {
$record->delete(); // CASCADE видалить items
});
Терміни реалізації
- Dry-run режим, diff-комп'ютер, зберігання у temp-таблиці — 2 дні
- API summary/items/apply, UI з фільтрацією за типом операції — +1 день
- Часткове застосування, закінчення терміну, очистка — +0.5 дня







