Преглед изворни кода

添加 orders:reset,便于清空现有订单后重新迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl пре 1 недеља
родитељ
комит
2ce5b8c734

+ 271 - 0
app/Console/Commands/ResetOrdersCommand.php

@@ -0,0 +1,271 @@
+<?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 sales orders so `orders:migrate-asteria` can start clean.
+ *
+ * Keeps customers, products, carts, and customer groups. Invoices, shipments,
+ * refunds, payments, and order addresses are removed. Gift-card / reward /
+ * booking rows keep their snapshots; `order_id` is set to NULL.
+ *
+ * Usage
+ * ─────
+ * php artisan orders:reset --dry-run
+ * php artisan orders:reset --force
+ */
+class ResetOrdersCommand extends Command
+{
+    protected $signature = 'orders:reset
+        {--dry-run : Count rows only, do not write}
+        {--force : Skip the confirmation prompt}';
+
+    protected $description = 'Truncate existing orders so a fresh order migration can run';
+
+    private const PROGRESS_KEY = 'migrate_asteria_orders_last_id';
+
+    /**
+     * Sales tables truncated in child-first order.
+     *
+     * @var list<string>
+     */
+    private const ORDER_TABLES = [
+        'refund_items',
+        'refunds',
+        'invoice_items',
+        'invoices',
+        'shipment_items',
+        'shipments',
+        'order_comments',
+        'order_transactions',
+        'order_payment',
+        'downloadable_product_download_links',
+        'downloadable_link_purchased',
+        'order_items',
+        'notifications',
+        'product_ordered_inventories',
+        'orders',
+    ];
+
+    /**
+     * Rows that keep their snapshot but must not block deleting orders.
+     *
+     * @var list<array{0: string, 1: string}>
+     */
+    private const DETACH_ORDER_ID = [
+        ['bookings', 'order_id'],
+        ['bookings', 'order_item_id'],
+        ['gift_card_usage_logs', 'order_id'],
+        ['mw_growth_value_history', 'order_id'],
+        ['mw_reward_point_history', 'history_order_id'],
+        ['member_log', 'order_id'],
+        ['payment_attempts', 'order_id'],
+    ];
+
+    /** @var list<string> */
+    private const ORDER_ADDRESS_TYPES = [
+        'order_billing',
+        'order_shipping',
+        'invoice_billing',
+        'invoice_shipping',
+    ];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $tables = $this->existingTables(self::ORDER_TABLES);
+
+        $this->warn('This clears sales orders so they can be re-imported.');
+        $this->line('Kept: customers, products, carts, customer groups.');
+        $this->line('Removed: orders, items, payments, invoices, shipments, refunds, order addresses.');
+        $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("Order rows that would be truncated: {$total}");
+
+        $addressCount = $this->countOrderAddresses();
+        if ($addressCount > 0) {
+            $this->comment("addresses (order/invoice): {$addressCount}");
+        }
+
+        $detachCounts = $this->countDetachRows();
+        if ($detachCounts !== []) {
+            $this->newLine();
+            $this->comment('Related rows whose order_id will be set to NULL:');
+            $this->table(['table.column', 'rows'], collect($detachCounts)->map(fn ($count, $key) => [$key, $count])->values()->all());
+        }
+
+        if ($dryRun) {
+            $this->info('Dry run — nothing written.');
+
+            return self::SUCCESS;
+        }
+
+        if (! $this->option('force') && ! $this->confirm('Truncate order tables now?', false)) {
+            $this->info('Aborted.');
+
+            return self::SUCCESS;
+        }
+
+        $driver = DB::getDriverName();
+
+        try {
+            $this->disableForeignKeyChecks($driver);
+
+            $this->deleteOrderAddresses();
+            $this->detachHistoricalOrderIds();
+
+            foreach ($tables as $table) {
+                $this->emptyTable($driver, $table);
+            }
+        } finally {
+            $this->enableForeignKeyChecks($driver);
+        }
+
+        Cache::forget(self::PROGRESS_KEY);
+
+        $this->newLine();
+        $this->info('Order reset finished. Next:');
+        $this->line('  php artisan orders:migrate-asteria --reset-progress');
+
+        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_ORDER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $count = (int) DB::table($table)->whereNotNull($column)->where($column, '!=', 0)->count();
+
+            if ($count > 0) {
+                $counts[$table.'.'.$column] = $count;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function countOrderAddresses(): int
+    {
+        if (! Schema::hasTable('addresses')) {
+            return 0;
+        }
+
+        return (int) $this->orderAddressQuery()->count();
+    }
+
+    private function deleteOrderAddresses(): void
+    {
+        if (! Schema::hasTable('addresses')) {
+            return;
+        }
+
+        $this->orderAddressQuery()->delete();
+    }
+
+    private function orderAddressQuery()
+    {
+        $query = DB::table('addresses');
+
+        return $query->where(function ($builder) {
+            if (Schema::hasColumn('addresses', 'order_id')) {
+                $builder->orWhereNotNull('order_id');
+            }
+
+            if (Schema::hasColumn('addresses', 'address_type')) {
+                $builder->orWhereIn('address_type', self::ORDER_ADDRESS_TYPES);
+            }
+        });
+    }
+
+    private function detachHistoricalOrderIds(): void
+    {
+        foreach (self::DETACH_ORDER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            DB::table($table)->whereNotNull($column)->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');
+        }
+    }
+}

+ 43 - 0
database/scripts/reset_orders.sql

@@ -0,0 +1,43 @@
+-- Reset Bagisto sales orders so a fresh order 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 orders:reset --dry-run
+--   php artisan orders:reset --force
+--
+-- KEPT: customers, products, carts, customer groups.
+-- REMOVED: orders, items, payments, invoices, shipments, refunds, order addresses.
+--
+-- After this:
+--   php artisan orders:migrate-asteria --reset-progress
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+UPDATE bookings                 SET order_id = NULL WHERE order_id IS NOT NULL;
+UPDATE bookings                 SET order_item_id = NULL WHERE order_item_id IS NOT NULL;
+UPDATE gift_card_usage_logs    SET order_id = NULL WHERE order_id IS NOT NULL;
+UPDATE mw_growth_value_history SET order_id = NULL WHERE order_id IS NOT NULL;
+UPDATE mw_reward_point_history  SET history_order_id = NULL WHERE history_order_id IS NOT NULL;
+UPDATE member_log             SET order_id = NULL WHERE order_id IS NOT NULL;
+UPDATE payment_attempts        SET order_id = NULL WHERE order_id IS NOT NULL;
+
+DELETE FROM addresses
+ WHERE order_id IS NOT NULL
+    OR address_type IN ('order_billing', 'order_shipping', 'invoice_billing', 'invoice_shipping');
+
+TRUNCATE TABLE refund_items;
+TRUNCATE TABLE refunds;
+TRUNCATE TABLE invoice_items;
+TRUNCATE TABLE invoices;
+TRUNCATE TABLE shipment_items;
+TRUNCATE TABLE shipments;
+TRUNCATE TABLE order_comments;
+TRUNCATE TABLE order_transactions;
+TRUNCATE TABLE order_payment;
+TRUNCATE TABLE downloadable_link_purchased;
+TRUNCATE TABLE order_items;
+TRUNCATE TABLE notifications;
+TRUNCATE TABLE product_ordered_inventories;
+TRUNCATE TABLE orders;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 33 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/ResetOrdersCommandTest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Sales\Models\Order;
+
+class ResetOrdersCommandTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('orders')) {
+            $this->markTestSkipped('Order tables are missing.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_dry_run_does_not_delete_orders(): void
+    {
+        $this->createCustomer();
+
+        $order = Order::factory()->create();
+
+        $this->artisan('orders:reset', ['--dry-run' => true])
+            ->assertSuccessful();
+
+        $this->assertDatabaseHas('orders', ['id' => $order->id]);
+    }
+}