Selaa lähdekoodia

用户迁移脚本

chengwl 3 päivää sitten
vanhempi
commit
b034226a53

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

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

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

@@ -0,0 +1,216 @@
+<?php
+
+namespace App\Services\Asteria;
+
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+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',
+    ];
+
+    public function __construct(private string $connection = 'asteria') {}
+
+    /**
+     * @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 (! Schema::connection($this->connection)->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 (Schema::connection($this->connection)->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
+    {
+        $schema = Schema::connection($this->connection);
+
+        return array_values(array_filter(
+            $candidates,
+            fn (string $column) => $schema->hasColumn($table, $column)
+        ));
+    }
+}

+ 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,
         ],
 

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

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

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

@@ -0,0 +1,199 @@
+<?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'],
+        ]);
+    }
+
+    /**
+     * @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'],
+            ]);
+        }
+    }
+}

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

+ 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.
      *