소스 검색

Merge branch 'dev' into dev-rewardPoints

bianjunhui 3 일 전
부모
커밋
ea49f644bf
24개의 변경된 파일3515개의 추가작업 그리고 6개의 파일을 삭제
  1. 24 0
      app/Auth/CustomerUserProvider.php
  2. 398 0
      app/Console/Commands/MigrateAsteriaCustomers.php
  3. 739 0
      app/Console/Commands/MigrateAsteriaOrders.php
  4. 6 0
      app/Providers/AppServiceProvider.php
  5. 215 0
      app/Services/Asteria/Magento1CustomerReader.php
  6. 259 0
      app/Services/Asteria/Magento1OrderReader.php
  7. 74 0
      app/Services/Asteria/Magento1Schema.php
  8. 63 0
      app/Support/Magento1Password.php
  9. 1 1
      config/auth.php
  10. 1 1
      config/database.php
  11. 26 0
      database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php
  12. 25 0
      database/migrations/2026_08_25_143200_add_asteria_migration_column_to_orders_table.php
  13. 164 0
      docs/asteria-migration.md
  14. 2 2
      packages/Webkul/BagistoApi/src/State/LoginProcessor.php
  15. 67 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/LoginProcessorLegacyPasswordTest.php
  16. 112 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1CustomerReaderTest.php
  17. 136 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1OrderReaderTest.php
  18. 67 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1PasswordAttemptTest.php
  19. 31 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1PasswordTest.php
  20. 58 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1SchemaTest.php
  21. 437 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/MagentoSchema.php
  22. 242 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaCustomersCommandTest.php
  23. 360 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaOrdersCommandTest.php
  24. 8 2
      packages/Webkul/Customer/src/Models/Customer.php

+ 24 - 0
app/Auth/CustomerUserProvider.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Auth;
+
+use App\Support\Magento1Password;
+use Illuminate\Auth\EloquentUserProvider;
+use Illuminate\Contracts\Auth\Authenticatable as UserContract;
+
+class CustomerUserProvider extends EloquentUserProvider
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function validateCredentials(UserContract $user, array $credentials): bool
+    {
+        $plain = $credentials['password'] ?? null;
+
+        if (! is_string($plain) || $plain === '') {
+            return false;
+        }
+
+        return Magento1Password::attempt($user, $plain);
+    }
+}

+ 398 - 0
app/Console/Commands/MigrateAsteriaCustomers.php

@@ -0,0 +1,398 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Asteria\Magento1CustomerReader;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
+use Webkul\Customer\Models\Customer;
+use Webkul\Customer\Models\CustomerAddress;
+
+/**
+ * Migrates storefront customers and addresses from Asteria (Magento 1.x).
+ *
+ * Usage
+ * ─────
+ * php artisan customers:migrate-asteria
+ * php artisan customers:migrate-asteria --batch-size=200
+ * php artisan customers:migrate-asteria --reset-progress
+ * php artisan customers:migrate-asteria --dry-run
+ */
+class MigrateAsteriaCustomers extends Command
+{
+    protected $signature = 'customers:migrate-asteria
+        {--batch-size=100     : Number of Magento customers 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) customers and addresses into Bagisto';
+
+    private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
+
+    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 (['customer_entity', 'eav_attribute'] as $table) {
+            if (! Schema::connection($connection)->hasTable($table)) {
+                $this->error("Asteria table '{$table}' is missing on connection '{$connection}'.");
+
+                return self::FAILURE;
+            }
+        }
+
+        if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
+            || ! Schema::hasColumn('customers', 'legacy_password')) {
+            $this->error('customers.migrated_from_asteria_id / legacy_password are missing. Run php artisan migrate.');
+
+            return self::FAILURE;
+        }
+
+        $groupId = DB::table('customer_groups')->where('code', 'general')->value('id');
+
+        if (! $groupId) {
+            $this->error("Bagisto customer group 'general' was not found.");
+
+            return self::FAILURE;
+        }
+
+        $channelId = core()->getDefaultChannel()?->id ?? core()->getCurrentChannel()?->id;
+
+        $reader = new Magento1CustomerReader($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).");
+        }
+
+        $usedPhones = DB::table('customers')
+            ->whereNotNull('phone')
+            ->where('phone', '!=', '')
+            ->pluck('phone')
+            ->map(fn ($phone) => mb_strtolower((string) $phone))
+            ->flip()
+            ->all();
+
+        $created = 0;
+        $linked = 0;
+        $skipped = 0;
+        $addressesImported = 0;
+        $batchNumber = 0;
+
+        $this->info($dryRun ? '[DRY RUN] Scanning Magento customers…' : 'Migrating Magento customers…');
+
+        do {
+            $customers = $reader->fetchCustomers($lastId, $batchSize);
+
+            if ($customers->isEmpty()) {
+                break;
+            }
+
+            $batchNumber++;
+            $lastId = (int) $customers->max('entity_id');
+            $addresses = $reader->fetchAddresses(
+                $customers->pluck('entity_id')->map(fn ($id) => (int) $id)->all()
+            )->groupBy(fn (array $row) => (int) $row['parent_id']);
+
+            if ($dryRun) {
+                $created += $customers->count();
+                $addressesImported += $addresses->flatten(1)->count();
+                $this->line(sprintf(
+                    '  Batch #%d: %d customers, %d addresses (last entity_id=%d) [skipped – dry-run]',
+                    $batchNumber,
+                    $customers->count(),
+                    $addresses->flatten(1)->count(),
+                    $lastId
+                ));
+
+                continue;
+            }
+
+            $batchCreated = 0;
+            $batchLinked = 0;
+            $batchSkipped = 0;
+            $batchAddresses = 0;
+
+            DB::transaction(function () use (
+                $customers,
+                $addresses,
+                $groupId,
+                $channelId,
+                &$usedPhones,
+                &$batchCreated,
+                &$batchLinked,
+                &$batchSkipped,
+                &$batchAddresses
+            ) {
+                foreach ($customers as $row) {
+                    $result = $this->migrateCustomer($row, $addresses->get((int) $row['entity_id'], collect()), (int) $groupId, $channelId, $usedPhones);
+                    $batchCreated += $result['created'];
+                    $batchLinked += $result['linked'];
+                    $batchSkipped += $result['skipped'];
+                    $batchAddresses += $result['addresses'];
+                }
+            });
+
+            Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
+
+            $created += $batchCreated;
+            $linked += $batchLinked;
+            $skipped += $batchSkipped;
+            $addressesImported += $batchAddresses;
+
+            $this->line(sprintf(
+                '  Batch #%d: created=%d linked=%d skipped=%d addresses=%d (last entity_id=%d)',
+                $batchNumber,
+                $batchCreated,
+                $batchLinked,
+                $batchSkipped,
+                $batchAddresses,
+                $lastId
+            ));
+
+            Log::info('MigrateAsteriaCustomers: batch '.$batchNumber.', last_id='.$lastId);
+        } while ($customers->count() === $batchSize);
+
+        $this->newLine();
+        $this->info("Done. Batches: {$batchNumber}, created: {$created}, linked: {$linked}, skipped: {$skipped}, addresses: {$addressesImported}.");
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  \Illuminate\Support\Collection<int, array<string, mixed>>  $addressRows
+     * @param  array<string, int>  $usedPhones
+     * @return array{created: int, linked: int, skipped: int, addresses: int}
+     */
+    private function migrateCustomer(
+        array $row,
+        $addressRows,
+        int $groupId,
+        mixed $channelId,
+        array &$usedPhones
+    ): array {
+        $email = strtolower(trim((string) ($row['email'] ?? '')));
+        $asteriaId = (int) $row['entity_id'];
+
+        if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
+            return ['created' => 0, 'linked' => 0, 'skipped' => 1, 'addresses' => 0];
+        }
+
+        $existing = Customer::query()
+            ->where(function ($query) use ($asteriaId, $email) {
+                $query->where('migrated_from_asteria_id', $asteriaId)
+                    ->orWhereRaw('LOWER(email) = ?', [$email]);
+            })
+            ->first();
+
+        if ($existing) {
+            if (! $existing->migrated_from_asteria_id) {
+                $existing->migrated_from_asteria_id = $asteriaId;
+                $existing->save();
+            }
+
+            $imported = $this->importAddresses($existing, $row, $addressRows);
+
+            return ['created' => 0, 'linked' => 1, 'skipped' => 0, 'addresses' => $imported];
+        }
+
+        $phone = $this->uniquePhone((string) ($row['telephone'] ?? ''), $usedPhones);
+
+        $customer = new Customer;
+        $customer->forceFill([
+            'migrated_from_asteria_id'  => $asteriaId,
+            'first_name'                => $this->requiredName($row['firstname'] ?? null, $email),
+            'last_name'                 => trim((string) ($row['lastname'] ?? '')) ?: '-',
+            'gender'                    => $this->mapGender($row['gender'] ?? null),
+            'date_of_birth'             => $this->mapDate($row['dob'] ?? null),
+            'email'                     => $email,
+            'phone'                     => $phone,
+            'password'                  => Hash::make(Str::random(32)),
+            'legacy_password'           => $this->nullableString($row['password_hash'] ?? null),
+            'api_token'                 => Str::random(80),
+            'customer_group_id'         => $groupId,
+            'channel_id'                => $channelId,
+            'subscribed_to_news_letter' => false,
+            'status'                    => ((int) ($row['is_active'] ?? 1)) === 1 ? 1 : 0,
+            'is_verified'               => 1,
+            'is_suspended'              => 0,
+        ]);
+
+        if (! empty($row['created_at'])) {
+            $customer->created_at = $row['created_at'];
+        }
+
+        $customer->save();
+
+        $imported = $this->importAddresses($customer, $row, $addressRows);
+
+        return ['created' => 1, 'linked' => 0, 'skipped' => 0, 'addresses' => $imported];
+    }
+
+    /**
+     * @param  array<string, mixed>  $customerRow
+     * @param  \Illuminate\Support\Collection<int, array<string, mixed>>  $addressRows
+     */
+    private function importAddresses(Customer $customer, array $customerRow, $addressRows): int
+    {
+        if ($addressRows->isEmpty()) {
+            return 0;
+        }
+
+        $existingIds = $customer->addresses()
+            ->get()
+            ->map(fn (CustomerAddress $address) => $this->asteriaAddressId($address))
+            ->filter()
+            ->all();
+
+        $defaultBilling = (int) ($customerRow['default_billing'] ?? 0);
+        $defaultShipping = (int) ($customerRow['default_shipping'] ?? 0);
+        $imported = 0;
+
+        foreach ($addressRows as $row) {
+            $asteriaAddressId = (int) $row['entity_id'];
+
+            if (in_array($asteriaAddressId, $existingIds, true)) {
+                continue;
+            }
+
+            $address = new CustomerAddress;
+            $address->forceFill([
+                'customer_id'      => $customer->id,
+                'address_type'     => CustomerAddress::ADDRESS_TYPE,
+                'first_name'       => $this->requiredName($row['firstname'] ?? null, $customer->first_name),
+                'last_name'        => trim((string) ($row['lastname'] ?? '')) ?: $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'            => $customer->email,
+                'phone'            => $this->nullableString($row['telephone'] ?? null) ?? $customer->phone,
+                'default_address'  => $defaultBilling > 0 && $asteriaAddressId === $defaultBilling,
+                'use_for_shipping' => $defaultShipping > 0 && $asteriaAddressId === $defaultShipping,
+                'additional'       => json_encode(['asteria_address_id' => $asteriaAddressId]),
+            ]);
+            $address->save();
+
+            $imported++;
+        }
+
+        return $imported;
+    }
+
+    /**
+     * @param  array<string, int>  $usedPhones
+     */
+    private function uniquePhone(string $phone, array &$usedPhones): ?string
+    {
+        $phone = trim($phone);
+
+        if ($phone === '') {
+            return null;
+        }
+
+        $key = mb_strtolower($phone);
+
+        if (isset($usedPhones[$key])) {
+            return null;
+        }
+
+        $usedPhones[$key] = 1;
+
+        return $phone;
+    }
+
+    private function mapGender(mixed $value): ?string
+    {
+        return match ((int) $value) {
+            1       => 'Male',
+            2       => 'Female',
+            default => null,
+        };
+    }
+
+    private function mapDate(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        if ($value === '' || str_starts_with($value, '0000-00-00')) {
+            return null;
+        }
+
+        return substr($value, 0, 10);
+    }
+
+    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;
+        }
+
+        $local = strstr($fallback, '@', true);
+
+        return $local !== false && $local !== '' ? $local : 'Customer';
+    }
+
+    private function nullableString(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        return $value === '' ? null : $value;
+    }
+
+    private function asteriaAddressId(CustomerAddress $address): ?int
+    {
+        $additional = $address->additional;
+
+        if (is_string($additional) && $additional !== '') {
+            $additional = json_decode($additional, true);
+        }
+
+        if (! is_array($additional)) {
+            return null;
+        }
+
+        return isset($additional['asteria_address_id'])
+            ? (int) $additional['asteria_address_id']
+            : null;
+    }
+}

+ 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);
+    }
+}

+ 6 - 0
app/Providers/AppServiceProvider.php

@@ -2,8 +2,10 @@
 
 namespace App\Providers;
 
+use App\Auth\CustomerUserProvider;
 use Barryvdh\Debugbar\Facades\Debugbar;
 use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\ParallelTesting;
 use Illuminate\Support\Facades\Request;
 use Illuminate\Support\ServiceProvider;
@@ -35,6 +37,10 @@ class AppServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
+        Auth::provider('customer-eloquent', function ($app, array $config) {
+            return new CustomerUserProvider($app['hash'], $config['model']);
+        });
+
         ParallelTesting::setUpTestDatabase(function (string $database, int $token) {
             Artisan::call('db:seed');
         });

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

@@ -0,0 +1,215 @@
+<?php
+
+namespace App\Services\Asteria;
+
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
+class Magento1CustomerReader
+{
+    private const CUSTOMER_CODES = [
+        'firstname',
+        'lastname',
+        'dob',
+        'gender',
+        'telephone',
+        'password_hash',
+        'default_billing',
+        'default_shipping',
+    ];
+
+    private const ADDRESS_CODES = [
+        'firstname',
+        'lastname',
+        'company',
+        'street',
+        'city',
+        'region',
+        'postcode',
+        'country_id',
+        'telephone',
+    ];
+
+    private Magento1Schema $schema;
+
+    public function __construct(private string $connection = 'asteria')
+    {
+        $this->schema = new Magento1Schema($connection);
+    }
+
+    /**
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchCustomers(int $afterEntityId, int $limit): Collection
+    {
+        $query = DB::connection($this->connection)
+            ->table('customer_entity')
+            ->where('entity_id', '>', $afterEntityId)
+            ->orderBy('entity_id')
+            ->limit($limit);
+
+        $staticColumns = $this->existingColumns('customer_entity', array_merge(
+            ['entity_id', 'email', 'is_active', 'created_at', 'updated_at', 'group_id'],
+            self::CUSTOMER_CODES
+        ));
+
+        $rows = $query->get($staticColumns);
+
+        if ($rows->isEmpty()) {
+            return collect();
+        }
+
+        $eav = $this->loadEavValues(
+            'customer',
+            'customer_entity',
+            $rows->pluck('entity_id')->all(),
+            self::CUSTOMER_CODES
+        );
+
+        return $rows->map(function ($row) use ($eav) {
+            $id = (int) $row->entity_id;
+            $merged = array_merge($eav[$id] ?? [], (array) $row);
+            $merged['entity_id'] = $id;
+
+            return $merged;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, int>  $customerEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchAddresses(array $customerEntityIds): Collection
+    {
+        if ($customerEntityIds === []) {
+            return collect();
+        }
+
+        $staticColumns = $this->existingColumns('customer_address_entity', array_merge(
+            ['entity_id', 'parent_id', 'created_at', 'is_active'],
+            self::ADDRESS_CODES
+        ));
+
+        $rows = DB::connection($this->connection)
+            ->table('customer_address_entity')
+            ->whereIn('parent_id', $customerEntityIds)
+            ->orderBy('entity_id')
+            ->get($staticColumns);
+
+        if ($rows->isEmpty()) {
+            return collect();
+        }
+
+        $eav = $this->loadEavValues(
+            'customer_address',
+            'customer_address_entity',
+            $rows->pluck('entity_id')->all(),
+            self::ADDRESS_CODES
+        );
+
+        return $rows->map(function ($row) use ($eav) {
+            $id = (int) $row->entity_id;
+            $merged = array_merge($eav[$id] ?? [], (array) $row);
+            $merged['entity_id'] = $id;
+            $merged['parent_id'] = (int) $row->parent_id;
+
+            return $merged;
+        })->values();
+    }
+
+    /**
+     * @param  array<int, string>  $codes
+     * @return array<int, array<string, mixed>>
+     */
+    private function loadEavValues(
+        string $entityTypeCode,
+        string $valueTablePrefix,
+        array $entityIds,
+        array $codes
+    ): array {
+        if ($entityIds === []) {
+            return [];
+        }
+
+        $attributes = $this->attributeMap($entityTypeCode, $codes);
+
+        if ($attributes === []) {
+            return [];
+        }
+
+        $byType = [];
+
+        foreach ($attributes as $code => $attribute) {
+            $type = $attribute->backend_type ?? 'varchar';
+
+            if ($type === 'static' || $type === '') {
+                continue;
+            }
+
+            $byType[$type][(int) $attribute->attribute_id] = $code;
+        }
+
+        $values = [];
+
+        foreach ($byType as $type => $idToCode) {
+            $table = $valueTablePrefix.'_'.$type;
+
+            if (! $this->schema->hasTable($table)) {
+                continue;
+            }
+
+            $rows = DB::connection($this->connection)
+                ->table($table)
+                ->select('entity_id', 'attribute_id', 'value')
+                ->whereIn('entity_id', $entityIds)
+                ->whereIn('attribute_id', array_keys($idToCode))
+                ->get();
+
+            foreach ($rows as $row) {
+                $code = $idToCode[(int) $row->attribute_id] ?? null;
+
+                if ($code === null || $row->value === null || $row->value === '') {
+                    continue;
+                }
+
+                $values[(int) $row->entity_id][$code] = $row->value;
+            }
+        }
+
+        return $values;
+    }
+
+    /**
+     * @param  array<int, string>  $codes
+     * @return array<string, object>
+     */
+    private function attributeMap(string $entityTypeCode, array $codes): array
+    {
+        $query = DB::connection($this->connection)
+            ->table('eav_attribute')
+            ->select('attribute_id', 'attribute_code', 'backend_type')
+            ->whereIn('attribute_code', $codes);
+
+        if ($this->schema->hasTable('eav_entity_type')) {
+            $typeId = DB::connection($this->connection)
+                ->table('eav_entity_type')
+                ->where('entity_type_code', $entityTypeCode)
+                ->value('entity_type_id');
+
+            if ($typeId) {
+                $query->where('entity_type_id', $typeId);
+            }
+        }
+
+        return $query->get()->keyBy('attribute_code')->all();
+    }
+
+    /**
+     * @param  array<int, string>  $candidates
+     * @return array<int, string>
+     */
+    private function existingColumns(string $table, array $candidates): array
+    {
+        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);
+    }
+}

+ 63 - 0
app/Support/Magento1Password.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Contracts\Auth\Authenticatable;
+use Illuminate\Support\Facades\Hash;
+
+/**
+ * Magento 1.x password hashes: md5(password) or md5(salt + password).':'.salt
+ */
+class Magento1Password
+{
+    public static function verify(string $plain, string $hash): bool
+    {
+        if ($plain === '' || $hash === '') {
+            return false;
+        }
+
+        $parts = explode(':', $hash);
+
+        $computed = match (count($parts)) {
+            1       => md5($plain),
+            2       => md5($parts[1].$plain),
+            default => null,
+        };
+
+        if ($computed === null) {
+            return false;
+        }
+
+        return hash_equals($parts[0], $computed);
+    }
+
+    public static function upgradeToBcrypt(Authenticatable $customer, string $plain): void
+    {
+        $customer->password = Hash::make($plain);
+        $customer->legacy_password = null;
+        $customer->save();
+    }
+
+    public static function attempt(Authenticatable $customer, string $plain): bool
+    {
+        $stored = (string) $customer->getAuthPassword();
+
+        try {
+            if ($stored !== '' && Hash::check($plain, $stored)) {
+                return true;
+            }
+        } catch (\Throwable) {
+            // Magento hashes are not bcrypt; continue with the legacy verifier.
+        }
+
+        $legacy = (string) ($customer->legacy_password ?? '');
+
+        if ($legacy !== '' && self::verify($plain, $legacy)) {
+            self::upgradeToBcrypt($customer, $plain);
+
+            return true;
+        }
+
+        return false;
+    }
+}

+ 1 - 1
config/auth.php

@@ -66,7 +66,7 @@ return [
 
     'providers' => [
         'customers' => [
-            'driver' => 'eloquent',
+            'driver' => 'customer-eloquent',
             'model'  => Webkul\Customer\Models\Customer::class,
         ],
 

+ 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

+ 26 - 0
database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->unsignedBigInteger('migrated_from_asteria_id')->nullable()->after('id');
+            $table->text('legacy_password')->nullable()->after('password');
+
+            $table->unique('migrated_from_asteria_id', 'uq_customers_migrated_asteria_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->dropUnique('uq_customers_migrated_asteria_id');
+            $table->dropColumn(['migrated_from_asteria_id', 'legacy_password']);
+        });
+    }
+};

+ 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`。

+ 2 - 2
packages/Webkul/BagistoApi/src/State/LoginProcessor.php

@@ -4,8 +4,8 @@ namespace Webkul\BagistoApi\State;
 
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\ProcessorInterface;
+use App\Support\Magento1Password;
 use Illuminate\Support\Facades\Event;
-use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 use Webkul\BagistoApi\Dto\LoginInput;
 use Webkul\BagistoApi\Validators\LoginValidator;
@@ -25,7 +25,7 @@ class LoginProcessor implements ProcessorInterface
 
                 $customer = Customer::where('email', $data->email)->first();
 
-                if (! $customer || ! Hash::check($data->password, $customer->password)) {
+                if (! $customer || ! Magento1Password::attempt($customer, $data->password)) {
                     return (object) [
                         'id'       => 0,
                         '_id'      => 0,

+ 67 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/LoginProcessorLegacyPasswordTest.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use ApiPlatform\Metadata\Post;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Dto\LoginInput;
+use Webkul\BagistoApi\State\LoginProcessor;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+
+class LoginProcessorLegacyPasswordTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('customers', 'legacy_password')) {
+            $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_login_succeeds_with_a_magento_legacy_password_and_rehashes(): void
+    {
+        Event::fake();
+
+        $customer = $this->createCustomer([
+            'email'           => 'migrated@example.com',
+            'password'        => Hash::make('placeholder-secret'),
+            'legacy_password' => md5('abcsecret12').':abc',
+            'is_suspended'    => 0,
+        ]);
+
+        $result = app(LoginProcessor::class)->process(
+            new LoginInput('migrated@example.com', 'secret12'),
+            new Post
+        );
+
+        $this->assertTrue($result->success);
+        $this->assertNotSame('', $result->token);
+
+        $customer->refresh();
+
+        $this->assertTrue(Hash::check('secret12', $customer->password));
+        $this->assertNull($customer->legacy_password);
+    }
+
+    public function test_login_rejects_an_invalid_legacy_password(): void
+    {
+        $this->createCustomer([
+            'email'           => 'migrated@example.com',
+            'password'        => Hash::make('placeholder-secret'),
+            'legacy_password' => md5('abcsecret12').':abc',
+        ]);
+
+        $result = app(LoginProcessor::class)->process(
+            new LoginInput('migrated@example.com', 'wrong-password'),
+            new Post
+        );
+
+        $this->assertFalse($result->success);
+        $this->assertSame('', $result->token);
+    }
+}

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

@@ -0,0 +1,112 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Services\Asteria\Magento1CustomerReader;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class Magento1CustomerReaderTest extends TestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_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::seedCustomer($this->connection, [
+            'entity_id'     => 10,
+            'email'         => 'jane@example.com',
+            'firstname'     => 'Jane',
+            'lastname'      => 'Doe',
+            'telephone'     => '555111',
+            'gender'        => 1,
+            'dob'           => '1990-05-01 00:00:00',
+            'password_hash' => md5('abcsecret12').':abc',
+            'is_active'     => 1,
+            'group_id'      => 99,
+        ]);
+        MagentoSchema::seedAddress($this->connection, [
+            'entity_id'  => 21,
+            'parent_id'  => 10,
+            'firstname'  => 'Jane',
+            'lastname'   => 'Doe',
+            'street'     => "123 Main St\nApt 4",
+            'city'       => 'Austin',
+            'region'     => 'TX',
+            'postcode'   => '78701',
+            'country_id' => 'US',
+            'telephone'  => '555111',
+            'company'    => 'Acme',
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_flattens_customer_and_address_eav_attributes(): void
+    {
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $customers = $reader->fetchCustomers(0, 50);
+        $this->assertCount(1, $customers);
+
+        $customer = $customers->first();
+        $this->assertSame(10, $customer['entity_id']);
+        $this->assertSame('jane@example.com', $customer['email']);
+        $this->assertSame('Jane', $customer['firstname']);
+        $this->assertSame('Doe', $customer['lastname']);
+        $this->assertSame('555111', $customer['telephone']);
+        $this->assertSame('1', (string) $customer['gender']);
+        $this->assertSame('1990-05-01 00:00:00', $customer['dob']);
+        $this->assertSame(md5('abcsecret12').':abc', $customer['password_hash']);
+
+        $addresses = $reader->fetchAddresses([10]);
+        $this->assertCount(1, $addresses);
+
+        $address = $addresses->first();
+        $this->assertSame(21, $address['entity_id']);
+        $this->assertSame(10, $address['parent_id']);
+        $this->assertSame("123 Main St\nApt 4", $address['street']);
+        $this->assertSame('Austin', $address['city']);
+        $this->assertSame('US', $address['country_id']);
+        $this->assertSame('Acme', $address['company']);
+    }
+
+    public function test_it_pages_from_the_last_entity_id(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 11,
+            'email'     => 'second@example.com',
+            'firstname' => 'Second',
+            'lastname'  => 'User',
+        ]);
+
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $this->assertCount(1, $reader->fetchCustomers(10, 50));
+        $this->assertSame('second@example.com', $reader->fetchCustomers(10, 50)->first()['email']);
+    }
+}

+ 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']);
+    }
+}

+ 67 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1PasswordAttemptTest.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Auth\CustomerUserProvider;
+use App\Support\Magento1Password;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Customer\Models\Customer;
+
+class Magento1PasswordAttemptTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('customers', 'legacy_password')) {
+            $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_attempt_upgrades_a_legacy_hash_to_bcrypt(): void
+    {
+        $customer = $this->createCustomer([
+            'password'        => Hash::make('placeholder-secret'),
+            'legacy_password' => md5('abcsecret12').':abc',
+        ]);
+
+        $this->assertTrue(Magento1Password::attempt($customer, 'secret12'));
+
+        $customer->refresh();
+
+        $this->assertTrue(Hash::check('secret12', $customer->password));
+        $this->assertNull($customer->legacy_password);
+    }
+
+    public function test_attempt_accepts_an_existing_bcrypt_password(): void
+    {
+        $customer = $this->createCustomer([
+            'password'        => Hash::make('secret12'),
+            'legacy_password' => null,
+        ]);
+
+        $this->assertTrue(Magento1Password::attempt($customer, 'secret12'));
+        $this->assertFalse(Magento1Password::attempt($customer, 'wrong-password'));
+    }
+
+    public function test_customer_user_provider_upgrades_legacy_password(): void
+    {
+        $customer = $this->createCustomer([
+            'password'        => Hash::make('placeholder-secret'),
+            'legacy_password' => md5('abcsecret12').':abc',
+        ]);
+
+        $provider = new CustomerUserProvider(app('hash'), Customer::class);
+
+        $this->assertTrue($provider->validateCredentials($customer, ['password' => 'secret12']));
+
+        $customer->refresh();
+
+        $this->assertTrue(Hash::check('secret12', $customer->password));
+        $this->assertNull($customer->legacy_password);
+    }
+}

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

@@ -0,0 +1,31 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use App\Support\Magento1Password;
+use PHPUnit\Framework\TestCase;
+
+class Magento1PasswordTest extends TestCase
+{
+    public function test_it_verifies_a_salted_md5_hash(): void
+    {
+        $hash = md5('abcsecret12').':abc';
+
+        $this->assertTrue(Magento1Password::verify('secret12', $hash));
+    }
+
+    public function test_it_verifies_an_unsalted_md5_hash(): void
+    {
+        $this->assertTrue(Magento1Password::verify('secret12', md5('secret12')));
+    }
+
+    public function test_it_rejects_the_wrong_password(): void
+    {
+        $hash = md5('abcsecret12').':abc';
+
+        $this->assertFalse(Magento1Password::verify('wrong-password', $hash));
+        $this->assertFalse(Magento1Password::verify('', $hash));
+        $this->assertFalse(Magento1Password::verify('secret12', ''));
+        $this->assertFalse(Magento1Password::verify('secret12', 'not:a:magento:hash'));
+    }
+}

+ 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']));
+    }
+}

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

@@ -0,0 +1,437 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class MagentoSchema
+{
+    public const CUSTOMER_TYPE_ID = 1;
+
+    public const ADDRESS_TYPE_ID = 2;
+
+    public static function create(string $connection): void
+    {
+        $schema = Schema::connection($connection);
+
+        $schema->create('eav_entity_type', function (Blueprint $table) {
+            $table->integer('entity_type_id');
+            $table->string('entity_type_code');
+        });
+
+        $schema->create('eav_attribute', function (Blueprint $table) {
+            $table->integer('attribute_id');
+            $table->integer('entity_type_id');
+            $table->string('attribute_code');
+            $table->string('backend_type');
+        });
+
+        $schema->create('customer_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('email')->nullable();
+            $table->integer('is_active')->default(1);
+            $table->integer('group_id')->default(1);
+            $table->string('created_at')->nullable();
+            $table->string('updated_at')->nullable();
+        });
+
+        foreach (['varchar', 'int', 'datetime', 'text'] as $type) {
+            $schema->create('customer_entity_'.$type, function (Blueprint $table) {
+                $table->increments('value_id');
+                $table->integer('entity_id');
+                $table->integer('attribute_id');
+                $table->text('value')->nullable();
+            });
+        }
+
+        $schema->create('customer_address_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->integer('parent_id');
+            $table->integer('is_active')->default(1);
+            $table->string('created_at')->nullable();
+        });
+
+        foreach (['varchar', 'int', 'text'] as $type) {
+            $schema->create('customer_address_entity_'.$type, function (Blueprint $table) {
+                $table->increments('value_id');
+                $table->integer('entity_id');
+                $table->integer('attribute_id');
+                $table->text('value')->nullable();
+            });
+        }
+
+        $db = DB::connection($connection);
+
+        $db->table('eav_entity_type')->insert([
+            ['entity_type_id' => self::CUSTOMER_TYPE_ID, 'entity_type_code' => 'customer'],
+            ['entity_type_id' => self::ADDRESS_TYPE_ID, 'entity_type_code' => 'customer_address'],
+        ]);
+
+        $db->table('eav_attribute')->insert([
+            ['attribute_id' => 5, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'firstname', 'backend_type' => 'varchar'],
+            ['attribute_id' => 7, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'lastname', 'backend_type' => 'varchar'],
+            ['attribute_id' => 11, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'dob', 'backend_type' => 'datetime'],
+            ['attribute_id' => 12, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'password_hash', 'backend_type' => 'varchar'],
+            ['attribute_id' => 13, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'default_billing', 'backend_type' => 'int'],
+            ['attribute_id' => 14, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'default_shipping', 'backend_type' => 'int'],
+            ['attribute_id' => 18, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'gender', 'backend_type' => 'int'],
+            ['attribute_id' => 88, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
+            ['attribute_id' => 20, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'firstname', 'backend_type' => 'varchar'],
+            ['attribute_id' => 21, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'lastname', 'backend_type' => 'varchar'],
+            ['attribute_id' => 22, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'company', 'backend_type' => 'varchar'],
+            ['attribute_id' => 23, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'street', 'backend_type' => 'text'],
+            ['attribute_id' => 24, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'city', 'backend_type' => 'varchar'],
+            ['attribute_id' => 25, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'region', 'backend_type' => 'varchar'],
+            ['attribute_id' => 26, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'postcode', 'backend_type' => 'varchar'],
+            ['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();
+        });
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedCustomer(string $connection, array $data): void
+    {
+        $entityId = (int) $data['entity_id'];
+
+        DB::connection($connection)->table('customer_entity')->insert([
+            'entity_id'  => $entityId,
+            'email'      => $data['email'],
+            'is_active'  => $data['is_active'] ?? 1,
+            'group_id'   => $data['group_id'] ?? 99,
+            'created_at' => $data['created_at'] ?? '2020-01-01 00:00:00',
+            'updated_at' => $data['updated_at'] ?? '2020-01-01 00:00:00',
+        ]);
+
+        $varchar = [
+            5  => $data['firstname'] ?? null,
+            7  => $data['lastname'] ?? null,
+            12 => $data['password_hash'] ?? null,
+            88 => $data['telephone'] ?? null,
+        ];
+
+        foreach ($varchar as $attributeId => $value) {
+            if ($value === null || $value === '') {
+                continue;
+            }
+
+            DB::connection($connection)->table('customer_entity_varchar')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => $attributeId,
+                'value'        => $value,
+            ]);
+        }
+
+        if (isset($data['gender'])) {
+            DB::connection($connection)->table('customer_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 18,
+                'value'        => $data['gender'],
+            ]);
+        }
+
+        if (! empty($data['default_billing'])) {
+            DB::connection($connection)->table('customer_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 13,
+                'value'        => $data['default_billing'],
+            ]);
+        }
+
+        if (! empty($data['default_shipping'])) {
+            DB::connection($connection)->table('customer_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 14,
+                'value'        => $data['default_shipping'],
+            ]);
+        }
+
+        if (! empty($data['dob'])) {
+            DB::connection($connection)->table('customer_entity_datetime')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 11,
+                'value'        => $data['dob'],
+            ]);
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedAddress(string $connection, array $data): void
+    {
+        $entityId = (int) $data['entity_id'];
+
+        DB::connection($connection)->table('customer_address_entity')->insert([
+            'entity_id'  => $entityId,
+            'parent_id'  => $data['parent_id'],
+            'is_active'  => 1,
+            'created_at' => '2020-01-01 00:00:00',
+        ]);
+
+        $map = [
+            20 => ['table' => 'customer_address_entity_varchar', 'value' => $data['firstname'] ?? null],
+            21 => ['table' => 'customer_address_entity_varchar', 'value' => $data['lastname'] ?? null],
+            22 => ['table' => 'customer_address_entity_varchar', 'value' => $data['company'] ?? null],
+            23 => ['table' => 'customer_address_entity_text', 'value' => $data['street'] ?? null],
+            24 => ['table' => 'customer_address_entity_varchar', 'value' => $data['city'] ?? null],
+            25 => ['table' => 'customer_address_entity_varchar', 'value' => $data['region'] ?? null],
+            26 => ['table' => 'customer_address_entity_varchar', 'value' => $data['postcode'] ?? null],
+            27 => ['table' => 'customer_address_entity_varchar', 'value' => $data['country_id'] ?? null],
+            28 => ['table' => 'customer_address_entity_varchar', 'value' => $data['telephone'] ?? null],
+        ];
+
+        foreach ($map as $attributeId => $item) {
+            if ($item['value'] === null || $item['value'] === '') {
+                continue;
+            }
+
+            DB::connection($connection)->table($item['table'])->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => $attributeId,
+                'value'        => $item['value'],
+            ]);
+        }
+    }
+
+    /**
+     * @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,
+        ]);
+    }
+}

+ 242 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaCustomersCommandTest.php

@@ -0,0 +1,242 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Customer\Models\Customer;
+use Webkul\Customer\Models\CustomerAddress;
+use Webkul\Customer\Models\CustomerGroup;
+
+class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
+            || ! Schema::hasColumn('customers', 'legacy_password')) {
+            $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
+        }
+
+        $this->seedRequiredData();
+        Cache::forget('migrate_asteria_customers_last_id');
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_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_a_customer_and_address_into_the_general_group(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id'        => 10,
+            'email'            => 'jane@example.com',
+            'firstname'        => 'Jane',
+            'lastname'         => 'Doe',
+            'telephone'        => '5551112222',
+            'gender'           => 1,
+            'dob'              => '1990-05-01 00:00:00',
+            'password_hash'    => md5('abcsecret12').':abc',
+            'group_id'         => 99,
+            'default_billing'  => 21,
+            'default_shipping' => 21,
+        ]);
+        MagentoSchema::seedAddress($this->connection, [
+            'entity_id'  => 21,
+            'parent_id'  => 10,
+            'firstname'  => 'Jane',
+            'lastname'   => 'Doe',
+            'street'     => "123 Main St\nApt 4",
+            'city'       => 'Austin',
+            'region'     => 'TX',
+            'postcode'   => '78701',
+            'country_id' => 'US',
+            'telephone'  => '5551112222',
+            'company'    => 'Acme',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'jane@example.com')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(10, (int) $customer->migrated_from_asteria_id);
+        $this->assertSame('Jane', $customer->first_name);
+        $this->assertSame('Doe', $customer->last_name);
+        $this->assertSame('Male', $customer->gender);
+        $this->assertSame('1990-05-01', $customer->date_of_birth);
+        $this->assertSame('5551112222', $customer->phone);
+        $this->assertSame(md5('abcsecret12').':abc', $customer->legacy_password);
+        $this->assertSame(1, (int) $customer->is_verified);
+
+        $generalId = CustomerGroup::query()->where('code', 'general')->value('id');
+        $this->assertSame((int) $generalId, (int) $customer->customer_group_id);
+
+        $address = CustomerAddress::query()->where('customer_id', $customer->id)->first();
+        $this->assertNotNull($address);
+        $this->assertSame('123 Main St, Apt 4', $address->address);
+        $this->assertSame('Austin', $address->city);
+        $this->assertSame('US', $address->country);
+        $this->assertTrue((bool) $address->default_address);
+        $this->assertTrue((bool) $address->use_for_shipping);
+        $this->assertSame(21, $this->asteriaAddressId($address));
+    }
+
+    public function test_dry_run_does_not_write_customers(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 10,
+            'email'     => 'dry-run@example.com',
+            'firstname' => 'Dry',
+            'lastname'  => 'Run',
+        ]);
+
+        $before = Customer::query()->count();
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--dry-run'        => true,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $this->assertSame($before, Customer::query()->count());
+        $this->assertNull(Customer::query()->where('email', 'dry-run@example.com')->first());
+    }
+
+    public function test_it_links_an_existing_email_without_overwriting_the_password(): void
+    {
+        $existing = $this->createCustomer([
+            'email'     => 'existing@example.com',
+            'password'  => Hash::make('bagisto-secret'),
+            'first_name'=> 'Keep',
+        ]);
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id'     => 44,
+            'email'         => 'existing@example.com',
+            'firstname'     => 'Magento',
+            'lastname'      => 'Name',
+            'password_hash' => md5('abcsecret12').':abc',
+        ]);
+        MagentoSchema::seedAddress($this->connection, [
+            'entity_id'  => 45,
+            'parent_id'  => 44,
+            'firstname'  => 'Magento',
+            'lastname'   => 'Name',
+            'street'     => '9 Oak Rd',
+            'city'       => 'Dallas',
+            'country_id' => 'US',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $existing->refresh();
+
+        $this->assertSame(44, (int) $existing->migrated_from_asteria_id);
+        $this->assertSame('Keep', $existing->first_name);
+        $this->assertTrue(Hash::check('bagisto-secret', $existing->password));
+        $this->assertNull($existing->legacy_password);
+        $this->assertSame(1, Customer::query()->where('email', 'existing@example.com')->count());
+        $this->assertSame(1, CustomerAddress::query()->where('customer_id', $existing->id)->count());
+    }
+
+    public function test_it_nulls_a_conflicting_phone_number(): void
+    {
+        $this->createCustomer([
+            'email' => 'owner@example.com',
+            'phone' => '5550001111',
+        ]);
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 70,
+            'email'     => 'other@example.com',
+            'firstname' => 'Other',
+            'lastname'  => 'Person',
+            'telephone' => '5550001111',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $migrated = Customer::query()->where('email', 'other@example.com')->first();
+        $this->assertNotNull($migrated);
+        $this->assertNull($migrated->phone);
+    }
+
+    public function test_it_is_idempotent_on_a_second_run(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 80,
+            'email'     => 'once@example.com',
+            'firstname' => 'Once',
+            'lastname'  => 'Only',
+        ]);
+        MagentoSchema::seedAddress($this->connection, [
+            'entity_id'  => 81,
+            'parent_id'  => 80,
+            'firstname'  => 'Once',
+            'lastname'   => 'Only',
+            'street'     => '1 Repeat Ln',
+            'city'       => 'Miami',
+            'country_id' => 'US',
+        ]);
+
+        $options = [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ];
+
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+
+        $this->assertSame(1, Customer::query()->where('email', 'once@example.com')->count());
+        $customer = Customer::query()->where('email', 'once@example.com')->first();
+        $this->assertSame(1, CustomerAddress::query()->where('customer_id', $customer->id)->count());
+    }
+
+    private function asteriaAddressId(CustomerAddress $address): ?int
+    {
+        $additional = $address->additional;
+
+        if (is_string($additional) && $additional !== '') {
+            $additional = json_decode($additional, true);
+        }
+
+        return is_array($additional) ? (int) ($additional['asteria_address_id'] ?? 0) : null;
+    }
+}

+ 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,
+        ]);
+    }
+}

+ 8 - 2
packages/Webkul/Customer/src/Models/Customer.php

@@ -52,6 +52,7 @@ class Customer extends Authenticatable implements CustomerContract
         'email',
         'phone',
         'password',
+        'legacy_password',
         'api_token',
         'token',
         'customer_group_id',
@@ -60,6 +61,7 @@ class Customer extends Authenticatable implements CustomerContract
         'status',
         'is_verified',
         'is_suspended',
+        'migrated_from_asteria_id',
     ];
 
     /**
@@ -69,6 +71,7 @@ class Customer extends Authenticatable implements CustomerContract
      */
     protected $hidden = [
         'password',
+        'legacy_password',
         'api_token',
         'remember_token',
     ];
@@ -107,18 +110,21 @@ class Customer extends Authenticatable implements CustomerContract
     {
         return ucfirst($this->first_name).' '.ucfirst($this->last_name);
     }
+
     /**
      * Get the isVip.
      */
     public function getIsVipAttribute(): string
     {
-        if (!$this->vip_expire_date) {
+        if (! $this->vip_expire_date) {
             return false;
         }
 
         $expireDate = \Carbon\Carbon::parse($this->vip_expire_date);
-        return !$expireDate->isPast();
+
+        return ! $expireDate->isPast();
     }
+
     /**
      * Get image url for the customer image.
      *