Quellcode durchsuchen

新增 Asteria 订单迁移,并兼容源库 MySQL 5.6。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl vor 3 Tagen
Ursprung
Commit
83b1e700b0

+ 739 - 0
app/Console/Commands/MigrateAsteriaOrders.php

@@ -0,0 +1,739 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Asteria\Magento1OrderReader;
+use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+use Webkul\Core\Models\Channel;
+use Webkul\Customer\Models\Customer;
+use Webkul\Product\Models\Product;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Models\OrderAddress;
+use Webkul\Sales\Models\OrderItem;
+use Webkul\Sales\Models\OrderPayment;
+
+/**
+ * Migrates storefront orders from Asteria (Magento 1.x).
+ *
+ * Does not create invoices/shipments/refunds, does not decrement inventory,
+ * and does not fire checkout.order.save.after listeners.
+ *
+ * Usage
+ * ─────
+ * php artisan orders:migrate-asteria
+ * php artisan orders:migrate-asteria --batch-size=200
+ * php artisan orders:migrate-asteria --reset-progress
+ * php artisan orders:migrate-asteria --dry-run
+ */
+class MigrateAsteriaOrders extends Command
+{
+    protected $signature = 'orders:migrate-asteria
+        {--batch-size=100     : Number of Magento orders per batch}
+        {--reset-progress     : Ignore saved progress and start from entity_id=0}
+        {--dry-run            : Count records without writing}
+        {--connection=asteria : Laravel DB connection for the Asteria database}';
+
+    protected $description = 'Migrate Asteria (Magento 1.x) orders into Bagisto';
+
+    private const PROGRESS_KEY = 'migrate_asteria_orders_last_id';
+
+    private const BAGISTO_PRODUCT_TYPES = [
+        'simple',
+        'configurable',
+        'virtual',
+        'downloadable',
+        'bundle',
+        'grouped',
+    ];
+
+    public function handle(): int
+    {
+        $connection = (string) $this->option('connection');
+        $batchSize = max(1, (int) $this->option('batch-size'));
+        $resetProgress = (bool) $this->option('reset-progress');
+        $dryRun = (bool) $this->option('dry-run');
+
+        try {
+            DB::connection($connection)->getPdo();
+        } catch (\Throwable $e) {
+            $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
+
+            return self::FAILURE;
+        }
+
+        foreach ([
+            'sales_flat_order',
+            'sales_flat_order_item',
+            'sales_flat_order_address',
+            'sales_flat_order_payment',
+        ] as $table) {
+            if (! Schema::connection($connection)->hasTable($table)) {
+                $this->error("Asteria table '{$table}' is missing on connection '{$connection}'.");
+
+                return self::FAILURE;
+            }
+        }
+
+        if (! Schema::hasColumn('orders', 'migrated_from_asteria_id')) {
+            $this->error('orders.migrated_from_asteria_id is missing. Run php artisan migrate.');
+
+            return self::FAILURE;
+        }
+
+        $channel = core()->getDefaultChannel() ?? core()->getCurrentChannel();
+
+        if (! $channel) {
+            $this->error('Bagisto default channel was not found.');
+
+            return self::FAILURE;
+        }
+
+        $reader = new Magento1OrderReader($connection);
+        $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
+
+        if ($resetProgress) {
+            Cache::forget(self::PROGRESS_KEY);
+        }
+
+        if ($lastId > 0) {
+            $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
+        }
+
+        $created = 0;
+        $skipped = 0;
+        $itemsImported = 0;
+        $addressesImported = 0;
+        $batchNumber = 0;
+
+        $this->info($dryRun ? '[DRY RUN] Scanning Magento orders…' : 'Migrating Magento orders…');
+
+        do {
+            $orders = $reader->fetchOrders($lastId, $batchSize);
+
+            if ($orders->isEmpty()) {
+                break;
+            }
+
+            $batchNumber++;
+            $lastId = (int) $orders->max('entity_id');
+            $orderIds = $orders->pluck('entity_id')->map(fn ($id) => (int) $id)->all();
+
+            $items = $reader->fetchItems($orderIds)->groupBy(fn (array $row) => (int) $row['order_id']);
+            $addresses = $reader->fetchAddresses($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
+            $payments = $reader->fetchPayments($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
+
+            if ($dryRun) {
+                $created += $orders->count();
+                $itemsImported += $items->flatten(1)->count();
+                $addressesImported += $addresses->flatten(1)->count();
+                $this->line(sprintf(
+                    '  Batch #%d: %d orders, %d items, %d addresses (last entity_id=%d) [skipped – dry-run]',
+                    $batchNumber,
+                    $orders->count(),
+                    $items->flatten(1)->count(),
+                    $addresses->flatten(1)->count(),
+                    $lastId
+                ));
+
+                continue;
+            }
+
+            [$customersByAsteriaId, $customersByEmail] = $this->loadCustomers($orders);
+            $productsBySku = $this->loadProducts($items);
+
+            $batchCreated = 0;
+            $batchSkipped = 0;
+            $batchItems = 0;
+            $batchAddresses = 0;
+
+            DB::transaction(function () use (
+                $orders,
+                $items,
+                $addresses,
+                $payments,
+                $channel,
+                $customersByAsteriaId,
+                $customersByEmail,
+                $productsBySku,
+                &$batchCreated,
+                &$batchSkipped,
+                &$batchItems,
+                &$batchAddresses
+            ) {
+                foreach ($orders as $row) {
+                    $result = $this->migrateOrder(
+                        $row,
+                        $items->get((int) $row['entity_id'], collect()),
+                        $addresses->get((int) $row['entity_id'], collect()),
+                        $payments->get((int) $row['entity_id'], collect())->first(),
+                        $channel,
+                        $customersByAsteriaId,
+                        $customersByEmail,
+                        $productsBySku
+                    );
+
+                    $batchCreated += $result['created'];
+                    $batchSkipped += $result['skipped'];
+                    $batchItems += $result['items'];
+                    $batchAddresses += $result['addresses'];
+                }
+            });
+
+            Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
+
+            $created += $batchCreated;
+            $skipped += $batchSkipped;
+            $itemsImported += $batchItems;
+            $addressesImported += $batchAddresses;
+
+            $this->line(sprintf(
+                '  Batch #%d: created=%d skipped=%d items=%d addresses=%d (last entity_id=%d)',
+                $batchNumber,
+                $batchCreated,
+                $batchSkipped,
+                $batchItems,
+                $batchAddresses,
+                $lastId
+            ));
+
+            Log::info('MigrateAsteriaOrders: batch '.$batchNumber.', last_id='.$lastId);
+        } while ($orders->count() === $batchSize);
+
+        $this->newLine();
+        $this->info("Done. Batches: {$batchNumber}, created: {$created}, skipped: {$skipped}, items: {$itemsImported}, addresses: {$addressesImported}.");
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  Collection<int, array<string, mixed>>  $itemRows
+     * @param  Collection<int, array<string, mixed>>  $addressRows
+     * @param  array<string, mixed>|null  $paymentRow
+     * @param  array<int, Customer>  $customersByAsteriaId
+     * @param  array<string, Customer>  $customersByEmail
+     * @param  array<string, Product>  $productsBySku
+     * @return array{created: int, skipped: int, items: int, addresses: int}
+     */
+    private function migrateOrder(
+        array $row,
+        $itemRows,
+        $addressRows,
+        ?array $paymentRow,
+        Channel $channel,
+        array $customersByAsteriaId,
+        array $customersByEmail,
+        array $productsBySku
+    ): array {
+        $asteriaId = (int) $row['entity_id'];
+        $incrementId = trim((string) ($row['increment_id'] ?? ''));
+
+        if ($asteriaId < 1 || $incrementId === '') {
+            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
+        }
+
+        $alreadyMigrated = Order::query()
+            ->where('migrated_from_asteria_id', $asteriaId)
+            ->exists();
+
+        if ($alreadyMigrated) {
+            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
+        }
+
+        $incrementTaken = Order::query()
+            ->where('increment_id', $incrementId)
+            ->exists();
+
+        if ($incrementTaken) {
+            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
+        }
+
+        $customer = $this->resolveCustomer($row, $customersByAsteriaId, $customersByEmail);
+        $email = strtolower(trim((string) ($row['customer_email'] ?? '')));
+
+        if ($email === '' && $customer) {
+            $email = strtolower((string) $customer->email);
+        }
+
+        $subTotal = $this->money($row['subtotal'] ?? 0);
+        $baseSubTotal = $this->money($row['base_subtotal'] ?? $subTotal);
+        $taxAmount = $this->money($row['tax_amount'] ?? 0);
+        $baseTaxAmount = $this->money($row['base_tax_amount'] ?? $taxAmount);
+        $shippingAmount = $this->money($row['shipping_amount'] ?? 0);
+        $baseShippingAmount = $this->money($row['base_shipping_amount'] ?? $shippingAmount);
+        $shippingTaxAmount = $this->money($row['shipping_tax_amount'] ?? 0);
+        $baseShippingTaxAmount = $this->money($row['base_shipping_tax_amount'] ?? $shippingTaxAmount);
+        $discountAmount = $this->money($row['discount_amount'] ?? 0);
+        $baseDiscountAmount = $this->money($row['base_discount_amount'] ?? $discountAmount);
+        $grandTotal = $this->money($row['grand_total'] ?? 0);
+        $baseGrandTotal = $this->money($row['base_grand_total'] ?? $grandTotal);
+
+        $subTotalInclTax = array_key_exists('subtotal_incl_tax', $row)
+            ? $this->money($row['subtotal_incl_tax'])
+            : $subTotal + $taxAmount;
+        $baseSubTotalInclTax = array_key_exists('base_subtotal_incl_tax', $row)
+            ? $this->money($row['base_subtotal_incl_tax'])
+            : $baseSubTotal + $baseTaxAmount;
+        $shippingInclTax = array_key_exists('shipping_incl_tax', $row)
+            ? $this->money($row['shipping_incl_tax'])
+            : $shippingAmount + $shippingTaxAmount;
+        $baseShippingInclTax = array_key_exists('base_shipping_incl_tax', $row)
+            ? $this->money($row['base_shipping_incl_tax'])
+            : $baseShippingAmount + $baseShippingTaxAmount;
+
+        $order = new Order;
+        $order->forceFill([
+            'migrated_from_asteria_id'         => $asteriaId,
+            'increment_id'                     => $incrementId,
+            'status'                           => $this->mapStatus($row),
+            'channel_name'                     => $channel->name,
+            'is_guest'                         => $customer ? 0 : 1,
+            'customer_email'                   => $email !== '' ? $email : null,
+            'customer_first_name'              => $this->requiredName($row['customer_firstname'] ?? null, $customer?->first_name ?? $email),
+            'customer_last_name'               => trim((string) ($row['customer_lastname'] ?? '')) ?: ($customer?->last_name ?? '-'),
+            'customer_id'                      => $customer?->id,
+            'customer_type'                    => $customer ? Customer::class : null,
+            'channel_id'                       => $channel->id,
+            'channel_type'                     => get_class($channel),
+            'cart_id'                          => null,
+            'shipping_method'                  => $this->nullableString($row['shipping_method'] ?? null),
+            'shipping_title'                   => $this->nullableString($row['shipping_description'] ?? null),
+            'shipping_description'             => $this->nullableString($row['shipping_description'] ?? null),
+            'coupon_code'                      => $this->nullableString($row['coupon_code'] ?? null),
+            'is_gift'                          => 0,
+            'total_item_count'                 => $this->qty($row['total_item_count'] ?? $itemRows->count()),
+            'total_qty_ordered'                => $this->qty($row['total_qty_ordered'] ?? $itemRows->sum(fn (array $item) => (float) ($item['qty_ordered'] ?? 0))),
+            'base_currency_code'               => $this->nullableString($row['base_currency_code'] ?? null) ?? 'USD',
+            'channel_currency_code'            => $this->nullableString($row['store_currency_code'] ?? null)
+                ?? $this->nullableString($row['order_currency_code'] ?? null)
+                ?? 'USD',
+            'order_currency_code'              => $this->nullableString($row['order_currency_code'] ?? null) ?? 'USD',
+            'grand_total'                      => $grandTotal,
+            'base_grand_total'                 => $baseGrandTotal,
+            'grand_total_invoiced'             => $this->money($row['total_invoiced'] ?? 0),
+            'base_grand_total_invoiced'        => $this->money($row['base_total_invoiced'] ?? 0),
+            'grand_total_refunded'             => $this->money($row['total_refunded'] ?? 0),
+            'base_grand_total_refunded'        => $this->money($row['base_total_refunded'] ?? 0),
+            'sub_total'                        => $subTotal,
+            'base_sub_total'                   => $baseSubTotal,
+            'sub_total_incl_tax'               => $subTotalInclTax,
+            'base_sub_total_incl_tax'          => $baseSubTotalInclTax,
+            'sub_total_invoiced'               => $this->money($row['subtotal_invoiced'] ?? 0),
+            'base_sub_total_invoiced'          => $this->money($row['base_subtotal_invoiced'] ?? 0),
+            'sub_total_refunded'               => $this->money($row['subtotal_refunded'] ?? 0),
+            'base_sub_total_refunded'          => $this->money($row['base_subtotal_refunded'] ?? 0),
+            'discount_amount'                  => $discountAmount,
+            'base_discount_amount'             => $baseDiscountAmount,
+            'discount_invoiced'                => $this->money($row['discount_invoiced'] ?? 0),
+            'base_discount_invoiced'           => $this->money($row['base_discount_invoiced'] ?? 0),
+            'discount_refunded'                => $this->money($row['discount_refunded'] ?? 0),
+            'base_discount_refunded'           => $this->money($row['base_discount_refunded'] ?? 0),
+            'tax_amount'                       => $taxAmount,
+            'base_tax_amount'                  => $baseTaxAmount,
+            'tax_amount_invoiced'              => $this->money($row['tax_invoiced'] ?? 0),
+            'base_tax_amount_invoiced'         => $this->money($row['base_tax_invoiced'] ?? 0),
+            'tax_amount_refunded'              => $this->money($row['tax_refunded'] ?? 0),
+            'base_tax_amount_refunded'         => $this->money($row['base_tax_refunded'] ?? 0),
+            'shipping_amount'                  => $shippingAmount,
+            'base_shipping_amount'             => $baseShippingAmount,
+            'shipping_amount_incl_tax'         => $shippingInclTax,
+            'base_shipping_amount_incl_tax'    => $baseShippingInclTax,
+            'shipping_invoiced'                => $this->money($row['shipping_invoiced'] ?? 0),
+            'base_shipping_invoiced'           => $this->money($row['base_shipping_invoiced'] ?? 0),
+            'shipping_refunded'                => $this->money($row['shipping_refunded'] ?? 0),
+            'base_shipping_refunded'           => $this->money($row['base_shipping_refunded'] ?? 0),
+            'shipping_tax_amount'              => $shippingTaxAmount,
+            'base_shipping_tax_amount'         => $baseShippingTaxAmount,
+        ]);
+
+        if (! empty($row['created_at'])) {
+            $order->created_at = $row['created_at'];
+        }
+
+        $order->save();
+
+        $this->importPayment($order, $paymentRow);
+        $importedAddresses = $this->importAddresses($order, $addressRows, $customer, $email);
+        $importedItems = $this->importItems($order, $itemRows, $productsBySku);
+
+        return [
+            'created'   => 1,
+            'skipped'   => 0,
+            'items'     => $importedItems,
+            'addresses' => $importedAddresses,
+        ];
+    }
+
+    /**
+     * @param  array<string, mixed>|null  $paymentRow
+     */
+    private function importPayment(Order $order, ?array $paymentRow): void
+    {
+        $magentoMethod = trim((string) ($paymentRow['method'] ?? ''));
+
+        $additional = [
+            'magento_method' => $magentoMethod !== '' ? $magentoMethod : null,
+        ];
+
+        if ($paymentRow) {
+            $additional['asteria_payment_id'] = (int) ($paymentRow['entity_id'] ?? 0);
+
+            foreach (['last_trans_id', 'cc_type', 'cc_last4'] as $key) {
+                $value = $this->nullableString($paymentRow[$key] ?? null);
+
+                if ($value !== null) {
+                    $additional[$key] = $value;
+                }
+            }
+        }
+
+        $payment = new OrderPayment;
+        $payment->forceFill([
+            'order_id'     => $order->id,
+            'method'       => $this->mapPaymentMethod($magentoMethod),
+            'method_title' => $magentoMethod !== '' ? $magentoMethod : null,
+            'additional'   => $additional,
+        ]);
+        $payment->save();
+    }
+
+    /**
+     * @param  Collection<int, array<string, mixed>>  $addressRows
+     */
+    private function importAddresses(Order $order, $addressRows, ?Customer $customer, string $email): int
+    {
+        $imported = 0;
+
+        foreach ($addressRows as $row) {
+            $type = strtolower(trim((string) ($row['address_type'] ?? '')));
+            $addressType = $type === 'shipping'
+                ? OrderAddress::ADDRESS_TYPE_SHIPPING
+                : OrderAddress::ADDRESS_TYPE_BILLING;
+
+            $address = new OrderAddress;
+            $address->forceFill([
+                'order_id'     => $order->id,
+                'customer_id'  => $customer?->id,
+                'address_type' => $addressType,
+                'first_name'   => $this->requiredName($row['firstname'] ?? null, $order->customer_first_name),
+                'last_name'    => trim((string) ($row['lastname'] ?? '')) ?: $order->customer_last_name,
+                'company_name' => $this->nullableString($row['company'] ?? null),
+                'address'      => $this->mapStreet($row['street'] ?? null) ?: '-',
+                'city'         => trim((string) ($row['city'] ?? '')) ?: '-',
+                'state'        => $this->nullableString($row['region'] ?? null),
+                'country'      => $this->nullableString($row['country_id'] ?? null),
+                'postcode'     => $this->nullableString($row['postcode'] ?? null),
+                'email'        => $this->nullableString($row['email'] ?? null) ?? ($email !== '' ? $email : null),
+                'phone'        => $this->nullableString($row['telephone'] ?? null),
+                'additional'   => json_encode(['asteria_address_id' => (int) ($row['entity_id'] ?? 0)]),
+            ]);
+            $address->save();
+
+            $imported++;
+        }
+
+        return $imported;
+    }
+
+    /**
+     * @param  Collection<int, array<string, mixed>>  $itemRows
+     * @param  array<string, Product>  $productsBySku
+     */
+    private function importItems(Order $order, $itemRows, array $productsBySku): int
+    {
+        if ($itemRows->isEmpty()) {
+            return 0;
+        }
+
+        $parents = $itemRows->filter(fn (array $row) => empty($row['parent_item_id']));
+        $children = $itemRows->filter(fn (array $row) => ! empty($row['parent_item_id']));
+        $idMap = [];
+        $imported = 0;
+
+        foreach ($parents as $row) {
+            $item = $this->createOrderItem($order, $row, null, $productsBySku);
+            $idMap[(int) $row['item_id']] = $item->id;
+            $imported++;
+        }
+
+        foreach ($children as $row) {
+            $parentId = $idMap[(int) $row['parent_item_id']] ?? null;
+            $this->createOrderItem($order, $row, $parentId, $productsBySku);
+            $imported++;
+        }
+
+        return $imported;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  array<string, Product>  $productsBySku
+     */
+    private function createOrderItem(Order $order, array $row, ?int $parentId, array $productsBySku): OrderItem
+    {
+        $sku = trim((string) ($row['sku'] ?? ''));
+        $product = $sku !== '' ? ($productsBySku[$sku] ?? null) : null;
+        $type = $this->mapProductType($row['product_type'] ?? null, $product);
+
+        $price = $this->money($row['price'] ?? 0);
+        $basePrice = $this->money($row['base_price'] ?? $price);
+        $total = $this->money($row['row_total'] ?? 0);
+        $baseTotal = $this->money($row['base_row_total'] ?? $total);
+        $taxAmount = $this->money($row['tax_amount'] ?? 0);
+        $baseTaxAmount = $this->money($row['base_tax_amount'] ?? $taxAmount);
+
+        $item = new OrderItem;
+        $item->forceFill([
+            'order_id'              => $order->id,
+            'parent_id'             => $parentId,
+            'sku'                   => $sku !== '' ? $sku : null,
+            'type'                  => $type,
+            'name'                  => $this->nullableString($row['name'] ?? null) ?? '-',
+            'weight'                => $this->money($row['weight'] ?? 0),
+            'total_weight'          => $this->money($row['row_weight'] ?? $row['weight'] ?? 0),
+            'qty_ordered'           => $this->qty($row['qty_ordered'] ?? 0),
+            'qty_shipped'           => $this->qty($row['qty_shipped'] ?? 0),
+            'qty_invoiced'          => $this->qty($row['qty_invoiced'] ?? 0),
+            'qty_canceled'          => $this->qty($row['qty_canceled'] ?? 0),
+            'qty_refunded'          => $this->qty($row['qty_refunded'] ?? 0),
+            'price'                 => $price,
+            'base_price'            => $basePrice,
+            'price_incl_tax'        => array_key_exists('price_incl_tax', $row) ? $this->money($row['price_incl_tax']) : $price,
+            'base_price_incl_tax'   => array_key_exists('base_price_incl_tax', $row) ? $this->money($row['base_price_incl_tax']) : $basePrice,
+            'total'                 => $total,
+            'base_total'            => $baseTotal,
+            'total_incl_tax'        => array_key_exists('row_total_incl_tax', $row) ? $this->money($row['row_total_incl_tax']) : $total + $taxAmount,
+            'base_total_incl_tax'   => array_key_exists('base_row_total_incl_tax', $row) ? $this->money($row['base_row_total_incl_tax']) : $baseTotal + $baseTaxAmount,
+            'tax_percent'           => $this->money($row['tax_percent'] ?? 0),
+            'tax_amount'            => $taxAmount,
+            'base_tax_amount'       => $baseTaxAmount,
+            'discount_percent'      => $this->money($row['discount_percent'] ?? 0),
+            'discount_amount'       => $this->money($row['discount_amount'] ?? 0),
+            'base_discount_amount'  => $this->money($row['base_discount_amount'] ?? 0),
+            'product_id'            => $product?->id,
+            'product_type'          => $product ? get_class($product) : null,
+            'additional'            => [
+                'asteria_item_id' => (int) ($row['item_id'] ?? 0),
+            ],
+        ]);
+        $item->save();
+
+        return $item;
+    }
+
+    /**
+     * @param  Collection<int, array<string, mixed>>  $orders
+     * @return array{0: array<int, Customer>, 1: array<string, Customer>}
+     */
+    private function loadCustomers($orders): array
+    {
+        $asteriaIds = $orders
+            ->pluck('customer_id')
+            ->filter(fn ($id) => (int) $id > 0)
+            ->map(fn ($id) => (int) $id)
+            ->unique()
+            ->values()
+            ->all();
+
+        $emails = $orders
+            ->pluck('customer_email')
+            ->map(fn ($email) => strtolower(trim((string) $email)))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+
+        $byAsteriaId = [];
+        $byEmail = [];
+
+        if ($asteriaIds !== []) {
+            foreach (Customer::query()->whereIn('migrated_from_asteria_id', $asteriaIds)->get() as $customer) {
+                $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $customer;
+            }
+        }
+
+        if ($emails !== []) {
+            $query = Customer::query();
+            $query->where(function ($inner) use ($emails) {
+                foreach ($emails as $email) {
+                    $inner->orWhereRaw('LOWER(email) = ?', [$email]);
+                }
+            });
+
+            foreach ($query->get() as $customer) {
+                $byEmail[strtolower((string) $customer->email)] = $customer;
+            }
+        }
+
+        return [$byAsteriaId, $byEmail];
+    }
+
+    /**
+     * @param  Collection<int, Collection<int, array<string, mixed>>>  $items
+     * @return array<string, Product>
+     */
+    private function loadProducts($items): array
+    {
+        $skus = $items
+            ->flatten(1)
+            ->pluck('sku')
+            ->map(fn ($sku) => trim((string) $sku))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+
+        if ($skus === []) {
+            return [];
+        }
+
+        return Product::query()
+            ->whereIn('sku', $skus)
+            ->get(['id', 'sku', 'type'])
+            ->keyBy('sku')
+            ->all();
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  array<int, Customer>  $customersByAsteriaId
+     * @param  array<string, Customer>  $customersByEmail
+     */
+    private function resolveCustomer(array $row, array $customersByAsteriaId, array $customersByEmail): ?Customer
+    {
+        $asteriaCustomerId = (int) ($row['customer_id'] ?? 0);
+
+        if ($asteriaCustomerId > 0 && isset($customersByAsteriaId[$asteriaCustomerId])) {
+            return $customersByAsteriaId[$asteriaCustomerId];
+        }
+
+        $email = strtolower(trim((string) ($row['customer_email'] ?? '')));
+
+        if ($email !== '' && isset($customersByEmail[$email])) {
+            return $customersByEmail[$email];
+        }
+
+        return null;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     */
+    private function mapStatus(array $row): string
+    {
+        $status = strtolower(trim((string) ($row['status'] ?? '')));
+        $state = strtolower(trim((string) ($row['state'] ?? '')));
+        $value = $status !== '' ? $status : $state;
+
+        return match ($value) {
+            'complete' => Order::STATUS_COMPLETED,
+            'canceled', 'cancelled' => Order::STATUS_CANCELED,
+            'pending_payment', 'payment_review', 'pending_paypal' => Order::STATUS_PENDING_PAYMENT,
+            'holded' => Order::STATUS_PENDING,
+            'fraud' => Order::STATUS_FRAUD,
+            'closed' => Order::STATUS_CLOSED,
+            'processing' => Order::STATUS_PROCESSING,
+            default => Order::STATUS_PENDING,
+        };
+    }
+
+    private function mapPaymentMethod(string $method): string
+    {
+        $method = strtolower(trim($method));
+
+        if ($method === '') {
+            return 'unknown';
+        }
+
+        if (in_array($method, ['paypal_express', 'paypal_standard'], true) || str_starts_with($method, 'paypaluk_')) {
+            return 'paypal_standard';
+        }
+
+        if ($method === 'checkmo') {
+            return 'moneytransfer';
+        }
+
+        if ($method === 'cashondelivery') {
+            return 'cashondelivery';
+        }
+
+        if (str_starts_with($method, 'klarna')) {
+            return 'klarna';
+        }
+
+        if (str_starts_with($method, 'afterpay') || str_starts_with($method, 'clearpay')) {
+            return 'afterpay';
+        }
+
+        return $method;
+    }
+
+    private function mapProductType(mixed $magentoType, ?Product $product): string
+    {
+        if ($product && in_array($product->type, self::BAGISTO_PRODUCT_TYPES, true)) {
+            return $product->type;
+        }
+
+        $type = strtolower(trim((string) $magentoType));
+
+        if (in_array($type, self::BAGISTO_PRODUCT_TYPES, true)) {
+            return $type;
+        }
+
+        return 'simple';
+    }
+
+    private function mapStreet(mixed $value): string
+    {
+        $value = trim((string) $value);
+
+        if ($value === '') {
+            return '';
+        }
+
+        $lines = preg_split("/\r\n|\n|\r/", $value) ?: [];
+
+        return implode(', ', array_filter(array_map('trim', $lines)));
+    }
+
+    private function requiredName(mixed $value, string $fallback): string
+    {
+        $value = trim((string) $value);
+
+        if ($value !== '') {
+            return $value;
+        }
+
+        $fallback = trim($fallback);
+
+        if ($fallback !== '') {
+            $local = strstr($fallback, '@', true);
+
+            return $local !== false && $local !== '' ? $local : $fallback;
+        }
+
+        return 'Customer';
+    }
+
+    private function nullableString(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? null : $value;
+    }
+
+    private function money(mixed $value): float
+    {
+        return is_numeric($value) ? (float) $value : 0.0;
+    }
+
+    private function qty(mixed $value): int
+    {
+        return (int) round((float) $value);
+    }
+}

+ 9 - 10
app/Services/Asteria/Magento1CustomerReader.php

@@ -4,7 +4,6 @@ namespace App\Services\Asteria;
 
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Schema;
 
 class Magento1CustomerReader
 {
@@ -31,7 +30,12 @@ class Magento1CustomerReader
         'telephone',
     ];
 
-    public function __construct(private string $connection = 'asteria') {}
+    private Magento1Schema $schema;
+
+    public function __construct(private string $connection = 'asteria')
+    {
+        $this->schema = new Magento1Schema($connection);
+    }
 
     /**
      * @return Collection<int, array<string, mixed>>
@@ -150,7 +154,7 @@ class Magento1CustomerReader
         foreach ($byType as $type => $idToCode) {
             $table = $valueTablePrefix.'_'.$type;
 
-            if (! Schema::connection($this->connection)->hasTable($table)) {
+            if (! $this->schema->hasTable($table)) {
                 continue;
             }
 
@@ -186,7 +190,7 @@ class Magento1CustomerReader
             ->select('attribute_id', 'attribute_code', 'backend_type')
             ->whereIn('attribute_code', $codes);
 
-        if (Schema::connection($this->connection)->hasTable('eav_entity_type')) {
+        if ($this->schema->hasTable('eav_entity_type')) {
             $typeId = DB::connection($this->connection)
                 ->table('eav_entity_type')
                 ->where('entity_type_code', $entityTypeCode)
@@ -206,11 +210,6 @@ class Magento1CustomerReader
      */
     private function existingColumns(string $table, array $candidates): array
     {
-        $schema = Schema::connection($this->connection);
-
-        return array_values(array_filter(
-            $candidates,
-            fn (string $column) => $schema->hasColumn($table, $column)
-        ));
+        return $this->schema->existingColumns($table, $candidates);
     }
 }

+ 259 - 0
app/Services/Asteria/Magento1OrderReader.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Services\Asteria;
+
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
+class Magento1OrderReader
+{
+    private const ORDER_COLUMNS = [
+        'entity_id',
+        'increment_id',
+        'customer_id',
+        'customer_email',
+        'customer_firstname',
+        'customer_lastname',
+        'customer_is_guest',
+        'status',
+        'state',
+        'store_id',
+        'base_currency_code',
+        'order_currency_code',
+        'store_currency_code',
+        'grand_total',
+        'base_grand_total',
+        'subtotal',
+        'base_subtotal',
+        'subtotal_incl_tax',
+        'base_subtotal_incl_tax',
+        'tax_amount',
+        'base_tax_amount',
+        'discount_amount',
+        'base_discount_amount',
+        'shipping_amount',
+        'base_shipping_amount',
+        'shipping_incl_tax',
+        'base_shipping_incl_tax',
+        'shipping_tax_amount',
+        'base_shipping_tax_amount',
+        'shipping_method',
+        'shipping_description',
+        'coupon_code',
+        'total_item_count',
+        'total_qty_ordered',
+        'total_invoiced',
+        'base_total_invoiced',
+        'subtotal_invoiced',
+        'base_subtotal_invoiced',
+        'tax_invoiced',
+        'base_tax_invoiced',
+        'shipping_invoiced',
+        'base_shipping_invoiced',
+        'discount_invoiced',
+        'base_discount_invoiced',
+        'total_refunded',
+        'base_total_refunded',
+        'subtotal_refunded',
+        'base_subtotal_refunded',
+        'tax_refunded',
+        'base_tax_refunded',
+        'shipping_refunded',
+        'base_shipping_refunded',
+        'discount_refunded',
+        'base_discount_refunded',
+        'created_at',
+        'updated_at',
+    ];
+
+    private const ITEM_COLUMNS = [
+        'item_id',
+        'order_id',
+        'parent_item_id',
+        'product_id',
+        'product_type',
+        'sku',
+        'name',
+        'qty_ordered',
+        'qty_shipped',
+        'qty_invoiced',
+        'qty_canceled',
+        'qty_refunded',
+        'price',
+        'base_price',
+        'price_incl_tax',
+        'base_price_incl_tax',
+        'row_total',
+        'base_row_total',
+        'row_total_incl_tax',
+        'base_row_total_incl_tax',
+        'tax_amount',
+        'base_tax_amount',
+        'tax_percent',
+        'discount_amount',
+        'base_discount_amount',
+        'discount_percent',
+        'weight',
+        'row_weight',
+        'created_at',
+    ];
+
+    private const ADDRESS_COLUMNS = [
+        'entity_id',
+        'parent_id',
+        'address_type',
+        'firstname',
+        'lastname',
+        'company',
+        'street',
+        'city',
+        'region',
+        'postcode',
+        'country_id',
+        'telephone',
+        'email',
+    ];
+
+    private const PAYMENT_COLUMNS = [
+        'entity_id',
+        'parent_id',
+        'method',
+        'last_trans_id',
+        'cc_type',
+        'cc_last4',
+        'amount_ordered',
+        'base_amount_ordered',
+    ];
+
+    private Magento1Schema $schema;
+
+    public function __construct(private string $connection = 'asteria')
+    {
+        $this->schema = new Magento1Schema($connection);
+    }
+
+    /**
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchOrders(int $afterEntityId, int $limit): Collection
+    {
+        $columns = $this->schema->existingColumns('sales_flat_order', self::ORDER_COLUMNS);
+
+        if ($columns === [] || ! in_array('entity_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('sales_flat_order')
+            ->where('entity_id', '>', $afterEntityId)
+            ->orderBy('entity_id')
+            ->limit($limit)
+            ->get($columns);
+
+        return $rows->map(fn ($row) => $this->toArray($row, 'entity_id'))->values();
+    }
+
+    /**
+     * @param  array<int, int>  $orderEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchItems(array $orderEntityIds): Collection
+    {
+        if ($orderEntityIds === []) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_order_item', self::ITEM_COLUMNS);
+
+        if ($columns === [] || ! in_array('order_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('sales_flat_order_item')
+            ->whereIn('order_id', $orderEntityIds)
+            ->orderBy('item_id')
+            ->get($columns);
+
+        return $rows->map(function ($row) {
+            $item = $this->toArray($row, 'item_id');
+            $item['order_id'] = (int) ($row->order_id ?? 0);
+            $item['parent_item_id'] = isset($row->parent_item_id) && $row->parent_item_id !== null && $row->parent_item_id !== ''
+                ? (int) $row->parent_item_id
+                : null;
+
+            return $item;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $orderEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchAddresses(array $orderEntityIds): Collection
+    {
+        if ($orderEntityIds === []) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_order_address', self::ADDRESS_COLUMNS);
+
+        if ($columns === [] || ! in_array('parent_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('sales_flat_order_address')
+            ->whereIn('parent_id', $orderEntityIds)
+            ->orderBy('entity_id')
+            ->get($columns);
+
+        return $rows->map(function ($row) {
+            $address = $this->toArray($row, 'entity_id');
+            $address['parent_id'] = (int) ($row->parent_id ?? 0);
+
+            return $address;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $orderEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchPayments(array $orderEntityIds): Collection
+    {
+        if ($orderEntityIds === []) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('sales_flat_order_payment', self::PAYMENT_COLUMNS);
+
+        if ($columns === [] || ! in_array('parent_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('sales_flat_order_payment')
+            ->whereIn('parent_id', $orderEntityIds)
+            ->orderBy('entity_id')
+            ->get($columns);
+
+        return $rows->map(function ($row) {
+            $payment = $this->toArray($row, 'entity_id');
+            $payment['parent_id'] = (int) ($row->parent_id ?? 0);
+
+            return $payment;
+        })->values();
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function toArray(object $row, string $idColumn): array
+    {
+        $data = (array) $row;
+        $data[$idColumn] = (int) ($row->{$idColumn} ?? 0);
+
+        return $data;
+    }
+}

+ 74 - 0
app/Services/Asteria/Magento1Schema.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace App\Services\Asteria;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Schema helpers that work against Magento 1's MySQL 5.6.
+ *
+ * Laravel's Schema::hasColumn() reads information_schema.columns.generation_expression,
+ * which does not exist until MySQL 5.7.
+ */
+class Magento1Schema
+{
+    /** @var array<string, array<int, string>> */
+    private array $columns = [];
+
+    public function __construct(private string $connection = 'asteria') {}
+
+    public function hasTable(string $table): bool
+    {
+        return Schema::connection($this->connection)->hasTable($table);
+    }
+
+    /**
+     * @param  array<int, string>  $candidates
+     * @return array<int, string>
+     */
+    public function existingColumns(string $table, array $candidates): array
+    {
+        if (! $this->hasTable($table)) {
+            return [];
+        }
+
+        $listing = array_fill_keys(array_map('strtolower', $this->columnListing($table)), true);
+
+        return array_values(array_filter(
+            $candidates,
+            fn (string $column) => isset($listing[strtolower($column)])
+        ));
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    public function columnListing(string $table): array
+    {
+        if (isset($this->columns[$table])) {
+            return $this->columns[$table];
+        }
+
+        $connection = DB::connection($this->connection);
+
+        if ($connection->getDriverName() === 'mysql') {
+            $wrapped = $connection->getQueryGrammar()->wrapTable($table);
+            $rows = $connection->select('show columns from '.$wrapped);
+            $names = [];
+
+            foreach ($rows as $row) {
+                $row = (array) $row;
+                $name = $row['Field'] ?? $row['field'] ?? null;
+
+                if (is_string($name) && $name !== '') {
+                    $names[] = $name;
+                }
+            }
+
+            return $this->columns[$table] = $names;
+        }
+
+        return $this->columns[$table] = Schema::connection($this->connection)->getColumnListing($table);
+    }
+}

+ 1 - 1
config/database.php

@@ -50,7 +50,7 @@ return [
          |
          |   ASTERIA_DB_HOST=127.0.0.1
          |   ASTERIA_DB_PORT=3306
-         |   ASTERIA_DB_DATABASE=asteria_db
+         |   ASTERIA_DB_DATABASE=as
          |   ASTERIA_DB_USERNAME=root
          |   ASTERIA_DB_PASSWORD=
          |   ASTERIA_DB_PREFIX=          # leave blank if Magento has no table prefix

+ 25 - 0
database/migrations/2026_08_25_143200_add_asteria_migration_column_to_orders_table.php

@@ -0,0 +1,25 @@
+<?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('orders', function (Blueprint $table) {
+            $table->unsignedBigInteger('migrated_from_asteria_id')->nullable()->after('id');
+
+            $table->unique('migrated_from_asteria_id', 'uq_orders_migrated_asteria_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('orders', function (Blueprint $table) {
+            $table->dropUnique('uq_orders_migrated_asteria_id');
+            $table->dropColumn('migrated_from_asteria_id');
+        });
+    }
+};

+ 164 - 0
docs/asteria-migration.md

@@ -0,0 +1,164 @@
+# Asteria(Magento 1)→ Bagisto 数据迁移说明
+
+从旧站 Asteria(Magento 1.x)只读导入用户、评论、订单到本店。源库以 **`as`** 为准(不要用 `longyishop`)。三条命令共用 Laravel 连接名 `asteria`,可分批、可断点续跑、可 `--dry-run`,重复执行不会重复插入。
+
+**推荐顺序:** 先同步商品,再迁用户,再迁订单 / 评论。
+
+```bash
+php artisan migrate
+php artisan catalog:sync                    # 商品 SKU 需与 Magento 一致
+php artisan customers:migrate-asteria
+php artisan orders:migrate-asteria
+php artisan reviews:migrate-asteria --sync  # 或走队列,见下文
+```
+
+---
+
+## 1. 前置:Asteria 数据库连接
+
+`.env` 中配置只读 Magento 库(应用层只跑 SELECT):
+
+```env
+ASTERIA_DB_HOST=127.0.0.1
+ASTERIA_DB_PORT=3306
+ASTERIA_DB_DATABASE=as
+ASTERIA_DB_USERNAME=root
+ASTERIA_DB_PASSWORD=
+ASTERIA_DB_PREFIX=          # Magento 有表前缀时填写,例如 mag_
+ASTERIA_DB_CHARSET=utf8
+```
+
+连接名默认 `asteria`,定义在 `config/database.php`。所有命令都支持 `--connection=` 覆盖。
+
+跑迁移前确认 Bagisto 侧列已存在:
+
+```bash
+php artisan migrate
+```
+
+| 表 | 幂等列 | 迁移文件 |
+|---|---|---|
+| `customers` | `migrated_from_asteria_id`(unique),另有 `legacy_password` | `database/migrations/2026_08_20_163200_add_asteria_migration_columns_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` |
+
+---
+
+## 2. 用户 `customers:migrate-asteria`
+
+同步写入 Bagisto `customers` + `addresses`(同步、无队列)。
+
+```bash
+php artisan customers:migrate-asteria
+php artisan customers:migrate-asteria --batch-size=200
+php artisan customers:migrate-asteria --dry-run
+php artisan customers:migrate-asteria --reset-progress
+```
+
+源表:`customer_entity`、`customer_address_entity` 及对应 EAV。
+
+行为要点:
+
+- 无效邮箱:跳过。
+- 已有相同 `migrated_from_asteria_id` 或相同邮箱(不区分大小写):**关联**,不覆盖姓名/密码,只补未导入的地址。
+- 新用户:写入 `general` 分组、默认 Channel;`password` 为随机 bcrypt,Magento 哈希放在 `legacy_password`。用户用旧密码登录成功后会自动升级为 bcrypt。
+- 手机号与现有用户冲突:新用户 `phone` 置空。
+- 地址按 `addresses.additional.asteria_address_id` 去重;街道换行压成 `, `。
+
+进度缓存:`migrate_asteria_customers_last_id`(30 天)。
+
+---
+
+## 3. 评论 `reviews:migrate-asteria`
+
+写入 `product_reviews`。默认按队列 `review-migration` 分批投递;`--sync` 则当场处理。
+
+```bash
+php artisan queue:work --queue=review-migration   # 非 --sync 时需要
+php artisan reviews:migrate-asteria
+php artisan reviews:migrate-asteria --batch-size=200
+php artisan reviews:migrate-asteria --status=1     # 仅 Magento 已审核
+php artisan reviews:migrate-asteria --sync
+php artisan reviews:migrate-asteria --dry-run
+php artisan reviews:migrate-asteria --reset-progress
+```
+
+`--status`:Magento `status_id`,`1=approved`,`2=pending`,`3=not-approved`。不传则全部导入,状态映射为 Bagisto 的 `approved` / `pending` / `disapproved`。
+
+关联方式:
+
+- 商品:Magento `catalog_product_entity.sku` ↔ Bagisto `products.sku`。对不上的评论会跳过并打日志。
+- 用户:按邮箱挂 `customer_id`;没有则游客名(`name`)。
+- 图片:写入 Magento `review_media_image` 的 URL,后续再批量上 S3(不在本命令内下载文件)。
+
+进度缓存:`migrate_asteria_reviews_last_id`(30 天)。
+
+---
+
+## 4. 订单 `orders:migrate-asteria`
+
+同步写入订单头、商品行、账单/收货地址、支付方式。
+
+```bash
+php artisan orders:migrate-asteria
+php artisan orders:migrate-asteria --batch-size=100
+php artisan orders:migrate-asteria --dry-run
+php artisan orders:migrate-asteria --reset-progress
+```
+
+源表:`sales_flat_order`、`sales_flat_order_item`、`sales_flat_order_address`、`sales_flat_order_payment`。
+
+**会做:**
+
+- 保留 Magento `increment_id` 和 `created_at`,方便用户认历史单号。
+- 客户:先按 `customers.migrated_from_asteria_id`,再按邮箱;都没有则游客单(快照姓名/邮箱仍写入)。
+- 商品行:按订单行 SKU 对 Bagisto;对不上仍导入快照,`product_id` 为空。
+- 状态:`complete` → `completed`;`canceled` → `canceled`;`pending_payment` / `payment_review` / `pending_paypal` → `pending_payment`;`holded` → `pending`;其余常见状态原样对应,未知为 `pending`。
+- 支付:`paypal_express` / `paypal_standard` / `paypaluk_*` → `paypal_standard`;`checkmo` → `moneytransfer`;`klarna*` → `klarna`;`afterpay*` / `clearpay*` → `afterpay`。原文写在 `order_payment.additional.magento_method`。
+
+**不做:** 发票、发货、退款;不扣库存;不触发下单邮件 / 礼品卡等 `checkout.order.save.after` 事件。
+
+跳过规则:
+
+- 已有 `migrated_from_asteria_id`(幂等)。
+- Magento `increment_id` 已被 Bagisto **现网订单**占用(避免覆盖新单)。
+- `increment_id` 为空。
+
+进度缓存:`migrate_asteria_orders_last_id`(30 天)。
+
+---
+
+## 5. 共同约定
+
+| 选项 | 含义 |
+|---|---|
+| `--batch-size` | 每批条数,默认 100 |
+| `--dry-run` | 只统计,不写库,也不更新进度 |
+| `--reset-progress` | 从 ID=0 重新扫(仍幂等,已迁记录会 skip) |
+| `--connection` | 源库连接名,默认 `asteria` |
+
+中断后再次执行会从缓存的 last id 继续。换环境或确认要重扫时加 `--reset-progress`。
+
+---
+
+## 6. 明确不迁的数据
+
+后台管理员、收藏、积分、购物车 Quote、发票、发货单、退款单都不在这三条命令范围内。
+
+---
+
+## 7. 常见问题
+
+**连不上 Asteria:** 检查 `.env` 的 `ASTERIA_DB_*`,以及 Magento 表前缀 `ASTERIA_DB_PREFIX`。源库必须是 `as`,不要配成 `longyishop`。
+
+**MySQL 5.6 `Unknown column 'generation_expression'`:** Laravel 自带的 `Schema::hasColumn()` 会查 5.7 才有的 `information_schema.columns.generation_expression`。迁移脚本已改用 `SHOW COLUMNS`,可在 5.6 上跑。若 TablePlus 等客户端仍报这个错,是客户端自己在看表结构,与迁移无关。
+
+**提示缺列:** 先 `php artisan migrate`。评论列也可在命令交互里自动加。
+
+**评论一条都没有 / 大量 skip:** 先确认 `catalog:sync` 后 SKU 与 Magento 一致。
+
+**订单是游客单:** 先跑用户迁移,并确认 Magento `customer_id` / 邮箱能对上 `migrated_from_asteria_id` 或 Bagisto 邮箱。
+
+**旧密码登不上:** 新迁用户密码在 `legacy_password`。Web 登录与 API 登录都会走 Magento 1 MD5(可带 salt)校验,成功后改成 bcrypt。已存在的 Bagisto 账号被「关联」时**不会**写入 `legacy_password`,仍用原 Bagisto 密码。
+
+**重复跑会不会翻倍:** 不会。用户按 Asteria ID / 邮箱,地址按 `asteria_address_id`,订单/评论按 `migrated_from_asteria_id`。

+ 136 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1OrderReaderTest.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Services\Asteria\Magento1OrderReader;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class Magento1OrderReaderTest extends TestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_order_reader_'.uniqid('', true).'.sqlite';
+        touch($this->sqlitePath);
+
+        config()->set('database.connections.'.$this->connection, [
+            'driver'                  => 'sqlite',
+            'database'                => $this->sqlitePath,
+            'prefix'                  => '',
+            'foreign_key_constraints' => false,
+        ]);
+
+        DB::purge($this->connection);
+        MagentoSchema::create($this->connection);
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'          => 10,
+            'increment_id'       => '100000010',
+            'customer_id'        => 5,
+            'customer_email'     => 'jane@example.com',
+            'customer_firstname' => 'Jane',
+            'customer_lastname'  => 'Doe',
+            'status'             => 'complete',
+            'state'              => 'complete',
+            'grand_total'        => 110,
+            'subtotal'           => 100,
+            'shipping_amount'    => 10,
+            'items'              => [
+                [
+                    'item_id'   => 101,
+                    'sku'       => 'WIG-001',
+                    'name'      => 'Lace Wig',
+                    'qty_ordered'=> 1,
+                    'price'     => 100,
+                    'row_total' => 100,
+                ],
+            ],
+            'addresses' => [
+                [
+                    'entity_id'    => 201,
+                    'address_type' => 'billing',
+                    'firstname'    => 'Jane',
+                    'lastname'     => 'Doe',
+                    'street'       => "123 Main St\nApt 4",
+                    'city'         => 'Austin',
+                    'country_id'   => 'US',
+                ],
+                [
+                    'entity_id'    => 202,
+                    'address_type' => 'shipping',
+                    'firstname'    => 'Jane',
+                    'lastname'     => 'Doe',
+                    'street'       => '9 Oak Rd',
+                    'city'         => 'Dallas',
+                    'country_id'   => 'US',
+                ],
+            ],
+            'payment' => [
+                'entity_id'     => 301,
+                'method'        => 'paypal_express',
+                'last_trans_id' => 'ABC123',
+            ],
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_reads_orders_items_addresses_and_payments(): void
+    {
+        $reader = new Magento1OrderReader($this->connection);
+
+        $orders = $reader->fetchOrders(0, 50);
+        $this->assertCount(1, $orders);
+
+        $order = $orders->first();
+        $this->assertSame(10, $order['entity_id']);
+        $this->assertSame('100000010', $order['increment_id']);
+        $this->assertSame('jane@example.com', $order['customer_email']);
+        $this->assertSame('complete', $order['status']);
+        $this->assertEquals(110, $order['grand_total']);
+
+        $items = $reader->fetchItems([10]);
+        $this->assertCount(1, $items);
+        $this->assertSame(101, $items->first()['item_id']);
+        $this->assertSame('WIG-001', $items->first()['sku']);
+        $this->assertNull($items->first()['parent_item_id']);
+
+        $addresses = $reader->fetchAddresses([10]);
+        $this->assertCount(2, $addresses);
+        $this->assertSame("123 Main St\nApt 4", $addresses->first()['street']);
+
+        $payments = $reader->fetchPayments([10]);
+        $this->assertCount(1, $payments);
+        $this->assertSame('paypal_express', $payments->first()['method']);
+        $this->assertSame('ABC123', $payments->first()['last_trans_id']);
+    }
+
+    public function test_it_pages_from_the_last_entity_id(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'    => 11,
+            'increment_id' => '100000011',
+            'customer_email'=> 'second@example.com',
+        ]);
+
+        $reader = new Magento1OrderReader($this->connection);
+
+        $page = $reader->fetchOrders(10, 50);
+        $this->assertCount(1, $page);
+        $this->assertSame('100000011', $page->first()['increment_id']);
+    }
+}

+ 58 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1SchemaTest.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Services\Asteria\Magento1Schema;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class Magento1SchemaTest extends TestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_schema_'.uniqid('', true).'.sqlite';
+        touch($this->sqlitePath);
+
+        config()->set('database.connections.'.$this->connection, [
+            'driver'                  => 'sqlite',
+            'database'                => $this->sqlitePath,
+            'prefix'                  => '',
+            'foreign_key_constraints' => false,
+        ]);
+
+        DB::purge($this->connection);
+        MagentoSchema::create($this->connection);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_lists_existing_columns_without_using_generation_expression(): void
+    {
+        $schema = new Magento1Schema($this->connection);
+
+        $columns = $schema->existingColumns('customer_entity', [
+            'entity_id',
+            'email',
+            'missing_column',
+            'generation_expression',
+        ]);
+
+        $this->assertSame(['entity_id', 'email'], $columns);
+        $this->assertSame([], $schema->existingColumns('does_not_exist', ['entity_id']));
+    }
+}

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

@@ -88,6 +88,106 @@ class MagentoSchema
             ['attribute_id' => 27, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'country_id', 'backend_type' => 'varchar'],
             ['attribute_id' => 28, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
         ]);
+
+        $schema->create('sales_flat_order', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('increment_id')->nullable();
+            $table->integer('customer_id')->nullable();
+            $table->string('customer_email')->nullable();
+            $table->string('customer_firstname')->nullable();
+            $table->string('customer_lastname')->nullable();
+            $table->integer('customer_is_guest')->default(0);
+            $table->string('status')->nullable();
+            $table->string('state')->nullable();
+            $table->integer('store_id')->nullable();
+            $table->string('base_currency_code')->nullable();
+            $table->string('order_currency_code')->nullable();
+            $table->string('store_currency_code')->nullable();
+            $table->decimal('grand_total', 12, 4)->default(0);
+            $table->decimal('base_grand_total', 12, 4)->default(0);
+            $table->decimal('subtotal', 12, 4)->default(0);
+            $table->decimal('base_subtotal', 12, 4)->default(0);
+            $table->decimal('subtotal_incl_tax', 12, 4)->nullable();
+            $table->decimal('base_subtotal_incl_tax', 12, 4)->nullable();
+            $table->decimal('tax_amount', 12, 4)->default(0);
+            $table->decimal('base_tax_amount', 12, 4)->default(0);
+            $table->decimal('discount_amount', 12, 4)->default(0);
+            $table->decimal('base_discount_amount', 12, 4)->default(0);
+            $table->decimal('shipping_amount', 12, 4)->default(0);
+            $table->decimal('base_shipping_amount', 12, 4)->default(0);
+            $table->decimal('shipping_incl_tax', 12, 4)->nullable();
+            $table->decimal('base_shipping_incl_tax', 12, 4)->nullable();
+            $table->decimal('shipping_tax_amount', 12, 4)->default(0);
+            $table->decimal('base_shipping_tax_amount', 12, 4)->default(0);
+            $table->string('shipping_method')->nullable();
+            $table->string('shipping_description')->nullable();
+            $table->string('coupon_code')->nullable();
+            $table->integer('total_item_count')->default(0);
+            $table->decimal('total_qty_ordered', 12, 4)->default(0);
+            $table->decimal('total_invoiced', 12, 4)->default(0);
+            $table->decimal('base_total_invoiced', 12, 4)->default(0);
+            $table->string('created_at')->nullable();
+            $table->string('updated_at')->nullable();
+        });
+
+        $schema->create('sales_flat_order_item', function (Blueprint $table) {
+            $table->integer('item_id');
+            $table->integer('order_id');
+            $table->integer('parent_item_id')->nullable();
+            $table->integer('product_id')->nullable();
+            $table->string('product_type')->nullable();
+            $table->string('sku')->nullable();
+            $table->string('name')->nullable();
+            $table->decimal('qty_ordered', 12, 4)->default(0);
+            $table->decimal('qty_shipped', 12, 4)->default(0);
+            $table->decimal('qty_invoiced', 12, 4)->default(0);
+            $table->decimal('qty_canceled', 12, 4)->default(0);
+            $table->decimal('qty_refunded', 12, 4)->default(0);
+            $table->decimal('price', 12, 4)->default(0);
+            $table->decimal('base_price', 12, 4)->default(0);
+            $table->decimal('price_incl_tax', 12, 4)->nullable();
+            $table->decimal('base_price_incl_tax', 12, 4)->nullable();
+            $table->decimal('row_total', 12, 4)->default(0);
+            $table->decimal('base_row_total', 12, 4)->default(0);
+            $table->decimal('row_total_incl_tax', 12, 4)->nullable();
+            $table->decimal('base_row_total_incl_tax', 12, 4)->nullable();
+            $table->decimal('tax_amount', 12, 4)->default(0);
+            $table->decimal('base_tax_amount', 12, 4)->default(0);
+            $table->decimal('tax_percent', 12, 4)->default(0);
+            $table->decimal('discount_amount', 12, 4)->default(0);
+            $table->decimal('base_discount_amount', 12, 4)->default(0);
+            $table->decimal('discount_percent', 12, 4)->default(0);
+            $table->decimal('weight', 12, 4)->default(0);
+            $table->decimal('row_weight', 12, 4)->default(0);
+            $table->string('created_at')->nullable();
+        });
+
+        $schema->create('sales_flat_order_address', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->integer('parent_id');
+            $table->string('address_type')->nullable();
+            $table->string('firstname')->nullable();
+            $table->string('lastname')->nullable();
+            $table->string('company')->nullable();
+            $table->text('street')->nullable();
+            $table->string('city')->nullable();
+            $table->string('region')->nullable();
+            $table->string('postcode')->nullable();
+            $table->string('country_id')->nullable();
+            $table->string('telephone')->nullable();
+            $table->string('email')->nullable();
+        });
+
+        $schema->create('sales_flat_order_payment', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->integer('parent_id');
+            $table->string('method')->nullable();
+            $table->string('last_trans_id')->nullable();
+            $table->string('cc_type')->nullable();
+            $table->string('cc_last4')->nullable();
+            $table->decimal('amount_ordered', 12, 4)->nullable();
+            $table->decimal('base_amount_ordered', 12, 4)->nullable();
+        });
     }
 
     /**
@@ -196,4 +296,142 @@ class MagentoSchema
             ]);
         }
     }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedOrder(string $connection, array $data): void
+    {
+        $entityId = (int) $data['entity_id'];
+
+        DB::connection($connection)->table('sales_flat_order')->insert([
+            'entity_id'               => $entityId,
+            'increment_id'            => $data['increment_id'] ?? (string) (100000000 + $entityId),
+            'customer_id'             => $data['customer_id'] ?? null,
+            'customer_email'          => $data['customer_email'] ?? 'guest@example.com',
+            'customer_firstname'      => $data['customer_firstname'] ?? 'Jane',
+            'customer_lastname'       => $data['customer_lastname'] ?? 'Doe',
+            'customer_is_guest'       => $data['customer_is_guest'] ?? 0,
+            'status'                  => $data['status'] ?? 'complete',
+            'state'                   => $data['state'] ?? 'complete',
+            'store_id'                => $data['store_id'] ?? 1,
+            'base_currency_code'      => $data['base_currency_code'] ?? 'USD',
+            'order_currency_code'     => $data['order_currency_code'] ?? 'USD',
+            'store_currency_code'     => $data['store_currency_code'] ?? 'USD',
+            'grand_total'             => $data['grand_total'] ?? 110,
+            'base_grand_total'        => $data['base_grand_total'] ?? 110,
+            'subtotal'                => $data['subtotal'] ?? 100,
+            'base_subtotal'           => $data['base_subtotal'] ?? 100,
+            'subtotal_incl_tax'       => $data['subtotal_incl_tax'] ?? 100,
+            'base_subtotal_incl_tax'  => $data['base_subtotal_incl_tax'] ?? 100,
+            'tax_amount'              => $data['tax_amount'] ?? 0,
+            'base_tax_amount'         => $data['base_tax_amount'] ?? 0,
+            'discount_amount'         => $data['discount_amount'] ?? 0,
+            'base_discount_amount'    => $data['base_discount_amount'] ?? 0,
+            'shipping_amount'         => $data['shipping_amount'] ?? 10,
+            'base_shipping_amount'    => $data['base_shipping_amount'] ?? 10,
+            'shipping_incl_tax'       => $data['shipping_incl_tax'] ?? 10,
+            'base_shipping_incl_tax'  => $data['base_shipping_incl_tax'] ?? 10,
+            'shipping_tax_amount'     => $data['shipping_tax_amount'] ?? 0,
+            'base_shipping_tax_amount'=> $data['base_shipping_tax_amount'] ?? 0,
+            'shipping_method'         => $data['shipping_method'] ?? 'flatrate_flatrate',
+            'shipping_description'    => $data['shipping_description'] ?? 'Flat Rate - Fixed',
+            'coupon_code'             => $data['coupon_code'] ?? null,
+            'total_item_count'        => $data['total_item_count'] ?? 1,
+            'total_qty_ordered'       => $data['total_qty_ordered'] ?? 1,
+            'total_invoiced'          => $data['total_invoiced'] ?? 110,
+            'base_total_invoiced'     => $data['base_total_invoiced'] ?? 110,
+            'created_at'              => $data['created_at'] ?? '2021-06-01 12:00:00',
+            'updated_at'              => $data['updated_at'] ?? '2021-06-01 12:00:00',
+        ]);
+
+        foreach ($data['items'] ?? [] as $item) {
+            self::seedOrderItem($connection, array_merge(['order_id' => $entityId], $item));
+        }
+
+        foreach ($data['addresses'] ?? [] as $address) {
+            self::seedOrderAddress($connection, array_merge(['parent_id' => $entityId], $address));
+        }
+
+        if (! empty($data['payment'])) {
+            self::seedOrderPayment($connection, array_merge(['parent_id' => $entityId], $data['payment']));
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedOrderItem(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('sales_flat_order_item')->insert([
+            'item_id'                 => $data['item_id'],
+            'order_id'                => $data['order_id'],
+            'parent_item_id'          => $data['parent_item_id'] ?? null,
+            'product_id'              => $data['product_id'] ?? null,
+            'product_type'            => $data['product_type'] ?? 'simple',
+            'sku'                     => $data['sku'] ?? 'SKU-1',
+            'name'                    => $data['name'] ?? 'Test Product',
+            'qty_ordered'             => $data['qty_ordered'] ?? 1,
+            'qty_shipped'             => $data['qty_shipped'] ?? 1,
+            'qty_invoiced'            => $data['qty_invoiced'] ?? 1,
+            'qty_canceled'            => $data['qty_canceled'] ?? 0,
+            'qty_refunded'            => $data['qty_refunded'] ?? 0,
+            'price'                   => $data['price'] ?? 100,
+            'base_price'              => $data['base_price'] ?? 100,
+            'price_incl_tax'          => $data['price_incl_tax'] ?? 100,
+            'base_price_incl_tax'     => $data['base_price_incl_tax'] ?? 100,
+            'row_total'               => $data['row_total'] ?? 100,
+            'base_row_total'          => $data['base_row_total'] ?? 100,
+            'row_total_incl_tax'      => $data['row_total_incl_tax'] ?? 100,
+            'base_row_total_incl_tax' => $data['base_row_total_incl_tax'] ?? 100,
+            'tax_amount'              => $data['tax_amount'] ?? 0,
+            'base_tax_amount'         => $data['base_tax_amount'] ?? 0,
+            'tax_percent'             => $data['tax_percent'] ?? 0,
+            'discount_amount'         => $data['discount_amount'] ?? 0,
+            'base_discount_amount'    => $data['base_discount_amount'] ?? 0,
+            'discount_percent'        => $data['discount_percent'] ?? 0,
+            'weight'                  => $data['weight'] ?? 1,
+            'row_weight'              => $data['row_weight'] ?? 1,
+            'created_at'              => $data['created_at'] ?? '2021-06-01 12:00:00',
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedOrderAddress(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('sales_flat_order_address')->insert([
+            'entity_id'    => $data['entity_id'],
+            'parent_id'    => $data['parent_id'],
+            'address_type' => $data['address_type'] ?? 'billing',
+            'firstname'    => $data['firstname'] ?? 'Jane',
+            'lastname'     => $data['lastname'] ?? 'Doe',
+            'company'      => $data['company'] ?? null,
+            'street'       => $data['street'] ?? '123 Main St',
+            'city'         => $data['city'] ?? 'Austin',
+            'region'       => $data['region'] ?? 'TX',
+            'postcode'     => $data['postcode'] ?? '78701',
+            'country_id'   => $data['country_id'] ?? 'US',
+            'telephone'    => $data['telephone'] ?? '5551112222',
+            'email'        => $data['email'] ?? null,
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedOrderPayment(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('sales_flat_order_payment')->insert([
+            'entity_id'            => $data['entity_id'],
+            'parent_id'            => $data['parent_id'],
+            'method'               => $data['method'] ?? 'paypal_express',
+            'last_trans_id'        => $data['last_trans_id'] ?? null,
+            'cc_type'              => $data['cc_type'] ?? null,
+            'cc_last4'             => $data['cc_last4'] ?? null,
+            'amount_ordered'       => $data['amount_ordered'] ?? 110,
+            'base_amount_ordered'  => $data['base_amount_ordered'] ?? 110,
+        ]);
+    }
 }

+ 360 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaOrdersCommandTest.php

@@ -0,0 +1,360 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Core\Models\Channel;
+use Webkul\Customer\Models\Customer;
+use Webkul\Product\Models\Product;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Models\OrderAddress;
+use Webkul\Sales\Models\OrderItem;
+use Webkul\Sales\Models\OrderPayment;
+
+class MigrateAsteriaOrdersCommandTest extends BagistoApiTestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('orders', 'migrated_from_asteria_id')) {
+            $this->markTestSkipped('Run php artisan migrate to add Asteria order columns.');
+        }
+
+        $this->seedRequiredData();
+        Cache::forget('migrate_asteria_orders_last_id');
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_order_cmd_'.uniqid('', true).'.sqlite';
+        touch($this->sqlitePath);
+
+        config()->set('database.connections.'.$this->connection, [
+            'driver'                  => 'sqlite',
+            'database'                => $this->sqlitePath,
+            'prefix'                  => '',
+            'foreign_key_constraints' => false,
+        ]);
+
+        DB::purge($this->connection);
+        MagentoSchema::create($this->connection);
+    }
+
+    public function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (isset($this->sqlitePath) && is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_migrates_an_order_with_items_addresses_and_payment(): void
+    {
+        $customer = $this->createCustomer([
+            'email'                    => 'jane@example.com',
+            'first_name'               => 'Jane',
+            'last_name'                => 'Doe',
+            'migrated_from_asteria_id' => 10,
+        ]);
+        $product = $this->createSimpleProduct('WIG-001');
+
+        $this->seedCompleteMagentoOrder();
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $order = Order::query()->where('increment_id', '100000010')->first();
+        $this->assertNotNull($order);
+        $this->assertSame(10, (int) $order->migrated_from_asteria_id);
+        $this->assertSame(Order::STATUS_COMPLETED, $order->status);
+        $this->assertSame((int) $customer->id, (int) $order->customer_id);
+        $this->assertSame(0, (int) $order->is_guest);
+        $this->assertSame('jane@example.com', $order->customer_email);
+        $this->assertEquals(110, (float) $order->grand_total);
+        $this->assertEquals(100, (float) $order->sub_total);
+        $this->assertEquals(10, (float) $order->shipping_amount);
+        $this->assertSame('2021-06-01 12:00:00', $order->created_at?->format('Y-m-d H:i:s'));
+
+        $item = OrderItem::query()->where('order_id', $order->id)->first();
+        $this->assertNotNull($item);
+        $this->assertSame('WIG-001', $item->sku);
+        $this->assertSame('Lace Wig', $item->name);
+        $this->assertSame((int) $product->id, (int) $item->product_id);
+        $this->assertSame('simple', $item->type);
+        $this->assertEquals(100, (float) $item->price);
+        $this->assertSame(101, (int) ($item->additional['asteria_item_id'] ?? 0));
+
+        $billing = OrderAddress::query()
+            ->where('order_id', $order->id)
+            ->where('address_type', OrderAddress::ADDRESS_TYPE_BILLING)
+            ->first();
+        $this->assertNotNull($billing);
+        $this->assertSame('123 Main St, Apt 4', $billing->address);
+        $this->assertSame('Austin', $billing->city);
+        $this->assertSame('US', $billing->country);
+
+        $shipping = OrderAddress::query()
+            ->where('order_id', $order->id)
+            ->where('address_type', OrderAddress::ADDRESS_TYPE_SHIPPING)
+            ->first();
+        $this->assertNotNull($shipping);
+        $this->assertSame('9 Oak Rd', $shipping->address);
+
+        $payment = OrderPayment::query()->where('order_id', $order->id)->first();
+        $this->assertNotNull($payment);
+        $this->assertSame('paypal_standard', $payment->method);
+        $this->assertSame('paypal_express', $payment->additional['magento_method'] ?? null);
+        $this->assertSame('ABC123', $payment->additional['last_trans_id'] ?? null);
+    }
+
+    public function test_dry_run_does_not_write_orders(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'      => 10,
+            'increment_id'   => '100000010',
+            'customer_email' => 'dry-run@example.com',
+        ]);
+
+        $before = Order::query()->count();
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--dry-run'        => true,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $this->assertSame($before, Order::query()->count());
+        $this->assertNull(Order::query()->where('increment_id', '100000010')->first());
+    }
+
+    public function test_it_is_idempotent_on_a_second_run(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'      => 80,
+            'increment_id'   => '100000080',
+            'customer_email' => 'once@example.com',
+            'items'          => [
+                ['item_id' => 801, 'sku' => 'ONCE-1', 'name' => 'Once'],
+            ],
+            'addresses' => [
+                ['entity_id' => 802, 'address_type' => 'billing'],
+            ],
+            'payment' => [
+                'entity_id' => 803,
+                'method'    => 'checkmo',
+            ],
+        ]);
+
+        $options = [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ];
+
+        $this->artisan('orders:migrate-asteria', $options)->assertSuccessful();
+        $this->artisan('orders:migrate-asteria', $options)->assertSuccessful();
+
+        $this->assertSame(1, Order::query()->where('increment_id', '100000080')->count());
+        $order = Order::query()->where('increment_id', '100000080')->first();
+        $this->assertSame(1, OrderItem::query()->where('order_id', $order->id)->count());
+        $this->assertSame(1, OrderAddress::query()->where('order_id', $order->id)->count());
+        $this->assertSame(1, OrderPayment::query()->where('order_id', $order->id)->count());
+        $this->assertSame('moneytransfer', $order->payment->method);
+    }
+
+    public function test_it_links_a_customer_by_asteria_id(): void
+    {
+        $customer = $this->createCustomer([
+            'email'                    => 'linked@example.com',
+            'migrated_from_asteria_id' => 44,
+        ]);
+
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'      => 90,
+            'increment_id'   => '100000090',
+            'customer_id'    => 44,
+            'customer_email' => 'other-address@example.com',
+            'items'          => [
+                ['item_id' => 901, 'sku' => 'LINK-1'],
+            ],
+            'payment' => ['entity_id' => 903, 'method' => 'paypal_standard'],
+        ]);
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $order = Order::query()->where('increment_id', '100000090')->first();
+        $this->assertNotNull($order);
+        $this->assertSame((int) $customer->id, (int) $order->customer_id);
+        $this->assertSame(0, (int) $order->is_guest);
+    }
+
+    public function test_it_creates_a_guest_order_when_the_customer_is_missing(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'          => 91,
+            'increment_id'       => '100000091',
+            'customer_id'        => 9999,
+            'customer_email'     => 'nobody@example.com',
+            'customer_firstname' => 'Guest',
+            'customer_lastname'  => 'Buyer',
+            'customer_is_guest'  => 1,
+            'items'              => [
+                ['item_id' => 911, 'sku' => 'GUEST-1'],
+            ],
+            'payment' => ['entity_id' => 913, 'method' => 'cashondelivery'],
+        ]);
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $order = Order::query()->where('increment_id', '100000091')->first();
+        $this->assertNotNull($order);
+        $this->assertNull($order->customer_id);
+        $this->assertSame(1, (int) $order->is_guest);
+        $this->assertSame('nobody@example.com', $order->customer_email);
+        $this->assertSame('Guest', $order->customer_first_name);
+        $this->assertSame('cashondelivery', $order->payment->method);
+    }
+
+    public function test_it_imports_unmatched_sku_items_without_a_product_id(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'    => 92,
+            'increment_id' => '100000092',
+            'items'        => [
+                [
+                    'item_id'      => 921,
+                    'sku'          => 'MISSING-SKU',
+                    'name'         => 'Retired Product',
+                    'product_type' => 'simple',
+                ],
+            ],
+            'payment' => ['entity_id' => 923, 'method' => 'checkmo'],
+        ]);
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $order = Order::query()->where('increment_id', '100000092')->first();
+        $this->assertNotNull($order);
+        $item = OrderItem::query()->where('order_id', $order->id)->first();
+        $this->assertSame('MISSING-SKU', $item->sku);
+        $this->assertSame('Retired Product', $item->name);
+        $this->assertNull($item->product_id);
+        $this->assertNull($item->product_type);
+        $this->assertSame('simple', $item->type);
+    }
+
+    public function test_it_skips_when_increment_id_already_exists(): void
+    {
+        $channel = Channel::query()->first();
+        Order::factory()->create([
+            'increment_id'  => '100000099',
+            'channel_id'    => $channel?->id,
+            'channel_type'  => Channel::class,
+            'customer_id'   => null,
+            'is_guest'      => 1,
+            'customer_email'=> 'native@example.com',
+        ]);
+
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'      => 99,
+            'increment_id'   => '100000099',
+            'customer_email' => 'asteria@example.com',
+            'items'          => [
+                ['item_id' => 991, 'sku' => 'SKIP-1'],
+            ],
+            'payment' => ['entity_id' => 993, 'method' => 'checkmo'],
+        ]);
+
+        $this->artisan('orders:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $this->assertSame(1, Order::query()->where('increment_id', '100000099')->count());
+        $existing = Order::query()->where('increment_id', '100000099')->first();
+        $this->assertNull($existing->migrated_from_asteria_id);
+        $this->assertSame('native@example.com', $existing->customer_email);
+    }
+
+    private function seedCompleteMagentoOrder(): void
+    {
+        MagentoSchema::seedOrder($this->connection, [
+            'entity_id'          => 10,
+            'increment_id'       => '100000010',
+            'customer_id'        => 10,
+            'customer_email'     => 'jane@example.com',
+            'customer_firstname' => 'Jane',
+            'customer_lastname'  => 'Doe',
+            'status'             => 'complete',
+            'state'              => 'complete',
+            'grand_total'        => 110,
+            'subtotal'           => 100,
+            'shipping_amount'    => 10,
+            'created_at'         => '2021-06-01 12:00:00',
+            'items'              => [
+                [
+                    'item_id'    => 101,
+                    'sku'        => 'WIG-001',
+                    'name'       => 'Lace Wig',
+                    'qty_ordered'=> 1,
+                    'price'      => 100,
+                    'row_total'  => 100,
+                ],
+            ],
+            'addresses' => [
+                [
+                    'entity_id'    => 201,
+                    'address_type' => 'billing',
+                    'firstname'    => 'Jane',
+                    'lastname'     => 'Doe',
+                    'street'       => "123 Main St\nApt 4",
+                    'city'         => 'Austin',
+                    'country_id'   => 'US',
+                ],
+                [
+                    'entity_id'    => 202,
+                    'address_type' => 'shipping',
+                    'firstname'    => 'Jane',
+                    'lastname'     => 'Doe',
+                    'street'       => '9 Oak Rd',
+                    'city'         => 'Dallas',
+                    'country_id'   => 'US',
+                ],
+            ],
+            'payment' => [
+                'entity_id'     => 301,
+                'method'        => 'paypal_express',
+                'last_trans_id' => 'ABC123',
+            ],
+        ]);
+    }
+
+    private function createSimpleProduct(string $sku): Product
+    {
+        $attributeFamilyId = (int) (DB::table('attribute_families')->value('id') ?? 1);
+
+        return Product::factory()->create([
+            'sku'                 => $sku,
+            'type'                => 'simple',
+            'attribute_family_id' => $attributeFamilyId,
+        ]);
+    }
+}