From 6afae1901338e26f1a29856161d90c1ca78a14c0 Mon Sep 17 00:00:00 2001 From: Chris Rosenau Date: Wed, 12 Nov 2025 23:21:06 -0700 Subject: [PATCH] added category migration --- app/Http/Controllers/CategoriesController.php | 59 +++++ .../MagentoCategoryMigrationService.php | 201 ++++++++++++++++++ resources/css/categories.css | 21 ++ resources/js/categories.js | 171 ++++++++++++++- resources/views/categories/index.blade.php | 75 ++++++- routes/web.php | 2 + 6 files changed, 527 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/CategoriesController.php b/app/Http/Controllers/CategoriesController.php index 4b52e21..90f7023 100644 --- a/app/Http/Controllers/CategoriesController.php +++ b/app/Http/Controllers/CategoriesController.php @@ -23,11 +23,13 @@ public function index() $m1Categories = $this->migrationService->getMagento1Categories(); $m2Categories = $this->migrationService->getMagento2Categories(); $m2CategoriesNotInM1 = $this->migrationService->getM2CategoriesNotInM1(); + $m1CategoriesNotInM2 = $this->migrationService->getM1CategoriesNotInM2(); return view('categories.index', [ 'm1CategoriesCount' => $m1Categories->count(), 'm2CategoriesCount' => $m2Categories->count(), 'm2CategoriesNotInM1' => $m2CategoriesNotInM1, + 'm1CategoriesNotInM2' => $m1CategoriesNotInM2, ]); } @@ -173,6 +175,38 @@ public function getM2CategoriesNotInM1() } } + /** + * Get M1 categories not in M2 (for AJAX refresh) + */ + public function getM1CategoriesNotInM2() + { + try { + $categories = $this->migrationService->getM1CategoriesNotInM2(); + + return response()->json([ + 'success' => true, + 'categories' => $categories->map(function($category) { + return [ + 'entity_id' => $category->entity_id, + 'name' => $category->name ?? 'Unnamed Category', + 'level' => $category->level ?? 'N/A', + 'is_active' => $category->is_active ?? 0, + 'root_category_name' => $category->root_category_name ?? 'N/A', + 'root_category_id' => $category->root_category_id ?? null, + 'path' => $category->path ?? 'N/A', + ]; + }), + 'count' => $categories->count(), + ]); + } catch (\Exception $e) { + Log::error('Error fetching M1 categories not in M2: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch categories: ' . $e->getMessage(), + ], 500); + } + } + /** * Delete a category */ @@ -198,6 +232,31 @@ public function deleteCategory(Request $request, $categoryId) } } + /** + * Migrate a single category from Magento 1 to Magento 2 + */ + public function migrateCategory(Request $request, $categoryId) + { + $request->validate([ + 'store_id' => 'nullable|integer', + ]); + + try { + $storeId = $request->input('store_id', 0); + $result = $this->migrationService->migrateSingleCategory($categoryId, $storeId); + + return response()->json($result, $result['success'] ? 200 : 400); + + } catch (\Exception $e) { + Log::error('Category migration error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Migration failed: ' . $e->getMessage(), + ], 500); + } + } + /** * Rename a Magento 2 category */ diff --git a/app/Services/MagentoCategoryMigrationService.php b/app/Services/MagentoCategoryMigrationService.php index 39dc887..e1468ed 100644 --- a/app/Services/MagentoCategoryMigrationService.php +++ b/app/Services/MagentoCategoryMigrationService.php @@ -305,6 +305,67 @@ public function getM2CategoriesNotInM1() } } + /** + * Find categories in Magento 1 that don't exist in Magento 2 + * Compares by category name (case-insensitive) + */ + public function getM1CategoriesNotInM2() + { + try { + $m1Categories = $this->getMagento1Categories(); + $m2Categories = $this->getMagento2Categories(); + + // Create a set of M2 category names (case-insensitive, normalized) + $m2CategoryNames = $m2Categories->map(function($cat) { + return strtolower(trim($cat->name ?? '')); + })->filter(function($name) { + return !empty($name); + })->unique()->toArray(); + + // Get all M1 categories to build a map for root category lookup + $m1CategoryMap = []; + foreach ($m1Categories as $cat) { + $m1CategoryMap[$cat->entity_id] = $cat; + } + + // Find M1 categories that don't have a matching name in M2 + $missingCategories = $m1Categories->filter(function($m1Cat) use ($m2CategoryNames) { + $m1Name = strtolower(trim($m1Cat->name ?? '')); + return !empty($m1Name) && !in_array($m1Name, $m2CategoryNames); + })->map(function($m1Cat) use ($m1CategoryMap) { + // Extract root category ID from path (typically second element: 1/2/3/4 -> 2) + $rootCategoryId = null; + $rootCategoryName = 'N/A'; + + if (!empty($m1Cat->path)) { + $pathParts = explode('/', $m1Cat->path); + // Root category is typically at index 1 (second element) + if (count($pathParts) > 1 && isset($pathParts[1])) { + $rootCategoryId = (int)$pathParts[1]; + + // Get root category name if it exists in our map + if (isset($m1CategoryMap[$rootCategoryId])) { + $rootCategoryName = $m1CategoryMap[$rootCategoryId]->name ?? "ID: {$rootCategoryId}"; + } else { + $rootCategoryName = "ID: {$rootCategoryId}"; + } + } + } + + // Add root category info to the category object + $m1Cat->root_category_id = $rootCategoryId; + $m1Cat->root_category_name = $rootCategoryName; + + return $m1Cat; + })->values(); + + return $missingCategories; + } catch (Exception $e) { + Log::error('Error finding M1 categories not in M2: ' . $e->getMessage()); + return collect([]); + } + } + protected function getCategoryProductCounts($categoryIds, $source = 'm2') { if (empty($categoryIds)) { @@ -780,6 +841,146 @@ protected function getMagento2AttributeIds() return $attributes; } + /** + * Migrate a single category from Magento 1 to Magento 2 + */ + public function migrateSingleCategory($m1CategoryId, $storeId = 0) + { + try { + DB::connection($this->magento2Connection)->beginTransaction(); + + // Get the M1 category + $m1Categories = $this->getMagento1Categories($storeId); + $m1Category = $m1Categories->firstWhere('entity_id', $m1CategoryId); + + if (!$m1Category) { + return [ + 'success' => false, + 'message' => 'Category not found in Magento 1', + ]; + } + + // Determine parent ID in M2 + $m2ParentId = $this->getMagento2RootCategoryId(); + + // If category has a parent in M1, try to find it in M2 + if ($m1Category->parent_id && $m1Category->parent_id != 0 && $m1Category->parent_id != 1) { + // Check if parent was already migrated (check by name) + $m1ParentCategories = $this->getMagento1Categories($storeId); + $m1Parent = $m1ParentCategories->firstWhere('entity_id', $m1Category->parent_id); + + if ($m1Parent && $m1Parent->name) { + $m2ParentId = $this->findExistingCategoryInM2($m1Parent->name, $this->getMagento2RootCategoryId()); + if (!$m2ParentId) { + // Parent not found, use root + $m2ParentId = $this->getMagento2RootCategoryId(); + } + } + } + + // Check if category already exists in M2 + $existingM2CategoryId = null; + if ($m1Category->name) { + $existingM2CategoryId = $this->findExistingCategoryInM2($m1Category->name, $m2ParentId); + } + + if ($existingM2CategoryId) { + DB::connection($this->magento2Connection)->rollBack(); + return [ + 'success' => false, + 'message' => 'Category already exists in Magento 2 (ID: ' . $existingM2CategoryId . ')', + 'm2_category_id' => $existingM2CategoryId, + ]; + } + + // Create new category entity + $m2EntityId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_entity') + ->insertGetId([ + 'attribute_set_id' => 3, // Default category attribute set + 'parent_id' => $m2ParentId, + 'created_at' => now(), + 'updated_at' => now(), + 'path' => '', + 'position' => $m1Category->position ?? 0, + 'level' => $m1Category->level ?? 1, + 'children_count' => 0, + ]); + + // Update path + $path = $this->buildCategoryPath($m2EntityId, $m2ParentId); + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_entity') + ->where('entity_id', $m2EntityId) + ->update(['path' => $path]); + + // Migrate attributes for default store (0) + $attributeIds = $this->getMagento2AttributeIds(); + + // Migrate name + if ($m1Category->name) { + $this->insertCategoryAttribute( + $m2EntityId, + $attributeIds['name'], + $storeId, + $m1Category->name + ); + } + + // Migrate is_active + if ($m1Category->is_active !== null) { + $this->insertCategoryAttribute( + $m2EntityId, + $attributeIds['is_active'], + $storeId, + $m1Category->is_active + ); + } + + // Migrate url_key + if ($m1Category->url_key) { + $this->insertCategoryAttribute( + $m2EntityId, + $attributeIds['url_key'], + $storeId, + $m1Category->url_key + ); + } + + // Update children_count for parent + if ($m2ParentId > 0) { + $childrenCount = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_entity') + ->where('parent_id', $m2ParentId) + ->count(); + + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_category_entity') + ->where('entity_id', $m2ParentId) + ->update(['children_count' => $childrenCount]); + } + + DB::connection($this->magento2Connection)->commit(); + + return [ + 'success' => true, + 'message' => 'Category migrated successfully', + 'm1_category_id' => $m1CategoryId, + 'm2_category_id' => $m2EntityId, + 'category_name' => $m1Category->name ?? 'Unnamed Category', + ]; + + } catch (Exception $e) { + DB::connection($this->magento2Connection)->rollBack(); + Log::error("Error migrating single category {$m1CategoryId}: " . $e->getMessage()); + + return [ + 'success' => false, + 'message' => 'Migration failed: ' . $e->getMessage(), + ]; + } + } + /** * Rename a Magento 2 category */ diff --git a/resources/css/categories.css b/resources/css/categories.css index a76bc2a..59942ca 100644 --- a/resources/css/categories.css +++ b/resources/css/categories.css @@ -107,6 +107,27 @@ .tree-error { margin: 10px 0; } +.tree-migrate-btn { + background: #007bff; + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 0.75em; + cursor: pointer; + margin-left: 8px; + transition: background 0.2s; +} + +.tree-migrate-btn:hover { + background: #0056b3; +} + +.tree-migrate-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + .tree-delete-btn { background: #dc3545; color: white; diff --git a/resources/js/categories.js b/resources/js/categories.js index 91c6263..a2bbdfd 100644 --- a/resources/js/categories.js +++ b/resources/js/categories.js @@ -264,9 +264,15 @@ function deleteCategory(categoryId, categoryName, source) { if (data.success) { if (source === 'm1') { loadM1Tree(); + // Reload M1 Categories Not Found in M2 section + loadM1CategoriesNotInM2(); + // Reload page to refresh statistics + location.reload(); } else { loadM2Tree(); - // Reload page to refresh statistics and M2 Categories Not Found in M1 section + // Reload M2 Categories Not Found in M1 section + loadM2CategoriesNotInM1(); + // Reload page to refresh statistics location.reload(); } } else { @@ -403,6 +409,8 @@ function saveRenameCategory(nodeDiv, node, newName, source) { // Reload M2 Categories Not Found in M1 section loadM2CategoriesNotInM1(); + // Reload M1 Categories Not Found in M2 section (in case renamed M2 category now matches an M1 category) + loadM1CategoriesNotInM2(); } else { alert('Error: ' + (data.message || 'Failed to rename category')); // Re-enable input and buttons @@ -530,6 +538,167 @@ function loadM2CategoriesNotInM1() { }); } +// Load M1 Categories Not Found in M2 section +function loadM1CategoriesNotInM2() { + const tbody = document.getElementById('m1-categories-not-in-m2-tbody'); + const wrapper = document.getElementById('m1-categories-not-in-m2-table-wrapper'); + const totalDiv = document.getElementById('m1-categories-not-in-m2-total'); + const emptyDiv = document.getElementById('m1-categories-not-in-m2-empty'); + + if (!tbody && !wrapper && !totalDiv && !emptyDiv) { + // Section doesn't exist on this page + return; + } + + fetch(routes.m1CategoriesNotInM2) + .then(response => response.json()) + .then(data => { + if (data.success) { + const categories = data.categories || []; + const count = data.count || 0; + + // Remove existing content + if (tbody) tbody.innerHTML = ''; + if (totalDiv) totalDiv.style.display = 'none'; + if (emptyDiv) emptyDiv.style.display = 'none'; + if (wrapper) wrapper.style.display = 'none'; + + if (count > 0) { + // Show table and populate it + if (wrapper) wrapper.style.display = 'block'; + if (totalDiv) { + totalDiv.style.display = 'block'; + totalDiv.innerHTML = `Total: ${count} ${count === 1 ? 'category' : 'categories'} found in Magento 1 but not in Magento 2.`; + } + + if (tbody) { + categories.forEach(category => { + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid #dee2e6'; + + const activeClass = category.is_active ? 'active' : 'inactive'; + const activeText = category.is_active ? 'Active' : 'Inactive'; + + let rootCategoryHtml = 'N/A'; + if (category.root_category_name && category.root_category_name !== 'N/A') { + rootCategoryHtml = `${escapeHtml(category.root_category_name)}`; + if (category.root_category_id) { + rootCategoryHtml += ` (ID: ${category.root_category_id})`; + } + } + + // Create migrate button with event listener + const migrateBtn = document.createElement('button'); + migrateBtn.className = 'tree-migrate-btn'; + migrateBtn.textContent = 'Migrate'; + migrateBtn.title = 'Migrate this category to Magento 2'; + migrateBtn.setAttribute('data-migrate-id', category.entity_id); + migrateBtn.onclick = function(e) { + e.stopPropagation(); + migrateM1Category(category.entity_id, category.name, migrateBtn); + }; + + // Create delete button with event listener + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'tree-delete-btn'; + deleteBtn.textContent = 'Delete'; + deleteBtn.title = 'Delete this category'; + deleteBtn.onclick = function(e) { + e.stopPropagation(); + showDeleteConfirmPopup(deleteBtn, category.entity_id, category.name, 'm1', false, 0); + }; + + row.innerHTML = ` + ${category.entity_id} + ${escapeHtml(category.name)} + ${category.level} + + ${activeText} + + ${rootCategoryHtml} + ${escapeHtml(category.path)} + + `; + + // Append buttons to the actions cell + const actionsCell = row.querySelector('td:last-child'); + if (actionsCell) { + actionsCell.appendChild(migrateBtn); + actionsCell.appendChild(deleteBtn); + } + + tbody.appendChild(row); + }); + } + } else { + // Show empty message + if (emptyDiv) { + emptyDiv.style.display = 'block'; + } + } + } + }) + .catch(error => { + console.error('Error loading M1 categories not in M2:', error); + }); +} + +// Migrate a single M1 category to M2 +window.migrateM1Category = function(categoryId, categoryName, buttonElement) { + if (!confirm(`Are you sure you want to migrate the category "${categoryName}" (ID: ${categoryId}) to Magento 2?`)) { + return; + } + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; + const migrateRoute = routes.migrateCategory.replace(':id', categoryId); + + // Disable the migrate button + const button = buttonElement || document.querySelector(`button[data-migrate-id="${categoryId}"]`); + if (button) { + button.disabled = true; + button.textContent = 'Migrating...'; + } + + fetch(migrateRoute, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ store_id: 0 }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(`Category "${categoryName}" migrated successfully to Magento 2 (ID: ${data.m2_category_id})`); + + // Reload both sections + loadM1CategoriesNotInM2(); + loadM2CategoriesNotInM1(); + + // Reload M2 tree to show the new category + loadM2Tree(); + } else { + alert('Error: ' + (data.message || 'Failed to migrate category')); + + // Re-enable button + if (button) { + button.disabled = false; + button.textContent = 'Migrate'; + } + } + }) + .catch(error => { + alert('Error: ' + error.message); + + // Re-enable button + if (button) { + button.disabled = false; + button.textContent = 'Migrate'; + } + }); +}; + // Helper function to escape HTML function escapeHtml(text) { const div = document.createElement('div'); diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php index f15c67b..159adfe 100644 --- a/resources/views/categories/index.blade.php +++ b/resources/views/categories/index.blade.php @@ -84,6 +84,77 @@ class="tree-delete-btn" @endif + +
+

⚠️ Magento 1 Categories Not Found in Magento 2

+

These categories exist in Magento 1 but do not have a matching name in Magento 2:

+ + @if($m1CategoriesNotInM2->count() > 0) +
+ + + + + + + + + + + + + + @foreach($m1CategoriesNotInM2 as $category) + + + + + + + + + + @endforeach + +
IDCategory NameLevelStatusRoot CategoryPathActions
{{ $category->entity_id }}{{ $category->name ?? 'Unnamed Category' }}{{ $category->level ?? 'N/A' }} + + {{ ($category->is_active ?? 0) ? 'Active' : 'Inactive' }} + + + @if(isset($category->root_category_name) && $category->root_category_name !== 'N/A') + {{ $category->root_category_name }} + @if(isset($category->root_category_id)) + (ID: {{ $category->root_category_id }}) + @endif + @else + N/A + @endif + {{ $category->path ?? 'N/A' }} + + +
+
+
+ Total: {{ $m1CategoriesNotInM2->count() }} {{ Str::plural('category', $m1CategoriesNotInM2->count()) }} found in Magento 1 but not in Magento 2. +
+ @else +
+ ✓ All Magento 1 categories have matching names in Magento 2. +
+ @endif +
+

🌳 Category Trees

View category hierarchies from Magento 1 and Magento 2

@@ -118,7 +189,9 @@ class="tree-delete-btn" magento2CategoryTree: '{{ route("categories.magento2-category-tree") }}', deleteCategory: '{{ route("categories.delete-category", ["categoryId" => ":id"]) }}', renameCategory: '{{ route("categories.rename-category", ["categoryId" => ":id"]) }}', - m2CategoriesNotInM1: '{{ route("categories.m2-categories-not-in-m1") }}' + migrateCategory: '{{ route("categories.migrate-category", ["categoryId" => ":id"]) }}', + m2CategoriesNotInM1: '{{ route("categories.m2-categories-not-in-m1") }}', + m1CategoriesNotInM2: '{{ route("categories.m1-categories-not-in-m2") }}' }; @endpush diff --git a/routes/web.php b/routes/web.php index beb73c4..470755e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,8 @@ Route::get('/magento2-tree', [CategoriesController::class, 'getMagento2CategoryTree'])->name('magento2-category-tree'); Route::get('/magento2-tree-with-products', [CategoriesController::class, 'getMagento2CategoryTreeWithProducts'])->name('magento2-category-tree-with-products'); Route::get('/m2-not-in-m1', [CategoriesController::class, 'getM2CategoriesNotInM1'])->name('m2-categories-not-in-m1'); + Route::get('/m1-not-in-m2', [CategoriesController::class, 'getM1CategoriesNotInM2'])->name('m1-categories-not-in-m2'); + Route::post('/{categoryId}/migrate', [CategoriesController::class, 'migrateCategory'])->name('migrate-category'); Route::delete('/{categoryId}', [CategoriesController::class, 'deleteCategory'])->name('delete-category'); Route::put('/{categoryId}/rename', [CategoriesController::class, 'renameCategory'])->name('rename-category'); });