Переглянути джерело

添加 customers:reset,便于清空现有用户后重新迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl 1 день тому
батько
коміт
e0f699bd7f

+ 377 - 0
app/Console/Commands/ResetCustomersCommand.php

@@ -0,0 +1,377 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Wipe Bagisto storefront customers so `customers:migrate-asteria` can start clean.
+ *
+ * Keeps customer_groups, admins, products, and orders. Historical orders / reviews /
+ * Q&A keep their snapshots; `customer_id` is set to NULL. Customer addresses,
+ * newsletters, wishlists, compare items, reward/growth balances, and login tokens
+ * are removed.
+ *
+ * Usage
+ * ─────
+ * php artisan customers:reset --dry-run
+ * php artisan customers:reset --force
+ */
+class ResetCustomersCommand extends Command
+{
+    protected $signature = 'customers:reset
+        {--dry-run : Count rows only, do not write}
+        {--force : Skip the confirmation prompt}';
+
+    protected $description = 'Truncate existing storefront customers so a fresh customer migration can run';
+
+    private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
+
+    /**
+     * Customer-owned tables truncated in child-first order.
+     *
+     * @var list<string>
+     */
+    private const CUSTOMER_TABLES = [
+        'customer_notes',
+        'customer_social_accounts',
+        'customer_password_resets',
+        'gdpr_data_request',
+        'wishlist_items',
+        'wishlist',
+        'compare_items',
+        'cart_rule_customers',
+        'cart_rule_coupon_usage',
+        'mw_reward_point_customer_sign',
+        'mw_reward_point_history',
+        'mw_reward_point_customer',
+        'mw_growth_value_history',
+        'mw_growth_value_customer',
+        'member_log',
+        'downloadable_link_purchased',
+        'subscribers_list',
+        'customers',
+    ];
+
+    /**
+     * Historical / catalog rows that keep their snapshot but must not block deleting customers.
+     *
+     * @var list<array{0: string, 1: string}>
+     */
+    private const DETACH_CUSTOMER_ID = [
+        ['orders', 'customer_id'],
+        ['shipments', 'customer_id'],
+        ['product_reviews', 'customer_id'],
+        ['product_questions', 'customer_id'],
+        ['product_question_answers', 'customer_id'],
+        ['product_question_answer_votes', 'customer_id'],
+        ['cart_rule_coupons', 'customer_id'],
+        ['gift_cards', 'customer_id'],
+        ['gift_card_usage_logs', 'customer_id'],
+        ['addresses', 'customer_id'],
+    ];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $tables = $this->existingTables(self::CUSTOMER_TABLES);
+
+        $this->warn('This clears storefront customers so they can be re-imported.');
+        $this->line('Kept: customer groups, admins, products, orders (email snapshot stays; customer_id set null).');
+        $this->line('Removed: customers, customer addresses, newsletters, wishlists, compare, reward/growth balances, tokens.');
+        $this->newLine();
+
+        $rows = $this->countRows($tables);
+        $this->table(['table', 'rows'], collect($rows)->map(fn ($count, $table) => [$table, $count])->values()->all());
+
+        $total = array_sum($rows);
+        $this->info("Customer rows that would be truncated: {$total}");
+
+        $addressCount = $this->countCustomerAddresses();
+        if ($addressCount > 0) {
+            $this->comment("addresses (address_type=customer): {$addressCount}");
+        }
+
+        $cartCount = $this->countCustomerCarts();
+        if ($cartCount > 0) {
+            $this->comment("cart (logged-in customers): {$cartCount}");
+        }
+
+        $tokenCount = $this->countCustomerTokens();
+        if ($tokenCount > 0) {
+            $this->comment("personal_access_tokens (customers): {$tokenCount}");
+        }
+
+        $detachCounts = $this->countDetachRows();
+        if ($detachCounts !== []) {
+            $this->newLine();
+            $this->comment('Historical rows whose customer_id will be set to NULL:');
+            $this->table(['table', 'rows with customer_id'], collect($detachCounts)->map(fn ($count, $table) => [$table, $count])->values()->all());
+        }
+
+        if ($dryRun) {
+            $this->info('Dry run — nothing written.');
+
+            return self::SUCCESS;
+        }
+
+        if (! $this->option('force') && ! $this->confirm('Truncate customer tables now?', false)) {
+            $this->info('Aborted.');
+
+            return self::SUCCESS;
+        }
+
+        $driver = DB::getDriverName();
+
+        try {
+            $this->disableForeignKeyChecks($driver);
+
+            $this->deleteCustomerAddresses();
+            $this->deleteCustomerCarts();
+            $this->deleteCustomerTokens();
+            $this->deleteCustomerVisits();
+            $this->detachHistoricalCustomerIds();
+
+            foreach ($tables as $table) {
+                $this->emptyTable($driver, $table);
+            }
+        } finally {
+            $this->enableForeignKeyChecks($driver);
+        }
+
+        Cache::forget(self::PROGRESS_KEY);
+
+        $this->newLine();
+        $this->info('Customer reset finished. Next:');
+        $this->line('  php artisan customers:migrate-asteria --reset-progress');
+        $this->line('  php artisan orders:migrate-asteria --reset-progress   # if orders should re-link to new customer ids');
+        $this->line('  php artisan reviews:migrate-asteria --reset-progress --sync');
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return list<string>
+     */
+    private function existingTables(array $tables): array
+    {
+        return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return array<string, int>
+     */
+    private function countRows(array $tables): array
+    {
+        $counts = [];
+
+        foreach ($tables as $table) {
+            $counts[$table] = (int) DB::table($table)->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * @return array<string, int>
+     */
+    private function countDetachRows(): array
+    {
+        $counts = [];
+
+        foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $query = DB::table($table)->whereNotNull($column);
+
+            if ($table === 'gift_cards') {
+                $query->where($column, '!=', 0);
+            }
+
+            if ($table === 'addresses' && Schema::hasColumn($table, 'address_type')) {
+                $query->where('address_type', '!=', 'customer');
+            }
+
+            $count = (int) $query->count();
+
+            if ($count > 0) {
+                $counts[$table] = $count;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function countCustomerAddresses(): int
+    {
+        if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
+            return 0;
+        }
+
+        return (int) DB::table('addresses')->where('address_type', 'customer')->count();
+    }
+
+    private function countCustomerCarts(): int
+    {
+        if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
+            return 0;
+        }
+
+        return (int) DB::table('cart')->whereNotNull('customer_id')->count();
+    }
+
+    private function countCustomerTokens(): int
+    {
+        if (! Schema::hasTable('personal_access_tokens')) {
+            return 0;
+        }
+
+        return (int) $this->customerTokenQuery()->count();
+    }
+
+    private function deleteCustomerAddresses(): void
+    {
+        if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
+            return;
+        }
+
+        DB::table('addresses')->where('address_type', 'customer')->delete();
+    }
+
+    private function deleteCustomerCarts(): void
+    {
+        if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
+            return;
+        }
+
+        $cartIds = DB::table('cart')->whereNotNull('customer_id')->pluck('id');
+
+        if ($cartIds->isEmpty()) {
+            return;
+        }
+
+        $itemIds = Schema::hasTable('cart_items')
+            ? DB::table('cart_items')->whereIn('cart_id', $cartIds)->pluck('id')
+            : collect();
+
+        if ($itemIds->isNotEmpty() && Schema::hasTable('cart_item_inventories')) {
+            DB::table('cart_item_inventories')->whereIn('cart_item_id', $itemIds)->delete();
+        }
+
+        if ($itemIds->isNotEmpty()) {
+            DB::table('cart_items')->whereIn('id', $itemIds)->delete();
+        }
+
+        if (Schema::hasTable('cart_payment')) {
+            DB::table('cart_payment')->whereIn('cart_id', $cartIds)->delete();
+        }
+
+        if (Schema::hasTable('guest_cart_tokens')) {
+            DB::table('guest_cart_tokens')->whereIn('cart_id', $cartIds)->delete();
+        }
+
+        $addressIds = collect();
+
+        if (Schema::hasTable('addresses') && Schema::hasColumn('addresses', 'cart_id')) {
+            $addressIds = DB::table('addresses')->whereIn('cart_id', $cartIds)->pluck('id');
+        }
+
+        if ($addressIds->isNotEmpty() && Schema::hasTable('cart_shipping_rates') && Schema::hasColumn('cart_shipping_rates', 'cart_address_id')) {
+            DB::table('cart_shipping_rates')->whereIn('cart_address_id', $addressIds)->delete();
+        }
+
+        if ($addressIds->isNotEmpty()) {
+            DB::table('addresses')->whereIn('id', $addressIds)->delete();
+        }
+
+        DB::table('cart')->whereIn('id', $cartIds)->delete();
+    }
+
+    private function deleteCustomerTokens(): void
+    {
+        if (! Schema::hasTable('personal_access_tokens')) {
+            return;
+        }
+
+        $this->customerTokenQuery()->delete();
+    }
+
+    private function customerTokenQuery()
+    {
+        return DB::table('personal_access_tokens')
+            ->where('tokenable_type', 'like', '%Customer%');
+    }
+
+    private function deleteCustomerVisits(): void
+    {
+        if (! Schema::hasTable('visits') || ! Schema::hasColumn('visits', 'visitable_type')) {
+            return;
+        }
+
+        DB::table('visits')->where('visitable_type', 'like', '%Customer%')->delete();
+    }
+
+    private function detachHistoricalCustomerIds(): void
+    {
+        foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $query = DB::table($table)->whereNotNull($column);
+
+            if ($table === 'gift_cards') {
+                $query->where($column, '!=', 0);
+            }
+
+            $query->update([$column => null]);
+        }
+    }
+
+    private function emptyTable(string $driver, string $table): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
+
+            return;
+        }
+
+        DB::table($table)->delete();
+
+        if ($driver === 'sqlite') {
+            DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
+        }
+    }
+
+    private function quoteTable(string $table): string
+    {
+        $name = DB::getTablePrefix().$table;
+
+        return '`'.str_replace('`', '``', $name).'`';
+    }
+
+    private function disableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=0');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = OFF');
+        }
+    }
+
+    private function enableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=1');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = ON');
+        }
+    }
+}

+ 76 - 0
database/scripts/reset_customers.sql

@@ -0,0 +1,76 @@
+-- Reset Bagisto storefront customers so a fresh customer import can run.
+-- MySQL / TablePlus. If a statement fails because the table is missing, skip that line.
+--
+-- Prefer the artisan command (counts first, skips missing tables, resets AUTO_INCREMENT):
+--   php artisan customers:reset --dry-run
+--   php artisan customers:reset --force
+--
+-- KEPT: customer_groups, admins, products, orders (email snapshot stays).
+-- REMOVED: customers, customer addresses, newsletters, wishlists, compare, reward/growth.
+--
+-- After this:
+--   php artisan customers:migrate-asteria --reset-progress
+--   php artisan orders:migrate-asteria --reset-progress
+--   php artisan reviews:migrate-asteria --reset-progress --sync
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+UPDATE orders                         SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE shipments                      SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_reviews                SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_questions              SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_question_answers       SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_question_answer_votes  SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE cart_rule_coupons              SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE gift_cards                     SET customer_id = NULL WHERE customer_id IS NOT NULL AND customer_id != 0;
+UPDATE gift_card_usage_logs           SET customer_id = NULL WHERE customer_id IS NOT NULL;
+
+DELETE FROM addresses WHERE address_type = 'customer';
+UPDATE addresses SET customer_id = NULL WHERE customer_id IS NOT NULL;
+
+DELETE FROM personal_access_tokens WHERE tokenable_type LIKE '%Customer%';
+DELETE FROM visits WHERE visitable_type LIKE '%Customer%';
+
+DELETE ci FROM cart_item_inventories ci
+    INNER JOIN cart_items i ON i.id = ci.cart_item_id
+    INNER JOIN cart c ON c.id = i.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE i FROM cart_items i
+    INNER JOIN cart c ON c.id = i.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE p FROM cart_payment p
+    INNER JOIN cart c ON c.id = p.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE r FROM cart_shipping_rates r
+    INNER JOIN addresses a ON a.id = r.cart_address_id
+    INNER JOIN cart c ON c.id = a.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE t FROM guest_cart_tokens t
+    INNER JOIN cart c ON c.id = t.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE a FROM addresses a
+    INNER JOIN cart c ON c.id = a.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE FROM cart WHERE customer_id IS NOT NULL;
+
+TRUNCATE TABLE customer_notes;
+TRUNCATE TABLE customer_social_accounts;
+TRUNCATE TABLE customer_password_resets;
+TRUNCATE TABLE gdpr_data_request;
+TRUNCATE TABLE wishlist_items;
+TRUNCATE TABLE wishlist;
+TRUNCATE TABLE compare_items;
+TRUNCATE TABLE cart_rule_customers;
+TRUNCATE TABLE cart_rule_coupon_usage;
+TRUNCATE TABLE mw_reward_point_customer_sign;
+TRUNCATE TABLE mw_reward_point_history;
+TRUNCATE TABLE mw_reward_point_customer;
+TRUNCATE TABLE mw_growth_value_history;
+TRUNCATE TABLE mw_growth_value_customer;
+TRUNCATE TABLE member_log;
+TRUNCATE TABLE downloadable_link_purchased;
+TRUNCATE TABLE subscribers_list;
+
+TRUNCATE TABLE customers;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 5 - 1
docs/asteria-migration.md

@@ -2,13 +2,15 @@
 
 从旧站 Asteria(Magento 1.x)只读导入商品、用户、评论、订单到本店。源库以 **`as`** 为准(不要用 `longyishop`)。命令共用 Laravel 连接名 `asteria`,可分批、可断点续跑、可 `--dry-run`,重复执行不会重复插入。
 
-**推荐顺序:** 需要清空现有商品时先 reset,再迁商品,再迁用户,再迁订单 / 评论。
+**推荐顺序:** 需要清空现有数据时先 reset,再迁商品,再迁用户,再迁订单 / 评论。
 
 ```bash
 php artisan migrate
 php artisan catalog:reset --dry-run         # 先看会清哪些表
 php artisan catalog:reset --force           # 清空现有商品,便于重新导入
 php artisan products:migrate-asteria        # Magento 商品 → flexible_variant,SKU 与旧站一致
+php artisan customers:reset --dry-run
+php artisan customers:reset --force         # 清空现有用户,便于重新导入
 php artisan customers:migrate-asteria
 php artisan orders:migrate-asteria
 php artisan reviews:migrate-asteria --sync  # 或走队列,见下文
@@ -16,6 +18,8 @@ php artisan reviews:migrate-asteria --sync  # 或走队列,见下文
 
 `catalog:reset` 会 TRUNCATE 商品及变体 / 库存 / 评论 / 购物车行 / 收藏,并把历史订单行的 `product_id` 置空(SKU 快照保留)。属性、分类、用户、订单头不删。也可跑 `database/scripts/reset_catalog.sql`。图片目录加 `--purge-files`。
 
+`customers:reset` 会 TRUNCATE 前台用户及地址 / 订阅 / 收藏 / 积分成长值,并把历史订单、评论的 `customer_id` 置空(邮箱快照保留)。客户分组、后台管理员、商品、订单头不删。也可跑 `database/scripts/reset_customers.sql`。
+
 ---
 
 ## 1. 前置:Asteria 数据库连接

+ 31 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/ResetCustomersCommandTest.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Customer\Models\Customer;
+
+class ResetCustomersCommandTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('customers') || ! Schema::hasTable('customer_groups')) {
+            $this->markTestSkipped('Customer tables are missing.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_dry_run_does_not_delete_customers(): void
+    {
+        $customer = $this->createCustomer();
+
+        $this->artisan('customers:reset', ['--dry-run' => true])
+            ->assertSuccessful();
+
+        $this->assertDatabaseHas('customers', ['id' => $customer->id]);
+    }
+}