From 7777b6e9e7938c4c9aafcfa3f6ce8f6fbd43f2f4 Mon Sep 17 00:00:00 2001 From: Chris Rosenau Date: Sun, 23 Nov 2025 21:37:24 -0700 Subject: [PATCH] updated readme --- README.md | 253 +- app/Http/Controllers/ProductsController.php | 115 +- .../MagentoCategoryMigrationService.php | 333 +- .../MagentoProductMigrationService.php | 4962 +++++++++++++++++ resources/js/products.js | 491 +- resources/views/products/index.blade.php | 66 +- routes/web.php | 2 + 7 files changed, 6115 insertions(+), 107 deletions(-) create mode 100644 app/Services/MagentoProductMigrationService.php diff --git a/README.md b/README.md index 0165a77..74a663c 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,236 @@ -

Laravel Logo

+# Magento 1 to Magento 2 Migration Tool -

-Build Status -Total Downloads -Latest Stable Version -License -

+A comprehensive web-based migration tool built with Laravel to migrate data from Magento 1 to Magento 2, including categories, products, customers, and product options. -## About Laravel +## Features -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +### 🗂️ Category Migration +- Multi-store category migration with store mapping +- Preserves category hierarchy and structure +- Migrates category attributes (name, URL key, is_active) for each store view +- Category preview before migration +- Real-time migration progress -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +### 📦 Product Migration +- Migrates products with all attributes +- Handles products with and without SKUs (auto-generates SKUs when needed) +- Updates existing products or creates new ones +- Migrates product attributes across all backend types (varchar, int, text, decimal, datetime) +- Product-to-category assignments +- Product options migration (catalog_product_option tables) +- Missing attribute detection and reporting +- Dry run mode for testing -Laravel is accessible, powerful, and provides tools required for large, robust applications. +### 👥 Customer Migration +- Migrates customer data with all attributes +- Creates new customers or updates existing ones based on email +- Preserves customer website and group assignments -## Learning Laravel +### 📊 Additional Features +- Web-based user interface +- Real-time migration progress tracking +- Detailed error logging (errors-only view) +- Database connection testing +- Comparison tools for products and categories +- Statistics and reporting -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. +## Requirements -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +- PHP 8.1 or higher +- Laravel 11.x +- MySQL/MariaDB (for both Magento 1 and Magento 2 databases) +- Node.js and NPM (for frontend assets) -## Laravel Sponsors +## Installation -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). +1. **Clone the repository:** + ```bash + git clone + cd migrate + ``` -### Premium Partners +2. **Install PHP dependencies:** + ```bash + composer install + ``` -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** +3. **Install Node dependencies:** + ```bash + npm install + ``` -## Contributing +4. **Set up environment:** + ```bash + cp .env.example .env + php artisan key:generate + ``` -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +5. **Configure database connections:** + + Edit your `.env` file and add the Magento database configurations: + + ```env + # Magento 1 Database + MAGENTO1_DB_HOST=127.0.0.1 + MAGENTO1_DB_PORT=3306 + MAGENTO1_DB_DATABASE=magento1 + MAGENTO1_DB_USERNAME=root + MAGENTO1_DB_PASSWORD=your_password + MAGENTO1_DB_PREFIX= + + # Magento 2 Database + MAGENTO2_DB_HOST=127.0.0.1 + MAGENTO2_DB_PORT=3306 + MAGENTO2_DB_DATABASE=magento2 + MAGENTO2_DB_USERNAME=root + MAGENTO2_DB_PASSWORD=your_password + MAGENTO2_DB_PREFIX= + ``` -## Code of Conduct + For DDEV setups, see [DATABASE_SETUP.md](DATABASE_SETUP.md) for detailed instructions. -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +6. **Build frontend assets:** + ```bash + npm run build + ``` -## Security Vulnerabilities +7. **Run migrations:** + ```bash + php artisan migrate + ``` -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +## Usage + +### Accessing the Migration Interface + +Once your Laravel application is running, navigate to: + +- **Main migration page:** `/migration` +- **Products page:** `/products` +- **Categories page:** `/categories` +- **Customers page:** `/customers` +- **Attributes page:** `/attributes` +- **Connections page:** `/connections` + +### Migration Workflow + +1. **Test Database Connections** + - Go to the Connections page or use the "Test Connections" button + - Verify both Magento 1 and Magento 2 databases are accessible + +2. **Category Migration** + - Map Magento 1 stores to Magento 2 stores + - Preview categories (optional) + - Start the migration + +3. **Product Migration** + - Review missing attributes (if any) + - Run a dry run first to check for errors + - Start the actual migration + - Review error logs (only errors are shown) + +4. **Customer Migration** + - Run dry run to preview changes + - Start customer migration + +5. **Product Options Migration** + - Migrate catalog_product_option tables + - Includes options, prices, titles, and type values + +## Important Notes + +⚠️ **Backup First**: Always backup your Magento 2 database before running any migration. + +⚠️ **Test Environment**: It's highly recommended to test the migration on a development/staging environment first. + +⚠️ **No Data Deletion**: The migration tool only performs INSERT and UPDATE operations. No data is deleted from Magento 2. + +⚠️ **Products Without SKUs**: Products without SKUs will automatically receive a generated SKU in the format `MIGRATED-{entity_id}` to satisfy Magento 2's non-null SKU requirement. + +⚠️ **Error Logs**: The Product Migration Logs section now shows only errors for easier troubleshooting. + +## Project Structure + +``` +app/ +├── Http/Controllers/ +│ ├── ProductsController.php # Product migration controller +│ ├── CategoriesController.php # Category migration controller +│ ├── CustomersController.php # Customer migration controller +│ └── ... +├── Services/ +│ ├── MagentoProductMigrationService.php # Product migration logic +│ └── MagentoCategoryMigrationService.php # Category migration logic +resources/ +├── views/ +│ ├── products/ # Product migration interface +│ ├── categories/ # Category migration interface +│ ├── customers/ # Customer migration interface +│ └── ... +└── js/ + ├── products.js # Product migration frontend logic + ├── categories.js # Category migration frontend logic + └── ... +``` + +## Documentation + +- [MAGENTO_MIGRATION_README.md](MAGENTO_MIGRATION_README.md) - Detailed category migration documentation +- [DATABASE_SETUP.md](DATABASE_SETUP.md) - Database connection setup for DDEV +- [ENV_SETUP_INSTRUCTIONS.md](ENV_SETUP_INSTRUCTIONS.md) - Environment configuration guide + +## Troubleshooting + +### Connection Errors + +If you see connection errors: + +1. Verify database credentials in `.env` +2. Ensure both databases are accessible from your Laravel application +3. Check database table prefixes if your Magento installations use them +4. For DDEV setups, see [DATABASE_SETUP.md](DATABASE_SETUP.md) + +### Migration Errors + +1. Check the migration logs in the web interface (errors-only view) +2. Review Laravel logs: `storage/logs/laravel.log` +3. Ensure Magento 2 database has proper permissions +4. Verify that required Magento 2 tables exist +5. Check for missing attributes that need to be created first + +### Common Issues + +- **"Column 'sku' cannot be null"**: This has been fixed - products without SKUs now get auto-generated SKUs +- **Missing attributes**: The tool will report missing attributes that need to be created in Magento 2 first +- **Store mapping errors**: Ensure store mappings are correct before starting migration + +## Development + +### Running Tests + +```bash +php artisan test +``` + +### Building Assets + +For development: +```bash +npm run dev +``` + +For production: +```bash +npm run build +``` ## License -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). + +## Support + +For issues or questions: +- Check Laravel logs: `storage/logs/laravel.log` +- Review migration logs in the web interface +- Check database connection status in the web interface diff --git a/app/Http/Controllers/ProductsController.php b/app/Http/Controllers/ProductsController.php index c75514b..6cfcde7 100644 --- a/app/Http/Controllers/ProductsController.php +++ b/app/Http/Controllers/ProductsController.php @@ -2,17 +2,23 @@ namespace App\Http\Controllers; +use App\Services\MagentoProductMigrationService; use App\Services\MagentoCategoryMigrationService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; class ProductsController extends Controller { - protected $migrationService; + protected $productService; + protected $categoryService; - public function __construct(MagentoCategoryMigrationService $migrationService) + public function __construct(MagentoProductMigrationService $productService, MagentoCategoryMigrationService $categoryService) { - $this->migrationService = $migrationService; + $this->productService = $productService; + $this->categoryService = $categoryService; + // Inject category service into product service + $this->productService->setCategoryService($categoryService); } /** @@ -20,10 +26,10 @@ public function __construct(MagentoCategoryMigrationService $migrationService) */ public function index() { - $m1Products = $this->migrationService->getMagento1Products(); - $m2Products = $this->migrationService->getMagento2Products(); - $m1ProductsNotInM2 = $this->migrationService->getM1ProductsNotInM2(); - $m2ProductsNotInM1 = $this->migrationService->getM2ProductsNotInM1(); + $m1Products = $this->productService->getMagento1Products(); + $m2Products = $this->productService->getMagento2Products(); + $m1ProductsNotInM2 = $this->productService->getM1ProductsNotInM2(); + $m2ProductsNotInM1 = $this->productService->getM2ProductsNotInM1(); return view('products.index', [ 'm1Products' => $m1Products, @@ -40,7 +46,19 @@ public function migrateProducts(Request $request) { try { $dryRun = $request->input('dry_run', false); - $result = $this->migrationService->migrateProducts($dryRun); + + // Get progress key from request or generate one (only for non-dry-run) + $progressKey = $request->input('progress_key'); + if (!$dryRun && !$progressKey) { + $progressKey = 'product_migration_progress_' . uniqid(); + } + + $result = $this->productService->migrateProducts($dryRun, $progressKey); + + // Add progress key to result + if ($progressKey) { + $result['progress_key'] = $progressKey; + } return response()->json($result, $result['success'] ? 200 : 400); @@ -65,7 +83,7 @@ public function migrateProducts(Request $request) public function deleteM2Product(Request $request, $productId) { try { - $result = $this->migrationService->deleteM2Product($productId); + $result = $this->productService->deleteM2Product($productId); return response()->json($result, $result['success'] ? 200 : 400); @@ -85,7 +103,7 @@ public function deleteM2Product(Request $request, $productId) public function syncProductCategories(Request $request) { try { - $result = $this->migrationService->syncProductCategories(); + $result = $this->productService->syncProductCategories(); return response()->json($result, $result['success'] ? 200 : 400); @@ -109,7 +127,7 @@ public function syncProductCategories(Request $request) public function deleteM2ProductsAboveM1Max(Request $request) { try { - $result = $this->migrationService->deleteM2ProductsAboveM1Max(); + $result = $this->productService->deleteM2ProductsAboveM1Max(); return response()->json($result, $result['success'] ? 200 : 400); @@ -130,7 +148,7 @@ public function deleteM2ProductsAboveM1Max(Request $request) public function fixCategoryProducts(Request $request) { try { - $result = $this->migrationService->fixCategoryProducts(); + $result = $this->productService->fixCategoryProducts(); return response()->json($result, $result['success'] ? 200 : 400); @@ -155,7 +173,7 @@ public function migrateProductOptions(Request $request) { try { $dryRun = $request->input('dry_run', false); - $result = $this->migrationService->migrateProductOptions($dryRun); + $result = $this->productService->migrateProductOptions($dryRun); return response()->json($result, $result['success'] ? 200 : 400); @@ -172,5 +190,76 @@ public function migrateProductOptions(Request $request) ], 500); } } + + /** + * Compare M1 and M2 product values by SKU + */ + public function compareProductBySku(Request $request) + { + try { + $sku = $request->input('sku'); + + if (empty($sku)) { + return response()->json([ + 'success' => false, + 'message' => 'SKU is required', + 'changes' => [] + ], 400); + } + + $result = $this->productService->compareProductBySku($sku); + + return response()->json($result, $result['success'] ? 200 : 400); + + } catch (\Exception $e) { + Log::error('Product comparison error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Comparison failed: ' . $e->getMessage(), + 'changes' => [] + ], 500); + } + } + + /** + * Get migration progress + */ + public function getMigrationProgress(Request $request) + { + try { + $progressKey = $request->input('progress_key'); + + if (empty($progressKey)) { + return response()->json([ + 'success' => false, + 'message' => 'Progress key is required' + ], 400); + } + + $progress = Cache::get($progressKey); + + if (!$progress) { + return response()->json([ + 'success' => false, + 'message' => 'Progress not found', + 'progress' => null + ], 404); + } + + return response()->json([ + 'success' => true, + 'progress' => $progress + ]); + + } catch (\Exception $e) { + Log::error('Get migration progress error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Failed to get progress: ' . $e->getMessage() + ], 500); + } + } } diff --git a/app/Services/MagentoCategoryMigrationService.php b/app/Services/MagentoCategoryMigrationService.php index 1f56a1b..eed59ae 100644 --- a/app/Services/MagentoCategoryMigrationService.php +++ b/app/Services/MagentoCategoryMigrationService.php @@ -1965,6 +1965,16 @@ public function getM2ProductsNotInM1() /** * Migrate all products from Magento 1 to Magento 2 */ + /** + * Migrate all products from Magento 1 to Magento 2 + * + * IMPORTANT: This migration only performs INSERT and UPDATE operations. + * NO DATA IS DELETED during migration. Existing data in M2 that doesn't + * exist in M1 will be preserved and not removed. + * + * @param bool $dryRun If true, only simulates the migration without making changes + * @return array Migration result with counts and logs + */ public function migrateProducts($dryRun = false) { try { @@ -2166,15 +2176,30 @@ public function migrateProducts($dryRun = false) 'updated_at' => now(), ]; - // If SKU column exists, set it to NULL or empty + // If SKU column exists, generate a SKU for products without one + $generatedSku = 'MIGRATED-' . $m2ProductId; if ($m2HasSkuColumn) { - $insertData['sku'] = null; + // Generate a unique SKU using entity_id to avoid null constraint violation + $insertData['sku'] = $generatedSku; } DB::connection($this->magento2Connection) ->table($this->magento2Prefix . 'catalog_product_entity') ->insert($insertData); - $this->migrationLog[] = "Created new product: No SKU (ID: {$m2ProductId}, using M1 ID)"; + + // If SKU is stored in EAV table, insert it there too + if (!$m2HasSkuColumn && isset($m2AttributeIds['sku'])) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->insert([ + 'attribute_id' => $m2AttributeIds['sku'], + 'store_id' => 0, + 'entity_id' => $m2ProductId, + 'value' => $generatedSku, + ]); + } + + $this->migrationLog[] = "Created new product: Generated SKU {$generatedSku} (ID: {$m2ProductId}, using M1 ID)"; } else { $this->migrationLog[] = "Updating existing product: No SKU (ID: {$m2ProductId})"; $updatedCount++; @@ -2424,6 +2449,10 @@ protected function getMagento2ProductAttributeIds() /** * Migrate ALL data from ALL catalog_product_entity_* tables for a product * This ensures we migrate images, all store views, and all attributes + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing attribute values in M2 that don't exist + * in M1 will be preserved and not removed. */ protected function migrateAllProductEntityData($m1ProductId, $m2ProductId, $m1EntityTypeId, $m2EntityTypeId, $m1AttributeIds, $m2AttributeIds, $dryRun = false, $allM1Attributes = null) { @@ -3050,6 +3079,282 @@ protected function migrateProductAttributes($m1ProductId, $m2ProductId, $m1Entit return $missingAttributes; } + /** + * Compare M1 and M2 product values by SKU + * Returns all differences that would be updated during migration + */ + public function compareProductBySku($sku) + { + try { + $comparisonByTable = []; + + // Get M1 and M2 entity type IDs + $m1EntityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + $m2EntityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$m1EntityTypeId || !$m2EntityTypeId) { + return [ + 'success' => false, + 'message' => 'Entity type not found', + 'changes_by_table' => [] + ]; + } + + // Find M1 product by SKU + $m1Product = $this->findProductBySku($sku, $this->magento1Connection, $this->magento1Prefix); + if (!$m1Product) { + return [ + 'success' => false, + 'message' => "Product with SKU '{$sku}' not found in Magento 1", + 'changes_by_table' => [] + ]; + } + + $m1ProductId = $m1Product->entity_id; + + // Find M2 product by SKU + $m2Product = $this->findProductBySku($sku, $this->magento2Connection, $this->magento2Prefix); + if (!$m2Product) { + return [ + 'success' => true, + 'message' => "Product with SKU '{$sku}' not found in Magento 2. It will be created during migration.", + 'm1_product_id' => $m1ProductId, + 'm2_product_id' => null, + 'changes_by_table' => [] + ]; + } + + $m2ProductId = $m2Product->entity_id; + + // Get all M1 attributes + $allM1Attributes = $this->getAllMagento1ProductAttributes(); + $m2AttributeIds = $this->getAllMagento2ProductAttributeIds(); + + // Build attribute mapping + $attributeMapping = []; + if ($allM1Attributes) { + foreach ($allM1Attributes as $m1Attr) { + $attrCode = $m1Attr->attribute_code; + if (isset($m2AttributeIds[$attrCode])) { + $attributeMapping[$m1Attr->attribute_id] = [ + 'm2_attr_id' => $m2AttributeIds[$attrCode], + 'backend_type' => $m1Attr->backend_type ?? 'varchar', + 'attribute_code' => $attrCode, + 'frontend_label' => $m1Attr->frontend_label ?? $attrCode + ]; + } + } + } + + // Compare all backend type tables + $backendTypes = ['varchar', 'int', 'text', 'decimal', 'datetime']; + + foreach ($backendTypes as $backendType) { + $m1Table = $this->magento1Prefix . 'catalog_product_entity_' . $backendType; + $m2Table = $this->magento2Prefix . 'catalog_product_entity_' . $backendType; + $tableChanges = []; + + try { + // Get M1 values + $m1Rows = DB::connection($this->magento1Connection) + ->table($m1Table) + ->where('entity_id', $m1ProductId) + ->get(); + + // Get M2 values + $m2Rows = DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->get(); + + // Build M2 lookup by attribute_id and store_id + $m2Lookup = []; + foreach ($m2Rows as $m2Row) { + $key = $m2Row->attribute_id . '_' . ($m2Row->store_id ?? 0); + $m2Lookup[$key] = $m2Row; + } + + // Compare M1 values with M2 + foreach ($m1Rows as $m1Row) { + $m1AttrId = $m1Row->attribute_id; + + // Check if attribute exists in M2 + if (!isset($attributeMapping[$m1AttrId])) { + continue; // Skip attributes that don't exist in M2 + } + + $mapping = $attributeMapping[$m1AttrId]; + $m2AttrId = $mapping['m2_attr_id']; + $storeId = $m1Row->store_id ?? 0; + $key = $m2AttrId . '_' . $storeId; + + $m1Value = $m1Row->value ?? ''; + $m2Value = isset($m2Lookup[$key]) ? ($m2Lookup[$key]->value ?? '') : null; + + // Check if values are different + if ($m1Value != $m2Value) { + $tableChanges[] = [ + 'attribute_code' => $mapping['attribute_code'], + 'attribute_label' => $mapping['frontend_label'], + 'backend_type' => $mapping['backend_type'], + 'store_id' => $storeId, + 'm1_value' => $m1Value, + 'm2_value' => $m2Value, + 'action' => $m2Value === null ? 'INSERT' : 'UPDATE' + ]; + } + } + + // Check for M2 values that don't exist in M1 (would be deleted) + foreach ($m2Rows as $m2Row) { + $m2AttrId = $m2Row->attribute_id; + $storeId = $m2Row->store_id ?? 0; + + // Check if this attribute exists in M1 mapping + $foundInM1 = false; + foreach ($attributeMapping as $m1AttrId => $mapping) { + if ($mapping['m2_attr_id'] == $m2AttrId) { + // Check if M1 has this value + $m1HasValue = $m1Rows->where('attribute_id', $m1AttrId) + ->where('store_id', $storeId) + ->isNotEmpty(); + if ($m1HasValue) { + $foundInM1 = true; + break; + } + } + } + + // If not found in M1 and it's not a system attribute, note it + if (!$foundInM1) { + // Try to get attribute code for display + $attrCode = 'unknown'; + foreach ($attributeMapping as $m1AttrId => $mapping) { + if ($mapping['m2_attr_id'] == $m2AttrId) { + $attrCode = $mapping['attribute_code']; + break; + } + } + + // Only add if it's a meaningful attribute (not system-generated) + if ($attrCode !== 'unknown') { + $tableChanges[] = [ + 'attribute_code' => $attrCode, + 'attribute_label' => 'Unknown', + 'backend_type' => $backendType, + 'store_id' => $storeId, + 'm1_value' => null, + 'm2_value' => $m2Row->value ?? '', + 'action' => 'DELETE' + ]; + } + } + } + + // Add table changes to comparison (even if empty, to show all tables) + $comparisonByTable[$backendType] = [ + 'table_name' => 'catalog_product_entity_' . $backendType, + 'm1_table' => $m1Table, + 'm2_table' => $m2Table, + 'changes' => $tableChanges + ]; + + } catch (Exception $e) { + // Table might not exist, add empty entry + $comparisonByTable[$backendType] = [ + 'table_name' => 'catalog_product_entity_' . $backendType, + 'm1_table' => $m1Table, + 'm2_table' => $m2Table, + 'changes' => [], + 'error' => $e->getMessage() + ]; + Log::debug("Table comparison error for {$m1Table}: " . $e->getMessage()); + continue; + } + } + + return [ + 'success' => true, + 'message' => "Comparison completed for SKU '{$sku}'", + 'm1_product_id' => $m1ProductId, + 'm2_product_id' => $m2ProductId, + 'sku' => $sku, + 'changes_by_table' => $comparisonByTable + ]; + + } catch (Exception $e) { + Log::error('Error comparing product by SKU: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Comparison failed: ' . $e->getMessage(), + 'changes' => [] + ]; + } + } + + /** + * Find product by SKU in either M1 or M2 + */ + protected function findProductBySku($sku, $connection, $prefix) + { + // Try entity table first + try { + $product = DB::connection($connection) + ->table($prefix . 'catalog_product_entity') + ->where('sku', $sku) + ->first(); + + if ($product) { + return $product; + } + } catch (Exception $e) { + // SKU might be in EAV table + } + + // Try EAV table + $entityTypeId = DB::connection($connection) + ->table($prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return null; + } + + $skuAttributeId = DB::connection($connection) + ->table($prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + if (!$skuAttributeId) { + return null; + } + + $skuRow = DB::connection($connection) + ->table($prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('value', $sku) + ->where('store_id', 0) + ->first(); + + if (!$skuRow) { + return null; + } + + return DB::connection($connection) + ->table($prefix . 'catalog_product_entity') + ->where('entity_id', $skuRow->entity_id) + ->first(); + } + /** * Delete a single product from Magento 2 */ @@ -3452,6 +3757,9 @@ public function deleteM2ProductsAboveM1Max() /** * Migrate product category associations + * + * IMPORTANT: This method only performs INSERT operations. + * NO DATA IS DELETED. Existing category associations in M2 will be preserved. */ protected function migrateProductCategories($m1ProductId, $m2ProductId, $categoryMapping) { @@ -3489,7 +3797,7 @@ protected function migrateProductCategories($m1ProductId, $m2ProductId, $categor /** * Get store mapping by matching store codes */ - protected function getStoreMapping() + public function getStoreMapping() { // If store mapping was set during category migration, use it if (!empty($this->storeMapping)) { @@ -3600,6 +3908,9 @@ protected function getWebsiteMapping() /** * Migrate product website assignments from M1 catalog_product_website to M2 catalog_product_website + * + * IMPORTANT: This method only performs INSERT operations. + * NO DATA IS DELETED. Existing website assignments in M2 will be preserved. */ protected function migrateProductWebsites($m1ProductId, $m2ProductId) { @@ -3754,6 +4065,12 @@ protected function migrateProductUrlRewrites($m1ProductId, $m2ProductId) /** * Migrate stock inventory from M1 cataloginventory_stock_item to M2 cataloginventory_stock_item */ + /** + * Migrate product stock inventory from M1 to M2 + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing stock data in M2 will be preserved or updated. + */ protected function migrateProductStock($m1ProductId, $m2ProductId) { try { @@ -3909,6 +4226,12 @@ protected function createDefaultStockItem($productId) /** * Migrate all catalog_product_option tables from M1 to M2 */ + /** + * Migrate catalog_product_option tables from M1 to M2 + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing product options in M2 will be preserved or updated. + */ protected function migrateCatalogProductOptions() { $migratedCount = 0; @@ -4809,7 +5132,7 @@ protected function findCategoryByName($categoryName, $source = 'm2') /** * Get category mapping from previous migrations */ -protected function getCategoryMapping() + public function getCategoryMapping() { // Try to get mapping from category_mapping table or build it from existing categories // Build it by matching category names (case-insensitive, ignoring parent differences) diff --git a/app/Services/MagentoProductMigrationService.php b/app/Services/MagentoProductMigrationService.php new file mode 100644 index 0000000..c827527 --- /dev/null +++ b/app/Services/MagentoProductMigrationService.php @@ -0,0 +1,4962 @@ +magento1Connection = 'magento1'; + $this->magento2Connection = 'magento2'; + $this->magento1Prefix = config('database.connections.magento1.prefix', ''); + $this->magento2Prefix = config('database.connections.magento2.prefix', ''); + } + + /** + * Set the category service (for dependency injection) + */ + public function setCategoryService(MagentoCategoryMigrationService $categoryService) + { + $this->categoryService = $categoryService; + } + + /** + * Get category mapping from category service + */ + protected function getCategoryMapping() + { + if ($this->categoryService) { + return $this->categoryService->getCategoryMapping(); + } + return []; + } + + /** + * Get all stores from Magento 1 + */ + protected function getMagento1Stores() + { + try { + $stores = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_store') + ->select('store_id', 'code', 'name', 'website_id', 'group_id') + ->where('store_id', '>', 0) + ->get(); + + return $stores; + } catch (Exception $e) { + Log::error('Error fetching Magento 1 stores: ' . $e->getMessage()); + return collect([]); + } + } + + /** + * Get all stores from Magento 2 + */ + protected function getMagento2Stores() + { + try { + $stores = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'store') + ->select('store_id', 'code', 'name', 'website_id') + ->where('store_id', '>', 0) + ->get(); + + return $stores; + } catch (Exception $e) { + Log::error('Error fetching Magento 2 stores: ' . $e->getMessage()); + return collect([]); + } + } + + // Product methods will be added here + + public function getMagento1Products() + { + try { + // Get entity type ID for catalog_product + $entityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return collect([]); + } + + // Get base product data - check if SKU column exists in entity table + $hasSkuColumn = false; + try { + $testQuery = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->select('sku') + ->limit(1) + ->first(); + $hasSkuColumn = true; + } catch (Exception $e) { + // SKU column doesn't exist, will use EAV + $hasSkuColumn = false; + } + + if ($hasSkuColumn) { + // SKU is stored directly in entity table + $products = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->select('entity_id', 'sku', 'type_id', 'attribute_set_id', 'has_options', 'required_options', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + } else { + // SKU is stored as EAV attribute + $products = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->select('entity_id', 'type_id', 'attribute_set_id', 'has_options', 'required_options', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + } + + // Get SKU attribute ID (for EAV storage) + $skuAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + // Get name attribute ID + $nameAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + // Get SKU and name values from EAV if needed + $skus = []; + $names = []; + + if (!$hasSkuColumn && $skuAttributeId) { + $skuValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('store_id', 0) + ->pluck('value', 'entity_id') + ->toArray(); + $skus = $skuValues; + } + + if ($nameAttributeId) { + $nameValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->pluck('value', 'entity_id') + ->toArray(); + $names = $nameValues; + } + + // Combine data + foreach ($products as $product) { + if ($hasSkuColumn) { + // SKU from entity table + $product->sku = !empty($product->sku) ? $product->sku : 'N/A'; + } else { + // SKU from EAV + $product->sku = $skus[$product->entity_id] ?? 'N/A'; + } + $product->name = $names[$product->entity_id] ?? 'Unnamed Product'; + } + + return $products; + } catch (Exception $e) { + Log::error('Error fetching Magento 1 products: ' . $e->getMessage()); + return collect([]); + } + } + + /** + * Get all products from Magento 2 + */ + public function getMagento2Products() + { + try { + // Get entity type ID for catalog_product + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return collect([]); + } + + // Get base product data - check if SKU column exists in entity table + $hasSkuColumn = false; + try { + $testQuery = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->select('sku') + ->limit(1) + ->first(); + $hasSkuColumn = true; + } catch (Exception $e) { + // SKU column doesn't exist, will use EAV + $hasSkuColumn = false; + } + + if ($hasSkuColumn) { + // SKU is stored directly in entity table + $products = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->select('entity_id', 'sku', 'type_id', 'attribute_set_id', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + } else { + // SKU is stored as EAV attribute + $products = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->select('entity_id', 'type_id', 'attribute_set_id', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + } + + // Get SKU attribute ID (for EAV storage) + $skuAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + // Get name attribute ID + $nameAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + // Get SKU and name values from EAV if needed + $skus = []; + $names = []; + + if (!$hasSkuColumn && $skuAttributeId) { + $skuValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('store_id', 0) + ->pluck('value', 'entity_id') + ->toArray(); + $skus = $skuValues; + } + + if ($nameAttributeId) { + $nameValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->pluck('value', 'entity_id') + ->toArray(); + $names = $nameValues; + } + + // Combine data + foreach ($products as $product) { + if ($hasSkuColumn) { + // SKU from entity table + $product->sku = !empty($product->sku) ? $product->sku : 'N/A'; + } else { + // SKU from EAV + $product->sku = $skus[$product->entity_id] ?? 'N/A'; + } + $product->name = $names[$product->entity_id] ?? 'Unnamed Product'; + } + + return $products; + } catch (Exception $e) { + Log::error('Error fetching Magento 2 products: ' . $e->getMessage()); + return collect([]); + } + } + + /** + * Get Magento 1 products that don't exist in Magento 2 + */ + public function getM1ProductsNotInM2() + { + try { + $m1Products = $this->getMagento1Products(); + $m2Products = $this->getMagento2Products(); + + // Get all M2 SKUs + $m2Skus = $m2Products->pluck('sku')->filter(function($sku) { + return $sku !== 'N/A' && !empty($sku); + })->toArray(); + + // Get M2 product IDs (for products without SKU) + $m2ProductIds = $m2Products->pluck('entity_id')->toArray(); + + // Filter M1 products that don't exist in M2 + $missingProducts = $m1Products->filter(function ($m1Product) use ($m2Skus, $m2ProductIds) { + $m1Sku = $m1Product->sku ?? 'N/A'; + $hasSku = ($m1Sku !== 'N/A' && !empty($m1Sku)); + + if ($hasSku) { + // Check by SKU + return !in_array($m1Sku, $m2Skus); + } else { + // Check by product ID + return !in_array($m1Product->entity_id, $m2ProductIds); + } + }); + + return $missingProducts->values(); + } catch (Exception $e) { + Log::error('Error fetching missing products: ' . $e->getMessage()); + return collect([]); + } + } + + /** + * Get Magento 2 products that don't exist in Magento 1 + */ + public function getM2ProductsNotInM1() + { + try { + $m1Products = $this->getMagento1Products(); + $m2Products = $this->getMagento2Products(); + + // Get all M1 SKUs + $m1Skus = $m1Products->pluck('sku')->filter(function($sku) { + return $sku !== 'N/A' && !empty($sku); + })->toArray(); + + // Get M1 product IDs (for products without SKU) + $m1ProductIds = $m1Products->pluck('entity_id')->toArray(); + + // Filter M2 products that don't exist in M1 + $missingProducts = $m2Products->filter(function ($m2Product) use ($m1Skus, $m1ProductIds) { + $m2Sku = $m2Product->sku ?? 'N/A'; + $hasSku = ($m2Sku !== 'N/A' && !empty($m2Sku)); + + if ($hasSku) { + // Check by SKU + return !in_array($m2Sku, $m1Skus); + } else { + // Check by product ID + return !in_array($m2Product->entity_id, $m1ProductIds); + } + }); + + return $missingProducts->values(); + } catch (Exception $e) { + Log::error('Error fetching M2 products not in M1: ' . $e->getMessage()); + return collect([]); + } + } + + /** + * Migrate all products from Magento 1 to Magento 2 + */ + /** + * Migrate all products from Magento 1 to Magento 2 + * + * IMPORTANT: This migration only performs INSERT and UPDATE operations. + * NO DATA IS DELETED during migration. Existing data in M2 that doesn't + * exist in M1 will be preserved and not removed. + * + * @param bool $dryRun If true, only simulates the migration without making changes + * @return array Migration result with counts and logs + */ + public function migrateProducts($dryRun = false, $progressKey = null) + { + try { + $this->migrationLog = []; + $addedCount = 0; + $updatedCount = 0; + $errorCount = 0; + $missingAttributes = []; + + // Get M1 and M2 entity type IDs + $m1EntityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + $m2EntityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$m1EntityTypeId || !$m2EntityTypeId) { + return [ + 'success' => false, + 'message' => 'Entity type not found', + 'added' => 0, + 'updated' => 0, + 'errors' => 0, + 'log' => [], + 'missing_attributes' => [] + ]; + } + + // Get all M1 products + $m1Products = $this->getMagento1Products(); + $totalProducts = $m1Products->count(); + + // Initialize progress tracking if key provided + if ($progressKey) { + Cache::put($progressKey, [ + 'total' => $totalProducts, + 'current' => 0, + 'added' => 0, + 'updated' => 0, + 'errors' => 0, + 'status' => 'running', + 'current_sku' => '' + ], 3600); // Store for 1 hour + } + + // Get M2 attribute IDs (common ones) + $m2AttributeIds = $this->getMagento2ProductAttributeIds(); + + // Get M1 attribute IDs for common attributes + $m1AttributeIds = $this->getMagento1ProductAttributeIds(); + + // Get all M1 attributes to check for missing ones + $allM1Attributes = $this->getAllMagento1ProductAttributes(); + + // Also get all M1 attribute IDs (not just common ones) for comprehensive checking + $allM1AttributeIds = []; + if ($allM1Attributes) { + foreach ($allM1Attributes as $attr) { + $allM1AttributeIds[$attr->attribute_code] = $attr->attribute_id; + } + } + + // Get all M2 attribute IDs for comparison + $allM2AttributeIds = $this->getAllMagento2ProductAttributeIds(); + + // Get category mapping (from previous category migrations) + $categoryMapping = $this->getCategoryMapping(); + + if (!$dryRun) { + DB::connection($this->magento2Connection)->beginTransaction(); + } + + // Check if M2 has SKU column in entity table + $m2HasSkuColumn = false; + try { + $testQuery = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->select('sku') + ->limit(1) + ->first(); + $m2HasSkuColumn = true; + } catch (Exception $e) { + $m2HasSkuColumn = false; + } + + $currentIndex = 0; + foreach ($m1Products as $m1Product) { + $currentIndex++; + try { + $m1Sku = $m1Product->sku ?? 'N/A'; + $hasSku = ($m1Sku !== 'N/A' && !empty($m1Sku)); + + // Update progress if tracking enabled + if ($progressKey) { + Cache::put($progressKey, [ + 'total' => $totalProducts, + 'current' => $currentIndex, + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'status' => 'running', + 'current_sku' => $m1Sku + ], 3600); + } + + // Check if product exists in M2 by SKU (if SKU exists) or by ID (if no SKU) + $m2Product = null; + $m2ProductId = null; + $isNew = false; + + if ($hasSku) { + // Check if SKU exists in M2 - try both entity table and EAV + if ($m2HasSkuColumn) { + // SKU is stored in entity table + $m2Product = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('sku', $m1Sku) + ->first(); + } else { + // SKU is stored in EAV table + if (isset($m2AttributeIds['sku'])) { + $m2Product = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $m2AttributeIds['sku']) + ->where('value', $m1Sku) + ->where('store_id', 0) + ->first(); + } + } + } else { + // Product has no SKU, check by entity_id + $m2Product = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $m1Product->entity_id) + ->first(); + } + + if ($m2Product) { + // Product exists, get its entity_id + $m2ProductId = $m2Product->entity_id; + if (!$dryRun) { + // Update has_options and required_options for existing products + $updateData = [ + 'has_options' => $m1Product->has_options ?? 0, + 'required_options' => $m1Product->required_options ?? 0, + 'updated_at' => now(), + ]; + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $m2ProductId) + ->update($updateData); + $this->migrationLog[] = "Updating existing product: " . ($hasSku ? "SKU {$m1Sku}" : "No SKU") . " (ID: {$m2ProductId})"; + } else { + $this->migrationLog[] = "Would update existing product: " . ($hasSku ? "SKU {$m1Sku}" : "No SKU") . " (ID: {$m2ProductId})"; + } + $updatedCount++; + } else { + // Product doesn't exist + if ($hasSku) { + // Product has SKU, will get new ID + if (!$dryRun) { + // Create new product - check if we need to set SKU in entity table or EAV + if ($m2HasSkuColumn) { + // SKU goes in entity table + $m2ProductId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->insertGetId([ + 'sku' => $m1Sku, + 'attribute_set_id' => $m1Product->attribute_set_id ?? 4, + 'type_id' => $m1Product->type_id ?? 'simple', + 'has_options' => $m1Product->has_options ?? 0, + 'required_options' => $m1Product->required_options ?? 0, + 'created_at' => $m1Product->created_at ?? now(), + 'updated_at' => now(), + ]); + } else { + // SKU goes in EAV table + $m2ProductId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->insertGetId([ + 'attribute_set_id' => $m1Product->attribute_set_id ?? 4, + 'type_id' => $m1Product->type_id ?? 'simple', + 'has_options' => $m1Product->has_options ?? 0, + 'required_options' => $m1Product->required_options ?? 0, + 'created_at' => $m1Product->created_at ?? now(), + 'updated_at' => now(), + ]); + + // Insert SKU in EAV table + if (isset($m2AttributeIds['sku'])) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->insert([ + 'attribute_id' => $m2AttributeIds['sku'], + 'store_id' => 0, + 'entity_id' => $m2ProductId, + 'value' => $m1Sku, + ]); + } + } + $this->migrationLog[] = "Created new product: SKU {$m1Sku} (ID: {$m2ProductId})"; + } else { + $m2ProductId = $m1Product->entity_id; // Use M1 ID for dry run simulation + $this->migrationLog[] = "Would create new product: SKU {$m1Sku} (ID: {$m2ProductId})"; + } + } else { + // Product has no SKU, use M1 product ID + $m2ProductId = $m1Product->entity_id; + if (!$dryRun) { + // Check if this ID already exists in M2 + $existing = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $m2ProductId) + ->exists(); + + if (!$existing) { + $insertData = [ + 'entity_id' => $m2ProductId, + 'attribute_set_id' => $m1Product->attribute_set_id ?? 4, + 'type_id' => $m1Product->type_id ?? 'simple', + 'has_options' => $m1Product->has_options ?? 0, + 'required_options' => $m1Product->required_options ?? 0, + 'created_at' => $m1Product->created_at ?? now(), + 'updated_at' => now(), + ]; + + // If SKU column exists, generate a SKU for products without one + $generatedSku = 'MIGRATED-' . $m2ProductId; + if ($m2HasSkuColumn) { + // Generate a unique SKU using entity_id to avoid null constraint violation + $insertData['sku'] = $generatedSku; + } + + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->insert($insertData); + + // If SKU is stored in EAV table, insert it there too + if (!$m2HasSkuColumn && isset($m2AttributeIds['sku'])) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->insert([ + 'attribute_id' => $m2AttributeIds['sku'], + 'store_id' => 0, + 'entity_id' => $m2ProductId, + 'value' => $generatedSku, + ]); + } + + $this->migrationLog[] = "Created new product: Generated SKU {$generatedSku} (ID: {$m2ProductId}, using M1 ID)"; + } else { + $this->migrationLog[] = "Updating existing product: No SKU (ID: {$m2ProductId})"; + $updatedCount++; + $addedCount--; // Adjust counts + continue; + } + } else { + $this->migrationLog[] = "Would create new product: No SKU (ID: {$m2ProductId}, using M1 ID)"; + } + } + $isNew = true; + $addedCount++; + } + + // Migrate product attributes (with dry run support) + // Migrate ALL data from ALL catalog_product_entity_* tables + // This includes images, all store views, and all attributes + $missingAttrs = $this->migrateAllProductEntityData($m1Product->entity_id, $m2ProductId, $m1EntityTypeId, $m2EntityTypeId, $allM1AttributeIds, $allM2AttributeIds, $dryRun, $allM1Attributes); + if ($missingAttrs) { + foreach ($missingAttrs as $attr) { + // Check if this attribute is already in the list + $exists = false; + foreach ($missingAttributes as $existingAttr) { + if ($existingAttr['code'] === $attr['code']) { + $exists = true; + break; + } + } + if (!$exists) { + $missingAttributes[] = $attr; + } + } + } + + // Migrate media gallery images (skip in dry run) + if (!$dryRun) { + Log::info("Migrating images for product SKU: {$m1Sku}, M1 ID: {$m1Product->entity_id}, M2 ID: {$m2ProductId}"); + $this->migrateProductMediaGallery($m1Product->entity_id, $m2ProductId, $m1EntityTypeId); + } + + // Migrate category associations (skip in dry run) + if (!$dryRun) { + // Ensure product is enabled and visible (required for products to show in categories after reindex) + $this->ensureProductIsEnabledAndVisible($m2ProductId); + $this->migrateProductCategories($m1Product->entity_id, $m2ProductId, $categoryMapping); + + // Migrate URL rewrites for new products + if ($isNew) { + $this->migrateProductUrlRewrites($m1Product->entity_id, $m2ProductId); + } + + // Migrate stock inventory for all products + $this->migrateProductStock($m1Product->entity_id, $m2ProductId); + + // Migrate product website assignments for all products + $this->migrateProductWebsites($m1Product->entity_id, $m2ProductId); + } + + } catch (Exception $e) { + $errorCount++; + $errorMsg = $e->getMessage(); + Log::error("Error migrating product {$m1Product->entity_id} (SKU: " . ($m1Product->sku ?? 'N/A') . "): " . $errorMsg); + $this->migrationLog[] = "ERROR: Failed to migrate product SKU " . ($m1Product->sku ?? 'N/A') . ": " . $errorMsg; + + // Try to extract missing attribute from error message + if (preg_match("/Table.*catalog_product_entity_(\w+).*doesn't exist/", $errorMsg, $matches)) { + $tableType = $matches[1] ?? null; + if ($tableType && $allM1Attributes) { + // Find attributes that use this backend type + foreach ($allM1Attributes as $attr) { + if ($attr->backend_type === $tableType) { + $missingAttr = [ + 'code' => $attr->attribute_code, + 'label' => $attr->frontend_label ?? $attr->attribute_code, + 'type' => $attr->backend_type + ]; + // Check if already exists + $exists = false; + foreach ($missingAttributes as $existingAttr) { + if ($existingAttr['code'] === $missingAttr['code']) { + $exists = true; + break; + } + } + if (!$exists) { + $missingAttributes[] = $missingAttr; + } + } + } + } + } + } + } + + // Migrate catalog_product_option tables after all products are migrated + if (!$dryRun) { + $this->migrationLog[] = "Starting catalog_product_option tables migration..."; + $optionMigrationResult = $this->migrateCatalogProductOptions(); + if ($optionMigrationResult['migrated'] > 0 || $optionMigrationResult['errors'] > 0) { + $this->migrationLog[] = "Catalog product options migration: Migrated {$optionMigrationResult['migrated']} options, Errors: {$optionMigrationResult['errors']}"; + } + } + + if (!$dryRun) { + DB::connection($this->magento2Connection)->commit(); + } + + // Update final progress status if tracking enabled + if ($progressKey) { + Cache::put($progressKey, [ + 'total' => $totalProducts, + 'current' => $totalProducts, + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'status' => 'completed', + 'current_sku' => '' + ], 3600); + } + + return [ + 'success' => true, + 'message' => $dryRun ? 'Product migration dry run completed' : 'Product migration completed', + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog, + 'missing_attributes' => $missingAttributes, + 'dry_run' => $dryRun, + 'progress_key' => $progressKey + ]; + + } catch (Exception $e) { + if (!$dryRun && isset($this->magento2Connection)) { + DB::connection($this->magento2Connection)->rollBack(); + } + + // Update progress status to failed if tracking enabled + if (isset($progressKey) && $progressKey) { + Cache::put($progressKey, [ + 'total' => $totalProducts ?? 0, + 'current' => $currentIndex ?? 0, + 'added' => $addedCount ?? 0, + 'updated' => $updatedCount ?? 0, + 'errors' => $errorCount ?? 0, + 'status' => 'failed', + 'current_sku' => '' + ], 3600); + } + + Log::error('Product migration error: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Product migration failed: ' . $e->getMessage(), + 'added' => 0, + 'updated' => 0, + 'errors' => 0, + 'log' => [], + 'missing_attributes' => [], + 'progress_key' => $progressKey ?? null + ]; + } + } + + /** + * Get Magento 1 product attribute IDs + */ + protected function getMagento1ProductAttributeIds() + { + $entityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return []; + } + + $attributes = ['sku', 'name', 'description', 'short_description', 'price', 'weight', 'status', 'visibility', 'tax_class_id']; + $attributeIds = []; + + foreach ($attributes as $attrCode) { + $attrId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', $attrCode) + ->value('attribute_id'); + if ($attrId) { + $attributeIds[$attrCode] = $attrId; + } + } + + return $attributeIds; + } + + /** + * Get all Magento 1 product attributes + */ + protected function getAllMagento1ProductAttributes() + { + $entityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return collect([]); + } + + return DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->select('attribute_id', 'attribute_code', 'backend_type', 'frontend_label') + ->get(); + } + + /** + * Get all Magento 2 product attribute IDs + */ + protected function getAllMagento2ProductAttributeIds() + { + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return []; + } + + $attributes = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->select('attribute_id', 'attribute_code') + ->get(); + + $attributeIds = []; + foreach ($attributes as $attr) { + $attributeIds[$attr->attribute_code] = $attr->attribute_id; + } + + return $attributeIds; + } + + /** + * Get Magento 2 product attribute IDs + */ + protected function getMagento2ProductAttributeIds() + { + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return []; + } + + $attributes = ['sku', 'name', 'description', 'short_description', 'price', 'weight', 'status', 'visibility', 'tax_class_id']; + $attributeIds = []; + + foreach ($attributes as $attrCode) { + $attrId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', $attrCode) + ->value('attribute_id'); + if ($attrId) { + $attributeIds[$attrCode] = $attrId; + } + } + + return $attributeIds; + } + + /** + * Migrate ALL data from ALL catalog_product_entity_* tables for a product + * This ensures we migrate images, all store views, and all attributes + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing attribute values in M2 that don't exist + * in M1 will be preserved and not removed. + */ + protected function migrateAllProductEntityData($m1ProductId, $m2ProductId, $m1EntityTypeId, $m2EntityTypeId, $m1AttributeIds, $m2AttributeIds, $dryRun = false, $allM1Attributes = null) + { + $missingAttributes = []; + + // All possible backend types for EAV attributes + $backendTypes = ['varchar', 'int', 'text', 'decimal', 'datetime']; + + // Get attribute mapping: M1 attribute_id => M2 attribute_id + $attributeMapping = []; + if ($allM1Attributes) { + foreach ($allM1Attributes as $m1Attr) { + $attrCode = $m1Attr->attribute_code; + if (isset($m2AttributeIds[$attrCode])) { + $attributeMapping[$m1Attr->attribute_id] = [ + 'm2_attr_id' => $m2AttributeIds[$attrCode], + 'backend_type' => $m1Attr->backend_type ?? 'varchar', + 'attribute_code' => $attrCode + ]; + } + } + } + + // Migrate data from each backend type table + foreach ($backendTypes as $backendType) { + $m1Table = $this->magento1Prefix . 'catalog_product_entity_' . $backendType; + $m2Table = $this->magento2Prefix . 'catalog_product_entity_' . $backendType; + + try { + // Check if M1 table exists + $m1Rows = DB::connection($this->magento1Connection) + ->table($m1Table) + ->where('entity_id', $m1ProductId) + ->get(); + + if ($m1Rows->isEmpty()) { + continue; // No data in this table for this product + } + + // Check if M2 table exists + try { + DB::connection($this->magento2Connection) + ->table($m2Table) + ->limit(1) + ->first(); + } catch (Exception $e) { + Log::warning("M2 table {$m2Table} doesn't exist: " . $e->getMessage()); + continue; + } + + if (!$dryRun) { + // Migrate each row + foreach ($m1Rows as $m1Row) { + $m1AttrId = $m1Row->attribute_id; + + // Check if we have a mapping for this attribute + if (!isset($attributeMapping[$m1AttrId])) { + // Attribute doesn't exist in M2, log it + if ($allM1Attributes) { + $attr = $allM1Attributes->firstWhere('attribute_id', $m1AttrId); + if ($attr) { + $missingAttr = [ + 'code' => $attr->attribute_code ?? 'unknown', + 'label' => $attr->frontend_label ?? 'Unknown', + 'type' => $attr->backend_type ?? $backendType + ]; + // Check if already in list + $exists = false; + foreach ($missingAttributes as $existingAttr) { + if ($existingAttr['code'] === $missingAttr['code']) { + $exists = true; + break; + } + } + if (!$exists) { + $missingAttributes[] = $missingAttr; + } + } + } + continue; // Skip this attribute + } + + $m2AttrId = $attributeMapping[$m1AttrId]['m2_attr_id']; + $storeId = $m1Row->store_id ?? 0; + + // Check if this row already exists in M2 + $exists = DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $m2AttrId) + ->where('store_id', $storeId) + ->exists(); + + if ($exists) { + // Update existing row + DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $m2AttrId) + ->where('store_id', $storeId) + ->update(['value' => $m1Row->value]); + } else { + // Insert new row + DB::connection($this->magento2Connection) + ->table($m2Table) + ->insert([ + 'attribute_id' => $m2AttrId, + 'store_id' => $storeId, + 'entity_id' => $m2ProductId, + 'value' => $m1Row->value, + ]); + } + } + } + } catch (Exception $e) { + // Table doesn't exist in M1, skip it + Log::debug("M1 table {$m1Table} doesn't exist or error: " . $e->getMessage()); + continue; + } + } + + return $missingAttributes; + } + + /** + * Migrate product media gallery images from M1 to M2 + * Populates catalog_product_entity_media_gallery, catalog_product_entity_media_gallery_value, + * and catalog_product_entity_media_gallery_value_to_entity tables + * Also handles individual image attributes (image, small_image, thumbnail) + */ + protected function migrateProductMediaGallery($m1ProductId, $m2ProductId, $m1EntityTypeId) + { + try { + // Get media_gallery attribute ID from M1 + $m1MediaGalleryAttrId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $m1EntityTypeId) + ->where('attribute_code', 'media_gallery') + ->value('attribute_id'); + + // Get media_gallery attribute ID from M2 + $m2EntityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$m2EntityTypeId) { + return; + } + + $m2MediaGalleryAttrId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $m2EntityTypeId) + ->where('attribute_code', 'media_gallery') + ->value('attribute_id'); + + if (!$m2MediaGalleryAttrId) { + // Media gallery attribute doesn't exist in M2, skip + return; + } + + $images = []; + + // Also check for individual image attributes (image, small_image, thumbnail) + // These are stored as separate varchar attributes in M1 + $imageAttributes = ['image', 'small_image', 'thumbnail']; + $m1ImageAttributeIds = []; + foreach ($imageAttributes as $attrCode) { + $attrId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $m1EntityTypeId) + ->where('attribute_code', $attrCode) + ->value('attribute_id'); + if ($attrId) { + $m1ImageAttributeIds[$attrCode] = $attrId; + } + } + + // Get individual image attribute values from M1 + if (!empty($m1ImageAttributeIds)) { + $m1ImageValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('entity_id', $m1ProductId) + ->whereIn('attribute_id', array_values($m1ImageAttributeIds)) + ->where('store_id', 0) + ->whereNotNull('value') + ->where('value', '!=', '') + ->where('value', '!=', 'no_selection') + ->get(); + + foreach ($m1ImageValues as $imageValue) { + $imageFile = $imageValue->value; + if (!empty($imageFile)) { + // Find which attribute this is + $attrCode = array_search($imageValue->attribute_id, $m1ImageAttributeIds); + // Add to images array if not already present + $found = false; + foreach ($images as $existingImage) { + if (isset($existingImage['file']) && $existingImage['file'] === $imageFile) { + $found = true; + break; + } + } + if (!$found) { + $images[] = [ + 'file' => $imageFile, + 'label' => ucfirst($attrCode ?? 'Image'), + 'position' => count($images) + 1, + 'disabled' => 0, + ]; + } + } + } + } + + // First, try to get images from M1 media gallery tables (if they exist) + // In M1, catalog_product_entity_media_gallery has: value_id, attribute_id, entity_id, value + try { + $m1MediaGalleryTable = $this->magento1Prefix . 'catalog_product_entity_media_gallery'; + $m1MediaGalleryValueTable = $this->magento1Prefix . 'catalog_product_entity_media_gallery_value'; + + // Check if M1 media gallery tables exist by trying to query them + // Filter by entity_id (product ID) - this is the key field + $m1GalleryQuery = DB::connection($this->magento1Connection) + ->table($m1MediaGalleryTable) + ->where('entity_id', $m1ProductId); + + // Some M1 versions have attribute_id in the media_gallery table, try to filter by it if it exists + // But don't fail if the column doesn't exist + try { + if ($m1MediaGalleryAttrId) { + $m1GalleryQuery->where('attribute_id', $m1MediaGalleryAttrId); + } + } catch (Exception $e) { + // attribute_id column might not exist, continue without it + Log::debug("attribute_id column not found in M1 media gallery table, using entity_id only"); + } + + $m1GalleryImages = $m1GalleryQuery->get(); + + Log::info("Found " . $m1GalleryImages->count() . " images in M1 media gallery table for product ID {$m1ProductId} (SKU mapping: M1 ID {$m1ProductId} -> M2 ID {$m2ProductId})"); + + if ($m1GalleryImages->isNotEmpty()) { + // M1 has media gallery tables, use them + Log::info("Processing " . $m1GalleryImages->count() . " images from M1 media gallery table"); + foreach ($m1GalleryImages as $m1Image) { + $valueId = $m1Image->value_id; + + // Get value data (label, position, disabled) from the value table + $m1Value = null; + try { + $m1Value = DB::connection($this->magento1Connection) + ->table($m1MediaGalleryValueTable) + ->where('value_id', $valueId) + ->where('store_id', 0) + ->first(); + } catch (Exception $e) { + // Value table might not exist or have different structure + Log::debug("Could not read from M1 media gallery value table: " . $e->getMessage()); + } + + $imageFile = $m1Image->value ?? null; + if (empty($imageFile)) { + Log::warning("Image file is empty for value_id {$valueId} in M1 product {$m1ProductId}"); + continue; + } + + // Check if this image is already in the array (from individual attributes) + $found = false; + foreach ($images as $key => $existingImage) { + if (isset($existingImage['file']) && $existingImage['file'] === $imageFile) { + $found = true; + // Update with gallery data if it has better info + if ($m1Value && ($m1Value->label || $m1Value->position > 0)) { + $images[$key]['label'] = $m1Value->label ?? $images[$key]['label']; + $images[$key]['position'] = $m1Value->position ?? $images[$key]['position']; + $images[$key]['disabled'] = $m1Value->disabled ?? $images[$key]['disabled']; + } + break; + } + } + if (!$found) { + $images[] = [ + 'file' => $imageFile, + 'label' => $m1Value->label ?? null, + 'position' => $m1Value->position ?? 0, + 'disabled' => $m1Value->disabled ?? 0, + ]; + Log::debug("Added image from M1 media gallery: {$imageFile}"); + } + } + } else { + Log::debug("No images found in M1 media gallery table for product ID {$m1ProductId}"); + } + } catch (Exception $e) { + // M1 media gallery tables don't exist, fall back to varchar table + Log::warning("M1 media gallery tables not found or error accessing them, will try varchar table: " . $e->getMessage()); + } + + // If no images from gallery tables, try varchar table (serialized data) + if (empty($images) && $m1MediaGalleryAttrId) { + $m1MediaGalleryData = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('entity_id', $m1ProductId) + ->where('attribute_id', $m1MediaGalleryAttrId) + ->where('store_id', 0) + ->value('value'); + + if (empty($m1MediaGalleryData)) { + // No media gallery data in M1, but we might have individual images already + if (empty($images)) { + return; + } + } + + // Parse the media gallery data (Magento 1 stores it as serialized PHP) + if (is_string($m1MediaGalleryData)) { + // Try to unserialize (Magento 1 format) + $unserialized = @unserialize($m1MediaGalleryData); + if ($unserialized !== false && is_array($unserialized)) { + $parsedImages = $unserialized; + } else { + // Try JSON decode (some versions might use JSON) + $jsonDecoded = @json_decode($m1MediaGalleryData, true); + if (is_array($jsonDecoded)) { + $parsedImages = $jsonDecoded; + } else { + // If it's a simple string, treat it as a single image path + if (!empty(trim($m1MediaGalleryData))) { + $parsedImages = [['file' => trim($m1MediaGalleryData)]]; + } else { + $parsedImages = []; + } + } + } + + // Convert parsed images to our format + if (!empty($parsedImages)) { + if (isset($parsedImages['images']) && is_array($parsedImages['images'])) { + $parsedImages = $parsedImages['images']; + } + + foreach ($parsedImages as $img) { + $imageFile = null; + if (is_string($img)) { + $imageFile = $img; + } elseif (is_array($img)) { + $imageFile = $img['file'] ?? $img['value'] ?? null; + } + + if (!empty($imageFile)) { + // Check if this image is already in the array + $found = false; + foreach ($images as $existingImage) { + if (isset($existingImage['file']) && $existingImage['file'] === $imageFile) { + $found = true; + break; + } + } + if (!$found) { + if (is_string($img)) { + $images[] = ['file' => $imageFile]; + } elseif (is_array($img)) { + $images[] = [ + 'file' => $imageFile, + 'label' => $img['label'] ?? $img['label_default'] ?? null, + 'position' => isset($img['position']) ? (int)$img['position'] : 0, + 'disabled' => isset($img['disabled']) ? (int)$img['disabled'] : 0, + ]; + } + } + } + } + } + } + } + + if (empty($images)) { + Log::debug("No images found for product M1 ID {$m1ProductId} -> M2 ID {$m2ProductId}"); + return; + } + + Log::info("Migrating " . count($images) . " images for product M1 ID {$m1ProductId} -> M2 ID {$m2ProductId}"); + + // Process each image + foreach ($images as $imageData) { + // Handle different data structures + $imageFile = null; + $label = null; + $position = 0; + $disabled = 0; + + if (is_string($imageData)) { + // Simple string path + $imageFile = $imageData; + } elseif (is_array($imageData)) { + // Array structure + $imageFile = $imageData['file'] ?? $imageData['value'] ?? null; + $label = $imageData['label'] ?? $imageData['label_default'] ?? null; + $position = isset($imageData['position']) ? (int)$imageData['position'] : 0; + $disabled = isset($imageData['disabled']) ? (int)$imageData['disabled'] : 0; + } + + if (empty($imageFile)) { + continue; + } + + // Clean up the image file path (remove leading slashes, etc.) + $imageFile = ltrim($imageFile, '/'); + if (empty($imageFile)) { + continue; + } + + // Check if this image already exists in M2 for this product + $existingValueId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery') + ->where('attribute_id', $m2MediaGalleryAttrId) + ->where('value', $imageFile) + ->value('value_id'); + + if (!$existingValueId) { + // Insert into catalog_product_entity_media_gallery + $valueId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery') + ->insertGetId([ + 'attribute_id' => $m2MediaGalleryAttrId, + 'value' => $imageFile, + ]); + } else { + $valueId = $existingValueId; + } + + // Check if value_to_entity link already exists + $linkExists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery_value_to_entity') + ->where('value_id', $valueId) + ->where('entity_id', $m2ProductId) + ->exists(); + + if (!$linkExists) { + // Insert into catalog_product_entity_media_gallery_value_to_entity + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery_value_to_entity') + ->insert([ + 'value_id' => $valueId, + 'entity_id' => $m2ProductId, + ]); + Log::debug("Linked image {$imageFile} (value_id: {$valueId}) to M2 product ID {$m2ProductId}"); + } else { + Log::debug("Image {$imageFile} (value_id: {$valueId}) already linked to M2 product ID {$m2ProductId}"); + } + + // Insert/update catalog_product_entity_media_gallery_value for default store (store_id = 0) + // Note: This table requires entity_id as it has a foreign key constraint + $valueExists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery_value') + ->where('value_id', $valueId) + ->where('store_id', 0) + ->where('entity_id', $m2ProductId) + ->exists(); + + if (!$valueExists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery_value') + ->insert([ + 'value_id' => $valueId, + 'store_id' => 0, + 'entity_id' => $m2ProductId, + 'label' => $label, + 'position' => $position, + 'disabled' => $disabled, + ]); + Log::debug("Inserted media gallery value for image {$imageFile} (value_id: {$valueId}, entity_id: {$m2ProductId})"); + } else { + // Update existing value + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_media_gallery_value') + ->where('value_id', $valueId) + ->where('store_id', 0) + ->where('entity_id', $m2ProductId) + ->update([ + 'label' => $label, + 'position' => $position, + 'disabled' => $disabled, + ]); + Log::debug("Updated media gallery value for image {$imageFile} (value_id: {$valueId}, entity_id: {$m2ProductId})"); + } + } + + } catch (Exception $e) { + // Log error but don't fail the entire migration + Log::warning("Error migrating media gallery for product M1 ID {$m1ProductId} -> M2 ID {$m2ProductId}: " . $e->getMessage()); + } + } + + /** + * Migrate product attributes from M1 to M2 + */ + protected function migrateProductAttributes($m1ProductId, $m2ProductId, $m1EntityTypeId, $m2EntityTypeId, $m1AttributeIds, $m2AttributeIds, $isNew, $dryRun = false, $allM1Attributes = null) + { + $missingAttributes = []; + + // Get attribute backend types for all M1 attributes + $m1AttributeTypes = []; + if (!empty($m1AttributeIds)) { + $m1AttributeTypes = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $m1EntityTypeId) + ->whereIn('attribute_id', array_values($m1AttributeIds)) + ->pluck('backend_type', 'attribute_id') + ->toArray(); + } + + $m2AttributeTypes = []; + if (!empty($m2AttributeIds)) { + $m2AttributeTypes = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $m2EntityTypeId) + ->whereIn('attribute_id', array_values($m2AttributeIds)) + ->pluck('backend_type', 'attribute_id') + ->toArray(); + } + + // Migrate each attribute + foreach ($m1AttributeIds as $attrCode => $m1AttrId) { + // Check if attribute exists in M2 + if (!isset($m2AttributeIds[$attrCode])) { + // Attribute doesn't exist in M2 + if ($allM1Attributes) { + $attr = $allM1Attributes->firstWhere('attribute_id', $m1AttrId); + if ($attr) { + $missingAttributes[] = [ + 'code' => $attrCode, + 'label' => $attr->frontend_label ?? $attrCode, + 'type' => $attr->backend_type ?? 'varchar' + ]; + } + } + continue; + } + + $m2AttrId = $m2AttributeIds[$attrCode]; + $backendType = $m1AttributeTypes[$m1AttrId] ?? 'varchar'; + + // Handle static attributes (stored in main entity table) + if ($backendType === 'static') { + // Static attributes are columns in catalog_product_entity table + // Skip for now as they're usually handled during entity creation + continue; + } + + // Get M1 attribute value + $m1Table = $this->magento1Prefix . 'catalog_product_entity_' . $backendType; + + try { + $m1Value = DB::connection($this->magento1Connection) + ->table($m1Table) + ->where('entity_id', $m1ProductId) + ->where('attribute_id', $m1AttrId) + ->where('store_id', 0) + ->value('value'); + } catch (Exception $e) { + // Table doesn't exist - this means the attribute table is missing + // Track this as a missing attribute/table + if ($allM1Attributes) { + $attr = $allM1Attributes->firstWhere('attribute_id', $m1AttrId); + if ($attr) { + $missingAttr = [ + 'code' => $attrCode, + 'label' => $attr->frontend_label ?? $attrCode, + 'type' => $backendType + ]; + // Check if already in list + $exists = false; + foreach ($missingAttributes as $existingAttr) { + if ($existingAttr['code'] === $missingAttr['code']) { + $exists = true; + break; + } + } + if (!$exists) { + $missingAttributes[] = $missingAttr; + } + } + } + Log::warning("Table {$m1Table} doesn't exist for attribute {$attrCode}: " . $e->getMessage()); + continue; + } + + if ($m1Value === null) { + continue; // No value in M1 + } + + if (!$dryRun) { + // Insert or update in M2 + $m2Table = $this->magento2Prefix . 'catalog_product_entity_' . $backendType; + $exists = DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $m2AttrId) + ->where('store_id', 0) + ->exists(); + + if ($exists) { + DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $m2AttrId) + ->where('store_id', 0) + ->update(['value' => $m1Value]); + } else { + DB::connection($this->magento2Connection) + ->table($m2Table) + ->insert([ + 'attribute_id' => $m2AttrId, + 'store_id' => 0, + 'entity_id' => $m2ProductId, + 'value' => $m1Value, + ]); + } + } + } + + return $missingAttributes; + } + + /** + * Compare M1 and M2 product values by SKU + * Returns all differences that would be updated during migration + */ + public function compareProductBySku($sku) + { + try { + $comparisonByTable = []; + + // Get M1 and M2 entity type IDs + $m1EntityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + $m2EntityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$m1EntityTypeId || !$m2EntityTypeId) { + return [ + 'success' => false, + 'message' => 'Entity type not found', + 'changes_by_table' => [] + ]; + } + + // Find M1 product by SKU + $m1Product = $this->findProductBySku($sku, $this->magento1Connection, $this->magento1Prefix); + if (!$m1Product) { + return [ + 'success' => false, + 'message' => "Product with SKU '{$sku}' not found in Magento 1", + 'changes_by_table' => [] + ]; + } + + $m1ProductId = $m1Product->entity_id; + + // Find M2 product by SKU + $m2Product = $this->findProductBySku($sku, $this->magento2Connection, $this->magento2Prefix); + if (!$m2Product) { + return [ + 'success' => true, + 'message' => "Product with SKU '{$sku}' not found in Magento 2. It will be created during migration.", + 'm1_product_id' => $m1ProductId, + 'm2_product_id' => null, + 'changes_by_table' => [] + ]; + } + + $m2ProductId = $m2Product->entity_id; + + // Get all M1 attributes + $allM1Attributes = $this->getAllMagento1ProductAttributes(); + $m2AttributeIds = $this->getAllMagento2ProductAttributeIds(); + + // Build attribute mapping + $attributeMapping = []; + if ($allM1Attributes) { + foreach ($allM1Attributes as $m1Attr) { + $attrCode = $m1Attr->attribute_code; + if (isset($m2AttributeIds[$attrCode])) { + $attributeMapping[$m1Attr->attribute_id] = [ + 'm2_attr_id' => $m2AttributeIds[$attrCode], + 'backend_type' => $m1Attr->backend_type ?? 'varchar', + 'attribute_code' => $attrCode, + 'frontend_label' => $m1Attr->frontend_label ?? $attrCode + ]; + } + } + } + + // Compare all backend type tables + $backendTypes = ['varchar', 'int', 'text', 'decimal', 'datetime']; + + foreach ($backendTypes as $backendType) { + $m1Table = $this->magento1Prefix . 'catalog_product_entity_' . $backendType; + $m2Table = $this->magento2Prefix . 'catalog_product_entity_' . $backendType; + $tableChanges = []; + + try { + // Get M1 values + $m1Rows = DB::connection($this->magento1Connection) + ->table($m1Table) + ->where('entity_id', $m1ProductId) + ->get(); + + // Get M2 values + $m2Rows = DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2ProductId) + ->get(); + + // Build M2 lookup by attribute_id and store_id + $m2Lookup = []; + foreach ($m2Rows as $m2Row) { + $key = $m2Row->attribute_id . '_' . ($m2Row->store_id ?? 0); + $m2Lookup[$key] = $m2Row; + } + + // Compare M1 values with M2 + foreach ($m1Rows as $m1Row) { + $m1AttrId = $m1Row->attribute_id; + + // Check if attribute exists in M2 + if (!isset($attributeMapping[$m1AttrId])) { + continue; // Skip attributes that don't exist in M2 + } + + $mapping = $attributeMapping[$m1AttrId]; + $m2AttrId = $mapping['m2_attr_id']; + $storeId = $m1Row->store_id ?? 0; + $key = $m2AttrId . '_' . $storeId; + + $m1Value = $m1Row->value ?? ''; + $m2Value = isset($m2Lookup[$key]) ? ($m2Lookup[$key]->value ?? '') : null; + + // Check if values are different + if ($m1Value != $m2Value) { + $tableChanges[] = [ + 'attribute_code' => $mapping['attribute_code'], + 'attribute_label' => $mapping['frontend_label'], + 'backend_type' => $mapping['backend_type'], + 'store_id' => $storeId, + 'm1_value' => $m1Value, + 'm2_value' => $m2Value, + 'action' => $m2Value === null ? 'INSERT' : 'UPDATE' + ]; + } + } + + // Check for M2 values that don't exist in M1 (would be deleted) + foreach ($m2Rows as $m2Row) { + $m2AttrId = $m2Row->attribute_id; + $storeId = $m2Row->store_id ?? 0; + + // Check if this attribute exists in M1 mapping + $foundInM1 = false; + foreach ($attributeMapping as $m1AttrId => $mapping) { + if ($mapping['m2_attr_id'] == $m2AttrId) { + // Check if M1 has this value + $m1HasValue = $m1Rows->where('attribute_id', $m1AttrId) + ->where('store_id', $storeId) + ->isNotEmpty(); + if ($m1HasValue) { + $foundInM1 = true; + break; + } + } + } + + // If not found in M1 and it's not a system attribute, note it + if (!$foundInM1) { + // Try to get attribute code for display + $attrCode = 'unknown'; + foreach ($attributeMapping as $m1AttrId => $mapping) { + if ($mapping['m2_attr_id'] == $m2AttrId) { + $attrCode = $mapping['attribute_code']; + break; + } + } + + // Only add if it's a meaningful attribute (not system-generated) + if ($attrCode !== 'unknown') { + $tableChanges[] = [ + 'attribute_code' => $attrCode, + 'attribute_label' => 'Unknown', + 'backend_type' => $backendType, + 'store_id' => $storeId, + 'm1_value' => null, + 'm2_value' => $m2Row->value ?? '', + 'action' => 'DELETE' + ]; + } + } + } + + // Add table changes to comparison (even if empty, to show all tables) + $comparisonByTable[$backendType] = [ + 'table_name' => 'catalog_product_entity_' . $backendType, + 'm1_table' => $m1Table, + 'm2_table' => $m2Table, + 'changes' => $tableChanges + ]; + + } catch (Exception $e) { + // Table might not exist, add empty entry + $comparisonByTable[$backendType] = [ + 'table_name' => 'catalog_product_entity_' . $backendType, + 'm1_table' => $m1Table, + 'm2_table' => $m2Table, + 'changes' => [], + 'error' => $e->getMessage() + ]; + Log::debug("Table comparison error for {$m1Table}: " . $e->getMessage()); + continue; + } + } + + return [ + 'success' => true, + 'message' => "Comparison completed for SKU '{$sku}'", + 'm1_product_id' => $m1ProductId, + 'm2_product_id' => $m2ProductId, + 'sku' => $sku, + 'changes_by_table' => $comparisonByTable + ]; + + } catch (Exception $e) { + Log::error('Error comparing product by SKU: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Comparison failed: ' . $e->getMessage(), + 'changes' => [] + ]; + } + } + + /** + * Find product by SKU in either M1 or M2 + */ + protected function findProductBySku($sku, $connection, $prefix) + { + // Try entity table first + try { + $product = DB::connection($connection) + ->table($prefix . 'catalog_product_entity') + ->where('sku', $sku) + ->first(); + + if ($product) { + return $product; + } + } catch (Exception $e) { + // SKU might be in EAV table + } + + // Try EAV table + $entityTypeId = DB::connection($connection) + ->table($prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return null; + } + + $skuAttributeId = DB::connection($connection) + ->table($prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + if (!$skuAttributeId) { + return null; + } + + $skuRow = DB::connection($connection) + ->table($prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('value', $sku) + ->where('store_id', 0) + ->first(); + + if (!$skuRow) { + return null; + } + + return DB::connection($connection) + ->table($prefix . 'catalog_product_entity') + ->where('entity_id', $skuRow->entity_id) + ->first(); + } + + /** + * Delete a single product from Magento 2 + */ + public function deleteM2Product($productId) + { + try { + // Check if product exists + $product = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $productId) + ->first(); + + if (!$product) { + return [ + 'success' => false, + 'message' => 'Product not found in Magento 2' + ]; + } + + DB::connection($this->magento2Connection)->beginTransaction(); + + try { + // Delete product attributes + $attributeTables = ['varchar', 'int', 'text', 'decimal', 'datetime']; + foreach ($attributeTables as $tableType) { + $table = $this->magento2Prefix . 'catalog_product_entity_' . $tableType; + try { + DB::connection($this->magento2Connection) + ->table($table) + ->where('entity_id', $productId) + ->delete(); + } catch (Exception $e) { + // Table might not exist, continue + Log::warning("Table {$table} might not exist: " . $e->getMessage()); + } + } + + // Delete category associations + try { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('product_id', $productId) + ->delete(); + } catch (Exception $e) { + Log::warning("Table {$this->magento2Prefix}catalog_category_product might not exist: " . $e->getMessage()); + } + + // Delete stock items if they exist + try { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->where('product_id', $productId) + ->delete(); + } catch (Exception $e) { + // Table might not exist, continue + } + + // Delete the product entity + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $productId) + ->delete(); + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => "Product ID {$productId} deleted successfully" + ]; + + } catch (Exception $e) { + DB::connection($this->magento2Connection)->rollBack(); + throw $e; + } + + } catch (Exception $e) { + Log::error('Error deleting M2 product: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Failed to delete product: ' . $e->getMessage() + ]; + } + } + + /** + * Sync product category assignments from M1 to M2 + * Updates M2 products to be in the same categories as their M1 counterparts + */ + public function syncProductCategories() + { + try { + $this->migrationLog = []; + $updatedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + // Get category mapping + $categoryMapping = $this->getCategoryMapping(); + + if (empty($categoryMapping)) { + return [ + 'success' => false, + 'message' => 'No category mapping found. Please migrate categories first.', + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + + // Get all M1 products with their category associations + $m1Products = $this->getMagento1Products(); + $m2Products = $this->getMagento2Products(); + + // Create lookup maps for M2 products + $m2ProductBySku = []; + $m2ProductById = []; + foreach ($m2Products as $m2Product) { + $sku = $m2Product->sku ?? 'N/A'; + if ($sku !== 'N/A' && !empty($sku)) { + $m2ProductBySku[$sku] = $m2Product->entity_id; + } + $m2ProductById[$m2Product->entity_id] = $m2Product->entity_id; + } + + DB::connection($this->magento2Connection)->beginTransaction(); + + foreach ($m1Products as $m1Product) { + try { + // Find corresponding M2 product + $m2ProductId = null; + $m1Sku = $m1Product->sku ?? 'N/A'; + + if ($m1Sku !== 'N/A' && !empty($m1Sku)) { + // Match by SKU + if (isset($m2ProductBySku[$m1Sku])) { + $m2ProductId = $m2ProductBySku[$m1Sku]; + } + } else { + // Match by product ID + if (isset($m2ProductById[$m1Product->entity_id])) { + $m2ProductId = $m1Product->entity_id; + } + } + + if (!$m2ProductId) { + $skippedCount++; + $this->migrationLog[] = "SKIPPED: M1 Product ID {$m1Product->entity_id} (SKU: {$m1Sku}) - not found in M2"; + continue; + } + + // Ensure product is enabled and visible (required for products to show in categories after reindex) + $this->ensureProductIsEnabledAndVisible($m2ProductId); + + // Get M1 product categories + $m1Categories = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_category_product') + ->where('product_id', $m1Product->entity_id) + ->get(); + + if ($m1Categories->isEmpty()) { + $skippedCount++; + $this->migrationLog[] = "SKIPPED: M1 Product ID {$m1Product->entity_id} (SKU: {$m1Sku}) - no categories in M1"; + continue; + } + + // Get current M2 product categories + $m2CurrentCategories = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('product_id', $m2ProductId) + ->pluck('category_id') + ->toArray(); + + // Build expected M2 categories from M1 categories using mapping + $expectedM2Categories = []; + $categoriesToAdd = []; + $unmappedCategories = []; + + foreach ($m1Categories as $m1Category) { + if (isset($categoryMapping[$m1Category->category_id])) { + $m2CategoryId = $categoryMapping[$m1Category->category_id]; + $expectedM2Categories[] = $m2CategoryId; + + // Check if this category association needs to be added + if (!in_array($m2CategoryId, $m2CurrentCategories)) { + $categoriesToAdd[] = [ + 'category_id' => $m2CategoryId, + 'product_id' => $m2ProductId, + 'position' => $m1Category->position ?? 0, + ]; + } + } else { + // Category not in mapping - try to find it by name + $m1CategoryName = $this->getCategoryNameById($m1Category->category_id, 'm1'); + if ($m1CategoryName) { + $m2CategoryId = $this->findCategoryByName($m1CategoryName, 'm2'); + if ($m2CategoryId) { + // Found by name, add to mapping for future use + $categoryMapping[$m1Category->category_id] = $m2CategoryId; + $expectedM2Categories[] = $m2CategoryId; + + if (!in_array($m2CategoryId, $m2CurrentCategories)) { + $categoriesToAdd[] = [ + 'category_id' => $m2CategoryId, + 'product_id' => $m2ProductId, + 'position' => $m1Category->position ?? 0, + ]; + } + $this->migrationLog[] = "FOUND BY NAME: M1 Category ID {$m1Category->category_id} ({$m1CategoryName}) -> M2 Category ID {$m2CategoryId}"; + } else { + $unmappedCategories[] = $m1CategoryName ?: "ID {$m1Category->category_id}"; + } + } else { + $unmappedCategories[] = "ID {$m1Category->category_id}"; + } + } + } + + if (!empty($unmappedCategories)) { + $this->migrationLog[] = "WARNING: M2 Product ID {$m2ProductId} (SKU: {$m1Sku}) - M1 categories not found in M2: " . implode(', ', $unmappedCategories); + } + + // Remove categories that are in M2 but not in M1 (after mapping) + $categoriesToRemove = array_diff($m2CurrentCategories, $expectedM2Categories); + + // Add missing category associations + foreach ($categoriesToAdd as $categoryData) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->insert($categoryData); + } + + // Remove categories that shouldn't be there + if (!empty($categoriesToRemove)) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('product_id', $m2ProductId) + ->whereIn('category_id', $categoriesToRemove) + ->delete(); + } + + if (!empty($categoriesToAdd) || !empty($categoriesToRemove)) { + $updatedCount++; + $addedCount = count($categoriesToAdd); + $removedCount = count($categoriesToRemove); + $this->migrationLog[] = "UPDATED: M2 Product ID {$m2ProductId} (SKU: {$m1Sku}) - Added {$addedCount} category(ies), Removed {$removedCount} category(ies)"; + } else { + $skippedCount++; + $this->migrationLog[] = "SKIPPED: M2 Product ID {$m2ProductId} (SKU: {$m1Sku}) - categories already match"; + } + + } catch (Exception $e) { + $errorCount++; + $m1Sku = $m1Product->sku ?? 'N/A'; + $this->migrationLog[] = "ERROR: Failed to sync categories for M1 Product ID {$m1Product->entity_id} (SKU: {$m1Sku}): " . $e->getMessage(); + Log::error("Error syncing product categories for M1 Product ID {$m1Product->entity_id}: " . $e->getMessage()); + } + } + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => "Synced product categories. Updated: {$updatedCount}, Skipped: {$skippedCount}, Errors: {$errorCount}", + 'updated' => $updatedCount, + 'skipped' => $skippedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog + ]; + + } catch (Exception $e) { + if (isset($this->magento2Connection)) { + DB::connection($this->magento2Connection)->rollBack(); + } + Log::error('Error syncing product categories: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Failed to sync product categories: ' . $e->getMessage(), + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => $this->migrationLog + ]; + } + } + + /** + * Delete all products in Magento 2 that have entity_id greater than max M1 product ID + */ + public function deleteM2ProductsAboveM1Max() + { + try { + // Get max product ID from M1 + $maxM1ProductId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->max('entity_id'); + + if (!$maxM1ProductId) { + return [ + 'success' => false, + 'message' => 'No products found in Magento 1', + 'deleted' => 0 + ]; + } + + // Get products to delete from M2 + $productsToDelete = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', '>', $maxM1ProductId) + ->pluck('entity_id') + ->toArray(); + + if (empty($productsToDelete)) { + return [ + 'success' => true, + 'message' => 'No products found to delete', + 'deleted' => 0, + 'max_m1_id' => $maxM1ProductId + ]; + } + + $deletedCount = 0; + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + DB::connection($this->magento2Connection)->beginTransaction(); + + foreach ($productsToDelete as $productId) { + try { + // Delete product attributes + $attributeTables = ['varchar', 'int', 'text', 'decimal', 'datetime']; + foreach ($attributeTables as $tableType) { + $table = $this->magento2Prefix . 'catalog_product_entity_' . $tableType; + try { + DB::connection($this->magento2Connection) + ->table($table) + ->where('entity_id', $productId) + ->delete(); + } catch (Exception $e) { + // Table might not exist, continue + Log::warning("Table {$table} might not exist: " . $e->getMessage()); + } + } + + // Delete category associations + try { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('product_id', $productId) + ->delete(); + } catch (Exception $e) { + Log::warning("Table {$this->magento2Prefix}catalog_category_product might not exist: " . $e->getMessage()); + } + + // Delete stock items if they exist + try { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->where('product_id', $productId) + ->delete(); + } catch (Exception $e) { + // Table might not exist, continue + } + + // Delete the product entity + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('entity_id', $productId) + ->delete(); + + $deletedCount++; + } catch (Exception $e) { + Log::error("Error deleting product {$productId}: " . $e->getMessage()); + } + } + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => "Deleted {$deletedCount} product(s) with ID > {$maxM1ProductId}", + 'deleted' => $deletedCount, + 'max_m1_id' => $maxM1ProductId + ]; + + } catch (Exception $e) { + if (isset($this->magento2Connection)) { + DB::connection($this->magento2Connection)->rollBack(); + } + Log::error('Error deleting M2 products above M1 max: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Failed to delete products: ' . $e->getMessage(), + 'deleted' => 0 + ]; + } + } + + /** + * Migrate product category associations + * + * IMPORTANT: This method only performs INSERT operations. + * NO DATA IS DELETED. Existing category associations in M2 will be preserved. + */ + protected function migrateProductCategories($m1ProductId, $m2ProductId, $categoryMapping) + { + // Get M1 product categories + $m1Categories = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_category_product') + ->where('product_id', $m1ProductId) + ->get(); + + foreach ($m1Categories as $m1Category) { + // Find corresponding M2 category + if (isset($categoryMapping[$m1Category->category_id])) { + $m2CategoryId = $categoryMapping[$m1Category->category_id]; + + // Check if association already exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('category_id', $m2CategoryId) + ->where('product_id', $m2ProductId) + ->exists(); + + if (!$exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->insert([ + 'category_id' => $m2CategoryId, + 'product_id' => $m2ProductId, + 'position' => $m1Category->position ?? 0, + ]); + } + } + } + } + + /** + * Get store mapping by matching store codes + */ + protected function getStoreMapping() + { + // If store mapping was set, use it + if (!empty($this->storeMapping)) { + return $this->storeMapping; + } + + // Try to get from category service if available + if ($this->categoryService && method_exists($this->categoryService, 'getStoreMapping')) { + $mapping = $this->categoryService->getStoreMapping(); + if (!empty($mapping)) { + return $mapping; + } + } + + // Otherwise, try to build mapping by matching store codes + $mapping = []; + + try { + $m1Stores = $this->getMagento1Stores(); + $m2Stores = $this->getMagento2Stores(); + + // Create a map of M2 stores by code + $m2StoreMap = []; + foreach ($m2Stores as $m2Store) { + $code = strtolower(trim($m2Store->code ?? '')); + if (!empty($code) && !isset($m2StoreMap[$code])) { + $m2StoreMap[$code] = $m2Store->store_id; + } + } + + // Match M1 stores to M2 by code + foreach ($m1Stores as $m1Store) { + $code = strtolower(trim($m1Store->code ?? '')); + if (!empty($code) && isset($m2StoreMap[$code])) { + $mapping[$m1Store->store_id] = $m2StoreMap[$code]; + } else { + // If no match by code, try to match by store_id (default store) + // This is a fallback for stores with same ID + $mapping[$m1Store->store_id] = $m1Store->store_id; + } + } + } catch (Exception $e) { + Log::warning('Error building store mapping: ' . $e->getMessage()); + } + + return $mapping; + } + + /** + * Get website mapping by matching website codes or IDs from stores + */ + protected function getWebsiteMapping() + { + $mapping = []; + + try { + $m1Stores = $this->getMagento1Stores(); + $m2Stores = $this->getMagento2Stores(); + + // Build website mapping from store website_ids + // Group stores by website_id and match by store codes + $m1WebsiteMap = []; + foreach ($m1Stores as $m1Store) { + $websiteId = $m1Store->website_id ?? 0; + if ($websiteId > 0 && !isset($m1WebsiteMap[$websiteId])) { + $m1WebsiteMap[$websiteId] = $m1Store->code ?? ''; + } + } + + $m2WebsiteMap = []; + foreach ($m2Stores as $m2Store) { + $websiteId = $m2Store->website_id ?? 0; + if ($websiteId > 0 && !isset($m2WebsiteMap[$websiteId])) { + $m2WebsiteMap[$websiteId] = $m2Store->code ?? ''; + } + } + + // Match websites by store codes (websites with stores that have matching codes) + foreach ($m1WebsiteMap as $m1WebsiteId => $m1StoreCode) { + $m1StoreCodeLower = strtolower(trim($m1StoreCode)); + if (!empty($m1StoreCodeLower)) { + // Find M2 website with matching store code + foreach ($m2WebsiteMap as $m2WebsiteId => $m2StoreCode) { + $m2StoreCodeLower = strtolower(trim($m2StoreCode)); + if ($m1StoreCodeLower === $m2StoreCodeLower) { + $mapping[$m1WebsiteId] = $m2WebsiteId; + break; + } + } + } + + // If no match found, try to match by website_id (fallback) + if (!isset($mapping[$m1WebsiteId])) { + // Check if M2 has a website with the same ID + if (isset($m2WebsiteMap[$m1WebsiteId])) { + $mapping[$m1WebsiteId] = $m1WebsiteId; + } else { + // Default to website ID 1 (main website) if no match + $mapping[$m1WebsiteId] = 1; + } + } + } + + // If no mapping found, ensure at least default website (ID 1) is mapped + if (empty($mapping)) { + $mapping[1] = 1; // Default website mapping + } + } catch (Exception $e) { + Log::warning('Error building website mapping: ' . $e->getMessage()); + // Default to website ID 1 if error + $mapping[1] = 1; + } + + return $mapping; + } + + /** + * Migrate product website assignments from M1 catalog_product_website to M2 catalog_product_website + * + * IMPORTANT: This method only performs INSERT operations. + * NO DATA IS DELETED. Existing website assignments in M2 will be preserved. + */ + protected function migrateProductWebsites($m1ProductId, $m2ProductId) + { + try { + // Get website mapping + $websiteMapping = $this->getWebsiteMapping(); + + // Get product websites from M1 + $m1ProductWebsites = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_website') + ->where('product_id', $m1ProductId) + ->get(); + + if ($m1ProductWebsites->isEmpty()) { + // No websites assigned in M1, assign to default website (ID 1) + $this->assignProductToWebsite($m2ProductId, 1); + return; + } + + // Migrate each website assignment + foreach ($m1ProductWebsites as $m1ProductWebsite) { + $m1WebsiteId = $m1ProductWebsite->website_id ?? 1; + $m2WebsiteId = isset($websiteMapping[$m1WebsiteId]) ? $websiteMapping[$m1WebsiteId] : 1; + + // Check if assignment already exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_website') + ->where('product_id', $m2ProductId) + ->where('website_id', $m2WebsiteId) + ->exists(); + + if (!$exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_website') + ->insert([ + 'product_id' => $m2ProductId, + 'website_id' => $m2WebsiteId, + ]); + } + } + } catch (Exception $e) { + Log::error("Error migrating product websites for M1 ID {$m1ProductId}, M2 ID {$m2ProductId}: " . $e->getMessage()); + // Don't throw - just log the error and assign to default website + try { + $this->assignProductToWebsite($m2ProductId, 1); + } catch (Exception $e2) { + Log::error("Error assigning product to default website: " . $e2->getMessage()); + } + } + } + + /** + * Assign a product to a website + */ + protected function assignProductToWebsite($productId, $websiteId) + { + try { + // Check if assignment already exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_website') + ->where('product_id', $productId) + ->where('website_id', $websiteId) + ->exists(); + + if (!$exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_website') + ->insert([ + 'product_id' => $productId, + 'website_id' => $websiteId, + ]); + } + } catch (Exception $e) { + Log::error("Error assigning product {$productId} to website {$websiteId}: " . $e->getMessage()); + } + } + + /** + * Migrate URL rewrites from M1 core_url_rewrite to M2 url_rewrite for a product + */ + protected function migrateProductUrlRewrites($m1ProductId, $m2ProductId) + { + try { + // Get store mapping + $storeMapping = $this->getStoreMapping(); + + // Get all URL rewrites for this product from M1 + // In M1, product URLs are identified by id_path like "product/{product_id}" or "product/{product_id}/..." + $m1UrlRewrites = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_url_rewrite') + ->where(function($query) use ($m1ProductId) { + $query->where('id_path', '=', 'product/' . $m1ProductId) + ->orWhere('id_path', 'like', 'product/' . $m1ProductId . '/%'); + }) + ->get(); + + if ($m1UrlRewrites->isEmpty()) { + return; + } + + foreach ($m1UrlRewrites as $m1Rewrite) { + // Map store ID + $m1StoreId = $m1Rewrite->store_id ?? 0; + $m2StoreId = isset($storeMapping[$m1StoreId]) ? $storeMapping[$m1StoreId] : $m1StoreId; + + // Extract request_path and target_path from M1 + $requestPath = $m1Rewrite->request_path ?? ''; + $targetPath = $m1Rewrite->target_path ?? ''; + + // Skip if paths are empty + if (empty($requestPath) || empty($targetPath)) { + continue; + } + + // Update target_path to use M2 product ID + // M1 target_path format: catalog/product/view/id/{product_id} + // M2 target_path format: catalog/product/view/id/{product_id} + $targetPath = preg_replace('/\/id\/\d+/', '/id/' . $m2ProductId, $targetPath); + + // Check if this URL rewrite already exists in M2 + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite') + ->where('entity_type', 'product') + ->where('entity_id', $m2ProductId) + ->where('request_path', $requestPath) + ->where('store_id', $m2StoreId) + ->exists(); + + if (!$exists) { + // Insert URL rewrite into M2 + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite') + ->insert([ + 'entity_type' => 'product', + 'entity_id' => $m2ProductId, + 'request_path' => $requestPath, + 'target_path' => $targetPath, + 'redirect_type' => 0, // 0 = No redirect + 'store_id' => $m2StoreId, + 'description' => $m1Rewrite->description ?? null, + 'is_autogenerated' => ($m1Rewrite->is_system ?? 0) ? 1 : 0, // M1 is_system=1 means auto-generated, same as M2 + 'metadata' => null, + ]); + } + } + } catch (Exception $e) { + Log::error("Error migrating URL rewrites for product M1 ID {$m1ProductId}, M2 ID {$m2ProductId}: " . $e->getMessage()); + // Don't throw - just log the error and continue + } + } + + /** + * Migrate stock inventory from M1 cataloginventory_stock_item to M2 cataloginventory_stock_item + */ + /** + * Migrate product stock inventory from M1 to M2 + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing stock data in M2 will be preserved or updated. + */ + protected function migrateProductStock($m1ProductId, $m2ProductId) + { + try { + // Get stock item from M1 + $m1StockItem = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'cataloginventory_stock_item') + ->where('product_id', $m1ProductId) + ->first(); + + if (!$m1StockItem) { + // No stock record in M1, create a default one in M2 + $this->createDefaultStockItem($m2ProductId); + return; + } + + // Check if stock item already exists in M2 + $existingStockItem = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->where('product_id', $m2ProductId) + ->first(); + + // Prepare stock data for M2 + $stockData = [ + 'product_id' => $m2ProductId, + 'stock_id' => $m1StockItem->stock_id ?? 1, // Default to 1 if not set + 'qty' => $m1StockItem->qty ?? 0, + 'is_in_stock' => $m1StockItem->is_in_stock ?? 0, + 'manage_stock' => $m1StockItem->manage_stock ?? 1, + 'use_config_manage_stock' => $m1StockItem->use_config_manage_stock ?? 1, + 'min_qty' => $m1StockItem->min_qty ?? 0, + 'min_sale_qty' => $m1StockItem->min_sale_qty ?? 1, + 'max_sale_qty' => $m1StockItem->max_sale_qty ?? 0, + 'is_qty_decimal' => $m1StockItem->is_qty_decimal ?? 0, + 'backorders' => $m1StockItem->backorders ?? 0, + 'notify_stock_qty' => $m1StockItem->notify_stock_qty ?? 0, + 'use_config_notify_stock_qty' => $m1StockItem->use_config_notify_stock_qty ?? 1, + 'use_config_min_qty' => $m1StockItem->use_config_min_qty ?? 1, + 'use_config_min_sale_qty' => $m1StockItem->use_config_min_sale_qty ?? 1, + 'use_config_max_sale_qty' => $m1StockItem->use_config_max_sale_qty ?? 1, + 'use_config_backorders' => $m1StockItem->use_config_backorders ?? 1, + 'use_config_enable_qty_inc' => $m1StockItem->use_config_enable_qty_inc ?? 1, + 'enable_qty_increments' => $m1StockItem->enable_qty_increments ?? 0, + 'use_config_qty_increments' => $m1StockItem->use_config_qty_increments ?? 1, + 'qty_increments' => $m1StockItem->qty_increments ?? 0, + ]; + + // Add optional fields if they exist in M1 + if (isset($m1StockItem->low_stock_date)) { + $stockData['low_stock_date'] = $m1StockItem->low_stock_date; + } + if (isset($m1StockItem->is_decimal_divided)) { + $stockData['is_decimal_divided'] = $m1StockItem->is_decimal_divided; + } + + if ($existingStockItem) { + // Update existing stock item + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->where('product_id', $m2ProductId) + ->update($stockData); + } else { + // Insert new stock item + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->insert($stockData); + } + + // Also update cataloginventory_stock_status if it exists + try { + $stockStatusData = [ + 'product_id' => $m2ProductId, + 'website_id' => 0, // Default website + 'stock_id' => $m1StockItem->stock_id ?? 1, + 'qty' => $m1StockItem->qty ?? 0, + 'stock_status' => $m1StockItem->is_in_stock ?? 0, + ]; + + $existingStatus = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_status') + ->where('product_id', $m2ProductId) + ->where('website_id', 0) + ->where('stock_id', $stockStatusData['stock_id']) + ->first(); + + if ($existingStatus) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_status') + ->where('product_id', $m2ProductId) + ->where('website_id', 0) + ->where('stock_id', $stockStatusData['stock_id']) + ->update($stockStatusData); + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_status') + ->insert($stockStatusData); + } + } catch (Exception $e) { + // Stock status table might not exist or have different structure, log and continue + Log::debug("Could not update stock status table: " . $e->getMessage()); + } + + } catch (Exception $e) { + Log::error("Error migrating stock for product M1 ID {$m1ProductId}, M2 ID {$m2ProductId}: " . $e->getMessage()); + // Don't throw - just log the error and continue + } + } + + /** + * Create a default stock item for a product + */ + protected function createDefaultStockItem($productId) + { + try { + // Check if stock item already exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->where('product_id', $productId) + ->exists(); + + if (!$exists) { + // Create default stock item + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'cataloginventory_stock_item') + ->insert([ + 'product_id' => $productId, + 'stock_id' => 1, + 'qty' => 0, + 'is_in_stock' => 0, + 'manage_stock' => 1, + 'use_config_manage_stock' => 1, + 'min_qty' => 0, + 'min_sale_qty' => 1, + 'max_sale_qty' => 0, + 'is_qty_decimal' => 0, + 'backorders' => 0, + 'notify_stock_qty' => 0, + 'use_config_notify_stock_qty' => 1, + 'use_config_min_qty' => 1, + 'use_config_min_sale_qty' => 1, + 'use_config_max_sale_qty' => 1, + 'use_config_backorders' => 1, + 'use_config_enable_qty_inc' => 1, + 'enable_qty_increments' => 0, + 'use_config_qty_increments' => 1, + 'qty_increments' => 0, + ]); + } + } catch (Exception $e) { + Log::error("Error creating default stock item for product ID {$productId}: " . $e->getMessage()); + } + } + + /** + * Migrate all catalog_product_option tables from M1 to M2 + */ + /** + * Migrate catalog_product_option tables from M1 to M2 + * + * IMPORTANT: This method only performs INSERT and UPDATE operations. + * NO DATA IS DELETED. Existing product options in M2 will be preserved or updated. + */ + protected function migrateCatalogProductOptions() + { + $migratedCount = 0; + $errorCount = 0; + + try { + // Build product ID mapping from M1 to M2 + $productIdMapping = $this->buildProductIdMapping(); + + if (empty($productIdMapping)) { + Log::warning("No product ID mapping found for catalog_product_option migration"); + return [ + 'migrated' => 0, + 'errors' => 0 + ]; + } + + // Get all M1 product options + $m1Options = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option') + ->get(); + + if ($m1Options->isEmpty()) { + return [ + 'migrated' => 0, + 'errors' => 0 + ]; + } + + // Build option_id mapping (M1 option_id => M2 option_id) + $optionIdMapping = []; + + // Migrate catalog_product_option table + foreach ($m1Options as $m1Option) { + try { + $m1ProductId = $m1Option->product_id; + $m1OptionId = $m1Option->option_id; + + // Skip if product doesn't exist in M2 + if (!isset($productIdMapping[$m1ProductId])) { + continue; + } + + $m2ProductId = $productIdMapping[$m1ProductId]; + + // Check if option already exists in M2 (by product_id and type) + $existingOption = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option') + ->where('product_id', $m2ProductId) + ->where('type', $m1Option->type) + ->where('sku', $m1Option->sku ?? '') + ->first(); + + $optionData = [ + 'product_id' => $m2ProductId, + 'type' => $m1Option->type, + 'is_require' => $m1Option->is_require ?? 0, + 'sku' => $m1Option->sku ?? null, + 'max_characters' => $m1Option->max_characters ?? null, + 'file_extension' => $m1Option->file_extension ?? null, + 'image_size_x' => $m1Option->image_size_x ?? null, + 'image_size_y' => $m1Option->image_size_y ?? null, + 'sort_order' => $m1Option->sort_order ?? 0, + ]; + + if ($existingOption) { + // Update existing option + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option') + ->where('option_id', $existingOption->option_id) + ->update($optionData); + $m2OptionId = $existingOption->option_id; + } else { + // Insert new option + $m2OptionId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option') + ->insertGetId($optionData); + } + + // Store option_id mapping + $optionIdMapping[$m1OptionId] = $m2OptionId; + $migratedCount++; + + } catch (Exception $e) { + $errorCount++; + $errorMsg = $e->getMessage(); + Log::error("Error migrating product option M1 ID {$m1OptionId}: " . $errorMsg); + $this->migrationLog[] = "ERROR: Failed to migrate product option M1 ID {$m1OptionId}: " . $errorMsg; + } + } + + // Migrate catalog_product_option_price + $this->migrateCatalogProductOptionPrice($optionIdMapping); + + // Migrate catalog_product_option_title + $this->migrateCatalogProductOptionTitle($optionIdMapping); + + // Migrate catalog_product_option_type_value + $typeValueMapping = $this->migrateCatalogProductOptionTypeValue($optionIdMapping); + + // Migrate catalog_product_option_type_price + $this->migrateCatalogProductOptionTypePrice($typeValueMapping); + + // Migrate catalog_product_option_type_title + $this->migrateCatalogProductOptionTypeTitle($typeValueMapping); + + } catch (Exception $e) { + $errorMsg = $e->getMessage(); + Log::error("Error migrating catalog_product_option tables: " . $errorMsg); + $errorCount++; + $this->migrationLog[] = "ERROR: Failed to migrate catalog_product_option tables: " . $errorMsg; + } + + return [ + 'migrated' => $migratedCount, + 'errors' => $errorCount + ]; + } + + /** + * Build product ID mapping from M1 to M2 (by SKU or entity_id) + */ + protected function buildProductIdMapping() + { + $mapping = []; + + try { + $m1Products = $this->getMagento1Products(); + $m2Products = $this->getMagento2Products(); + + // Build M2 product lookup by SKU and entity_id + $m2ProductBySku = []; + $m2ProductById = []; + + foreach ($m2Products as $m2Product) { + $sku = $m2Product->sku ?? 'N/A'; + if ($sku !== 'N/A' && !empty($sku)) { + $m2ProductBySku[$sku] = $m2Product->entity_id; + } + $m2ProductById[$m2Product->entity_id] = $m2Product->entity_id; + } + + // Build mapping from M1 to M2 + foreach ($m1Products as $m1Product) { + $m1ProductId = $m1Product->entity_id; + $m1Sku = $m1Product->sku ?? 'N/A'; + + $m2ProductId = null; + + if ($m1Sku !== 'N/A' && !empty($m1Sku)) { + // Match by SKU + if (isset($m2ProductBySku[$m1Sku])) { + $m2ProductId = $m2ProductBySku[$m1Sku]; + } + } else { + // Match by entity_id + if (isset($m2ProductById[$m1ProductId])) { + $m2ProductId = $m1ProductId; + } + } + + if ($m2ProductId) { + $mapping[$m1ProductId] = $m2ProductId; + } + } + + } catch (Exception $e) { + Log::error("Error building product ID mapping: " . $e->getMessage()); + } + + return $mapping; + } + + /** + * Migrate catalog_product_option_price + */ + protected function migrateCatalogProductOptionPrice($optionIdMapping) + { + try { + foreach ($optionIdMapping as $m1OptionId => $m2OptionId) { + $m1Prices = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option_price') + ->where('option_id', $m1OptionId) + ->get(); + + foreach ($m1Prices as $m1Price) { + $priceData = [ + 'option_id' => $m2OptionId, + 'store_id' => $m1Price->store_id ?? 0, + 'price' => $m1Price->price ?? 0, + 'price_type' => $m1Price->price_type ?? 'fixed', + ]; + + // Check if exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_price') + ->where('option_id', $m2OptionId) + ->where('store_id', $priceData['store_id']) + ->exists(); + + if ($exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_price') + ->where('option_id', $m2OptionId) + ->where('store_id', $priceData['store_id']) + ->update($priceData); + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_price') + ->insert($priceData); + } + } + } + } catch (Exception $e) { + Log::error("Error migrating catalog_product_option_price: " . $e->getMessage()); + } + } + + /** + * Migrate catalog_product_option_title + */ + protected function migrateCatalogProductOptionTitle($optionIdMapping) + { + try { + foreach ($optionIdMapping as $m1OptionId => $m2OptionId) { + $m1Titles = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option_title') + ->where('option_id', $m1OptionId) + ->get(); + + foreach ($m1Titles as $m1Title) { + $titleData = [ + 'option_id' => $m2OptionId, + 'store_id' => $m1Title->store_id ?? 0, + 'title' => $m1Title->title ?? '', + ]; + + // Check if exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_title') + ->where('option_id', $m2OptionId) + ->where('store_id', $titleData['store_id']) + ->exists(); + + if ($exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_title') + ->where('option_id', $m2OptionId) + ->where('store_id', $titleData['store_id']) + ->update($titleData); + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_title') + ->insert($titleData); + } + } + } + } catch (Exception $e) { + Log::error("Error migrating catalog_product_option_title: " . $e->getMessage()); + } + } + + /** + * Migrate catalog_product_option_type_value and return type_value_id mapping + */ + protected function migrateCatalogProductOptionTypeValue($optionIdMapping) + { + $typeValueMapping = []; + + try { + foreach ($optionIdMapping as $m1OptionId => $m2OptionId) { + $m1TypeValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option_type_value') + ->where('option_id', $m1OptionId) + ->get(); + + foreach ($m1TypeValues as $m1TypeValue) { + $m1TypeValueId = $m1TypeValue->option_type_id; + + $typeValueData = [ + 'option_id' => $m2OptionId, + 'sku' => $m1TypeValue->sku ?? null, + 'sort_order' => $m1TypeValue->sort_order ?? 0, + ]; + + // Check if exists (by option_id and sku/sort_order) + $existing = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_value') + ->where('option_id', $m2OptionId) + ->where('sku', $typeValueData['sku']) + ->where('sort_order', $typeValueData['sort_order']) + ->first(); + + if ($existing) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_value') + ->where('option_type_id', $existing->option_type_id) + ->update($typeValueData); + $m2TypeValueId = $existing->option_type_id; + } else { + $m2TypeValueId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_value') + ->insertGetId($typeValueData); + } + + $typeValueMapping[$m1TypeValueId] = $m2TypeValueId; + } + } + } catch (Exception $e) { + Log::error("Error migrating catalog_product_option_type_value: " . $e->getMessage()); + } + + return $typeValueMapping; + } + + /** + * Migrate catalog_product_option_type_price + */ + protected function migrateCatalogProductOptionTypePrice($typeValueMapping) + { + try { + foreach ($typeValueMapping as $m1TypeValueId => $m2TypeValueId) { + $m1Prices = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option_type_price') + ->where('option_type_id', $m1TypeValueId) + ->get(); + + foreach ($m1Prices as $m1Price) { + $priceData = [ + 'option_type_id' => $m2TypeValueId, + 'store_id' => $m1Price->store_id ?? 0, + 'price' => $m1Price->price ?? 0, + 'price_type' => $m1Price->price_type ?? 'fixed', + ]; + + // Check if exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_price') + ->where('option_type_id', $m2TypeValueId) + ->where('store_id', $priceData['store_id']) + ->exists(); + + if ($exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_price') + ->where('option_type_id', $m2TypeValueId) + ->where('store_id', $priceData['store_id']) + ->update($priceData); + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_price') + ->insert($priceData); + } + } + } + } catch (Exception $e) { + Log::error("Error migrating catalog_product_option_type_price: " . $e->getMessage()); + } + } + + /** + * Migrate catalog_product_option_type_title + */ + protected function migrateCatalogProductOptionTypeTitle($typeValueMapping) + { + try { + foreach ($typeValueMapping as $m1TypeValueId => $m2TypeValueId) { + $m1Titles = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_option_type_title') + ->where('option_type_id', $m1TypeValueId) + ->get(); + + foreach ($m1Titles as $m1Title) { + $titleData = [ + 'option_type_id' => $m2TypeValueId, + 'store_id' => $m1Title->store_id ?? 0, + 'title' => $m1Title->title ?? '', + ]; + + // Check if exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_title') + ->where('option_type_id', $m2TypeValueId) + ->where('store_id', $titleData['store_id']) + ->exists(); + + if ($exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_title') + ->where('option_type_id', $m2TypeValueId) + ->where('store_id', $titleData['store_id']) + ->update($titleData); + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_option_type_title') + ->insert($titleData); + } + } + } + } catch (Exception $e) { + Log::error("Error migrating catalog_product_option_type_title: " . $e->getMessage()); + } + } + + /** + * Ensure product is enabled and visible (required for products to appear in categories after reindex) + */ + protected function ensureProductIsEnabledAndVisible($productId) + { + try { + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return; + } + + // Get attribute IDs + $statusAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'status') + ->value('attribute_id'); + + $visibilityAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'visibility') + ->value('attribute_id'); + + // Set status to 1 (enabled) if not already set + if ($statusAttributeId) { + $statusTable = $this->magento2Prefix . 'catalog_product_entity_int'; + $hasStatus = DB::connection($this->magento2Connection) + ->table($statusTable) + ->where('entity_id', $productId) + ->where('attribute_id', $statusAttributeId) + ->where('store_id', 0) + ->exists(); + + if (!$hasStatus) { + DB::connection($this->magento2Connection) + ->table($statusTable) + ->insert([ + 'attribute_id' => $statusAttributeId, + 'store_id' => 0, + 'entity_id' => $productId, + 'value' => 1, // Enabled + ]); + } else { + // Update to enabled if it's disabled + DB::connection($this->magento2Connection) + ->table($statusTable) + ->where('entity_id', $productId) + ->where('attribute_id', $statusAttributeId) + ->where('store_id', 0) + ->update(['value' => 1]); + } + } + + // Set visibility to 4 (Catalog, Search) if not already set + if ($visibilityAttributeId) { + $visibilityTable = $this->magento2Prefix . 'catalog_product_entity_int'; + $hasVisibility = DB::connection($this->magento2Connection) + ->table($visibilityTable) + ->where('entity_id', $productId) + ->where('attribute_id', $visibilityAttributeId) + ->where('store_id', 0) + ->exists(); + + if (!$hasVisibility) { + DB::connection($this->magento2Connection) + ->table($visibilityTable) + ->insert([ + 'attribute_id' => $visibilityAttributeId, + 'store_id' => 0, + 'entity_id' => $productId, + 'value' => 4, // Catalog, Search + ]); + } else { + // Update to Catalog, Search if it's Not Visible or Search Only + $currentVisibility = DB::connection($this->magento2Connection) + ->table($visibilityTable) + ->where('entity_id', $productId) + ->where('attribute_id', $visibilityAttributeId) + ->where('store_id', 0) + ->value('value'); + + // Only update if visibility is 1 (Not Visible) or 2 (Catalog) + // 3 = Search, 4 = Catalog, Search (both) + if ($currentVisibility == 1 || $currentVisibility == 2) { + DB::connection($this->magento2Connection) + ->table($visibilityTable) + ->where('entity_id', $productId) + ->where('attribute_id', $visibilityAttributeId) + ->where('store_id', 0) + ->update(['value' => 4]); + } + } + } + } catch (Exception $e) { + Log::warning("Error ensuring product {$productId} is enabled and visible: " . $e->getMessage()); + } + } + + /** + * Get Magento 1 category tree with products + */ + public function getMagento1CategoryTreeWithProducts() + { + try { + $categories = $this->getMagento1Categories(); + $categoryMap = []; + + // Build category map + foreach ($categories as $category) { + $categoryMap[$category->entity_id] = [ + 'id' => $category->entity_id, + 'name' => $category->name, + 'parent_id' => $category->parent_id, + 'is_active' => $category->is_active ?? true, + 'path' => $category->path ?? '', + 'children' => [], + 'products' => [] + ]; + } + + // Get all products in categories + $categoryProducts = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_category_product') + ->select('category_id', 'product_id', 'position') + ->orderBy('category_id') + ->orderBy('position') + ->get(); + + // Get product details + $productIds = $categoryProducts->pluck('product_id')->unique()->toArray(); + $products = []; + + if (!empty($productIds)) { + // Check if SKU column exists in entity table + $hasSkuColumn = false; + try { + $testQuery = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->select('sku') + ->limit(1) + ->first(); + $hasSkuColumn = true; + } catch (Exception $e) { + $hasSkuColumn = false; + } + + $entityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if ($entityTypeId) { + // Get SKU attribute ID (for EAV storage) + $skuAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + // Get name attribute ID + $nameAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + // Get SKUs - from entity table or EAV + $skus = []; + if ($hasSkuColumn) { + // SKU is stored in entity table + $skuValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->whereIn('entity_id', $productIds) + ->pluck('sku', 'entity_id') + ->toArray(); + $skus = $skuValues; + } else if ($skuAttributeId) { + // SKU is stored in EAV table + $skuValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('store_id', 0) + ->whereIn('entity_id', $productIds) + ->pluck('value', 'entity_id') + ->toArray(); + $skus = $skuValues; + } + + // Get names + $names = []; + if ($nameAttributeId) { + $nameValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->whereIn('entity_id', $productIds) + ->pluck('value', 'entity_id') + ->toArray(); + $names = $nameValues; + } + + // Build products array + foreach ($productIds as $productId) { + $sku = $skus[$productId] ?? 'N/A'; + $products[$productId] = [ + 'id' => $productId, + 'sku' => !empty($sku) ? $sku : 'N/A', + 'name' => $names[$productId] ?? 'Unnamed Product' + ]; + } + } + } + + // Assign products to categories + foreach ($categoryProducts as $cp) { + if (isset($categoryMap[$cp->category_id]) && isset($products[$cp->product_id])) { + $categoryMap[$cp->category_id]['products'][] = [ + 'id' => $products[$cp->product_id]['id'], + 'sku' => $products[$cp->product_id]['sku'], + 'name' => $products[$cp->product_id]['name'], + 'position' => $cp->position ?? 0 + ]; + } + } + + // Build tree structure + $tree = []; + foreach ($categoryMap as $categoryId => $category) { + if ($category['parent_id'] == 0 || $category['parent_id'] == 1) { + // Root category + $tree[] = $this->buildCategoryTreeWithProducts($category, $categoryMap); + } + } + + return $tree; + } catch (Exception $e) { + Log::error('Error fetching M1 category tree with products: ' . $e->getMessage()); + return []; + } + } + + /** + * Get Magento 2 category tree with products + */ + public function getMagento2CategoryTreeWithProducts() + { + try { + $categories = $this->getMagento2Categories(); + $categoryMap = []; + + // Build category map + foreach ($categories as $category) { + $categoryMap[$category->entity_id] = [ + 'id' => $category->entity_id, + 'name' => $category->name, + 'parent_id' => $category->parent_id, + 'is_active' => $category->is_active ?? true, + 'path' => $category->path ?? '', + 'children' => [], + 'products' => [] + ]; + } + + // Get all products in categories + $categoryProducts = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->select('category_id', 'product_id', 'position') + ->orderBy('category_id') + ->orderBy('position') + ->get(); + + // Get product details + $productIds = $categoryProducts->pluck('product_id')->unique()->toArray(); + $products = []; + + if (!empty($productIds)) { + // Check if SKU column exists in entity table + $hasSkuColumn = false; + try { + $testQuery = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->select('sku') + ->limit(1) + ->first(); + $hasSkuColumn = true; + } catch (Exception $e) { + $hasSkuColumn = false; + } + + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if ($entityTypeId) { + // Get SKU attribute ID (for EAV storage) + $skuAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + + // Get name attribute ID + $nameAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + // Get SKUs - from entity table or EAV + $skus = []; + if ($hasSkuColumn) { + // SKU is stored in entity table + $skuValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->whereIn('entity_id', $productIds) + ->pluck('sku', 'entity_id') + ->toArray(); + $skus = $skuValues; + } else if ($skuAttributeId) { + // SKU is stored in EAV table + $skuValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $skuAttributeId) + ->where('store_id', 0) + ->whereIn('entity_id', $productIds) + ->pluck('value', 'entity_id') + ->toArray(); + $skus = $skuValues; + } + + // Get names + $names = []; + if ($nameAttributeId) { + $nameValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->whereIn('entity_id', $productIds) + ->pluck('value', 'entity_id') + ->toArray(); + $names = $nameValues; + } + + // Build products array + foreach ($productIds as $productId) { + $sku = $skus[$productId] ?? 'N/A'; + $products[$productId] = [ + 'id' => $productId, + 'sku' => !empty($sku) ? $sku : 'N/A', + 'name' => $names[$productId] ?? 'Unnamed Product' + ]; + } + } + } + + // Assign products to categories + foreach ($categoryProducts as $cp) { + if (isset($categoryMap[$cp->category_id]) && isset($products[$cp->product_id])) { + $categoryMap[$cp->category_id]['products'][] = [ + 'id' => $products[$cp->product_id]['id'], + 'sku' => $products[$cp->product_id]['sku'], + 'name' => $products[$cp->product_id]['name'], + 'position' => $cp->position ?? 0 + ]; + } + } + + // Build tree structure + $tree = []; + foreach ($categoryMap as $categoryId => $category) { + if ($category['parent_id'] == 0 || $category['parent_id'] == 1) { + // Root category + $tree[] = $this->buildCategoryTreeWithProducts($category, $categoryMap); + } + } + + return $tree; + } catch (Exception $e) { + Log::error('Error fetching M2 category tree with products: ' . $e->getMessage()); + return []; + } + } + + /** + * Build category tree structure with products recursively + */ + protected function buildCategoryTreeWithProducts($category, $categoryMap) + { + $node = [ + 'id' => $category['id'], + 'name' => $category['name'], + 'is_active' => $category['is_active'], + 'products' => $category['products'], + 'children' => [] + ]; + + // Find children + foreach ($categoryMap as $catId => $cat) { + if ($cat['parent_id'] == $category['id']) { + $node['children'][] = $this->buildCategoryTreeWithProducts($cat, $categoryMap); + } + } + + return $node; + } + + /** + * Get category name by ID + */ + protected function getCategoryNameById($categoryId, $source = 'm1') + { + try { + $connection = $source === 'm1' ? $this->magento1Connection : $this->magento2Connection; + $prefix = $source === 'm1' ? $this->magento1Prefix : $this->magento2Prefix; + + $entityTypeId = DB::connection($connection) + ->table($prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_category') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return null; + } + + $nameAttributeId = DB::connection($connection) + ->table($prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + if (!$nameAttributeId) { + return null; + } + + $name = DB::connection($connection) + ->table($prefix . 'catalog_category_entity_varchar') + ->where('entity_id', $categoryId) + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->value('value'); + + return $name; + } catch (Exception $e) { + Log::warning("Error getting category name for ID {$categoryId}: " . $e->getMessage()); + return null; + } + } + + /** + * Find category by name (case-insensitive, matches any parent) + */ + protected function findCategoryByName($categoryName, $source = 'm2') + { + try { + $connection = $source === 'm1' ? $this->magento1Connection : $this->magento2Connection; + $prefix = $source === 'm1' ? $this->magento1Prefix : $this->magento2Prefix; + + $entityTypeId = DB::connection($connection) + ->table($prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_category') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return null; + } + + $nameAttributeId = DB::connection($connection) + ->table($prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'name') + ->value('attribute_id'); + + if (!$nameAttributeId) { + return null; + } + + // Find category by name (case-insensitive) + $category = DB::connection($connection) + ->table($prefix . 'catalog_category_entity_varchar') + ->where('attribute_id', $nameAttributeId) + ->where('store_id', 0) + ->whereRaw('LOWER(value) = ?', [strtolower(trim($categoryName))]) + ->first(); + + return $category ? $category->entity_id : null; + } catch (Exception $e) { + Log::warning("Error finding category by name '{$categoryName}': " . $e->getMessage()); + return null; + } + } + + /** + * Recursively delete all children of a category + */ + protected function deleteCategoryChildren($categoryId, $connection, $prefix) + { + // Get all children + $children = DB::connection($connection) + ->table($prefix . 'catalog_category_entity') + ->where('parent_id', $categoryId) + ->get(); + + // Recursively delete each child + foreach ($children as $child) { + $this->deleteCategoryChildren($child->entity_id, $connection, $prefix); + $this->deleteCategoryData($child->entity_id, $connection, $prefix); + } + } + + /** + * Fix category products - ensure products are added to categories if missing from catalog_category_product table + * Only looks at M2 products and their category_ids attribute + */ + public function fixCategoryProducts() + { + try { + $this->migrationLog = []; + $addedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + // Get entity type ID for catalog_product + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + if (!$entityTypeId) { + return [ + 'success' => false, + 'message' => 'Could not find catalog_product entity type in Magento 2', + 'added' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + + // Get category_ids attribute ID + $categoryIdsAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'category_ids') + ->value('attribute_id'); + + // Get all M2 products + $m2Products = $this->getMagento2Products(); + + // Get all products that are already in catalog_category_product table + $productsInCategories = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->distinct() + ->pluck('product_id') + ->toArray(); + + // Get all valid M2 category IDs + $validCategoryIds = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_entity') + ->pluck('entity_id') + ->toArray(); + + DB::connection($this->magento2Connection)->beginTransaction(); + + foreach ($m2Products as $m2Product) { + try { + $m2ProductId = $m2Product->entity_id; + $m2Sku = $m2Product->sku ?? 'N/A'; + + // Check if product is already in catalog_category_product table + if (in_array($m2ProductId, $productsInCategories)) { + $skippedCount++; + continue; + } + + // Get category_ids from product attribute + $categoryIdsString = null; + + if ($categoryIdsAttributeId) { + // Try to get from varchar table first + $categoryIdsValue = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_varchar') + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $categoryIdsAttributeId) + ->where('store_id', 0) + ->value('value'); + + if ($categoryIdsValue) { + $categoryIdsString = $categoryIdsValue; + } else { + // Try text table + $categoryIdsValue = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity_text') + ->where('entity_id', $m2ProductId) + ->where('attribute_id', $categoryIdsAttributeId) + ->where('store_id', 0) + ->value('value'); + + if ($categoryIdsValue) { + $categoryIdsString = $categoryIdsValue; + } + } + } + + if (empty($categoryIdsString)) { + $skippedCount++; + $this->migrationLog[] = "SKIPPED: M2 Product ID {$m2ProductId} (SKU: {$m2Sku}) - no category_ids attribute found"; + continue; + } + + // Parse category IDs (comma-separated string) + $categoryIds = array_filter( + array_map('trim', explode(',', $categoryIdsString)), + function($id) use ($validCategoryIds) { + return !empty($id) && is_numeric($id) && in_array((int)$id, $validCategoryIds); + } + ); + + if (empty($categoryIds)) { + $skippedCount++; + $this->migrationLog[] = "SKIPPED: M2 Product ID {$m2ProductId} (SKU: {$m2Sku}) - no valid category IDs found in attribute"; + continue; + } + + // Ensure product is enabled and visible + $this->ensureProductIsEnabledAndVisible($m2ProductId); + + // Add product to categories + $categoriesAdded = 0; + $invalidCategories = []; + + foreach ($categoryIds as $categoryId) { + $categoryId = (int)$categoryId; + + // Check if association already exists + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->where('category_id', $categoryId) + ->where('product_id', $m2ProductId) + ->exists(); + + if (!$exists) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_product') + ->insert([ + 'category_id' => $categoryId, + 'product_id' => $m2ProductId, + 'position' => 0, // Default position + ]); + $categoriesAdded++; + } + } + + if ($categoriesAdded > 0) { + $addedCount++; + $invalidMsg = !empty($invalidCategories) ? " (invalid categories: " . implode(', ', $invalidCategories) . ")" : ""; + $this->migrationLog[] = "ADDED: M2 Product ID {$m2ProductId} (SKU: {$m2Sku}) - added to {$categoriesAdded} category(ies){$invalidMsg}"; + } else { + $skippedCount++; + $invalidMsg = !empty($invalidCategories) ? " (invalid categories: " . implode(', ', $invalidCategories) . ")" : ""; + $this->migrationLog[] = "SKIPPED: M2 Product ID {$m2ProductId} (SKU: {$m2Sku}) - categories already exist or invalid{$invalidMsg}"; + } + + } catch (Exception $e) { + $errorCount++; + $m2Sku = $m2Product->sku ?? 'N/A'; + $this->migrationLog[] = "ERROR: Failed to fix categories for M2 Product ID {$m2ProductId} (SKU: {$m2Sku}): " . $e->getMessage(); + Log::error("Error fixing category products for M2 Product ID {$m2ProductId}: " . $e->getMessage()); + } + } + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => "Fixed category products. Added: {$addedCount}, Skipped: {$skippedCount}, Errors: {$errorCount}", + 'added' => $addedCount, + 'skipped' => $skippedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog + ]; + + } catch (Exception $e) { + if (isset($this->magento2Connection)) { + DB::connection($this->magento2Connection)->rollBack(); + } + Log::error('Error fixing category products: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Failed to fix category products: ' . $e->getMessage(), + 'added' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + } + + /** + * Delete category data (attributes and entity) + */ + protected function deleteCategoryData($categoryId, $connection, $prefix) + { + // Get entity type ID + $entityTypeId = DB::connection($connection) + ->table($prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_category') + ->value('entity_type_id'); + + if ($entityTypeId) { + // Delete attribute values from all attribute tables + $attributeTables = ['varchar', 'int', 'text', 'decimal', 'datetime']; + foreach ($attributeTables as $tableType) { + $table = $prefix . 'catalog_category_entity_' . $tableType; + try { + DB::connection($connection) + ->table($table) + ->where('entity_id', $categoryId) + ->delete(); + } catch (Exception $e) { + // Table might not exist, continue + Log::warning("Table {$table} might not exist or has no data: " . $e->getMessage()); + } + } + + // Delete category product associations + try { + DB::connection($connection) + ->table($prefix . 'catalog_category_product') + ->where('category_id', $categoryId) + ->delete(); + } catch (Exception $e) { + Log::warning("Table {$prefix}catalog_category_product might not exist: " . $e->getMessage()); + } + } + + // Delete the category entity + DB::connection($connection) + ->table($prefix . 'catalog_category_entity') + ->where('entity_id', $categoryId) + ->delete(); + } + + /** + * Get all customers from Magento 1 + */ + public function getMagento1Customers() + { + try { + // Get entity type ID for customer + $entityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'customer') + ->value('entity_type_id'); + + if (!$entityTypeId) { + Log::warning('Magento 1 customer entity type not found'); + return collect([]); + } + + Log::info("Magento 1 customer entity_type_id: {$entityTypeId}"); + + // Get base customer data - email is stored directly in customer_entity table + $customers = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'customer_entity') + ->select('entity_id', 'email', 'website_id', 'group_id', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + + Log::info("Found " . $customers->count() . " customers in Magento 1 customer_entity table"); + + // Get firstname and lastname attribute IDs (these are in EAV) + $firstnameAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'firstname') + ->value('attribute_id'); + + $lastnameAttributeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'lastname') + ->value('attribute_id'); + + // Get firstname and lastname from EAV + $firstnames = []; + $lastnames = []; + + if ($firstnameAttributeId) { + $firstnameValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'customer_entity_varchar') + ->where('attribute_id', $firstnameAttributeId) + ->pluck('value', 'entity_id') + ->toArray(); + $firstnames = $firstnameValues; + } + + if ($lastnameAttributeId) { + $lastnameValues = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'customer_entity_varchar') + ->where('attribute_id', $lastnameAttributeId) + ->pluck('value', 'entity_id') + ->toArray(); + $lastnames = $lastnameValues; + } + + // Combine data - email is already in customer object from the query + foreach ($customers as $customer) { + // Email is already in customer object, just normalize it + $customer->email = !empty($customer->email) ? trim($customer->email) : null; + $customer->firstname = $firstnames[$customer->entity_id] ?? 'N/A'; + $customer->lastname = $lastnames[$customer->entity_id] ?? 'N/A'; + } + + Log::info("Returning " . $customers->count() . " Magento 1 customers"); + return $customers; + } catch (Exception $e) { + Log::error('Error fetching Magento 1 customers: ' . $e->getMessage()); + Log::error('Stack trace: ' . $e->getTraceAsString()); + return collect([]); + } + } + + /** + * Get all customers from Magento 2 + */ + public function getMagento2Customers() + { + try { + // Get entity type ID for customer + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'customer') + ->value('entity_type_id'); + + if (!$entityTypeId) { + Log::warning('Magento 2 customer entity type not found'); + return collect([]); + } + + Log::info("Magento 2 customer entity_type_id: {$entityTypeId}"); + + // Get base customer data - email is stored directly in customer_entity table + $customers = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity') + ->select('entity_id', 'email', 'website_id', 'group_id', 'created_at', 'updated_at') + ->orderBy('entity_id') + ->get(); + + Log::info("Found " . $customers->count() . " customers in Magento 2 customer_entity table"); + + // Get firstname and lastname attribute IDs (these are in EAV) + $firstnameAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'firstname') + ->value('attribute_id'); + + $lastnameAttributeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeId) + ->where('attribute_code', 'lastname') + ->value('attribute_id'); + + // Get firstname and lastname from EAV + $firstnames = []; + $lastnames = []; + + if ($firstnameAttributeId) { + $firstnameValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity_varchar') + ->where('attribute_id', $firstnameAttributeId) + ->pluck('value', 'entity_id') + ->toArray(); + $firstnames = $firstnameValues; + } + + if ($lastnameAttributeId) { + $lastnameValues = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity_varchar') + ->where('attribute_id', $lastnameAttributeId) + ->pluck('value', 'entity_id') + ->toArray(); + $lastnames = $lastnameValues; + } + + // Combine data - email is already in customer object from the query + foreach ($customers as $customer) { + // Email is already in customer object, just normalize it + $customer->email = !empty($customer->email) ? trim($customer->email) : null; + $customer->firstname = $firstnames[$customer->entity_id] ?? 'N/A'; + $customer->lastname = $lastnames[$customer->entity_id] ?? 'N/A'; + } + + Log::info("Returning " . $customers->count() . " Magento 2 customers"); + return $customers; + } catch (Exception $e) { + Log::error('Error fetching Magento 2 customers: ' . $e->getMessage()); + Log::error('Stack trace: ' . $e->getTraceAsString()); + return collect([]); + } + } + + /** + * Get Magento 1 customers that don't exist in Magento 2 + */ + public function getM1CustomersNotInM2() + { + try { + $m1Customers = $this->getMagento1Customers(); + $m2Customers = $this->getMagento2Customers(); + + Log::info("M1 Customers Not In M2: M1 count = " . $m1Customers->count() . ", M2 count = " . $m2Customers->count()); + + // Get all M2 emails - create a set for faster lookup + $m2EmailsSet = []; + foreach ($m2Customers as $m2Customer) { + $email = $m2Customer->email ?? null; + if (!empty($email) && is_string($email)) { + $normalizedEmail = strtolower(trim($email)); + if (!empty($normalizedEmail)) { + $m2EmailsSet[$normalizedEmail] = true; + } + } + } + + Log::info("M1 Customers Not In M2: M2 emails count = " . count($m2EmailsSet)); + + // Get all M1 emails for comparison + $m1EmailsWithCustomers = []; + foreach ($m1Customers as $m1Customer) { + $email = $m1Customer->email ?? null; + if (!empty($email) && is_string($email)) { + $normalizedEmail = strtolower(trim($email)); + if (!empty($normalizedEmail)) { + $m1EmailsWithCustomers[$normalizedEmail] = $m1Customer; + } + } + } + + Log::info("M1 Customers Not In M2: M1 emails count = " . count($m1EmailsWithCustomers)); + + // Filter M1 customers that don't exist in M2 + $missingCustomers = collect(); + foreach ($m1EmailsWithCustomers as $normalizedEmail => $m1Customer) { + if (!isset($m2EmailsSet[$normalizedEmail])) { + $missingCustomers->push($m1Customer); + } + } + + Log::info("M1 Customers Not In M2: Missing customers count = " . $missingCustomers->count()); + + return $missingCustomers->values(); + } catch (Exception $e) { + Log::error('Error fetching missing customers: ' . $e->getMessage()); + Log::error('Stack trace: ' . $e->getTraceAsString()); + return collect([]); + } + } + + /** + * Get Magento 2 customers that don't exist in Magento 1 + */ + public function getM2CustomersNotInM1() + { + try { + $m1Customers = $this->getMagento1Customers(); + $m2Customers = $this->getMagento2Customers(); + + // Get all M1 emails - create a set for faster lookup + $m1EmailsSet = []; + foreach ($m1Customers as $m1Customer) { + $email = $m1Customer->email ?? null; + if (!empty($email) && is_string($email)) { + $normalizedEmail = strtolower(trim($email)); + if (!empty($normalizedEmail)) { + $m1EmailsSet[$normalizedEmail] = true; + } + } + } + + // Filter M2 customers that don't exist in M1 + $missingCustomers = collect(); + foreach ($m2Customers as $m2Customer) { + $email = $m2Customer->email ?? null; + if (!empty($email) && is_string($email)) { + $normalizedEmail = strtolower(trim($email)); + if (!empty($normalizedEmail) && !isset($m1EmailsSet[$normalizedEmail])) { + $missingCustomers->push($m2Customer); + } + } + } + + return $missingCustomers->values(); + } catch (Exception $e) { + Log::error('Error fetching M2 customers not in M1: ' . $e->getMessage()); + Log::error('Stack trace: ' . $e->getTraceAsString()); + return collect([]); + } + } + + /** + * Migrate all customers from Magento 1 to Magento 2 + */ + public function migrateCustomers($dryRun = false) + { + try { + $this->migrationLog = []; + $addedCount = 0; + $updatedCount = 0; + $errorCount = 0; + + // Get M1 and M2 entity type IDs + $m1EntityTypeId = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'customer') + ->value('entity_type_id'); + + $m2EntityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'customer') + ->value('entity_type_id'); + + if (!$m1EntityTypeId || !$m2EntityTypeId) { + return [ + 'success' => false, + 'message' => 'Entity type not found', + 'added' => 0, + 'updated' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + + // Get all M1 customers + $m1Customers = $this->getMagento1Customers(); + + // Get all customer attribute IDs from M1 + $m1AttributeIds = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $m1EntityTypeId) + ->pluck('attribute_id', 'attribute_code') + ->toArray(); + + // Get all customer attribute IDs from M2 + $m2AttributeIds = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $m2EntityTypeId) + ->pluck('attribute_id', 'attribute_code') + ->toArray(); + + if (!$dryRun) { + DB::connection($this->magento2Connection)->beginTransaction(); + } + + foreach ($m1Customers as $m1Customer) { + try { + $m1Email = !empty($m1Customer->email) ? strtolower(trim($m1Customer->email)) : null; + + if (empty($m1Email)) { + $this->migrationLog[] = "SKIPPED: Customer ID {$m1Customer->entity_id} - no email address"; + continue; + } + + // Check if customer exists in M2 by email (case-insensitive) + $m2Customer = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity') + ->whereRaw('LOWER(TRIM(email)) = ?', [strtolower(trim($m1Email))]) + ->first(); + + $m2CustomerId = null; + $isNew = false; + + if ($m2Customer) { + // Customer exists, update + $m2CustomerId = $m2Customer->entity_id; + if (!$dryRun) { + $this->migrationLog[] = "Updating existing customer: {$m1Email} (ID: {$m2CustomerId})"; + } else { + $this->migrationLog[] = "Would update existing customer: {$m1Email} (ID: {$m2CustomerId})"; + } + $updatedCount++; + } else { + // Customer doesn't exist, create + if (!$dryRun) { + // Insert customer entity + $m2CustomerId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity') + ->insertGetId([ + 'email' => $m1Email, + 'website_id' => $m1Customer->website_id ?? 1, + 'group_id' => $m1Customer->group_id ?? 1, + 'created_at' => $m1Customer->created_at ?? now(), + 'updated_at' => $m1Customer->updated_at ?? now(), + ]); + $this->migrationLog[] = "Added new customer: {$m1Email} (ID: {$m2CustomerId})"; + } else { + $this->migrationLog[] = "Would add new customer: {$m1Email}"; + $m2CustomerId = 0; // Placeholder for dry run + } + $addedCount++; + $isNew = true; + } + + if (!$dryRun && $m2CustomerId) { + // Migrate customer attributes + $attributeTables = ['varchar', 'int', 'text', 'decimal', 'datetime']; + + foreach ($attributeTables as $tableType) { + $m1Table = $this->magento1Prefix . 'customer_entity_' . $tableType; + $m2Table = $this->magento2Prefix . 'customer_entity_' . $tableType; + + try { + // Get all attributes for this customer from M1 + $m1Attributes = DB::connection($this->magento1Connection) + ->table($m1Table) + ->where('entity_id', $m1Customer->entity_id) + ->get(); + + foreach ($m1Attributes as $m1Attr) { + // Check if attribute exists in M2 + $attributeCode = array_search($m1Attr->attribute_id, $m1AttributeIds); + if ($attributeCode && isset($m2AttributeIds[$attributeCode])) { + $m2AttributeId = $m2AttributeIds[$attributeCode]; + + // Check if attribute value already exists (customer attributes don't have store_id) + $exists = DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2CustomerId) + ->where('attribute_id', $m2AttributeId) + ->exists(); + + if (!$exists) { + // Customer attributes don't have store_id column + $insertData = [ + 'entity_id' => $m2CustomerId, + 'attribute_id' => $m2AttributeId, + 'value' => $m1Attr->value ?? null, + ]; + DB::connection($this->magento2Connection) + ->table($m2Table) + ->insert($insertData); + } else { + // Update existing attribute + DB::connection($this->magento2Connection) + ->table($m2Table) + ->where('entity_id', $m2CustomerId) + ->where('attribute_id', $m2AttributeId) + ->update(['value' => $m1Attr->value ?? null]); + } + } + } + } catch (Exception $e) { + // Table might not exist, continue + Log::warning("Table {$m1Table} or {$m2Table} might not exist: " . $e->getMessage()); + } + } + } + + } catch (Exception $e) { + $errorCount++; + $m1Email = $m1Customer->email ?? 'N/A'; + $this->migrationLog[] = "ERROR: Failed to migrate customer {$m1Email}: " . $e->getMessage(); + Log::error("Error migrating customer {$m1Email}: " . $e->getMessage()); + } + } + + if (!$dryRun) { + DB::connection($this->magento2Connection)->commit(); + } + + return [ + 'success' => true, + 'message' => $dryRun ? 'Dry run completed' : 'Customer migration completed', + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog + ]; + + } catch (Exception $e) { + if (!$dryRun) { + DB::connection($this->magento2Connection)->rollBack(); + } + Log::error('Error migrating customers: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Migration failed: ' . $e->getMessage(), + 'added' => 0, + 'updated' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + } + + /** + * Delete a single customer from Magento 2 + */ + public function deleteM2Customer($customerId) + { + try { + DB::connection($this->magento2Connection)->beginTransaction(); + + // Get entity type ID + $entityTypeId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'customer') + ->value('entity_type_id'); + + if ($entityTypeId) { + // Delete attribute values from all attribute tables + $attributeTables = ['varchar', 'int', 'text', 'decimal', 'datetime']; + foreach ($attributeTables as $tableType) { + $table = $this->magento2Prefix . 'customer_entity_' . $tableType; + try { + DB::connection($this->magento2Connection) + ->table($table) + ->where('entity_id', $customerId) + ->delete(); + } catch (Exception $e) { + Log::warning("Table {$table} might not exist: " . $e->getMessage()); + } + } + } + + // Delete the customer entity + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity') + ->where('entity_id', $customerId) + ->delete(); + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => 'Customer deleted successfully' + ]; + + } catch (Exception $e) { + DB::connection($this->magento2Connection)->rollBack(); + Log::error("Error deleting customer {$customerId}: " . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Failed to delete customer: ' . $e->getMessage() + ]; + } + } + + /** + * Migrate product options from M1 mageworx_custom_option_ tables to M2 mageworx_optiontemplates_ tables + */ + public function migrateProductOptions($dryRun = false) + { + $this->migrationLog = []; + $migratedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + try { + // Get all mageworx custom option tables from M1 + $m1Tables = $this->getMageworxM1Tables(); + if (empty($m1Tables)) { + return [ + 'success' => false, + 'message' => 'No mageworx_custom_option_ tables found in Magento 1', + 'migrated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [] + ]; + } + + $this->migrationLog[] = "Found " . count($m1Tables) . " mageworx custom option tables in M1"; + + // Get available M2 tables for reference + $m2Tables = $this->getMageworxM2Tables(); + $this->migrationLog[] = "Found " . count($m2Tables) . " mageworx option templates tables in M2"; + + // Map M1 table names to M2 table names + $tableMapping = $this->getMageworxTableMapping(); + + // Sort tables by dependency order (parent tables first) + $sortedTables = $this->sortTablesByDependency($m1Tables, $tableMapping); + $this->migrationLog[] = "Migrating tables in dependency order"; + + foreach ($sortedTables as $m1Table) { + try { + // Get the corresponding M2 table name + $m2Table = $this->getM2TableName($m1Table, $tableMapping); + if (!$m2Table) { + $this->migrationLog[] = "INFO: Skipping M1 table {$m1Table} - no corresponding M2 table exists or mapping not configured"; + $skippedCount++; + continue; + } + + // Check if M2 table exists + if (!$this->tableExists($this->magento2Connection, $this->magento2Prefix . $m2Table)) { + $this->migrationLog[] = "WARNING: M2 table does not exist: {$m2Table} (mapped from M1: {$m1Table}). Available M2 tables: " . implode(', ', array_slice($m2Tables, 0, 10)); + $skippedCount++; + continue; + } + + // Get all data from M1 table + $m1Data = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . $m1Table) + ->get(); + + if ($m1Data->isEmpty()) { + $this->migrationLog[] = "INFO: No data in M1 table: {$m1Table}"; + continue; + } + + $this->migrationLog[] = "Migrating {$m1Data->count()} records from {$m1Table} to {$m2Table}"; + + if (!$dryRun) { + DB::connection($this->magento2Connection)->beginTransaction(); + + try { + foreach ($m1Data as $row) { + $rowArray = (array) $row; + + // Map column names if needed + $mappedRow = $this->mapMageworxColumns($m1Table, $m2Table, $rowArray); + + // Validate foreign key constraints + $fkValid = $this->validateForeignKeyConstraints($m2Table, $mappedRow); + if (!$fkValid) { + $skippedCount++; + continue; + } + + // Check if record already exists in M2 (by primary key or unique identifier) + $exists = $this->recordExists($m2Table, $mappedRow); + + if ($exists) { + // Update existing record + $this->updateM2Record($m2Table, $mappedRow); + $this->migrationLog[] = "Updated record in {$m2Table} (ID: " . ($mappedRow['id'] ?? $mappedRow['option_id'] ?? 'N/A') . ")"; + } else { + // Insert new record + $this->insertM2Record($m2Table, $mappedRow); + $this->migrationLog[] = "Inserted record into {$m2Table} (ID: " . ($mappedRow['id'] ?? $mappedRow['option_id'] ?? 'N/A') . ")"; + } + + $migratedCount++; + } + + DB::connection($this->magento2Connection)->commit(); + } catch (Exception $e) { + DB::connection($this->magento2Connection)->rollBack(); + throw $e; + } + } else { + // Dry run - just log what would be migrated + foreach ($m1Data as $row) { + $rowArray = (array) $row; + $mappedRow = $this->mapMageworxColumns($m1Table, $m2Table, $rowArray); + + // Validate foreign key constraints in dry run + $fkValid = $this->validateForeignKeyConstraints($m2Table, $mappedRow, true); + if (!$fkValid) { + $skippedCount++; + continue; + } + + $exists = $this->recordExists($m2Table, $mappedRow); + + if ($exists) { + $this->migrationLog[] = "Would update record in {$m2Table} (ID: " . ($mappedRow['id'] ?? $mappedRow['option_id'] ?? 'N/A') . ")"; + } else { + $this->migrationLog[] = "Would insert record into {$m2Table} (ID: " . ($mappedRow['id'] ?? $mappedRow['option_id'] ?? 'N/A') . ")"; + } + + $migratedCount++; + } + } + + } catch (Exception $e) { + $errorCount++; + $this->migrationLog[] = "ERROR migrating {$m1Table}: " . $e->getMessage(); + Log::error("Error migrating mageworx table {$m1Table}: " . $e->getMessage()); + } + } + + return [ + 'success' => true, + 'message' => $dryRun ? 'Dry run completed' : 'Product options migration completed', + 'migrated' => $migratedCount, + 'skipped' => $skippedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog + ]; + + } catch (Exception $e) { + Log::error('Product options migration error: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Migration failed: ' . $e->getMessage(), + 'migrated' => $migratedCount, + 'skipped' => $skippedCount, + 'errors' => $errorCount, + 'log' => $this->migrationLog + ]; + } + } + + /** + * Get all mageworx custom option tables from M1 + */ + protected function getMageworxM1Tables() + { + try { + $tables = []; + $database = config("database.connections.{$this->magento1Connection}.database"); + $prefix = $this->magento1Prefix; + + // Get all tables from information_schema - check for both singular and plural forms + $allTables = DB::connection($this->magento1Connection) + ->select("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND (TABLE_NAME LIKE ? OR TABLE_NAME LIKE ?)", [ + $database, + $prefix . 'mageworx_custom_option_%', + $prefix . 'mageworx_custom_options_%' + ]); + + foreach ($allTables as $table) { + $tableName = $table->TABLE_NAME; + // Remove prefix to get base table name + $baseTableName = str_replace($prefix, '', $tableName); + $tables[] = $baseTableName; + } + + return $tables; + } catch (Exception $e) { + Log::error('Error getting mageworx M1 tables: ' . $e->getMessage()); + return []; + } + } + + /** + * Get all mageworx option templates tables from M2 + */ + protected function getMageworxM2Tables() + { + try { + $tables = []; + $database = config("database.connections.{$this->magento2Connection}.database"); + $prefix = $this->magento2Prefix; + + // Get all tables from information_schema + $allTables = DB::connection($this->magento2Connection) + ->select("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME LIKE ?", [ + $database, + $prefix . 'mageworx_optiontemplates_%' + ]); + + foreach ($allTables as $table) { + $tableName = $table->TABLE_NAME; + // Remove prefix to get base table name + $baseTableName = str_replace($prefix, '', $tableName); + $tables[] = $baseTableName; + } + + return $tables; + } catch (Exception $e) { + Log::error('Error getting mageworx M2 tables: ' . $e->getMessage()); + return []; + } + } + + /** + * Get table name mapping from M1 to M2 + */ + protected function getMageworxTableMapping() + { + return [ + // Singular form mappings + 'mageworx_custom_option_template' => 'mageworx_optiontemplates_group', + 'mageworx_custom_option_group' => 'mageworx_optiontemplates_group', + 'mageworx_custom_option_option' => 'mageworx_optiontemplates_group_option', + 'mageworx_custom_option_value' => 'mageworx_optiontemplates_option_type_value', + 'mageworx_custom_option_type' => 'mageworx_optiontemplates_option_type', + 'mageworx_custom_option_store' => 'mageworx_optiontemplates_store', + + // Plural form mappings (actual M1 table names) + // Note: M2 uses "group_option_" prefix structure, not just "option_" + 'mageworx_custom_options_group' => 'mageworx_optiontemplates_group', + 'mageworx_custom_options_group_store' => 'mageworx_optiontemplates_group_option_store_view', // M2 uses store_view + 'mageworx_custom_options_option_default' => null, // May not exist in M2 or merged into another table + 'mageworx_custom_options_option_description' => 'mageworx_optiontemplates_group_option_description', + 'mageworx_custom_options_option_type_description' => 'mageworx_optiontemplates_group_option_type_description', + 'mageworx_custom_options_option_type_image' => 'mageworx_optiontemplates_group_option_type_image', + 'mageworx_custom_options_option_type_special_price' => null, // May not exist in M2 or merged into price table + 'mageworx_custom_options_option_type_tier_price' => null, // May not exist in M2 or merged into price table + 'mageworx_custom_options_option_view_mode' => null, // May not exist in M2 + 'mageworx_custom_options_relation' => 'mageworx_optiontemplates_relation', // M2 table exists + ]; + } + + /** + * Get M2 table name for M1 table + */ + protected function getM2TableName($m1Table, $tableMapping) + { + // Direct mapping (including null values for tables that don't exist in M2) + if (isset($tableMapping[$m1Table])) { + return $tableMapping[$m1Table]; + } + + // Try pattern matching for singular form (e.g., mageworx_custom_option_* -> mageworx_optiontemplates_*) + if (strpos($m1Table, 'mageworx_custom_option_') === 0) { + $suffix = str_replace('mageworx_custom_option_', '', $m1Table); + + // M2 uses "group_option_" structure for option-related tables + if (strpos($suffix, 'option_') === 0) { + return 'mageworx_optiontemplates_group_' . $suffix; + } + + return 'mageworx_optiontemplates_' . $suffix; + } + + // Try pattern matching for plural form (e.g., mageworx_custom_options_* -> mageworx_optiontemplates_*) + if (strpos($m1Table, 'mageworx_custom_options_') === 0) { + // Explicitly skip view_mode table + if ($m1Table === 'mageworx_custom_options_option_view_mode') { + return null; + } + + // Explicitly skip option_default table + if ($m1Table === 'mageworx_custom_options_option_default') { + return null; + } + + $suffix = str_replace('mageworx_custom_options_', '', $m1Table); + + // Handle special cases for M2 structure + // M2 uses "group_option_" prefix for option-related tables + if (strpos($suffix, 'option_') === 0) { + return 'mageworx_optiontemplates_group_' . $suffix; + } + + // Handle group_store -> group_option_store_view + if ($suffix === 'group_store') { + return 'mageworx_optiontemplates_group_option_store_view'; + } + + // Default: just replace prefix + return 'mageworx_optiontemplates_' . $suffix; + } + + return null; + } + + /** + * Check if table exists + */ + protected function tableExists($connection, $tableName) + { + try { + DB::connection($connection) + ->table($tableName) + ->limit(1) + ->first(); + return true; + } catch (Exception $e) { + return false; + } + } + + /** + * Map column names from M1 to M2 if needed + */ + protected function mapMageworxColumns($m1Table, $m2Table, $rowArray) + { + // Table-specific column mappings + $tableColumnMappings = [ + 'mageworx_optiontemplates_group_option_type_image' => [ + 'image_file' => 'image', // M2 uses 'image' instead of 'image_file' + ], + ]; + + // Common column mappings + $columnMappings = [ + // Generic mappings that might apply + 'custom_option_id' => 'option_id', + 'custom_option_group_id' => 'group_id', + ]; + + // Get table-specific mappings if they exist + $tableMappings = $tableColumnMappings[$m2Table] ?? []; + + $mappedRow = []; + foreach ($rowArray as $key => $value) { + // First check table-specific mapping, then common mapping, then use original key + if (isset($tableMappings[$key])) { + $mappedKey = $tableMappings[$key]; + } elseif (isset($columnMappings[$key])) { + $mappedKey = $columnMappings[$key]; + } else { + $mappedKey = $key; + } + $mappedRow[$mappedKey] = $value; + } + + return $mappedRow; + } + + /** + * Check if record exists in M2 table + */ + protected function recordExists($m2Table, $rowArray) + { + try { + $table = $this->magento2Prefix . $m2Table; + + // Try common primary key columns in order of preference + $primaryKeys = ['id', 'option_id', 'group_id', 'value_id', 'type_id', 'store_id']; + + foreach ($primaryKeys as $key) { + if (isset($rowArray[$key]) && $rowArray[$key] !== null) { + $exists = DB::connection($this->magento2Connection) + ->table($table) + ->where($key, $rowArray[$key]) + ->exists(); + + if ($exists) { + return true; + } + } + } + + return false; + } catch (Exception $e) { + Log::warning("Error checking if record exists in {$m2Table}: " . $e->getMessage()); + return false; + } + } + + /** + * Sort tables by dependency order (parent tables first) + */ + protected function sortTablesByDependency($m1Tables, $tableMapping) + { + // Define table dependency order (parent tables first) + $dependencyOrder = [ + 'mageworx_optiontemplates_group', // Parent - no dependencies + 'mageworx_optiontemplates_group_option', // Depends on group + 'mageworx_optiontemplates_group_option_type_value', // Depends on group_option + 'mageworx_optiontemplates_group_option_description', // Depends on group_option + 'mageworx_optiontemplates_group_option_title', // Depends on group_option + 'mageworx_optiontemplates_group_option_price', // Depends on group_option + 'mageworx_optiontemplates_group_option_type_description', // Depends on group_option_type_value + 'mageworx_optiontemplates_group_option_type_image', // Depends on group_option_type_value + 'mageworx_optiontemplates_group_option_store_view', // Depends on group_option + 'mageworx_optiontemplates_relation', // Depends on group + ]; + + $sorted = []; + $unsorted = []; + + // First, add tables in dependency order + foreach ($dependencyOrder as $m2TableName) { + // Find M1 table that maps to this M2 table + foreach ($m1Tables as $m1Table) { + $m2Table = $this->getM2TableName($m1Table, $tableMapping); + if ($m2Table === $m2TableName) { + $sorted[] = $m1Table; + break; + } + } + } + + // Add any remaining tables that weren't in the dependency list + foreach ($m1Tables as $m1Table) { + if (!in_array($m1Table, $sorted)) { + $sorted[] = $m1Table; + } + } + + return $sorted; + } + + /** + * Validate foreign key constraints before inserting/updating + */ + protected function validateForeignKeyConstraints($m2Table, $mappedRow, $dryRun = false) + { + $prefix = $dryRun ? "Would skip" : "Skipping"; + + // Validate option_id foreign key for option_description + if ($m2Table === 'mageworx_optiontemplates_group_option_description' && isset($mappedRow['option_id'])) { + $optionExists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'mageworx_optiontemplates_group_option') + ->where('option_id', $mappedRow['option_id']) + ->exists(); + + if (!$optionExists) { + $this->migrationLog[] = "WARNING: {$prefix} option_description record - option_id {$mappedRow['option_id']} does not exist in mageworx_optiontemplates_group_option"; + return false; + } + } + + // Validate option_type_id foreign key for option_type_image + if ($m2Table === 'mageworx_optiontemplates_group_option_type_image' && isset($mappedRow['option_type_id'])) { + $typeExists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'mageworx_optiontemplates_group_option_type_value') + ->where('option_type_id', $mappedRow['option_type_id']) + ->exists(); + + if (!$typeExists) { + $this->migrationLog[] = "WARNING: {$prefix} option_type_image record - option_type_id {$mappedRow['option_type_id']} does not exist in mageworx_optiontemplates_group_option_type_value"; + return false; + } + } + + // Validate group_id foreign key for relation table + if ($m2Table === 'mageworx_optiontemplates_relation' && isset($mappedRow['group_id'])) { + $groupExists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'mageworx_optiontemplates_group') + ->where('group_id', $mappedRow['group_id']) + ->exists(); + + if (!$groupExists) { + $this->migrationLog[] = "WARNING: {$prefix} relation record - group_id {$mappedRow['group_id']} does not exist in mageworx_optiontemplates_group"; + return false; + } + } + + return true; + } + + /** + * Get columns that exist in M2 table + */ + protected function getM2TableColumns($m2Table) + { + static $columnCache = []; + + if (isset($columnCache[$m2Table])) { + return $columnCache[$m2Table]; + } + + try { + $database = config("database.connections.{$this->magento2Connection}.database"); + $prefix = $this->magento2Prefix; + + $columns = DB::connection($this->magento2Connection) + ->select("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", [ + $database, + $prefix . $m2Table + ]); + + $columnNames = array_map(function($col) { + return $col->COLUMN_NAME; + }, $columns); + + $columnCache[$m2Table] = $columnNames; + return $columnNames; + } catch (Exception $e) { + Log::warning("Error getting columns for {$m2Table}: " . $e->getMessage()); + return []; + } + } + + /** + * Insert record into M2 table + */ + protected function insertM2Record($m2Table, $rowArray) + { + $table = $this->magento2Prefix . $m2Table; + + // Get valid columns for this table + $validColumns = $this->getM2TableColumns($m2Table); + + // Filter row to only include columns that exist in M2 table + $filteredRow = []; + foreach ($rowArray as $key => $value) { + if (in_array($key, $validColumns)) { + $filteredRow[$key] = $value; + } else { + $this->migrationLog[] = "INFO: Skipping column '{$key}' - does not exist in M2 table {$m2Table}"; + } + } + + // Remove null values + $cleanRow = array_filter($filteredRow, function($value) { + return $value !== null; + }); + + if (empty($cleanRow)) { + $this->migrationLog[] = "WARNING: No valid columns to insert for record in {$m2Table}"; + return; + } + + DB::connection($this->magento2Connection) + ->table($table) + ->insert($cleanRow); + } + + /** + * Update record in M2 table + */ + protected function updateM2Record($m2Table, $rowArray) + { + $table = $this->magento2Prefix . $m2Table; + + // Find primary key + $primaryKeys = ['id', 'option_id', 'group_id', 'value_id', 'type_id', 'store_id']; + $whereClause = []; + + foreach ($primaryKeys as $key) { + if (isset($rowArray[$key]) && $rowArray[$key] !== null) { + $whereClause[$key] = $rowArray[$key]; + break; + } + } + + if (empty($whereClause)) { + throw new Exception("Cannot update record: no primary key found in table {$m2Table}"); + } + + // Remove primary key from update data + $updateData = $rowArray; + foreach ($primaryKeys as $key) { + unset($updateData[$key]); + } + + // Get valid columns for this table + $validColumns = $this->getM2TableColumns($m2Table); + + // Filter update data to only include columns that exist in M2 table + $filteredData = []; + foreach ($updateData as $key => $value) { + if (in_array($key, $validColumns)) { + $filteredData[$key] = $value; + } + } + + // Remove null values + $cleanData = array_filter($filteredData, function($value) { + return $value !== null; + }); + + if (!empty($cleanData)) { + DB::connection($this->magento2Connection) + ->table($table) + ->where($whereClause) + ->update($cleanData); + } + } +} + diff --git a/resources/js/products.js b/resources/js/products.js index cf62763..980a939 100644 --- a/resources/js/products.js +++ b/resources/js/products.js @@ -43,6 +43,8 @@ function startProductMigration(dryRun) { button.style.cursor = 'not-allowed'; const logContent = document.getElementById('productMigrationLogContent'); + const progressContainer = document.getElementById('migrationProgressContainer'); + if (!logContent) { console.error('Log content element not found'); button.disabled = false; @@ -53,69 +55,60 @@ function startProductMigration(dryRun) { return; } + // Show progress bar + if (progressContainer && !dryRun) { + progressContainer.style.display = 'block'; + updateProgressBar(0, 0, 0, 0, 0, 'Starting migration...', ''); + } + logContent.innerHTML = '
' + (dryRun ? 'Running dry run...' : 'Starting migration...') + '
'; + // Generate progress key for tracking (only for non-dry-run) + let progressKey = null; + let progressInterval = null; + + if (!dryRun) { + progressKey = 'product_migration_progress_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + // Start polling immediately + progressInterval = pollMigrationProgress(progressKey, button, otherButton, originalText, logContent); + } + fetch(routes.migrateProducts, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, - body: JSON.stringify({ dry_run: dryRun }) + body: JSON.stringify({ dry_run: dryRun, progress_key: progressKey }) }) .then(response => response.json()) .then(data => { - button.disabled = false; - otherButton.disabled = false; - button.textContent = originalText; - button.style.cursor = 'pointer'; - - if (data.success) { - const successEntry = document.createElement('div'); - successEntry.className = 'log-entry success'; - successEntry.textContent = `✓ ${dryRun ? 'Dry run' : 'Migration'} completed! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: ${data.errors || 0}`; - logContent.appendChild(successEntry); - - if (data.log && data.log.length > 0) { - data.log.forEach(log => { - const entry = document.createElement('div'); - entry.className = 'log-entry ' + (log.includes('ERROR') ? 'error' : 'success'); - entry.textContent = log; - logContent.appendChild(entry); - }); - } - - if (data.missing_attributes && data.missing_attributes.length > 0) { - const missingSection = document.getElementById('missingAttributesSection'); - const tbody = document.getElementById('missingAttributesTableBody'); - tbody.innerHTML = ''; - data.missing_attributes.forEach(attr => { - const row = document.createElement('tr'); - row.innerHTML = ` - ${attr.attribute_code} - ${attr.frontend_label || 'N/A'} - ${attr.backend_type || 'N/A'} - `; - tbody.appendChild(row); - }); - missingSection.style.display = 'block'; - } - - const logContainer = document.getElementById('productMigrationLogContainer'); - logContainer.scrollTop = logContainer.scrollHeight; - } else { - const errorEntry = document.createElement('div'); - errorEntry.className = 'log-entry error'; - errorEntry.textContent = '✗ ' + (dryRun ? 'Dry run' : 'Migration') + ' failed: ' + (data.message || 'Unknown error'); - logContent.appendChild(errorEntry); + // Stop polling when migration completes + if (progressInterval) { + clearInterval(progressInterval); } + + // Handle final result + handleMigrationComplete(data, button, otherButton, originalText, logContent, dryRun); }) .catch(error => { + // Stop polling on error + if (progressInterval) { + clearInterval(progressInterval); + } + button.disabled = false; - otherButton.disabled = false; + if (otherButton) { + otherButton.disabled = false; + } button.textContent = originalText; button.style.cursor = 'pointer'; + const progressContainer = document.getElementById('migrationProgressContainer'); + if (progressContainer) { + progressContainer.style.display = 'none'; + } + const errorEntry = document.createElement('div'); errorEntry.className = 'log-entry error'; errorEntry.textContent = '✗ Error: ' + error.message; @@ -123,6 +116,165 @@ function startProductMigration(dryRun) { }); } +function pollMigrationProgress(progressKey, button, otherButton, originalText, logContent) { + return setInterval(() => { + if (!routes.migrationProgress) { + console.error('Migration progress route not available'); + return; + } + + fetch(routes.migrationProgress + '?progress_key=' + encodeURIComponent(progressKey), { + method: 'GET', + headers: { + 'X-CSRF-TOKEN': csrfToken + } + }) + .then(response => response.json()) + .then(data => { + if (data.success && data.progress) { + const progress = data.progress; + const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + + updateProgressBar( + percentage, + progress.current, + progress.total, + progress.added, + progress.updated, + progress.errors, + progress.status, + progress.current_sku + ); + } else if (data.success === false && data.message === 'Progress not found') { + // Progress not found yet, migration might not have started + // This is okay, just continue polling + } + }) + .catch(error => { + console.error('Error polling progress:', error); + }); + }, 500); // Poll every 500ms for smoother updates +} + +function updateProgressBar(percentage, current, total, added, updated, errors, status, currentSku) { + const progressBar = document.getElementById('progressBar'); + const progressPercentage = document.getElementById('progressPercentage'); + const progressStatus = document.getElementById('progressStatus'); + const currentProduct = document.getElementById('currentProduct'); + const progressAdded = document.getElementById('progressAdded'); + const progressUpdated = document.getElementById('progressUpdated'); + const progressErrors = document.getElementById('progressErrors'); + + if (progressBar) { + progressBar.style.width = percentage + '%'; + } + if (progressPercentage) { + progressPercentage.textContent = percentage + '%'; + } + if (progressStatus) { + const statusText = status === 'running' ? 'Migrating...' : + status === 'completed' ? 'Migration completed!' : + status === 'failed' ? 'Migration failed!' : 'Starting...'; + progressStatus.textContent = statusText; + } + if (currentProduct) { + currentProduct.textContent = currentSku ? `Current: ${currentSku}` : `Processing ${current} of ${total}`; + } + if (progressAdded) { + progressAdded.textContent = added; + } + if (progressUpdated) { + progressUpdated.textContent = updated; + } + if (progressErrors) { + progressErrors.textContent = errors; + } +} + +function handleMigrationComplete(data, button, otherButton, originalText, logContent, dryRun) { + button.disabled = false; + if (otherButton) { + otherButton.disabled = false; + } + button.textContent = originalText; + button.style.cursor = 'pointer'; + + const progressContainer = document.getElementById('migrationProgressContainer'); + if (progressContainer) { + if (data.success) { + updateProgressBar(100, data.added + data.updated, data.added + data.updated, data.added, data.updated, data.errors, 'completed', ''); + } + } + + if (data.success) { + // Clear log content and show only errors + logContent.innerHTML = ''; + + if (data.log && data.log.length > 0) { + // Filter to show only error logs (case-insensitive check) + const errorLogs = data.log.filter(log => { + const logUpper = log.toUpperCase(); + return logUpper.includes('ERROR'); + }); + + if (errorLogs.length > 0) { + // Show summary with error count + const summaryEntry = document.createElement('div'); + summaryEntry.className = 'log-entry'; + summaryEntry.style.cssText = 'font-weight: 600; margin-bottom: 10px; color: #dc3545;'; + summaryEntry.textContent = `⚠️ Migration completed with ${errorLogs.length} error(s). Added: ${data.added || 0}, Updated: ${data.updated || 0}`; + logContent.appendChild(summaryEntry); + + // Show each error + errorLogs.forEach(log => { + const entry = document.createElement('div'); + entry.className = 'log-entry error'; + entry.textContent = log; + logContent.appendChild(entry); + }); + } else { + // No errors found + const noErrorsEntry = document.createElement('div'); + noErrorsEntry.className = 'log-entry'; + noErrorsEntry.style.cssText = 'color: #28a745; font-weight: 600;'; + noErrorsEntry.textContent = `✓ ${dryRun ? 'Dry run' : 'Migration'} completed successfully! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: 0`; + logContent.appendChild(noErrorsEntry); + } + } else { + // No logs at all + const noLogsEntry = document.createElement('div'); + noLogsEntry.className = 'log-entry'; + noLogsEntry.style.cssText = 'color: #28a745; font-weight: 600;'; + noLogsEntry.textContent = `✓ ${dryRun ? 'Dry run' : 'Migration'} completed! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: ${data.errors || 0}`; + logContent.appendChild(noLogsEntry); + } + + if (data.missing_attributes && data.missing_attributes.length > 0) { + const missingSection = document.getElementById('missingAttributesSection'); + const tbody = document.getElementById('missingAttributesTableBody'); + tbody.innerHTML = ''; + data.missing_attributes.forEach(attr => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${attr.attribute_code} + ${attr.frontend_label || 'N/A'} + ${attr.backend_type || 'N/A'} + `; + tbody.appendChild(row); + }); + missingSection.style.display = 'block'; + } + + const logContainer = document.getElementById('productMigrationLogContainer'); + logContainer.scrollTop = logContainer.scrollHeight; + } else { + const errorEntry = document.createElement('div'); + errorEntry.className = 'log-entry error'; + errorEntry.textContent = '✗ ' + (dryRun ? 'Dry run' : 'Migration') + ' failed: ' + (data.message || 'Unknown error'); + logContent.appendChild(errorEntry); + } +} + function deleteM2Product(productId, productName, productSku) { if (!confirm(`Are you sure you want to delete product "${productName}" (SKU: ${productSku})?`)) { return; @@ -546,6 +698,254 @@ function startProductOptionsMigration(dryRun) { }); } +function compareProductBySku() { + const skuInput = document.getElementById('productSkuInput'); + const compareBtn = document.getElementById('compareProductBtn'); + const comparisonSection = document.getElementById('productComparisonSection'); + const comparisonMessage = document.getElementById('productComparisonMessage'); + + if (!skuInput || !compareBtn || !comparisonSection) { + console.error('Required elements not found'); + alert('Error: Required elements not found. Please refresh the page.'); + return; + } + + const sku = skuInput.value.trim(); + + if (!sku) { + alert('Please enter a product SKU'); + return; + } + + if (!routes || !routes.compareProductBySku) { + console.error('Route not available', routes); + alert('Error: Route not configured. Please refresh the page.'); + return; + } + + const originalText = compareBtn.textContent; + compareBtn.disabled = true; + compareBtn.textContent = 'Comparing...'; + comparisonSection.style.display = 'none'; + + fetch(routes.compareProductBySku, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ sku: sku }) + }) + .then(response => response.json()) + .then(data => { + compareBtn.disabled = false; + compareBtn.textContent = originalText; + comparisonSection.style.display = 'block'; + + const tablesContainer = document.getElementById('productComparisonTablesContainer'); + if (!tablesContainer) { + console.error('Tables container not found'); + return; + } + + // Clear previous results + tablesContainer.innerHTML = ''; + + if (data.success) { + // Display message + comparisonMessage.textContent = data.message || 'Comparison completed'; + comparisonMessage.style.background = '#d4edda'; + comparisonMessage.style.color = '#155724'; + comparisonMessage.style.border = '1px solid #c3e6cb'; + + if (data.m1_product_id) { + const infoText = document.createElement('div'); + infoText.style.marginTop = '5px'; + infoText.style.fontSize = '0.9em'; + infoText.textContent = `M1 Product ID: ${data.m1_product_id}${data.m2_product_id ? ` | M2 Product ID: ${data.m2_product_id}` : ''}`; + comparisonMessage.appendChild(infoText); + } + + // Display tables for each backend type + if (data.changes_by_table) { + const backendTypes = ['varchar', 'int', 'text', 'decimal', 'datetime']; + let hasAnyChanges = false; + + backendTypes.forEach(backendType => { + const tableData = data.changes_by_table[backendType]; + if (!tableData) return; + + const changes = tableData.changes || []; + if (changes.length > 0) { + hasAnyChanges = true; + } + + // Create table container + const tableWrapper = document.createElement('div'); + tableWrapper.style.cssText = 'background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; margin-bottom: 20px; max-height: 600px; overflow-y: auto;'; + + // Table title + const tableTitle = document.createElement('h4'); + tableTitle.style.cssText = 'margin: 0 0 15px 0; color: #333; font-size: 1.1em;'; + tableTitle.textContent = `Table: ${tableData.table_name}`; + tableWrapper.appendChild(tableTitle); + + // Table info + const tableInfo = document.createElement('div'); + tableInfo.style.cssText = 'margin-bottom: 15px; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 0.9em; color: #666;'; + tableInfo.innerHTML = ` + M1 Table: ${tableData.m1_table}
+ M2 Table: ${tableData.m2_table}
+ Changes: ${changes.length} + `; + tableWrapper.appendChild(tableInfo); + + // Create table + const table = document.createElement('table'); + table.className = 'products-table'; + table.style.width = '100%'; + + // Table header + const thead = document.createElement('thead'); + thead.innerHTML = ` + + Attribute Code + Attribute Label + Store ID + M1 Value (To Update) + M2 Current Value + Action + + `; + table.appendChild(thead); + + // Table body + const tbody = document.createElement('tbody'); + + if (changes.length > 0) { + changes.forEach(change => { + const row = document.createElement('tr'); + + // Determine row style based on action + if (change.action === 'INSERT') { + row.style.backgroundColor = '#d4edda'; + } else if (change.action === 'UPDATE') { + row.style.backgroundColor = '#fff3cd'; + } else if (change.action === 'DELETE') { + row.style.backgroundColor = '#f8d7da'; + } + + // Format values for display + const m1Value = change.m1_value !== null && change.m1_value !== undefined + ? (change.m1_value.toString().length > 100 + ? change.m1_value.toString().substring(0, 100) + '...' + : change.m1_value.toString()) + : '(empty)'; + + const m2Value = change.m2_value !== null && change.m2_value !== undefined + ? (change.m2_value.toString().length > 100 + ? change.m2_value.toString().substring(0, 100) + '...' + : change.m2_value.toString()) + : '(empty)'; + + // Determine action badge color + let actionBadgeClass = ''; + let actionText = change.action; + if (change.action === 'INSERT') { + actionBadgeClass = 'style="background-color: #28a745; color: white; padding: 2px 8px; border-radius: 3px; font-size: 0.85em;"'; + } else if (change.action === 'UPDATE') { + actionBadgeClass = 'style="background-color: #ffc107; color: #000; padding: 2px 8px; border-radius: 3px; font-size: 0.85em;"'; + } else if (change.action === 'DELETE') { + actionBadgeClass = 'style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 3px; font-size: 0.85em;"'; + } + + row.innerHTML = ` + ${change.attribute_code || 'N/A'} + ${change.attribute_label || 'N/A'} + ${change.store_id || 0} + ${m1Value} + ${m2Value} + ${actionText} + `; + + tbody.appendChild(row); + }); + } else { + const row = document.createElement('tr'); + row.innerHTML = ` + + No changes in this table. Values are identical in M1 and M2. + + `; + tbody.appendChild(row); + } + + table.appendChild(tbody); + tableWrapper.appendChild(table); + tablesContainer.appendChild(tableWrapper); + }); + + if (!hasAnyChanges) { + const noChangesMsg = document.createElement('div'); + noChangesMsg.style.cssText = 'text-align: center; padding: 40px; color: #666; background: white; border: 1px solid #ddd; border-radius: 6px;'; + noChangesMsg.textContent = 'No differences found. The product values are identical in M1 and M2 across all tables.'; + tablesContainer.appendChild(noChangesMsg); + } + } else { + const noDataMsg = document.createElement('div'); + noDataMsg.style.cssText = 'text-align: center; padding: 40px; color: #666; background: white; border: 1px solid #ddd; border-radius: 6px;'; + noDataMsg.textContent = 'No comparison data available.'; + tablesContainer.appendChild(noDataMsg); + } + } else { + // Error case + comparisonMessage.textContent = data.message || 'Comparison failed'; + comparisonMessage.style.background = '#f8d7da'; + comparisonMessage.style.color = '#721c24'; + comparisonMessage.style.border = '1px solid #f5c6cb'; + + const errorMsg = document.createElement('div'); + errorMsg.style.cssText = 'text-align: center; padding: 40px; color: #dc3545; background: white; border: 1px solid #ddd; border-radius: 6px;'; + errorMsg.textContent = data.message || 'Failed to compare product'; + tablesContainer.appendChild(errorMsg); + } + }) + .catch(error => { + compareBtn.disabled = false; + compareBtn.textContent = originalText; + comparisonSection.style.display = 'block'; + + const tablesContainer = document.getElementById('productComparisonTablesContainer'); + if (tablesContainer) { + tablesContainer.innerHTML = ''; + } + + comparisonMessage.textContent = 'Error: ' + error.message; + comparisonMessage.style.background = '#f8d7da'; + comparisonMessage.style.color = '#721c24'; + comparisonMessage.style.border = '1px solid #f5c6cb'; + + if (tablesContainer) { + const errorMsg = document.createElement('div'); + errorMsg.style.cssText = 'text-align: center; padding: 40px; color: #dc3545; background: white; border: 1px solid #ddd; border-radius: 6px;'; + errorMsg.textContent = 'Error: ' + error.message; + tablesContainer.appendChild(errorMsg); + } + }); +} + +// Add Enter key support for SKU input +document.addEventListener('DOMContentLoaded', function() { + const skuInput = document.getElementById('productSkuInput'); + if (skuInput) { + skuInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + compareProductBySku(); + } + }); + } +}); + // Make functions available globally window.startProductMigration = startProductMigration; window.startProductOptionsMigration = startProductOptionsMigration; @@ -555,4 +955,5 @@ window.syncProductCategories = syncProductCategories; window.fixCategoryProducts = fixCategoryProducts; window.loadM1CategoryTreeWithProducts = loadM1CategoryTreeWithProducts; window.loadM2CategoryTreeWithProducts = loadM2CategoryTreeWithProducts; +window.compareProductBySku = compareProductBySku; diff --git a/resources/views/products/index.blade.php b/resources/views/products/index.blade.php index 584d311..daae322 100644 --- a/resources/views/products/index.blade.php +++ b/resources/views/products/index.blade.php @@ -1,8 +1,46 @@ @extends('layouts.app') @section('content') - +
+

🔍 Migrate Single Product

+
+

Compare Product Values

+

Enter a product SKU to see all differences between Magento 1 and Magento 2 values:

+
    +
  • M1 Values: Values from Magento 1 that will be used to update Magento 2
  • +
  • M2 Current Values: Current values in Magento 2
  • +
  • Action: Shows whether the value will be INSERTED, UPDATED, or DELETED
  • +
+
+ +
+
+ + +
+ +
+ + + +
+ + +

🚀 Product Migration

What happens when you click "Start Product Migration"?

@@ -19,9 +57,6 @@
- @@ -29,16 +64,33 @@ Delete M2 Products Above M1 Max ID
+ + +

📋 Product Migration Logs

-

Detailed logs showing which products were added, updated, or encountered errors during migration:

+

Error logs showing which products encountered errors during migration:

- No product migration logs yet. Click "Run Dry Run" or "Start Product Migration" to begin. + No product migration logs yet. Click "Start Product Migration" to begin.
@@ -281,11 +333,13 @@ class="btn btn-danger"