MigrateAsteriaCustomers.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\Asteria\Magento1CustomerReader;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Hash;
  8. use Illuminate\Support\Facades\Log;
  9. use Illuminate\Support\Facades\Schema;
  10. use Illuminate\Support\Str;
  11. use Webkul\Customer\Models\Customer;
  12. use Webkul\Customer\Models\CustomerAddress;
  13. /**
  14. * Migrates storefront customers and addresses from Asteria (Magento 1.x).
  15. *
  16. * Usage
  17. * ─────
  18. * php artisan customers:migrate-asteria
  19. * php artisan customers:migrate-asteria --batch-size=200
  20. * php artisan customers:migrate-asteria --reset-progress
  21. * php artisan customers:migrate-asteria --dry-run
  22. */
  23. class MigrateAsteriaCustomers extends Command
  24. {
  25. protected $signature = 'customers:migrate-asteria
  26. {--batch-size=100 : Number of Magento customers per batch}
  27. {--reset-progress : Ignore saved progress and start from entity_id=0}
  28. {--dry-run : Count records without writing}
  29. {--connection=asteria : Laravel DB connection for the Asteria database}';
  30. protected $description = 'Migrate Asteria (Magento 1.x) customers and addresses into Bagisto';
  31. private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
  32. public function handle(): int
  33. {
  34. $connection = (string) $this->option('connection');
  35. $batchSize = max(1, (int) $this->option('batch-size'));
  36. $resetProgress = (bool) $this->option('reset-progress');
  37. $dryRun = (bool) $this->option('dry-run');
  38. try {
  39. DB::connection($connection)->getPdo();
  40. } catch (\Throwable $e) {
  41. $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
  42. return self::FAILURE;
  43. }
  44. foreach (['customer_entity', 'eav_attribute'] as $table) {
  45. if (! Schema::connection($connection)->hasTable($table)) {
  46. $this->error("Asteria table '{$table}' is missing on connection '{$connection}'.");
  47. return self::FAILURE;
  48. }
  49. }
  50. if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
  51. || ! Schema::hasColumn('customers', 'legacy_password')) {
  52. $this->error('customers.migrated_from_asteria_id / legacy_password are missing. Run php artisan migrate.');
  53. return self::FAILURE;
  54. }
  55. $groupId = DB::table('customer_groups')->where('code', 'general')->value('id');
  56. if (! $groupId) {
  57. $this->error("Bagisto customer group 'general' was not found.");
  58. return self::FAILURE;
  59. }
  60. $channelId = core()->getDefaultChannel()?->id ?? core()->getCurrentChannel()?->id;
  61. $reader = new Magento1CustomerReader($connection);
  62. $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
  63. if ($resetProgress) {
  64. Cache::forget(self::PROGRESS_KEY);
  65. }
  66. if ($lastId > 0) {
  67. $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
  68. }
  69. $usedPhones = DB::table('customers')
  70. ->whereNotNull('phone')
  71. ->where('phone', '!=', '')
  72. ->pluck('phone')
  73. ->map(fn ($phone) => mb_strtolower((string) $phone))
  74. ->flip()
  75. ->all();
  76. $created = 0;
  77. $linked = 0;
  78. $skipped = 0;
  79. $addressesImported = 0;
  80. $batchNumber = 0;
  81. $this->info($dryRun ? '[DRY RUN] Scanning Magento customers…' : 'Migrating Magento customers…');
  82. do {
  83. $customers = $reader->fetchCustomers($lastId, $batchSize);
  84. if ($customers->isEmpty()) {
  85. break;
  86. }
  87. $batchNumber++;
  88. $lastId = (int) $customers->max('entity_id');
  89. $addresses = $reader->fetchAddresses(
  90. $customers->pluck('entity_id')->map(fn ($id) => (int) $id)->all()
  91. )->groupBy(fn (array $row) => (int) $row['parent_id']);
  92. if ($dryRun) {
  93. $created += $customers->count();
  94. $addressesImported += $addresses->flatten(1)->count();
  95. $this->line(sprintf(
  96. ' Batch #%d: %d customers, %d addresses (last entity_id=%d) [skipped – dry-run]',
  97. $batchNumber,
  98. $customers->count(),
  99. $addresses->flatten(1)->count(),
  100. $lastId
  101. ));
  102. continue;
  103. }
  104. $batchCreated = 0;
  105. $batchLinked = 0;
  106. $batchSkipped = 0;
  107. $batchAddresses = 0;
  108. DB::transaction(function () use (
  109. $customers,
  110. $addresses,
  111. $groupId,
  112. $channelId,
  113. &$usedPhones,
  114. &$batchCreated,
  115. &$batchLinked,
  116. &$batchSkipped,
  117. &$batchAddresses
  118. ) {
  119. foreach ($customers as $row) {
  120. $result = $this->migrateCustomer($row, $addresses->get((int) $row['entity_id'], collect()), (int) $groupId, $channelId, $usedPhones);
  121. $batchCreated += $result['created'];
  122. $batchLinked += $result['linked'];
  123. $batchSkipped += $result['skipped'];
  124. $batchAddresses += $result['addresses'];
  125. }
  126. });
  127. Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
  128. $created += $batchCreated;
  129. $linked += $batchLinked;
  130. $skipped += $batchSkipped;
  131. $addressesImported += $batchAddresses;
  132. $this->line(sprintf(
  133. ' Batch #%d: created=%d linked=%d skipped=%d addresses=%d (last entity_id=%d)',
  134. $batchNumber,
  135. $batchCreated,
  136. $batchLinked,
  137. $batchSkipped,
  138. $batchAddresses,
  139. $lastId
  140. ));
  141. Log::info('MigrateAsteriaCustomers: batch '.$batchNumber.', last_id='.$lastId);
  142. } while ($customers->count() === $batchSize);
  143. $this->newLine();
  144. $this->info("Done. Batches: {$batchNumber}, created: {$created}, linked: {$linked}, skipped: {$skipped}, addresses: {$addressesImported}.");
  145. return self::SUCCESS;
  146. }
  147. /**
  148. * @param array<string, mixed> $row
  149. * @param \Illuminate\Support\Collection<int, array<string, mixed>> $addressRows
  150. * @param array<string, int> $usedPhones
  151. * @return array{created: int, linked: int, skipped: int, addresses: int}
  152. */
  153. private function migrateCustomer(
  154. array $row,
  155. $addressRows,
  156. int $groupId,
  157. mixed $channelId,
  158. array &$usedPhones
  159. ): array {
  160. $email = strtolower(trim((string) ($row['email'] ?? '')));
  161. $asteriaId = (int) $row['entity_id'];
  162. if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
  163. return ['created' => 0, 'linked' => 0, 'skipped' => 1, 'addresses' => 0];
  164. }
  165. $existing = Customer::query()
  166. ->where(function ($query) use ($asteriaId, $email) {
  167. $query->where('migrated_from_asteria_id', $asteriaId)
  168. ->orWhereRaw('LOWER(email) = ?', [$email]);
  169. })
  170. ->first();
  171. if ($existing) {
  172. if (! $existing->migrated_from_asteria_id) {
  173. $existing->migrated_from_asteria_id = $asteriaId;
  174. $existing->save();
  175. }
  176. $imported = $this->importAddresses($existing, $row, $addressRows);
  177. return ['created' => 0, 'linked' => 1, 'skipped' => 0, 'addresses' => $imported];
  178. }
  179. $phone = $this->uniquePhone((string) ($row['telephone'] ?? ''), $usedPhones);
  180. $customer = new Customer;
  181. $customer->forceFill([
  182. 'migrated_from_asteria_id' => $asteriaId,
  183. 'first_name' => $this->requiredName($row['firstname'] ?? null, $email),
  184. 'last_name' => trim((string) ($row['lastname'] ?? '')) ?: '-',
  185. 'gender' => $this->mapGender($row['gender'] ?? null),
  186. 'date_of_birth' => $this->mapDate($row['dob'] ?? null),
  187. 'email' => $email,
  188. 'phone' => $phone,
  189. 'password' => Hash::make(Str::random(32)),
  190. 'legacy_password' => $this->nullableString($row['password_hash'] ?? null),
  191. 'api_token' => Str::random(80),
  192. 'customer_group_id' => $groupId,
  193. 'channel_id' => $channelId,
  194. 'subscribed_to_news_letter' => false,
  195. 'status' => ((int) ($row['is_active'] ?? 1)) === 1 ? 1 : 0,
  196. 'is_verified' => 1,
  197. 'is_suspended' => 0,
  198. ]);
  199. if (! empty($row['created_at'])) {
  200. $customer->created_at = $row['created_at'];
  201. }
  202. $customer->save();
  203. $imported = $this->importAddresses($customer, $row, $addressRows);
  204. return ['created' => 1, 'linked' => 0, 'skipped' => 0, 'addresses' => $imported];
  205. }
  206. /**
  207. * @param array<string, mixed> $customerRow
  208. * @param \Illuminate\Support\Collection<int, array<string, mixed>> $addressRows
  209. */
  210. private function importAddresses(Customer $customer, array $customerRow, $addressRows): int
  211. {
  212. if ($addressRows->isEmpty()) {
  213. return 0;
  214. }
  215. $existingIds = $customer->addresses()
  216. ->get()
  217. ->map(fn (CustomerAddress $address) => $this->asteriaAddressId($address))
  218. ->filter()
  219. ->all();
  220. $defaultBilling = (int) ($customerRow['default_billing'] ?? 0);
  221. $defaultShipping = (int) ($customerRow['default_shipping'] ?? 0);
  222. $imported = 0;
  223. foreach ($addressRows as $row) {
  224. $asteriaAddressId = (int) $row['entity_id'];
  225. if (in_array($asteriaAddressId, $existingIds, true)) {
  226. continue;
  227. }
  228. $address = new CustomerAddress;
  229. $address->forceFill([
  230. 'customer_id' => $customer->id,
  231. 'address_type' => CustomerAddress::ADDRESS_TYPE,
  232. 'first_name' => $this->requiredName($row['firstname'] ?? null, $customer->first_name),
  233. 'last_name' => trim((string) ($row['lastname'] ?? '')) ?: $customer->last_name,
  234. 'company_name' => $this->nullableString($row['company'] ?? null),
  235. 'address' => $this->mapStreet($row['street'] ?? null) ?: '-',
  236. 'city' => trim((string) ($row['city'] ?? '')) ?: '-',
  237. 'state' => $this->nullableString($row['region'] ?? null),
  238. 'country' => $this->nullableString($row['country_id'] ?? null),
  239. 'postcode' => $this->nullableString($row['postcode'] ?? null),
  240. 'email' => $customer->email,
  241. 'phone' => $this->nullableString($row['telephone'] ?? null) ?? $customer->phone,
  242. 'default_address' => $defaultBilling > 0 && $asteriaAddressId === $defaultBilling,
  243. 'use_for_shipping' => $defaultShipping > 0 && $asteriaAddressId === $defaultShipping,
  244. 'additional' => json_encode(['asteria_address_id' => $asteriaAddressId]),
  245. ]);
  246. $address->save();
  247. $imported++;
  248. }
  249. return $imported;
  250. }
  251. /**
  252. * @param array<string, int> $usedPhones
  253. */
  254. private function uniquePhone(string $phone, array &$usedPhones): ?string
  255. {
  256. $phone = trim($phone);
  257. if ($phone === '') {
  258. return null;
  259. }
  260. $key = mb_strtolower($phone);
  261. if (isset($usedPhones[$key])) {
  262. return null;
  263. }
  264. $usedPhones[$key] = 1;
  265. return $phone;
  266. }
  267. private function mapGender(mixed $value): ?string
  268. {
  269. return match ((int) $value) {
  270. 1 => 'Male',
  271. 2 => 'Female',
  272. default => null,
  273. };
  274. }
  275. private function mapDate(mixed $value): ?string
  276. {
  277. $value = trim((string) $value);
  278. if ($value === '' || str_starts_with($value, '0000-00-00')) {
  279. return null;
  280. }
  281. return substr($value, 0, 10);
  282. }
  283. private function mapStreet(mixed $value): string
  284. {
  285. $value = trim((string) $value);
  286. if ($value === '') {
  287. return '';
  288. }
  289. $lines = preg_split("/\r\n|\n|\r/", $value) ?: [];
  290. return implode(', ', array_filter(array_map('trim', $lines)));
  291. }
  292. private function requiredName(mixed $value, string $fallback): string
  293. {
  294. $value = trim((string) $value);
  295. if ($value !== '') {
  296. return $value;
  297. }
  298. $local = strstr($fallback, '@', true);
  299. return $local !== false && $local !== '' ? $local : 'Customer';
  300. }
  301. private function nullableString(mixed $value): ?string
  302. {
  303. $value = trim((string) $value);
  304. return $value === '' ? null : $value;
  305. }
  306. private function asteriaAddressId(CustomerAddress $address): ?int
  307. {
  308. $additional = $address->additional;
  309. if (is_string($additional) && $additional !== '') {
  310. $additional = json_decode($additional, true);
  311. }
  312. if (! is_array($additional)) {
  313. return null;
  314. }
  315. return isset($additional['asteria_address_id'])
  316. ? (int) $additional['asteria_address_id']
  317. : null;
  318. }
  319. }