Просмотр исходного кода

Queue Excel product import and notify via the admin bell.

Large syncs no longer block the request; completion shows in Bagisto notifications instead of email.

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl 5 дней назад
Родитель
Сommit
fd2cd23453

+ 36 - 0
packages/Longyi/Core/src/Database/Migrations/2026_09_16_000001_create_product_sync_imports_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('product_sync_imports', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('admin_id')->nullable();
+            $table->string('original_filename');
+            $table->string('disk_path');
+            $table->string('status', 32)->default('pending');
+            $table->unsignedInteger('updated_count')->default(0);
+            $table->unsignedInteger('failed_count')->default(0);
+            $table->json('errors')->nullable();
+            $table->text('message')->nullable();
+            $table->string('locale', 20)->nullable();
+            $table->timestamp('started_at')->nullable();
+            $table->timestamp('finished_at')->nullable();
+            $table->timestamp('seen_at')->nullable();
+            $table->timestamps();
+
+            $table->index(['admin_id', 'status']);
+            $table->foreign('admin_id')->references('id')->on('admins')->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_sync_imports');
+    }
+};

+ 42 - 0
packages/Longyi/Core/src/Database/Migrations/2026_09_16_000002_add_generic_fields_to_notifications_table.php

@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->dropForeign(['order_id']);
+        });
+
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->unsignedInteger('order_id')->nullable()->change();
+            $table->string('title')->nullable()->after('type');
+            $table->text('description')->nullable()->after('title');
+            $table->string('route')->nullable()->after('description');
+            $table->unsignedInteger('admin_id')->nullable()->after('route');
+
+            $table->foreign('order_id')->references('id')->on('orders')->nullOnDelete();
+            $table->foreign('admin_id')->references('id')->on('admins')->nullOnDelete();
+            $table->index(['type', 'read']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->dropForeign(['order_id']);
+            $table->dropForeign(['admin_id']);
+            $table->dropIndex(['type', 'read']);
+            $table->dropColumn(['title', 'description', 'route', 'admin_id']);
+        });
+
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->unsignedInteger('order_id')->nullable(false)->change();
+            $table->foreign('order_id')->references('id')->on('orders')->cascadeOnDelete();
+        });
+    }
+};

+ 34 - 32
packages/Longyi/Core/src/Http/Controllers/Admin/ProductSyncController.php

@@ -7,21 +7,30 @@ use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\View\View;
 use Longyi\Core\Exports\ProductBasicInfoExport;
-use Longyi\Core\Imports\ProductBasicInfoSpreadsheet;
-use Longyi\Core\Services\ProductBasicInfoSyncException;
-use Longyi\Core\Services\ProductBasicInfoSyncService;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
 use Maatwebsite\Excel\Facades\Excel;
 use Symfony\Component\HttpFoundation\BinaryFileResponse;
 use Webkul\Admin\Http\Controllers\Controller;
 
 class ProductSyncController extends Controller
 {
-    public function __construct(protected ProductBasicInfoSyncService $syncService) {}
+    public function __construct(protected ProductSyncImportService $importService) {}
 
     public function index(): View
     {
+        $adminId = (int) auth()->guard('admin')->id();
+        $imports = $adminId ? $this->importService->recentForAdmin($adminId) : [];
+        $latestFinished = collect($imports)->first(fn (ProductSyncImport $import) => $import->isFinished());
+
         return view('longyi::admin.catalog.products.sync', [
-            'result' => session('product_sync_result'),
+            'imports' => $imports,
+            'result'  => $latestFinished ? [
+                'updated' => $latestFinished->updated_count,
+                'failed'  => $latestFinished->failed_count,
+                'errors'  => $latestFinished->errors ?? [],
+            ] : null,
         ]);
     }
 
@@ -48,36 +57,29 @@ class ProductSyncController extends Controller
             ],
         ]);
 
-        try {
-            $sheets = Excel::toArray(new ProductBasicInfoSpreadsheet, $request->file('import_file'));
-            $table = $sheets[0] ?? [];
-            $result = $this->syncService->syncFromTable($table);
-        } catch (ProductBasicInfoSyncException $e) {
-            return redirect()
-                ->route('admin.catalog.products.sync.index')
-                ->with('error', $e->getMessage());
-        } catch (\Throwable $e) {
-            return redirect()
-                ->route('admin.catalog.products.sync.index')
-                ->with('error', trans('longyi::app.product-sync.errors.generic', [
-                    'message' => $e->getMessage(),
-                ]));
-        }
+        $this->importService->queue(
+            $request->file('import_file'),
+            auth()->guard('admin')->user(),
+            app()->getLocale()
+        );
 
         return redirect()
             ->route('admin.catalog.products.sync.index')
-            ->with('product_sync_result', [
-                'updated' => $result->updated,
-                'failed'  => $result->failed,
-                'errors'  => $result->errors,
-            ])
-            ->with(
-                $result->failed > 0 ? 'warning' : 'success',
-                trans('longyi::app.product-sync.import-summary', [
-                    'updated' => $result->updated,
-                    'failed'  => $result->failed,
-                ])
-            );
+            ->with('success', trans('longyi::app.product-sync.queued'));
+    }
+
+    public function viewNotice(int $id): RedirectResponse
+    {
+        $notification = AdminNotification::query()
+            ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+            ->findOrFail($id);
+
+        $notification->read = 1;
+        $notification->save();
+
+        $route = $notification->route ?: 'admin.catalog.products.sync.index';
+
+        return redirect()->route($route);
     }
 
     protected function exportLocale(): string

+ 52 - 0
packages/Longyi/Core/src/Jobs/ImportProductBasicInfoJob.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace Longyi\Core\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
+
+class ImportProductBasicInfoJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $timeout = 900;
+
+    public int $tries = 1;
+
+    public function __construct(
+        public int $importId,
+        public string $locale,
+    ) {}
+
+    public function handle(ProductSyncImportService $service): void
+    {
+        $import = ProductSyncImport::query()->find($this->importId);
+
+        if (! $import) {
+            return;
+        }
+
+        app()->setLocale($this->locale);
+
+        $service->process($import);
+    }
+
+    public function failed(\Throwable $exception): void
+    {
+        $import = ProductSyncImport::query()->find($this->importId);
+
+        if (! $import || $import->isFinished()) {
+            return;
+        }
+
+        app(ProductSyncImportService::class)->markFailedAndNotify(
+            $import,
+            $exception->getMessage()
+        );
+    }
+}

+ 66 - 0
packages/Longyi/Core/src/Models/AdminNotification.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Webkul\Notification\Models\Notification as BaseNotification;
+
+class AdminNotification extends BaseNotification
+{
+    public const TYPE_ORDER = 'order';
+
+    public const TYPE_PRODUCT_SYNC = 'product_sync';
+
+    protected $table = 'notifications';
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'type',
+        'read',
+        'order_id',
+        'title',
+        'description',
+        'route',
+        'admin_id',
+    ];
+
+    /**
+     * @var list<string>
+     */
+    protected $appends = [
+        'datetime',
+    ];
+
+    protected static function booted(): void
+    {
+        static::addGlobalScope('visible-to-admin', function (Builder $query) {
+            if (! auth()->guard('admin')->check()) {
+                return;
+            }
+
+            $adminId = (int) auth()->guard('admin')->id();
+
+            $query->where(function (Builder $visible) use ($adminId) {
+                $visible
+                    ->where(function (Builder $orders) {
+                        $orders->where('notifications.type', self::TYPE_ORDER)
+                            ->orWhereNull('notifications.type');
+                    })
+                    ->orWhere(function (Builder $productSync) use ($adminId) {
+                        $productSync->where('notifications.type', self::TYPE_PRODUCT_SYNC)
+                            ->where(function (Builder $owner) use ($adminId) {
+                                $owner->where('notifications.admin_id', $adminId)
+                                    ->orWhereNull('notifications.admin_id');
+                            });
+                    });
+            });
+        });
+    }
+
+    public function getDatetimeAttribute(): ?string
+    {
+        return $this->created_at?->diffForHumans();
+    }
+}

+ 70 - 0
packages/Longyi/Core/src/Models/ProductSyncImport.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Webkul\User\Models\Admin;
+
+class ProductSyncImport extends Model
+{
+    public const STATUS_PENDING = 'pending';
+
+    public const STATUS_PROCESSING = 'processing';
+
+    public const STATUS_COMPLETED = 'completed';
+
+    public const STATUS_FAILED = 'failed';
+
+    protected $fillable = [
+        'admin_id',
+        'original_filename',
+        'disk_path',
+        'status',
+        'updated_count',
+        'failed_count',
+        'errors',
+        'message',
+        'locale',
+        'started_at',
+        'finished_at',
+        'seen_at',
+    ];
+
+    /**
+     * @var array<string, string>
+     */
+    protected $casts = [
+        'admin_id'      => 'integer',
+        'updated_count' => 'integer',
+        'failed_count'  => 'integer',
+        'errors'        => 'array',
+        'started_at'    => 'datetime',
+        'finished_at'   => 'datetime',
+        'seen_at'       => 'datetime',
+    ];
+
+    public function admin(): BelongsTo
+    {
+        return $this->belongsTo(Admin::class, 'admin_id');
+    }
+
+    public function isFinished(): bool
+    {
+        return in_array($this->status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true);
+    }
+
+    public function summaryMessage(): string
+    {
+        if ($this->status === self::STATUS_FAILED && filled($this->message)) {
+            return trans('longyi::app.product-sync.errors.generic', [
+                'message' => $this->message,
+            ]);
+        }
+
+        return trans('longyi::app.product-sync.import-summary', [
+            'updated' => $this->updated_count,
+            'failed'  => $this->failed_count,
+        ]);
+    }
+}

+ 5 - 0
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -121,6 +121,11 @@ class LongyiCoreServiceProvider extends ServiceProvider
             \Longyi\Core\Models\ProductImage::class
         );
 
+        $this->app->bind(
+            \Webkul\Notification\Contracts\Notification::class,
+            \Longyi\Core\Models\AdminNotification::class
+        );
+
         $this->app->singleton(
             \Webkul\Product\ProductImage::class,
             \Longyi\Core\Helpers\ProductImage::class

+ 11 - 1
packages/Longyi/Core/src/Resources/lang/en/app.php

@@ -98,11 +98,21 @@ return [
         'title'         => 'Product Sync',
         'export'        => 'Export Excel',
         'import'        => 'Upload Sync',
-        'help'          => 'Export product basics, edit the spreadsheet, then upload to update existing products by SKU. New products are not created. Images and variant options are not changed.',
+        'help'          => 'Export product basics, edit the spreadsheet, then upload to update existing products by SKU. Import runs in the background; when it finishes you will see it in the admin notification bell. New products are not created. Images and variant options are not changed.',
         'file-label'    => 'Excel file',
         'file-hint'     => 'xlsx / xls / csv. Match the exported column headers. SKU is the lookup key.',
         'columns-title' => 'Columns',
         'errors-title'  => 'Row errors',
+        'history-title' => 'Recent imports',
+        'status'        => 'Status',
+        'filename'      => 'File',
+        'queued'        => 'Import queued. You will be notified in the admin notification bell when it finishes.',
+        'statuses'      => [
+            'pending'    => 'Queued',
+            'processing' => 'Importing',
+            'completed'  => 'Completed',
+            'failed'     => 'Failed',
+        ],
         'row'           => 'Row',
         'message'       => 'Message',
         'import-summary'=> 'Updated :updated product(s), :failed row(s) failed.',

+ 11 - 1
packages/Longyi/Core/src/Resources/lang/zh_CN/app.php

@@ -96,11 +96,21 @@ return [
         'title'         => '批量同步',
         'export'        => '导出 Excel',
         'import'        => '上传同步',
-        'help'          => '先导出产品基础信息,在 Excel 中修改后再上传。按 SKU 更新已有产品,不会新建或删除,也不会改图片和规格变体。',
+        'help'          => '先导出产品基础信息,在 Excel 中修改后再上传。导入在后台异步执行,完成后会显示在后台右上角通知铃铛中。按 SKU 更新已有产品,不会新建或删除,也不会改图片和规格变体。',
         'file-label'    => 'Excel 文件',
         'file-hint'     => '支持 xlsx / xls / csv。请保持导出时的表头。SKU 为匹配键。',
         'columns-title' => '列说明',
         'errors-title'  => '失败行',
+        'history-title' => '最近导入',
+        'status'        => '状态',
+        'filename'      => '文件',
+        'queued'        => '已提交后台导入,完成后可在右上角通知中查看。',
+        'statuses'      => [
+            'pending'    => '排队中',
+            'processing' => '导入中',
+            'completed'  => '已完成',
+            'failed'     => '失败',
+        ],
         'row'           => '行号',
         'message'       => '原因',
         'import-summary'=> '已更新 :updated 个产品,失败 :failed 行。',

+ 40 - 1
packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync.blade.php

@@ -84,7 +84,46 @@
                     </ul>
                 </div>
 
-                @if (! empty($result['errors']))
+                @if (! empty($imports))
+                    <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                        <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('longyi::app.product-sync.history-title')
+                        </p>
+
+                        <div class="overflow-x-auto">
+                            <table class="w-full text-left text-sm text-gray-600 dark:text-gray-300">
+                                <thead>
+                                    <tr class="border-b dark:border-gray-800">
+                                        <th class="px-2 py-2">ID</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.filename')</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.status')</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.message')</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    @foreach ($imports as $import)
+                                        <tr class="border-b dark:border-gray-800">
+                                            <td class="px-2 py-2">{{ $import->id }}</td>
+                                            <td class="px-2 py-2">{{ $import->original_filename }}</td>
+                                            <td class="px-2 py-2">
+                                                @lang('longyi::app.product-sync.statuses.'.$import->status)
+                                            </td>
+                                            <td class="px-2 py-2">
+                                                @if ($import->isFinished())
+                                                    {{ $import->summaryMessage() }}
+                                                @else
+                                                    —
+                                                @endif
+                                            </td>
+                                        </tr>
+                                    @endforeach
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                @endif
+
+                @if (! empty($result['errors'] ?? []))
                     <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
                         <p class="mb-3 text-base font-semibold text-gray-800 dark:text-white">
                             @lang('longyi::app.product-sync.errors-title')

+ 1 - 0
packages/Longyi/Core/src/Routes/admin-routes.php

@@ -9,6 +9,7 @@ Route::group(['middleware' => ['admin']], function () {
         Route::get('/', [ProductSyncController::class, 'index'])->name('admin.catalog.products.sync.index');
         Route::get('/export', [ProductSyncController::class, 'export'])->name('admin.catalog.products.sync.export');
         Route::post('/import', [ProductSyncController::class, 'import'])->name('admin.catalog.products.sync.import');
+        Route::get('/notices/{id}', [ProductSyncController::class, 'viewNotice'])->name('admin.catalog.products.sync.notice');
     });
 
     Route::prefix('flexible-variant')->group(function () {

+ 159 - 0
packages/Longyi/Core/src/Services/ProductSyncImportService.php

@@ -0,0 +1,159 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Longyi\Core\Imports\ProductBasicInfoSpreadsheet;
+use Longyi\Core\Jobs\ImportProductBasicInfoJob;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Maatwebsite\Excel\Facades\Excel;
+use Webkul\User\Models\Admin;
+
+class ProductSyncImportService
+{
+    public const DISK = 'local';
+
+    public function __construct(protected ProductBasicInfoSyncService $syncService) {}
+
+    public function queue(UploadedFile $file, ?Admin $admin, string $locale): ProductSyncImport
+    {
+        $path = $file->store('product-sync', self::DISK);
+
+        $import = ProductSyncImport::query()->create([
+            'admin_id'          => $admin?->id,
+            'original_filename' => $file->getClientOriginalName(),
+            'disk_path'         => $path,
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => $locale,
+        ]);
+
+        ImportProductBasicInfoJob::dispatch($import->id, $locale);
+
+        return $import;
+    }
+
+    public function process(ProductSyncImport $import, bool $reindex = true): ProductSyncImport
+    {
+        if ($import->status !== ProductSyncImport::STATUS_PENDING) {
+            return $import;
+        }
+
+        $import->update([
+            'status'     => ProductSyncImport::STATUS_PROCESSING,
+            'started_at' => now(),
+        ]);
+
+        try {
+            if (! Storage::disk(self::DISK)->exists($import->disk_path)) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.empty-file'));
+            }
+
+            $sheets = Excel::toArray(
+                new ProductBasicInfoSpreadsheet,
+                $import->disk_path,
+                self::DISK
+            );
+
+            $result = $this->syncService->syncFromTable($sheets[0] ?? [], $reindex);
+
+            $import->update([
+                'status'        => ProductSyncImport::STATUS_COMPLETED,
+                'updated_count' => $result->updated,
+                'failed_count'  => $result->failed,
+                'errors'        => $result->errors,
+                'message'       => null,
+                'finished_at'   => now(),
+            ]);
+        } catch (ProductBasicInfoSyncException $e) {
+            $this->markFailed($import, $e->getMessage());
+        } catch (\Throwable $e) {
+            Log::error('Product sync import failed.', [
+                'import_id' => $import->id,
+                'message'   => $e->getMessage(),
+            ]);
+
+            $this->markFailed($import, $e->getMessage());
+        } finally {
+            $this->deleteStoredFile($import);
+        }
+
+        $import->refresh();
+
+        $this->notify($import);
+
+        return $import;
+    }
+
+    public function markFailedAndNotify(ProductSyncImport $import, string $message): void
+    {
+        if ($import->isFinished()) {
+            return;
+        }
+
+        $this->markFailed($import, $message);
+        $this->deleteStoredFile($import);
+        $this->notify($import->fresh());
+    }
+
+    public function notify(ProductSyncImport $import): void
+    {
+        try {
+            AdminNotification::query()->create([
+                'type'        => AdminNotification::TYPE_PRODUCT_SYNC,
+                'read'        => 0,
+                'order_id'    => null,
+                'title'       => $import->summaryMessage(),
+                'description' => $import->original_filename,
+                'route'       => 'admin.catalog.products.sync.index',
+                'admin_id'    => $import->admin_id,
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Product sync import notification failed.', [
+                'import_id' => $import->id,
+                'message'   => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * @return list<ProductSyncImport>
+     */
+    public function recentForAdmin(int $adminId, int $limit = 10): array
+    {
+        return ProductSyncImport::query()
+            ->where('admin_id', $adminId)
+            ->latest('id')
+            ->limit($limit)
+            ->get()
+            ->all();
+    }
+
+    protected function markFailed(ProductSyncImport $import, string $message): void
+    {
+        $import->update([
+            'status'      => ProductSyncImport::STATUS_FAILED,
+            'message'     => $message,
+            'finished_at' => now(),
+        ]);
+    }
+
+    protected function deleteStoredFile(ProductSyncImport $import): void
+    {
+        if ($import->disk_path === '' || $import->disk_path === null) {
+            return;
+        }
+
+        try {
+            Storage::disk(self::DISK)->delete($import->disk_path);
+        } catch (\Throwable $e) {
+            Log::warning('Product sync import file could not be deleted.', [
+                'import_id' => $import->id,
+                'path'      => $import->disk_path,
+                'message'   => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 124 - 0
packages/Longyi/Core/tests/Unit/ProductSyncImportServiceTest.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Longyi\Core\Tests\Unit;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Queue;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Longyi\Core\Jobs\ImportProductBasicInfoJob;
+use Longyi\Core\Models\AdminNotification;
+use Longyi\Core\Models\ProductSyncImport;
+use Longyi\Core\Services\ProductSyncImportService;
+use Longyi\Core\Tests\TestCase;
+
+class ProductSyncImportServiceTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('product_sync_imports')) {
+            $this->markTestSkipped('product_sync_imports table is missing.');
+        }
+    }
+
+    public function test_queue_stores_file_and_dispatches_job(): void
+    {
+        Queue::fake();
+        Storage::fake(ProductSyncImportService::DISK);
+
+        $file = UploadedFile::fake()->create('products.csv', 20, 'text/csv');
+
+        $import = $this->service()->queue($file, null, 'en');
+
+        $this->assertSame(ProductSyncImport::STATUS_PENDING, $import->status);
+        $this->assertSame('products.csv', $import->original_filename);
+        $this->assertTrue(Storage::disk(ProductSyncImportService::DISK)->exists($import->disk_path));
+
+        Queue::assertPushed(ImportProductBasicInfoJob::class, function (ImportProductBasicInfoJob $job) use ($import) {
+            return $job->importId === $import->id && $job->locale === 'en';
+        });
+    }
+
+    public function test_process_updates_existing_sku_and_creates_admin_notice(): void
+    {
+        if (! Schema::hasColumn('notifications', 'title')) {
+            $this->markTestSkipped('notifications.title column is missing.');
+        }
+
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-ASYNC-1']);
+        $path = 'product-sync/test-'.uniqid('', true).'.csv';
+
+        Storage::disk(ProductSyncImportService::DISK)->put(
+            $path,
+            "sku,name\nSYNC-ASYNC-1,Async Updated Name\n"
+        );
+
+        $import = ProductSyncImport::query()->create([
+            'original_filename' => 'products.csv',
+            'disk_path'         => $path,
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => 'en',
+        ]);
+
+        try {
+            $import = $this->service()->process($import, false);
+        } finally {
+            Storage::disk(ProductSyncImportService::DISK)->delete($path);
+        }
+
+        $this->assertSame(ProductSyncImport::STATUS_COMPLETED, $import->status);
+        $this->assertSame(1, $import->updated_count);
+        $this->assertSame(0, $import->failed_count);
+        $this->assertSame('Async Updated Name', $this->attributeText($product->id, 'name'));
+        $this->assertFalse(Storage::disk(ProductSyncImportService::DISK)->exists($path));
+
+        $this->assertTrue(
+            AdminNotification::query()
+                ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+                ->where('description', 'products.csv')
+                ->exists()
+        );
+    }
+
+    public function test_process_missing_file_marks_failed_and_notifies(): void
+    {
+        if (! Schema::hasColumn('notifications', 'title')) {
+            $this->markTestSkipped('notifications.title column is missing.');
+        }
+
+        $import = ProductSyncImport::query()->create([
+            'original_filename' => 'missing.csv',
+            'disk_path'         => 'product-sync/does-not-exist-'.uniqid('', true).'.csv',
+            'status'            => ProductSyncImport::STATUS_PENDING,
+            'locale'            => 'en',
+        ]);
+
+        $import = $this->service()->process($import, false);
+
+        $this->assertSame(ProductSyncImport::STATUS_FAILED, $import->status);
+        $this->assertNotSame('', (string) $import->message);
+        $this->assertTrue(
+            AdminNotification::query()
+                ->where('type', AdminNotification::TYPE_PRODUCT_SYNC)
+                ->where('description', 'missing.csv')
+                ->exists()
+        );
+    }
+
+    public function test_notice_route_is_registered(): void
+    {
+        $route = app('router')->getRoutes()->match(
+            Request::create('/admin/catalog/products/sync/notices/1', 'GET')
+        );
+
+        $this->assertSame('admin.catalog.products.sync.notice', $route->getName());
+    }
+
+    protected function service(): ProductSyncImportService
+    {
+        return app(ProductSyncImportService::class);
+    }
+}

+ 24 - 7
packages/Webkul/Admin/src/Resources/views/components/layouts/header/index.blade.php

@@ -569,26 +569,35 @@
                     <a
                         class="flex items-start gap-1.5 border-b p-3 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-950"
                         v-for="notification in notifications"
-                        :href="'{{ route('admin.notification.viewed_notification', ':orderId') }}'.replace(':orderId', notification.order_id)"
+                        :href="notificationHref(notification)"
                     >
                         <!-- Notification Icon -->
                         <span
-                            v-if="notification.order.status in notificationStatusIcon"
+                            v-if="notification.type === 'product_sync'"
+                            class="icon-done h-fit rounded-full bg-blue-100 text-2xl text-blue-600 dark:!text-blue-600"
+                        >
+                        </span>
+
+                        <span
+                            v-else-if="notification.order && notification.order.status in notificationStatusIcon"
                             class="h-fit"
                             :class="notificationStatusIcon[notification.order.status]"
                         >
                         </span>
 
                         <div class="grid">
-                            <!-- Order Id & Status -->
                             <p class="text-gray-800 dark:text-white">
-                                #@{{ notification.order.id }}
-                                @{{ orderTypeMessages[notification.order.status] }}
+                                <template v-if="notification.type === 'product_sync'">
+                                    @{{ notification.title }}
+                                </template>
+                                <template v-else>
+                                    #@{{ notification.order.id }}
+                                    @{{ orderTypeMessages[notification.order.status] }}
+                                </template>
                             </p>
 
-                            <!-- Created Date In humand Readable Format -->
                             <p class="text-xs text-gray-600 dark:text-gray-300">
-                                @{{ notification.order.datetime }}
+                                @{{ notification.order ? notification.order.datetime : (notification.datetime || notification.created_at) }}
                             </p>
                         </div>
                     </a>
@@ -690,6 +699,14 @@
                 },
 
                 methods: {
+                    notificationHref(notification) {
+                        if (notification.type === 'product_sync') {
+                            return '{{ route('admin.catalog.products.sync.notice', ['id' => '__ID__']) }}'.replace('__ID__', notification.id);
+                        }
+
+                        return '{{ route('admin.notification.viewed_notification', ':orderId') }}'.replace(':orderId', notification.order_id);
+                    },
+
                     getNotification() {
                         this.$axios.get('{{ route('admin.notification.get_notification') }}', {
                                 params: {

+ 24 - 5
packages/Webkul/Admin/src/Resources/views/notifications/index.blade.php

@@ -60,12 +60,18 @@
                             v-if="notifications.length"
                         >
                             <a
-                                :href="'{{ route('admin.notification.viewed_notification', ':orderId') }}'.replace(':orderId', notification.order_id)"
+                                :href="notificationHref(notification)"
                                 class="flex h-14 items-start gap-1.5 p-4 hover:bg-gray-50 dark:hover:bg-gray-950"
                                 v-for="notification in notifications"
                             >
                                 <span
-                                    v-if="notification.order.status in orderType"
+                                    v-if="notification.type === 'product_sync'"
+                                    class="icon-done h-fit rounded-full bg-blue-100 text-2xl text-blue-600 dark:!text-blue-600"
+                                >
+                                </span>
+
+                                <span
+                                    v-else-if="notification.order && notification.order.status in orderType"
                                     class="h-fit rounded-full text-2xl"
                                     :class="orderType[notification.order.status].icon"
                                 >
@@ -76,12 +82,17 @@
                                         class="text-gray-800 dark:text-white"
                                         :class="notification.read ? 'font-normal' : 'font-semibold'"
                                     >
-                                        #@{{ notification.order.id }}
-                                        @{{ orderType[notification.order.status].message }}
+                                        <template v-if="notification.type === 'product_sync'">
+                                            @{{ notification.title }}
+                                        </template>
+                                        <template v-else>
+                                            #@{{ notification.order.id }}
+                                            @{{ orderType[notification.order.status].message }}
+                                        </template>
                                     </p>
 
                                     <p class="text-xs text-gray-600 dark:text-gray-300">
-                                        @{{ notification.order.datetime }}
+                                        @{{ notification.order ? notification.order.datetime : (notification.datetime || notification.created_at) }}
                                     </p>
                                 </div>
                             </a>
@@ -197,6 +208,14 @@
                 },
 
                 methods: {
+                    notificationHref(notification) {
+                        if (notification.type === 'product_sync') {
+                            return '{{ route('admin.catalog.products.sync.notice', ['id' => '__ID__']) }}'.replace('__ID__', notification.id);
+                        }
+
+                        return '{{ route('admin.notification.viewed_notification', ':orderId') }}'.replace(':orderId', notification.order_id);
+                    },
+
                     getNotification() {
                         const params = {};