Jelajahi Sumber

用户迁移补齐 newsletter 订阅和 customer_source。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl 1 hari lalu
induk
melakukan
3950ab5df8

+ 30 - 2
app/Console/Commands/MigrateAsteriaCustomers.php

@@ -74,8 +74,9 @@ class MigrateAsteriaCustomers extends Command
         }
 
         if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
-            || ! Schema::hasColumn('customers', 'legacy_password')) {
-            $this->error('customers.migrated_from_asteria_id / legacy_password are missing. Run php artisan migrate.');
+            || ! Schema::hasColumn('customers', 'legacy_password')
+            || ! Schema::hasColumn('customers', 'customer_source')) {
+            $this->error('customers.migrated_from_asteria_id / legacy_password / customer_source are missing. Run php artisan migrate.');
 
             return self::FAILURE;
         }
@@ -212,6 +213,7 @@ class MigrateAsteriaCustomers extends Command
 
         $insertCustomers = [];
         $linkUpdates = [];
+        $sourceUpdates = [];
         $addressJobs = [];
         $seenEmails = [];
 
@@ -237,6 +239,11 @@ class MigrateAsteriaCustomers extends Command
                 if ($existing) {
                     $addressJobs[] = ['customer' => $existing, 'row' => $row];
                     $linked++;
+                    $source = $this->mapCustomerSource($row['customer_source'] ?? null);
+
+                    if ($source !== null) {
+                        $sourceUpdates[(int) $existing->id] = $source;
+                    }
                 } else {
                     $skipped++;
                 }
@@ -261,6 +268,7 @@ class MigrateAsteriaCustomers extends Command
                 'api_token'                 => Str::random(80),
                 'customer_group_id'         => $this->groupId,
                 'channel_id'                => $this->channelId,
+                'customer_source'           => $this->mapCustomerSource($row['customer_source'] ?? null),
                 'subscribed_to_news_letter' => $this->isMagentoSubscribed($subscriber) ? 1 : 0,
                 'status'                    => ((int) ($row['is_active'] ?? 1)) === 1 ? 1 : 0,
                 'is_verified'               => 1,
@@ -276,6 +284,7 @@ class MigrateAsteriaCustomers extends Command
         DB::transaction(function () use (
             $insertCustomers,
             $linkUpdates,
+            $sourceUpdates,
             $addressJobs,
             $addresses,
             $subscribers,
@@ -300,6 +309,12 @@ class MigrateAsteriaCustomers extends Command
                     ->update(['migrated_from_asteria_id' => $asteriaId]);
             }
 
+            foreach ($sourceUpdates as $customerId => $source) {
+                DB::table('customers')
+                    ->where('id', $customerId)
+                    ->update(['customer_source' => $source]);
+            }
+
             $customerIdsForAddresses = [];
 
             foreach ($addressJobs as $job) {
@@ -805,6 +820,19 @@ class MigrateAsteriaCustomers extends Command
         };
     }
 
+    private function mapCustomerSource(mixed $value): ?int
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        if (! is_numeric($value)) {
+            return null;
+        }
+
+        return (int) $value;
+    }
+
     private function mapDate(mixed $value): ?string
     {
         $value = trim((string) $value);

+ 1 - 0
app/Services/Asteria/Magento1CustomerReader.php

@@ -16,6 +16,7 @@ class Magento1CustomerReader
         'password_hash',
         'default_billing',
         'default_shipping',
+        'customer_source',
     ];
 
     private const ADDRESS_CODES = [

+ 22 - 0
database/migrations/2026_09_02_144100_add_customer_source_to_customers_table.php

@@ -0,0 +1,22 @@
+<?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('customers', function (Blueprint $table) {
+            $table->unsignedInteger('customer_source')->nullable()->after('channel_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->dropColumn('customer_source');
+        });
+    }
+};

+ 2 - 1
docs/asteria-migration.md

@@ -49,7 +49,7 @@ php artisan migrate
 | 表 | 幂等列 | 迁移文件 |
 |---|---|---|
 | `products` | `migrated_from_asteria_id`(unique);SKU 仍唯一 | `database/migrations/2026_09_01_151700_add_asteria_migration_column_to_products_table.php` |
-| `customers` | `migrated_from_asteria_id`(unique),另有 `legacy_password` | `database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php` |
+| `customers` | `migrated_from_asteria_id`(unique),另有 `legacy_password`、`customer_source` | `database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php`、`database/migrations/2026_09_02_144100_add_customer_source_to_customers_table.php` |
 | `orders` | `migrated_from_asteria_id`(unique) | `database/migrations/2026_08_25_143200_add_asteria_migration_column_to_orders_table.php` |
 | `product_reviews` | `migrated_from_asteria_id`(unique) | 评论命令在缺列时会提示,也可交互自动 `ALTER TABLE` |
 
@@ -110,6 +110,7 @@ php artisan customers:migrate-asteria --reset-progress
 - 手机号与现有用户冲突:新用户 `phone` 置空。
 - 地址按 `addresses.additional.asteria_address_id` 去重;街道换行压成 `, `。
 - 订阅:Magento `newsletter_subscriber.subscriber_status = 1` 为已订阅,写入 `customers.subscribed_to_news_letter`,并按邮箱幂等写入 `subscribers_list`。已退订(status=3 等)的**注册用户**会写成未订阅并保留 `subscribers_list` 记录;无账号的游客订阅只导入已订阅邮箱。无 `newsletter_subscriber` 表时跳过,不影响用户导入。重复执行会回填已迁用户的订阅状态。
+- `customer_source`:从 Magento 客户 EAV(或 `customer_entity` 静态列)写入,表示注册入口数字码。不迁 `source_url`。已迁用户再跑(`--reset-progress`)会按 Magento 值回填。
 
 进度缓存:`migrate_asteria_customers_last_id`(30 天)。
 

+ 40 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1CustomerReaderTest.php

@@ -4,6 +4,7 @@ namespace Webkul\BagistoApi\Tests\Unit\Migration;
 
 use App\Services\Asteria\Magento1CustomerReader;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 use Tests\TestCase;
 
 class Magento1CustomerReaderTest extends TestCase
@@ -39,6 +40,7 @@ class Magento1CustomerReaderTest extends TestCase
             'password_hash' => md5('abcsecret12').':abc',
             'is_active'     => 1,
             'group_id'      => 99,
+            'customer_source' => 20,
         ]);
         MagentoSchema::seedAddress($this->connection, [
             'entity_id'  => 21,
@@ -82,6 +84,7 @@ class Magento1CustomerReaderTest extends TestCase
         $this->assertSame('1', (string) $customer['gender']);
         $this->assertSame('1990-05-01 00:00:00', $customer['dob']);
         $this->assertSame(md5('abcsecret12').':abc', $customer['password_hash']);
+        $this->assertSame('20', (string) $customer['customer_source']);
 
         $addresses = $reader->fetchAddresses([10]);
         $this->assertCount(1, $addresses);
@@ -109,4 +112,41 @@ class Magento1CustomerReaderTest extends TestCase
         $this->assertCount(1, $reader->fetchCustomers(10, 50));
         $this->assertSame('second@example.com', $reader->fetchCustomers(10, 50)->first()['email']);
     }
+
+    public function test_it_reads_newsletter_subscribers_by_customer_id_and_email(): void
+    {
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 1,
+            'customer_id'        => 10,
+            'subscriber_email'  => 'Jane@example.com',
+            'subscriber_status' => 1,
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 2,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'guest@example.com',
+            'subscriber_status' => 1,
+        ]);
+
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $forCustomer = $reader->fetchSubscribers([10], ['jane@example.com']);
+        $this->assertCount(1, $forCustomer);
+        $this->assertSame(1, $forCustomer->first()['subscriber_id']);
+        $this->assertSame(1, $forCustomer->first()['subscriber_status']);
+
+        $paged = $reader->fetchSubscribersAfter(0, 50);
+        $this->assertCount(2, $paged);
+        $this->assertSame(2, $reader->fetchSubscribersAfter(1, 50)->first()['subscriber_id']);
+    }
+
+    public function test_it_returns_no_subscribers_when_the_table_is_missing(): void
+    {
+        Schema::connection($this->connection)->drop('newsletter_subscriber');
+
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $this->assertTrue($reader->fetchSubscribers([10], ['jane@example.com'])->isEmpty());
+        $this->assertTrue($reader->fetchSubscribersAfter(0, 50)->isEmpty());
+    }
 }

+ 424 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/MagentoSchema.php

@@ -12,6 +12,40 @@ class MagentoSchema
 
     public const ADDRESS_TYPE_ID = 2;
 
+    public const CATEGORY_TYPE_ID = 3;
+
+    public const PRODUCT_TYPE_ID = 4;
+
+    public const ATTR_PRODUCT_NAME = 70;
+
+    public const ATTR_PRODUCT_DESCRIPTION = 71;
+
+    public const ATTR_PRODUCT_SHORT_DESCRIPTION = 72;
+
+    public const ATTR_PRODUCT_PRICE = 73;
+
+    public const ATTR_PRODUCT_SPECIAL_PRICE = 74;
+
+    public const ATTR_PRODUCT_COST = 75;
+
+    public const ATTR_PRODUCT_WEIGHT = 76;
+
+    public const ATTR_PRODUCT_STATUS = 77;
+
+    public const ATTR_PRODUCT_META_TITLE = 78;
+
+    public const ATTR_PRODUCT_META_KEYWORD = 79;
+
+    public const ATTR_PRODUCT_META_DESCRIPTION = 80;
+
+    public const ATTR_PRODUCT_URL_KEY = 81;
+
+    public const ATTR_PRODUCT_HAIR_COLORR = 90;
+
+    public const ATTR_CATEGORY_NAME = 111;
+
+    public const HAIR_COLORR_OPTION_ID = 501;
+
     public static function create(string $connection): void
     {
         $schema = Schema::connection($connection);
@@ -26,6 +60,10 @@ class MagentoSchema
             $table->integer('entity_type_id');
             $table->string('attribute_code');
             $table->string('backend_type');
+            $table->string('frontend_label')->nullable();
+            $table->string('frontend_input')->nullable();
+            $table->integer('is_required')->default(0);
+            $table->integer('is_user_defined')->default(0);
         });
 
         $schema->create('customer_entity', function (Blueprint $table) {
@@ -78,6 +116,7 @@ class MagentoSchema
             ['attribute_id' => 14, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'default_shipping', 'backend_type' => 'int'],
             ['attribute_id' => 18, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'gender', 'backend_type' => 'int'],
             ['attribute_id' => 88, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
+            ['attribute_id' => 89, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'customer_source', 'backend_type' => 'int'],
             ['attribute_id' => 20, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'firstname', 'backend_type' => 'varchar'],
             ['attribute_id' => 21, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'lastname', 'backend_type' => 'varchar'],
             ['attribute_id' => 22, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'company', 'backend_type' => 'varchar'],
@@ -89,6 +128,16 @@ class MagentoSchema
             ['attribute_id' => 28, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
         ]);
 
+        $schema->create('newsletter_subscriber', function (Blueprint $table) {
+            $table->integer('subscriber_id');
+            $table->integer('store_id')->default(0);
+            $table->string('change_status_at')->nullable();
+            $table->integer('customer_id')->default(0);
+            $table->string('subscriber_email')->nullable();
+            $table->integer('subscriber_status')->default(0);
+            $table->string('subscriber_confirm_code')->nullable();
+        });
+
         $schema->create('sales_flat_order', function (Blueprint $table) {
             $table->integer('entity_id');
             $table->string('increment_id')->nullable();
@@ -122,6 +171,11 @@ class MagentoSchema
             $table->string('shipping_method')->nullable();
             $table->string('shipping_description')->nullable();
             $table->string('coupon_code')->nullable();
+            $table->integer('mw_rewardpoint')->default(0);
+            $table->decimal('mw_rewardpoint_discount', 12, 4)->default(0);
+            $table->decimal('mw_rewardpoint_discount_show', 12, 4)->default(0);
+            $table->decimal('amcheckoutfees_amount', 12, 4)->default(0);
+            $table->decimal('base_amcheckoutfees_amount', 12, 4)->default(0);
             $table->integer('total_item_count')->default(0);
             $table->decimal('total_qty_ordered', 12, 4)->default(0);
             $table->decimal('total_invoiced', 12, 4)->default(0);
@@ -188,6 +242,17 @@ class MagentoSchema
             $table->decimal('amount_ordered', 12, 4)->nullable();
             $table->decimal('base_amount_ordered', 12, 4)->nullable();
         });
+
+        $schema->create('mw_reward_point_order', function (Blueprint $table) {
+            $table->integer('order_id');
+            $table->integer('reward_point')->default(0);
+            $table->integer('rewardpoint_sell_product')->default(0);
+            $table->integer('earn_rewardpoint')->default(0);
+            $table->float('money')->default(0);
+            $table->string('reward_point_money_rate')->nullable();
+        });
+
+        self::createProductCatalog($connection);
     }
 
     /**
@@ -233,6 +298,14 @@ class MagentoSchema
             ]);
         }
 
+        if (isset($data['customer_source'])) {
+            DB::connection($connection)->table('customer_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 89,
+                'value'        => $data['customer_source'],
+            ]);
+        }
+
         if (! empty($data['default_billing'])) {
             DB::connection($connection)->table('customer_entity_int')->insert([
                 'entity_id'    => $entityId,
@@ -297,6 +370,22 @@ class MagentoSchema
         }
     }
 
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedSubscriber(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('newsletter_subscriber')->insert([
+            'subscriber_id'           => $data['subscriber_id'],
+            'store_id'                => $data['store_id'] ?? 1,
+            'change_status_at'        => $data['change_status_at'] ?? '2020-01-01 00:00:00',
+            'customer_id'             => $data['customer_id'] ?? 0,
+            'subscriber_email'       => $data['subscriber_email'],
+            'subscriber_status'      => $data['subscriber_status'] ?? 1,
+            'subscriber_confirm_code' => $data['subscriber_confirm_code'] ?? 'confirmcode',
+        ]);
+    }
+
     /**
      * @param  array<string, mixed>  $data
      */
@@ -337,6 +426,11 @@ class MagentoSchema
             'shipping_method'         => $data['shipping_method'] ?? 'flatrate_flatrate',
             'shipping_description'    => $data['shipping_description'] ?? 'Flat Rate - Fixed',
             'coupon_code'             => $data['coupon_code'] ?? null,
+            'mw_rewardpoint'          => $data['mw_rewardpoint'] ?? 0,
+            'mw_rewardpoint_discount' => $data['mw_rewardpoint_discount'] ?? 0,
+            'mw_rewardpoint_discount_show' => $data['mw_rewardpoint_discount_show'] ?? 0,
+            'amcheckoutfees_amount'   => $data['amcheckoutfees_amount'] ?? 0,
+            'base_amcheckoutfees_amount' => $data['base_amcheckoutfees_amount'] ?? 0,
             'total_item_count'        => $data['total_item_count'] ?? 1,
             'total_qty_ordered'       => $data['total_qty_ordered'] ?? 1,
             'total_invoiced'          => $data['total_invoiced'] ?? 110,
@@ -356,6 +450,25 @@ class MagentoSchema
         if (! empty($data['payment'])) {
             self::seedOrderPayment($connection, array_merge(['parent_id' => $entityId], $data['payment']));
         }
+
+        if (! empty($data['reward_point_order'])) {
+            self::seedRewardPointOrder($connection, array_merge(['order_id' => $entityId], $data['reward_point_order']));
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedRewardPointOrder(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('mw_reward_point_order')->insert([
+            'order_id'                 => $data['order_id'],
+            'reward_point'             => $data['reward_point'] ?? 0,
+            'rewardpoint_sell_product' => $data['rewardpoint_sell_product'] ?? 0,
+            'earn_rewardpoint'         => $data['earn_rewardpoint'] ?? 0,
+            'money'                    => $data['money'] ?? 0,
+            'reward_point_money_rate'  => $data['reward_point_money_rate'] ?? '100/1',
+        ]);
     }
 
     /**
@@ -434,4 +547,315 @@ class MagentoSchema
             'base_amount_ordered'  => $data['base_amount_ordered'] ?? 110,
         ]);
     }
+
+    public static function createProductCatalog(string $connection): void
+    {
+        $schema = Schema::connection($connection);
+        $db = DB::connection($connection);
+
+        $db->table('eav_entity_type')->insert([
+            ['entity_type_id' => self::CATEGORY_TYPE_ID, 'entity_type_code' => 'catalog_category'],
+            ['entity_type_id' => self::PRODUCT_TYPE_ID, 'entity_type_code' => 'catalog_product'],
+        ]);
+
+        $schema->create('catalog_eav_attribute', function (Blueprint $table) {
+            $table->integer('attribute_id');
+            $table->integer('is_searchable')->default(0);
+            $table->integer('is_filterable')->default(0);
+            $table->integer('is_comparable')->default(0);
+            $table->integer('is_configurable')->default(0);
+            $table->integer('position')->default(0);
+        });
+
+        $schema->create('eav_attribute_option', function (Blueprint $table) {
+            $table->integer('option_id');
+            $table->integer('attribute_id');
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('eav_attribute_option_value', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('option_id');
+            $table->integer('store_id')->default(0);
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_product_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('sku')->nullable();
+            $table->string('type_id')->default('simple');
+            $table->integer('attribute_set_id')->default(4);
+            $table->string('created_at')->nullable();
+            $table->string('updated_at')->nullable();
+        });
+
+        foreach (['varchar', 'int', 'decimal', 'text', 'datetime'] as $type) {
+            $schema->create('catalog_product_entity_'.$type, function (Blueprint $table) {
+                $table->increments('value_id');
+                $table->integer('entity_id');
+                $table->integer('attribute_id');
+                $table->integer('store_id')->default(0);
+                $table->text('value')->nullable();
+            });
+        }
+
+        $schema->create('catalog_product_entity_media_gallery', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('entity_id');
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_product_entity_media_gallery_value', function (Blueprint $table) {
+            $table->integer('value_id');
+            $table->integer('store_id')->default(0);
+            $table->string('label')->nullable();
+            $table->integer('position')->default(0);
+            $table->integer('disabled')->default(0);
+        });
+
+        $schema->create('cataloginventory_stock_item', function (Blueprint $table) {
+            $table->increments('item_id');
+            $table->integer('product_id');
+            $table->integer('stock_id')->default(1);
+            $table->decimal('qty', 12, 4)->default(0);
+            $table->integer('is_in_stock')->default(0);
+        });
+
+        $schema->create('catalog_category_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('path')->nullable();
+        });
+
+        $schema->create('catalog_category_entity_varchar', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('entity_id');
+            $table->integer('attribute_id');
+            $table->integer('store_id')->default(0);
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_category_product', function (Blueprint $table) {
+            $table->integer('category_id');
+            $table->integer('product_id');
+            $table->integer('position')->default(0);
+        });
+
+        $schema->create('catalog_product_option', function (Blueprint $table) {
+            $table->increments('option_id');
+            $table->integer('product_id');
+            $table->string('type')->nullable();
+            $table->integer('is_require')->default(0);
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('catalog_product_option_title', function (Blueprint $table) {
+            $table->increments('option_title_id');
+            $table->integer('option_id');
+            $table->integer('store_id')->default(0);
+            $table->string('title')->nullable();
+        });
+
+        $schema->create('catalog_product_option_type_value', function (Blueprint $table) {
+            $table->increments('option_type_id');
+            $table->integer('option_id');
+            $table->string('sku')->nullable();
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('catalog_product_option_type_title', function (Blueprint $table) {
+            $table->increments('option_type_title_id');
+            $table->integer('option_type_id');
+            $table->integer('store_id')->default(0);
+            $table->string('title')->nullable();
+        });
+
+        $schema->create('catalog_product_option_type_price', function (Blueprint $table) {
+            $table->increments('option_type_price_id');
+            $table->integer('option_type_id');
+            $table->integer('store_id')->default(0);
+            $table->decimal('price', 12, 4)->default(0);
+            $table->string('price_type')->default('fixed');
+        });
+
+        $db->table('eav_attribute')->insert([
+            ['attribute_id' => self::ATTR_PRODUCT_NAME, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'name', 'backend_type' => 'varchar', 'frontend_label' => 'Name', 'frontend_input' => 'text', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'description', 'backend_type' => 'text', 'frontend_label' => 'Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_SHORT_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'short_description', 'backend_type' => 'text', 'frontend_label' => 'Short Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_PRICE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'price', 'backend_type' => 'decimal', 'frontend_label' => 'Price', 'frontend_input' => 'price', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_SPECIAL_PRICE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'special_price', 'backend_type' => 'decimal', 'frontend_label' => 'Special Price', 'frontend_input' => 'price', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_COST, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'cost', 'backend_type' => 'decimal', 'frontend_label' => 'Cost', 'frontend_input' => 'price', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_WEIGHT, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'weight', 'backend_type' => 'decimal', 'frontend_label' => 'Weight', 'frontend_input' => 'weight', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_STATUS, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'status', 'backend_type' => 'int', 'frontend_label' => 'Status', 'frontend_input' => 'select', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_TITLE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_title', 'backend_type' => 'varchar', 'frontend_label' => 'Meta Title', 'frontend_input' => 'text', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_KEYWORD, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_keyword', 'backend_type' => 'text', 'frontend_label' => 'Meta Keywords', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_description', 'backend_type' => 'varchar', 'frontend_label' => 'Meta Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_URL_KEY, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'url_key', 'backend_type' => 'varchar', 'frontend_label' => 'URL Key', 'frontend_input' => 'text', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'hair_colorr', 'backend_type' => 'int', 'frontend_label' => 'Hair Color', 'frontend_input' => 'select', 'is_required' => 0, 'is_user_defined' => 1],
+            ['attribute_id' => self::ATTR_CATEGORY_NAME, 'entity_type_id' => self::CATEGORY_TYPE_ID, 'attribute_code' => 'name', 'backend_type' => 'varchar', 'frontend_label' => 'Name', 'frontend_input' => 'text', 'is_required' => 1, 'is_user_defined' => 0],
+        ]);
+
+        $db->table('catalog_eav_attribute')->insert([
+            ['attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'is_searchable' => 1, 'is_filterable' => 1, 'is_comparable' => 0, 'is_configurable' => 0, 'position' => 10],
+        ]);
+
+        $db->table('eav_attribute_option')->insert([
+            ['option_id' => self::HAIR_COLORR_OPTION_ID, 'attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'sort_order' => 1],
+        ]);
+
+        $db->table('eav_attribute_option_value')->insert([
+            ['option_id' => self::HAIR_COLORR_OPTION_ID, 'store_id' => 0, 'value' => 'Natural Black'],
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedProduct(string $connection, array $data): void
+    {
+        $entityId = (int) $data['entity_id'];
+        $db = DB::connection($connection);
+
+        $db->table('catalog_product_entity')->insert([
+            'entity_id'        => $entityId,
+            'sku'              => $data['sku'] ?? 'SKU-'.$entityId,
+            'type_id'          => $data['type_id'] ?? 'simple',
+            'attribute_set_id' => $data['attribute_set_id'] ?? 4,
+            'created_at'       => $data['created_at'] ?? '2020-01-01 00:00:00',
+            'updated_at'       => $data['updated_at'] ?? '2020-01-01 00:00:00',
+        ]);
+
+        $map = [
+            'name'               => [self::ATTR_PRODUCT_NAME, 'varchar'],
+            'description'        => [self::ATTR_PRODUCT_DESCRIPTION, 'text'],
+            'short_description'  => [self::ATTR_PRODUCT_SHORT_DESCRIPTION, 'text'],
+            'price'              => [self::ATTR_PRODUCT_PRICE, 'decimal'],
+            'special_price'      => [self::ATTR_PRODUCT_SPECIAL_PRICE, 'decimal'],
+            'cost'               => [self::ATTR_PRODUCT_COST, 'decimal'],
+            'weight'             => [self::ATTR_PRODUCT_WEIGHT, 'decimal'],
+            'status'             => [self::ATTR_PRODUCT_STATUS, 'int'],
+            'meta_title'         => [self::ATTR_PRODUCT_META_TITLE, 'varchar'],
+            'meta_keyword'       => [self::ATTR_PRODUCT_META_KEYWORD, 'text'],
+            'meta_description'   => [self::ATTR_PRODUCT_META_DESCRIPTION, 'varchar'],
+            'url_key'            => [self::ATTR_PRODUCT_URL_KEY, 'varchar'],
+            'hair_colorr'        => [self::ATTR_PRODUCT_HAIR_COLORR, 'int'],
+        ];
+
+        $values = $data['eav'] ?? $data;
+
+        foreach ($map as $code => [$attributeId, $type]) {
+            if (! array_key_exists($code, $values) || $values[$code] === null || $values[$code] === '') {
+                continue;
+            }
+
+            $db->table('catalog_product_entity_'.$type)->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => $attributeId,
+                'store_id'     => 0,
+                'value'        => $values[$code],
+            ]);
+        }
+
+        if (! array_key_exists('status', $values)) {
+            $db->table('catalog_product_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => self::ATTR_PRODUCT_STATUS,
+                'store_id'     => 0,
+                'value'        => 1,
+            ]);
+        }
+
+        foreach ($data['images'] ?? [] as $position => $path) {
+            $valueId = $db->table('catalog_product_entity_media_gallery')->insertGetId([
+                'entity_id' => $entityId,
+                'value'     => $path,
+            ]);
+
+            $db->table('catalog_product_entity_media_gallery_value')->insert([
+                'value_id' => $valueId,
+                'store_id' => 0,
+                'label'    => '',
+                'position' => $position,
+                'disabled' => 0,
+            ]);
+        }
+
+        if (isset($data['qty']) || isset($data['is_in_stock'])) {
+            $db->table('cataloginventory_stock_item')->insert([
+                'product_id'  => $entityId,
+                'stock_id'    => 1,
+                'qty'         => $data['qty'] ?? 0,
+                'is_in_stock' => $data['is_in_stock'] ?? 0,
+            ]);
+        }
+
+        foreach ($data['categories'] ?? [] as $category) {
+            $categoryId = (int) $category['entity_id'];
+
+            if (! $db->table('catalog_category_entity')->where('entity_id', $categoryId)->exists()) {
+                $db->table('catalog_category_entity')->insert([
+                    'entity_id' => $categoryId,
+                    'path'      => $category['path'] ?? (string) $categoryId,
+                ]);
+
+                $db->table('catalog_category_entity_varchar')->insert([
+                    'entity_id'    => $categoryId,
+                    'attribute_id' => self::ATTR_CATEGORY_NAME,
+                    'store_id'     => 0,
+                    'value'        => $category['name'] ?? 'Category',
+                ]);
+            }
+
+            $db->table('catalog_category_product')->insert([
+                'category_id' => $categoryId,
+                'product_id'  => $entityId,
+                'position'    => $category['position'] ?? 0,
+            ]);
+        }
+
+        foreach ($data['options'] ?? [] as $option) {
+            self::seedProductOption($connection, array_merge($option, ['product_id' => $entityId]));
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedProductOption(string $connection, array $data): void
+    {
+        $db = DB::connection($connection);
+
+        $optionId = $db->table('catalog_product_option')->insertGetId([
+            'product_id'  => $data['product_id'],
+            'type'        => $data['type'] ?? 'drop_down',
+            'is_require'  => $data['is_require'] ?? 1,
+            'sort_order'  => $data['sort_order'] ?? 0,
+        ]);
+
+        $db->table('catalog_product_option_title')->insert([
+            'option_id' => $optionId,
+            'store_id'  => 0,
+            'title'     => $data['title'] ?? 'Option',
+        ]);
+
+        foreach ($data['values'] ?? [] as $index => $value) {
+            $typeId = $db->table('catalog_product_option_type_value')->insertGetId([
+                'option_id'  => $optionId,
+                'sku'        => $value['sku'] ?? '',
+                'sort_order' => $value['sort_order'] ?? $index,
+            ]);
+
+            $db->table('catalog_product_option_type_title')->insert([
+                'option_type_id' => $typeId,
+                'store_id'       => 0,
+                'title'          => $value['title'] ?? '',
+            ]);
+
+            $db->table('catalog_product_option_type_price')->insert([
+                'option_type_id' => $typeId,
+                'store_id'       => 0,
+                'price'          => $value['price'] ?? 0,
+                'price_type'     => $value['price_type'] ?? 'fixed',
+            ]);
+        }
+    }
 }

+ 263 - 1
packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaCustomersCommandTest.php

@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Schema;
 use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Core\Models\SubscribersList;
 use Webkul\Customer\Models\Customer;
 use Webkul\Customer\Models\CustomerAddress;
 use Webkul\Customer\Models\CustomerGroup;
@@ -22,7 +23,8 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         parent::setUp();
 
         if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
-            || ! Schema::hasColumn('customers', 'legacy_password')) {
+            || ! Schema::hasColumn('customers', 'legacy_password')
+            || ! Schema::hasColumn('customers', 'customer_source')) {
             $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
         }
 
@@ -68,6 +70,7 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
             'group_id'         => 99,
             'default_billing'  => 21,
             'default_shipping' => 21,
+            'customer_source'  => 20,
         ]);
         MagentoSchema::seedAddress($this->connection, [
             'entity_id'  => 21,
@@ -98,6 +101,8 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         $this->assertSame('5551112222', $customer->phone);
         $this->assertSame(md5('abcsecret12').':abc', $customer->legacy_password);
         $this->assertSame(1, (int) $customer->is_verified);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(20, (int) $customer->customer_source);
 
         $generalId = CustomerGroup::query()->where('code', 'general')->value('id');
         $this->assertSame((int) $generalId, (int) $customer->customer_group_id);
@@ -229,6 +234,263 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         $this->assertSame(1, CustomerAddress::query()->where('customer_id', $customer->id)->count());
     }
 
+    public function test_it_migrates_a_subscribed_customer_into_subscribers_list(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000090,
+            'email'     => 'asteria-nl-sub@nshop.test',
+            'firstname' => 'Jane',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'           => 1,
+            'customer_id'             => 880000090,
+            'subscriber_email'       => 'asteria-nl-sub@nshop.test',
+            'subscriber_status'      => 1,
+            'subscriber_confirm_code' => 'magento-token',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-sub@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(1, (int) $customer->subscribed_to_news_letter);
+
+        $subscription = SubscribersList::query()->where('email', 'asteria-nl-sub@nshop.test')->first();
+        $this->assertNotNull($subscription);
+        $this->assertSame(1, (int) $subscription->is_subscribed);
+        $this->assertSame($customer->id, (int) $subscription->customer_id);
+        $this->assertSame('magento-token', $subscription->token);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-sub@nshop.test')->count());
+    }
+
+    public function test_it_records_an_unsubscribed_magento_customer(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000091,
+            'email'     => 'asteria-nl-unsub@nshop.test',
+            'firstname' => 'Un',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 2,
+            'customer_id'        => 880000091,
+            'subscriber_email'  => 'asteria-nl-unsub@nshop.test',
+            'subscriber_status' => 3,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-unsub@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+
+        $subscription = SubscribersList::query()->where('email', 'asteria-nl-unsub@nshop.test')->first();
+        $this->assertNotNull($subscription);
+        $this->assertSame(0, (int) $subscription->is_subscribed);
+        $this->assertSame($customer->id, (int) $subscription->customer_id);
+    }
+
+    public function test_it_syncs_subscription_when_linking_an_existing_customer(): void
+    {
+        $existing = $this->createCustomer([
+            'email'                     => 'asteria-nl-keep@nshop.test',
+            'first_name'                => 'Keep',
+            'subscribed_to_news_letter' => 0,
+        ]);
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000092,
+            'email'     => 'asteria-nl-keep@nshop.test',
+            'firstname' => 'Magento',
+            'lastname'  => 'Name',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 3,
+            'customer_id'        => 880000092,
+            'subscriber_email'  => 'asteria-nl-keep@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $existing->refresh();
+        $this->assertSame('Keep', $existing->first_name);
+        $this->assertSame(1, (int) $existing->subscribed_to_news_letter);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-keep@nshop.test')->where('is_subscribed', 1)->count());
+    }
+
+    public function test_it_backfills_subscriptions_without_rescanning_customers(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000093,
+            'email'     => 'asteria-nl-later@nshop.test',
+            'firstname' => 'Later',
+            'lastname'  => 'Sub',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-later@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 4,
+            'customer_id'        => 880000093,
+            'subscriber_email'  => 'asteria-nl-later@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection' => $this->connection,
+        ])->assertSuccessful();
+
+        $customer->refresh();
+        $this->assertSame(1, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-later@nshop.test')->where('customer_id', $customer->id)->count());
+    }
+
+    public function test_it_imports_guest_newsletter_subscribers(): void
+    {
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 5,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'asteria-nl-guest@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 6,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'asteria-nl-guest-unsub@nshop.test',
+            'subscriber_status' => 3,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $guest = SubscribersList::query()->where('email', 'asteria-nl-guest@nshop.test')->first();
+        $this->assertNotNull($guest);
+        $this->assertSame(1, (int) $guest->is_subscribed);
+        $this->assertNull($guest->customer_id);
+        $this->assertNull(SubscribersList::query()->where('email', 'asteria-nl-guest-unsub@nshop.test')->first());
+    }
+
+    public function test_it_does_not_duplicate_subscribers_on_a_second_run(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000094,
+            'email'     => 'asteria-nl-twice@nshop.test',
+            'firstname' => 'Twice',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 7,
+            'customer_id'        => 880000094,
+            'subscriber_email'  => 'asteria-nl-twice@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $options = [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ];
+
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-twice@nshop.test')->count());
+    }
+
+    public function test_it_skips_newsletter_when_the_magento_table_is_missing(): void
+    {
+        Schema::connection($this->connection)->drop('newsletter_subscriber');
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000095,
+            'email'     => 'asteria-nl-notable@nshop.test',
+            'firstname' => 'No',
+            'lastname'  => 'Table',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-notable@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(0, SubscribersList::query()->where('email', 'asteria-nl-notable@nshop.test')->count());
+    }
+
+    public function test_it_migrates_customer_source(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id'       => 880000096,
+            'email'           => 'asteria-src@nshop.test',
+            'firstname'       => 'Src',
+            'lastname'        => 'User',
+            'customer_source' => 22,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-src@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(22, (int) $customer->customer_source);
+    }
+
+    public function test_it_backfills_customer_source_on_an_already_migrated_customer(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000097,
+            'email'     => 'asteria-src-later@nshop.test',
+            'firstname' => 'Later',
+            'lastname'  => 'Src',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-src-later@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertNull($customer->customer_source);
+
+        DB::connection($this->connection)->table('customer_entity_int')->insert([
+            'entity_id'    => 880000097,
+            'attribute_id' => 89,
+            'value'        => 15,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer->refresh();
+        $this->assertSame(15, (int) $customer->customer_source);
+    }
+
     private function asteriaAddressId(CustomerAddress $address): ?int
     {
         $additional = $address->additional;

+ 1 - 0
packages/Webkul/Customer/src/Models/Customer.php

@@ -57,6 +57,7 @@ class Customer extends Authenticatable implements CustomerContract
         'token',
         'customer_group_id',
         'channel_id',
+        'customer_source',
         'subscribed_to_news_letter',
         'status',
         'is_verified',