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

Fix Excel product sync so category updates persist and reindex.

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

+ 2 - 1
composer.json

@@ -138,7 +138,8 @@
             "Webkul\\DataGrid\\Tests\\": "packages/Webkul/DataGrid/tests",
             "Webkul\\Installer\\Tests\\": "packages/Webkul/Installer/tests",
             "Webkul\\Shop\\Tests\\": "packages/Webkul/Shop/tests",
-            "Webkul\\BagistoApi\\Tests\\": "packages/Webkul/BagistoApi/tests"
+            "Webkul\\BagistoApi\\Tests\\": "packages/Webkul/BagistoApi/tests",
+            "Longyi\\Core\\Tests\\": "packages/Longyi/Core/tests/"
         }
     },
     "scripts": {

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

@@ -111,7 +111,7 @@ return [
             '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.',
+            'categories' => 'categories: current-locale category names (or slugs) separated by |. Empty keeps current categories. Unknown names fail the row.',
         ],
         'errors'        => [
             'empty-file'            => 'The uploaded file is empty.',

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

@@ -109,7 +109,7 @@ return [
             'locale'     => 'locale:语言代码;留空则使用默认渠道语言',
             'core'       => 'name、url_key、short_description、description、price、special_price、weight、status(1/0)、SEO 字段',
             'qty'        => 'qty:父商品库存;留空则不改。变体库存本版不更新。',
-            'categories' => 'categories:当前语言分类名,用 | 分隔。留空则不改分类。任一名称找不到则整行失败。',
+            'categories' => 'categories:分类名或 slug,用 | 分隔(名称里的逗号不会拆开)。留空则不改分类。名称找不到则整行失败。',
         ],
         'errors'        => [
             'empty-file'            => '上传文件为空。',

+ 103 - 27
packages/Longyi/Core/src/Services/ProductBasicInfoSyncService.php

@@ -47,7 +47,7 @@ class ProductBasicInfoSyncService
         $headings = [];
 
         foreach ((array) array_shift($table) as $heading) {
-            $headings[] = strtolower(trim((string) $heading));
+            $headings[] = $this->normalizeHeading($heading);
         }
 
         if (! in_array(ProductBasicInfoColumns::SKU, $headings, true)) {
@@ -210,17 +210,7 @@ class ProductBasicInfoSyncService
             }
 
             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);
-                }
+                $this->saveCategories($product, $categoryIds);
             }
 
             if ($qty !== null) {
@@ -228,6 +218,14 @@ class ProductBasicInfoSyncService
             }
         });
 
+        $product->unsetRelation('categories');
+
+        try {
+            event('catalog.product.update.after', $product->fresh(['categories']));
+        } catch (\Throwable) {
+            // Cache listeners must not roll back a successful spreadsheet row.
+        }
+
         return (int) $product->id;
     }
 
@@ -340,20 +338,20 @@ class ProductBasicInfoSyncService
             ]));
         }
 
-        $query = DB::table('category_translations')->whereIn('name', $names);
+        $ids = [];
+        $missing = [];
 
-        if (Schema::hasColumn('category_translations', 'locale')) {
-            $query->where('locale', $locale);
-        }
+        foreach ($names as $name) {
+            $categoryId = $this->findCategoryId($name, $locale);
 
-        $found = $query->get(['name', 'category_id']);
-        $idsByName = [];
+            if ($categoryId) {
+                $ids[] = $categoryId;
 
-        foreach ($found as $row) {
-            $idsByName[(string) $row->name] = (int) $row->category_id;
-        }
+                continue;
+            }
 
-        $missing = array_values(array_diff($names, array_keys($idsByName)));
+            $missing[] = $name;
+        }
 
         if ($missing !== []) {
             throw new ProductBasicInfoSyncException(trans('longyi::app.product-sync.errors.unknown-category', [
@@ -361,10 +359,72 @@ class ProductBasicInfoSyncService
             ]));
         }
 
-        return array_values(array_unique(array_map(
-            fn (string $name) => $idsByName[$name],
-            $names
-        )));
+        return array_values(array_unique($ids));
+    }
+
+    protected function findCategoryId(string $name, string $locale): ?int
+    {
+        $match = function ($query) use ($name) {
+            $query->where(function ($inner) use ($name) {
+                $inner->whereRaw('TRIM(name) = ?', [$name])
+                    ->orWhere('slug', $name);
+            });
+        };
+
+        $preferred = DB::table('category_translations')
+            ->where('locale', $locale)
+            ->where($match)
+            ->orderBy('category_id')
+            ->value('category_id');
+
+        if ($preferred) {
+            return (int) $preferred;
+        }
+
+        $any = DB::table('category_translations')
+            ->where($match)
+            ->orderBy('category_id')
+            ->value('category_id');
+
+        return $any ? (int) $any : null;
+    }
+
+    /**
+     * @param  list<int>  $categoryIds
+     */
+    protected function saveCategories(Product $product, array $categoryIds): void
+    {
+        DB::table('product_categories')->where('product_id', $product->id)->delete();
+
+        $categoryIds = array_values(array_unique(array_map('intval', $categoryIds)));
+
+        if ($categoryIds === []) {
+            return;
+        }
+
+        $hasPosition = Schema::hasColumn('product_categories', 'position');
+        $rows = [];
+
+        foreach ($categoryIds as $categoryId) {
+            $row = [
+                'product_id'  => $product->id,
+                'category_id' => $categoryId,
+            ];
+
+            if ($hasPosition) {
+                $maxPosition = (int) DB::table('product_categories')
+                    ->where('category_id', $categoryId)
+                    ->max('position');
+
+                $row['position'] = $maxPosition + 1;
+            }
+
+            $rows[] = $row;
+        }
+
+        DB::table('product_categories')->insert($rows);
+
+        $product->unsetRelation('categories');
     }
 
     protected function saveInventory(Product $product, int $qty): void
@@ -410,6 +470,12 @@ class ProductBasicInfoSyncService
             'attribute_family',
             'attribute_values',
             'channels',
+            'categories',
+            'super_attributes',
+            'variants',
+            'variants.channels',
+            'variants.attribute_family',
+            'variants.attribute_values',
             'price_indices',
             'customer_group_prices',
             'catalog_rule_prices',
@@ -445,10 +511,20 @@ class ProductBasicInfoSyncService
     {
         return array_values(array_unique(array_filter(array_map(
             'trim',
-            preg_split('/[|,]/', $raw) ?: []
+            preg_split('/\s*[||]\s*/u', $raw) ?: []
         ), fn (string $name) => $name !== '')));
     }
 
+    protected function normalizeHeading(mixed $heading): string
+    {
+        $normalized = strtolower(trim((string) $heading, " \t\n\r\0\x0B\xC2\xA0"));
+
+        return match ($normalized) {
+            'category', '分类' => ProductBasicInfoColumns::CATEGORIES,
+            default => $normalized,
+        };
+    }
+
     /**
      * @param  array<string, mixed>  $row
      */

+ 184 - 0
packages/Longyi/Core/tests/TestCase.php

@@ -0,0 +1,184 @@
+<?php
+
+namespace Longyi\Core\Tests;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Tests\TestCase as BaseTestCase;
+use Webkul\Attribute\Models\Attribute;
+use Webkul\Category\Models\Category;
+use Webkul\Core\Models\Channel;
+use Webkul\Product\Models\Product;
+use Webkul\Product\Models\ProductAttributeValue;
+
+abstract class TestCase extends BaseTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('products') || ! Schema::hasTable('attribute_families')) {
+            $this->markTestSkipped('Catalog tables are missing.');
+        }
+    }
+
+    protected function seedRequiredData(): void
+    {
+        try {
+            if (! Category::query()->exists()) {
+                Category::factory()->create([
+                    'parent_id' => null,
+                ]);
+            }
+
+            if (! Channel::query()->exists()) {
+                Channel::factory()->create();
+            }
+        } catch (\Exception $e) {
+            $this->markTestSkipped('Test database not properly configured: '.$e->getMessage());
+        }
+    }
+
+    protected function createSimpleProduct(array $overrides = []): Product
+    {
+        $this->seedRequiredData();
+
+        $attributeFamilyId = (int) (DB::table('attribute_families')->value('id') ?? 0);
+
+        if ($attributeFamilyId === 0) {
+            $this->markTestSkipped('Run Bagisto seeders for attribute_families.');
+        }
+
+        $product = Product::factory()->create([
+            'type'                => 'simple',
+            'attribute_family_id' => $attributeFamilyId,
+            ...$overrides,
+        ]);
+
+        $this->upsertProductAttributeValue($product->id, 'name', 'Test '.$product->sku);
+        $this->upsertProductAttributeValue($product->id, 'url_key', strtolower($product->sku));
+        $this->upsertProductAttributeValue($product->id, 'status', 1, null, 'default');
+        $this->upsertProductAttributeValue($product->id, 'price', 10.0, null, null);
+
+        return $product;
+    }
+
+    protected function upsertProductAttributeValue(
+        int $productId,
+        string $attributeCode,
+        mixed $value,
+        ?string $locale = 'en',
+        ?string $channel = null
+    ): void {
+        $attribute = Attribute::query()->where('code', $attributeCode)->first();
+
+        if (! $attribute) {
+            $this->markTestSkipped(sprintf('Required attribute "%s" not found. Run Bagisto seeders for attributes.', $attributeCode));
+        }
+
+        $type = (string) ($attribute->type ?? 'text');
+        $field = ProductAttributeValue::$attributeTypeFields[$type] ?? 'text_value';
+
+        $payload = [
+            'product_id'     => $productId,
+            'attribute_id'   => (int) $attribute->id,
+            'locale'         => $attribute->value_per_locale ? $locale : null,
+            'channel'        => $attribute->value_per_channel ? ($channel ?? 'default') : null,
+            'text_value'     => null,
+            'boolean_value'  => null,
+            'integer_value'  => null,
+            'float_value'    => null,
+            'datetime_value' => null,
+            'date_value'     => null,
+            'json_value'     => null,
+        ];
+
+        $payload[$field] = $field === 'boolean_value'
+            ? (bool) $value
+            : ($field === 'float_value' ? (float) $value : $value);
+
+        $payload['unique_id'] = implode('|', array_filter([
+            $payload['channel'],
+            $payload['locale'],
+            $productId,
+            (int) $attribute->id,
+        ]));
+
+        ProductAttributeValue::query()->updateOrCreate(
+            [
+                'product_id'   => $productId,
+                'attribute_id' => (int) $attribute->id,
+                'locale'       => $payload['locale'],
+                'channel'      => $payload['channel'],
+            ],
+            $payload
+        );
+    }
+
+    protected function attributeText(int $productId, string $code): ?string
+    {
+        $attribute = Attribute::query()->where('code', $code)->first();
+
+        if (! $attribute) {
+            return null;
+        }
+
+        $field = ProductAttributeValue::$attributeTypeFields[$attribute->type] ?? 'text_value';
+
+        $row = ProductAttributeValue::query()
+            ->where('product_id', $productId)
+            ->where('attribute_id', $attribute->id)
+            ->orderByDesc('id')
+            ->first();
+
+        if (! $row) {
+            return null;
+        }
+
+        $value = $row->{$field};
+
+        return $value === null ? null : (string) $value;
+    }
+
+    protected function ensureInventory(Product $product, int $qty = 50): int
+    {
+        $inventorySourceId = (int) (DB::table('inventory_sources')->value('id') ?? 0);
+
+        if (! $inventorySourceId) {
+            $this->markTestSkipped('No inventory_sources found. Run Bagisto seeders for inventory sources.');
+        }
+
+        DB::table('product_inventories')->updateOrInsert(
+            [
+                'product_id'          => $product->id,
+                'inventory_source_id' => $inventorySourceId,
+                'vendor_id'           => 0,
+            ],
+            ['qty' => $qty]
+        );
+
+        return $inventorySourceId;
+    }
+
+    protected function createNamedCategory(string $name, string $locale = 'en'): Category
+    {
+        $category = Category::factory()->create([
+            'parent_id' => null,
+        ]);
+
+        DB::table('category_translations')->updateOrInsert(
+            [
+                'category_id' => $category->id,
+                'locale'      => $locale,
+            ],
+            [
+                'name'        => $name,
+                'slug'        => strtolower(str_replace(' ', '-', $name)).'-'.$category->id,
+                'url_path'    => strtolower(str_replace(' ', '-', $name)).'-'.$category->id,
+                'description' => $name,
+            ]
+        );
+
+        return $category->fresh();
+    }
+}

+ 267 - 0
packages/Longyi/Core/tests/Unit/ProductBasicInfoSyncServiceTest.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace Longyi\Core\Tests\Unit;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Longyi\Core\Models\ProductOption;
+use Longyi\Core\Services\ProductBasicInfoSyncService;
+use Longyi\Core\Support\ProductBasicInfoColumns;
+use Longyi\Core\Tests\TestCase;
+use Webkul\Product\Models\Product;
+
+class ProductBasicInfoSyncServiceTest extends TestCase
+{
+    public function test_updates_name_price_and_status_for_existing_sku(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-1']);
+
+        $result = $this->syncService()->sync([[
+            'sku'    => 'SYNC-CORE-1',
+            'locale' => 'en',
+            'name'   => 'Updated Name',
+            'price'  => 29.99,
+            'status' => 0,
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame(0, $result->failed);
+        $this->assertSame('Updated Name', $this->attributeText($product->id, 'name'));
+        $this->assertEqualsWithDelta(29.99, (float) $this->attributeText($product->id, 'price'), 0.001);
+        $this->assertSame(0, (int) $this->attributeText($product->id, 'status'));
+    }
+
+    public function test_unknown_sku_empty_name_and_unknown_category_fail_without_writing(): void
+    {
+        $emptyNameProduct = $this->createSimpleProduct(['sku' => 'SYNC-CORE-2']);
+        $categoryProduct = $this->createSimpleProduct(['sku' => 'SYNC-CORE-2B']);
+        $originalEmptyName = $this->attributeText($emptyNameProduct->id, 'name');
+        $originalCategoryName = $this->attributeText($categoryProduct->id, 'name');
+
+        $result = $this->syncService()->sync([
+            [
+                'sku'    => 'MISSING-SKU',
+                'locale' => 'en',
+                'name'   => 'Should Not Exist',
+            ],
+            [
+                'sku'    => 'SYNC-CORE-2',
+                'locale' => 'en',
+                'name'   => '',
+            ],
+            [
+                'sku'        => 'SYNC-CORE-2B',
+                'locale'     => 'en',
+                'name'       => 'Changed Anyway',
+                'categories' => 'No Such Category',
+            ],
+        ], false);
+
+        $this->assertSame(0, $result->updated);
+        $this->assertSame(3, $result->failed);
+        $this->assertSame($originalEmptyName, $this->attributeText($emptyNameProduct->id, 'name'));
+        $this->assertSame($originalCategoryName, $this->attributeText($categoryProduct->id, 'name'));
+        $this->assertFalse(
+            DB::table('products')->where('sku', 'MISSING-SKU')->exists()
+        );
+    }
+
+    public function test_empty_qty_and_categories_do_not_overwrite_existing_values(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-3']);
+        $this->ensureInventory($product, 42);
+
+        $category = $this->createNamedCategory('Kept Category');
+        $product->categories()->sync([$category->id => ['position' => 0]]);
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CORE-3',
+            'locale'     => 'en',
+            'name'       => 'Name After Empty Columns',
+            'qty'        => '',
+            'categories' => '',
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame('Name After Empty Columns', $this->attributeText($product->id, 'name'));
+        $this->assertSame(42, (int) DB::table('product_inventories')->where('product_id', $product->id)->value('qty'));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_replaces_categories_when_names_are_updated(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-1']);
+        $original = $this->createNamedCategory('Original Category');
+        $target = $this->createNamedCategory('Target Category');
+        $product->categories()->sync([$original->id => ['position' => 0]]);
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-1',
+            'locale'     => 'en',
+            'categories' => 'Target Category',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertSame(0, $result->failed);
+        $fresh = $product->fresh()->categories->pluck('id')->all();
+        $this->assertEqualsCanonicalizing([$target->id], $fresh);
+    }
+
+    public function test_resolves_category_name_from_another_locale(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-2']);
+        $category = $this->createNamedCategory('假发', 'zh_CN');
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-2',
+            'locale'     => 'en',
+            'categories' => '假发',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_keeps_comma_inside_category_name(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CAT-3']);
+        $category = $this->createNamedCategory('13x4 Lace Front Wigs, Human Hair');
+
+        $result = $this->syncService()->sync([[
+            'sku'        => 'SYNC-CAT-3',
+            'locale'     => 'en',
+            'categories' => '13x4 Lace Front Wigs, Human Hair',
+        ]], false);
+
+        $this->assertSame(1, $result->updated, json_encode($result->errors));
+        $this->assertTrue($product->fresh()->categories->contains('id', $category->id));
+    }
+
+    public function test_map_table_accepts_category_heading_alias(): void
+    {
+        $rows = $this->syncService()->mapTableToRows([
+            ['sku', 'category'],
+            ['ABC', 'Wigs'],
+        ]);
+
+        $this->assertSame('Wigs', $rows[0]['categories']);
+    }
+
+    public function test_whitelist_update_does_not_remove_images_or_options(): void
+    {
+        $product = $this->createSimpleProduct(['sku' => 'SYNC-CORE-4']);
+
+        DB::table('product_images')->insert([
+            'type'       => 'images',
+            'path'       => 'catalog/product/keep-me.jpg',
+            'product_id' => $product->id,
+            'position'   => 1,
+        ]);
+
+        $optionCount = null;
+
+        if (Schema::hasTable('product_options') && Schema::hasTable('product_product_options')) {
+            $option = ProductOption::query()->create([
+                'label'    => 'Keep Option',
+                'code'     => 'keep_option_'.$product->id,
+                'type'     => 'select',
+                'position' => 0,
+            ]);
+
+            $product->options()->attach($option->id, [
+                'position'    => 0,
+                'is_required' => true,
+            ]);
+
+            $optionCount = DB::table('product_product_options')->where('product_id', $product->id)->count();
+            $this->assertSame(1, $optionCount);
+        }
+
+        $imageCount = DB::table('product_images')->where('product_id', $product->id)->count();
+
+        $result = $this->syncService()->sync([[
+            'sku'   => 'SYNC-CORE-4',
+            'name'  => 'Still Has Media',
+            'price' => 12.5,
+        ]], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame($imageCount, DB::table('product_images')->where('product_id', $product->id)->count());
+
+        if ($optionCount !== null) {
+            $this->assertSame(
+                $optionCount,
+                DB::table('product_product_options')->where('product_id', $product->id)->count()
+            );
+        }
+    }
+
+    public function test_map_table_requires_sku_column(): void
+    {
+        $this->expectExceptionMessage(trans('longyi::app.product-sync.errors.sku-column-missing'));
+
+        $this->syncService()->mapTableToRows([
+            ['name', 'price'],
+            ['Wig', '10'],
+        ]);
+    }
+
+    public function test_headings_match_export_contract(): void
+    {
+        $this->assertSame([
+            'sku',
+            'locale',
+            'name',
+            'url_key',
+            'short_description',
+            'description',
+            'price',
+            'special_price',
+            'weight',
+            'status',
+            'qty',
+            'categories',
+            'meta_title',
+            'meta_keywords',
+            'meta_description',
+        ], ProductBasicInfoColumns::HEADINGS);
+    }
+
+    public function test_duplicate_sku_rows_are_rejected(): void
+    {
+        $this->createSimpleProduct(['sku' => 'SYNC-CORE-5']);
+
+        $result = $this->syncService()->sync([
+            [
+                'sku'  => 'SYNC-CORE-5',
+                'name' => 'First',
+            ],
+            [
+                'sku'  => 'SYNC-CORE-5',
+                'name' => 'Second',
+            ],
+        ], false);
+
+        $this->assertSame(1, $result->updated);
+        $this->assertSame(1, $result->failed);
+        $this->assertSame('First', $this->attributeText(
+            (int) Product::query()->where('sku', 'SYNC-CORE-5')->value('id'),
+            'name'
+        ));
+    }
+
+    public function test_export_route_is_not_captured_by_product_file_download(): void
+    {
+        $route = app('router')->getRoutes()->match(
+            Request::create('/admin/catalog/products/sync/export', 'GET')
+        );
+
+        $this->assertSame('admin.catalog.products.sync.export', $route->getName());
+    }
+
+    protected function syncService(): ProductBasicInfoSyncService
+    {
+        return app(ProductBasicInfoSyncService::class);
+    }
+}

+ 2 - 1
packages/Webkul/Product/src/Models/Product.php

@@ -131,7 +131,8 @@ class Product extends Model implements ProductContract
      */
     public function categories(): BelongsToMany
     {
-        return $this->belongsToMany(CategoryProxy::modelClass(), 'product_categories');
+        return $this->belongsToMany(CategoryProxy::modelClass(), 'product_categories')
+            ->withPivot('position');
     }
 
     /**

+ 5 - 0
phpunit.xml

@@ -35,6 +35,11 @@
         <testsuite name="BagistoApi Unit Test">
             <directory suffix="Test.php">packages/Webkul/BagistoApi/tests/Unit</directory>
         </testsuite>
+
+        <!-- Longyi Core package testsuites. -->
+        <testsuite name="Longyi Core Unit Test">
+            <directory suffix="Test.php">packages/Longyi/Core/tests/Unit</directory>
+        </testsuite>
     </testsuites>
 
     <source>