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

Add admin Excel export/import to batch-update existing product basics by SKU.

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl 1 месяц назад
Родитель
Сommit
94dd170cfb

+ 20 - 0
packages/Longyi/Core/src/Config/acl.php

@@ -0,0 +1,20 @@
+<?php
+
+return [
+    [
+        'key'   => 'catalog.product_sync',
+        'name'  => 'longyi::app.product-sync.title',
+        'route' => 'admin.catalog.products.sync.index',
+        'sort'  => 5,
+    ], [
+        'key'   => 'catalog.product_sync.export',
+        'name'  => 'longyi::app.product-sync.export',
+        'route' => 'admin.catalog.products.sync.export',
+        'sort'  => 1,
+    ], [
+        'key'   => 'catalog.product_sync.import',
+        'name'  => 'longyi::app.product-sync.import',
+        'route' => 'admin.catalog.products.sync.import',
+        'sort'  => 2,
+    ],
+];

+ 11 - 0
packages/Longyi/Core/src/Config/admin-menu.php

@@ -0,0 +1,11 @@
+<?php
+
+return [
+    [
+        'key'   => 'catalog.product_sync',
+        'name'  => 'longyi::app.product-sync.title',
+        'route' => 'admin.catalog.products.sync.index',
+        'sort'  => 2,
+        'icon'  => '',
+    ],
+];

+ 139 - 0
packages/Longyi/Core/src/Exports/ProductBasicInfoExport.php

@@ -0,0 +1,139 @@
+<?php
+
+namespace Longyi\Core\Exports;
+
+use Illuminate\Support\Facades\DB;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Maatwebsite\Excel\Concerns\FromQuery;
+use Maatwebsite\Excel\Concerns\ShouldAutoSize;
+use Maatwebsite\Excel\Concerns\WithHeadings;
+use Maatwebsite\Excel\Concerns\WithMapping;
+
+class ProductBasicInfoExport implements FromQuery, ShouldAutoSize, WithHeadings, WithMapping
+{
+    /**
+     * @var array<int, string>
+     */
+    protected array $categoryNames = [];
+
+    /**
+     * @var array<int, int>
+     */
+    protected array $quantities = [];
+
+    public function __construct(
+        protected string $locale,
+        protected ?string $channel = null,
+    ) {
+        $this->categoryNames = $this->loadCategoryNames();
+        $this->quantities = $this->loadQuantities();
+    }
+
+    public function headings(): array
+    {
+        return ProductBasicInfoColumns::HEADINGS;
+    }
+
+    public function query()
+    {
+        $query = DB::table('product_flat')
+            ->select([
+                'product_flat.product_id',
+                'product_flat.sku',
+                'product_flat.locale',
+                'product_flat.name',
+                'product_flat.url_key',
+                'product_flat.short_description',
+                'product_flat.description',
+                'product_flat.price',
+                'product_flat.special_price',
+                'product_flat.weight',
+                'product_flat.status',
+                'product_flat.meta_title',
+                'product_flat.meta_keywords',
+                'product_flat.meta_description',
+            ])
+            ->where('product_flat.locale', $this->locale)
+            ->orderBy('product_flat.id');
+
+        if ($this->channel) {
+            $query->where('product_flat.channel', $this->channel);
+        }
+
+        return $query;
+    }
+
+    /**
+     * @param  object  $row
+     */
+    public function map($row): array
+    {
+        $productId = (int) $row->product_id;
+
+        return [
+            $row->sku,
+            $row->locale,
+            $row->name,
+            $row->url_key,
+            $row->short_description,
+            $row->description,
+            $row->price,
+            $row->special_price,
+            $row->weight,
+            $row->status === null ? '' : (int) $row->status,
+            $this->quantities[$productId] ?? '',
+            $this->categoryNames[$productId] ?? '',
+            $row->meta_title,
+            $row->meta_keywords,
+            $row->meta_description,
+        ];
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    protected function loadCategoryNames(): array
+    {
+        $locale = $this->locale;
+
+        $rows = DB::table('product_categories as pc')
+            ->join('category_translations as ct', function ($join) use ($locale) {
+                $join->on('pc.category_id', '=', 'ct.category_id')
+                    ->where('ct.locale', $locale);
+            })
+            ->orderBy('pc.product_id')
+            ->orderBy('pc.position')
+            ->get(['pc.product_id', 'ct.name']);
+
+        $map = [];
+
+        foreach ($rows as $row) {
+            $productId = (int) $row->product_id;
+            $name = trim((string) $row->name);
+
+            if ($name === '') {
+                continue;
+            }
+
+            $map[$productId][] = $name;
+        }
+
+        return array_map(
+            fn (array $names) => implode('|', array_values(array_unique($names))),
+            $map
+        );
+    }
+
+    /**
+     * @return array<int, int>
+     */
+    protected function loadQuantities(): array
+    {
+        return DB::table('product_inventories')
+            ->select('product_id', DB::raw('SUM(qty) as qty'))
+            ->groupBy('product_id')
+            ->pluck('qty', 'product_id')
+            ->map(fn ($qty) => (int) $qty)
+            ->all();
+    }
+}

+ 99 - 0
packages/Longyi/Core/src/Http/Controllers/Admin/ProductSyncController.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace Longyi\Core\Http\Controllers\Admin;
+
+use Illuminate\Http\RedirectResponse;
+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 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 index(): View
+    {
+        return view('longyi::admin.catalog.products.sync', [
+            'result' => session('product_sync_result'),
+        ]);
+    }
+
+    public function export(): BinaryFileResponse
+    {
+        $locale = $this->exportLocale();
+        $channel = core()->getDefaultChannelCode();
+
+        return Excel::download(
+            new ProductBasicInfoExport($locale, $channel),
+            'products-basic-info-'.now()->format('Y-m-d').'.xlsx'
+        );
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $request->validate([
+            'import_file' => [
+                'required',
+                'file',
+                'extensions:xlsx,xls,csv',
+                'mimetypes:text/csv,text/plain,application/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                'max:10240',
+            ],
+        ]);
+
+        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(),
+                ]));
+        }
+
+        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,
+                ])
+            );
+    }
+
+    protected function exportLocale(): string
+    {
+        $locale = core()->getRequestedLocaleCode();
+        $channel = core()->getDefaultChannelCode();
+
+        $exists = DB::table('product_flat')
+            ->where('locale', $locale)
+            ->when($channel, fn ($query) => $query->where('channel', $channel))
+            ->exists();
+
+        if ($exists) {
+            return $locale;
+        }
+
+        return core()->getDefaultLocaleCodeFromDefaultChannel();
+    }
+}

+ 10 - 0
packages/Longyi/Core/src/Imports/ProductBasicInfoSpreadsheet.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Longyi\Core\Imports;
+
+use Maatwebsite\Excel\Concerns\ToArray;
+
+class ProductBasicInfoSpreadsheet implements ToArray
+{
+    public function array(array $array): void {}
+}

+ 23 - 10
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -15,26 +15,26 @@ class LongyiCoreServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
-        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+        $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
 
-        $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'longyi');
+        $this->loadTranslationsFrom(__DIR__.'/../Resources/lang', 'longyi');
 
-        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'longyi');
+        $this->loadViewsFrom(__DIR__.'/../Resources/views', 'longyi');
 
         Blade::anonymousComponentPath(__DIR__.'/../Resources/views/components', 'longyi');
 
         $this->registerRoutes();
 
         $this->publishes([
-            __DIR__ . '/../Config/flexible_variant.php' => config_path('flexible_variant.php'),
+            __DIR__.'/../Config/flexible_variant.php' => config_path('flexible_variant.php'),
         ], 'longyi-config');
 
         $this->publishes([
-            __DIR__ . '/../Resources/views' => resource_path('views/vendor/longyi'),
+            __DIR__.'/../Resources/views' => resource_path('views/vendor/longyi'),
         ], 'longyi-views');
 
         $this->publishes([
-            __DIR__ . '/../Resources/lang' => lang_path('vendor/longyi'),
+            __DIR__.'/../Resources/lang' => lang_path('vendor/longyi'),
         ], 'longyi-lang');
 
         $this->registerEventListeners();
@@ -48,6 +48,10 @@ class LongyiCoreServiceProvider extends ServiceProvider
         Event::listen('checkout.order.save.after', [VariantInventoryListener::class, 'decrementOnOrderSave']);
 
         Event::listen('sales.order.cancel.after', [VariantInventoryListener::class, 'restoreOnOrderCancel']);
+
+        Event::listen('bagisto.admin.catalog.products.create.before', function ($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('longyi::admin.catalog.products.sync-button');
+        });
     }
 
     /**
@@ -56,15 +60,25 @@ class LongyiCoreServiceProvider extends ServiceProvider
     public function register(): void
     {
         $this->mergeConfigFrom(
-            __DIR__ . '/../Config/flexible_variant.php',
+            __DIR__.'/../Config/flexible_variant.php',
             'flexible_variant'
         );
 
         $this->mergeConfigFrom(
-            __DIR__ . '/../Config/product_types.php',
+            __DIR__.'/../Config/product_types.php',
             'product_types'
         );
 
+        $this->mergeConfigFrom(
+            __DIR__.'/../Config/admin-menu.php',
+            'menu.admin'
+        );
+
+        $this->mergeConfigFrom(
+            __DIR__.'/../Config/acl.php',
+            'acl'
+        );
+
         $this->registerContracts();
 
         $this->registerRepositories();
@@ -77,7 +91,7 @@ class LongyiCoreServiceProvider extends ServiceProvider
     {
         Route::middleware('web')
             ->prefix('admin')
-            ->group(__DIR__ . '/../Routes/admin-routes.php');
+            ->group(__DIR__.'/../Routes/admin-routes.php');
     }
 
     /**
@@ -107,7 +121,6 @@ class LongyiCoreServiceProvider extends ServiceProvider
             \Longyi\Core\Models\ProductImage::class
         );
 
-
         $this->app->singleton(
             \Webkul\Product\ProductImage::class,
             \Longyi\Core\Helpers\ProductImage::class

+ 101 - 65
packages/Longyi/Core/src/Resources/lang/en/app.php

@@ -2,95 +2,131 @@
 
 return [
     'flexible-variant' => [
-        'title' => 'Flexible Variant',
+        'title'        => 'Flexible Variant',
         'product-type' => 'Flexible Variant Product',
-        
+
         'options' => [
-            'title' => 'Product Options',
-            'create' => 'Create Option',
-            'edit' => 'Edit Option',
-            'delete' => 'Delete Option',
-            'label' => 'Option Label',
-            'code' => 'Option Code',
-            'type' => 'Option Type',
-            'position' => 'Position',
+            'title'       => 'Product Options',
+            'create'      => 'Create Option',
+            'edit'        => 'Edit Option',
+            'delete'      => 'Delete Option',
+            'label'       => 'Option Label',
+            'code'        => 'Option Code',
+            'type'        => 'Option Type',
+            'position'    => 'Position',
             'is-required' => 'Required',
-            'meta' => 'Meta Data',
-            'values' => 'Option Values',
-            'empty' => 'No options created yet',
-            
+            'meta'        => 'Meta Data',
+            'values'      => 'Option Values',
+            'empty'       => 'No options created yet',
+
             'types' => [
-                'select' => 'Select Dropdown',
-                'radio' => 'Radio Buttons',
+                'select'   => 'Select Dropdown',
+                'radio'    => 'Radio Buttons',
                 'checkbox' => 'Checkboxes',
-                'color' => 'Color Swatches',
-                'button' => 'Button Select',
+                'color'    => 'Color Swatches',
+                'button'   => 'Button Select',
             ],
         ],
-        
+
         'option-values' => [
-            'title' => 'Option Values',
-            'create' => 'Add Value',
-            'edit' => 'Edit Value',
-            'delete' => 'Delete Value',
-            'label' => 'Value Label',
-            'code' => 'Value Code',
+            'title'    => 'Option Values',
+            'create'   => 'Add Value',
+            'edit'     => 'Edit Value',
+            'delete'   => 'Delete Value',
+            'label'    => 'Value Label',
+            'code'     => 'Value Code',
             'position' => 'Position',
-            'meta' => 'Meta Data',
+            'meta'     => 'Meta Data',
         ],
-        
+
         'variants' => [
-            'title' => 'Product Variants',
-            'create' => 'Create Variant',
-            'edit' => 'Edit Variant',
-            'delete' => 'Delete Variant',
-            'sku' => 'SKU',
-            'name' => 'Variant Name',
-            'price' => 'Price',
-            'compare-price' => 'Compare Price',
-            'special-price' => 'Special Price',
+            'title'              => 'Product Variants',
+            'create'             => 'Create Variant',
+            'edit'               => 'Edit Variant',
+            'delete'             => 'Delete Variant',
+            'sku'                => 'SKU',
+            'name'               => 'Variant Name',
+            'price'              => 'Price',
+            'compare-price'      => 'Compare Price',
+            'special-price'      => 'Special Price',
             'special-price-from' => 'Special Price From',
-            'special-price-to' => 'Special Price To',
-            'cost' => 'Cost',
-            'weight' => 'Weight',
-            'quantity' => 'Quantity',
-            'status' => 'Status',
-            'images' => 'Images',
-            'sort-order' => 'Sort Order',
-            'option-values' => 'Option Values',
-            'saleable' => 'Saleable',
-            'not-saleable' => 'Not Saleable',
-            'empty' => 'No variants created yet',
-            
+            'special-price-to'   => 'Special Price To',
+            'cost'               => 'Cost',
+            'weight'             => 'Weight',
+            'quantity'           => 'Quantity',
+            'status'             => 'Status',
+            'images'             => 'Images',
+            'sort-order'         => 'Sort Order',
+            'option-values'      => 'Option Values',
+            'saleable'           => 'Saleable',
+            'not-saleable'       => 'Not Saleable',
+            'empty'              => 'No variants created yet',
+
             'bulk' => [
                 'update-quantities' => 'Update Quantities',
-                'update-status' => 'Update Status',
-                'enable' => 'Enable Selected',
-                'disable' => 'Disable Selected',
+                'update-status'     => 'Update Status',
+                'enable'            => 'Enable Selected',
+                'disable'           => 'Disable Selected',
             ],
         ],
-        
+
         'messages' => [
-            'option-created' => 'Option created successfully',
-            'option-updated' => 'Option updated successfully',
-            'option-deleted' => 'Option deleted successfully',
+            'option-created'   => 'Option created successfully',
+            'option-updated'   => 'Option updated successfully',
+            'option-deleted'   => 'Option deleted successfully',
             'options-attached' => 'Options attached successfully',
-            
-            'variant-created' => 'Variant created successfully',
-            'variant-updated' => 'Variant updated successfully',
-            'variant-deleted' => 'Variant deleted successfully',
+
+            'variant-created'  => 'Variant created successfully',
+            'variant-updated'  => 'Variant updated successfully',
+            'variant-deleted'  => 'Variant deleted successfully',
             'variants-updated' => 'Variants updated successfully',
-            
-            'variant-exists' => 'A variant with these option values already exists',
-            'option-not-found' => 'Option not found',
+
+            'variant-exists'    => 'A variant with these option values already exists',
+            'option-not-found'  => 'Option not found',
             'variant-not-found' => 'Variant not found',
             'product-not-found' => 'Product not found',
         ],
-        
+
         'validations' => [
-            'option-required' => 'Option :option is required',
+            'option-required'           => 'Option :option is required',
             'combination-not-available' => 'Selected combination is not available',
-            'variant-not-saleable' => 'Selected variant is not available for purchase',
+            'variant-not-saleable'      => 'Selected variant is not available for purchase',
+        ],
+    ],
+
+    'product-sync' => [
+        '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.',
+        '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',
+        'row'           => 'Row',
+        'message'       => 'Message',
+        'import-summary'=> 'Updated :updated product(s), :failed row(s) failed.',
+        'columns'       => [
+            'sku'        => 'sku: existing product SKU (required, not used to rename SKU)',
+            'locale'     => 'locale: store locale code; empty uses the default channel locale',
+            'core'       => 'name, url_key, short_description, description, price, special_price, weight, status (1/0), SEO fields',
+            'qty'        => 'qty: parent inventory; empty keeps the current quantity. Variant stock is not updated.',
+            'categories' => 'categories: current-locale category names separated by |. Empty keeps current categories. Unknown names fail the row.',
+        ],
+        'errors'        => [
+            'empty-file'            => 'The uploaded file is empty.',
+            'sku-column-missing'    => 'The file must include a sku column.',
+            'sku-required'          => 'SKU is required.',
+            'sku-not-found'         => 'SKU was not found.',
+            'duplicate-sku'         => 'Duplicate SKU (already used on row :row).',
+            'field-required'        => ':field cannot be empty.',
+            'url-key-taken'         => 'url_key is already used by another product.',
+            'unknown-category'      => 'Unknown category: :name',
+            'invalid-qty'           => 'qty must be a number.',
+            'invalid-status'        => 'status must be 1 or 0.',
+            'invalid-number'        => ':field must be a number.',
+            'no-inventory-source'   => 'No inventory source is configured.',
+            'generic'               => 'Import failed: :message',
         ],
     ],
 ];

+ 99 - 63
packages/Longyi/Core/src/Resources/lang/zh_CN/app.php

@@ -2,93 +2,129 @@
 
 return [
     'flexible-variant' => [
-        'title' => '灵活变体',
+        'title'        => '灵活变体',
         'product-type' => '灵活变体产品',
-        
+
         'options' => [
-            'title' => '产品选项',
-            'create' => '创建选项',
-            'edit' => '编辑选项',
-            'delete' => '删除选项',
-            'label' => '选项标签',
-            'code' => '选项代码',
-            'type' => '选项类型',
-            'position' => '排序',
+            'title'       => '产品选项',
+            'create'      => '创建选项',
+            'edit'        => '编辑选项',
+            'delete'      => '删除选项',
+            'label'       => '选项标签',
+            'code'        => '选项代码',
+            'type'        => '选项类型',
+            'position'    => '排序',
             'is-required' => '必选',
-            'meta' => '元数据',
-            'values' => '选项值',
-            
+            'meta'        => '元数据',
+            'values'      => '选项值',
+
             'types' => [
-                'select' => '下拉选择',
-                'radio' => '单选按钮',
+                'select'   => '下拉选择',
+                'radio'    => '单选按钮',
                 'checkbox' => '复选框',
-                'color' => '颜色选择器',
-                'button' => '按钮选择',
+                'color'    => '颜色选择器',
+                'button'   => '按钮选择',
             ],
         ],
-        
+
         'option-values' => [
-            'title' => '选项值',
-            'create' => '添加值',
-            'edit' => '编辑值',
-            'delete' => '删除值',
-            'label' => '值标签',
-            'code' => '值代码',
+            'title'    => '选项值',
+            'create'   => '添加值',
+            'edit'     => '编辑值',
+            'delete'   => '删除值',
+            'label'    => '值标签',
+            'code'     => '值代码',
             'position' => '排序',
-            'meta' => '元数据',
+            'meta'     => '元数据',
         ],
-        
+
         'variants' => [
-            'title' => '产品变体',
-            'create' => '创建变体',
-            'edit' => '编辑变体',
-            'delete' => '删除变体',
-            'sku' => 'SKU',
-            'name' => '变体名称',
-            'price' => '价格',
-            'compare-price' => '对比价格',
-            'special-price' => '特价',
+            'title'              => '产品变体',
+            'create'             => '创建变体',
+            'edit'               => '编辑变体',
+            'delete'             => '删除变体',
+            'sku'                => 'SKU',
+            'name'               => '变体名称',
+            'price'              => '价格',
+            'compare-price'      => '对比价格',
+            'special-price'      => '特价',
             'special-price-from' => '特价开始日期',
-            'special-price-to' => '特价结束日期',
-            'cost' => '成本价',
-            'weight' => '重量',
-            'quantity' => '库存数量',
-            'status' => '状态',
-            'images' => '图片',
-            'sort-order' => '排序',
-            'option-values' => '选项值',
-            'saleable' => '可售',
-            'not-saleable' => '不可售',
-            
+            'special-price-to'   => '特价结束日期',
+            'cost'               => '成本价',
+            'weight'             => '重量',
+            'quantity'           => '库存数量',
+            'status'             => '状态',
+            'images'             => '图片',
+            'sort-order'         => '排序',
+            'option-values'      => '选项值',
+            'saleable'           => '可售',
+            'not-saleable'       => '不可售',
+
             'bulk' => [
                 'update-quantities' => '批量更新库存',
-                'update-status' => '批量更新状态',
-                'enable' => '启用选中项',
-                'disable' => '禁用选中项',
+                'update-status'     => '批量更新状态',
+                'enable'            => '启用选中项',
+                'disable'           => '禁用选中项',
             ],
         ],
-        
+
         'messages' => [
-            'option-created' => '选项创建成功',
-            'option-updated' => '选项更新成功',
-            'option-deleted' => '选项删除成功',
+            'option-created'   => '选项创建成功',
+            'option-updated'   => '选项更新成功',
+            'option-deleted'   => '选项删除成功',
             'options-attached' => '选项关联成功',
-            
-            'variant-created' => '变体创建成功',
-            'variant-updated' => '变体更新成功',
-            'variant-deleted' => '变体删除成功',
+
+            'variant-created'  => '变体创建成功',
+            'variant-updated'  => '变体更新成功',
+            'variant-deleted'  => '变体删除成功',
             'variants-updated' => '变体更新成功',
-            
-            'variant-exists' => '具有这些选项值的变体已存在',
-            'option-not-found' => '选项未找到',
+
+            'variant-exists'    => '具有这些选项值的变体已存在',
+            'option-not-found'  => '选项未找到',
             'variant-not-found' => '变体未找到',
             'product-not-found' => '产品未找到',
         ],
-        
+
         'validations' => [
-            'option-required' => '选项 :option 是必选的',
+            'option-required'           => '选项 :option 是必选的',
             'combination-not-available' => '所选组合不可用',
-            'variant-not-saleable' => '所选变体无法购买',
+            'variant-not-saleable'      => '所选变体无法购买',
+        ],
+    ],
+
+    'product-sync' => [
+        'title'         => '批量同步',
+        'export'        => '导出 Excel',
+        'import'        => '上传同步',
+        'help'          => '先导出产品基础信息,在 Excel 中修改后再上传。按 SKU 更新已有产品,不会新建或删除,也不会改图片和规格变体。',
+        'file-label'    => 'Excel 文件',
+        'file-hint'     => '支持 xlsx / xls / csv。请保持导出时的表头。SKU 为匹配键。',
+        'columns-title' => '列说明',
+        'errors-title'  => '失败行',
+        'row'           => '行号',
+        'message'       => '原因',
+        'import-summary'=> '已更新 :updated 个产品,失败 :failed 行。',
+        'columns'       => [
+            'sku'        => 'sku:已有产品 SKU(必填,不能用来改 SKU)',
+            'locale'     => 'locale:语言代码;留空则使用默认渠道语言',
+            'core'       => 'name、url_key、short_description、description、price、special_price、weight、status(1/0)、SEO 字段',
+            'qty'        => 'qty:父商品库存;留空则不改。变体库存本版不更新。',
+            'categories' => 'categories:当前语言分类名,用 | 分隔。留空则不改分类。任一名称找不到则整行失败。',
+        ],
+        'errors'        => [
+            'empty-file'            => '上传文件为空。',
+            'sku-column-missing'    => '文件必须包含 sku 列。',
+            'sku-required'          => 'SKU 不能为空。',
+            'sku-not-found'         => '找不到该 SKU。',
+            'duplicate-sku'         => 'SKU 重复(已在第 :row 行出现)。',
+            'field-required'        => ':field 不能为空。',
+            'url-key-taken'         => 'url_key 已被其他产品占用。',
+            'unknown-category'      => '未知分类::name',
+            'invalid-qty'           => 'qty 必须是数字。',
+            'invalid-status'        => 'status 必须是 1 或 0。',
+            'invalid-number'        => ':field 必须是数字。',
+            'no-inventory-source'   => '未配置库存来源。',
+            'generic'               => '导入失败::message',
         ],
     ],
 ];

+ 9 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/sync-button.blade.php

@@ -0,0 +1,9 @@
+@if (bouncer()->hasPermission('catalog.product_sync'))
+    <a
+        href="{{ route('admin.catalog.products.sync.index') }}"
+        class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+    >
+        <span class="icon-export text-xl text-gray-600"></span>
+        @lang('longyi::app.product-sync.title')
+    </a>
+@endif

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

@@ -0,0 +1,119 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('longyi::app.product-sync.title')
+    </x-slot>
+
+    {!! view_render_event('bagisto.admin.catalog.products.sync.before') !!}
+
+    <x-admin::form
+        :action="route('admin.catalog.products.sync.import')"
+        method="POST"
+        enctype="multipart/form-data"
+    >
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            <p class="text-xl font-bold text-gray-800 dark:text-white">
+                @lang('longyi::app.product-sync.title')
+            </p>
+
+            <div class="flex items-center gap-x-2.5">
+                @if (bouncer()->hasPermission('catalog.product_sync.export'))
+                    <a
+                        href="{{ route('admin.catalog.products.sync.export') }}"
+                        class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+                    >
+                        <span class="icon-export text-xl text-gray-600"></span>
+                        @lang('longyi::app.product-sync.export')
+                    </a>
+                @endif
+
+                @if (bouncer()->hasPermission('catalog.product_sync.import'))
+                    <button
+                        type="submit"
+                        class="primary-button"
+                    >
+                        @lang('longyi::app.product-sync.import')
+                    </button>
+                @endif
+            </div>
+        </div>
+
+        <div class="mt-3.5 flex gap-2.5 max-xl:flex-wrap">
+            <div class="flex flex-1 flex-col gap-2 max-xl:flex-auto">
+                <div class="box-shadow rounded bg-white p-4 dark:bg-gray-900">
+                    <p class="mb-4 text-base font-semibold text-gray-800 dark:text-white">
+                        @lang('longyi::app.product-sync.import')
+                    </p>
+
+                    <p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
+                        @lang('longyi::app.product-sync.help')
+                    </p>
+
+                    <x-admin::form.control-group>
+                        <x-admin::form.control-group.label class="required">
+                            @lang('longyi::app.product-sync.file-label')
+                        </x-admin::form.control-group.label>
+
+                        <x-admin::form.control-group.control
+                            type="file"
+                            name="import_file"
+                            rules="required"
+                            accept=".xlsx,.xls,.csv"
+                            :label="trans('longyi::app.product-sync.file-label')"
+                        />
+
+                        <x-admin::form.control-group.error control-name="import_file" />
+
+                        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                            @lang('longyi::app.product-sync.file-hint')
+                        </p>
+                    </x-admin::form.control-group>
+                </div>
+
+                <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.columns-title')
+                    </p>
+
+                    <ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-300">
+                        <li>@lang('longyi::app.product-sync.columns.sku')</li>
+                        <li>@lang('longyi::app.product-sync.columns.locale')</li>
+                        <li>@lang('longyi::app.product-sync.columns.core')</li>
+                        <li>@lang('longyi::app.product-sync.columns.qty')</li>
+                        <li>@lang('longyi::app.product-sync.columns.categories')</li>
+                    </ul>
+                </div>
+
+                @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')
+                        </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">@lang('longyi::app.product-sync.row')</th>
+                                        <th class="px-2 py-2">SKU</th>
+                                        <th class="px-2 py-2">@lang('longyi::app.product-sync.message')</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    @foreach ($result['errors'] as $error)
+                                        <tr class="border-b dark:border-gray-800">
+                                            <td class="px-2 py-2">{{ $error['row'] }}</td>
+                                            <td class="px-2 py-2">{{ $error['sku'] }}</td>
+                                            <td class="px-2 py-2 text-red-600">{{ $error['message'] }}</td>
+                                        </tr>
+                                    @endforeach
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                @endif
+            </div>
+        </div>
+    </x-admin::form>
+
+    {!! view_render_event('bagisto.admin.catalog.products.sync.after') !!}
+</x-admin::layouts>

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

@@ -2,10 +2,17 @@
 
 use Illuminate\Support\Facades\Route;
 use Longyi\Core\Http\Controllers\Admin\FlexibleVariantController;
+use Longyi\Core\Http\Controllers\Admin\ProductSyncController;
 
 Route::group(['middleware' => ['admin']], function () {
+    Route::prefix('catalog/products/sync')->group(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::prefix('flexible-variant')->group(function () {
-        
+
         // ==================== OPTION ROUTES ====================
         Route::prefix('options')->group(function () {
             Route::get('/', [FlexibleVariantController::class, 'getOptions'])->name('admin.flexible_variant.options.index');

+ 7 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use RuntimeException;
+
+class ProductBasicInfoSyncException extends RuntimeException {}

+ 26 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncResult.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+class ProductBasicInfoSyncResult
+{
+    public int $updated = 0;
+
+    public int $failed = 0;
+
+    /**
+     * @var list<array{row: int, sku: string, message: string}>
+     */
+    public array $errors = [];
+
+    public function addError(int $row, string $sku, string $message): void
+    {
+        $this->failed++;
+
+        $this->errors[] = [
+            'row'     => $row,
+            'sku'     => $sku,
+            'message' => $message,
+        ];
+    }
+}

+ 503 - 0
packages/Longyi/Core/src/Services/ProductBasicInfoSyncService.php

@@ -0,0 +1,503 @@
+<?php
+
+namespace Longyi\Core\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Webkul\Attribute\Repositories\AttributeRepository;
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
+use Webkul\Product\Helpers\Indexers\Flat;
+use Webkul\Product\Helpers\Indexers\Price;
+use Webkul\Product\Models\Product;
+use Webkul\Product\Repositories\ProductAttributeValueRepository;
+use Webkul\Product\Repositories\ProductRepository;
+
+class ProductBasicInfoSyncService
+{
+    /**
+     * @var array<string, int>
+     */
+    protected array $productsBySku = [];
+
+    public function __construct(
+        protected ProductRepository $productRepository,
+        protected ProductAttributeValueRepository $productAttributeValueRepository,
+        protected AttributeRepository $attributeRepository,
+    ) {}
+
+    /**
+     * @param  list<list<mixed>>  $table  First row is headings.
+     */
+    public function syncFromTable(array $table, bool $reindex = true): ProductBasicInfoSyncResult
+    {
+        return $this->sync($this->mapTableToRows($table), $reindex);
+    }
+
+    /**
+     * @param  list<list<mixed>>  $table
+     * @return list<array<string, mixed>>
+     */
+    public function mapTableToRows(array $table): array
+    {
+        if ($table === []) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.empty-file'));
+        }
+
+        $headings = [];
+
+        foreach ((array) array_shift($table) as $heading) {
+            $headings[] = strtolower(trim((string) $heading));
+        }
+
+        if (! in_array(ProductBasicInfoColumns::SKU, $headings, true)) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-column-missing'));
+        }
+
+        $rows = [];
+
+        foreach ($table as $line) {
+            $row = [];
+
+            foreach ($headings as $index => $heading) {
+                if ($heading === '') {
+                    continue;
+                }
+
+                $row[$heading] = $line[$index] ?? null;
+            }
+
+            $rows[] = $row;
+        }
+
+        return $rows;
+    }
+
+    /**
+     * @param  list<array<string, mixed>>  $rows
+     */
+    public function sync(array $rows, bool $reindex = true): ProductBasicInfoSyncResult
+    {
+        $result = new ProductBasicInfoSyncResult;
+        $touchedProductIds = [];
+        $seenSkus = [];
+
+        foreach ($rows as $index => $row) {
+            $excelRow = $index + 2;
+            $sku = $this->cellString($row[ProductBasicInfoColumns::SKU] ?? '');
+
+            if ($sku === '' && $this->rowIsEmpty($row)) {
+                continue;
+            }
+
+            if ($sku === '') {
+                $result->addError($excelRow, '', trans('longyi::app.product-sync.errors.sku-required'));
+
+                continue;
+            }
+
+            if (isset($seenSkus[$sku])) {
+                $result->addError(
+                    $excelRow,
+                    $sku,
+                    trans('longyi::app.product-sync.errors.duplicate-sku', ['row' => $seenSkus[$sku]])
+                );
+
+                continue;
+            }
+
+            $seenSkus[$sku] = $excelRow;
+
+            try {
+                $productId = $this->syncRow($row, $sku);
+                $result->updated++;
+                $touchedProductIds[] = $productId;
+            } catch (ProductBasicInfoSyncException $e) {
+                $result->addError($excelRow, $sku, $e->getMessage());
+            } catch (\Throwable $e) {
+                $result->addError($excelRow, $sku, $e->getMessage());
+            }
+        }
+
+        if ($reindex) {
+            $this->reindexProducts($touchedProductIds);
+        }
+
+        return $result;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    protected function syncRow(array $row, string $sku): int
+    {
+        $product = $this->findProductBySku($sku);
+
+        if (! $product) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.sku-not-found'));
+        }
+
+        $locale = $this->cellString($row[ProductBasicInfoColumns::LOCALE] ?? '');
+
+        if ($locale === '') {
+            $locale = core()->getDefaultLocaleCodeFromDefaultChannel();
+        }
+
+        $channel = core()->getDefaultChannelCode();
+        $presentCodes = $this->presentAttributeCodes($row);
+        $payload = [
+            'channel' => $channel,
+            'locale'  => $locale,
+        ];
+
+        foreach ($presentCodes as $code) {
+            $raw = $row[$code] ?? null;
+            $value = $this->normalizeAttributeValue($code, $raw);
+
+            if (
+                in_array($code, ProductBasicInfoColumns::REQUIRED_ATTRIBUTE_CODES, true)
+                && $this->isEmptyValue($value)
+            ) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.field-required', [
+                    'field' => $code,
+                ]));
+            }
+
+            if ($code === 'status' && $this->isEmptyValue($raw)) {
+                continue;
+            }
+
+            if ($code === 'url_key' && ! $this->isUrlKeyUnique($product->id, (string) $value, $locale)) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.url-key-taken'));
+            }
+
+            $payload[$code] = $value;
+        }
+
+        $attributeCodes = array_values(array_intersect($presentCodes, array_keys($payload)));
+
+        $categoryNames = null;
+
+        if (array_key_exists(ProductBasicInfoColumns::CATEGORIES, $row)) {
+            $categoriesRaw = $this->cellString($row[ProductBasicInfoColumns::CATEGORIES] ?? '');
+
+            if ($categoriesRaw !== '') {
+                $categoryNames = $this->splitCategories($categoriesRaw);
+            }
+        }
+
+        $qty = null;
+
+        if (array_key_exists(ProductBasicInfoColumns::QTY, $row)) {
+            $qtyRaw = $this->cellString($row[ProductBasicInfoColumns::QTY] ?? '');
+
+            if ($qtyRaw !== '') {
+                if (! is_numeric($qtyRaw)) {
+                    throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-qty'));
+                }
+
+                $qty = (int) $qtyRaw;
+            }
+        }
+
+        $categoryIds = $categoryNames !== null
+            ? $this->resolveCategoryIds($categoryNames, $locale)
+            : null;
+
+        DB::transaction(function () use ($product, $payload, $attributeCodes, $categoryIds, $qty) {
+            if ($attributeCodes !== []) {
+                $this->productRepository->update($payload, $product->id, $attributeCodes);
+            }
+
+            if ($categoryIds !== null) {
+                if (Schema::hasColumn('product_categories', 'position')) {
+                    $sync = [];
+
+                    foreach ($categoryIds as $position => $categoryId) {
+                        $sync[$categoryId] = ['position' => $position];
+                    }
+
+                    $product->categories()->sync($sync);
+                } else {
+                    $product->categories()->sync($categoryIds);
+                }
+            }
+
+            if ($qty !== null) {
+                $this->saveInventory($product, $qty);
+            }
+        });
+
+        return (int) $product->id;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @return list<string>
+     */
+    protected function presentAttributeCodes(array $row): array
+    {
+        $codes = [];
+
+        foreach (ProductBasicInfoColumns::ATTRIBUTE_CODES as $code) {
+            if (array_key_exists($code, $row)) {
+                $codes[] = $code;
+            }
+        }
+
+        return $codes;
+    }
+
+    protected function normalizeAttributeValue(string $code, mixed $raw): mixed
+    {
+        if ($code === 'special_price' && $this->isEmptyValue($raw)) {
+            return null;
+        }
+
+        if ($code === 'status') {
+            if ($this->isEmptyValue($raw)) {
+                return null;
+            }
+
+            $normalized = strtolower($this->cellString($raw));
+
+            if (in_array($normalized, ['1', 'true', 'yes', 'enabled'], true)) {
+                return 1;
+            }
+
+            if (in_array($normalized, ['0', 'false', 'no', 'disabled'], true)) {
+                return 0;
+            }
+
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-status'));
+        }
+
+        if (in_array($code, ['price', 'special_price', 'weight'], true)) {
+            if ($this->isEmptyValue($raw)) {
+                return $code === 'special_price' ? null : '';
+            }
+
+            if (! is_numeric($raw) && ! is_numeric($this->cellString($raw))) {
+                throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.invalid-number', [
+                    'field' => $code,
+                ]));
+            }
+
+            return (float) $raw;
+        }
+
+        return $this->cellString($raw);
+    }
+
+    protected function findProductBySku(string $sku): ?Product
+    {
+        if ($this->productsBySku === []) {
+            $this->productsBySku = Product::query()
+                ->pluck('id', 'sku')
+                ->mapWithKeys(fn ($id, $skuKey) => [(string) $skuKey => (int) $id])
+                ->all();
+        }
+
+        $id = $this->productsBySku[$sku] ?? null;
+
+        if (! $id) {
+            return null;
+        }
+
+        return Product::query()->find($id);
+    }
+
+    protected function isUrlKeyUnique(int $productId, string $urlKey, string $locale): bool
+    {
+        $attribute = $this->attributeRepository->findOneByField('code', 'url_key');
+
+        if (! $attribute) {
+            return true;
+        }
+
+        return ! DB::table('product_attribute_values')
+            ->where('attribute_id', $attribute->id)
+            ->where('text_value', $urlKey)
+            ->where('product_id', '!=', $productId)
+            ->when(
+                Schema::hasColumn('product_attribute_values', 'locale'),
+                fn ($query) => $query->where(function ($inner) use ($locale) {
+                    $inner->where('locale', $locale)->orWhereNull('locale');
+                })
+            )
+            ->exists();
+    }
+
+    /**
+     * @param  list<string>  $names
+     * @return list<int>
+     */
+    protected function resolveCategoryIds(array $names, string $locale): array
+    {
+        if ($names === [] || ! Schema::hasTable('category_translations')) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
+                'name' => implode('|', $names),
+            ]));
+        }
+
+        $query = DB::table('category_translations')->whereIn('name', $names);
+
+        if (Schema::hasColumn('category_translations', 'locale')) {
+            $query->where('locale', $locale);
+        }
+
+        $found = $query->get(['name', 'category_id']);
+        $idsByName = [];
+
+        foreach ($found as $row) {
+            $idsByName[(string) $row->name] = (int) $row->category_id;
+        }
+
+        $missing = array_values(array_diff($names, array_keys($idsByName)));
+
+        if ($missing !== []) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
+                'name' => implode('|', $missing),
+            ]));
+        }
+
+        return array_values(array_unique(array_map(
+            fn (string $name) => $idsByName[$name],
+            $names
+        )));
+    }
+
+    protected function saveInventory(Product $product, int $qty): void
+    {
+        $existing = DB::table('product_inventories')
+            ->where('product_id', $product->id)
+            ->orderBy('id')
+            ->first();
+
+        $sourceId = $existing?->inventory_source_id
+            ?? DB::table('inventory_sources')->value('id');
+
+        if (! $sourceId) {
+            throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.no-inventory-source'));
+        }
+
+        DB::table('product_inventories')->updateOrInsert(
+            [
+                'product_id'          => $product->id,
+                'inventory_source_id' => $sourceId,
+                'vendor_id'           => $existing?->vendor_id ?? 0,
+            ],
+            ['qty' => $qty]
+        );
+    }
+
+    /**
+     * @param  list<int>  $productIds
+     */
+    public function reindexProducts(array $productIds): void
+    {
+        $ids = array_values(array_unique(array_filter($productIds)));
+
+        if ($ids === []) {
+            return;
+        }
+
+        $priceIndexer = app(Price::class);
+        $flatIndexer = app(Flat::class);
+        $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
+
+        $relations = [
+            'attribute_family',
+            'attribute_values',
+            'channels',
+            'price_indices',
+            'customer_group_prices',
+            'catalog_rule_prices',
+            'flexibleVariants',
+            'flexibleVariants.price_indices',
+            'flexibleVariants.customer_group_prices',
+        ];
+
+        foreach (array_chunk($ids, 20) as $chunk) {
+            $products = Product::with($relations)->whereIn('id', $chunk)->get();
+
+            foreach ($products as $product) {
+                try {
+                    $priceIndexer->reindexRow($product);
+                    $flatIndexer->reindexRow($product);
+                    $elasticIndexer?->reindexRow($product);
+                } catch (\Throwable) {
+                    // Keep the spreadsheet update even if a single index rebuild fails.
+                }
+            }
+        }
+    }
+
+    protected function elasticEnabled(): bool
+    {
+        return core()->getConfigData('catalog.products.search.engine') === 'elastic';
+    }
+
+    /**
+     * @return list<string>
+     */
+    protected function splitCategories(string $raw): array
+    {
+        return array_values(array_unique(array_filter(array_map(
+            'trim',
+            preg_split('/[|,]/', $raw) ?: []
+        ), fn (string $name) => $name !== '')));
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    protected function rowIsEmpty(array $row): bool
+    {
+        foreach ($row as $value) {
+            if (! $this->isEmptyValue($value)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    protected function isEmptyValue(mixed $value): bool
+    {
+        if ($value === null) {
+            return true;
+        }
+
+        if (is_string($value)) {
+            return trim($value) === '';
+        }
+
+        return false;
+    }
+
+    protected function cellString(mixed $value): string
+    {
+        if ($value === null) {
+            return '';
+        }
+
+        if (is_bool($value)) {
+            return $value ? '1' : '0';
+        }
+
+        if (is_int($value)) {
+            return (string) $value;
+        }
+
+        if (is_float($value)) {
+            if (floor($value) == $value) {
+                return (string) (int) $value;
+            }
+
+            return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.');
+        }
+
+        return trim((string) $value);
+    }
+}

+ 67 - 0
packages/Longyi/Core/src/Support/ProductBasicInfoColumns.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Longyi\Core\Support;
+
+class ProductBasicInfoColumns
+{
+    public const SKU = 'sku';
+
+    public const LOCALE = 'locale';
+
+    public const QTY = 'qty';
+
+    public const CATEGORIES = 'categories';
+
+    /**
+     * Excel header order. First row of export/import files.
+     *
+     * @var list<string>
+     */
+    public const HEADINGS = [
+        self::SKU,
+        self::LOCALE,
+        'name',
+        'url_key',
+        'short_description',
+        'description',
+        'price',
+        'special_price',
+        'weight',
+        'status',
+        self::QTY,
+        self::CATEGORIES,
+        'meta_title',
+        'meta_keywords',
+        'meta_description',
+    ];
+
+    /**
+     * EAV attribute codes written via type update whitelist.
+     *
+     * @var list<string>
+     */
+    public const ATTRIBUTE_CODES = [
+        'name',
+        'url_key',
+        'short_description',
+        'description',
+        'price',
+        'special_price',
+        'weight',
+        'status',
+        'meta_title',
+        'meta_keywords',
+        'meta_description',
+    ];
+
+    /**
+     * Attribute codes that may not be cleared to an empty value.
+     *
+     * @var list<string>
+     */
+    public const REQUIRED_ATTRIBUTE_CODES = [
+        'name',
+        'url_key',
+        'price',
+    ];
+}

+ 9 - 2
packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductController.php

@@ -385,10 +385,17 @@ class ProductController extends Controller
     public function download($productId, $attributeId)
     {
         $productAttribute = $this->productAttributeValueRepository->findOneWhere([
-            'product_id' => $productId,
+            'product_id'   => $productId,
             'attribute_id' => $attributeId,
         ]);
 
-        return Storage::download($productAttribute['text_value']);
+        if (
+            ! $productAttribute
+            || empty($productAttribute->text_value)
+        ) {
+            abort(404);
+        }
+
+        return Storage::download($productAttribute->text_value);
     }
 }

+ 3 - 1
packages/Webkul/Admin/src/Routes/catalog-routes.php

@@ -145,7 +145,9 @@ Route::prefix('catalog')->group(function () {
 
         Route::get('search', 'search')->name('admin.catalog.products.search');
 
-        Route::get('{id}/{attribute_id}', 'download')->name('admin.catalog.products.file.download');
+        Route::get('{id}/{attribute_id}', 'download')
+            ->whereNumber(['id', 'attribute_id'])
+            ->name('admin.catalog.products.file.download');
     });
 
     /**