| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- <?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;
- }
- }
|