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