Реалізація превью імпорту товарів перед застосуванням

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація превью імпорту товарів перед застосуванням
Середня
~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

Реалізація превью імпорту товарів перед застосуванням

Превью імпорту — це можливість побачити, що саме зміниться в каталозі, перш ніж натиснути «Застосувати». Особливо важливо при ручних загрузках та при роботі з новими постачальниками: помилка в маппінгу колонок може переписати ціни неправильними значеннями у тисяч товарів.

Архітектура 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 дня