[FEAT] 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔背景處理

1. ProductImportService 新增 deferImages 模式與第 18 欄「圖片檔名」,匯入時收集 pending_images 供背景處理,並保留同步轉檔路徑。
2. 新增 AttachProductImagesJob:背景逐筆將圖片轉為 WebP 入庫、更新時刪除舊圖、重建目錄快取、清除暫存資料夾並寫入操作日誌。
3. ProductController::import 新增圖片 zip 上傳(上限 60MB)與安全解壓 extractImageZip:副檔名白名單、getimagesize 內容驗證、basename 防路徑穿越、項目數與單檔及總量上限防 zip bomb,解壓後派發背景 Job。
4. 匯入 Modal 新增選填的圖片 .zip 上傳欄位與背景處理提示文字。
5. config/queue.php 的 redis retry_after 提高至 1830(可由 REDIS_QUEUE_RETRY_AFTER 覆寫),避免長時間圖片任務未完成即被重複派發造成重複處理。
6. 三語系新增圖片匯入相關文案並對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-16 13:41:22 +08:00
parent 02e4e99308
commit 4a86be6cbf
8 changed files with 336 additions and 10 deletions

View File

@ -594,6 +594,7 @@ class ProductController extends Controller
$request->validate([
'file' => 'required|file|mimes:xlsx|max:5120',
'company_id' => 'nullable|exists:companies,id',
'images' => 'nullable|file|mimes:zip|max:61440', // 60MB落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定
]);
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
@ -605,23 +606,128 @@ class ProductController extends Controller
], 422);
}
$results = $importService->import(
$request->file('file')->getRealPath(),
$companyId,
auth()->id()
);
// 有圖片壓縮檔:解壓到暫存目錄,圖片轉檔交由背景 Job 處理 (避免大量同步轉檔逾時)
$imageDir = $request->hasFile('images') ? $this->extractImageZip($request->file('images')) : null;
try {
$results = $importService->import(
$request->file('file')->getRealPath(),
$companyId,
auth()->id(),
$imageDir,
deferImages: $imageDir !== null
);
} catch (\Throwable $e) {
if ($imageDir && is_dir($imageDir)) {
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
}
throw $e;
}
foreach ($results['company_ids'] as $importedCompanyId) {
$this->catalogService->rebuildCache($importedCompanyId);
}
// 派發背景圖片處理 Job (Job 負責轉檔 + 重建快取 + 刪暫存目錄)
$queuedImages = 0;
if ($imageDir) {
if (!empty($results['pending_images'])) {
$queuedImages = count($results['pending_images']);
\App\Jobs\Product\AttachProductImagesJob::dispatch(
$results['pending_images'],
$imageDir,
$companyId,
auth()->id()
);
} else {
// 沒有任何圖片需處理,立即清掉暫存目錄
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
}
}
$message = __(':count products imported successfully', ['count' => $results['success_count']]);
if ($queuedImages > 0) {
$message .= ' · ' . __(':count images are being processed in the background', ['count' => $queuedImages]);
}
return response()->json([
'success' => true,
'message' => __(':count products imported successfully', ['count' => $results['success_count']]),
'message' => $message,
'results' => $results,
]);
}
/**
* 將上傳的圖片壓縮檔安全解壓到暫存目錄,回傳目錄路徑。
* 防護副檔名白名單、basename 防穿越、項目數/單檔/總量上限 ( zip bomb)、內容驗證為圖片。
*/
private function extractImageZip(\Illuminate\Http\UploadedFile $zipFile): string
{
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
$maxEntries = 2000;
$maxFileBytes = 5 * 1024 * 1024; // 單檔解壓上限 5MB
$maxTotalBytes = 200 * 1024 * 1024; // 總解壓上限 200MB
$dir = storage_path('app/tmp/product_import_' . Str::uuid()->toString());
\Illuminate\Support\Facades\File::ensureDirectoryExists($dir);
$zip = new \ZipArchive();
if ($zip->open($zipFile->getRealPath()) !== true) {
throw new \RuntimeException(__('Unable to read the image archive'));
}
if ($zip->numFiles > $maxEntries) {
$zip->close();
throw new \RuntimeException(__('The image archive contains too many files'));
}
$totalBytes = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$entryName = $stat['name'];
// 跳過目錄與含路徑穿越的項目,只取檔名
if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) {
continue;
}
$base = basename($entryName);
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt, true)) {
continue;
}
if ($stat['size'] > $maxFileBytes) {
continue;
}
$totalBytes += $stat['size'];
if ($totalBytes > $maxTotalBytes) {
$zip->close();
throw new \RuntimeException(__('The image archive is too large when extracted'));
}
$stream = $zip->getStream($entryName);
if ($stream === false) {
continue;
}
$contents = stream_get_contents($stream);
fclose($stream);
// 內容驗證:確認真為圖片,擋掉偽裝檔
if ($contents === false || getimagesizefromstring($contents) === false) {
continue;
}
file_put_contents($dir . '/' . $base, $contents);
}
$zip->close();
return $dir;
}
/**
* 同步商品目錄至全公司機台 (Phase 2)
*/

View File

@ -0,0 +1,99 @@
<?php
namespace App\Jobs\Product;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Services\Product\ProductCatalogService;
use App\Traits\ImageHandler;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\UploadedFile;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
/**
* 背景處理產品匯入的圖片:將已解壓資料夾中的圖片逐筆轉 WebP 入庫並寫回 image_url。
* 與商品文字匯入解耦,避免大量圖片同步轉檔導致請求逾時。
* 完成後重建目錄快取並刪除暫存圖片資料夾。
*/
class AttachProductImagesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ImageHandler;
public int $timeout = 1800; // 30 分鐘,容納大量圖片轉檔
public int $tries = 1; // 圖片步驟不重試整批(避免重複轉檔),失敗以 log 呈現
/**
* @param array<int, array{product_id:int, filename:string, row:int}> $pendingImages
*/
public function __construct(
protected array $pendingImages,
protected string $imageDir,
protected ?int $companyId = null,
protected ?int $userId = null
) {}
public function handle(ProductCatalogService $catalogService): void
{
$success = 0;
$missing = [];
try {
foreach ($this->pendingImages as $item) {
$product = Product::withoutGlobalScopes()->find($item['product_id']);
if (!$product) {
$missing[] = $item;
continue;
}
$path = rtrim($this->imageDir, '/') . '/' . basename($item['filename']);
if (!is_file($path) || getimagesize($path) === false) {
$missing[] = $item;
continue;
}
try {
$oldImage = $product->image_url;
$file = new UploadedFile($path, basename($path), null, null, true);
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
$product->update(['image_url' => Storage::url($storedPath)]);
if ($oldImage) {
Storage::disk('public')->delete(str_replace('/storage/', '', $oldImage));
}
$success++;
} catch (\Throwable $e) {
$missing[] = $item;
}
}
// 圖片已更新,重建目錄快取讓機台拿到新圖
if ($this->companyId) {
$catalogService->rebuildCache($this->companyId);
}
SystemOperationLog::create([
'company_id' => $this->companyId,
'user_id' => $this->userId,
'module' => 'product',
'action' => 'update',
'target_type' => Product::class,
'note' => 'product_import_images_processed',
'new_values' => [
'image_success' => $success,
'image_missing' => count($missing),
'missing_detail' => $missing,
],
]);
} finally {
// 無論成敗,清掉暫存圖片資料夾
if (is_dir($this->imageDir)) {
File::deleteDirectory($this->imageDir);
}
}
}
}

View File

@ -6,7 +6,10 @@ use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\SystemOperationLog;
use App\Models\System\Translation;
use App\Traits\ImageHandler;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenSpout\Reader\XLSX\Reader;
@ -15,7 +18,14 @@ use OpenSpout\Common\Entity\Row;
class ProductImportService
{
public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array
use ImageHandler;
/**
* @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案)
* @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images']
* 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。
*/
public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array
{
$reader = new Reader();
$reader->open($filePath);
@ -27,6 +37,9 @@ class ProductImportService
'error_count' => 0,
'errors' => [],
'company_ids' => [],
'image_success' => 0,
'image_missing' => [],
'pending_images' => [],
];
$rowCount = 0;
@ -65,6 +78,7 @@ class ProductImportService
'points_half' => 'nullable|integer|min:0',
'points_half_amount' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'image_filename' => 'nullable|string|max:255',
]);
if ($validator->fails()) {
@ -73,7 +87,7 @@ class ProductImportService
}
try {
DB::transaction(function () use ($data, $rowCount, $userId, &$results) {
DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) {
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
$lookup = $this->buildProductLookup($data);
$product = Product::withoutGlobalScopes()->where($lookup)->first();
@ -116,6 +130,18 @@ class ProductImportService
$note = 'product_import_created';
}
if ($deferImages) {
if ($imageDir && $data['image_filename']) {
$results['pending_images'][] = [
'product_id' => $product->id,
'filename' => $data['image_filename'],
'row' => $rowCount,
];
}
} else {
$this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results);
}
$results['success_count']++;
if ($product->company_id) {
$results['company_ids'][$product->company_id] = $product->company_id;
@ -168,6 +194,7 @@ class ProductImportService
'半額點數(選填)',
'半額點數折抵金額(選填)',
'是否啟用1/0選填',
'圖片檔名(選填,需一併上傳圖片壓縮檔)',
]));
$writer->addRow(Row::fromValues([
@ -188,6 +215,7 @@ class ProductImportService
400,
40,
1,
'latte.png',
]));
$writer->close();
@ -214,9 +242,46 @@ class ProductImportService
'points_half' => (int) $this->numericValue($cells[14] ?? null, 0),
'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0),
'is_active' => $this->booleanValue($cells[16] ?? null),
'image_filename' => $this->stringValue($cells[17] ?? null),
];
}
/**
* 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。
* 失敗 (找不到檔/非圖) 只記錄至 image_missing不影響商品本身寫入。
*/
private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void
{
if (!$filename || !$imageDir) {
return;
}
try {
$path = rtrim($imageDir, '/') . '/' . basename($filename);
if (!is_file($path) || getimagesize($path) === false) {
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
return;
}
$oldImage = $product->image_url;
$file = new UploadedFile($path, basename($path), null, null, true);
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
$product->update(['image_url' => Storage::url($storedPath)]);
if ($oldImage) {
$oldPath = str_replace('/storage/', '', $oldImage);
Storage::disk('public')->delete($oldPath);
}
$results['image_success']++;
} catch (\Throwable $e) {
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
}
}
private function isEmptyRow(array $data): bool
{
return empty($data['category'])

View File

@ -66,7 +66,8 @@ return [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
// 須大於最長 Job 的 timeoutAttachProductImagesJob 1800s否則長任務未跑完即被重派造成重複處理
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830),
'block_for' => null,
'after_commit' => false,
],

View File

@ -6,6 +6,9 @@
"30s": "30s",
"60 Seconds": "60 Seconds",
"60s": "60s",
":count image files not found": ":count image files not found",
":count images are being processed in the background": ":count images are being processed in the background",
":count images matched": ":count images matched",
":count products imported successfully": ":count products imported successfully",
":count staff cards imported successfully": ":count staff cards imported successfully",
"A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.",
@ -1407,6 +1410,7 @@
"Optional remarks": "Optional remarks",
"Optional remarks...": "Optional remarks...",
"Optional. If you fill in any field, all fields below become required.": "Optional. If you fill in any field, all fields below become required.",
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)",
"Or Choose Custom Date": "Or Choose Custom Date",
"Order Details": "Order Details",
"Order Info": "Order Info",
@ -1594,6 +1598,7 @@
"Product Details": "Product Details",
"Product ID": "Product ID",
"Product Image": "Product Image",
"Product Images (ZIP)": "Product Images (ZIP)",
"Product Info": "Product Info",
"Product Items": "Product Items",
"Product List": "Product List",
@ -2092,6 +2097,8 @@
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.",
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
"The image archive contains too many files": "The image archive contains too many files",
"The image archive is too large when extracted": "The image archive is too large when extracted",
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
@ -2182,6 +2189,7 @@
"Type": "Type",
"Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.",
"UI Elements": "UI Elements",
"Unable to read the image archive": "Unable to read the image archive",
"Unassigned": "Unassigned",
"Unassigned / No Change": "Unassigned / No Change",
"Unauthorized": "Unauthorized",

View File

@ -6,6 +6,9 @@
"30s": "30秒",
"60 Seconds": "60秒",
"60s": "60秒",
":count image files not found": ":count 個の画像ファイルが見つかりません",
":count images are being processed in the background": ":count 枚の画像をバックグラウンドで処理しています",
":count images matched": ":count 枚の画像を紐付けました",
":count products imported successfully": ":count products imported successfully",
":count staff cards imported successfully": ":count 枚のスタッフカードが正常にインポートされました",
"A new verification link has been sent to your email address.": "新しい確認リンクがメールアドレスに送信されました。",
@ -1407,6 +1410,7 @@
"Optional remarks": "任意の備考",
"Optional remarks...": "任意の備考...",
"Optional. If you fill in any field, all fields below become required.": "任意。いずれかの項目を入力すると、以下のすべての項目が必須になります。",
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "任意。画像のファイル名を「画像ファイル名」列に記入し、画像を .zip にまとめてアップロードしてください (最大 60MB)",
"Or Choose Custom Date": "または日付を選択",
"Order Details": "注文詳細",
"Order Info": "注文情報",
@ -1594,6 +1598,7 @@
"Product Details": "商品詳細",
"Product ID": "商品ID",
"Product Image": "商品画像",
"Product Images (ZIP)": "商品画像 (ZIP)",
"Product Info": "商品情報",
"Product Items": "Product Items",
"Product List": "商品リスト",
@ -2092,6 +2097,8 @@
"The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。",
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
"The image archive contains too many files": "画像アーカイブのファイル数が多すぎます",
"The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます",
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
@ -2182,6 +2189,7 @@
"Type": "タイプ",
"Type to search or leave blank for system defaults.": "入力して検索、または空白のままでシステムデフォルトを使用します。",
"UI Elements": "UI要素",
"Unable to read the image archive": "画像アーカイブを読み込めません",
"Unassigned": "未割り当て",
"Unassigned / No Change": "未割り当て / 変更なし",
"Unauthorized": "未授権",

View File

@ -6,6 +6,9 @@
"30s": "30秒",
"60 Seconds": "60 秒",
"60s": "60秒",
":count image files not found": ":count 個圖片檔找不到",
":count images are being processed in the background": ":count 張圖片背景處理中",
":count images matched": "已配對 :count 張圖片",
":count products imported successfully": "成功匯入 :count 筆商品",
":count staff cards imported successfully": "成功匯入 :count 張員工識別卡",
"A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。",
@ -1407,6 +1410,7 @@
"Optional remarks": "選填備註",
"Optional remarks...": "選填備註...",
"Optional. If you fill in any field, all fields below become required.": "選填。只要填寫任一欄位,以下欄位即全部必填。",
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "選填。在「圖片檔名」欄填寫檔名,再將圖片打包成 .zip 一併上傳 (上限 60MB)",
"Or Choose Custom Date": "或選擇自訂日期",
"Order Details": "訂單詳情",
"Order Info": "單據資訊",
@ -1594,6 +1598,7 @@
"Product Details": "商品明細",
"Product ID": "商品 ID",
"Product Image": "商品圖片",
"Product Images (ZIP)": "商品圖片壓縮檔 (ZIP)",
"Product Info": "商品資訊",
"Product Items": "商品品項",
"Product List": "商品清單",
@ -2092,6 +2097,8 @@
"The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。",
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
"The image archive contains too many files": "圖片壓縮檔內的檔案數量過多",
"The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大",
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
@ -2182,6 +2189,7 @@
"Type": "類型",
"Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。",
"UI Elements": "UI元素",
"Unable to read the image archive": "無法讀取圖片壓縮檔",
"Unassigned": "未指派",
"Unassigned / No Change": "未指派 / 不變動",
"Unauthorized": "未授權",

View File

@ -188,6 +188,27 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
</p>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Product Images (ZIP)') }}
</label>
<div class="relative group">
<input type="file" id="product-import-images" @change="importFormFields.images = $event.target.files[0]"
accept=".zip" class="luxury-input w-full pr-12 cursor-pointer">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<p class="text-xs text-slate-400 mt-2 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ __('Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)') }}
</p>
</div>
<div class="bg-slate-50 dark:bg-white/5 rounded-2xl p-4 border border-slate-100 dark:border-white/5">
<h4 class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
{{ __('Download Template') }}
@ -827,7 +848,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
names: { zh_TW: '', en: '', ja: '' },
company_id: ''
},
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null },
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null, images: null },
init() {
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
@ -1265,6 +1286,9 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
if (this.importFormFields.company_id) {
formData.append('company_id', this.importFormFields.company_id);
}
if (this.importFormFields.images) {
formData.append('images', this.importFormFields.images);
}
try {
const response = await fetch(`{{ route($baseRoute . '.import') }}`, {
@ -1280,8 +1304,15 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
if (data.success) {
this.isImportModalOpen = false;
this.importFormFields.file = null;
this.importFormFields.images = null;
const input = document.getElementById('product-import-file');
if (input) input.value = '';
const imagesInput = document.getElementById('product-import-images');
if (imagesInput) imagesInput.value = '';
if (data.results.image_missing && data.results.image_missing.length > 0) {
console.warn('Image files not found for some rows:', data.results.image_missing);
}
sessionStorage.setItem('productImportToast', JSON.stringify({
message: data.message,
type: 'success'