Procházet zdrojové kódy

Merge branch 'dev' into dev-rewardPoints

bianjunhui před 2 dny
rodič
revize
1040dd4bdb
58 změnil soubory, kde provedl 6123 přidání a 489 odebrání
  1. 3 0
      .env.example
  2. 626 128
      app/Console/Commands/MigrateAsteriaCustomers.php
  3. 462 266
      app/Console/Commands/MigrateAsteriaOrders.php
  4. 1184 0
      app/Console/Commands/MigrateAsteriaProducts.php
  5. 345 0
      app/Console/Commands/ResetCatalogCommand.php
  6. 377 0
      app/Console/Commands/ResetCustomersCommand.php
  7. 118 1
      app/Services/Asteria/Magento1CustomerReader.php
  8. 42 0
      app/Services/Asteria/Magento1OrderReader.php
  9. 1113 0
      app/Services/Asteria/Magento1ProductReader.php
  10. 4 1
      app/Services/Asteria/Magento1Schema.php
  11. 69 0
      config/asteria.php
  12. 8 0
      config/filesystems.php
  13. 25 0
      database/migrations/2026_09_01_151700_add_asteria_migration_column_to_products_table.php
  14. 22 0
      database/migrations/2026_09_02_144100_add_customer_source_to_customers_table.php
  15. 22 0
      database/migrations/2026_09_03_101500_change_customer_source_to_string_on_customers_table.php
  16. 86 0
      database/scripts/reset_catalog.sql
  17. 76 0
      database/scripts/reset_customers.sql
  18. 73 17
      docs/asteria-migration.md
  19. 2 2
      packages/Longyi/Blog/src/Models/Blog.php
  20. 20 18
      packages/Longyi/Blog/src/Repositories/BlogRepository.php
  21. 32 0
      packages/Longyi/FrontMenu/src/Database/Migrations/2026_08_28_000006_add_show_icon_to_front_menu_items.php
  22. 3 0
      packages/Longyi/FrontMenu/src/Http/Controllers/FrontMenuController.php
  23. 2 0
      packages/Longyi/FrontMenu/src/Models/FrontMenuItem.php
  24. 4 0
      packages/Longyi/FrontMenu/src/Resources/lang/en/app.php
  25. 4 0
      packages/Longyi/FrontMenu/src/Resources/lang/zh_CN/app.php
  26. 16 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php
  27. 16 0
      packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php
  28. 9 8
      packages/Longyi/RewardPoints/src/Listeners/OrderEvents.php
  29. 10 0
      packages/Webkul/Admin/src/DataGrids/Customers/CustomerDataGrid.php
  30. 2 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  31. 2 0
      packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php
  32. 5 1
      packages/Webkul/Admin/src/Resources/views/customers/customers/index.blade.php
  33. 4 0
      packages/Webkul/Admin/src/Resources/views/customers/customers/view.blade.php
  34. 7 3
      packages/Webkul/Admin/tests/Feature/Customers/CustomersTest.php
  35. 1 1
      packages/Webkul/BagistoApi/README.md
  36. 25 0
      packages/Webkul/BagistoApi/config/bagistoapi.php
  37. 14 0
      packages/Webkul/BagistoApi/src/Dto/CancelOrderInput.php
  38. 27 0
      packages/Webkul/BagistoApi/src/Dto/ResetPasswordInput.php
  39. 6 1
      packages/Webkul/BagistoApi/src/Models/CancelOrder.php
  40. 69 0
      packages/Webkul/BagistoApi/src/Models/ResetPassword.php
  41. 8 0
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  42. 13 1
      packages/Webkul/BagistoApi/src/Resources/lang/en/app.php
  43. 13 1
      packages/Webkul/BagistoApi/src/Services/PaymentService.php
  44. 58 15
      packages/Webkul/BagistoApi/src/State/CancelOrderProcessor.php
  45. 28 11
      packages/Webkul/BagistoApi/src/State/CustomerProfileProcessor.php
  46. 123 0
      packages/Webkul/BagistoApi/src/State/ResetPasswordProcessor.php
  47. 71 0
      packages/Webkul/BagistoApi/src/Support/Frontend.php
  48. 35 12
      packages/Webkul/BagistoApi/src/Validators/CustomerValidator.php
  49. 40 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1CustomerReaderTest.php
  50. 424 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/MagentoSchema.php
  51. 263 1
      packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaCustomersCommandTest.php
  52. 41 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/ResetCatalogCommandTest.php
  53. 31 0
      packages/Webkul/BagistoApi/tests/Unit/Migration/ResetCustomersCommandTest.php
  54. 33 0
      packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCancelStrategyTest.php
  55. 1 0
      packages/Webkul/Customer/src/Models/Customer.php
  56. 1 0
      packages/Webkul/Product/src/Models/Product.php
  57. 1 0
      packages/Webkul/Shop/src/Mail/Customer/ResetPasswordNotification.php
  58. 4 1
      packages/Webkul/Shop/src/Resources/views/emails/customers/forgot-password.blade.php

+ 3 - 0
.env.example

@@ -16,6 +16,9 @@ APP_CURRENCY=USD
 APP_MAINTENANCE_DRIVER=file
 APP_MAINTENANCE_DRIVER=file
 # APP_MAINTENANCE_STORE=database
 # APP_MAINTENANCE_STORE=database
 
 
+FRONTEND_RESET_PASSWORD_URL_PC=https://pc.sisbridal.com/customer/reset-password
+FRONTEND_RESET_PASSWORD_URL_MOBILE=https://m.sisbridal.com/customer/reset-password
+
 BCRYPT_ROUNDS=12
 BCRYPT_ROUNDS=12
 
 
 LOG_CHANNEL=stack
 LOG_CHANNEL=stack

+ 626 - 128
app/Console/Commands/MigrateAsteriaCustomers.php

@@ -4,37 +4,50 @@ namespace App\Console\Commands;
 
 
 use App\Services\Asteria\Magento1CustomerReader;
 use App\Services\Asteria\Magento1CustomerReader;
 use Illuminate\Console\Command;
 use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
-use Webkul\Customer\Models\Customer;
 use Webkul\Customer\Models\CustomerAddress;
 use Webkul\Customer\Models\CustomerAddress;
 
 
 /**
 /**
- * Migrates storefront customers and addresses from Asteria (Magento 1.x).
+ * Migrates storefront customers, addresses, and newsletter subscriptions from Asteria (Magento 1.x).
  *
  *
  * Usage
  * Usage
  * ─────
  * ─────
  * php artisan customers:migrate-asteria
  * php artisan customers:migrate-asteria
- * php artisan customers:migrate-asteria --batch-size=200
+ * php artisan customers:migrate-asteria --batch-size=500
  * php artisan customers:migrate-asteria --reset-progress
  * php artisan customers:migrate-asteria --reset-progress
  * php artisan customers:migrate-asteria --dry-run
  * php artisan customers:migrate-asteria --dry-run
  */
  */
 class MigrateAsteriaCustomers extends Command
 class MigrateAsteriaCustomers extends Command
 {
 {
     protected $signature = 'customers:migrate-asteria
     protected $signature = 'customers:migrate-asteria
-        {--batch-size=100     : Number of Magento customers per batch}
+        {--batch-size=500     : Number of Magento customers per batch}
         {--reset-progress     : Ignore saved progress and start from entity_id=0}
         {--reset-progress     : Ignore saved progress and start from entity_id=0}
         {--dry-run            : Count records without writing}
         {--dry-run            : Count records without writing}
         {--connection=asteria : Laravel DB connection for the Asteria database}';
         {--connection=asteria : Laravel DB connection for the Asteria database}';
 
 
-    protected $description = 'Migrate Asteria (Magento 1.x) customers and addresses into Bagisto';
+    protected $description = 'Migrate Asteria (Magento 1.x) customers, addresses, and newsletter subscriptions into Bagisto';
+
+    private const MAGENTO_NEWSLETTER_SUBSCRIBED = 1;
 
 
     private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
     private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
 
 
+    private const INSERT_CHUNK = 200;
+
+    private int $groupId;
+
+    private mixed $channelId;
+
+    private string $placeholderPassword;
+
+    /** @var array<string, int> */
+    private array $usedPhones = [];
+
     public function handle(): int
     public function handle(): int
     {
     {
         $connection = (string) $this->option('connection');
         $connection = (string) $this->option('connection');
@@ -42,6 +55,8 @@ class MigrateAsteriaCustomers extends Command
         $resetProgress = (bool) $this->option('reset-progress');
         $resetProgress = (bool) $this->option('reset-progress');
         $dryRun = (bool) $this->option('dry-run');
         $dryRun = (bool) $this->option('dry-run');
 
 
+        DB::disableQueryLog();
+
         try {
         try {
             DB::connection($connection)->getPdo();
             DB::connection($connection)->getPdo();
         } catch (\Throwable $e) {
         } catch (\Throwable $e) {
@@ -59,8 +74,9 @@ class MigrateAsteriaCustomers extends Command
         }
         }
 
 
         if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
         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.');
+            || ! Schema::hasColumn('customers', 'legacy_password')
+            || ! Schema::hasColumn('customers', 'customer_source')) {
+            $this->error('customers.migrated_from_asteria_id / legacy_password / customer_source are missing. Run php artisan migrate.');
 
 
             return self::FAILURE;
             return self::FAILURE;
         }
         }
@@ -73,7 +89,9 @@ class MigrateAsteriaCustomers extends Command
             return self::FAILURE;
             return self::FAILURE;
         }
         }
 
 
-        $channelId = core()->getDefaultChannel()?->id ?? core()->getCurrentChannel()?->id;
+        $this->groupId = (int) $groupId;
+        $this->channelId = core()->getDefaultChannel()?->id ?? core()->getCurrentChannel()?->id;
+        $this->placeholderPassword = Hash::make(Str::random(32));
 
 
         $reader = new Magento1CustomerReader($connection);
         $reader = new Magento1CustomerReader($connection);
         $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
         $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
@@ -86,7 +104,7 @@ class MigrateAsteriaCustomers extends Command
             $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
             $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
         }
         }
 
 
-        $usedPhones = DB::table('customers')
+        $this->usedPhones = DB::table('customers')
             ->whereNotNull('phone')
             ->whereNotNull('phone')
             ->where('phone', '!=', '')
             ->where('phone', '!=', '')
             ->pluck('phone')
             ->pluck('phone')
@@ -103,6 +121,7 @@ class MigrateAsteriaCustomers extends Command
         $this->info($dryRun ? '[DRY RUN] Scanning Magento customers…' : 'Migrating Magento customers…');
         $this->info($dryRun ? '[DRY RUN] Scanning Magento customers…' : 'Migrating Magento customers…');
 
 
         do {
         do {
+            $started = microtime(true);
             $customers = $reader->fetchCustomers($lastId, $batchSize);
             $customers = $reader->fetchCustomers($lastId, $batchSize);
 
 
             if ($customers->isEmpty()) {
             if ($customers->isEmpty()) {
@@ -111,165 +130,623 @@ class MigrateAsteriaCustomers extends Command
 
 
             $batchNumber++;
             $batchNumber++;
             $lastId = (int) $customers->max('entity_id');
             $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']);
+            $asteriaIds = $customers->pluck('entity_id')->map(fn ($id) => (int) $id)->all();
+            $emails = $customers
+                ->pluck('email')
+                ->map(fn ($email) => strtolower(trim((string) $email)))
+                ->filter()
+                ->all();
+            $addresses = $reader->fetchAddresses($asteriaIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
+            $subscribers = $reader->fetchSubscribers($asteriaIds, $emails);
 
 
             if ($dryRun) {
             if ($dryRun) {
                 $created += $customers->count();
                 $created += $customers->count();
                 $addressesImported += $addresses->flatten(1)->count();
                 $addressesImported += $addresses->flatten(1)->count();
                 $this->line(sprintf(
                 $this->line(sprintf(
-                    '  Batch #%d: %d customers, %d addresses (last entity_id=%d) [skipped – dry-run]',
+                    '  Batch #%d: %d customers, %d addresses (last entity_id=%d) [skipped – dry-run] (%.1fs)',
                     $batchNumber,
                     $batchNumber,
                     $customers->count(),
                     $customers->count(),
                     $addresses->flatten(1)->count(),
                     $addresses->flatten(1)->count(),
-                    $lastId
+                    $lastId,
+                    microtime(true) - $started
                 ));
                 ));
 
 
                 continue;
                 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'];
-                }
-            });
+            $result = $this->persistBatch($customers, $addresses, $subscribers);
 
 
             Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
             Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
 
 
-            $created += $batchCreated;
-            $linked += $batchLinked;
-            $skipped += $batchSkipped;
-            $addressesImported += $batchAddresses;
+            $created += $result['created'];
+            $linked += $result['linked'];
+            $skipped += $result['skipped'];
+            $addressesImported += $result['addresses'];
 
 
             $this->line(sprintf(
             $this->line(sprintf(
-                '  Batch #%d: created=%d linked=%d skipped=%d addresses=%d (last entity_id=%d)',
+                '  Batch #%d: created=%d linked=%d skipped=%d addresses=%d (last entity_id=%d) (%.1fs)',
                 $batchNumber,
                 $batchNumber,
-                $batchCreated,
-                $batchLinked,
-                $batchSkipped,
-                $batchAddresses,
-                $lastId
+                $result['created'],
+                $result['linked'],
+                $result['skipped'],
+                $result['addresses'],
+                $lastId,
+                microtime(true) - $started
             ));
             ));
 
 
             Log::info('MigrateAsteriaCustomers: batch '.$batchNumber.', last_id='.$lastId);
             Log::info('MigrateAsteriaCustomers: batch '.$batchNumber.', last_id='.$lastId);
         } while ($customers->count() === $batchSize);
         } while ($customers->count() === $batchSize);
 
 
+        $subscriptions = $this->importAllNewsletterSubscribers($reader, $batchSize, $dryRun);
+
         $this->newLine();
         $this->newLine();
-        $this->info("Done. Batches: {$batchNumber}, created: {$created}, linked: {$linked}, skipped: {$skipped}, addresses: {$addressesImported}.");
+        $this->info("Done. Batches: {$batchNumber}, created: {$created}, linked: {$linked}, skipped: {$skipped}, addresses: {$addressesImported}, subscriptions: {$subscriptions}.");
 
 
         return self::SUCCESS;
         return self::SUCCESS;
     }
     }
 
 
     /**
     /**
-     * @param  array<string, mixed>  $row
-     * @param  \Illuminate\Support\Collection<int, array<string, mixed>>  $addressRows
-     * @param  array<string, int>  $usedPhones
+     * @param  Collection<int, array<string, mixed>>  $customers
+     * @param  Collection<int, Collection<int, array<string, mixed>>>  $addresses
+     * @param  Collection<int, array<string, mixed>>  $subscribers
      * @return array{created: int, linked: int, skipped: int, addresses: int}
      * @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'];
+    private function persistBatch(Collection $customers, Collection $addresses, Collection $subscribers): array
+    {
+        $now = now()->format('Y-m-d H:i:s');
+        $created = 0;
+        $linked = 0;
+        $skipped = 0;
+        $importedAddresses = 0;
+        [$subscribersByCustomerId, $subscribersByEmail] = $this->indexLatestSubscribers($subscribers);
+
+        $asteriaIds = $customers->pluck('entity_id')->map(fn ($id) => (int) $id)->unique()->values()->all();
+        $emails = $customers
+            ->pluck('email')
+            ->map(fn ($email) => strtolower(trim((string) $email)))
+            ->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL))
+            ->unique()
+            ->values()
+            ->all();
 
 
-        if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
-            return ['created' => 0, 'linked' => 0, 'skipped' => 1, 'addresses' => 0];
-        }
+        [$byAsteriaId, $byEmail] = $this->loadExistingCustomers($asteriaIds, $emails);
+
+        $insertCustomers = [];
+        $linkUpdates = [];
+        $sourceUpdates = [];
+        $addressJobs = [];
+        $seenEmails = [];
 
 
-        $existing = Customer::query()
-            ->where(function ($query) use ($asteriaId, $email) {
-                $query->where('migrated_from_asteria_id', $asteriaId)
-                    ->orWhereRaw('LOWER(email) = ?', [$email]);
-            })
-            ->first();
+        foreach ($customers as $row) {
+            $email = strtolower(trim((string) ($row['email'] ?? '')));
+            $asteriaId = (int) $row['entity_id'];
 
 
-        if ($existing) {
-            if (! $existing->migrated_from_asteria_id) {
-                $existing->migrated_from_asteria_id = $asteriaId;
-                $existing->save();
+            if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                $skipped++;
+
+                continue;
             }
             }
 
 
-            $imported = $this->importAddresses($existing, $row, $addressRows);
+            $existing = $byAsteriaId[$asteriaId] ?? $byEmail[$email] ?? null;
+
+            if ($existing || isset($seenEmails[$email])) {
+                if ($existing && empty($existing->migrated_from_asteria_id)) {
+                    $linkUpdates[(int) $existing->id] = $asteriaId;
+                    $existing->migrated_from_asteria_id = $asteriaId;
+                    $byAsteriaId[$asteriaId] = $existing;
+                }
+
+                if ($existing) {
+                    $addressJobs[] = ['customer' => $existing, 'row' => $row];
+                    $linked++;
+                    $source = $this->mapCustomerSource($row['source'] ?? $row['customer_source'] ?? null);
+
+                    if ($source !== null) {
+                        $sourceUpdates[(int) $existing->id] = $source;
+                    }
+                } else {
+                    $skipped++;
+                }
+
+                continue;
+            }
 
 
-            return ['created' => 0, 'linked' => 1, 'skipped' => 0, 'addresses' => $imported];
+            $seenEmails[$email] = true;
+            $phone = $this->uniquePhone((string) ($row['telephone'] ?? ''), $this->usedPhones);
+            $subscriber = $subscribersByCustomerId[$asteriaId] ?? $subscribersByEmail[$email] ?? null;
+
+            $insertCustomers[$asteriaId] = [
+                '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'                  => $this->placeholderPassword,
+                'legacy_password'           => $this->nullableString($row['password_hash'] ?? null),
+                'api_token'                 => Str::random(80),
+                'customer_group_id'         => $this->groupId,
+                'channel_id'                => $this->channelId,
+                'customer_source'           => $this->mapCustomerSource($row['source'] ?? $row['customer_source'] ?? null),
+                'subscribed_to_news_letter' => $this->isMagentoSubscribed($subscriber) ? 1 : 0,
+                'status'                    => ((int) ($row['is_active'] ?? 1)) === 1 ? 1 : 0,
+                'is_verified'               => 1,
+                'is_suspended'              => 0,
+                'created_at'                => ! empty($row['created_at']) ? $row['created_at'] : $now,
+                'updated_at'                => $now,
+            ];
+
+            $addressJobs[] = ['asteria_id' => $asteriaId, 'row' => $row, 'email' => $email, 'first_name' => $insertCustomers[$asteriaId]['first_name'], 'last_name' => $insertCustomers[$asteriaId]['last_name'], 'phone' => $phone];
+            $created++;
         }
         }
 
 
-        $phone = $this->uniquePhone((string) ($row['telephone'] ?? ''), $usedPhones);
+        DB::transaction(function () use (
+            $insertCustomers,
+            $linkUpdates,
+            $sourceUpdates,
+            $addressJobs,
+            $addresses,
+            $subscribers,
+            &$importedAddresses
+        ) {
+            if ($insertCustomers !== []) {
+                $this->insertRows('customers', array_values($insertCustomers));
+
+                $inserted = DB::table('customers')
+                    ->whereIn('migrated_from_asteria_id', array_keys($insertCustomers))
+                    ->get(['id', 'migrated_from_asteria_id', 'email', 'first_name', 'last_name', 'phone']);
+
+                $byInsertedAsteriaId = $inserted->keyBy(fn ($row) => (int) $row->migrated_from_asteria_id);
+            } else {
+                $byInsertedAsteriaId = collect();
+            }
+
+            foreach ($linkUpdates as $customerId => $asteriaId) {
+                DB::table('customers')
+                    ->where('id', $customerId)
+                    ->whereNull('migrated_from_asteria_id')
+                    ->update(['migrated_from_asteria_id' => $asteriaId]);
+            }
+
+            foreach ($sourceUpdates as $customerId => $source) {
+                DB::table('customers')
+                    ->where('id', $customerId)
+                    ->update(['customer_source' => $source]);
+            }
+
+            $customerIdsForAddresses = [];
 
 
-        $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,
-        ]);
+            foreach ($addressJobs as $job) {
+                if (isset($job['customer'])) {
+                    $customerIdsForAddresses[] = (int) $job['customer']->id;
+                } elseif (isset($job['asteria_id'])) {
+                    $inserted = $byInsertedAsteriaId->get((int) $job['asteria_id']);
+
+                    if ($inserted) {
+                        $customerIdsForAddresses[] = (int) $inserted->id;
+                    }
+                }
+            }
+
+            $existingAddressIds = $this->loadExistingAddressIds($customerIdsForAddresses);
+            $addressInserts = [];
+
+            foreach ($addressJobs as $job) {
+                $row = $job['row'];
+                $addressRows = $addresses->get((int) $row['entity_id'], collect());
+
+                if ($addressRows->isEmpty()) {
+                    continue;
+                }
+
+                if (isset($job['customer'])) {
+                    $customerId = (int) $job['customer']->id;
+                    $email = (string) $job['customer']->email;
+                    $firstName = (string) $job['customer']->first_name;
+                    $lastName = (string) $job['customer']->last_name;
+                    $phone = $job['customer']->phone;
+                } else {
+                    $inserted = $byInsertedAsteriaId->get((int) $job['asteria_id']);
+
+                    if (! $inserted) {
+                        continue;
+                    }
+
+                    $customerId = (int) $inserted->id;
+                    $email = (string) $inserted->email;
+                    $firstName = (string) $inserted->first_name;
+                    $lastName = (string) $inserted->last_name;
+                    $phone = $inserted->phone;
+                }
 
 
-        if (! empty($row['created_at'])) {
-            $customer->created_at = $row['created_at'];
+                $existingIds = $existingAddressIds[$customerId] ?? [];
+
+                foreach ($this->buildAddressRows($customerId, $row, $addressRows, $email, $firstName, $lastName, $phone, $existingIds) as $addressRow) {
+                    $addressInserts[] = $addressRow;
+                    $importedAddresses++;
+                }
+            }
+
+            if ($addressInserts !== []) {
+                $this->insertRows('addresses', $addressInserts);
+            }
+
+            $this->persistSubscriberRows($subscribers);
+        });
+
+        return [
+            'created'   => $created,
+            'linked'    => $linked,
+            'skipped'   => $skipped,
+            'addresses' => $importedAddresses,
+        ];
+    }
+
+    private function importAllNewsletterSubscribers(Magento1CustomerReader $reader, int $batchSize, bool $dryRun): int
+    {
+        if (! Schema::hasTable('subscribers_list')) {
+            $this->warn('subscribers_list is missing; skipping newsletter import.');
+
+            return 0;
         }
         }
 
 
-        $customer->save();
+        $synced = 0;
+        $lastId = 0;
 
 
-        $imported = $this->importAddresses($customer, $row, $addressRows);
+        do {
+            $rows = $reader->fetchSubscribersAfter($lastId, $batchSize);
 
 
-        return ['created' => 1, 'linked' => 0, 'skipped' => 0, 'addresses' => $imported];
+            if ($rows->isEmpty()) {
+                break;
+            }
+
+            $lastId = (int) $rows->max('subscriber_id');
+            $synced += $this->persistSubscriberRows($rows, $dryRun);
+        } while ($rows->count() === $batchSize);
+
+        return $synced;
     }
     }
 
 
     /**
     /**
-     * @param  array<string, mixed>  $customerRow
-     * @param  \Illuminate\Support\Collection<int, array<string, mixed>>  $addressRows
+     * @param  Collection<int, array<string, mixed>>  $subscribers
      */
      */
-    private function importAddresses(Customer $customer, array $customerRow, $addressRows): int
+    private function persistSubscriberRows(Collection $subscribers, bool $dryRun = false): int
     {
     {
-        if ($addressRows->isEmpty()) {
+        if ($subscribers->isEmpty()) {
             return 0;
             return 0;
         }
         }
 
 
-        $existingIds = $customer->addresses()
-            ->get()
-            ->map(fn (CustomerAddress $address) => $this->asteriaAddressId($address))
-            ->filter()
-            ->all();
+        [$byCustomerId, $byEmail] = $this->indexLatestSubscribers($subscribers);
+
+        if ($byCustomerId === [] && $byEmail === []) {
+            return 0;
+        }
+
+        [$customersByAsteriaId, $customersByEmail] = $this->loadCustomersForSubscribers(
+            array_keys($byCustomerId),
+            array_keys($byEmail)
+        );
 
 
+        $flagOn = [];
+        $flagOff = [];
+
+        foreach ($byCustomerId as $asteriaId => $sub) {
+            $customer = $customersByAsteriaId[$asteriaId] ?? null;
+
+            if (! $customer) {
+                continue;
+            }
+
+            if ($this->isMagentoSubscribed($sub)) {
+                $flagOn[(int) $customer->id] = true;
+            } else {
+                $flagOff[(int) $customer->id] = true;
+            }
+        }
+
+        foreach ($byEmail as $email => $sub) {
+            $customer = $customersByEmail[$email] ?? null;
+
+            if (! $customer || isset($flagOn[(int) $customer->id]) || isset($flagOff[(int) $customer->id])) {
+                continue;
+            }
+
+            if ($this->isMagentoSubscribed($sub)) {
+                $flagOn[(int) $customer->id] = true;
+            } else {
+                $flagOff[(int) $customer->id] = true;
+            }
+        }
+
+        $listRows = [];
+
+        foreach ($byEmail as $email => $sub) {
+            if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                continue;
+            }
+
+            $customer = $customersByEmail[$email] ?? null;
+
+            if (! $customer) {
+                $asteriaId = (int) ($sub['customer_id'] ?? 0);
+                $customer = $asteriaId > 0 ? ($customersByAsteriaId[$asteriaId] ?? null) : null;
+            }
+
+            if (! $customer && ! $this->isMagentoSubscribed($sub)) {
+                continue;
+            }
+
+            $listRows[$email] = [
+                'email'         => $email,
+                'is_subscribed' => $this->isMagentoSubscribed($sub) ? 1 : 0,
+                'token'         => $this->nullableString($sub['subscriber_confirm_code'] ?? null),
+                'customer_id'   => $customer ? (int) $customer->id : null,
+                'channel_id'    => $customer?->channel_id ?? $this->channelId,
+                'changed_at'    => $sub['change_status_at'] ?? null,
+            ];
+        }
+
+        $synced = count($listRows);
+
+        if ($dryRun) {
+            return $synced;
+        }
+
+        $flagOffIds = array_keys(array_diff_key($flagOff, $flagOn));
+        $flagOnIds = array_keys($flagOn);
+
+        if ($flagOffIds !== []) {
+            DB::table('customers')->whereIn('id', $flagOffIds)->update(['subscribed_to_news_letter' => 0]);
+        }
+
+        if ($flagOnIds !== []) {
+            DB::table('customers')->whereIn('id', $flagOnIds)->update(['subscribed_to_news_letter' => 1]);
+        }
+
+        if ($listRows === []) {
+            return 0;
+        }
+
+        $existing = $this->loadExistingSubscriberLists(array_keys($listRows));
+        $now = now()->format('Y-m-d H:i:s');
+        $inserts = [];
+
+        foreach ($listRows as $email => $row) {
+            $existingRow = $existing[$email] ?? null;
+
+            if ($existingRow) {
+                DB::table('subscribers_list')->where('id', $existingRow->id)->update([
+                    'is_subscribed' => $row['is_subscribed'],
+                    'customer_id'   => $row['customer_id'] ?? $existingRow->customer_id,
+                    'updated_at'    => $now,
+                ]);
+
+                continue;
+            }
+
+            $inserts[] = [
+                'email'         => $row['email'],
+                'is_subscribed' => $row['is_subscribed'],
+                'token'         => $row['token'] ?? uniqid(),
+                'customer_id'   => $row['customer_id'],
+                'channel_id'    => $row['channel_id'] ?? $this->channelId,
+                'created_at'    => $this->mapDateTime($row['changed_at'] ?? null) ?? $now,
+                'updated_at'    => $now,
+            ];
+        }
+
+        if ($inserts !== []) {
+            $this->insertRows('subscribers_list', $inserts);
+        }
+
+        return $synced;
+    }
+
+    /**
+     * @param  Collection<int, array<string, mixed>>  $subscribers
+     * @return array{0: array<int, array<string, mixed>>, 1: array<string, array<string, mixed>>}
+     */
+    private function indexLatestSubscribers(Collection $subscribers): array
+    {
+        $byCustomerId = [];
+        $byEmail = [];
+
+        foreach ($subscribers as $sub) {
+            $customerId = (int) ($sub['customer_id'] ?? 0);
+            $email = strtolower(trim((string) ($sub['subscriber_email'] ?? '')));
+
+            if ($customerId > 0) {
+                $byCustomerId[$customerId] = $this->newerSubscriber($byCustomerId[$customerId] ?? null, $sub);
+            }
+
+            if ($email !== '') {
+                $byEmail[$email] = $this->newerSubscriber($byEmail[$email] ?? null, $sub);
+            }
+        }
+
+        return [$byCustomerId, $byEmail];
+    }
+
+    /**
+     * @param  array<string, mixed>|null  $current
+     * @param  array<string, mixed>  $candidate
+     * @return array<string, mixed>
+     */
+    private function newerSubscriber(?array $current, array $candidate): array
+    {
+        if ($current === null) {
+            return $candidate;
+        }
+
+        $currentAt = (string) ($current['change_status_at'] ?? '');
+        $candidateAt = (string) ($candidate['change_status_at'] ?? '');
+
+        if ($candidateAt !== $currentAt) {
+            return $candidateAt > $currentAt ? $candidate : $current;
+        }
+
+        return ((int) ($candidate['subscriber_id'] ?? 0)) >= ((int) ($current['subscriber_id'] ?? 0))
+            ? $candidate
+            : $current;
+    }
+
+    /**
+     * @param  array<string, mixed>|null  $subscriber
+     */
+    private function isMagentoSubscribed(?array $subscriber): bool
+    {
+        return $subscriber !== null
+            && ((int) ($subscriber['subscriber_status'] ?? 0)) === self::MAGENTO_NEWSLETTER_SUBSCRIBED;
+    }
+
+    /**
+     * @param  array<int, int>  $asteriaIds
+     * @param  array<int, string>  $emails
+     * @return array{0: array<int, object>, 1: array<string, object>}
+     */
+    private function loadCustomersForSubscribers(array $asteriaIds, array $emails): array
+    {
+        $asteriaIds = array_values(array_unique(array_filter(array_map('intval', $asteriaIds))));
+        $emails = array_values(array_unique(array_filter($emails)));
+
+        $byAsteriaId = [];
+        $byEmail = [];
+        $select = ['id', 'email', 'channel_id', 'migrated_from_asteria_id'];
+
+        if ($asteriaIds !== []) {
+            foreach (DB::table('customers')->select($select)->whereIn('migrated_from_asteria_id', $asteriaIds)->get() as $customer) {
+                $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $customer;
+                $byEmail[strtolower((string) $customer->email)] = $customer;
+            }
+        }
+
+        if ($emails !== []) {
+            foreach (DB::table('customers')->select($select)->whereIn(DB::raw('LOWER(email)'), $emails)->get() as $customer) {
+                $byEmail[strtolower((string) $customer->email)] = $byEmail[strtolower((string) $customer->email)] ?? $customer;
+
+                if ($customer->migrated_from_asteria_id) {
+                    $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $byAsteriaId[(int) $customer->migrated_from_asteria_id] ?? $customer;
+                }
+            }
+        }
+
+        return [$byAsteriaId, $byEmail];
+    }
+
+    /**
+     * @param  array<int, string>  $emails
+     * @return array<string, object>
+     */
+    private function loadExistingSubscriberLists(array $emails): array
+    {
+        $emails = array_values(array_unique(array_filter($emails)));
+
+        if ($emails === []) {
+            return [];
+        }
+
+        $byEmail = [];
+
+        foreach (DB::table('subscribers_list')->whereIn(DB::raw('LOWER(email)'), $emails)->get(['id', 'email', 'customer_id', 'token']) as $row) {
+            $byEmail[strtolower((string) $row->email)] = $row;
+        }
+
+        return $byEmail;
+    }
+
+    private function mapDateTime(mixed $value): ?string
+    {
+        $value = trim((string) $value);
+
+        if ($value === '' || str_starts_with($value, '0000-00-00')) {
+            return null;
+        }
+
+        return $value;
+    }
+
+    /**
+     * @param  array<int, int>  $asteriaIds
+     * @param  array<int, string>  $emails
+     * @return array{0: array<int, object>, 1: array<string, object>}
+     */
+    private function loadExistingCustomers(array $asteriaIds, array $emails): array
+    {
+        $byAsteriaId = [];
+        $byEmail = [];
+
+        $select = ['id', 'email', 'first_name', 'last_name', 'phone', 'migrated_from_asteria_id', 'legacy_password'];
+
+        if ($asteriaIds !== []) {
+            foreach (DB::table('customers')->select($select)->whereIn('migrated_from_asteria_id', $asteriaIds)->get() as $customer) {
+                $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $customer;
+                $byEmail[strtolower((string) $customer->email)] = $customer;
+            }
+        }
+
+        if ($emails !== []) {
+            foreach (DB::table('customers')->select($select)->whereIn(DB::raw('LOWER(email)'), $emails)->get() as $customer) {
+                $byEmail[strtolower((string) $customer->email)] = $byEmail[strtolower((string) $customer->email)] ?? $customer;
+
+                if ($customer->migrated_from_asteria_id) {
+                    $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $byAsteriaId[(int) $customer->migrated_from_asteria_id] ?? $customer;
+                }
+            }
+        }
+
+        return [$byAsteriaId, $byEmail];
+    }
+
+    /**
+     * @param  array<int, int>  $customerIds
+     * @return array<int, array<int, int>>
+     */
+    private function loadExistingAddressIds(array $customerIds): array
+    {
+        $customerIds = array_values(array_unique(array_filter($customerIds)));
+
+        if ($customerIds === []) {
+            return [];
+        }
+
+        $map = [];
+
+        $rows = DB::table('addresses')
+            ->where('address_type', CustomerAddress::ADDRESS_TYPE)
+            ->whereIn('customer_id', $customerIds)
+            ->get(['customer_id', 'additional']);
+
+        foreach ($rows as $row) {
+            $asteriaId = $this->asteriaAddressIdFromAdditional($row->additional);
+
+            if ($asteriaId) {
+                $map[(int) $row->customer_id][] = $asteriaId;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * @param  Collection<int, array<string, mixed>>  $addressRows
+     * @param  array<int, int>  $existingIds
+     * @return array<int, array<string, mixed>>
+     */
+    private function buildAddressRows(
+        int $customerId,
+        array $customerRow,
+        $addressRows,
+        string $email,
+        string $firstName,
+        string $lastName,
+        mixed $phone,
+        array $existingIds
+    ): array {
         $defaultBilling = (int) ($customerRow['default_billing'] ?? 0);
         $defaultBilling = (int) ($customerRow['default_billing'] ?? 0);
         $defaultShipping = (int) ($customerRow['default_shipping'] ?? 0);
         $defaultShipping = (int) ($customerRow['default_shipping'] ?? 0);
-        $imported = 0;
+        $now = now()->format('Y-m-d H:i:s');
+        $rows = [];
 
 
         foreach ($addressRows as $row) {
         foreach ($addressRows as $row) {
             $asteriaAddressId = (int) $row['entity_id'];
             $asteriaAddressId = (int) $row['entity_id'];
@@ -278,30 +755,38 @@ class MigrateAsteriaCustomers extends Command
                 continue;
                 continue;
             }
             }
 
 
-            $address = new CustomerAddress;
-            $address->forceFill([
-                'customer_id'      => $customer->id,
+            $rows[] = [
+                'customer_id'      => $customerId,
                 'address_type'     => CustomerAddress::ADDRESS_TYPE,
                 'address_type'     => CustomerAddress::ADDRESS_TYPE,
-                'first_name'       => $this->requiredName($row['firstname'] ?? null, $customer->first_name),
-                'last_name'        => trim((string) ($row['lastname'] ?? '')) ?: $customer->last_name,
+                'first_name'       => $this->requiredName($row['firstname'] ?? null, $firstName),
+                'last_name'        => trim((string) ($row['lastname'] ?? '')) ?: $lastName,
                 'company_name'     => $this->nullableString($row['company'] ?? null),
                 'company_name'     => $this->nullableString($row['company'] ?? null),
                 'address'          => $this->mapStreet($row['street'] ?? null) ?: '-',
                 'address'          => $this->mapStreet($row['street'] ?? null) ?: '-',
                 'city'             => trim((string) ($row['city'] ?? '')) ?: '-',
                 'city'             => trim((string) ($row['city'] ?? '')) ?: '-',
                 'state'            => $this->nullableString($row['region'] ?? null),
                 'state'            => $this->nullableString($row['region'] ?? null),
                 'country'          => $this->nullableString($row['country_id'] ?? null),
                 'country'          => $this->nullableString($row['country_id'] ?? null),
                 'postcode'         => $this->nullableString($row['postcode'] ?? 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,
+                'email'            => $email,
+                'phone'            => $this->nullableString($row['telephone'] ?? null) ?? $phone,
+                'default_address'  => $defaultBilling > 0 && $asteriaAddressId === $defaultBilling ? 1 : 0,
+                'use_for_shipping' => $defaultShipping > 0 && $asteriaAddressId === $defaultShipping ? 1 : 0,
                 'additional'       => json_encode(['asteria_address_id' => $asteriaAddressId]),
                 'additional'       => json_encode(['asteria_address_id' => $asteriaAddressId]),
-            ]);
-            $address->save();
-
-            $imported++;
+                'created_at'       => $now,
+                'updated_at'       => $now,
+            ];
         }
         }
 
 
-        return $imported;
+        return $rows;
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $rows
+     */
+    private function insertRows(string $table, array $rows): void
+    {
+        foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
+            DB::table($table)->insert($chunk);
+        }
     }
     }
 
 
     /**
     /**
@@ -335,6 +820,21 @@ class MigrateAsteriaCustomers extends Command
         };
         };
     }
     }
 
 
+    /**
+     * Magento 1 stores `source` as a free-text varchar attribute (e.g. "popup"),
+     * so the raw value is kept as-is rather than coerced to a code.
+     */
+    private function mapCustomerSource(mixed $value): ?string
+    {
+        if (! is_scalar($value)) {
+            return null;
+        }
+
+        $value = trim((string) $value);
+
+        return $value === '' ? null : mb_substr($value, 0, 64);
+    }
+
     private function mapDate(mixed $value): ?string
     private function mapDate(mixed $value): ?string
     {
     {
         $value = trim((string) $value);
         $value = trim((string) $value);
@@ -379,10 +879,8 @@ class MigrateAsteriaCustomers extends Command
         return $value === '' ? null : $value;
         return $value === '' ? null : $value;
     }
     }
 
 
-    private function asteriaAddressId(CustomerAddress $address): ?int
+    private function asteriaAddressIdFromAdditional(mixed $additional): ?int
     {
     {
-        $additional = $address->additional;
-
         if (is_string($additional) && $additional !== '') {
         if (is_string($additional) && $additional !== '') {
             $additional = json_decode($additional, true);
             $additional = json_decode($additional, true);
         }
         }

+ 462 - 266
app/Console/Commands/MigrateAsteriaOrders.php

@@ -14,8 +14,6 @@ use Webkul\Customer\Models\Customer;
 use Webkul\Product\Models\Product;
 use Webkul\Product\Models\Product;
 use Webkul\Sales\Models\Order;
 use Webkul\Sales\Models\Order;
 use Webkul\Sales\Models\OrderAddress;
 use Webkul\Sales\Models\OrderAddress;
-use Webkul\Sales\Models\OrderItem;
-use Webkul\Sales\Models\OrderPayment;
 
 
 /**
 /**
  * Migrates storefront orders from Asteria (Magento 1.x).
  * Migrates storefront orders from Asteria (Magento 1.x).
@@ -33,7 +31,7 @@ use Webkul\Sales\Models\OrderPayment;
 class MigrateAsteriaOrders extends Command
 class MigrateAsteriaOrders extends Command
 {
 {
     protected $signature = 'orders:migrate-asteria
     protected $signature = 'orders:migrate-asteria
-        {--batch-size=100     : Number of Magento orders per batch}
+        {--batch-size=500     : Number of Magento orders per batch}
         {--reset-progress     : Ignore saved progress and start from entity_id=0}
         {--reset-progress     : Ignore saved progress and start from entity_id=0}
         {--dry-run            : Count records without writing}
         {--dry-run            : Count records without writing}
         {--connection=asteria : Laravel DB connection for the Asteria database}';
         {--connection=asteria : Laravel DB connection for the Asteria database}';
@@ -51,6 +49,11 @@ class MigrateAsteriaOrders extends Command
         'grouped',
         'grouped',
     ];
     ];
 
 
+    private const INSERT_CHUNK = 200;
+
+    /** @var array<string, bool>|null */
+    private ?array $destinationOrderColumns = null;
+
     public function handle(): int
     public function handle(): int
     {
     {
         $connection = (string) $this->option('connection');
         $connection = (string) $this->option('connection');
@@ -58,6 +61,8 @@ class MigrateAsteriaOrders extends Command
         $resetProgress = (bool) $this->option('reset-progress');
         $resetProgress = (bool) $this->option('reset-progress');
         $dryRun = (bool) $this->option('dry-run');
         $dryRun = (bool) $this->option('dry-run');
 
 
+        DB::disableQueryLog();
+
         try {
         try {
             DB::connection($connection)->getPdo();
             DB::connection($connection)->getPdo();
         } catch (\Throwable $e) {
         } catch (\Throwable $e) {
@@ -85,6 +90,14 @@ class MigrateAsteriaOrders extends Command
             return self::FAILURE;
             return self::FAILURE;
         }
         }
 
 
+        if (! Schema::hasColumn('orders', 'reward_points_used')) {
+            $this->warn('orders.reward_points_used is missing. Run php artisan migrate to import order reward points.');
+        }
+
+        if (! Schema::hasColumn('orders', 'shipping_insurance_amount')) {
+            $this->warn('orders.shipping_insurance_amount is missing. Run php artisan migrate to import lost-package insurance.');
+        }
+
         $channel = core()->getDefaultChannel() ?? core()->getCurrentChannel();
         $channel = core()->getDefaultChannel() ?? core()->getCurrentChannel();
 
 
         if (! $channel) {
         if (! $channel) {
@@ -113,6 +126,7 @@ class MigrateAsteriaOrders extends Command
         $this->info($dryRun ? '[DRY RUN] Scanning Magento orders…' : 'Migrating Magento orders…');
         $this->info($dryRun ? '[DRY RUN] Scanning Magento orders…' : 'Migrating Magento orders…');
 
 
         do {
         do {
+            $started = microtime(true);
             $orders = $reader->fetchOrders($lastId, $batchSize);
             $orders = $reader->fetchOrders($lastId, $batchSize);
 
 
             if ($orders->isEmpty()) {
             if ($orders->isEmpty()) {
@@ -126,18 +140,20 @@ class MigrateAsteriaOrders extends Command
             $items = $reader->fetchItems($orderIds)->groupBy(fn (array $row) => (int) $row['order_id']);
             $items = $reader->fetchItems($orderIds)->groupBy(fn (array $row) => (int) $row['order_id']);
             $addresses = $reader->fetchAddresses($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
             $addresses = $reader->fetchAddresses($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
             $payments = $reader->fetchPayments($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
             $payments = $reader->fetchPayments($orderIds)->groupBy(fn (array $row) => (int) $row['parent_id']);
+            $rewardPoints = $reader->fetchRewardPoints($orderIds)->keyBy(fn (array $row) => (int) $row['order_id']);
 
 
             if ($dryRun) {
             if ($dryRun) {
                 $created += $orders->count();
                 $created += $orders->count();
                 $itemsImported += $items->flatten(1)->count();
                 $itemsImported += $items->flatten(1)->count();
                 $addressesImported += $addresses->flatten(1)->count();
                 $addressesImported += $addresses->flatten(1)->count();
                 $this->line(sprintf(
                 $this->line(sprintf(
-                    '  Batch #%d: %d orders, %d items, %d addresses (last entity_id=%d) [skipped – dry-run]',
+                    '  Batch #%d: %d orders, %d items, %d addresses (last entity_id=%d) [skipped – dry-run] (%.1fs)',
                     $batchNumber,
                     $batchNumber,
                     $orders->count(),
                     $orders->count(),
                     $items->flatten(1)->count(),
                     $items->flatten(1)->count(),
                     $addresses->flatten(1)->count(),
                     $addresses->flatten(1)->count(),
-                    $lastId
+                    $lastId,
+                    microtime(true) - $started
                 ));
                 ));
 
 
                 continue;
                 continue;
@@ -146,59 +162,34 @@ class MigrateAsteriaOrders extends Command
             [$customersByAsteriaId, $customersByEmail] = $this->loadCustomers($orders);
             [$customersByAsteriaId, $customersByEmail] = $this->loadCustomers($orders);
             $productsBySku = $this->loadProducts($items);
             $productsBySku = $this->loadProducts($items);
 
 
-            $batchCreated = 0;
-            $batchSkipped = 0;
-            $batchItems = 0;
-            $batchAddresses = 0;
-
-            DB::transaction(function () use (
+            $result = $this->persistBatch(
                 $orders,
                 $orders,
                 $items,
                 $items,
                 $addresses,
                 $addresses,
                 $payments,
                 $payments,
+                $rewardPoints,
                 $channel,
                 $channel,
                 $customersByAsteriaId,
                 $customersByAsteriaId,
                 $customersByEmail,
                 $customersByEmail,
-                $productsBySku,
-                &$batchCreated,
-                &$batchSkipped,
-                &$batchItems,
-                &$batchAddresses
-            ) {
-                foreach ($orders as $row) {
-                    $result = $this->migrateOrder(
-                        $row,
-                        $items->get((int) $row['entity_id'], collect()),
-                        $addresses->get((int) $row['entity_id'], collect()),
-                        $payments->get((int) $row['entity_id'], collect())->first(),
-                        $channel,
-                        $customersByAsteriaId,
-                        $customersByEmail,
-                        $productsBySku
-                    );
-
-                    $batchCreated += $result['created'];
-                    $batchSkipped += $result['skipped'];
-                    $batchItems += $result['items'];
-                    $batchAddresses += $result['addresses'];
-                }
-            });
+                $productsBySku
+            );
 
 
             Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
             Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
 
 
-            $created += $batchCreated;
-            $skipped += $batchSkipped;
-            $itemsImported += $batchItems;
-            $addressesImported += $batchAddresses;
+            $created += $result['created'];
+            $skipped += $result['skipped'];
+            $itemsImported += $result['items'];
+            $addressesImported += $result['addresses'];
 
 
             $this->line(sprintf(
             $this->line(sprintf(
-                '  Batch #%d: created=%d skipped=%d items=%d addresses=%d (last entity_id=%d)',
+                '  Batch #%d: created=%d skipped=%d items=%d addresses=%d (last entity_id=%d) (%.1fs)',
                 $batchNumber,
                 $batchNumber,
-                $batchCreated,
-                $batchSkipped,
-                $batchItems,
-                $batchAddresses,
-                $lastId
+                $result['created'],
+                $result['skipped'],
+                $result['items'],
+                $result['addresses'],
+                $lastId,
+                microtime(true) - $started
             ));
             ));
 
 
             Log::info('MigrateAsteriaOrders: batch '.$batchNumber.', last_id='.$lastId);
             Log::info('MigrateAsteriaOrders: batch '.$batchNumber.', last_id='.$lastId);
@@ -211,48 +202,208 @@ class MigrateAsteriaOrders extends Command
     }
     }
 
 
     /**
     /**
-     * @param  array<string, mixed>  $row
-     * @param  Collection<int, array<string, mixed>>  $itemRows
-     * @param  Collection<int, array<string, mixed>>  $addressRows
-     * @param  array<string, mixed>|null  $paymentRow
+     * @param  Collection<int, array<string, mixed>>  $orders
+     * @param  Collection<int, Collection<int, array<string, mixed>>>  $items
+     * @param  Collection<int, Collection<int, array<string, mixed>>>  $addresses
+     * @param  Collection<int, Collection<int, array<string, mixed>>>  $payments
+     * @param  Collection<int, array<string, mixed>>  $rewardPoints
      * @param  array<int, Customer>  $customersByAsteriaId
      * @param  array<int, Customer>  $customersByAsteriaId
      * @param  array<string, Customer>  $customersByEmail
      * @param  array<string, Customer>  $customersByEmail
      * @param  array<string, Product>  $productsBySku
      * @param  array<string, Product>  $productsBySku
      * @return array{created: int, skipped: int, items: int, addresses: int}
      * @return array{created: int, skipped: int, items: int, addresses: int}
      */
      */
-    private function migrateOrder(
-        array $row,
-        $itemRows,
-        $addressRows,
-        ?array $paymentRow,
+    private function persistBatch(
+        Collection $orders,
+        Collection $items,
+        Collection $addresses,
+        Collection $payments,
+        Collection $rewardPoints,
         Channel $channel,
         Channel $channel,
         array $customersByAsteriaId,
         array $customersByAsteriaId,
         array $customersByEmail,
         array $customersByEmail,
         array $productsBySku
         array $productsBySku
     ): array {
     ): array {
-        $asteriaId = (int) $row['entity_id'];
-        $incrementId = trim((string) ($row['increment_id'] ?? ''));
+        $asteriaIds = $orders->pluck('entity_id')->map(fn ($id) => (int) $id)->filter()->unique()->values()->all();
+        $incrementIds = $orders
+            ->pluck('increment_id')
+            ->map(fn ($id) => trim((string) $id))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
 
 
-        if ($asteriaId < 1 || $incrementId === '') {
-            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
-        }
+        $existingAsteria = $asteriaIds === []
+            ? []
+            : DB::table('orders')
+                ->whereIn('migrated_from_asteria_id', $asteriaIds)
+                ->pluck('migrated_from_asteria_id')
+                ->map(fn ($id) => (int) $id)
+                ->flip()
+                ->all();
+
+        $existingIncrements = $incrementIds === []
+            ? []
+            : DB::table('orders')
+                ->whereIn('increment_id', $incrementIds)
+                ->pluck('increment_id')
+                ->map(fn ($id) => (string) $id)
+                ->flip()
+                ->all();
+
+        $now = now()->format('Y-m-d H:i:s');
+        $orderInserts = [];
+        $pending = [];
+        $skipped = 0;
+        $seenIncrements = [];
 
 
-        $alreadyMigrated = Order::query()
-            ->where('migrated_from_asteria_id', $asteriaId)
-            ->exists();
+        foreach ($orders as $row) {
+            $asteriaId = (int) $row['entity_id'];
+            $incrementId = trim((string) ($row['increment_id'] ?? ''));
 
 
-        if ($alreadyMigrated) {
-            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
-        }
+            if ($asteriaId < 1 || $incrementId === ''
+                || isset($existingAsteria[$asteriaId])
+                || isset($existingIncrements[$incrementId])
+                || isset($seenIncrements[$incrementId])) {
+                $skipped++;
 
 
-        $incrementTaken = Order::query()
-            ->where('increment_id', $incrementId)
-            ->exists();
+                continue;
+            }
 
 
-        if ($incrementTaken) {
-            return ['created' => 0, 'skipped' => 1, 'items' => 0, 'addresses' => 0];
-        }
+            $seenIncrements[$incrementId] = true;
+            $orderInserts[] = $this->buildOrderRow(
+                $row,
+                $channel,
+                $customersByAsteriaId,
+                $customersByEmail,
+                $items->get($asteriaId, collect()),
+                $rewardPoints->get($asteriaId),
+                $now
+            );
+            $pending[] = $row;
+        }
+
+        $created = count($orderInserts);
+        $importedItems = 0;
+        $importedAddresses = 0;
+
+        if ($orderInserts === []) {
+            return ['created' => 0, 'skipped' => $skipped, 'items' => 0, 'addresses' => 0];
+        }
+
+        DB::transaction(function () use (
+            $orderInserts,
+            $pending,
+            $items,
+            $addresses,
+            $payments,
+            $productsBySku,
+            $customersByAsteriaId,
+            $customersByEmail,
+            &$importedItems,
+            &$importedAddresses
+        ) {
+            $this->insertRows('orders', $orderInserts);
+
+            $idMap = DB::table('orders')
+                ->whereIn('migrated_from_asteria_id', array_column($orderInserts, 'migrated_from_asteria_id'))
+                ->pluck('id', 'migrated_from_asteria_id')
+                ->mapWithKeys(fn ($id, $asteriaId) => [(int) $asteriaId => (int) $id])
+                ->all();
+
+            $paymentInserts = [];
+            $addressInserts = [];
+            $parentItemInserts = [];
+            $childItemRows = [];
+            $now = now()->format('Y-m-d H:i:s');
+
+            foreach ($pending as $row) {
+                $asteriaId = (int) $row['entity_id'];
+                $orderId = $idMap[$asteriaId] ?? null;
+
+                if (! $orderId) {
+                    continue;
+                }
+
+                $customer = $this->resolveCustomer($row, $customersByAsteriaId, $customersByEmail);
+                $email = strtolower(trim((string) ($row['customer_email'] ?? '')));
 
 
+                if ($email === '' && $customer) {
+                    $email = strtolower((string) $customer->email);
+                }
+
+                $paymentInserts[] = $this->buildPaymentRow($orderId, $payments->get($asteriaId, collect())->first(), $now);
+
+                foreach ($addresses->get($asteriaId, collect()) as $addressRow) {
+                    $addressInserts[] = $this->buildAddressRow($orderId, $addressRow, $customer, $email, $row, $now);
+                    $importedAddresses++;
+                }
+
+                $itemRows = $items->get($asteriaId, collect());
+                $parents = $itemRows->filter(fn (array $item) => empty($item['parent_item_id']));
+                $children = $itemRows->filter(fn (array $item) => ! empty($item['parent_item_id']));
+
+                foreach ($parents as $itemRow) {
+                    $parentItemInserts[] = $this->buildItemRow($orderId, $itemRow, null, $productsBySku, $now);
+                    $importedItems++;
+                }
+
+                foreach ($children as $itemRow) {
+                    $childItemRows[] = ['order_id' => $orderId, 'row' => $itemRow];
+                    $importedItems++;
+                }
+            }
+
+            if ($paymentInserts !== []) {
+                $this->insertRows('order_payment', $paymentInserts);
+            }
+
+            if ($addressInserts !== []) {
+                $this->insertRows('addresses', $addressInserts);
+            }
+
+            if ($parentItemInserts !== []) {
+                $this->insertRows('order_items', $parentItemInserts);
+            }
+
+            if ($childItemRows !== []) {
+                $itemIdMap = $this->loadInsertedItemIds(array_values($idMap));
+
+                $childInserts = [];
+
+                foreach ($childItemRows as $child) {
+                    $parentId = $itemIdMap[(int) $child['row']['parent_item_id']] ?? null;
+                    $childInserts[] = $this->buildItemRow($child['order_id'], $child['row'], $parentId, $productsBySku, $now);
+                }
+
+                $this->insertRows('order_items', $childInserts);
+            }
+        });
+
+        return [
+            'created'   => $created,
+            'skipped'   => $skipped,
+            'items'     => $importedItems,
+            'addresses' => $importedAddresses,
+        ];
+    }
+
+    /**
+     * @param  array<string, mixed>  $row
+     * @param  array<int, Customer>  $customersByAsteriaId
+     * @param  array<string, Customer>  $customersByEmail
+     * @param  Collection<int, array<string, mixed>>  $itemRows
+     * @param  array<string, mixed>|null  $rewardRow
+     * @return array<string, mixed>
+     */
+    private function buildOrderRow(
+        array $row,
+        Channel $channel,
+        array $customersByAsteriaId,
+        array $customersByEmail,
+        $itemRows,
+        ?array $rewardRow,
+        string $now
+    ): array {
         $customer = $this->resolveCustomer($row, $customersByAsteriaId, $customersByEmail);
         $customer = $this->resolveCustomer($row, $customersByAsteriaId, $customersByEmail);
         $email = strtolower(trim((string) ($row['customer_email'] ?? '')));
         $email = strtolower(trim((string) ($row['customer_email'] ?? '')));
 
 
@@ -286,93 +437,130 @@ class MigrateAsteriaOrders extends Command
             ? $this->money($row['base_shipping_incl_tax'])
             ? $this->money($row['base_shipping_incl_tax'])
             : $baseShippingAmount + $baseShippingTaxAmount;
             : $baseShippingAmount + $baseShippingTaxAmount;
 
 
-        $order = new Order;
-        $order->forceFill([
-            'migrated_from_asteria_id'         => $asteriaId,
-            'increment_id'                     => $incrementId,
-            'status'                           => $this->mapStatus($row),
-            'channel_name'                     => $channel->name,
-            'is_guest'                         => $customer ? 0 : 1,
-            'customer_email'                   => $email !== '' ? $email : null,
-            'customer_first_name'              => $this->requiredName($row['customer_firstname'] ?? null, $customer?->first_name ?? $email),
-            'customer_last_name'               => trim((string) ($row['customer_lastname'] ?? '')) ?: ($customer?->last_name ?? '-'),
-            'customer_id'                      => $customer?->id,
-            'customer_type'                    => $customer ? Customer::class : null,
-            'channel_id'                       => $channel->id,
-            'channel_type'                     => get_class($channel),
-            'cart_id'                          => null,
-            'shipping_method'                  => $this->nullableString($row['shipping_method'] ?? null),
-            'shipping_title'                   => $this->nullableString($row['shipping_description'] ?? null),
-            'shipping_description'             => $this->nullableString($row['shipping_description'] ?? null),
-            'coupon_code'                      => $this->nullableString($row['coupon_code'] ?? null),
-            'is_gift'                          => 0,
-            'total_item_count'                 => $this->qty($row['total_item_count'] ?? $itemRows->count()),
-            'total_qty_ordered'                => $this->qty($row['total_qty_ordered'] ?? $itemRows->sum(fn (array $item) => (float) ($item['qty_ordered'] ?? 0))),
-            'base_currency_code'               => $this->nullableString($row['base_currency_code'] ?? null) ?? 'USD',
-            'channel_currency_code'            => $this->nullableString($row['store_currency_code'] ?? null)
+        $insert = [
+            'migrated_from_asteria_id'      => (int) $row['entity_id'],
+            'increment_id'                  => trim((string) $row['increment_id']),
+            'status'                        => $this->mapStatus($row),
+            'channel_name'                  => $channel->name,
+            'is_guest'                      => $customer ? 0 : 1,
+            'customer_email'                => $email !== '' ? $email : null,
+            'customer_first_name'           => $this->requiredName($row['customer_firstname'] ?? null, $customer?->first_name ?? $email),
+            'customer_last_name'            => trim((string) ($row['customer_lastname'] ?? '')) ?: ($customer?->last_name ?? '-'),
+            'customer_id'                   => $customer?->id,
+            'customer_type'                 => $customer ? Customer::class : null,
+            'channel_id'                    => $channel->id,
+            'channel_type'                  => get_class($channel),
+            'cart_id'                       => null,
+            'shipping_method'               => $this->nullableString($row['shipping_method'] ?? null),
+            'shipping_title'                => $this->nullableString($row['shipping_description'] ?? null),
+            'shipping_description'          => $this->nullableString($row['shipping_description'] ?? null),
+            'coupon_code'                   => $this->nullableString($row['coupon_code'] ?? null),
+            'is_gift'                       => 0,
+            'total_item_count'              => $this->qty($row['total_item_count'] ?? $itemRows->count()),
+            'total_qty_ordered'             => $this->qty($row['total_qty_ordered'] ?? $itemRows->sum(fn (array $item) => (float) ($item['qty_ordered'] ?? 0))),
+            'base_currency_code'            => $this->nullableString($row['base_currency_code'] ?? null) ?? 'USD',
+            'channel_currency_code'         => $this->nullableString($row['store_currency_code'] ?? null)
                 ?? $this->nullableString($row['order_currency_code'] ?? null)
                 ?? $this->nullableString($row['order_currency_code'] ?? null)
                 ?? 'USD',
                 ?? 'USD',
-            'order_currency_code'              => $this->nullableString($row['order_currency_code'] ?? null) ?? 'USD',
-            'grand_total'                      => $grandTotal,
-            'base_grand_total'                 => $baseGrandTotal,
-            'grand_total_invoiced'             => $this->money($row['total_invoiced'] ?? 0),
-            'base_grand_total_invoiced'        => $this->money($row['base_total_invoiced'] ?? 0),
-            'grand_total_refunded'             => $this->money($row['total_refunded'] ?? 0),
-            'base_grand_total_refunded'        => $this->money($row['base_total_refunded'] ?? 0),
-            'sub_total'                        => $subTotal,
-            'base_sub_total'                   => $baseSubTotal,
-            'sub_total_incl_tax'               => $subTotalInclTax,
-            'base_sub_total_incl_tax'          => $baseSubTotalInclTax,
-            'sub_total_invoiced'               => $this->money($row['subtotal_invoiced'] ?? 0),
-            'base_sub_total_invoiced'          => $this->money($row['base_subtotal_invoiced'] ?? 0),
-            'sub_total_refunded'               => $this->money($row['subtotal_refunded'] ?? 0),
-            'base_sub_total_refunded'          => $this->money($row['base_subtotal_refunded'] ?? 0),
-            'discount_amount'                  => $discountAmount,
-            'base_discount_amount'             => $baseDiscountAmount,
-            'discount_invoiced'                => $this->money($row['discount_invoiced'] ?? 0),
-            'base_discount_invoiced'           => $this->money($row['base_discount_invoiced'] ?? 0),
-            'discount_refunded'                => $this->money($row['discount_refunded'] ?? 0),
-            'base_discount_refunded'           => $this->money($row['base_discount_refunded'] ?? 0),
-            'tax_amount'                       => $taxAmount,
-            'base_tax_amount'                  => $baseTaxAmount,
-            'tax_amount_invoiced'              => $this->money($row['tax_invoiced'] ?? 0),
-            'base_tax_amount_invoiced'         => $this->money($row['base_tax_invoiced'] ?? 0),
-            'tax_amount_refunded'              => $this->money($row['tax_refunded'] ?? 0),
-            'base_tax_amount_refunded'         => $this->money($row['base_tax_refunded'] ?? 0),
-            'shipping_amount'                  => $shippingAmount,
-            'base_shipping_amount'             => $baseShippingAmount,
-            'shipping_amount_incl_tax'         => $shippingInclTax,
-            'base_shipping_amount_incl_tax'    => $baseShippingInclTax,
-            'shipping_invoiced'                => $this->money($row['shipping_invoiced'] ?? 0),
-            'base_shipping_invoiced'           => $this->money($row['base_shipping_invoiced'] ?? 0),
-            'shipping_refunded'                => $this->money($row['shipping_refunded'] ?? 0),
-            'base_shipping_refunded'           => $this->money($row['base_shipping_refunded'] ?? 0),
-            'shipping_tax_amount'              => $shippingTaxAmount,
-            'base_shipping_tax_amount'         => $baseShippingTaxAmount,
-        ]);
-
-        if (! empty($row['created_at'])) {
-            $order->created_at = $row['created_at'];
-        }
-
-        $order->save();
-
-        $this->importPayment($order, $paymentRow);
-        $importedAddresses = $this->importAddresses($order, $addressRows, $customer, $email);
-        $importedItems = $this->importItems($order, $itemRows, $productsBySku);
+            'order_currency_code'           => $this->nullableString($row['order_currency_code'] ?? null) ?? 'USD',
+            'grand_total'                   => $grandTotal,
+            'base_grand_total'              => $baseGrandTotal,
+            'grand_total_invoiced'          => $this->money($row['total_invoiced'] ?? 0),
+            'base_grand_total_invoiced'     => $this->money($row['base_total_invoiced'] ?? 0),
+            'grand_total_refunded'          => $this->money($row['total_refunded'] ?? 0),
+            'base_grand_total_refunded'     => $this->money($row['base_total_refunded'] ?? 0),
+            'sub_total'                     => $subTotal,
+            'base_sub_total'                => $baseSubTotal,
+            'sub_total_incl_tax'            => $subTotalInclTax,
+            'base_sub_total_incl_tax'       => $baseSubTotalInclTax,
+            'sub_total_invoiced'            => $this->money($row['subtotal_invoiced'] ?? 0),
+            'base_sub_total_invoiced'       => $this->money($row['base_subtotal_invoiced'] ?? 0),
+            'sub_total_refunded'            => $this->money($row['subtotal_refunded'] ?? 0),
+            'base_sub_total_refunded'       => $this->money($row['base_subtotal_refunded'] ?? 0),
+            'discount_amount'               => $discountAmount,
+            'base_discount_amount'          => $baseDiscountAmount,
+            'discount_invoiced'             => $this->money($row['discount_invoiced'] ?? 0),
+            'base_discount_invoiced'        => $this->money($row['base_discount_invoiced'] ?? 0),
+            'discount_refunded'             => $this->money($row['discount_refunded'] ?? 0),
+            'base_discount_refunded'        => $this->money($row['base_discount_refunded'] ?? 0),
+            'tax_amount'                    => $taxAmount,
+            'base_tax_amount'               => $baseTaxAmount,
+            'tax_amount_invoiced'           => $this->money($row['tax_invoiced'] ?? 0),
+            'base_tax_amount_invoiced'      => $this->money($row['base_tax_invoiced'] ?? 0),
+            'tax_amount_refunded'           => $this->money($row['tax_refunded'] ?? 0),
+            'base_tax_amount_refunded'      => $this->money($row['base_tax_refunded'] ?? 0),
+            'shipping_amount'               => $shippingAmount,
+            'base_shipping_amount'          => $baseShippingAmount,
+            'shipping_amount_incl_tax'      => $shippingInclTax,
+            'base_shipping_amount_incl_tax' => $baseShippingInclTax,
+            'shipping_invoiced'             => $this->money($row['shipping_invoiced'] ?? 0),
+            'base_shipping_invoiced'        => $this->money($row['base_shipping_invoiced'] ?? 0),
+            'shipping_refunded'             => $this->money($row['shipping_refunded'] ?? 0),
+            'base_shipping_refunded'        => $this->money($row['base_shipping_refunded'] ?? 0),
+            'shipping_tax_amount'           => $shippingTaxAmount,
+            'base_shipping_tax_amount'      => $baseShippingTaxAmount,
+            'created_at'                    => ! empty($row['created_at']) ? $row['created_at'] : $now,
+            'updated_at'                    => $now,
+        ];
 
 
-        return [
-            'created'   => 1,
-            'skipped'   => 0,
-            'items'     => $importedItems,
-            'addresses' => $importedAddresses,
+        return $this->appendRewardAndInsurance($insert, $row, $rewardRow);
+    }
+
+    /**
+     * @param  array<string, mixed>  $insert
+     * @param  array<string, mixed>  $row
+     * @param  array<string, mixed>|null  $rewardRow
+     * @return array<string, mixed>
+     */
+    private function appendRewardAndInsurance(array $insert, array $row, ?array $rewardRow): array
+    {
+        $rewardRow ??= [];
+
+        if ($this->hasDestinationColumn('reward_points_used')) {
+            $usedFromOrder = (int) ($row['mw_rewardpoint'] ?? 0);
+            $usedFromHistory = (int) ($rewardRow['reward_point'] ?? 0);
+            $amountFromOrder = $this->absMoney($row['mw_rewardpoint_discount'] ?? 0);
+            $amountFromHistory = $this->absMoney($rewardRow['money'] ?? 0);
+
+            $insert['reward_points_used'] = max($usedFromOrder, $usedFromHistory);
+            $insert['reward_points_amount'] = $amountFromOrder > 0 ? $amountFromOrder : $amountFromHistory;
+            $insert['base_reward_points_amount'] = $insert['reward_points_amount'];
+            $insert['reward_points_earned'] = (int) ($rewardRow['earn_rewardpoint'] ?? 0);
+        }
+
+        if ($this->hasDestinationColumn('shipping_insurance_amount')) {
+            $insurance = $this->absMoney($row['amcheckoutfees_amount'] ?? 0);
+            $baseInsurance = array_key_exists('base_amcheckoutfees_amount', $row)
+                ? $this->absMoney($row['base_amcheckoutfees_amount'])
+                : $insurance;
+
+            $insert['shipping_insurance_amount'] = $insurance;
+            $insert['base_shipping_insurance_amount'] = $baseInsurance > 0 ? $baseInsurance : $insurance;
+        }
+
+        return $insert;
+    }
+
+    private function hasDestinationColumn(string $column): bool
+    {
+        $this->destinationOrderColumns ??= [
+            'reward_points_used'         => Schema::hasColumn('orders', 'reward_points_used'),
+            'shipping_insurance_amount'  => Schema::hasColumn('orders', 'shipping_insurance_amount'),
         ];
         ];
+
+        return $this->destinationOrderColumns[$column] ?? false;
+    }
+
+    private function absMoney(mixed $value): float
+    {
+        return abs($this->money($value));
     }
     }
 
 
     /**
     /**
      * @param  array<string, mixed>|null  $paymentRow
      * @param  array<string, mixed>|null  $paymentRow
+     * @return array<string, mixed>
      */
      */
-    private function importPayment(Order $order, ?array $paymentRow): void
+    private function buildPaymentRow(int $orderId, ?array $paymentRow, string $now): array
     {
     {
         $magentoMethod = trim((string) ($paymentRow['method'] ?? ''));
         $magentoMethod = trim((string) ($paymentRow['method'] ?? ''));
 
 
@@ -392,89 +580,61 @@ class MigrateAsteriaOrders extends Command
             }
             }
         }
         }
 
 
-        $payment = new OrderPayment;
-        $payment->forceFill([
-            'order_id'     => $order->id,
+        return [
+            'order_id'     => $orderId,
             'method'       => $this->mapPaymentMethod($magentoMethod),
             'method'       => $this->mapPaymentMethod($magentoMethod),
             'method_title' => $magentoMethod !== '' ? $magentoMethod : null,
             'method_title' => $magentoMethod !== '' ? $magentoMethod : null,
-            'additional'   => $additional,
-        ]);
-        $payment->save();
+            'additional'   => json_encode($additional),
+            'created_at'   => $now,
+            'updated_at'   => $now,
+        ];
     }
     }
 
 
     /**
     /**
-     * @param  Collection<int, array<string, mixed>>  $addressRows
+     * @param  array<string, mixed>  $row
+     * @param  array<string, mixed>  $orderRow
+     * @return array<string, mixed>
      */
      */
-    private function importAddresses(Order $order, $addressRows, ?Customer $customer, string $email): int
+    private function buildAddressRow(int $orderId, array $row, ?object $customer, string $email, array $orderRow, string $now): array
     {
     {
-        $imported = 0;
-
-        foreach ($addressRows as $row) {
-            $type = strtolower(trim((string) ($row['address_type'] ?? '')));
-            $addressType = $type === 'shipping'
-                ? OrderAddress::ADDRESS_TYPE_SHIPPING
-                : OrderAddress::ADDRESS_TYPE_BILLING;
-
-            $address = new OrderAddress;
-            $address->forceFill([
-                'order_id'     => $order->id,
-                'customer_id'  => $customer?->id,
-                'address_type' => $addressType,
-                'first_name'   => $this->requiredName($row['firstname'] ?? null, $order->customer_first_name),
-                'last_name'    => trim((string) ($row['lastname'] ?? '')) ?: $order->customer_last_name,
-                'company_name' => $this->nullableString($row['company'] ?? null),
-                'address'      => $this->mapStreet($row['street'] ?? null) ?: '-',
-                'city'         => trim((string) ($row['city'] ?? '')) ?: '-',
-                'state'        => $this->nullableString($row['region'] ?? null),
-                'country'      => $this->nullableString($row['country_id'] ?? null),
-                'postcode'     => $this->nullableString($row['postcode'] ?? null),
-                'email'        => $this->nullableString($row['email'] ?? null) ?? ($email !== '' ? $email : null),
-                'phone'        => $this->nullableString($row['telephone'] ?? null),
-                'additional'   => json_encode(['asteria_address_id' => (int) ($row['entity_id'] ?? 0)]),
-            ]);
-            $address->save();
-
-            $imported++;
-        }
-
-        return $imported;
-    }
+        $type = strtolower(trim((string) ($row['address_type'] ?? '')));
+        $addressType = $type === 'shipping'
+            ? OrderAddress::ADDRESS_TYPE_SHIPPING
+            : OrderAddress::ADDRESS_TYPE_BILLING;
+
+        $firstName = $this->requiredName(
+            $row['firstname'] ?? null,
+            $this->requiredName($orderRow['customer_firstname'] ?? null, $customer?->first_name ?? $email)
+        );
+        $lastName = trim((string) ($row['lastname'] ?? ''))
+            ?: (trim((string) ($orderRow['customer_lastname'] ?? '')) ?: ($customer?->last_name ?? '-'));
 
 
-    /**
-     * @param  Collection<int, array<string, mixed>>  $itemRows
-     * @param  array<string, Product>  $productsBySku
-     */
-    private function importItems(Order $order, $itemRows, array $productsBySku): int
-    {
-        if ($itemRows->isEmpty()) {
-            return 0;
-        }
-
-        $parents = $itemRows->filter(fn (array $row) => empty($row['parent_item_id']));
-        $children = $itemRows->filter(fn (array $row) => ! empty($row['parent_item_id']));
-        $idMap = [];
-        $imported = 0;
-
-        foreach ($parents as $row) {
-            $item = $this->createOrderItem($order, $row, null, $productsBySku);
-            $idMap[(int) $row['item_id']] = $item->id;
-            $imported++;
-        }
-
-        foreach ($children as $row) {
-            $parentId = $idMap[(int) $row['parent_item_id']] ?? null;
-            $this->createOrderItem($order, $row, $parentId, $productsBySku);
-            $imported++;
-        }
-
-        return $imported;
+        return [
+            'order_id'     => $orderId,
+            'customer_id'  => $customer?->id,
+            'address_type' => $addressType,
+            'first_name'   => $firstName,
+            'last_name'    => $lastName,
+            'company_name' => $this->nullableString($row['company'] ?? null),
+            'address'      => $this->mapStreet($row['street'] ?? null) ?: '-',
+            'city'         => trim((string) ($row['city'] ?? '')) ?: '-',
+            'state'        => $this->nullableString($row['region'] ?? null),
+            'country'      => $this->nullableString($row['country_id'] ?? null),
+            'postcode'     => $this->nullableString($row['postcode'] ?? null),
+            'email'        => $this->nullableString($row['email'] ?? null) ?? ($email !== '' ? $email : null),
+            'phone'        => $this->nullableString($row['telephone'] ?? null),
+            'additional'   => json_encode(['asteria_address_id' => (int) ($row['entity_id'] ?? 0)]),
+            'created_at'   => $now,
+            'updated_at'   => $now,
+        ];
     }
     }
 
 
     /**
     /**
      * @param  array<string, mixed>  $row
      * @param  array<string, mixed>  $row
      * @param  array<string, Product>  $productsBySku
      * @param  array<string, Product>  $productsBySku
+     * @return array<string, mixed>
      */
      */
-    private function createOrderItem(Order $order, array $row, ?int $parentId, array $productsBySku): OrderItem
+    private function buildItemRow(int $orderId, array $row, ?int $parentId, array $productsBySku, string $now): array
     {
     {
         $sku = trim((string) ($row['sku'] ?? ''));
         $sku = trim((string) ($row['sku'] ?? ''));
         $product = $sku !== '' ? ($productsBySku[$sku] ?? null) : null;
         $product = $sku !== '' ? ($productsBySku[$sku] ?? null) : null;
@@ -487,48 +647,81 @@ class MigrateAsteriaOrders extends Command
         $taxAmount = $this->money($row['tax_amount'] ?? 0);
         $taxAmount = $this->money($row['tax_amount'] ?? 0);
         $baseTaxAmount = $this->money($row['base_tax_amount'] ?? $taxAmount);
         $baseTaxAmount = $this->money($row['base_tax_amount'] ?? $taxAmount);
 
 
-        $item = new OrderItem;
-        $item->forceFill([
-            'order_id'              => $order->id,
-            'parent_id'             => $parentId,
-            'sku'                   => $sku !== '' ? $sku : null,
-            'type'                  => $type,
-            'name'                  => $this->nullableString($row['name'] ?? null) ?? '-',
-            'weight'                => $this->money($row['weight'] ?? 0),
-            'total_weight'          => $this->money($row['row_weight'] ?? $row['weight'] ?? 0),
-            'qty_ordered'           => $this->qty($row['qty_ordered'] ?? 0),
-            'qty_shipped'           => $this->qty($row['qty_shipped'] ?? 0),
-            'qty_invoiced'          => $this->qty($row['qty_invoiced'] ?? 0),
-            'qty_canceled'          => $this->qty($row['qty_canceled'] ?? 0),
-            'qty_refunded'          => $this->qty($row['qty_refunded'] ?? 0),
-            'price'                 => $price,
-            'base_price'            => $basePrice,
-            'price_incl_tax'        => array_key_exists('price_incl_tax', $row) ? $this->money($row['price_incl_tax']) : $price,
-            'base_price_incl_tax'   => array_key_exists('base_price_incl_tax', $row) ? $this->money($row['base_price_incl_tax']) : $basePrice,
-            'total'                 => $total,
-            'base_total'            => $baseTotal,
-            'total_incl_tax'        => array_key_exists('row_total_incl_tax', $row) ? $this->money($row['row_total_incl_tax']) : $total + $taxAmount,
-            'base_total_incl_tax'   => array_key_exists('base_row_total_incl_tax', $row) ? $this->money($row['base_row_total_incl_tax']) : $baseTotal + $baseTaxAmount,
-            'tax_percent'           => $this->money($row['tax_percent'] ?? 0),
-            'tax_amount'            => $taxAmount,
-            'base_tax_amount'       => $baseTaxAmount,
-            'discount_percent'      => $this->money($row['discount_percent'] ?? 0),
-            'discount_amount'       => $this->money($row['discount_amount'] ?? 0),
-            'base_discount_amount'  => $this->money($row['base_discount_amount'] ?? 0),
-            'product_id'            => $product?->id,
-            'product_type'          => $product ? get_class($product) : null,
-            'additional'            => [
-                'asteria_item_id' => (int) ($row['item_id'] ?? 0),
-            ],
-        ]);
-        $item->save();
-
-        return $item;
+        return [
+            'order_id'             => $orderId,
+            'parent_id'            => $parentId,
+            'sku'                  => $sku !== '' ? $sku : null,
+            'type'                 => $type,
+            'name'                 => $this->nullableString($row['name'] ?? null) ?? '-',
+            'weight'               => $this->money($row['weight'] ?? 0),
+            'total_weight'         => $this->money($row['row_weight'] ?? $row['weight'] ?? 0),
+            'qty_ordered'          => $this->qty($row['qty_ordered'] ?? 0),
+            'qty_shipped'          => $this->qty($row['qty_shipped'] ?? 0),
+            'qty_invoiced'         => $this->qty($row['qty_invoiced'] ?? 0),
+            'qty_canceled'         => $this->qty($row['qty_canceled'] ?? 0),
+            'qty_refunded'         => $this->qty($row['qty_refunded'] ?? 0),
+            'price'                => $price,
+            'base_price'           => $basePrice,
+            'price_incl_tax'       => array_key_exists('price_incl_tax', $row) ? $this->money($row['price_incl_tax']) : $price,
+            'base_price_incl_tax'  => array_key_exists('base_price_incl_tax', $row) ? $this->money($row['base_price_incl_tax']) : $basePrice,
+            'total'                => $total,
+            'base_total'           => $baseTotal,
+            'total_incl_tax'       => array_key_exists('row_total_incl_tax', $row) ? $this->money($row['row_total_incl_tax']) : $total + $taxAmount,
+            'base_total_incl_tax'  => array_key_exists('base_row_total_incl_tax', $row) ? $this->money($row['base_row_total_incl_tax']) : $baseTotal + $baseTaxAmount,
+            'tax_percent'          => $this->money($row['tax_percent'] ?? 0),
+            'tax_amount'           => $taxAmount,
+            'base_tax_amount'      => $baseTaxAmount,
+            'discount_percent'     => $this->money($row['discount_percent'] ?? 0),
+            'discount_amount'      => $this->money($row['discount_amount'] ?? 0),
+            'base_discount_amount' => $this->money($row['base_discount_amount'] ?? 0),
+            'product_id'           => $product?->id,
+            'product_type'         => $product ? get_class($product) : null,
+            'additional'           => json_encode(['asteria_item_id' => (int) ($row['item_id'] ?? 0)]),
+            'created_at'           => $now,
+            'updated_at'           => $now,
+        ];
+    }
+
+    /**
+     * @param  array<int, int>  $orderIds
+     * @return array<int, int>
+     */
+    private function loadInsertedItemIds(array $orderIds): array
+    {
+        if ($orderIds === []) {
+            return [];
+        }
+
+        $map = [];
+
+        foreach (DB::table('order_items')->whereIn('order_id', $orderIds)->get(['id', 'additional']) as $item) {
+            $additional = $item->additional;
+
+            if (is_string($additional) && $additional !== '') {
+                $additional = json_decode($additional, true);
+            }
+
+            if (is_array($additional) && isset($additional['asteria_item_id'])) {
+                $map[(int) $additional['asteria_item_id']] = (int) $item->id;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $rows
+     */
+    private function insertRows(string $table, array $rows): void
+    {
+        foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
+            DB::table($table)->insert($chunk);
+        }
     }
     }
 
 
     /**
     /**
      * @param  Collection<int, array<string, mixed>>  $orders
      * @param  Collection<int, array<string, mixed>>  $orders
-     * @return array{0: array<int, Customer>, 1: array<string, Customer>}
+     * @return array{0: array<int, object>, 1: array<string, object>}
      */
      */
     private function loadCustomers($orders): array
     private function loadCustomers($orders): array
     {
     {
@@ -552,20 +745,23 @@ class MigrateAsteriaOrders extends Command
         $byEmail = [];
         $byEmail = [];
 
 
         if ($asteriaIds !== []) {
         if ($asteriaIds !== []) {
-            foreach (Customer::query()->whereIn('migrated_from_asteria_id', $asteriaIds)->get() as $customer) {
+            foreach (
+                DB::table('customers')
+                    ->select('id', 'email', 'first_name', 'last_name', 'migrated_from_asteria_id')
+                    ->whereIn('migrated_from_asteria_id', $asteriaIds)
+                    ->get() as $customer
+            ) {
                 $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $customer;
                 $byAsteriaId[(int) $customer->migrated_from_asteria_id] = $customer;
             }
             }
         }
         }
 
 
         if ($emails !== []) {
         if ($emails !== []) {
-            $query = Customer::query();
-            $query->where(function ($inner) use ($emails) {
-                foreach ($emails as $email) {
-                    $inner->orWhereRaw('LOWER(email) = ?', [$email]);
-                }
-            });
-
-            foreach ($query->get() as $customer) {
+            foreach (
+                DB::table('customers')
+                    ->select('id', 'email', 'first_name', 'last_name', 'migrated_from_asteria_id')
+                    ->whereIn(DB::raw('LOWER(email)'), $emails)
+                    ->get() as $customer
+            ) {
                 $byEmail[strtolower((string) $customer->email)] = $customer;
                 $byEmail[strtolower((string) $customer->email)] = $customer;
             }
             }
         }
         }
@@ -604,7 +800,7 @@ class MigrateAsteriaOrders extends Command
      * @param  array<int, Customer>  $customersByAsteriaId
      * @param  array<int, Customer>  $customersByAsteriaId
      * @param  array<string, Customer>  $customersByEmail
      * @param  array<string, Customer>  $customersByEmail
      */
      */
-    private function resolveCustomer(array $row, array $customersByAsteriaId, array $customersByEmail): ?Customer
+    private function resolveCustomer(array $row, array $customersByAsteriaId, array $customersByEmail): ?object
     {
     {
         $asteriaCustomerId = (int) ($row['customer_id'] ?? 0);
         $asteriaCustomerId = (int) ($row['customer_id'] ?? 0);
 
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1184 - 0
app/Console/Commands/MigrateAsteriaProducts.php


+ 345 - 0
app/Console/Commands/ResetCatalogCommand.php

@@ -0,0 +1,345 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Throwable;
+use Webkul\Core\Facades\ElasticSearch;
+use Webkul\Core\Models\Channel;
+use Webkul\Product\Helpers\Product as ProductIndexName;
+
+/**
+ * Wipe Bagisto catalog rows so `products:migrate-asteria` (or catalog:sync) can start clean.
+ *
+ * Keeps attributes, categories, customers, and orders. Historical sales lines keep their
+ * SKU snapshots; `product_id` is set to NULL. Reviews / Q&A / carts / wishlists that
+ * point at products are removed — re-run `reviews:migrate-asteria --reset-progress`
+ * after the product import if those comments should come back.
+ *
+ * Usage
+ * ─────
+ * php artisan catalog:reset --dry-run
+ * php artisan catalog:reset --force
+ * php artisan catalog:reset --force --purge-files
+ */
+class ResetCatalogCommand extends Command
+{
+    protected $signature = 'catalog:reset
+        {--dry-run : Count rows only, do not write}
+        {--force : Skip the confirmation prompt}
+        {--purge-files : Also delete storage/app/public/product image directories}';
+
+    protected $description = 'Truncate existing product catalog data so a fresh product migration can run';
+
+    /**
+     * Catalog tables truncated in child-first order (safe even if FK checks stay on).
+     *
+     * @var list<string>
+     */
+    private const CATALOG_TABLES = [
+        // Longyi flexible variants
+        'product_variant_option_values',
+        'product_variant_images',
+        'product_variants',
+        'product_product_options',
+        'product_option_values',
+        'product_options',
+
+        // Q&A / reviews
+        'product_question_answer_votes',
+        'product_question_answers',
+        'product_questions',
+        'product_review_images',
+        'product_reviews',
+
+        // Booking
+        'booking_product_event_ticket_translations',
+        'booking_product_event_tickets',
+        'booking_product_appointment_slots',
+        'booking_product_default_slots',
+        'booking_product_rental_slots',
+        'booking_product_table_slots',
+        'booking_products',
+
+        // Bundle / grouped / customizable / downloadable
+        'product_bundle_option_products',
+        'product_bundle_option_translations',
+        'product_bundle_options',
+        'product_grouped_products',
+        'product_customizable_option_prices',
+        'product_customizable_option_translations',
+        'product_customizable_options',
+        'product_downloadable_link_translations',
+        'product_downloadable_links',
+        'product_downloadable_sample_translations',
+        'product_downloadable_samples',
+
+        // Catalog rule product indices (not the rules themselves)
+        'catalog_rule_product_prices',
+        'catalog_rule_products',
+
+        // Runtime references
+        'cart_item_inventories',
+        'cart_items',
+        'wishlist_items',
+        'wishlist',
+        'compare_items',
+
+        // Associations / media / inventory / indices
+        'product_cross_sells',
+        'product_up_sells',
+        'product_relations',
+        'product_super_attributes',
+        'product_categories',
+        'product_channels',
+        'product_attribute_values',
+        'product_images',
+        'product_videos',
+        'product_inventories',
+        'product_ordered_inventories',
+        'product_inventory_indices',
+        'product_salable_inventories',
+        'product_price_indices',
+        'product_customer_group_prices',
+        'product_flat',
+
+        'products',
+    ];
+
+    /**
+     * Historical rows that keep their snapshot but must not block deleting products.
+     *
+     * @var list<array{0: string, 1: string}>
+     */
+    private const DETACH_PRODUCT_ID = [
+        ['order_items', 'product_id'],
+        ['invoice_items', 'product_id'],
+        ['shipment_items', 'product_id'],
+        ['refund_items', 'product_id'],
+        ['bookings', 'product_id'],
+    ];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $tables = $this->existingTables(self::CATALOG_TABLES);
+
+        $this->warn('This clears catalog products so they can be re-imported.');
+        $this->line('Kept: attributes, categories, customers, orders (SKU snapshot stays; product_id set null).');
+        $this->line('Removed: products, variants, inventories, reviews, Q&A, cart items, wishlists, compare items.');
+        $this->newLine();
+
+        $rows = $this->countRows($tables);
+        $this->table(['table', 'rows'], collect($rows)->map(fn ($count, $table) => [$table, $count])->values()->all());
+
+        $total = array_sum($rows);
+        $this->info("Catalog rows that would be truncated: {$total}");
+
+        $detachCounts = $this->countDetachRows();
+        if ($detachCounts !== []) {
+            $this->newLine();
+            $this->comment('Historical rows whose product_id will be set to NULL:');
+            $this->table(['table', 'rows with product_id'], collect($detachCounts)->map(fn ($count, $table) => [$table, $count])->values()->all());
+        }
+
+        $rewriteCount = $this->countProductUrlRewrites();
+        if ($rewriteCount > 0) {
+            $this->comment("url_rewrites (entity_type=product): {$rewriteCount}");
+        }
+
+        if ($dryRun) {
+            $this->info('Dry run — nothing written.');
+
+            return self::SUCCESS;
+        }
+
+        if (! $this->option('force') && ! $this->confirm('Truncate catalog tables now?', false)) {
+            $this->info('Aborted.');
+
+            return self::SUCCESS;
+        }
+
+        $driver = DB::getDriverName();
+
+        try {
+            $this->disableForeignKeyChecks($driver);
+
+            $this->detachHistoricalProductIds();
+            $this->deleteProductUrlRewrites();
+            $this->deleteProductVisits();
+
+            foreach ($tables as $table) {
+                $this->emptyTable($driver, $table);
+            }
+        } finally {
+            $this->enableForeignKeyChecks($driver);
+        }
+
+        if ($this->option('purge-files')) {
+            Storage::deleteDirectory('product');
+            $this->info('Deleted storage directory: product/');
+        }
+
+        $this->wipeElasticsearchIndices();
+
+        $this->newLine();
+        $this->info('Catalog reset finished. Next:');
+        $this->line('  php artisan products:migrate-asteria');
+        $this->line('  php artisan reviews:migrate-asteria --reset-progress --sync   # if reviews should be re-imported');
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return list<string>
+     */
+    private function existingTables(array $tables): array
+    {
+        return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return array<string, int>
+     */
+    private function countRows(array $tables): array
+    {
+        $counts = [];
+
+        foreach ($tables as $table) {
+            $counts[$table] = (int) DB::table($table)->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * @return array<string, int>
+     */
+    private function countDetachRows(): array
+    {
+        $counts = [];
+
+        foreach (self::DETACH_PRODUCT_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $count = (int) DB::table($table)->whereNotNull($column)->count();
+
+            if ($count > 0) {
+                $counts[$table] = $count;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function countProductUrlRewrites(): int
+    {
+        if (! Schema::hasTable('url_rewrites')) {
+            return 0;
+        }
+
+        return (int) DB::table('url_rewrites')->where('entity_type', 'product')->count();
+    }
+
+    private function detachHistoricalProductIds(): void
+    {
+        foreach (self::DETACH_PRODUCT_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            DB::table($table)->whereNotNull($column)->update([$column => null]);
+        }
+    }
+
+    private function deleteProductUrlRewrites(): void
+    {
+        if (! Schema::hasTable('url_rewrites')) {
+            return;
+        }
+
+        DB::table('url_rewrites')->where('entity_type', 'product')->delete();
+    }
+
+    private function deleteProductVisits(): void
+    {
+        if (! Schema::hasTable('visits') || ! Schema::hasColumn('visits', 'visitable_type')) {
+            return;
+        }
+
+        DB::table('visits')->where('visitable_type', 'like', '%Product%')->delete();
+    }
+
+    private function emptyTable(string $driver, string $table): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
+
+            return;
+        }
+
+        DB::table($table)->delete();
+
+        if ($driver === 'sqlite') {
+            DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
+        }
+    }
+
+    private function quoteTable(string $table): string
+    {
+        $name = DB::getTablePrefix().$table;
+
+        return '`'.str_replace('`', '``', $name).'`';
+    }
+
+    private function disableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=0');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = OFF');
+        }
+    }
+
+    private function enableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=1');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = ON');
+        }
+    }
+
+    private function wipeElasticsearchIndices(): void
+    {
+        if (core()->getConfigData('catalog.products.search.engine') !== 'elastic') {
+            return;
+        }
+
+        try {
+            $channels = Channel::with('locales')->get();
+
+            foreach ($channels as $channel) {
+                foreach ($channel->locales as $locale) {
+                    $index = ProductIndexName::formatElasticSearchIndexName($channel->code, $locale->code);
+
+                    try {
+                        ElasticSearch::indices()->delete(['index' => $index]);
+                        $this->comment("Deleted Elasticsearch index: {$index}");
+                    } catch (Throwable $e) {
+                        $this->comment("Elasticsearch index {$index} skipped: ".$e->getMessage());
+                    }
+                }
+            }
+        } catch (Throwable $e) {
+            $this->warn('Elasticsearch wipe skipped: '.$e->getMessage());
+        }
+    }
+}

+ 377 - 0
app/Console/Commands/ResetCustomersCommand.php

@@ -0,0 +1,377 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Wipe Bagisto storefront customers so `customers:migrate-asteria` can start clean.
+ *
+ * Keeps customer_groups, admins, products, and orders. Historical orders / reviews /
+ * Q&A keep their snapshots; `customer_id` is set to NULL. Customer addresses,
+ * newsletters, wishlists, compare items, reward/growth balances, and login tokens
+ * are removed.
+ *
+ * Usage
+ * ─────
+ * php artisan customers:reset --dry-run
+ * php artisan customers:reset --force
+ */
+class ResetCustomersCommand extends Command
+{
+    protected $signature = 'customers:reset
+        {--dry-run : Count rows only, do not write}
+        {--force : Skip the confirmation prompt}';
+
+    protected $description = 'Truncate existing storefront customers so a fresh customer migration can run';
+
+    private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
+
+    /**
+     * Customer-owned tables truncated in child-first order.
+     *
+     * @var list<string>
+     */
+    private const CUSTOMER_TABLES = [
+        'customer_notes',
+        'customer_social_accounts',
+        'customer_password_resets',
+        'gdpr_data_request',
+        'wishlist_items',
+        'wishlist',
+        'compare_items',
+        'cart_rule_customers',
+        'cart_rule_coupon_usage',
+        'mw_reward_point_customer_sign',
+        'mw_reward_point_history',
+        'mw_reward_point_customer',
+        'mw_growth_value_history',
+        'mw_growth_value_customer',
+        'member_log',
+        'downloadable_link_purchased',
+        'subscribers_list',
+        'customers',
+    ];
+
+    /**
+     * Historical / catalog rows that keep their snapshot but must not block deleting customers.
+     *
+     * @var list<array{0: string, 1: string}>
+     */
+    private const DETACH_CUSTOMER_ID = [
+        ['orders', 'customer_id'],
+        ['shipments', 'customer_id'],
+        ['product_reviews', 'customer_id'],
+        ['product_questions', 'customer_id'],
+        ['product_question_answers', 'customer_id'],
+        ['product_question_answer_votes', 'customer_id'],
+        ['cart_rule_coupons', 'customer_id'],
+        ['gift_cards', 'customer_id'],
+        ['gift_card_usage_logs', 'customer_id'],
+        ['addresses', 'customer_id'],
+    ];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $tables = $this->existingTables(self::CUSTOMER_TABLES);
+
+        $this->warn('This clears storefront customers so they can be re-imported.');
+        $this->line('Kept: customer groups, admins, products, orders (email snapshot stays; customer_id set null).');
+        $this->line('Removed: customers, customer addresses, newsletters, wishlists, compare, reward/growth balances, tokens.');
+        $this->newLine();
+
+        $rows = $this->countRows($tables);
+        $this->table(['table', 'rows'], collect($rows)->map(fn ($count, $table) => [$table, $count])->values()->all());
+
+        $total = array_sum($rows);
+        $this->info("Customer rows that would be truncated: {$total}");
+
+        $addressCount = $this->countCustomerAddresses();
+        if ($addressCount > 0) {
+            $this->comment("addresses (address_type=customer): {$addressCount}");
+        }
+
+        $cartCount = $this->countCustomerCarts();
+        if ($cartCount > 0) {
+            $this->comment("cart (logged-in customers): {$cartCount}");
+        }
+
+        $tokenCount = $this->countCustomerTokens();
+        if ($tokenCount > 0) {
+            $this->comment("personal_access_tokens (customers): {$tokenCount}");
+        }
+
+        $detachCounts = $this->countDetachRows();
+        if ($detachCounts !== []) {
+            $this->newLine();
+            $this->comment('Historical rows whose customer_id will be set to NULL:');
+            $this->table(['table', 'rows with customer_id'], collect($detachCounts)->map(fn ($count, $table) => [$table, $count])->values()->all());
+        }
+
+        if ($dryRun) {
+            $this->info('Dry run — nothing written.');
+
+            return self::SUCCESS;
+        }
+
+        if (! $this->option('force') && ! $this->confirm('Truncate customer tables now?', false)) {
+            $this->info('Aborted.');
+
+            return self::SUCCESS;
+        }
+
+        $driver = DB::getDriverName();
+
+        try {
+            $this->disableForeignKeyChecks($driver);
+
+            $this->deleteCustomerAddresses();
+            $this->deleteCustomerCarts();
+            $this->deleteCustomerTokens();
+            $this->deleteCustomerVisits();
+            $this->detachHistoricalCustomerIds();
+
+            foreach ($tables as $table) {
+                $this->emptyTable($driver, $table);
+            }
+        } finally {
+            $this->enableForeignKeyChecks($driver);
+        }
+
+        Cache::forget(self::PROGRESS_KEY);
+
+        $this->newLine();
+        $this->info('Customer reset finished. Next:');
+        $this->line('  php artisan customers:migrate-asteria --reset-progress');
+        $this->line('  php artisan orders:migrate-asteria --reset-progress   # if orders should re-link to new customer ids');
+        $this->line('  php artisan reviews:migrate-asteria --reset-progress --sync');
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return list<string>
+     */
+    private function existingTables(array $tables): array
+    {
+        return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
+    }
+
+    /**
+     * @param  list<string>  $tables
+     * @return array<string, int>
+     */
+    private function countRows(array $tables): array
+    {
+        $counts = [];
+
+        foreach ($tables as $table) {
+            $counts[$table] = (int) DB::table($table)->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * @return array<string, int>
+     */
+    private function countDetachRows(): array
+    {
+        $counts = [];
+
+        foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $query = DB::table($table)->whereNotNull($column);
+
+            if ($table === 'gift_cards') {
+                $query->where($column, '!=', 0);
+            }
+
+            if ($table === 'addresses' && Schema::hasColumn($table, 'address_type')) {
+                $query->where('address_type', '!=', 'customer');
+            }
+
+            $count = (int) $query->count();
+
+            if ($count > 0) {
+                $counts[$table] = $count;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function countCustomerAddresses(): int
+    {
+        if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
+            return 0;
+        }
+
+        return (int) DB::table('addresses')->where('address_type', 'customer')->count();
+    }
+
+    private function countCustomerCarts(): int
+    {
+        if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
+            return 0;
+        }
+
+        return (int) DB::table('cart')->whereNotNull('customer_id')->count();
+    }
+
+    private function countCustomerTokens(): int
+    {
+        if (! Schema::hasTable('personal_access_tokens')) {
+            return 0;
+        }
+
+        return (int) $this->customerTokenQuery()->count();
+    }
+
+    private function deleteCustomerAddresses(): void
+    {
+        if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
+            return;
+        }
+
+        DB::table('addresses')->where('address_type', 'customer')->delete();
+    }
+
+    private function deleteCustomerCarts(): void
+    {
+        if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
+            return;
+        }
+
+        $cartIds = DB::table('cart')->whereNotNull('customer_id')->pluck('id');
+
+        if ($cartIds->isEmpty()) {
+            return;
+        }
+
+        $itemIds = Schema::hasTable('cart_items')
+            ? DB::table('cart_items')->whereIn('cart_id', $cartIds)->pluck('id')
+            : collect();
+
+        if ($itemIds->isNotEmpty() && Schema::hasTable('cart_item_inventories')) {
+            DB::table('cart_item_inventories')->whereIn('cart_item_id', $itemIds)->delete();
+        }
+
+        if ($itemIds->isNotEmpty()) {
+            DB::table('cart_items')->whereIn('id', $itemIds)->delete();
+        }
+
+        if (Schema::hasTable('cart_payment')) {
+            DB::table('cart_payment')->whereIn('cart_id', $cartIds)->delete();
+        }
+
+        if (Schema::hasTable('guest_cart_tokens')) {
+            DB::table('guest_cart_tokens')->whereIn('cart_id', $cartIds)->delete();
+        }
+
+        $addressIds = collect();
+
+        if (Schema::hasTable('addresses') && Schema::hasColumn('addresses', 'cart_id')) {
+            $addressIds = DB::table('addresses')->whereIn('cart_id', $cartIds)->pluck('id');
+        }
+
+        if ($addressIds->isNotEmpty() && Schema::hasTable('cart_shipping_rates') && Schema::hasColumn('cart_shipping_rates', 'cart_address_id')) {
+            DB::table('cart_shipping_rates')->whereIn('cart_address_id', $addressIds)->delete();
+        }
+
+        if ($addressIds->isNotEmpty()) {
+            DB::table('addresses')->whereIn('id', $addressIds)->delete();
+        }
+
+        DB::table('cart')->whereIn('id', $cartIds)->delete();
+    }
+
+    private function deleteCustomerTokens(): void
+    {
+        if (! Schema::hasTable('personal_access_tokens')) {
+            return;
+        }
+
+        $this->customerTokenQuery()->delete();
+    }
+
+    private function customerTokenQuery()
+    {
+        return DB::table('personal_access_tokens')
+            ->where('tokenable_type', 'like', '%Customer%');
+    }
+
+    private function deleteCustomerVisits(): void
+    {
+        if (! Schema::hasTable('visits') || ! Schema::hasColumn('visits', 'visitable_type')) {
+            return;
+        }
+
+        DB::table('visits')->where('visitable_type', 'like', '%Customer%')->delete();
+    }
+
+    private function detachHistoricalCustomerIds(): void
+    {
+        foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
+            if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
+                continue;
+            }
+
+            $query = DB::table($table)->whereNotNull($column);
+
+            if ($table === 'gift_cards') {
+                $query->where($column, '!=', 0);
+            }
+
+            $query->update([$column => null]);
+        }
+    }
+
+    private function emptyTable(string $driver, string $table): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
+
+            return;
+        }
+
+        DB::table($table)->delete();
+
+        if ($driver === 'sqlite') {
+            DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
+        }
+    }
+
+    private function quoteTable(string $table): string
+    {
+        $name = DB::getTablePrefix().$table;
+
+        return '`'.str_replace('`', '``', $name).'`';
+    }
+
+    private function disableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=0');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = OFF');
+        }
+    }
+
+    private function enableForeignKeyChecks(string $driver): void
+    {
+        if ($driver === 'mysql') {
+            DB::statement('SET FOREIGN_KEY_CHECKS=1');
+        } elseif ($driver === 'sqlite') {
+            DB::statement('PRAGMA foreign_keys = ON');
+        }
+    }
+}

+ 118 - 1
app/Services/Asteria/Magento1CustomerReader.php

@@ -16,6 +16,8 @@ class Magento1CustomerReader
         'password_hash',
         'password_hash',
         'default_billing',
         'default_billing',
         'default_shipping',
         'default_shipping',
+        'source',
+        'customer_source',
     ];
     ];
 
 
     private const ADDRESS_CODES = [
     private const ADDRESS_CODES = [
@@ -32,6 +34,9 @@ class Magento1CustomerReader
 
 
     private Magento1Schema $schema;
     private Magento1Schema $schema;
 
 
+    /** @var array<string, array<string, object>> */
+    private array $attributeMaps = [];
+
     public function __construct(private string $connection = 'asteria')
     public function __construct(private string $connection = 'asteria')
     {
     {
         $this->schema = new Magento1Schema($connection);
         $this->schema = new Magento1Schema($connection);
@@ -117,6 +122,112 @@ class Magento1CustomerReader
         })->values();
         })->values();
     }
     }
 
 
+    /**
+     * Magento 1 newsletter_subscriber rows for the given customers (by entity_id or email).
+     *
+     * @param  array<int, int>  $customerEntityIds
+     * @param  array<int, string>  $emails
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchSubscribers(array $customerEntityIds, array $emails): Collection
+    {
+        $columns = $this->subscriberColumns();
+
+        if ($columns === []) {
+            return collect();
+        }
+
+        $customerEntityIds = array_values(array_unique(array_filter(array_map('intval', $customerEntityIds))));
+        $emails = array_values(array_unique(array_filter(array_map(
+            fn ($email) => strtolower(trim((string) $email)),
+            $emails
+        ))));
+
+        if ($customerEntityIds === [] && $emails === []) {
+            return collect();
+        }
+
+        $query = DB::connection($this->connection)->table('newsletter_subscriber');
+
+        $query->where(function ($inner) use ($customerEntityIds, $emails, $columns) {
+            if ($customerEntityIds !== [] && in_array('customer_id', $columns, true)) {
+                $inner->whereIn('customer_id', $customerEntityIds);
+            }
+
+            if ($emails !== []) {
+                $method = $customerEntityIds !== [] && in_array('customer_id', $columns, true)
+                    ? 'orWhereIn'
+                    : 'whereIn';
+
+                $inner->{$method}(DB::raw('LOWER(subscriber_email)'), $emails);
+            }
+        });
+
+        return $this->mapSubscriberRows($query->get($columns));
+    }
+
+    /**
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchSubscribersAfter(int $afterSubscriberId, int $limit): Collection
+    {
+        $columns = $this->subscriberColumns();
+
+        if ($columns === [] || ! in_array('subscriber_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('newsletter_subscriber')
+            ->where('subscriber_id', '>', $afterSubscriberId)
+            ->orderBy('subscriber_id')
+            ->limit($limit)
+            ->get($columns);
+
+        return $this->mapSubscriberRows($rows);
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function subscriberColumns(): array
+    {
+        if (! $this->schema->hasTable('newsletter_subscriber')) {
+            return [];
+        }
+
+        $columns = $this->schema->existingColumns('newsletter_subscriber', [
+            'subscriber_id',
+            'customer_id',
+            'subscriber_email',
+            'subscriber_status',
+            'subscriber_confirm_code',
+            'change_status_at',
+        ]);
+
+        if (! in_array('subscriber_email', $columns, true) || ! in_array('subscriber_status', $columns, true)) {
+            return [];
+        }
+
+        return $columns;
+    }
+
+    /**
+     * @param  \Illuminate\Support\Collection<int, object>|iterable<int, object>  $rows
+     * @return Collection<int, array<string, mixed>>
+     */
+    private function mapSubscriberRows($rows): Collection
+    {
+        return collect($rows)->map(function ($row) {
+            $data = (array) $row;
+            $data['subscriber_id'] = (int) ($row->subscriber_id ?? 0);
+            $data['customer_id'] = (int) ($row->customer_id ?? 0);
+            $data['subscriber_status'] = (int) ($row->subscriber_status ?? 0);
+
+            return $data;
+        })->values();
+    }
+
     /**
     /**
      * @param  array<int, string>  $codes
      * @param  array<int, string>  $codes
      * @return array<int, array<string, mixed>>
      * @return array<int, array<string, mixed>>
@@ -185,6 +296,12 @@ class Magento1CustomerReader
      */
      */
     private function attributeMap(string $entityTypeCode, array $codes): array
     private function attributeMap(string $entityTypeCode, array $codes): array
     {
     {
+        $cacheKey = $entityTypeCode.':'.implode(',', $codes);
+
+        if (isset($this->attributeMaps[$cacheKey])) {
+            return $this->attributeMaps[$cacheKey];
+        }
+
         $query = DB::connection($this->connection)
         $query = DB::connection($this->connection)
             ->table('eav_attribute')
             ->table('eav_attribute')
             ->select('attribute_id', 'attribute_code', 'backend_type')
             ->select('attribute_id', 'attribute_code', 'backend_type')
@@ -201,7 +318,7 @@ class Magento1CustomerReader
             }
             }
         }
         }
 
 
-        return $query->get()->keyBy('attribute_code')->all();
+        return $this->attributeMaps[$cacheKey] = $query->get()->keyBy('attribute_code')->all();
     }
     }
 
 
     /**
     /**

+ 42 - 0
app/Services/Asteria/Magento1OrderReader.php

@@ -40,6 +40,11 @@ class Magento1OrderReader
         'shipping_method',
         'shipping_method',
         'shipping_description',
         'shipping_description',
         'coupon_code',
         'coupon_code',
+        'mw_rewardpoint',
+        'mw_rewardpoint_discount',
+        'mw_rewardpoint_discount_show',
+        'amcheckoutfees_amount',
+        'base_amcheckoutfees_amount',
         'total_item_count',
         'total_item_count',
         'total_qty_ordered',
         'total_qty_ordered',
         'total_invoiced',
         'total_invoiced',
@@ -246,6 +251,43 @@ class Magento1OrderReader
         })->values();
         })->values();
     }
     }
 
 
+    /**
+     * MW Reward Points rows keyed later by Magento order entity_id.
+     *
+     * @param  array<int, int>  $orderEntityIds
+     * @return Collection<int, array<string, mixed>>
+     */
+    public function fetchRewardPoints(array $orderEntityIds): Collection
+    {
+        if ($orderEntityIds === [] || ! $this->schema->hasTable('mw_reward_point_order')) {
+            return collect();
+        }
+
+        $columns = $this->schema->existingColumns('mw_reward_point_order', [
+            'order_id',
+            'reward_point',
+            'earn_rewardpoint',
+            'money',
+            'reward_point_money_rate',
+        ]);
+
+        if ($columns === [] || ! in_array('order_id', $columns, true)) {
+            return collect();
+        }
+
+        $rows = DB::connection($this->connection)
+            ->table('mw_reward_point_order')
+            ->whereIn('order_id', $orderEntityIds)
+            ->get($columns);
+
+        return $rows->map(function ($row) {
+            $data = $this->toArray($row, 'order_id');
+            $data['order_id'] = (int) ($row->order_id ?? 0);
+
+            return $data;
+        })->values();
+    }
+
     /**
     /**
      * @return array<string, mixed>
      * @return array<string, mixed>
      */
      */

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1113 - 0
app/Services/Asteria/Magento1ProductReader.php


+ 4 - 1
app/Services/Asteria/Magento1Schema.php

@@ -16,11 +16,14 @@ class Magento1Schema
     /** @var array<string, array<int, string>> */
     /** @var array<string, array<int, string>> */
     private array $columns = [];
     private array $columns = [];
 
 
+    /** @var array<string, bool> */
+    private array $tables = [];
+
     public function __construct(private string $connection = 'asteria') {}
     public function __construct(private string $connection = 'asteria') {}
 
 
     public function hasTable(string $table): bool
     public function hasTable(string $table): bool
     {
     {
-        return Schema::connection($this->connection)->hasTable($table);
+        return $this->tables[$table] ??= Schema::connection($this->connection)->hasTable($table);
     }
     }
 
 
     /**
     /**

+ 69 - 0
config/asteria.php

@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * Asteria (Magento 1.x) → Bagisto migration.
+ *
+ * Product settings mirror shell/migrate_to_bagisto.php in the Asteria repo.
+ */
+return [
+
+    'products' => [
+
+        'media_base_url' => env('ASTERIA_MEDIA_BASE_URL', 'https://img.asteriahair.com/media/catalog/product'),
+
+        'store_media_url' => env('ASTERIA_STORE_MEDIA_URL', 'https://img.asteriahair.com/media/'),
+
+        // Empty = every Bagisto channel. Comma-separated codes override, e.g. default,eu
+        'channels' => array_values(array_filter(array_map(
+            'trim',
+            explode(',', (string) env('ASTERIA_CHANNELS', ''))
+        ))),
+
+        // Optional single-code override used only when `channels` is empty.
+        'channel' => env('ASTERIA_CHANNEL', ''),
+
+        // Empty = every locale attached to the selected channels.
+        'locales' => array_values(array_filter(array_map(
+            'trim',
+            explode(',', (string) env('ASTERIA_LOCALES', ''))
+        ))),
+
+        'locale' => env('ASTERIA_LOCALE', 'en'),
+
+        // Bagisto attribute family code (catalog:sync uses "wigs"; the Magento export used "variant_product")
+        'attribute_family' => env('ASTERIA_ATTRIBUTE_FAMILY', 'wigs'),
+
+        'base_attributes' => [
+            'name', 'short_description', 'description',
+            'price', 'special_price', 'cost', 'weight',
+            'meta_title', 'meta_keyword', 'meta_description',
+            'url_key', 'status',
+        ],
+
+        'include_eav_attributes' => [
+            'feature', 'gmc_color', 'gmc_product_type',
+            'hair_closure', 'hair_colorr', 'hair_density',
+            'hair_wavy', 'human_hair_type',
+            'lace_color', 'lace_sizes',
+            'sale_num', 'texturee',
+            'video', 'alone',
+        ],
+
+        // Empty = every drop_down / radio option except skip_option_titles
+        'variant_option_titles' => [],
+
+        'skip_option_titles' => [
+            'Lace', 'Wig Type', 'Shipping Option', 'Construction', 'Free Gift',
+            'Headband Color', 'Color', 'Customized Product', 'X-Mas Gift Random?',
+            'Custom Item', 'Hair Type', 'Layering Option', '4C Hairline', 'Customize Item',
+            'Free Headband', 'Hair Parting Styles', 'Limited Design',
+            'Mannequin Head', 'Texture', 'Time', 'Trim the Ends (Optional)', 'U Part Open Size',
+            'Wig Color', 'Wig Style',
+        ],
+
+        'max_variants_warn' => 500,
+
+        'enabled_status' => 1,
+    ],
+
+];

+ 8 - 0
config/filesystems.php

@@ -52,6 +52,14 @@ return [
             'throw'      => false,
             'throw'      => false,
         ],
         ],
 
 
+        'blog_cdn' => [
+            'driver'     => 'local',
+            'root'       => storage_path('app/public'),
+            'url'        => env('BLOG_CDN_URL', 'https://cdn.asteriahair.com'),
+            'visibility' => 'public',
+            'throw'      => false,
+        ],
+
         's3' => [
         's3' => [
             'driver'                  => 's3',
             'driver'                  => 's3',
             'key'                     => env('AWS_ACCESS_KEY_ID'),
             'key'                     => env('AWS_ACCESS_KEY_ID'),

+ 25 - 0
database/migrations/2026_09_01_151700_add_asteria_migration_column_to_products_table.php

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

+ 22 - 0
database/migrations/2026_09_02_144100_add_customer_source_to_customers_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->unsignedInteger('customer_source')->nullable()->after('channel_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->dropColumn('customer_source');
+        });
+    }
+};

+ 22 - 0
database/migrations/2026_09_03_101500_change_customer_source_to_string_on_customers_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->string('customer_source', 64)->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->unsignedInteger('customer_source')->nullable()->change();
+        });
+    }
+};

+ 86 - 0
database/scripts/reset_catalog.sql

@@ -0,0 +1,86 @@
+-- Reset Bagisto catalog product data so a fresh product import can run.
+-- MySQL / TablePlus. If a TRUNCATE fails because the table is missing, skip that line.
+--
+-- Prefer the artisan command (counts first, skips missing tables, resets AUTO_INCREMENT):
+--   php artisan catalog:reset --dry-run
+--   php artisan catalog:reset --force
+--
+-- KEPT: attributes, categories, customers, orders (SKU snapshot stays).
+-- REMOVED: products, variants, inventories, reviews, Q&A, cart items, wishlists.
+--
+-- After this:
+--   php artisan catalog:sync
+--   php artisan reviews:migrate-asteria --reset-progress --sync
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+UPDATE order_items    SET product_id = NULL WHERE product_id IS NOT NULL;
+UPDATE invoice_items  SET product_id = NULL WHERE product_id IS NOT NULL;
+UPDATE shipment_items SET product_id = NULL WHERE product_id IS NOT NULL;
+UPDATE refund_items   SET product_id = NULL WHERE product_id IS NOT NULL;
+UPDATE bookings       SET product_id = NULL WHERE product_id IS NOT NULL;
+
+DELETE FROM url_rewrites WHERE entity_type = 'product';
+DELETE FROM visits WHERE visitable_type LIKE '%Product%';
+
+TRUNCATE TABLE product_variant_option_values;
+TRUNCATE TABLE product_variant_images;
+TRUNCATE TABLE product_variants;
+TRUNCATE TABLE product_product_options;
+TRUNCATE TABLE product_option_values;
+TRUNCATE TABLE product_options;
+
+TRUNCATE TABLE product_question_answer_votes;
+TRUNCATE TABLE product_question_answers;
+TRUNCATE TABLE product_questions;
+TRUNCATE TABLE product_review_images;
+TRUNCATE TABLE product_reviews;
+
+TRUNCATE TABLE booking_product_event_ticket_translations;
+TRUNCATE TABLE booking_product_event_tickets;
+TRUNCATE TABLE booking_product_appointment_slots;
+TRUNCATE TABLE booking_product_default_slots;
+TRUNCATE TABLE booking_product_rental_slots;
+TRUNCATE TABLE booking_product_table_slots;
+TRUNCATE TABLE booking_products;
+
+TRUNCATE TABLE product_bundle_option_products;
+TRUNCATE TABLE product_bundle_option_translations;
+TRUNCATE TABLE product_bundle_options;
+TRUNCATE TABLE product_grouped_products;
+TRUNCATE TABLE product_customizable_option_prices;
+TRUNCATE TABLE product_customizable_option_translations;
+TRUNCATE TABLE product_customizable_options;
+TRUNCATE TABLE product_downloadable_link_translations;
+TRUNCATE TABLE product_downloadable_links;
+TRUNCATE TABLE product_downloadable_sample_translations;
+TRUNCATE TABLE product_downloadable_samples;
+
+TRUNCATE TABLE catalog_rule_product_prices;
+TRUNCATE TABLE catalog_rule_products;
+
+TRUNCATE TABLE cart_item_inventories;
+TRUNCATE TABLE cart_items;
+TRUNCATE TABLE wishlist_items;
+TRUNCATE TABLE wishlist;
+TRUNCATE TABLE compare_items;
+
+TRUNCATE TABLE product_cross_sells;
+TRUNCATE TABLE product_up_sells;
+TRUNCATE TABLE product_relations;
+TRUNCATE TABLE product_super_attributes;
+TRUNCATE TABLE product_categories;
+TRUNCATE TABLE product_channels;
+TRUNCATE TABLE product_attribute_values;
+TRUNCATE TABLE product_images;
+TRUNCATE TABLE product_videos;
+TRUNCATE TABLE product_inventories;
+TRUNCATE TABLE product_ordered_inventories;
+TRUNCATE TABLE product_inventory_indices;
+TRUNCATE TABLE product_price_indices;
+TRUNCATE TABLE product_customer_group_prices;
+TRUNCATE TABLE product_flat;
+
+TRUNCATE TABLE products;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 76 - 0
database/scripts/reset_customers.sql

@@ -0,0 +1,76 @@
+-- Reset Bagisto storefront customers so a fresh customer import can run.
+-- MySQL / TablePlus. If a statement fails because the table is missing, skip that line.
+--
+-- Prefer the artisan command (counts first, skips missing tables, resets AUTO_INCREMENT):
+--   php artisan customers:reset --dry-run
+--   php artisan customers:reset --force
+--
+-- KEPT: customer_groups, admins, products, orders (email snapshot stays).
+-- REMOVED: customers, customer addresses, newsletters, wishlists, compare, reward/growth.
+--
+-- After this:
+--   php artisan customers:migrate-asteria --reset-progress
+--   php artisan orders:migrate-asteria --reset-progress
+--   php artisan reviews:migrate-asteria --reset-progress --sync
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+UPDATE orders                         SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE shipments                      SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_reviews                SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_questions              SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_question_answers       SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE product_question_answer_votes  SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE cart_rule_coupons              SET customer_id = NULL WHERE customer_id IS NOT NULL;
+UPDATE gift_cards                     SET customer_id = NULL WHERE customer_id IS NOT NULL AND customer_id != 0;
+UPDATE gift_card_usage_logs           SET customer_id = NULL WHERE customer_id IS NOT NULL;
+
+DELETE FROM addresses WHERE address_type = 'customer';
+UPDATE addresses SET customer_id = NULL WHERE customer_id IS NOT NULL;
+
+DELETE FROM personal_access_tokens WHERE tokenable_type LIKE '%Customer%';
+DELETE FROM visits WHERE visitable_type LIKE '%Customer%';
+
+DELETE ci FROM cart_item_inventories ci
+    INNER JOIN cart_items i ON i.id = ci.cart_item_id
+    INNER JOIN cart c ON c.id = i.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE i FROM cart_items i
+    INNER JOIN cart c ON c.id = i.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE p FROM cart_payment p
+    INNER JOIN cart c ON c.id = p.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE r FROM cart_shipping_rates r
+    INNER JOIN addresses a ON a.id = r.cart_address_id
+    INNER JOIN cart c ON c.id = a.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE t FROM guest_cart_tokens t
+    INNER JOIN cart c ON c.id = t.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE a FROM addresses a
+    INNER JOIN cart c ON c.id = a.cart_id
+    WHERE c.customer_id IS NOT NULL;
+DELETE FROM cart WHERE customer_id IS NOT NULL;
+
+TRUNCATE TABLE customer_notes;
+TRUNCATE TABLE customer_social_accounts;
+TRUNCATE TABLE customer_password_resets;
+TRUNCATE TABLE gdpr_data_request;
+TRUNCATE TABLE wishlist_items;
+TRUNCATE TABLE wishlist;
+TRUNCATE TABLE compare_items;
+TRUNCATE TABLE cart_rule_customers;
+TRUNCATE TABLE cart_rule_coupon_usage;
+TRUNCATE TABLE mw_reward_point_customer_sign;
+TRUNCATE TABLE mw_reward_point_history;
+TRUNCATE TABLE mw_reward_point_customer;
+TRUNCATE TABLE mw_growth_value_history;
+TRUNCATE TABLE mw_growth_value_customer;
+TRUNCATE TABLE member_log;
+TRUNCATE TABLE downloadable_link_purchased;
+TRUNCATE TABLE subscribers_list;
+
+TRUNCATE TABLE customers;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 73 - 17
docs/asteria-migration.md

@@ -1,17 +1,25 @@
 # Asteria(Magento 1)→ Bagisto 数据迁移说明
 # Asteria(Magento 1)→ Bagisto 数据迁移说明
 
 
-从旧站 Asteria(Magento 1.x)只读导入用户、评论、订单到本店。源库以 **`as`** 为准(不要用 `longyishop`)。三条命令共用 Laravel 连接名 `asteria`,可分批、可断点续跑、可 `--dry-run`,重复执行不会重复插入。
+从旧站 Asteria(Magento 1.x)只读导入商品、用户、评论、订单到本店。源库以 **`as`** 为准(不要用 `longyishop`)。命令共用 Laravel 连接名 `asteria`,可分批、可断点续跑、可 `--dry-run`,重复执行不会重复插入。
 
 
-**推荐顺序:** 先同步商品,再迁用户,再迁订单 / 评论。
+**推荐顺序:** 需要清空现有数据时先 reset,再迁商品,再迁用户,再迁订单 / 评论。
 
 
 ```bash
 ```bash
 php artisan migrate
 php artisan migrate
-php artisan catalog:sync                    # 商品 SKU 需与 Magento 一致
+php artisan catalog:reset --dry-run         # 先看会清哪些表
+php artisan catalog:reset --force           # 清空现有商品,便于重新导入
+php artisan products:migrate-asteria        # Magento 商品 → flexible_variant,SKU 与旧站一致
+php artisan customers:reset --dry-run
+php artisan customers:reset --force         # 清空现有用户,便于重新导入
 php artisan customers:migrate-asteria
 php artisan customers:migrate-asteria
 php artisan orders:migrate-asteria
 php artisan orders:migrate-asteria
 php artisan reviews:migrate-asteria --sync  # 或走队列,见下文
 php artisan reviews:migrate-asteria --sync  # 或走队列,见下文
 ```
 ```
 
 
+`catalog:reset` 会 TRUNCATE 商品及变体 / 库存 / 评论 / 购物车行 / 收藏,并把历史订单行的 `product_id` 置空(SKU 快照保留)。属性、分类、用户、订单头不删。也可跑 `database/scripts/reset_catalog.sql`。图片目录加 `--purge-files`。
+
+`customers:reset` 会 TRUNCATE 前台用户及地址 / 订阅 / 收藏 / 积分成长值,并把历史订单、评论的 `customer_id` 置空(邮箱快照保留)。客户分组、后台管理员、商品、订单头不删。也可跑 `database/scripts/reset_customers.sql`。
+
 ---
 ---
 
 
 ## 1. 前置:Asteria 数据库连接
 ## 1. 前置:Asteria 数据库连接
@@ -26,6 +34,12 @@ ASTERIA_DB_USERNAME=root
 ASTERIA_DB_PASSWORD=
 ASTERIA_DB_PASSWORD=
 ASTERIA_DB_PREFIX=          # Magento 有表前缀时填写,例如 mag_
 ASTERIA_DB_PREFIX=          # Magento 有表前缀时填写,例如 mag_
 ASTERIA_DB_CHARSET=utf8
 ASTERIA_DB_CHARSET=utf8
+ASTERIA_MEDIA_BASE_URL=https://img.asteriahair.com/media/catalog/product
+ASTERIA_STORE_MEDIA_URL=https://img.asteriahair.com/media/
+ASTERIA_ATTRIBUTE_FAMILY=wigs
+# 留空 = 挂到全部 Bagisto channel;只要部分站点再填 code,逗号分隔
+ASTERIA_CHANNELS=
+ASTERIA_LOCALES=
 ```
 ```
 
 
 连接名默认 `asteria`,定义在 `config/database.php`。所有命令都支持 `--connection=` 覆盖。
 连接名默认 `asteria`,定义在 `config/database.php`。所有命令都支持 `--connection=` 覆盖。
@@ -38,15 +52,50 @@ php artisan migrate
 
 
 | 表 | 幂等列 | 迁移文件 |
 | 表 | 幂等列 | 迁移文件 |
 |---|---|---|
 |---|---|---|
-| `customers` | `migrated_from_asteria_id`(unique),另有 `legacy_password` | `database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php` |
+| `products` | `migrated_from_asteria_id`(unique);SKU 仍唯一 | `database/migrations/2026_09_01_151700_add_asteria_migration_column_to_products_table.php` |
+| `customers` | `migrated_from_asteria_id`(unique),另有 `legacy_password`、`customer_source` | `database/migrations/2026_08_20_163200_add_asteria_migration_columns_to_customers_table.php`、`database/migrations/2026_09_02_144100_add_customer_source_to_customers_table.php`、`database/migrations/2026_09_03_101500_change_customer_source_to_string_on_customers_table.php` |
 | `orders` | `migrated_from_asteria_id`(unique) | `database/migrations/2026_08_25_143200_add_asteria_migration_column_to_orders_table.php` |
 | `orders` | `migrated_from_asteria_id`(unique) | `database/migrations/2026_08_25_143200_add_asteria_migration_column_to_orders_table.php` |
 | `product_reviews` | `migrated_from_asteria_id`(unique) | 评论命令在缺列时会提示,也可交互自动 `ALTER TABLE` |
 | `product_reviews` | `migrated_from_asteria_id`(unique) | 评论命令在缺列时会提示,也可交互自动 `ALTER TABLE` |
 
 
+商品白名单、变体 Option、图片 URL 前缀在 `config/asteria.php`(可用 `ASTERIA_MEDIA_BASE_URL`、`ASTERIA_ATTRIBUTE_FAMILY` 等覆盖)。
+
 ---
 ---
 
 
-## 2. 用户 `customers:migrate-asteria`
+## 2. 商品 `products:migrate-asteria`
 
 
-同步写入 Bagisto `customers` + `addresses`(同步、无队列)。
+从 Magento 目录只读写入 Bagisto:`include_eav_attributes` 属性、`flexible_variant` 商品、图片 URL、库存、按名称匹配的分类、以及由 Custom Option 展开的变体。逻辑对齐 Asteria 的 `shell/migrate_to_bagisto.php`,落库方式对齐 `catalog:sync`(不再经 XLS)。
+
+```bash
+php artisan products:migrate-asteria
+php artisan products:migrate-asteria --batch-size=50
+php artisan products:migrate-asteria --sku=PC001
+php artisan products:migrate-asteria --limit=5 --dry-run
+php artisan products:migrate-asteria --attributes-only
+php artisan products:migrate-asteria --products-only --no-index
+php artisan products:migrate-asteria --reset-progress
+```
+
+源表:`catalog_product_entity` 及 EAV、`catalog_product_entity_media_gallery`、`cataloginventory_stock_item`、`catalog_category_product`、`catalog_product_option*`。只导入 Magento `status=1`(启用)且 SKU 非空的商品。
+
+行为要点:
+
+- 属性:按 `config/asteria.php` 的 `include_eav_attributes` 创建 Bagisto 属性(select 带选项),并挂到属性族的 `general` 组。已存在的属性不改类型,只补缺失选项并保证在族里。若跑过 `config:cache`,改完配置后需要 `php artisan config:clear`。
+- 商品类型一律 `flexible_variant`。属性族优先 `wigs`,没有则 `variant_product` / `default`。
+- Channel:默认挂到 **Bagisto 全部 channel**(`product_channels`),库存写到这些 channel 关联的全部 `inventory_sources`。`value_per_locale` 属性按各 channel 的 locale 各写一份(内容相同);`value_per_channel` 按各 channel code 各写一份。只要部分站点:`ASTERIA_CHANNELS=default,eu` 或 `config/asteria.php` 的 `channels`。
+- Custom Option:`drop_down` / `radio` 作为变体维度(可用 `variant_option_titles` 白名单,或 `skip_option_titles` 排除),笛卡尔积写入 `product_options` / `product_variants`。图片不下载,写入 Magento 绝对 URL。
+- 分类:按 Bagisto 分类译名匹配,对不上的跳过(不自动建分类)。
+- 已有相同 `migrated_from_asteria_id` 或相同 SKU:**更新**(属性 / 图 / 库存 / 变体重建)。
+- 默认跑完会重建被改动商品的 price / flat(以及 elastic,若已开启)。大批量可加 `--no-index`,之后再索引。
+
+进度缓存:`migrate_asteria_products_last_id`(30 天)。`--sku=` 不写进度。
+
+`catalog:sync` 仍可从 `storage/sync` 的 CSV/XLS 导入,适合离线导出;连着 Asteria 库时用本命令。
+
+---
+
+## 3. 用户 `customers:migrate-asteria`
+
+同步写入 Bagisto `customers` + `addresses` + 订阅(同步、无队列)。
 
 
 ```bash
 ```bash
 php artisan customers:migrate-asteria
 php artisan customers:migrate-asteria
@@ -55,7 +104,7 @@ php artisan customers:migrate-asteria --dry-run
 php artisan customers:migrate-asteria --reset-progress
 php artisan customers:migrate-asteria --reset-progress
 ```
 ```
 
 
-源表:`customer_entity`、`customer_address_entity` 及对应 EAV。
+源表:`customer_entity`、`customer_address_entity` 及对应 EAV,以及可选的 `newsletter_subscriber`
 
 
 行为要点:
 行为要点:
 
 
@@ -64,12 +113,14 @@ php artisan customers:migrate-asteria --reset-progress
 - 新用户:写入 `general` 分组、默认 Channel;`password` 为随机 bcrypt,Magento 哈希放在 `legacy_password`。用户用旧密码登录成功后会自动升级为 bcrypt。
 - 新用户:写入 `general` 分组、默认 Channel;`password` 为随机 bcrypt,Magento 哈希放在 `legacy_password`。用户用旧密码登录成功后会自动升级为 bcrypt。
 - 手机号与现有用户冲突:新用户 `phone` 置空。
 - 手机号与现有用户冲突:新用户 `phone` 置空。
 - 地址按 `addresses.additional.asteria_address_id` 去重;街道换行压成 `, `。
 - 地址按 `addresses.additional.asteria_address_id` 去重;街道换行压成 `, `。
+- 订阅:Magento `newsletter_subscriber.subscriber_status = 1` 为已订阅,写入 `customers.subscribed_to_news_letter`,并按邮箱幂等写入 `subscribers_list`。已退订(status=3 等)的**注册用户**会写成未订阅并保留 `subscribers_list` 记录;无账号的游客订阅只导入已订阅邮箱。无 `newsletter_subscriber` 表时跳过,不影响用户导入。重复执行会回填已迁用户的订阅状态。
+- `customer_source`:从 Magento 客户属性 `source` 写入。该属性在 Magento 1 里是 varchar 自由文本(现网只有 `popup` 一种值,且仅 97 个客户有值),因此原样存字符串,不做数字编码转换;超过 64 字符会截断。不迁 `source_url`。已迁用户再跑(`--reset-progress`)会按 Magento 值回填。
 
 
 进度缓存:`migrate_asteria_customers_last_id`(30 天)。
 进度缓存:`migrate_asteria_customers_last_id`(30 天)。
 
 
 ---
 ---
 
 
-## 3. 评论 `reviews:migrate-asteria`
+## 4. 评论 `reviews:migrate-asteria`
 
 
 写入 `product_reviews`。默认按队列 `review-migration` 分批投递;`--sync` 则当场处理。
 写入 `product_reviews`。默认按队列 `review-migration` 分批投递;`--sync` 则当场处理。
 
 
@@ -95,7 +146,7 @@ php artisan reviews:migrate-asteria --reset-progress
 
 
 ---
 ---
 
 
-## 4. 订单 `orders:migrate-asteria`
+## 5. 订单 `orders:migrate-asteria`
 
 
 同步写入订单头、商品行、账单/收货地址、支付方式。
 同步写入订单头、商品行、账单/收货地址、支付方式。
 
 
@@ -106,13 +157,16 @@ php artisan orders:migrate-asteria --dry-run
 php artisan orders:migrate-asteria --reset-progress
 php artisan orders:migrate-asteria --reset-progress
 ```
 ```
 
 
-源表:`sales_flat_order`、`sales_flat_order_item`、`sales_flat_order_address`、`sales_flat_order_payment`。
+源表:`sales_flat_order`、`sales_flat_order_item`、`sales_flat_order_address`、`sales_flat_order_payment`,以及可选的 `mw_reward_point_order`
 
 
 **会做:**
 **会做:**
 
 
 - 保留 Magento `increment_id` 和 `created_at`,方便用户认历史单号。
 - 保留 Magento `increment_id` 和 `created_at`,方便用户认历史单号。
 - 客户:先按 `customers.migrated_from_asteria_id`,再按邮箱;都没有则游客单(快照姓名/邮箱仍写入)。
 - 客户:先按 `customers.migrated_from_asteria_id`,再按邮箱;都没有则游客单(快照姓名/邮箱仍写入)。
 - 商品行:按订单行 SKU 对 Bagisto;对不上仍导入快照,`product_id` 为空。
 - 商品行:按订单行 SKU 对 Bagisto;对不上仍导入快照,`product_id` 为空。
+- 优惠券:写入 `coupon_code` 和订单/行折扣金额。不迁 Magento `salesrule` 规则本身,也不回写券使用次数。
+- 积分抵扣 / 赚取:`mw_rewardpoint`、`mw_rewardpoint_discount` 与 `mw_reward_point_order` → `reward_points_used` / `reward_points_amount` / `reward_points_earned`。不迁客户积分余额。
+- 丢件险:Amasty Checkout Fees(`amcheckoutfees_amount`,费用名 insurance / Insurance for Lost)→ `shipping_insurance_amount`。
 - 状态:`complete` → `completed`;`canceled` → `canceled`;`pending_payment` / `payment_review` / `pending_paypal` → `pending_payment`;`holded` → `pending`;其余常见状态原样对应,未知为 `pending`。
 - 状态:`complete` → `completed`;`canceled` → `canceled`;`pending_payment` / `payment_review` / `pending_paypal` → `pending_payment`;`holded` → `pending`;其余常见状态原样对应,未知为 `pending`。
 - 支付:`paypal_express` / `paypal_standard` / `paypaluk_*` → `paypal_standard`;`checkmo` → `moneytransfer`;`klarna*` → `klarna`;`afterpay*` / `clearpay*` → `afterpay`。原文写在 `order_payment.additional.magento_method`。
 - 支付:`paypal_express` / `paypal_standard` / `paypaluk_*` → `paypal_standard`;`checkmo` → `moneytransfer`;`klarna*` → `klarna`;`afterpay*` / `clearpay*` → `afterpay`。原文写在 `order_payment.additional.magento_method`。
 
 
@@ -128,11 +182,11 @@ php artisan orders:migrate-asteria --reset-progress
 
 
 ---
 ---
 
 
-## 5. 共同约定
+## 6. 共同约定
 
 
 | 选项 | 含义 |
 | 选项 | 含义 |
 |---|---|
 |---|---|
-| `--batch-size` | 每批条数,默认 100 |
+| `--batch-size` | 每批条数;商品默认 100,用户默认 500 |
 | `--dry-run` | 只统计,不写库,也不更新进度 |
 | `--dry-run` | 只统计,不写库,也不更新进度 |
 | `--reset-progress` | 从 ID=0 重新扫(仍幂等,已迁记录会 skip) |
 | `--reset-progress` | 从 ID=0 重新扫(仍幂等,已迁记录会 skip) |
 | `--connection` | 源库连接名,默认 `asteria` |
 | `--connection` | 源库连接名,默认 `asteria` |
@@ -141,13 +195,13 @@ php artisan orders:migrate-asteria --reset-progress
 
 
 ---
 ---
 
 
-## 6. 明确不迁的数据
+## 7. 明确不迁的数据
 
 
-后台管理员、收藏、积分、购物车 Quote、发票、发货单、退款单都不在这三条命令范围内
+后台管理员、收藏、客户积分余额、购物车 Quote、发票、发货单、退款单、Magento 分类树本身都不在这些命令范围内。商品只按名称挂到**已有** Bagisto 分类。订单上的积分抵扣/赚取和丢件险金额会随 `orders:migrate-asteria` 写入
 
 
 ---
 ---
 
 
-## 7. 常见问题
+## 8. 常见问题
 
 
 **连不上 Asteria:** 检查 `.env` 的 `ASTERIA_DB_*`,以及 Magento 表前缀 `ASTERIA_DB_PREFIX`。源库必须是 `as`,不要配成 `longyishop`。
 **连不上 Asteria:** 检查 `.env` 的 `ASTERIA_DB_*`,以及 Magento 表前缀 `ASTERIA_DB_PREFIX`。源库必须是 `as`,不要配成 `longyishop`。
 
 
@@ -155,10 +209,12 @@ php artisan orders:migrate-asteria --reset-progress
 
 
 **提示缺列:** 先 `php artisan migrate`。评论列也可在命令交互里自动加。
 **提示缺列:** 先 `php artisan migrate`。评论列也可在命令交互里自动加。
 
 
-**评论一条都没有 / 大量 skip:** 先确认 `catalog:sync` 后 SKU 与 Magento 一致。
+**评论一条都没有 / 大量 skip:** 先确认 `products:migrate-asteria` 后 SKU 与 Magento 一致。
+
+**商品属性族找不到:** 在 Bagisto 建好 `wigs`(或改 `ASTERIA_ATTRIBUTE_FAMILY` / `config/asteria.php`)。命令会回退到 `variant_product`、`default`。
 
 
 **订单是游客单:** 先跑用户迁移,并确认 Magento `customer_id` / 邮箱能对上 `migrated_from_asteria_id` 或 Bagisto 邮箱。
 **订单是游客单:** 先跑用户迁移,并确认 Magento `customer_id` / 邮箱能对上 `migrated_from_asteria_id` 或 Bagisto 邮箱。
 
 
 **旧密码登不上:** 新迁用户密码在 `legacy_password`。Web 登录与 API 登录都会走 Magento 1 MD5(可带 salt)校验,成功后改成 bcrypt。已存在的 Bagisto 账号被「关联」时**不会**写入 `legacy_password`,仍用原 Bagisto 密码。
 **旧密码登不上:** 新迁用户密码在 `legacy_password`。Web 登录与 API 登录都会走 Magento 1 MD5(可带 salt)校验,成功后改成 bcrypt。已存在的 Bagisto 账号被「关联」时**不会**写入 `legacy_password`,仍用原 Bagisto 密码。
 
 
-**重复跑会不会翻倍:** 不会。用户按 Asteria ID / 邮箱,地址按 `asteria_address_id`,订单/评论按 `migrated_from_asteria_id`。
+**重复跑会不会翻倍:** 不会。用户按 Asteria ID / 邮箱,地址按 `asteria_address_id`,商品按 SKU 或 `migrated_from_asteria_id`(再跑会更新内容),订单/评论按 `migrated_from_asteria_id`。

+ 2 - 2
packages/Longyi/Blog/src/Models/Blog.php

@@ -71,7 +71,7 @@ class Blog extends Model implements BlogContract
             return;
             return;
         }
         }
 
 
-        return Storage::url($this->src);
+        return Storage::disk('blog_cdn')->url($this->src);
     }
     }
 
 
     /**
     /**
@@ -85,7 +85,7 @@ class Blog extends Model implements BlogContract
             return;
             return;
         }
         }
 
 
-        return Storage::url($this->m_src);
+        return Storage::disk('blog_cdn')->url($this->m_src);
     }
     }
 
 
     public function getAssignCategorysAttribute()
     public function getAssignCategorysAttribute()

+ 20 - 18
packages/Longyi/Blog/src/Repositories/BlogRepository.php

@@ -6,11 +6,9 @@ use Carbon\Carbon;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Event;
-use Illuminate\Support\Facades\Storage;
 use Longyi\Blog\Models\Category;
 use Longyi\Blog\Models\Category;
+use Longyi\ImageUpload\Services\ImageUploadService;
 use Webkul\Core\Eloquent\Repository;
 use Webkul\Core\Eloquent\Repository;
-use Intervention\Image\ImageManager;
-use Illuminate\Support\Str;
 
 
 class BlogRepository extends Repository
 class BlogRepository extends Repository
 {
 {
@@ -97,31 +95,33 @@ class BlogRepository extends Repository
      */
      */
     public function uploadImages($data, $blog, $type = 'src')
     public function uploadImages($data, $blog, $type = 'src')
     {
     {
+        $imageUploadService = app(ImageUploadService::class);
+
         if (isset($data[$type])) {
         if (isset($data[$type])) {
             foreach ($data[$type] as $imageId => $image) {
             foreach ($data[$type] as $imageId => $image) {
                 $file = $type . '.' . $imageId;
                 $file = $type . '.' . $imageId;
 
 
-                $dir = 'blog-images/' . $blog->id;
-
                 if (request()->hasFile($file)) {
                 if (request()->hasFile($file)) {
                     if ($blog->{$type}) {
                     if ($blog->{$type}) {
-                        Storage::delete($blog->{$type});
+                        $imageUploadService->delete($blog->{$type});
                     }
                     }
 
 
-                    $manager = new ImageManager();
+                    $result = $imageUploadService->upload(
+                        request()->file($file)
+                    );
 
 
-                    $image = $manager->make(request()->file($file))->encode('webp');
-
-                    $blog->{$type} = 'blog-images/' . $blog->id . '/' . Str::random(40) . '.webp';
+                    if (! ($result['success'] ?? false)) {
+                        throw new \Exception($result['error'] ?? 'Blog image upload failed.');
+                    }
 
 
-                    Storage::put($blog->{$type}, $image);
+                    $blog->{$type} = $result['path'];
 
 
                     $blog->save();
                     $blog->save();
                 }
                 }
             }
             }
         } else {
         } else {
             if ($blog->{$type}) {
             if ($blog->{$type}) {
-                Storage::delete($blog->{$type});
+                $imageUploadService->delete($blog->{$type});
             }
             }
 
 
             $blog->{$type} = null;
             $blog->{$type} = null;
@@ -140,13 +140,15 @@ class BlogRepository extends Repository
     {
     {
         $blogItem = $this->find($id);
         $blogItem = $this->find($id);
 
 
-        $blogItemImage = $blogItem->src;
+        $imageUploadService = app(ImageUploadService::class);
 
 
-        $blogItemMobileImage = $blogItem->m_src;
-
-        Storage::delete($blogItemImage);
+        if ($blogItem->src) {
+            $imageUploadService->delete($blogItem->src);
+        }
 
 
-        Storage::delete($blogItemMobileImage);
+        if ($blogItem->m_src) {
+            $imageUploadService->delete($blogItem->m_src);
+        }
 
 
         return $this->model->destroy($id);
         return $this->model->destroy($id);
     }
     }
@@ -208,4 +210,4 @@ class BlogRepository extends Repository
 
 
         return $blogs;
         return $blogs;
     }
     }
-}
+}

+ 32 - 0
packages/Longyi/FrontMenu/src/Database/Migrations/2026_08_28_000006_add_show_icon_to_front_menu_items.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('front_menu_items', function (Blueprint $table) {
+            if (! Schema::hasColumn('front_menu_items', 'show_icon')) {
+                $table->boolean('show_icon')->default(false)->after('wap_banner');
+            }
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('front_menu_items', function (Blueprint $table) {
+            if (Schema::hasColumn('front_menu_items', 'show_icon')) {
+                $table->dropColumn('show_icon');
+            }
+        });
+    }
+};

+ 3 - 0
packages/Longyi/FrontMenu/src/Http/Controllers/FrontMenuController.php

@@ -50,6 +50,7 @@ class FrontMenuController extends Controller
             'banner'     => 'nullable|string|max:2048',
             'banner'     => 'nullable|string|max:2048',
             'wap_image'  => 'nullable|string|max:2048',
             'wap_image'  => 'nullable|string|max:2048',
             'wap_banner' => 'nullable|string|max:2048',
             'wap_banner' => 'nullable|string|max:2048',
+            'show_icon'  => 'nullable|boolean',
             'parent_id'  => 'nullable|exists:front_menu_items,id',
             'parent_id'  => 'nullable|exists:front_menu_items,id',
             'sort_order' => 'nullable|integer',
             'sort_order' => 'nullable|integer',
             'status'     => 'nullable|boolean',
             'status'     => 'nullable|boolean',
@@ -86,6 +87,7 @@ class FrontMenuController extends Controller
             'banner'     => 'nullable|string|max:2048',
             'banner'     => 'nullable|string|max:2048',
             'wap_image'  => 'nullable|string|max:2048',
             'wap_image'  => 'nullable|string|max:2048',
             'wap_banner' => 'nullable|string|max:2048',
             'wap_banner' => 'nullable|string|max:2048',
+            'show_icon'  => 'nullable|boolean',
             'parent_id'  => 'nullable|exists:front_menu_items,id',
             'parent_id'  => 'nullable|exists:front_menu_items,id',
             'sort_order' => 'nullable|integer',
             'sort_order' => 'nullable|integer',
             'status'     => 'nullable|boolean',
             'status'     => 'nullable|boolean',
@@ -176,6 +178,7 @@ class FrontMenuController extends Controller
         $data['parent_id'] = $request->filled('parent_id') ? (int) $request->input('parent_id') : null;
         $data['parent_id'] = $request->filled('parent_id') ? (int) $request->input('parent_id') : null;
         $data['sort_order'] = $request->input('sort_order', 0);
         $data['sort_order'] = $request->input('sort_order', 0);
         $data['status'] = $request->has('status') ? (bool) $request->input('status') : true;
         $data['status'] = $request->has('status') ? (bool) $request->input('status') : true;
+        $data['show_icon'] = $request->has('show_icon') ? (bool) $request->input('show_icon') : false;
         $data['created_by'] = auth()->guard('admin')->user()->id ?? null;
         $data['created_by'] = auth()->guard('admin')->user()->id ?? null;
 
 
         return $data;
         return $data;

+ 2 - 0
packages/Longyi/FrontMenu/src/Models/FrontMenuItem.php

@@ -17,6 +17,7 @@ class FrontMenuItem extends Model
         'banner',
         'banner',
         'wap_image',
         'wap_image',
         'wap_banner',
         'wap_banner',
+        'show_icon',
         'parent_id',
         'parent_id',
         'sort_order',
         'sort_order',
         'status',
         'status',
@@ -25,6 +26,7 @@ class FrontMenuItem extends Model
 
 
     protected $casts = [
     protected $casts = [
         'status'     => 'boolean',
         'status'     => 'boolean',
+        'show_icon'  => 'boolean',
         'sort_order' => 'integer',
         'sort_order' => 'integer',
         'parent_id'  => 'integer',
         'parent_id'  => 'integer',
     ];
     ];

+ 4 - 0
packages/Longyi/FrontMenu/src/Resources/lang/en/app.php

@@ -52,6 +52,10 @@ return [
             'wap-banner' => 'WAP Banner',
             'wap-banner' => 'WAP Banner',
             'wap-banner-placeholder' => 'WAP banner image URL or upload path',
             'wap-banner-placeholder' => 'WAP banner image URL or upload path',
 
 
+            'show-icon' => 'Show badge icon',
+            'show-icon-yes' => 'Show',
+            'show-icon-no' => 'Hide',
+
             'drag-hint' => 'Drag to reorder and change hierarchy',
             'drag-hint' => 'Drag to reorder and change hierarchy',
 
 
             'parent' => 'Parent Menu',
             'parent' => 'Parent Menu',

+ 4 - 0
packages/Longyi/FrontMenu/src/Resources/lang/zh_CN/app.php

@@ -52,6 +52,10 @@ return [
             'wap-banner' => 'WAP 横幅',
             'wap-banner' => 'WAP 横幅',
             'wap-banner-placeholder' => 'WAP 横幅图片 URL 或上传路径',
             'wap-banner-placeholder' => 'WAP 横幅图片 URL 或上传路径',
 
 
+            'show-icon' => '显示角标图标',
+            'show-icon-yes' => '显示',
+            'show-icon-no' => '不显示',
+
             'drag-hint' => '拖动可调整顺序与层级',
             'drag-hint' => '拖动可调整顺序与层级',
 
 
             'parent' => '父级菜单',
             'parent' => '父级菜单',

+ 16 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/create.blade.php

@@ -97,6 +97,22 @@
                     />
                     />
                 </div>
                 </div>
 
 
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.show-icon')
+                    </label>
+                    <div class="mt-2 flex items-center gap-6">
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="show_icon" value="1" {{ old('show_icon') == 1 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.show-icon-yes')
+                        </label>
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="show_icon" value="0" {{ old('show_icon', 0) == 0 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.show-icon-no')
+                        </label>
+                    </div>
+                </div>
+
                 <div>
                 <div>
                     <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
                     <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
                         @lang('frontmenu::app.admin.menu.parent')
                         @lang('frontmenu::app.admin.menu.parent')

+ 16 - 0
packages/Longyi/FrontMenu/src/Resources/views/admin/menu/edit.blade.php

@@ -98,6 +98,22 @@
                     />
                     />
                 </div>
                 </div>
 
 
+                <div>
+                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+                        @lang('frontmenu::app.admin.menu.show-icon')
+                    </label>
+                    <div class="mt-2 flex items-center gap-6">
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="show_icon" value="1" {{ old('show_icon', $menuItem->show_icon) == 1 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.show-icon-yes')
+                        </label>
+                        <label class="inline-flex items-center gap-2">
+                            <input type="radio" name="show_icon" value="0" {{ old('show_icon', $menuItem->show_icon) == 0 ? 'checked' : '' }}>
+                            @lang('frontmenu::app.admin.menu.show-icon-no')
+                        </label>
+                    </div>
+                </div>
+
                 <div>
                 <div>
                     <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
                     <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="parent_id">
                         @lang('frontmenu::app.admin.menu.parent')
                         @lang('frontmenu::app.admin.menu.parent')

+ 9 - 8
packages/Longyi/RewardPoints/src/Listeners/OrderEvents.php

@@ -124,14 +124,15 @@ class OrderEvents
         }
         }
 
 
         DB::transaction(function () use ($order) {
         DB::transaction(function () use ($order) {
-            $histories = $this->rewardPointRepository->findWhere([
-                'customer_id' => $order->customer_id,
-                'history_order_id' => $order->id,
-                'type_of_transaction' => RewardActiveRule::TYPE_ORDER
-            ])->whereIn('status', [
-                RewardPointHistory::STATUS_PENDING,
-                RewardPointHistory::STATUS_COMPLETED
-            ])->get();
+            $histories = RewardPointHistory::query()
+                ->where('customer_id', $order->customer_id)
+                ->where('history_order_id', $order->id)
+                ->where('type_of_transaction', RewardActiveRule::TYPE_ORDER)
+                ->whereIn('status', [
+                    RewardPointHistory::STATUS_PENDING,
+                    RewardPointHistory::STATUS_COMPLETED
+                ])
+                ->get();
 
 
             foreach ($histories as $history) {
             foreach ($histories as $history) {
                 $this->processCancellationHistory($history, $order);
                 $this->processCancellationHistory($history, $order);

+ 10 - 0
packages/Webkul/Admin/src/DataGrids/Customers/CustomerDataGrid.php

@@ -49,6 +49,7 @@ class CustomerDataGrid extends DataGrid
                 'customers.is_suspended',
                 'customers.is_suspended',
                 'customer_groups.name as group',
                 'customer_groups.name as group',
                 'customers.channel_id',
                 'customers.channel_id',
+                'customers.customer_source',
             )
             )
             ->addSelect(DB::raw('COUNT(DISTINCT '.$tablePrefix.'addresses.id) as address_count'))
             ->addSelect(DB::raw('COUNT(DISTINCT '.$tablePrefix.'addresses.id) as address_count'))
             ->addSelect(DB::raw('COUNT(DISTINCT '.$tablePrefix.'orders.id) as order_count'))
             ->addSelect(DB::raw('COUNT(DISTINCT '.$tablePrefix.'orders.id) as order_count'))
@@ -60,6 +61,7 @@ class CustomerDataGrid extends DataGrid
         $this->addFilter('email', 'customers.email');
         $this->addFilter('email', 'customers.email');
         $this->addFilter('full_name', DB::raw('CONCAT('.$tablePrefix.'customers.first_name, " ", '.$tablePrefix.'customers.last_name)'));
         $this->addFilter('full_name', DB::raw('CONCAT('.$tablePrefix.'customers.first_name, " ", '.$tablePrefix.'customers.last_name)'));
         $this->addFilter('group', 'customer_groups.name');
         $this->addFilter('group', 'customer_groups.name');
+        $this->addFilter('customer_source', 'customers.customer_source');
         $this->addFilter('phone', 'customers.phone');
         $this->addFilter('phone', 'customers.phone');
         $this->addFilter('status', 'customers.status');
         $this->addFilter('status', 'customers.status');
 
 
@@ -153,6 +155,14 @@ class CustomerDataGrid extends DataGrid
             'filterable_options' => $this->customerGroupRepository->all(['name as label', 'name as value'])->toArray(),
             'filterable_options' => $this->customerGroupRepository->all(['name as label', 'name as value'])->toArray(),
         ]);
         ]);
 
 
+        $this->addColumn([
+            'index'      => 'customer_source',
+            'label'      => trans('admin::app.customers.customers.index.datagrid.source'),
+            'type'       => 'string',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
         $this->addColumn([
         $this->addColumn([
             'index'    => 'is_suspended',
             'index'    => 'is_suspended',
             'label'    => trans('admin::app.customers.customers.index.datagrid.suspended'),
             'label'    => trans('admin::app.customers.customers.index.datagrid.suspended'),

+ 2 - 0
packages/Webkul/Admin/src/Resources/lang/en/app.php

@@ -1826,6 +1826,7 @@ return [
                     'partial-action' => 'Some actions were not performed due restricted system constraints on :resource',
                     'partial-action' => 'Some actions were not performed due restricted system constraints on :resource',
                     'phone'          => 'Contact Number',
                     'phone'          => 'Contact Number',
                     'revenue'        => 'Revenue',
                     'revenue'        => 'Revenue',
+                    'source'         => 'Customer Source',
                     'status'         => 'Status',
                     'status'         => 'Status',
                     'suspended'      => 'Suspended',
                     'suspended'      => 'Suspended',
                     'update-status'  => 'Update Status',
                     'update-status'  => 'Update Status',
@@ -1874,6 +1875,7 @@ return [
                 'order-create-confirmation'   => 'Are you sure you want to create order for this customer?',
                 'order-create-confirmation'   => 'Are you sure you want to create order for this customer?',
                 'phone'                       => 'Phone - :phone',
                 'phone'                       => 'Phone - :phone',
                 'set-as-default'              => 'Set as Default',
                 'set-as-default'              => 'Set as Default',
+                'source'                      => 'Source - :source',
                 'suspended'                   => 'Suspended',
                 'suspended'                   => 'Suspended',
                 'title'                       => 'Customer View',
                 'title'                       => 'Customer View',
 
 

+ 2 - 0
packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php

@@ -1706,6 +1706,7 @@ return [
                     'partial-action' => '由于 :resource 上的受限制的系统约束,未执行一些操作',
                     'partial-action' => '由于 :resource 上的受限制的系统约束,未执行一些操作',
                     'phone'          => '联系号码',
                     'phone'          => '联系号码',
                     'revenue'        => '收入',
                     'revenue'        => '收入',
+                    'source'         => '客户来源',
                     'status'         => '状态',
                     'status'         => '状态',
                     'suspended'      => '已暂停',
                     'suspended'      => '已暂停',
                     'update-status'  => '更新状态',
                     'update-status'  => '更新状态',
@@ -1754,6 +1755,7 @@ return [
                 'order-create-confirmation'   => '您确定要为此客户创建订单吗?',
                 'order-create-confirmation'   => '您确定要为此客户创建订单吗?',
                 'phone'                       => '电话 - :phone',
                 'phone'                       => '电话 - :phone',
                 'set-as-default'              => '设为默认',
                 'set-as-default'              => '设为默认',
+                'source'                      => '来源 - :source',
                 'suspended'                   => '已暂停',
                 'suspended'                   => '已暂停',
                 'title'                       => '客户视图',
                 'title'                       => '客户视图',
 
 

+ 5 - 1
packages/Webkul/Admin/src/Resources/views/customers/customers/index.blade.php

@@ -64,7 +64,7 @@
                 <div class="row grid grid-cols-1 md:grid-cols-[2fr_1fr_1fr] grid-rows-1 gap-1 items-center border-b px-4 py-2.5 dark:border-gray-800 min-w-full">
                 <div class="row grid grid-cols-1 md:grid-cols-[2fr_1fr_1fr] grid-rows-1 gap-1 items-center border-b px-4 py-2.5 dark:border-gray-800 min-w-full">
                     <div
                     <div
                         class="flex select-none items-center gap-2.5"
                         class="flex select-none items-center gap-2.5"
-                        v-for="(columnGroup, index) in [['full_name', 'email', 'phone'], ['status', 'gender', 'group', 'customer_id'], ['revenue', 'order_count', 'address_count']]"
+                        v-for="(columnGroup, index) in [['full_name', 'email', 'phone'], ['status', 'gender', 'group', 'customer_source', 'customer_id'], ['revenue', 'order_count', 'address_count']]"
                     >
                     >
                         @if ($hasPermission)
                         @if ($hasPermission)
                             <label
                             <label
@@ -201,6 +201,10 @@
                             @{{ record.group ?? 'N/A' }}
                             @{{ record.group ?? 'N/A' }}
                         </p>
                         </p>
 
 
+                        <p class="text-gray-600 dark:text-gray-300">
+                            @{{ record.customer_source ?? 'N/A' }}
+                        </p>
+
                         <p class="text-gray-600 dark:text-gray-300">
                         <p class="text-gray-600 dark:text-gray-300">
                             @{{ "@lang('admin::app.customers.customers.index.datagrid.id-value')".replace(':id', record.customer_id) }}
                             @{{ "@lang('admin::app.customers.customers.index.datagrid.id-value')".replace(':id', record.customer_id) }}
                         </p>
                         </p>

+ 4 - 0
packages/Webkul/Admin/src/Resources/views/customers/customers/view.blade.php

@@ -218,6 +218,10 @@
                                     <p class="text-gray-600 dark:text-gray-300">
                                     <p class="text-gray-600 dark:text-gray-300">
                                         @{{ "@lang('admin::app.customers.customers.view.group')".replace(':group_code', customer.group?.name ?? 'N/A') }}
                                         @{{ "@lang('admin::app.customers.customers.view.group')".replace(':group_code', customer.group?.name ?? 'N/A') }}
                                     </p>
                                     </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        @{{ "@lang('admin::app.customers.customers.view.source')".replace(':source', customer.customer_source ?? 'N/A') }}
+                                    </p>
                                 </div>
                                 </div>
                             </x-slot:content>
                             </x-slot:content>
                         </x-admin::accordion>
                         </x-admin::accordion>

+ 7 - 3
packages/Webkul/Admin/tests/Feature/Customers/CustomersTest.php

@@ -27,7 +27,8 @@ it('should returns the customers page', function () {
 it('should return listing items of customers', function () {
 it('should return listing items of customers', function () {
     // Arrange.
     // Arrange.
     $customer = (new CustomerFaker)->factory()->create([
     $customer = (new CustomerFaker)->factory()->create([
-        'password' => Hash::make('admin123'),
+        'password'        => Hash::make('admin123'),
+        'customer_source' => 'popup',
     ]);
     ]);
 
 
     // Act and Assert.
     // Act and Assert.
@@ -39,13 +40,15 @@ it('should return listing items of customers', function () {
         ->assertOk()
         ->assertOk()
         ->assertJsonPath('records.0.customer_id', $customer->id)
         ->assertJsonPath('records.0.customer_id', $customer->id)
         ->assertJsonPath('records.0.email', $customer->email)
         ->assertJsonPath('records.0.email', $customer->email)
-        ->assertJsonPath('records.0.full_name', $customer->name);
+        ->assertJsonPath('records.0.full_name', $customer->name)
+        ->assertJsonPath('records.0.customer_source', 'popup');
 });
 });
 
 
 it('should return the view page of customer', function () {
 it('should return the view page of customer', function () {
     // Arrange.
     // Arrange.
     $customer = (new CustomerFaker)->factory()->create([
     $customer = (new CustomerFaker)->factory()->create([
-        'password' => Hash::make('admin123'),
+        'password'        => Hash::make('admin123'),
+        'customer_source' => 'popup',
     ]);
     ]);
 
 
     // Act and Assert.
     // Act and Assert.
@@ -58,6 +61,7 @@ it('should return the view page of customer', function () {
         ->assertSeeText($customer->gender)
         ->assertSeeText($customer->gender)
         ->assertSeeText($customer->email)
         ->assertSeeText($customer->email)
         ->assertSeeText($customer->phone)
         ->assertSeeText($customer->phone)
+        ->assertSeeText('popup')
         ->assertSeeText(trans('admin::app.customers.customers.view.title'));
         ->assertSeeText(trans('admin::app.customers.customers.view.title'));
 });
 });
 
 

+ 1 - 1
packages/Webkul/BagistoApi/README.md

@@ -115,7 +115,7 @@ Both paths use the same state machine:
 | `paymentInitiateCreate`   | Create the Bagisto order + a gateway order id in one shot. Returns the new cart token so the buyer can keep adding products to a fresh cart. Set `expressCheckout: true` to skip shipping/email validation. |
 | `paymentInitiateCreate`   | Create the Bagisto order + a gateway order id in one shot. Returns the new cart token so the buyer can keep adding products to a fresh cart. Set `expressCheckout: true` to skip shipping/email validation. |
 | `paymentCallbackCreate`   | Frontend hits this after the gateway redirect. `status = success` triggers capture + (for express orders) writes the real shipping/billing address. `status = cancel | failure` leaves the order pending. |
 | `paymentCallbackCreate`   | Frontend hits this after the gateway redirect. `status = success` triggers capture + (for express orders) writes the real shipping/billing address. `status = cancel | failure` leaves the order pending. |
 | `paymentReplayCreate`     | Authenticated. Generates a fresh gateway order id for a still-pending order so the buyer can retry payment. |
 | `paymentReplayCreate`     | Authenticated. Generates a fresh gateway order id for a still-pending order so the buyer can retry payment. |
-| `cancelOrderCreate`       | Existing mutation; now strategy-aware: guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
+| `cancelOrderCreate`       | Existing mutation; now strategy-aware: `userManual: true` always cancels immediately; guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
 
 
 ### `payment.additional` keys
 ### `payment.additional` keys
 
 

+ 25 - 0
packages/Webkul/BagistoApi/config/bagistoapi.php

@@ -48,6 +48,31 @@ return [
         ],
         ],
     ],
     ],
 
 
+    /*
+    |--------------------------------------------------------------------------
+    | Frontend URLs
+    |--------------------------------------------------------------------------
+    |
+    | frontend.reset_password_url
+    |   Generic base URL of the reset-password page on the headless frontend,
+    |   e.g. https://shop.example.com/reset-password. Used when no device
+    |   specific URL below is configured.
+    |
+    | frontend.reset_password_url_pc / frontend.reset_password_url_mobile
+    |   Device specific base URLs. PC requests use reset_password_url_pc,
+    |   mobile requests use reset_password_url_mobile (detected from the
+    |   X-Device-Type header or the User-Agent).
+    |
+    | The token and email are appended as query parameters
+    | (?token=...&email=...). When every option is empty, emails fall back to
+    | the built-in Shop storefront page.
+    */
+    'frontend' => [
+        'reset_password_url'        => env('FRONTEND_RESET_PASSWORD_URL', ''),
+        'reset_password_url_pc'     => env('FRONTEND_RESET_PASSWORD_URL_PC', ''),
+        'reset_password_url_mobile' => env('FRONTEND_RESET_PASSWORD_URL_MOBILE', ''),
+    ],
+
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
     | Payment Reconciliation Job
     | Payment Reconciliation Job

+ 14 - 0
packages/Webkul/BagistoApi/src/Dto/CancelOrderInput.php

@@ -5,6 +5,7 @@ namespace Webkul\BagistoApi\Dto;
 use ApiPlatform\Metadata\ApiProperty;
 use ApiPlatform\Metadata\ApiProperty;
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\ApiResource;
 use Symfony\Component\Serializer\Annotation\Groups;
 use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
 
 
 /**
 /**
  * DTO for canceling a customer order
  * DTO for canceling a customer order
@@ -22,5 +23,18 @@ class CancelOrderInput
         required: true
         required: true
     )]
     )]
     #[Groups(['mutation'])]
     #[Groups(['mutation'])]
+    #[SerializedName('orderId')]
     public ?int $orderId = null;
     public ?int $orderId = null;
+
+    /**
+     * When true, skip keep-pending and cancel the order immediately.
+     * Used when the customer explicitly cancels from the order list.
+     */
+    #[ApiProperty(
+        description: 'When true, cancel the order immediately instead of keeping it pending for payment retry',
+        required: false
+    )]
+    #[Groups(['mutation'])]
+    #[SerializedName('userManual')]
+    public ?bool $userManual = null;
 }
 }

+ 27 - 0
packages/Webkul/BagistoApi/src/Dto/ResetPasswordInput.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+class ResetPasswordInput
+{
+    #[ApiProperty(writable: true, readable: false)]
+    #[Groups(['mutation'])]
+    public string $token;
+
+    #[ApiProperty(writable: true, readable: false)]
+    #[Groups(['mutation'])]
+    public string $email;
+
+    #[ApiProperty(writable: true, readable: false)]
+    #[Groups(['mutation'])]
+    public string $password;
+
+    #[ApiProperty(writable: true, readable: false)]
+    #[Groups(['mutation'])]
+    #[SerializedName('passwordConfirmation')]
+    public ?string $passwordConfirmation = null;
+}

+ 6 - 1
packages/Webkul/BagistoApi/src/Models/CancelOrder.php

@@ -47,9 +47,14 @@ use ApiPlatform\OpenApi\Model\Operation;
                                         'description' => 'The ID of the order to cancel.',
                                         'description' => 'The ID of the order to cancel.',
                                         'example'     => 411,
                                         'example'     => 411,
                                     ],
                                     ],
+                                    'userManual' => [
+                                        'type'        => 'boolean',
+                                        'description' => 'When true, cancel immediately instead of keeping the order pending for payment retry.',
+                                        'example'     => true,
+                                    ],
                                 ],
                                 ],
                             ],
                             ],
-                            'example' => ['orderId' => 411],
+                            'example' => ['orderId' => 411, 'userManual' => true],
                         ],
                         ],
                     ]),
                     ]),
                 ),
                 ),

+ 69 - 0
packages/Webkul/BagistoApi/src/Models/ResetPassword.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use Webkul\BagistoApi\Dto\ResetPasswordInput;
+use Webkul\BagistoApi\State\ResetPasswordProcessor;
+
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'ResetPassword',
+    operations: [
+        new Post(
+            uriTemplate: '/reset-passwords',
+            processor: ResetPasswordProcessor::class,
+            normalizationContext: ['skip_null_values' => false],
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            openapi: new Operation(
+                tags: ['Customer'],
+                summary: 'Reset customer password',
+                description: 'Resets the customer password using the token received via the forgot-password email.',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/json' => [
+                            'schema' => [
+                                'type'     => 'object',
+                                'required' => ['token', 'email', 'password', 'passwordConfirmation'],
+                                'properties' => [
+                                    'token'                => ['type' => 'string'],
+                                    'email'                => ['type' => 'string', 'example' => 'customer@example.com'],
+                                    'password'             => ['type' => 'string'],
+                                    'passwordConfirmation' => ['type' => 'string'],
+                                ],
+                            ],
+                        ],
+                    ]),
+                ),
+            ),
+        ),
+    ],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: ResetPasswordInput::class,
+            output: self::class,
+            processor: ResetPasswordProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+        ),
+    ]
+)]
+class ResetPassword
+{
+    #[ApiProperty(writable: false, readable: true)]
+    public ?bool $success = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    public ?string $message = null;
+}

+ 8 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -74,6 +74,7 @@ use Webkul\BagistoApi\State\DownloadableSamplesProvider;
 use Webkul\BagistoApi\State\FilterableAttributesProvider;
 use Webkul\BagistoApi\State\FilterableAttributesProvider;
 use Webkul\BagistoApi\State\ForgotPasswordProcessor;
 use Webkul\BagistoApi\State\ForgotPasswordProcessor;
 use Webkul\BagistoApi\State\GetCheckoutAddressCollectionProvider;
 use Webkul\BagistoApi\State\GetCheckoutAddressCollectionProvider;
+use Webkul\BagistoApi\State\ResetPasswordProcessor;
 use Webkul\BagistoApi\State\GroupedProductsProvider;
 use Webkul\BagistoApi\State\GroupedProductsProvider;
 use Webkul\BagistoApi\State\LoginProcessor;
 use Webkul\BagistoApi\State\LoginProcessor;
 use Webkul\BagistoApi\State\LogoutProcessor;
 use Webkul\BagistoApi\State\LogoutProcessor;
@@ -181,6 +182,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(VerifyTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(VerifyTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(LogoutProcessor::class, ProcessorInterface::class);
         $this->app->tag(LogoutProcessor::class, ProcessorInterface::class);
         $this->app->tag(ForgotPasswordProcessor::class, ProcessorInterface::class);
         $this->app->tag(ForgotPasswordProcessor::class, ProcessorInterface::class);
+        $this->app->tag(ResetPasswordProcessor::class, ProcessorInterface::class);
         $this->app->tag(CustomerProfileProcessor::class, ProcessorInterface::class);
         $this->app->tag(CustomerProfileProcessor::class, ProcessorInterface::class);
         $this->app->tag(CustomerAddressTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(CustomerAddressTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(CartTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(CartTokenProcessor::class, ProcessorInterface::class);
@@ -241,6 +243,12 @@ class BagistoApiServiceProvider extends ServiceProvider
             );
             );
         });
         });
 
 
+        $this->app->singleton(ResetPasswordProcessor::class, function ($app) {
+            return new ResetPasswordProcessor(
+                $app->make('Webkul\Customer\Repositories\CustomerRepository')
+            );
+        });
+
         $this->app->singleton(CartTokenProcessor::class, function ($app) {
         $this->app->singleton(CartTokenProcessor::class, function ($app) {
             return new CartTokenProcessor(
             return new CartTokenProcessor(
                 $app->make('Webkul\Checkout\Repositories\CartRepository'),
                 $app->make('Webkul\Checkout\Repositories\CartRepository'),

+ 13 - 1
packages/Webkul/BagistoApi/src/Resources/lang/en/app.php

@@ -72,6 +72,18 @@ return [
             'error-sending-reset-link'          => 'An error occurred while sending reset link',
             'error-sending-reset-link'          => 'An error occurred while sending reset link',
         ],
         ],
 
 
+        'reset-password' => [
+            'invalid-operation'                 => 'Invalid operation',
+            'invalid-input-data'                => 'Invalid input data',
+            'token-required'                    => 'Token is required',
+            'email-required'                    => 'Email is required',
+            'password-required'                 => 'New password is required',
+            'password-reset'                    => 'Password has been reset successfully',
+            'invalid-token'                     => 'This password reset token is invalid',
+            'email-not-found'                   => 'Email address not found',
+            'error-resetting'                   => 'An error occurred while resetting password',
+        ],
+
         'logout' => [
         'logout' => [
             'invalid-operation'                 => 'Invalid operation',
             'invalid-operation'                 => 'Invalid operation',
             'invalid-input-data'                => 'Invalid input data',
             'invalid-input-data'                => 'Invalid input data',
@@ -109,7 +121,7 @@ return [
             'id-required'                       => 'Customer ID is required',
             'id-required'                       => 'Customer ID is required',
             'invalid-id-format'                 => 'Invalid ID format. Expected IRI format like "/api/admin/customers/1" or numeric ID',
             'invalid-id-format'                 => 'Invalid ID format. Expected IRI format like "/api/admin/customers/1" or numeric ID',
             'not-found'                         => 'Customer not found',
             'not-found'                         => 'Customer not found',
-            'phone-special-chars-not-allowed'   => 'Mobile number can only contain digits. Special characters are not allowed',
+            'phone-invalid-format'              => 'Invalid phone number format. Please use a valid phone number, e.g. "+1 212 316 5641"',
             'invalid-gender'                    => 'Invalid gender value ":gender". Allowed values are: :valid',
             'invalid-gender'                    => 'Invalid gender value ":gender". Allowed values are: :valid',
         ],
         ],
 
 

+ 13 - 1
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -291,9 +291,21 @@ class PaymentService
      * Decide what to do with a pending order when the cancel mutation
      * Decide what to do with a pending order when the cancel mutation
      * is invoked. Returns an array describing the action so the caller
      * is invoked. Returns an array describing the action so the caller
      * can report it back to the client.
      * can report it back to the client.
+     *
+     * A user-initiated cancel (`userManual`) always cancels immediately,
+     * including the customer-with-address path that would otherwise keep
+     * the order pending for paymentReplay.
      */
      */
-    public function decideCancelStrategy($order, bool $isGuest): array
+    public function decideCancelStrategy($order, bool $isGuest, bool $userManual = false): array
     {
     {
+        if ($userManual) {
+            return [
+                'action'          => 'cancel',
+                'reason'          => 'user_manual',
+                'reactivate_cart' => $isGuest ? $this->resolveOldCartId($order) : null,
+            ];
+        }
+
         $hasShippingAddress = (bool) $order->shipping_address && ! $this->isPlaceholderAddress($order->shipping_address);
         $hasShippingAddress = (bool) $order->shipping_address && ! $this->isPlaceholderAddress($order->shipping_address);
 
 
         if ($isGuest) {
         if ($isGuest) {

+ 58 - 15
packages/Webkul/BagistoApi/src/State/CancelOrderProcessor.php

@@ -21,6 +21,7 @@ use Webkul\Sales\Repositories\OrderRepository;
  * CancelOrderProcessor — Handles the cancel order mutation
  * CancelOrderProcessor — Handles the cancel order mutation
  *
  *
  * Strategy:
  * Strategy:
+ *  - userManual=true: always cancel immediately (customer clicked cancel).
  *  - Guest cancel: cancel immediately + reactivate the original cart so
  *  - Guest cancel: cancel immediately + reactivate the original cart so
  *    the buyer can keep going if they change their mind.
  *    the buyer can keep going if they change their mind.
  *  - Customer with a real shipping address: keep order PENDING so the
  *  - Customer with a real shipping address: keep order PENDING so the
@@ -66,7 +67,11 @@ class CancelOrderProcessor implements ProcessorInterface
             );
             );
         }
         }
 
 
-        $strategy = $this->paymentService->decideCancelStrategy($order, $isGuest);
+        $strategy = $this->paymentService->decideCancelStrategy(
+            $order,
+            $isGuest,
+            (bool) $input->userManual,
+        );
 
 
         if ($strategy['action'] === 'keep_pending') {
         if ($strategy['action'] === 'keep_pending') {
             Event::dispatch('bagistoapi.order.cancel.kept-pending', [
             Event::dispatch('bagistoapi.order.cancel.kept-pending', [
@@ -185,27 +190,44 @@ class CancelOrderProcessor implements ProcessorInterface
      */
      */
     private function hydrateInputFromContext(CancelOrderInput $data, array $context): void
     private function hydrateInputFromContext(CancelOrderInput $data, array $context): void
     {
     {
-        if (! empty($data->orderId)) {
-            return;
-        }
-
         $input = $context['args']['input'] ?? $context['args'] ?? null;
         $input = $context['args']['input'] ?? $context['args'] ?? null;
 
 
-        $orderId = $this->extractOrderId($input);
+        if (empty($data->orderId)) {
+            $orderId = $this->extractOrderId($input);
 
 
-        if ($orderId === null) {
-            $request = Request::instance();
+            if ($orderId === null) {
+                $request = Request::instance();
+
+                if ($request) {
+                    $orderId = $this->extractOrderId($request->input('variables.input'))
+                        ?? $this->extractOrderId($request->input('input'))
+                        ?? $this->extractOrderId($request->input('extensions.variables.input'))
+                        ?? $this->extractOrderId($request->all());
+                }
+            }
 
 
-            if ($request) {
-                $orderId = $this->extractOrderId($request->input('variables.input'))
-                    ?? $this->extractOrderId($request->input('input'))
-                    ?? $this->extractOrderId($request->input('extensions.variables.input'))
-                    ?? $this->extractOrderId($request->all());
+            if ($orderId !== null) {
+                $data->orderId = $orderId;
             }
             }
         }
         }
 
 
-        if ($orderId !== null) {
-            $data->orderId = $orderId;
+        if ($data->userManual === null) {
+            $userManual = $this->extractUserManual($input);
+
+            if ($userManual === null) {
+                $request = Request::instance();
+
+                if ($request) {
+                    $userManual = $this->extractUserManual($request->input('variables.input'))
+                        ?? $this->extractUserManual($request->input('input'))
+                        ?? $this->extractUserManual($request->input('extensions.variables.input'))
+                        ?? $this->extractUserManual($request->all());
+                }
+            }
+
+            if ($userManual !== null) {
+                $data->userManual = $userManual;
+            }
         }
         }
     }
     }
 
 
@@ -225,4 +247,25 @@ class CancelOrderProcessor implements ProcessorInterface
 
 
         return null;
         return null;
     }
     }
+
+    private function extractUserManual(mixed $input): ?bool
+    {
+        if (is_array($input)) {
+            $value = $input['userManual'] ?? $input['user_manual'] ?? null;
+        } elseif (is_object($input)) {
+            $value = $input->userManual ?? $input->user_manual ?? null;
+        } else {
+            return null;
+        }
+
+        if ($value === null) {
+            return null;
+        }
+
+        if (is_bool($value)) {
+            return $value;
+        }
+
+        return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+    }
 }
 }

+ 28 - 11
packages/Webkul/BagistoApi/src/State/CustomerProfileProcessor.php

@@ -139,9 +139,8 @@ class CustomerProfileProcessor implements ProcessorInterface
         }
         }
 
 
         if (is_object($data) && property_exists($data, 'phone') && ! empty($data->phone)) {
         if (is_object($data) && property_exists($data, 'phone') && ! empty($data->phone)) {
-            // Validate phone - no special characters allowed
-            $this->validatePhone($data->phone);
-            $updateData['phone'] = $data->phone;
+            // Validate phone - supports international format with country code, e.g. "+1 212 316 5641"
+            $updateData['phone'] = $this->validatePhone($data->phone);
         }
         }
 
 
         if (is_object($data) && property_exists($data, 'gender') && ! empty($data->gender)) {
         if (is_object($data) && property_exists($data, 'gender') && ! empty($data->gender)) {
@@ -314,22 +313,40 @@ class CustomerProfileProcessor implements ProcessorInterface
     }
     }
 
 
     /**
     /**
-     * Validate phone number - only digits allowed
+     * Validate phone number - supports international format.
+     *
+     * Allowed formats (optional "+" country code prefix, digits separated by
+     * spaces, hyphens, dots or parentheses):
+     *   +1 2123165641, +86 138 0013 8000, (212) 316-5641, 2123165641
+     *
+     * Returns the phone as-is (trimmed). Separators are preserved, so the
+     * stored value keeps the user's original formatting, e.g. "+1 530 996 4115".
      *
      *
      * @throws InvalidInputException
      * @throws InvalidInputException
      */
      */
-    private function validatePhone(?string $phone): void
+    private function validatePhone(?string $phone): ?string
     {
     {
         if ($phone === null || $phone === '') {
         if ($phone === null || $phone === '') {
-            return;
+            return null;
         }
         }
 
 
-        // Phone should only contain digits - remove all non-digit characters
-        $cleanedPhone = preg_replace('/[^0-9]/', '', $phone);
+        $phone = trim($phone);
 
 
-        // If the cleaned phone is different from original, it means special characters were present
-        if ($cleanedPhone !== $phone) {
-            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-special-chars-not-allowed'));
+        // "+" may only appear once, at the very beginning; the rest must be
+        // digits with optional space/hyphen/dot/parenthesis separators.
+        if (! preg_match('/^\+?\d[\d\s\-()\.]*$/', $phone)) {
+            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-invalid-format'));
         }
         }
+
+        // After stripping separators, the number of digits must match the
+        // international E.164 range (7 to 15 digits).
+        $digits = preg_replace('/[^0-9]/', '', $phone);
+
+        if (strlen($digits) < 7 || strlen($digits) > 15) {
+            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-invalid-format'));
+        }
+
+        // No separator stripping: keep the user's input (trimmed) exactly as-is.
+        return $phone;
     }
     }
 }
 }

+ 123 - 0
packages/Webkul/BagistoApi/src/State/ResetPasswordProcessor.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use Illuminate\Auth\Events\PasswordReset;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Password;
+use Illuminate\Support\Str;
+use Webkul\BagistoApi\Dto\ResetPasswordInput;
+use Webkul\Customer\Repositories\CustomerRepository;
+
+class ResetPasswordProcessor implements ProcessorInterface
+{
+    public function __construct(protected CustomerRepository $customerRepository) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
+    {
+        $defaultResponse = [
+            'success' => false,
+            'message' => '',
+        ];
+
+        $isRestPost = $operation instanceof Post;
+        $isGraphQlCreate = $operation->getName() === 'create';
+
+        if (! $isRestPost && ! $isGraphQlCreate) {
+            $defaultResponse['message'] = __('bagistoapi::app.graphql.reset-password.invalid-operation');
+
+            return (object) $defaultResponse;
+        }
+
+        if ($isRestPost && ! $data instanceof ResetPasswordInput) {
+            $input = new ResetPasswordInput;
+            $input->token = (string) (request()->input('token') ?? '');
+            $input->email = (string) (request()->input('email') ?? '');
+            $input->password = (string) (request()->input('password') ?? '');
+            $input->passwordConfirmation = (string) (request()->input('passwordConfirmation') ?? request()->input('password_confirmation') ?? '');
+            $data = $input;
+        }
+
+        if (! $data instanceof ResetPasswordInput) {
+            $defaultResponse['message'] = __('bagistoapi::app.graphql.reset-password.invalid-input-data');
+
+            return (object) $defaultResponse;
+        }
+
+        if (empty($data->token)) {
+            $defaultResponse['message'] = __('bagistoapi::app.graphql.reset-password.token-required');
+
+            return (object) $defaultResponse;
+        }
+
+        if (empty($data->email)) {
+            $defaultResponse['message'] = __('bagistoapi::app.graphql.reset-password.email-required');
+
+            return (object) $defaultResponse;
+        }
+
+        if (empty($data->password)) {
+            $defaultResponse['message'] = __('bagistoapi::app.graphql.reset-password.password-required');
+
+            return (object) $defaultResponse;
+        }
+
+        try {
+            $response = $this->broker()->reset([
+                'email'                 => $data->email,
+                'password'              => $data->password,
+                'password_confirmation' => $data->passwordConfirmation ?? $data->password,
+                'token'                 => $data->token,
+            ], function ($customer, $password) {
+                $this->resetPassword($customer, $password);
+            });
+
+            if ($response == Password::PASSWORD_RESET) {
+                $customer = $this->customerRepository->findOneByField('email', $data->email);
+
+                if ($customer) {
+                    Event::dispatch('customer.password.update.after', $customer);
+                }
+
+                return (object) [
+                    'success' => true,
+                    'message' => __('bagistoapi::app.graphql.reset-password.password-reset'),
+                ];
+            }
+
+            return (object) [
+                'success' => false,
+                'message' => trans($response),
+            ];
+
+        } catch (\Exception $e) {
+            return (object) [
+                'success' => false,
+                'message' => __('bagistoapi::app.graphql.reset-password.error-resetting'),
+            ];
+        }
+    }
+
+    /**
+     * Reset the given customer password.
+     */
+    protected function resetPassword($customer, $password)
+    {
+        $customer->password = Hash::make($password);
+
+        $customer->setRememberToken(Str::random(60));
+
+        $customer->save();
+
+        event(new PasswordReset($customer));
+    }
+
+    private function broker()
+    {
+        return Password::broker('customers');
+    }
+}

+ 71 - 0
packages/Webkul/BagistoApi/src/Support/Frontend.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Webkul\BagistoApi\Support;
+
+/**
+ * Resolves headless-frontend URLs.
+ *
+ * Supports separate base URLs per device type (PC / mobile), e.g.
+ * pc.sisbridal.com vs m.sisbridal.com. The device type is taken from the
+ * `X-Device-Type` request header when provided by the frontend, otherwise it
+ * is detected from the User-Agent.
+ */
+class Frontend
+{
+    public const DEVICE_PC = 'pc';
+
+    public const DEVICE_MOBILE = 'mobile';
+
+    /**
+     * Build the reset-password URL for the current request context.
+     *
+     * Resolution order:
+     *   1. `frontend.reset_password_url_mobile`  (mobile requests)
+     *      or `frontend.reset_password_url_pc`   (pc/desktop requests)
+     *   2. `frontend.reset_password_url`         (generic fallback)
+     *   3. built-in Shop storefront reset page   (final fallback)
+     */
+    public static function resetPasswordUrl(string $token, string $email = ''): string
+    {
+        $config = config('bagistoapi.frontend', []);
+
+        $baseUrl = self::isMobileRequest()
+            ? ($config['reset_password_url_mobile'] ?? '')
+            : ($config['reset_password_url_pc'] ?? '');
+
+        if (empty($baseUrl)) {
+            $baseUrl = $config['reset_password_url'] ?? '';
+        }
+
+        if (empty($baseUrl)) {
+            return route('shop.customers.reset_password.create', $token);
+        }
+
+        return rtrim($baseUrl, '/').'?token='.$token.'&email='.urlencode($email);
+    }
+
+    /**
+     * Detect whether the current request comes from a mobile device.
+     *
+     * The frontend may declare the device explicitly via the `X-Device-Type`
+     * header (`mobile` or `pc`); otherwise the User-Agent is inspected.
+     */
+    public static function isMobileRequest(): bool
+    {
+        $request = request();
+
+        $declared = strtolower(trim((string) optional($request)->header('X-Device-Type', '')));
+
+        if (in_array($declared, ['mobile', 'm', 'app'])) {
+            return true;
+        }
+
+        if (in_array($declared, ['pc', 'desktop', 'web'])) {
+            return false;
+        }
+
+        $userAgent = strtolower((string) optional($request)->header('User-Agent', ''));
+
+        return (bool) preg_match('/mobile|android|iphone|ipad|ipod|windows\s?phone|opera\s?mini|iemobile|blackberry/i', $userAgent);
+    }
+}

+ 35 - 12
packages/Webkul/BagistoApi/src/Validators/CustomerValidator.php

@@ -28,6 +28,11 @@ class CustomerValidator
             'password'   => 'min:6|required',
             'password'   => 'min:6|required',
         ];
         ];
 
 
+        // Trim phone first so the unique check and the stored value always match.
+        if ($customer->phone !== null) {
+            $customer->phone = trim($customer->phone);
+        }
+
         $data = [
         $data = [
             'first_name'            => $customer->first_name,
             'first_name'            => $customer->first_name,
             'last_name'             => $customer->last_name,
             'last_name'             => $customer->last_name,
@@ -46,7 +51,7 @@ class CustomerValidator
         }
         }
 
 
         // Additional custom validations
         // Additional custom validations
-        $this->validatePhone($customer->phone);
+        $customer->phone = $this->validatePhone($customer->phone);
         $this->validateGender($customer->gender);
         $this->validateGender($customer->gender);
     }
     }
 
 
@@ -78,10 +83,10 @@ class CustomerValidator
         }
         }
 
 
         if ($customer->phone !== null) {
         if ($customer->phone !== null) {
+            // Validate phone format and normalize before storage
+            $customer->phone = $this->validatePhone($customer->phone);
             $data['phone'] = $customer->phone;
             $data['phone'] = $customer->phone;
             $rules['phone'] = 'string|unique:customers,phone,'.$customer->id;
             $rules['phone'] = 'string|unique:customers,phone,'.$customer->id;
-            // Validate phone format
-            $this->validatePhone($customer->phone);
         }
         }
 
 
         // Validate gender if provided
         // Validate gender if provided
@@ -109,23 +114,41 @@ class CustomerValidator
     }
     }
 
 
     /**
     /**
-     * Validate phone number - only digits allowed
+     * Validate phone number - supports international format.
+     *
+     * Allowed formats (optional "+" country code prefix, digits separated by
+     * spaces, hyphens, dots or parentheses):
+     *   +1 2123165641, +86 138 0013 8000, (212) 316-5641, 2123165641
+     *
+     * Returns the phone as-is (trimmed). Separators are preserved, so the
+     * stored value keeps the user's original formatting, e.g. "+1 530 996 4115".
      *
      *
      * @throws InvalidInputException
      * @throws InvalidInputException
      */
      */
-    private function validatePhone(?string $phone): void
+    private function validatePhone(?string $phone): ?string
     {
     {
         if ($phone === null || $phone === '') {
         if ($phone === null || $phone === '') {
-            return;
+            return null;
         }
         }
 
 
-        // Phone should only contain digits - remove all non-digit characters
-        $cleanedPhone = preg_replace('/[^0-9]/', '', $phone);
-        
-        // If the cleaned phone is different from original, it means special characters were present
-        if ($cleanedPhone !== $phone) {
-            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-special-chars-not-allowed'));
+        $phone = trim($phone);
+
+        // "+" may only appear once, at the very beginning; the rest must be
+        // digits with optional space/hyphen/dot/parenthesis separators.
+        if (! preg_match('/^\+?\d[\d\s\-()\.]*$/', $phone)) {
+            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-invalid-format'));
         }
         }
+
+        // After stripping separators, the number of digits must match the
+        // international E.164 range (7 to 15 digits).
+        $digits = preg_replace('/[^0-9]/', '', $phone);
+
+        if (strlen($digits) < 7 || strlen($digits) > 15) {
+            throw new InvalidInputException(__('bagistoapi::app.graphql.customer.phone-invalid-format'));
+        }
+
+        // No separator stripping: keep the user's input (trimmed) exactly as-is.
+        return $phone;
     }
     }
 
 
     /**
     /**

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

@@ -4,6 +4,7 @@ namespace Webkul\BagistoApi\Tests\Unit\Migration;
 
 
 use App\Services\Asteria\Magento1CustomerReader;
 use App\Services\Asteria\Magento1CustomerReader;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 use Tests\TestCase;
 use Tests\TestCase;
 
 
 class Magento1CustomerReaderTest extends TestCase
 class Magento1CustomerReaderTest extends TestCase
@@ -39,6 +40,7 @@ class Magento1CustomerReaderTest extends TestCase
             'password_hash' => md5('abcsecret12').':abc',
             'password_hash' => md5('abcsecret12').':abc',
             'is_active'     => 1,
             'is_active'     => 1,
             'group_id'      => 99,
             'group_id'      => 99,
+            'source'        => 20,
         ]);
         ]);
         MagentoSchema::seedAddress($this->connection, [
         MagentoSchema::seedAddress($this->connection, [
             'entity_id'  => 21,
             'entity_id'  => 21,
@@ -82,6 +84,7 @@ class Magento1CustomerReaderTest extends TestCase
         $this->assertSame('1', (string) $customer['gender']);
         $this->assertSame('1', (string) $customer['gender']);
         $this->assertSame('1990-05-01 00:00:00', $customer['dob']);
         $this->assertSame('1990-05-01 00:00:00', $customer['dob']);
         $this->assertSame(md5('abcsecret12').':abc', $customer['password_hash']);
         $this->assertSame(md5('abcsecret12').':abc', $customer['password_hash']);
+        $this->assertSame('20', (string) $customer['source']);
 
 
         $addresses = $reader->fetchAddresses([10]);
         $addresses = $reader->fetchAddresses([10]);
         $this->assertCount(1, $addresses);
         $this->assertCount(1, $addresses);
@@ -109,4 +112,41 @@ class Magento1CustomerReaderTest extends TestCase
         $this->assertCount(1, $reader->fetchCustomers(10, 50));
         $this->assertCount(1, $reader->fetchCustomers(10, 50));
         $this->assertSame('second@example.com', $reader->fetchCustomers(10, 50)->first()['email']);
         $this->assertSame('second@example.com', $reader->fetchCustomers(10, 50)->first()['email']);
     }
     }
+
+    public function test_it_reads_newsletter_subscribers_by_customer_id_and_email(): void
+    {
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 1,
+            'customer_id'        => 10,
+            'subscriber_email'  => 'Jane@example.com',
+            'subscriber_status' => 1,
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 2,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'guest@example.com',
+            'subscriber_status' => 1,
+        ]);
+
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $forCustomer = $reader->fetchSubscribers([10], ['jane@example.com']);
+        $this->assertCount(1, $forCustomer);
+        $this->assertSame(1, $forCustomer->first()['subscriber_id']);
+        $this->assertSame(1, $forCustomer->first()['subscriber_status']);
+
+        $paged = $reader->fetchSubscribersAfter(0, 50);
+        $this->assertCount(2, $paged);
+        $this->assertSame(2, $reader->fetchSubscribersAfter(1, 50)->first()['subscriber_id']);
+    }
+
+    public function test_it_returns_no_subscribers_when_the_table_is_missing(): void
+    {
+        Schema::connection($this->connection)->drop('newsletter_subscriber');
+
+        $reader = new Magento1CustomerReader($this->connection);
+
+        $this->assertTrue($reader->fetchSubscribers([10], ['jane@example.com'])->isEmpty());
+        $this->assertTrue($reader->fetchSubscribersAfter(0, 50)->isEmpty());
+    }
 }
 }

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

@@ -12,6 +12,40 @@ class MagentoSchema
 
 
     public const ADDRESS_TYPE_ID = 2;
     public const ADDRESS_TYPE_ID = 2;
 
 
+    public const CATEGORY_TYPE_ID = 3;
+
+    public const PRODUCT_TYPE_ID = 4;
+
+    public const ATTR_PRODUCT_NAME = 70;
+
+    public const ATTR_PRODUCT_DESCRIPTION = 71;
+
+    public const ATTR_PRODUCT_SHORT_DESCRIPTION = 72;
+
+    public const ATTR_PRODUCT_PRICE = 73;
+
+    public const ATTR_PRODUCT_SPECIAL_PRICE = 74;
+
+    public const ATTR_PRODUCT_COST = 75;
+
+    public const ATTR_PRODUCT_WEIGHT = 76;
+
+    public const ATTR_PRODUCT_STATUS = 77;
+
+    public const ATTR_PRODUCT_META_TITLE = 78;
+
+    public const ATTR_PRODUCT_META_KEYWORD = 79;
+
+    public const ATTR_PRODUCT_META_DESCRIPTION = 80;
+
+    public const ATTR_PRODUCT_URL_KEY = 81;
+
+    public const ATTR_PRODUCT_HAIR_COLORR = 90;
+
+    public const ATTR_CATEGORY_NAME = 111;
+
+    public const HAIR_COLORR_OPTION_ID = 501;
+
     public static function create(string $connection): void
     public static function create(string $connection): void
     {
     {
         $schema = Schema::connection($connection);
         $schema = Schema::connection($connection);
@@ -26,6 +60,10 @@ class MagentoSchema
             $table->integer('entity_type_id');
             $table->integer('entity_type_id');
             $table->string('attribute_code');
             $table->string('attribute_code');
             $table->string('backend_type');
             $table->string('backend_type');
+            $table->string('frontend_label')->nullable();
+            $table->string('frontend_input')->nullable();
+            $table->integer('is_required')->default(0);
+            $table->integer('is_user_defined')->default(0);
         });
         });
 
 
         $schema->create('customer_entity', function (Blueprint $table) {
         $schema->create('customer_entity', function (Blueprint $table) {
@@ -78,6 +116,7 @@ class MagentoSchema
             ['attribute_id' => 14, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'default_shipping', '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' => 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' => 88, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
+            ['attribute_id' => 89, 'entity_type_id' => self::CUSTOMER_TYPE_ID, 'attribute_code' => 'source', 'backend_type' => 'int'],
             ['attribute_id' => 20, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'firstname', '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' => 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' => 22, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'company', 'backend_type' => 'varchar'],
@@ -89,6 +128,16 @@ class MagentoSchema
             ['attribute_id' => 28, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
             ['attribute_id' => 28, 'entity_type_id' => self::ADDRESS_TYPE_ID, 'attribute_code' => 'telephone', 'backend_type' => 'varchar'],
         ]);
         ]);
 
 
+        $schema->create('newsletter_subscriber', function (Blueprint $table) {
+            $table->integer('subscriber_id');
+            $table->integer('store_id')->default(0);
+            $table->string('change_status_at')->nullable();
+            $table->integer('customer_id')->default(0);
+            $table->string('subscriber_email')->nullable();
+            $table->integer('subscriber_status')->default(0);
+            $table->string('subscriber_confirm_code')->nullable();
+        });
+
         $schema->create('sales_flat_order', function (Blueprint $table) {
         $schema->create('sales_flat_order', function (Blueprint $table) {
             $table->integer('entity_id');
             $table->integer('entity_id');
             $table->string('increment_id')->nullable();
             $table->string('increment_id')->nullable();
@@ -122,6 +171,11 @@ class MagentoSchema
             $table->string('shipping_method')->nullable();
             $table->string('shipping_method')->nullable();
             $table->string('shipping_description')->nullable();
             $table->string('shipping_description')->nullable();
             $table->string('coupon_code')->nullable();
             $table->string('coupon_code')->nullable();
+            $table->integer('mw_rewardpoint')->default(0);
+            $table->decimal('mw_rewardpoint_discount', 12, 4)->default(0);
+            $table->decimal('mw_rewardpoint_discount_show', 12, 4)->default(0);
+            $table->decimal('amcheckoutfees_amount', 12, 4)->default(0);
+            $table->decimal('base_amcheckoutfees_amount', 12, 4)->default(0);
             $table->integer('total_item_count')->default(0);
             $table->integer('total_item_count')->default(0);
             $table->decimal('total_qty_ordered', 12, 4)->default(0);
             $table->decimal('total_qty_ordered', 12, 4)->default(0);
             $table->decimal('total_invoiced', 12, 4)->default(0);
             $table->decimal('total_invoiced', 12, 4)->default(0);
@@ -188,6 +242,17 @@ class MagentoSchema
             $table->decimal('amount_ordered', 12, 4)->nullable();
             $table->decimal('amount_ordered', 12, 4)->nullable();
             $table->decimal('base_amount_ordered', 12, 4)->nullable();
             $table->decimal('base_amount_ordered', 12, 4)->nullable();
         });
         });
+
+        $schema->create('mw_reward_point_order', function (Blueprint $table) {
+            $table->integer('order_id');
+            $table->integer('reward_point')->default(0);
+            $table->integer('rewardpoint_sell_product')->default(0);
+            $table->integer('earn_rewardpoint')->default(0);
+            $table->float('money')->default(0);
+            $table->string('reward_point_money_rate')->nullable();
+        });
+
+        self::createProductCatalog($connection);
     }
     }
 
 
     /**
     /**
@@ -233,6 +298,14 @@ class MagentoSchema
             ]);
             ]);
         }
         }
 
 
+        if (isset($data['source']) || isset($data['customer_source'])) {
+            DB::connection($connection)->table('customer_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => 89,
+                'value'        => $data['source'] ?? $data['customer_source'],
+            ]);
+        }
+
         if (! empty($data['default_billing'])) {
         if (! empty($data['default_billing'])) {
             DB::connection($connection)->table('customer_entity_int')->insert([
             DB::connection($connection)->table('customer_entity_int')->insert([
                 'entity_id'    => $entityId,
                 'entity_id'    => $entityId,
@@ -297,6 +370,22 @@ class MagentoSchema
         }
         }
     }
     }
 
 
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedSubscriber(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('newsletter_subscriber')->insert([
+            'subscriber_id'           => $data['subscriber_id'],
+            'store_id'                => $data['store_id'] ?? 1,
+            'change_status_at'        => $data['change_status_at'] ?? '2020-01-01 00:00:00',
+            'customer_id'             => $data['customer_id'] ?? 0,
+            'subscriber_email'       => $data['subscriber_email'],
+            'subscriber_status'      => $data['subscriber_status'] ?? 1,
+            'subscriber_confirm_code' => $data['subscriber_confirm_code'] ?? 'confirmcode',
+        ]);
+    }
+
     /**
     /**
      * @param  array<string, mixed>  $data
      * @param  array<string, mixed>  $data
      */
      */
@@ -337,6 +426,11 @@ class MagentoSchema
             'shipping_method'         => $data['shipping_method'] ?? 'flatrate_flatrate',
             'shipping_method'         => $data['shipping_method'] ?? 'flatrate_flatrate',
             'shipping_description'    => $data['shipping_description'] ?? 'Flat Rate - Fixed',
             'shipping_description'    => $data['shipping_description'] ?? 'Flat Rate - Fixed',
             'coupon_code'             => $data['coupon_code'] ?? null,
             'coupon_code'             => $data['coupon_code'] ?? null,
+            'mw_rewardpoint'          => $data['mw_rewardpoint'] ?? 0,
+            'mw_rewardpoint_discount' => $data['mw_rewardpoint_discount'] ?? 0,
+            'mw_rewardpoint_discount_show' => $data['mw_rewardpoint_discount_show'] ?? 0,
+            'amcheckoutfees_amount'   => $data['amcheckoutfees_amount'] ?? 0,
+            'base_amcheckoutfees_amount' => $data['base_amcheckoutfees_amount'] ?? 0,
             'total_item_count'        => $data['total_item_count'] ?? 1,
             'total_item_count'        => $data['total_item_count'] ?? 1,
             'total_qty_ordered'       => $data['total_qty_ordered'] ?? 1,
             'total_qty_ordered'       => $data['total_qty_ordered'] ?? 1,
             'total_invoiced'          => $data['total_invoiced'] ?? 110,
             'total_invoiced'          => $data['total_invoiced'] ?? 110,
@@ -356,6 +450,25 @@ class MagentoSchema
         if (! empty($data['payment'])) {
         if (! empty($data['payment'])) {
             self::seedOrderPayment($connection, array_merge(['parent_id' => $entityId], $data['payment']));
             self::seedOrderPayment($connection, array_merge(['parent_id' => $entityId], $data['payment']));
         }
         }
+
+        if (! empty($data['reward_point_order'])) {
+            self::seedRewardPointOrder($connection, array_merge(['order_id' => $entityId], $data['reward_point_order']));
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedRewardPointOrder(string $connection, array $data): void
+    {
+        DB::connection($connection)->table('mw_reward_point_order')->insert([
+            'order_id'                 => $data['order_id'],
+            'reward_point'             => $data['reward_point'] ?? 0,
+            'rewardpoint_sell_product' => $data['rewardpoint_sell_product'] ?? 0,
+            'earn_rewardpoint'         => $data['earn_rewardpoint'] ?? 0,
+            'money'                    => $data['money'] ?? 0,
+            'reward_point_money_rate'  => $data['reward_point_money_rate'] ?? '100/1',
+        ]);
     }
     }
 
 
     /**
     /**
@@ -434,4 +547,315 @@ class MagentoSchema
             'base_amount_ordered'  => $data['base_amount_ordered'] ?? 110,
             'base_amount_ordered'  => $data['base_amount_ordered'] ?? 110,
         ]);
         ]);
     }
     }
+
+    public static function createProductCatalog(string $connection): void
+    {
+        $schema = Schema::connection($connection);
+        $db = DB::connection($connection);
+
+        $db->table('eav_entity_type')->insert([
+            ['entity_type_id' => self::CATEGORY_TYPE_ID, 'entity_type_code' => 'catalog_category'],
+            ['entity_type_id' => self::PRODUCT_TYPE_ID, 'entity_type_code' => 'catalog_product'],
+        ]);
+
+        $schema->create('catalog_eav_attribute', function (Blueprint $table) {
+            $table->integer('attribute_id');
+            $table->integer('is_searchable')->default(0);
+            $table->integer('is_filterable')->default(0);
+            $table->integer('is_comparable')->default(0);
+            $table->integer('is_configurable')->default(0);
+            $table->integer('position')->default(0);
+        });
+
+        $schema->create('eav_attribute_option', function (Blueprint $table) {
+            $table->integer('option_id');
+            $table->integer('attribute_id');
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('eav_attribute_option_value', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('option_id');
+            $table->integer('store_id')->default(0);
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_product_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('sku')->nullable();
+            $table->string('type_id')->default('simple');
+            $table->integer('attribute_set_id')->default(4);
+            $table->string('created_at')->nullable();
+            $table->string('updated_at')->nullable();
+        });
+
+        foreach (['varchar', 'int', 'decimal', 'text', 'datetime'] as $type) {
+            $schema->create('catalog_product_entity_'.$type, function (Blueprint $table) {
+                $table->increments('value_id');
+                $table->integer('entity_id');
+                $table->integer('attribute_id');
+                $table->integer('store_id')->default(0);
+                $table->text('value')->nullable();
+            });
+        }
+
+        $schema->create('catalog_product_entity_media_gallery', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('entity_id');
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_product_entity_media_gallery_value', function (Blueprint $table) {
+            $table->integer('value_id');
+            $table->integer('store_id')->default(0);
+            $table->string('label')->nullable();
+            $table->integer('position')->default(0);
+            $table->integer('disabled')->default(0);
+        });
+
+        $schema->create('cataloginventory_stock_item', function (Blueprint $table) {
+            $table->increments('item_id');
+            $table->integer('product_id');
+            $table->integer('stock_id')->default(1);
+            $table->decimal('qty', 12, 4)->default(0);
+            $table->integer('is_in_stock')->default(0);
+        });
+
+        $schema->create('catalog_category_entity', function (Blueprint $table) {
+            $table->integer('entity_id');
+            $table->string('path')->nullable();
+        });
+
+        $schema->create('catalog_category_entity_varchar', function (Blueprint $table) {
+            $table->increments('value_id');
+            $table->integer('entity_id');
+            $table->integer('attribute_id');
+            $table->integer('store_id')->default(0);
+            $table->string('value')->nullable();
+        });
+
+        $schema->create('catalog_category_product', function (Blueprint $table) {
+            $table->integer('category_id');
+            $table->integer('product_id');
+            $table->integer('position')->default(0);
+        });
+
+        $schema->create('catalog_product_option', function (Blueprint $table) {
+            $table->increments('option_id');
+            $table->integer('product_id');
+            $table->string('type')->nullable();
+            $table->integer('is_require')->default(0);
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('catalog_product_option_title', function (Blueprint $table) {
+            $table->increments('option_title_id');
+            $table->integer('option_id');
+            $table->integer('store_id')->default(0);
+            $table->string('title')->nullable();
+        });
+
+        $schema->create('catalog_product_option_type_value', function (Blueprint $table) {
+            $table->increments('option_type_id');
+            $table->integer('option_id');
+            $table->string('sku')->nullable();
+            $table->integer('sort_order')->default(0);
+        });
+
+        $schema->create('catalog_product_option_type_title', function (Blueprint $table) {
+            $table->increments('option_type_title_id');
+            $table->integer('option_type_id');
+            $table->integer('store_id')->default(0);
+            $table->string('title')->nullable();
+        });
+
+        $schema->create('catalog_product_option_type_price', function (Blueprint $table) {
+            $table->increments('option_type_price_id');
+            $table->integer('option_type_id');
+            $table->integer('store_id')->default(0);
+            $table->decimal('price', 12, 4)->default(0);
+            $table->string('price_type')->default('fixed');
+        });
+
+        $db->table('eav_attribute')->insert([
+            ['attribute_id' => self::ATTR_PRODUCT_NAME, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'name', 'backend_type' => 'varchar', 'frontend_label' => 'Name', 'frontend_input' => 'text', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'description', 'backend_type' => 'text', 'frontend_label' => 'Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_SHORT_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'short_description', 'backend_type' => 'text', 'frontend_label' => 'Short Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_PRICE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'price', 'backend_type' => 'decimal', 'frontend_label' => 'Price', 'frontend_input' => 'price', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_SPECIAL_PRICE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'special_price', 'backend_type' => 'decimal', 'frontend_label' => 'Special Price', 'frontend_input' => 'price', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_COST, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'cost', 'backend_type' => 'decimal', 'frontend_label' => 'Cost', 'frontend_input' => 'price', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_WEIGHT, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'weight', 'backend_type' => 'decimal', 'frontend_label' => 'Weight', 'frontend_input' => 'weight', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_STATUS, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'status', 'backend_type' => 'int', 'frontend_label' => 'Status', 'frontend_input' => 'select', 'is_required' => 1, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_TITLE, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_title', 'backend_type' => 'varchar', 'frontend_label' => 'Meta Title', 'frontend_input' => 'text', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_KEYWORD, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_keyword', 'backend_type' => 'text', 'frontend_label' => 'Meta Keywords', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_META_DESCRIPTION, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'meta_description', 'backend_type' => 'varchar', 'frontend_label' => 'Meta Description', 'frontend_input' => 'textarea', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_URL_KEY, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'url_key', 'backend_type' => 'varchar', 'frontend_label' => 'URL Key', 'frontend_input' => 'text', 'is_required' => 0, 'is_user_defined' => 0],
+            ['attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'entity_type_id' => self::PRODUCT_TYPE_ID, 'attribute_code' => 'hair_colorr', 'backend_type' => 'int', 'frontend_label' => 'Hair Color', 'frontend_input' => 'select', 'is_required' => 0, 'is_user_defined' => 1],
+            ['attribute_id' => self::ATTR_CATEGORY_NAME, 'entity_type_id' => self::CATEGORY_TYPE_ID, 'attribute_code' => 'name', 'backend_type' => 'varchar', 'frontend_label' => 'Name', 'frontend_input' => 'text', 'is_required' => 1, 'is_user_defined' => 0],
+        ]);
+
+        $db->table('catalog_eav_attribute')->insert([
+            ['attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'is_searchable' => 1, 'is_filterable' => 1, 'is_comparable' => 0, 'is_configurable' => 0, 'position' => 10],
+        ]);
+
+        $db->table('eav_attribute_option')->insert([
+            ['option_id' => self::HAIR_COLORR_OPTION_ID, 'attribute_id' => self::ATTR_PRODUCT_HAIR_COLORR, 'sort_order' => 1],
+        ]);
+
+        $db->table('eav_attribute_option_value')->insert([
+            ['option_id' => self::HAIR_COLORR_OPTION_ID, 'store_id' => 0, 'value' => 'Natural Black'],
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedProduct(string $connection, array $data): void
+    {
+        $entityId = (int) $data['entity_id'];
+        $db = DB::connection($connection);
+
+        $db->table('catalog_product_entity')->insert([
+            'entity_id'        => $entityId,
+            'sku'              => $data['sku'] ?? 'SKU-'.$entityId,
+            'type_id'          => $data['type_id'] ?? 'simple',
+            'attribute_set_id' => $data['attribute_set_id'] ?? 4,
+            'created_at'       => $data['created_at'] ?? '2020-01-01 00:00:00',
+            'updated_at'       => $data['updated_at'] ?? '2020-01-01 00:00:00',
+        ]);
+
+        $map = [
+            'name'               => [self::ATTR_PRODUCT_NAME, 'varchar'],
+            'description'        => [self::ATTR_PRODUCT_DESCRIPTION, 'text'],
+            'short_description'  => [self::ATTR_PRODUCT_SHORT_DESCRIPTION, 'text'],
+            'price'              => [self::ATTR_PRODUCT_PRICE, 'decimal'],
+            'special_price'      => [self::ATTR_PRODUCT_SPECIAL_PRICE, 'decimal'],
+            'cost'               => [self::ATTR_PRODUCT_COST, 'decimal'],
+            'weight'             => [self::ATTR_PRODUCT_WEIGHT, 'decimal'],
+            'status'             => [self::ATTR_PRODUCT_STATUS, 'int'],
+            'meta_title'         => [self::ATTR_PRODUCT_META_TITLE, 'varchar'],
+            'meta_keyword'       => [self::ATTR_PRODUCT_META_KEYWORD, 'text'],
+            'meta_description'   => [self::ATTR_PRODUCT_META_DESCRIPTION, 'varchar'],
+            'url_key'            => [self::ATTR_PRODUCT_URL_KEY, 'varchar'],
+            'hair_colorr'        => [self::ATTR_PRODUCT_HAIR_COLORR, 'int'],
+        ];
+
+        $values = $data['eav'] ?? $data;
+
+        foreach ($map as $code => [$attributeId, $type]) {
+            if (! array_key_exists($code, $values) || $values[$code] === null || $values[$code] === '') {
+                continue;
+            }
+
+            $db->table('catalog_product_entity_'.$type)->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => $attributeId,
+                'store_id'     => 0,
+                'value'        => $values[$code],
+            ]);
+        }
+
+        if (! array_key_exists('status', $values)) {
+            $db->table('catalog_product_entity_int')->insert([
+                'entity_id'    => $entityId,
+                'attribute_id' => self::ATTR_PRODUCT_STATUS,
+                'store_id'     => 0,
+                'value'        => 1,
+            ]);
+        }
+
+        foreach ($data['images'] ?? [] as $position => $path) {
+            $valueId = $db->table('catalog_product_entity_media_gallery')->insertGetId([
+                'entity_id' => $entityId,
+                'value'     => $path,
+            ]);
+
+            $db->table('catalog_product_entity_media_gallery_value')->insert([
+                'value_id' => $valueId,
+                'store_id' => 0,
+                'label'    => '',
+                'position' => $position,
+                'disabled' => 0,
+            ]);
+        }
+
+        if (isset($data['qty']) || isset($data['is_in_stock'])) {
+            $db->table('cataloginventory_stock_item')->insert([
+                'product_id'  => $entityId,
+                'stock_id'    => 1,
+                'qty'         => $data['qty'] ?? 0,
+                'is_in_stock' => $data['is_in_stock'] ?? 0,
+            ]);
+        }
+
+        foreach ($data['categories'] ?? [] as $category) {
+            $categoryId = (int) $category['entity_id'];
+
+            if (! $db->table('catalog_category_entity')->where('entity_id', $categoryId)->exists()) {
+                $db->table('catalog_category_entity')->insert([
+                    'entity_id' => $categoryId,
+                    'path'      => $category['path'] ?? (string) $categoryId,
+                ]);
+
+                $db->table('catalog_category_entity_varchar')->insert([
+                    'entity_id'    => $categoryId,
+                    'attribute_id' => self::ATTR_CATEGORY_NAME,
+                    'store_id'     => 0,
+                    'value'        => $category['name'] ?? 'Category',
+                ]);
+            }
+
+            $db->table('catalog_category_product')->insert([
+                'category_id' => $categoryId,
+                'product_id'  => $entityId,
+                'position'    => $category['position'] ?? 0,
+            ]);
+        }
+
+        foreach ($data['options'] ?? [] as $option) {
+            self::seedProductOption($connection, array_merge($option, ['product_id' => $entityId]));
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public static function seedProductOption(string $connection, array $data): void
+    {
+        $db = DB::connection($connection);
+
+        $optionId = $db->table('catalog_product_option')->insertGetId([
+            'product_id'  => $data['product_id'],
+            'type'        => $data['type'] ?? 'drop_down',
+            'is_require'  => $data['is_require'] ?? 1,
+            'sort_order'  => $data['sort_order'] ?? 0,
+        ]);
+
+        $db->table('catalog_product_option_title')->insert([
+            'option_id' => $optionId,
+            'store_id'  => 0,
+            'title'     => $data['title'] ?? 'Option',
+        ]);
+
+        foreach ($data['values'] ?? [] as $index => $value) {
+            $typeId = $db->table('catalog_product_option_type_value')->insertGetId([
+                'option_id'  => $optionId,
+                'sku'        => $value['sku'] ?? '',
+                'sort_order' => $value['sort_order'] ?? $index,
+            ]);
+
+            $db->table('catalog_product_option_type_title')->insert([
+                'option_type_id' => $typeId,
+                'store_id'       => 0,
+                'title'          => $value['title'] ?? '',
+            ]);
+
+            $db->table('catalog_product_option_type_price')->insert([
+                'option_type_id' => $typeId,
+                'store_id'       => 0,
+                'price'          => $value['price'] ?? 0,
+                'price_type'     => $value['price_type'] ?? 'fixed',
+            ]);
+        }
+    }
 }
 }

+ 263 - 1
packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaCustomersCommandTest.php

@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Schema;
 use Webkul\BagistoApi\Tests\BagistoApiTestCase;
 use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Core\Models\SubscribersList;
 use Webkul\Customer\Models\Customer;
 use Webkul\Customer\Models\Customer;
 use Webkul\Customer\Models\CustomerAddress;
 use Webkul\Customer\Models\CustomerAddress;
 use Webkul\Customer\Models\CustomerGroup;
 use Webkul\Customer\Models\CustomerGroup;
@@ -22,7 +23,8 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         parent::setUp();
         parent::setUp();
 
 
         if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
         if (! Schema::hasColumn('customers', 'migrated_from_asteria_id')
-            || ! Schema::hasColumn('customers', 'legacy_password')) {
+            || ! Schema::hasColumn('customers', 'legacy_password')
+            || ! Schema::hasColumn('customers', 'customer_source')) {
             $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
             $this->markTestSkipped('Run php artisan migrate to add Asteria customer columns.');
         }
         }
 
 
@@ -68,6 +70,7 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
             'group_id'         => 99,
             'group_id'         => 99,
             'default_billing'  => 21,
             'default_billing'  => 21,
             'default_shipping' => 21,
             'default_shipping' => 21,
+            'source'           => 20,
         ]);
         ]);
         MagentoSchema::seedAddress($this->connection, [
         MagentoSchema::seedAddress($this->connection, [
             'entity_id'  => 21,
             'entity_id'  => 21,
@@ -98,6 +101,8 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         $this->assertSame('5551112222', $customer->phone);
         $this->assertSame('5551112222', $customer->phone);
         $this->assertSame(md5('abcsecret12').':abc', $customer->legacy_password);
         $this->assertSame(md5('abcsecret12').':abc', $customer->legacy_password);
         $this->assertSame(1, (int) $customer->is_verified);
         $this->assertSame(1, (int) $customer->is_verified);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(20, (int) $customer->customer_source);
 
 
         $generalId = CustomerGroup::query()->where('code', 'general')->value('id');
         $generalId = CustomerGroup::query()->where('code', 'general')->value('id');
         $this->assertSame((int) $generalId, (int) $customer->customer_group_id);
         $this->assertSame((int) $generalId, (int) $customer->customer_group_id);
@@ -229,6 +234,263 @@ class MigrateAsteriaCustomersCommandTest extends BagistoApiTestCase
         $this->assertSame(1, CustomerAddress::query()->where('customer_id', $customer->id)->count());
         $this->assertSame(1, CustomerAddress::query()->where('customer_id', $customer->id)->count());
     }
     }
 
 
+    public function test_it_migrates_a_subscribed_customer_into_subscribers_list(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000090,
+            'email'     => 'asteria-nl-sub@nshop.test',
+            'firstname' => 'Jane',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'           => 1,
+            'customer_id'             => 880000090,
+            'subscriber_email'       => 'asteria-nl-sub@nshop.test',
+            'subscriber_status'      => 1,
+            'subscriber_confirm_code' => 'magento-token',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-sub@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(1, (int) $customer->subscribed_to_news_letter);
+
+        $subscription = SubscribersList::query()->where('email', 'asteria-nl-sub@nshop.test')->first();
+        $this->assertNotNull($subscription);
+        $this->assertSame(1, (int) $subscription->is_subscribed);
+        $this->assertSame($customer->id, (int) $subscription->customer_id);
+        $this->assertSame('magento-token', $subscription->token);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-sub@nshop.test')->count());
+    }
+
+    public function test_it_records_an_unsubscribed_magento_customer(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000091,
+            'email'     => 'asteria-nl-unsub@nshop.test',
+            'firstname' => 'Un',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 2,
+            'customer_id'        => 880000091,
+            'subscriber_email'  => 'asteria-nl-unsub@nshop.test',
+            'subscriber_status' => 3,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-unsub@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+
+        $subscription = SubscribersList::query()->where('email', 'asteria-nl-unsub@nshop.test')->first();
+        $this->assertNotNull($subscription);
+        $this->assertSame(0, (int) $subscription->is_subscribed);
+        $this->assertSame($customer->id, (int) $subscription->customer_id);
+    }
+
+    public function test_it_syncs_subscription_when_linking_an_existing_customer(): void
+    {
+        $existing = $this->createCustomer([
+            'email'                     => 'asteria-nl-keep@nshop.test',
+            'first_name'                => 'Keep',
+            'subscribed_to_news_letter' => 0,
+        ]);
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000092,
+            'email'     => 'asteria-nl-keep@nshop.test',
+            'firstname' => 'Magento',
+            'lastname'  => 'Name',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 3,
+            'customer_id'        => 880000092,
+            'subscriber_email'  => 'asteria-nl-keep@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $existing->refresh();
+        $this->assertSame('Keep', $existing->first_name);
+        $this->assertSame(1, (int) $existing->subscribed_to_news_letter);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-keep@nshop.test')->where('is_subscribed', 1)->count());
+    }
+
+    public function test_it_backfills_subscriptions_without_rescanning_customers(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000093,
+            'email'     => 'asteria-nl-later@nshop.test',
+            'firstname' => 'Later',
+            'lastname'  => 'Sub',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-later@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 4,
+            'customer_id'        => 880000093,
+            'subscriber_email'  => 'asteria-nl-later@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection' => $this->connection,
+        ])->assertSuccessful();
+
+        $customer->refresh();
+        $this->assertSame(1, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-later@nshop.test')->where('customer_id', $customer->id)->count());
+    }
+
+    public function test_it_imports_guest_newsletter_subscribers(): void
+    {
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 5,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'asteria-nl-guest@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 6,
+            'customer_id'        => 0,
+            'subscriber_email'  => 'asteria-nl-guest-unsub@nshop.test',
+            'subscriber_status' => 3,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $guest = SubscribersList::query()->where('email', 'asteria-nl-guest@nshop.test')->first();
+        $this->assertNotNull($guest);
+        $this->assertSame(1, (int) $guest->is_subscribed);
+        $this->assertNull($guest->customer_id);
+        $this->assertNull(SubscribersList::query()->where('email', 'asteria-nl-guest-unsub@nshop.test')->first());
+    }
+
+    public function test_it_does_not_duplicate_subscribers_on_a_second_run(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000094,
+            'email'     => 'asteria-nl-twice@nshop.test',
+            'firstname' => 'Twice',
+            'lastname'  => 'Sub',
+        ]);
+        MagentoSchema::seedSubscriber($this->connection, [
+            'subscriber_id'      => 7,
+            'customer_id'        => 880000094,
+            'subscriber_email'  => 'asteria-nl-twice@nshop.test',
+            'subscriber_status' => 1,
+        ]);
+
+        $options = [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ];
+
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+        $this->artisan('customers:migrate-asteria', $options)->assertSuccessful();
+
+        $this->assertSame(1, SubscribersList::query()->where('email', 'asteria-nl-twice@nshop.test')->count());
+    }
+
+    public function test_it_skips_newsletter_when_the_magento_table_is_missing(): void
+    {
+        Schema::connection($this->connection)->drop('newsletter_subscriber');
+
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000095,
+            'email'     => 'asteria-nl-notable@nshop.test',
+            'firstname' => 'No',
+            'lastname'  => 'Table',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-nl-notable@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(0, (int) $customer->subscribed_to_news_letter);
+        $this->assertSame(0, SubscribersList::query()->where('email', 'asteria-nl-notable@nshop.test')->count());
+    }
+
+    public function test_it_migrates_customer_source(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id'       => 880000096,
+            'email'           => 'asteria-src@nshop.test',
+            'firstname' => 'Src',
+            'lastname'  => 'User',
+            'source'    => 22,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-src@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertSame(22, (int) $customer->customer_source);
+    }
+
+    public function test_it_backfills_customer_source_on_an_already_migrated_customer(): void
+    {
+        MagentoSchema::seedCustomer($this->connection, [
+            'entity_id' => 880000097,
+            'email'     => 'asteria-src-later@nshop.test',
+            'firstname' => 'Later',
+            'lastname'  => 'Src',
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer = Customer::query()->where('email', 'asteria-src-later@nshop.test')->first();
+        $this->assertNotNull($customer);
+        $this->assertNull($customer->customer_source);
+
+        DB::connection($this->connection)->table('customer_entity_int')->insert([
+            'entity_id'    => 880000097,
+            'attribute_id' => 89,
+            'value'        => 15,
+        ]);
+
+        $this->artisan('customers:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+        ])->assertSuccessful();
+
+        $customer->refresh();
+        $this->assertSame(15, (int) $customer->customer_source);
+    }
+
     private function asteriaAddressId(CustomerAddress $address): ?int
     private function asteriaAddressId(CustomerAddress $address): ?int
     {
     {
         $additional = $address->additional;
         $additional = $address->additional;

+ 41 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/ResetCatalogCommandTest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Product\Models\Product;
+
+class ResetCatalogCommandTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('products') || ! Schema::hasTable('attribute_families')) {
+            $this->markTestSkipped('Catalog tables are missing.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_dry_run_does_not_delete_products(): void
+    {
+        $familyId = (int) (DB::table('attribute_families')->value('id') ?? 0);
+
+        if ($familyId === 0) {
+            $this->markTestSkipped('Run Bagisto seeders for attribute_families.');
+        }
+
+        $product = Product::factory()->create([
+            'type'                => 'simple',
+            'attribute_family_id' => $familyId,
+        ]);
+
+        $this->artisan('catalog:reset', ['--dry-run' => true])
+            ->assertSuccessful();
+
+        $this->assertDatabaseHas('products', ['id' => $product->id]);
+    }
+}

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

@@ -0,0 +1,31 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Customer\Models\Customer;
+
+class ResetCustomersCommandTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasTable('customers') || ! Schema::hasTable('customer_groups')) {
+            $this->markTestSkipped('Customer tables are missing.');
+        }
+
+        $this->seedRequiredData();
+    }
+
+    public function test_dry_run_does_not_delete_customers(): void
+    {
+        $customer = $this->createCustomer();
+
+        $this->artisan('customers:reset', ['--dry-run' => true])
+            ->assertSuccessful();
+
+        $this->assertDatabaseHas('customers', ['id' => $customer->id]);
+    }
+}

+ 33 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCancelStrategyTest.php

@@ -17,6 +17,7 @@ use Webkul\Sales\Repositories\OrderTransactionRepository;
  * Coverage for the cancel-order strategy branches required by the
  * Coverage for the cancel-order strategy branches required by the
  * "支付流程" spec:
  * "支付流程" spec:
  *
  *
+ *   - userManual     -> always cancel immediately (overrides keep_pending)
  *   - guest          -> immediate cancel + reactivate old cart
  *   - guest          -> immediate cancel + reactivate old cart
  *   - customer + addr -> keep order pending
  *   - customer + addr -> keep order pending
  *   - customer + !addr + config=cancel       -> cancel
  *   - customer + !addr + config=cancel       -> cancel
@@ -58,6 +59,38 @@ class PaymentServiceCancelStrategyTest extends TestCase
         };
         };
     }
     }
 
 
+    public function test_user_manual_cancel_overrides_keep_pending(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'San Francisco',
+            'postcode' => '94016',
+        ]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false, userManual: true);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('user_manual', $result['reason']);
+        Event::assertNotDispatched('bagistoapi.express.cancel.no-address');
+    }
+
+    public function test_user_manual_guest_cancel_still_reactivates_cart(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'NYC',
+            'postcode' => '10001',
+        ], ['cart_id' => 42]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: true, userManual: true);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('user_manual', $result['reason']);
+        $this->assertSame(42, $result['reactivate_cart']);
+    }
+
     public function test_guest_cancel_reactivates_old_cart(): void
     public function test_guest_cancel_reactivates_old_cart(): void
     {
     {
         Event::fake();
         Event::fake();

+ 1 - 0
packages/Webkul/Customer/src/Models/Customer.php

@@ -57,6 +57,7 @@ class Customer extends Authenticatable implements CustomerContract
         'token',
         'token',
         'customer_group_id',
         'customer_group_id',
         'channel_id',
         'channel_id',
+        'customer_source',
         'subscribed_to_news_letter',
         'subscribed_to_news_letter',
         'status',
         'status',
         'is_verified',
         'is_verified',

+ 1 - 0
packages/Webkul/Product/src/Models/Product.php

@@ -36,6 +36,7 @@ class Product extends Model implements ProductContract
         'attribute_family_id',
         'attribute_family_id',
         'sku',
         'sku',
         'parent_id',
         'parent_id',
+        'migrated_from_asteria_id',
     ];
     ];
 
 
     /**
     /**

+ 1 - 0
packages/Webkul/Shop/src/Mail/Customer/ResetPasswordNotification.php

@@ -25,6 +25,7 @@ class ResetPasswordNotification extends ResetPassword
             ->view('shop::emails.customers.forgot-password', [
             ->view('shop::emails.customers.forgot-password', [
                 'userName' => $notifiable->name,
                 'userName' => $notifiable->name,
                 'token'    => $this->token,
                 'token'    => $this->token,
+                'email'    => $notifiable->email,
             ]);
             ]);
     }
     }
 }
 }

+ 4 - 1
packages/Webkul/Shop/src/Resources/views/emails/customers/forgot-password.blade.php

@@ -14,8 +14,11 @@
     </p>
     </p>
 
 
     <div style="display: flex;margin-bottom: 95px">
     <div style="display: flex;margin-bottom: 95px">
+        @php
+            $resetPasswordUrl = \Webkul\BagistoApi\Support\Frontend::resetPasswordUrl($token, $email ?? '');
+        @endphp
         <a
         <a
-            href="{{ route('shop.customers.reset_password.create', $token) }}"
+            href="{{ $resetPasswordUrl }}"
             style="padding: 16px 45px;justify-content: center;align-items: center;gap: 10px;border-radius: 2px;background: #060C3B;color: #FFFFFF;text-decoration: none;text-transform: uppercase;font-weight: 700;"
             style="padding: 16px 45px;justify-content: center;align-items: center;gap: 10px;border-radius: 2px;background: #060C3B;color: #FFFFFF;text-decoration: none;text-transform: uppercase;font-weight: 700;"
         >
         >
             @lang('shop::app.emails.customers.forgot-password.reset-password')
             @lang('shop::app.emails.customers.forgot-password.reset-password')