diff --git a/.ddev/config.hooks.yaml b/.ddev/config.hooks.yaml new file mode 100644 index 0000000..703b2c0 --- /dev/null +++ b/.ddev/config.hooks.yaml @@ -0,0 +1,6 @@ +hooks: + post-start: + # Magento triggers (created with DEFINER=`db`@`%`) write to changelog tables + # in the magento1/magento2 databases, so the `db` user needs full privileges + # there - not just on the default `db` database. + - exec: mysql -h db -uroot -proot -e "GRANT ALL PRIVILEGES ON magento1.* TO 'db'@'%'; GRANT ALL PRIVILEGES ON magento2.* TO 'db'@'%'; FLUSH PRIVILEGES;" diff --git a/app/Http/Controllers/AdditionalController.php b/app/Http/Controllers/AdditionalController.php new file mode 100644 index 0000000..4a36206 --- /dev/null +++ b/app/Http/Controllers/AdditionalController.php @@ -0,0 +1,626 @@ +magento1Prefix = config('database.connections.magento1.prefix', ''); + $this->magento2Prefix = config('database.connections.magento2.prefix', ''); + } + + /** + * Compare M1 text attributes (potential tab content) against M2 mgs_protabs config. + * Returns per-scope breakdown of what is configured and what is missing. + */ + public function compareProtabs(Request $request) + { + try { + // --- M2: gather all Protabs entries and website/store names --- + $m2Tabs = DB::connection($this->magento2Connection) + ->table('mgs_protabs') + ->orderBy('scope') + ->orderBy('scope_id') + ->orderBy('position') + ->get(); + + $m2Websites = DB::connection($this->magento2Connection) + ->table('store_website') + ->whereNotIn('website_id', [0]) + ->whereRaw("name NOT LIKE '%Admin%'") + ->pluck('name', 'website_id'); + + $m2Stores = DB::connection($this->magento2Connection) + ->table('store') + ->where('store_id', '>', 0) + ->pluck('name', 'store_id'); + + // Tabs keyed by "scope:scope_id" for easy lookup + $tabsByScope = []; + foreach ($m2Tabs as $tab) { + $key = $tab->scope . ':' . $tab->scope_id; + $tabsByScope[$key][] = (array) $tab; + } + + // --- M1: find all catalog_product text attributes with values --- + $entityTypeIdM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + + // Get all text-backend attributes that actually have product values + $m1TextAttrs = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute as a') + ->join($this->magento1Prefix . 'catalog_product_entity_text as v', 'a.attribute_id', '=', 'v.attribute_id') + ->where('a.entity_type_id', $entityTypeIdM1) + ->select('a.attribute_code', 'a.frontend_label', DB::raw('COUNT(v.value_id) as value_count')) + ->whereNotNull('v.value') + ->where('v.value', '!=', '') + ->groupBy('a.attribute_id', 'a.attribute_code', 'a.frontend_label') + ->having('value_count', '>', 0) + ->orderBy('a.attribute_code') + ->get() + ->keyBy('attribute_code'); + + // --- Build per-scope comparison --- + // Collect all unique scopes from existing tabs + $scopes = $m2Tabs->map(fn($t) => ['scope' => $t->scope, 'scope_id' => $t->scope_id]) + ->unique(fn($s) => $s['scope'] . ':' . $s['scope_id']) + ->values(); + + $comparison = []; + foreach ($scopes as $scope) { + $key = $scope['scope'] . ':' . $scope['scope_id']; + $existing = $tabsByScope[$key] ?? []; + $existingAttrCodes = collect($existing) + ->where('tab_type', 'attribute') + ->pluck('value') + ->filter() + ->flip(); // use as set + + // Find M1 text attrs with values not in this scope's protabs + $missing = []; + foreach ($m1TextAttrs as $attrCode => $attr) { + if (!isset($existingAttrCodes[$attrCode])) { + $missing[] = [ + 'attribute_code' => $attrCode, + 'frontend_label' => $attr->frontend_label ?? $attrCode, + 'value_count' => $attr->value_count, + 'suggested_title' => ucwords(str_replace('_', ' ', $attrCode)), + 'suggested_pos' => count($existing) + count($missing) + 1, + ]; + } + } + + $scopeLabel = match ($scope['scope']) { + 'default' => 'Default (all stores)', + 'websites' => 'Website: ' . ($m2Websites[$scope['scope_id']] ?? 'ID ' . $scope['scope_id']), + 'stores' => 'Store: ' . ($m2Stores[$scope['scope_id']] ?? 'ID ' . $scope['scope_id']), + default => $scope['scope'] . ' ' . $scope['scope_id'], + }; + + $comparison[] = [ + 'scope' => $scope['scope'], + 'scope_id' => $scope['scope_id'], + 'scope_label' => $scopeLabel, + 'existing' => $existing, + 'missing' => $missing, + ]; + } + + return response()->json([ + 'success' => true, + 'comparison' => $comparison, + 'm1_text_attr_count' => $m1TextAttrs->count(), + ]); + } catch (\Throwable $e) { + Log::error('Protabs compare error: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => $e->getMessage()], 500); + } + } + + /** + * Insert one or more missing tabs into M2 mgs_protabs. + * Accepts an array of tab objects: [{title, tab_type, value, position, scope, scope_id}, ...] + */ + public function syncProtabs(Request $request) + { + $tabs = $request->input('tabs', []); + if (empty($tabs)) { + return response()->json(['success' => false, 'message' => 'No tabs provided.'], 400); + } + + $inserted = 0; + $skipped = 0; + $errors = 0; + $log = []; + + foreach ($tabs as $tab) { + $scope = $tab['scope'] ?? 'default'; + $scopeId = (int)($tab['scope_id'] ?? 0); + $value = trim($tab['value'] ?? ''); + $title = trim($tab['title'] ?? ''); + $tabType = $tab['tab_type'] ?? 'attribute'; + $pos = (int)($tab['position'] ?? 99); + + if ($value === '' || $title === '') { + $log[] = "Skipped entry with empty value or title."; + $skipped++; + continue; + } + + // Check if already exists for this scope + $exists = DB::connection($this->magento2Connection) + ->table('mgs_protabs') + ->where('scope', $scope) + ->where('scope_id', $scopeId) + ->where('tab_type', $tabType) + ->where('value', $value) + ->exists(); + + if ($exists) { + $log[] = "Tab '{$title}' ({$value}) already exists for {$scope}:{$scopeId}, skipped."; + $skipped++; + continue; + } + + try { + DB::connection($this->magento2Connection) + ->table('mgs_protabs') + ->insert([ + 'title' => $title, + 'tab_type' => $tabType, + 'value' => $value, + 'position' => $pos, + 'scope' => $scope, + 'scope_id' => $scopeId, + ]); + $inserted++; + $log[] = "Added tab '{$title}' ({$value}) to {$scope}:{$scopeId} at position {$pos}."; + } catch (\Throwable $e) { + $errors++; + $log[] = "Error adding '{$value}': " . $e->getMessage(); + Log::error('Protabs sync error: ' . $e->getMessage()); + } + } + + return response()->json([ + 'success' => $errors === 0, + 'message' => "Protabs sync: {$inserted} added, {$skipped} skipped, {$errors} errors.", + 'inserted' => $inserted, + 'skipped' => $skipped, + 'errors' => $errors, + 'log' => $log, + ]); + } + + /** + * Show the Additional tools page + */ + public function index() + { + $frontpageTabsInfo = $this->getFrontpageTabsAttributeInfo(); + return view('additional.index', [ + 'frontpageTabsInfo' => $frontpageTabsInfo, + ]); + } + + /** + * Get info about the frontpage tabs attribute in M1 and M2 (for display / discovery) + */ + protected function getFrontpageTabsAttributeInfo() + { + $info = ['m1' => null, 'm2' => null, 'suggested_codes' => []]; + try { + $entityTypeIdM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + $entityTypeIdM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + if (!$entityTypeIdM1 || !$entityTypeIdM2) { + return $info; + } + + foreach (['frontpage_tabs', 'front_tabs', 'product_tabs'] as $code) { + $m1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM1) + ->where('attribute_code', $code) + ->select('attribute_id', 'attribute_code', 'backend_type', 'frontend_label') + ->first(); + $m2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM2) + ->where('attribute_code', $code) + ->select('attribute_id', 'attribute_code', 'backend_type', 'frontend_label') + ->first(); + if ($m1) { + $info['m1'] = $m1; + $info['m2'] = $m2; + break; + } + if ($m1 || $m2) { + $info['suggested_codes'][] = $code; + } + } + + if (!$info['m1']) { + $suggested = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM1) + ->where(function ($q) { + $q->where('attribute_code', 'like', '%frontpage%') + ->orWhere('attribute_code', 'like', '%front%tab%') + ->orWhere('attribute_code', 'like', '%tab%'); + }) + ->pluck('attribute_code') + ->toArray(); + $info['suggested_codes'] = array_unique(array_merge($info['suggested_codes'], $suggested)); + } + } catch (\Throwable $e) { + Log::warning('Frontpage tabs attribute info: ' . $e->getMessage()); + } + return $info; + } + + /** + * Return diagnostic info for an attribute code (M1/M2 existence, backend_type, value row counts) + */ + public function attributeDiagnostic(Request $request) + { + $attributeCode = trim((string) $request->input('attribute_code', '')) ?: 'frontpage_tabs'; + try { + $entityTypeIdM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + $entityTypeIdM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + if (!$entityTypeIdM1 || !$entityTypeIdM2) { + return response()->json(['success' => false, 'message' => 'Could not resolve entity type.', 'm1' => null, 'm2' => null]); + } + $attrM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM1) + ->where('attribute_code', $attributeCode) + ->select('attribute_id', 'attribute_code', 'backend_type', 'frontend_input', 'frontend_label') + ->first(); + $attrM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM2) + ->where('attribute_code', $attributeCode) + ->select('attribute_id', 'attribute_code', 'backend_type', 'frontend_input', 'frontend_label') + ->first(); + $diagnostic = ['attribute_code' => $attributeCode, 'm1' => null, 'm2' => null]; + if ($attrM1) { + $diagnostic['m1'] = (array) $attrM1; + $bt = $attrM1->backend_type ?? 'varchar'; + $tables = ['varchar', 'text', 'int', 'decimal', 'datetime']; + $diagnostic['m1']['value_counts_by_table'] = []; + foreach ($tables as $t) { + try { + $c = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity_' . $t) + ->where('attribute_id', $attrM1->attribute_id) + ->count(); + $diagnostic['m1']['value_counts_by_table'][$t] = $c; + } catch (\Throwable $e) { + $diagnostic['m1']['value_counts_by_table'][$t] = 'error'; + } + } + } + if ($attrM2) { + $diagnostic['m2'] = (array) $attrM2; + } + return response()->json(['success' => true, 'diagnostic' => $diagnostic]); + } catch (\Throwable $e) { + Log::warning('Attribute diagnostic: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => $e->getMessage(), 'diagnostic' => null], 500); + } + } + + /** + * Sync frontpage tabs from Magento 1 to Magento 2 by SKU + * Scans all M1 products by SKU and ensures M2 products have the same frontpage tabs value(s) + */ + public function syncFrontpageTabs(Request $request) + { + $attributeCode = $request->input('attribute_code', $this->frontpageTabsAttributeCode); + $dryRun = $request->boolean('dry_run', false); + + try { + $entityTypeIdM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + $entityTypeIdM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_entity_type') + ->where('entity_type_code', 'catalog_product') + ->value('entity_type_id'); + if (!$entityTypeIdM1 || !$entityTypeIdM2) { + return response()->json([ + 'success' => false, + 'message' => 'Could not resolve catalog_product entity type in M1 or M2.', + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + ], 400); + } + + $attrM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM1) + ->where('attribute_code', $attributeCode) + ->first(); + if (!$attrM1) { + return response()->json([ + 'success' => false, + 'message' => "Attribute '{$attributeCode}' not found in Magento 1.", + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + ], 400); + } + + $attrM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM2) + ->where('attribute_code', $attributeCode) + ->first(); + if (!$attrM2) { + return response()->json([ + 'success' => false, + 'message' => "Attribute '{$attributeCode}' not found in Magento 2. Create the attribute in M2 first.", + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + ], 400); + } + + $backendType = $attrM1->backend_type ?? 'varchar'; + if (!in_array($backendType, ['varchar', 'text', 'int', 'decimal', 'datetime'], true)) { + $backendType = 'varchar'; + } + $backendTypesToTry = ['varchar', 'text', 'int', 'decimal', 'datetime']; + $m1ValueTable = null; + $m2ValueTable = null; + $resolvedBackendType = null; + foreach ($backendTypesToTry as $bt) { + $t1 = $this->magento1Prefix . 'catalog_product_entity_' . $bt; + $count = 0; + try { + $count = DB::connection($this->magento1Connection) + ->table($t1) + ->where('attribute_id', $attrM1->attribute_id) + ->count(); + } catch (\Throwable $e) { + continue; + } + if ($count > 0) { + $resolvedBackendType = $bt; + $m1ValueTable = $t1; + $m2ValueTable = $this->magento2Prefix . 'catalog_product_entity_' . $bt; + break; + } + } + if ($m1ValueTable === null) { + $m1ValueTable = $this->magento1Prefix . 'catalog_product_entity_' . $backendType; + $m2ValueTable = $this->magento2Prefix . 'catalog_product_entity_' . $backendType; + $resolvedBackendType = $backendType; + } + + $m1ValueRowCount = 0; + try { + $m1ValueRowCount = DB::connection($this->magento1Connection) + ->table($m1ValueTable) + ->where('attribute_id', $attrM1->attribute_id) + ->count(); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'message' => "Magento 1 value table for attribute '{$attributeCode}' (backend_type {$resolvedBackendType}) not found or error: " . $e->getMessage(), + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + 'diagnostic' => ['backend_type' => $resolvedBackendType, 'm1_value_table' => $m1ValueTable], + ], 400); + } + if ($m1ValueRowCount === 0) { + return response()->json([ + 'success' => false, + 'message' => "No values found in Magento 1 for attribute '{$attributeCode}'. No products have this attribute set in M1. Check that the attribute is assigned to product attribute sets and that products have values saved.", + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + 'diagnostic' => [ + 'backend_type' => $resolvedBackendType, + 'm1_value_table' => $m1ValueTable, + 'm1_value_row_count' => 0, + ], + ], 400); + } + + $skuAttrIdM1 = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM1) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + $skuAttrIdM2 = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'eav_attribute') + ->where('entity_type_id', $entityTypeIdM2) + ->where('attribute_code', 'sku') + ->value('attribute_id'); + if (!$skuAttrIdM1 || !$skuAttrIdM2) { + return response()->json([ + 'success' => false, + 'message' => 'Could not resolve SKU attribute in M1 or M2.', + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + ], 400); + } + + $m1ProductsWithTabs = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity as e') + ->leftJoin($this->magento1Prefix . 'catalog_product_entity_varchar as sku_v', function ($join) use ($skuAttrIdM1) { + $join->on('e.entity_id', '=', 'sku_v.entity_id') + ->where('sku_v.attribute_id', '=', $skuAttrIdM1); + }) + ->join($m1ValueTable . ' as vt', function ($join) use ($attrM1) { + $join->on('e.entity_id', '=', 'vt.entity_id') + ->where('vt.attribute_id', '=', $attrM1->attribute_id); + }) + ->where(function ($q) { + $q->whereNotNull('sku_v.value') + ->orWhereNotNull('e.sku'); + }) + ->selectRaw('e.entity_id as m1_entity_id, TRIM(COALESCE(sku_v.value, e.sku)) as sku, vt.store_id, vt.value') + ->get(); + + if ($m1ProductsWithTabs->isEmpty()) { + return response()->json([ + 'success' => false, + 'message' => "Attribute '{$attributeCode}' has {$m1ValueRowCount} value row(s) in M1, but no rows matched when joining with products (by SKU). Possible causes: SKU is stored in a different store_id in M1, or the value table does not match the attribute's backend_type.", + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + 'diagnostic' => [ + 'backend_type' => $resolvedBackendType, + 'm1_value_table' => $m1ValueTable, + 'm1_value_row_count' => $m1ValueRowCount, + ], + ], 400); + } + + $m2SkuToEntityId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity as e') + ->leftJoin($this->magento2Prefix . 'catalog_product_entity_varchar as sku_v', function ($join) use ($skuAttrIdM2) { + $join->on('e.entity_id', '=', 'sku_v.entity_id') + ->where('sku_v.attribute_id', '=', $skuAttrIdM2); + }) + ->where(function ($q) { + $q->whereNotNull('sku_v.value') + ->orWhereNotNull('e.sku'); + }) + ->selectRaw('e.entity_id, TRIM(COALESCE(sku_v.value, e.sku)) as sku') + ->pluck('e.entity_id', 'sku'); + + $log = []; + $updated = 0; + $skipped = 0; + $skippedNoM2 = 0; + $skippedAlreadyMatch = 0; + $errors = 0; + + foreach ($m1ProductsWithTabs as $row) { + $sku = trim((string) $row->sku); + $m2EntityId = $m2SkuToEntityId->get($sku); + if ($m2EntityId === null) { + $skipped++; + $skippedNoM2++; + $log[] = "SKU {$sku}: no M2 product, skipped."; + continue; + } + $existing = DB::connection($this->magento2Connection) + ->table($m2ValueTable) + ->where('entity_id', $m2EntityId) + ->where('attribute_id', $attrM2->attribute_id) + ->where('store_id', $row->store_id) + ->first(); + if ($existing && (string) $existing->value === (string) $row->value) { + $skipped++; + $skippedAlreadyMatch++; + $log[] = "SKU {$sku} (store {$row->store_id}): M2 value already matches M1, skipped."; + continue; + } + if ($dryRun) { + $updated++; + $log[] = "SKU {$sku} (store {$row->store_id}): would set value."; + continue; + } + try { + if ($existing) { + DB::connection($this->magento2Connection) + ->table($m2ValueTable) + ->where('entity_id', $m2EntityId) + ->where('attribute_id', $attrM2->attribute_id) + ->where('store_id', $row->store_id) + ->update(['value' => $row->value]); + } else { + DB::connection($this->magento2Connection) + ->table($m2ValueTable) + ->insert([ + 'attribute_id' => $attrM2->attribute_id, + 'store_id' => $row->store_id, + 'entity_id' => $m2EntityId, + 'value' => $row->value, + ]); + } + $updated++; + $log[] = "SKU {$sku} (store {$row->store_id}): synced."; + } catch (\Throwable $e) { + $errors++; + $log[] = "SKU {$sku}: error - " . $e->getMessage(); + Log::error("Sync frontpage tabs SKU {$sku}: " . $e->getMessage()); + } + } + + $m1SampleSkus = $m1ProductsWithTabs->take(5)->pluck('sku')->unique()->values()->toArray(); + $diagnostic = [ + 'backend_type' => $resolvedBackendType, + 'm1_value_row_count' => $m1ValueRowCount, + 'm1_products_with_value' => $m1ProductsWithTabs->count(), + 'skipped_no_m2_product' => $skippedNoM2, + 'skipped_already_match' => $skippedAlreadyMatch, + 'm1_sample_skus' => $m1SampleSkus, + ]; + return response()->json([ + 'success' => true, + 'message' => $dryRun + ? "Dry run: {$updated} product/store rows would be updated, {$skipped} skipped." + : "Synced frontpage tabs: {$updated} updated, {$skipped} skipped, {$errors} errors.", + 'updated' => $updated, + 'skipped' => $skipped, + 'errors' => $errors, + 'log' => array_slice($log, -100), + 'diagnostic' => $diagnostic, + ]); + + } catch (\Throwable $e) { + Log::error('Sync frontpage tabs error: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Sync failed: ' . $e->getMessage(), + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + 'log' => [], + ], 500); + } + } +} diff --git a/app/Http/Controllers/ProductUrlsController.php b/app/Http/Controllers/ProductUrlsController.php new file mode 100644 index 0000000..8192e83 --- /dev/null +++ b/app/Http/Controllers/ProductUrlsController.php @@ -0,0 +1,616 @@ +magento1Prefix = config('database.connections.magento1.prefix', ''); + $this->magento2Prefix = config('database.connections.magento2.prefix', ''); + } + + /** + * Show the product URLs comparison page + */ + public function index() + { + return view('product-urls.index'); + } + + /** + * Get product URLs from Magento 1 + */ + public function getMagento1Urls(Request $request) + { + try { + $sku = $request->input('sku'); + $limit = $request->input('limit', 100); + $offset = $request->input('offset', 0); + + $query = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_url_rewrite as ur') + ->join($this->magento1Prefix . 'catalog_product_entity as p', function($join) { + // Match exactly `product/{id}` or `product/{id}/...` so we don't + // false-match product 1 against product 10/100/1000/etc. + $join->whereRaw("(ur.id_path = CONCAT('product/', p.entity_id) OR ur.id_path LIKE CONCAT('product/', p.entity_id, '/%'))"); + }) + ->select( + 'p.entity_id', + 'p.sku', + 'ur.request_path', + 'ur.target_path', + 'ur.store_id', + 'ur.id_path' + ) + ->where('ur.id_path', 'like', 'product/%'); + + if ($sku) { + $query->where('p.sku', 'like', '%' . $sku . '%'); + } + + $total = $query->count(); + $urls = $query->orderBy('p.entity_id') + ->orderBy('ur.store_id') + ->limit($limit) + ->offset($offset) + ->get(); + + return response()->json([ + 'success' => true, + 'urls' => $urls, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset + ]); + + } catch (\Exception $e) { + Log::error('Error fetching Magento 1 URLs: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch Magento 1 URLs: ' . $e->getMessage(), + 'urls' => [], + 'total' => 0 + ], 500); + } + } + + /** + * Get product URLs from Magento 2 + */ + public function getMagento2Urls(Request $request) + { + try { + $sku = $request->input('sku'); + $limit = $request->input('limit', 100); + $offset = $request->input('offset', 0); + + $query = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite as ur') + ->join($this->magento2Prefix . 'catalog_product_entity as p', function($join) { + $join->whereRaw("ur.entity_id = p.entity_id AND ur.entity_type = 'product'"); + }) + ->select( + 'p.entity_id', + 'p.sku', + 'ur.request_path', + 'ur.target_path', + 'ur.store_id', + 'ur.entity_id as url_entity_id' + ) + ->where('ur.entity_type', 'product'); + + if ($sku) { + $query->where('p.sku', 'like', '%' . $sku . '%'); + } + + $total = $query->count(); + $urls = $query->orderBy('p.entity_id') + ->orderBy('ur.store_id') + ->limit($limit) + ->offset($offset) + ->get(); + + return response()->json([ + 'success' => true, + 'urls' => $urls, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset + ]); + + } catch (\Exception $e) { + Log::error('Error fetching Magento 2 URLs: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch Magento 2 URLs: ' . $e->getMessage(), + 'urls' => [], + 'total' => 0 + ], 500); + } + } + + /** + * Compare product URLs between Magento 1 and Magento 2. + * + * A product can have multiple rewrites per store (canonical + one per category), + * so the comparison key is (sku, store_id, request_path). We restrict to catalog + * product URLs (target_path LIKE 'catalog/product/view/%') so this aligns with + * what the Fix tool migrates - non-catalog rewrites such as the productquestions + * module are deliberately excluded from migration and from comparison. + */ + public function compareUrls(Request $request) + { + try { + $sku = $request->input('sku'); + $limit = (int) $request->input('limit', 100); + $offset = (int) $request->input('offset', 0); + + $m1Query = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_url_rewrite as ur') + ->join($this->magento1Prefix . 'catalog_product_entity as p', function ($join) { + $join->whereRaw("(ur.id_path = CONCAT('product/', p.entity_id) OR ur.id_path LIKE CONCAT('product/', p.entity_id, '/%'))"); + }) + ->select( + 'p.entity_id as product_id', + 'p.sku', + 'ur.request_path', + 'ur.store_id' + ) + ->where('ur.target_path', 'like', 'catalog/product/view/%'); + if ($sku) { + $m1Query->where('p.sku', 'like', '%' . $sku . '%'); + } + $m1Rows = $m1Query->get(); + + $m2Query = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite as ur') + ->join($this->magento2Prefix . 'catalog_product_entity as p', function ($join) { + $join->whereRaw("ur.entity_id = p.entity_id AND ur.entity_type = 'product'"); + }) + ->select( + 'p.entity_id as product_id', + 'p.sku', + 'ur.request_path', + 'ur.store_id' + ) + ->where('ur.entity_type', 'product') + ->where('ur.target_path', 'like', 'catalog/product/view/%'); + if ($sku) { + $m2Query->where('p.sku', 'like', '%' . $sku . '%'); + } + $m2Rows = $m2Query->get(); + + $key = fn($r) => $r->sku . '|' . $r->store_id . '|' . $r->request_path; + $m1Map = []; + foreach ($m1Rows as $r) { $m1Map[$key($r)] = $r; } + $m2Map = []; + foreach ($m2Rows as $r) { $m2Map[$key($r)] = $r; } + + // sku -> product_id on each side (any row will do; same product per sku). + $m1Pid = []; + foreach ($m1Rows as $r) { $m1Pid[$r->sku] = $r->product_id; } + $m2Pid = []; + foreach ($m2Rows as $r) { $m2Pid[$r->sku] = $r->product_id; } + + $allKeys = array_keys($m1Map + $m2Map); + sort($allKeys); + + $comparison = []; + foreach ($allKeys as $k) { + $m1r = $m1Map[$k] ?? null; + $m2r = $m2Map[$k] ?? null; + $row = $m1r ?? $m2r; + + if ($m1r && $m2r) { $status = 'match'; } + elseif ($m1r) { $status = 'missing_in_m2'; } + else { $status = 'missing_in_m1'; } + + $comparison[] = [ + 'sku' => $row->sku, + 'm1_product_id' => $m1Pid[$row->sku] ?? null, + 'm2_product_id' => $m2Pid[$row->sku] ?? null, + 'store_id' => $row->store_id, + 'm1_url' => $m1r->request_path ?? null, + 'm2_url' => $m2r->request_path ?? null, + 'status' => $status, + ]; + } + + $total = count($comparison); + $paginated = array_slice($comparison, $offset, $limit); + + return response()->json([ + 'success' => true, + 'comparison' => $paginated, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + 'summary' => [ + 'match' => count(array_filter($comparison, fn($c) => $c['status'] === 'match')), + 'missing_in_m2' => count(array_filter($comparison, fn($c) => $c['status'] === 'missing_in_m2')), + 'missing_in_m1' => count(array_filter($comparison, fn($c) => $c['status'] === 'missing_in_m1')), + 'different' => 0, + ], + ]); + + } catch (\Exception $e) { + Log::error('Error comparing URLs: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to compare URLs: ' . $e->getMessage(), + 'comparison' => [], + 'total' => 0 + ], 500); + } + } + + /** + * Migrate / fix product URL rewrites from M1 -> M2. + * + * Reads M1 core_url_rewrite rows whose target_path is `catalog/product/view/...` + * (the canonical and category-bound product URLs) and inserts the corresponding + * row into M2 url_rewrite. Non-catalog rewrites such as the productquestions + * module are skipped, the M2 product is looked up by SKU so target_path uses + * the M2 entity_id, and category-bound URLs get metadata={"category_id":"X"}. + * + * Params: + * sku (optional) limit to a single product + * dry_run (optional) preview without inserting + */ + public function fixProductUrls(Request $request) + { + try { + $sku = $request->input('sku'); + $dryRun = filter_var($request->input('dry_run', false), FILTER_VALIDATE_BOOLEAN); + + $storeMapping = $this->getStoreMapping(); + + $query = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_url_rewrite as ur') + ->join($this->magento1Prefix . 'catalog_product_entity as p', function ($join) { + $join->whereRaw("(ur.id_path = CONCAT('product/', p.entity_id) OR ur.id_path LIKE CONCAT('product/', p.entity_id, '/%'))"); + }) + ->select( + 'p.entity_id as m1_product_id', + 'p.sku', + 'ur.url_rewrite_id', + 'ur.store_id', + 'ur.id_path', + 'ur.request_path', + 'ur.target_path', + 'ur.is_system', + 'ur.description' + ) + ->where('ur.target_path', 'like', 'catalog/product/view/%'); + + if (!empty($sku)) { + $query->where('p.sku', $sku); + } + + $m1Rewrites = $query->orderBy('p.entity_id')->orderBy('ur.store_id')->get(); + + $added = 0; + $skippedExisting = 0; + $skippedNoProduct = 0; + $errors = 0; + $log = []; + + $m2ProductCache = []; + + foreach ($m1Rewrites as $r) { + try { + $m1StoreId = (int) ($r->store_id ?? 0); + $m2StoreId = $storeMapping[$m1StoreId] ?? $m1StoreId; + + if (!isset($m2ProductCache[$r->sku])) { + $m2ProductCache[$r->sku] = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('sku', $r->sku) + ->value('entity_id'); + } + $m2ProductId = $m2ProductCache[$r->sku]; + + if (!$m2ProductId) { + $skippedNoProduct++; + $log[] = "SKIP no M2 product for sku '{$r->sku}' (M1 id={$r->m1_product_id}, request_path={$r->request_path})"; + continue; + } + + // M1 id_path is either `product/{id}` (canonical) or + // `product/{id}/{category_id}` (category-bound). + $idPathParts = explode('/', $r->id_path); + $categoryId = $idPathParts[2] ?? null; + + $targetPath = 'catalog/product/view/id/' . $m2ProductId; + $metadata = null; + if (!empty($categoryId) && ctype_digit((string) $categoryId)) { + $targetPath .= '/category/' . $categoryId; + $metadata = json_encode(['category_id' => (string) $categoryId]); + } + + // M2 unique key is (request_path, store_id), so check exactly that. + $exists = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite') + ->where('request_path', $r->request_path) + ->where('store_id', $m2StoreId) + ->exists(); + + if ($exists) { + $skippedExisting++; + continue; + } + + if ($dryRun) { + $added++; + $log[] = "WOULD ADD [{$r->sku}] store={$m2StoreId} {$r->request_path} -> {$targetPath}"; + continue; + } + + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite') + ->insert([ + 'entity_type' => 'product', + 'entity_id' => $m2ProductId, + 'request_path' => $r->request_path, + 'target_path' => $targetPath, + 'redirect_type' => 0, + 'store_id' => $m2StoreId, + 'description' => $r->description, + 'is_autogenerated' => ($r->is_system ?? 0) ? 1 : 0, + 'metadata' => $metadata, + ]); + $added++; + } catch (\Exception $e) { + $errors++; + $log[] = "ERROR [{$r->sku}] {$r->request_path}: " . $e->getMessage(); + Log::error("fixProductUrls error for sku '{$r->sku}', request_path '{$r->request_path}': " . $e->getMessage()); + } + } + + return response()->json([ + 'success' => true, + 'dry_run' => $dryRun, + 'sku' => $sku, + 'added' => $added, + 'skipped_existing' => $skippedExisting, + 'skipped_no_m2_product'=> $skippedNoProduct, + 'errors' => $errors, + 'total_m1_rewrites' => $m1Rewrites->count(), + 'log' => array_slice($log, 0, 500), + ]); + + } catch (\Exception $e) { + Log::error('fixProductUrls fatal: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Fix failed: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Map M1 store_id -> M2 store_id by store code (case-insensitive, trimmed, + * tolerant of M2's "_N" suffix that DDEV/Magento sometimes appends), with + * fall-through to identity mapping when codes don't line up. + */ + protected function getStoreMapping() + { + $m1Stores = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_store') + ->where('store_id', '>', 0) + ->get(); + + $m2Stores = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'store') + ->where('store_id', '>', 0) + ->get(); + + $m2ByCode = []; + $m2ById = []; + foreach ($m2Stores as $s) { + $m2ById[$s->store_id] = $s; + $code = strtolower(trim($s->code ?? '')); + if ($code !== '') { + $m2ByCode[$code] = $s->store_id; + // M2 sometimes carries a `_N` suffix on the code; index the bare prefix too. + $bare = preg_replace('/_\d+$/', '', $code); + if ($bare !== $code && !isset($m2ByCode[$bare])) { + $m2ByCode[$bare] = $s->store_id; + } + } + } + + $mapping = []; + foreach ($m1Stores as $m1) { + $code = strtolower(trim($m1->code ?? '')); + if ($code !== '' && isset($m2ByCode[$code])) { + $mapping[$m1->store_id] = $m2ByCode[$code]; + } elseif (isset($m2ById[$m1->store_id])) { + $mapping[$m1->store_id] = $m1->store_id; + } + } + return $mapping; + } + + /** + * Compare URLs for a single product SKU + */ + public function compareSingleSku(Request $request) + { + try { + $sku = $request->input('sku'); + + if (empty($sku)) { + return response()->json([ + 'success' => false, + 'message' => 'SKU is required', + 'comparison' => [] + ], 400); + } + + // Get M1 catalog product URLs for this SKU + $m1Urls = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'core_url_rewrite as ur') + ->join($this->magento1Prefix . 'catalog_product_entity as p', function($join) { + $join->whereRaw("(ur.id_path = CONCAT('product/', p.entity_id) OR ur.id_path LIKE CONCAT('product/', p.entity_id, '/%'))"); + }) + ->select( + 'p.entity_id as product_id', + 'p.sku', + 'ur.request_path as m1_url', + 'ur.target_path as m1_target_path', + 'ur.store_id as m1_store_id', + 'ur.id_path' + ) + ->where('ur.target_path', 'like', 'catalog/product/view/%') + ->where('p.sku', $sku) + ->get() + ->groupBy('m1_store_id'); + + // Get M2 catalog product URLs for this SKU + $m2Urls = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'url_rewrite as ur') + ->join($this->magento2Prefix . 'catalog_product_entity as p', function($join) { + $join->whereRaw("ur.entity_id = p.entity_id AND ur.entity_type = 'product'"); + }) + ->select( + 'p.entity_id as product_id', + 'p.sku', + 'ur.request_path as m2_url', + 'ur.target_path as m2_target_path', + 'ur.store_id as m2_store_id' + ) + ->where('ur.entity_type', 'product') + ->where('ur.target_path', 'like', 'catalog/product/view/%') + ->where('p.sku', $sku) + ->get() + ->groupBy('m2_store_id'); + + // Get product info + $m1Product = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'catalog_product_entity') + ->where('sku', $sku) + ->first(); + + $m2Product = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'catalog_product_entity') + ->where('sku', $sku) + ->first(); + + // Compare URLs by store - return ALL URLs for each store + $comparison = []; + $allStores = array_unique(array_merge( + $m1Urls->keys()->toArray(), + $m2Urls->keys()->toArray() + )); + + foreach ($allStores as $storeId) { + $m1StoreUrls = $m1Urls->get($storeId, collect([])); + $m2StoreUrls = $m2Urls->get($storeId, collect([])); + + // Get all M1 URLs for this store + $m1UrlsList = $m1StoreUrls->map(function($item) { + return [ + 'url' => $item->m1_url ?? null, + 'target_path' => $item->m1_target_path ?? null, + 'id_path' => $item->id_path ?? null + ]; + })->toArray(); + + // Get all M2 URLs for this store + $m2UrlsList = $m2StoreUrls->map(function($item) { + return [ + 'url' => $item->m2_url ?? null, + 'target_path' => $item->m2_target_path ?? null + ]; + })->toArray(); + + // Per-store status uses set semantics (URL order is not stable, so a + // positional compare would falsely flag rearranged but identical sets + // as 'different'). 'different' here means both sides have URLs but + // the sets aren't equal - i.e. some are missing on one side. + $m1UrlStrings = array_filter(array_column($m1UrlsList, 'url')); + $m2UrlStrings = array_filter(array_column($m2UrlsList, 'url')); + $m1Set = array_unique($m1UrlStrings); + $m2Set = array_unique($m2UrlStrings); + + if (empty($m1Set) && empty($m2Set)) { + $status = 'match'; + } elseif (!empty($m1Set) && empty($m2Set)) { + $status = 'missing_in_m2'; + } elseif (empty($m1Set) && !empty($m2Set)) { + $status = 'missing_in_m1'; + } else { + sort($m1Set); + sort($m2Set); + $status = ($m1Set === $m2Set) ? 'match' : 'different'; + } + + $comparison[] = [ + 'store_id' => $storeId, + 'm1_urls' => $m1UrlsList, + 'm2_urls' => $m2UrlsList, + 'status' => $status + ]; + } + + // Calculate summary statistics + $summary = [ + 'match' => 0, + 'missing_in_m2' => 0, + 'missing_in_m1' => 0, + 'different' => 0, + 'total_stores' => count($comparison), + 'total_m1_urls' => 0, + 'total_m2_urls' => 0 + ]; + + foreach ($comparison as $store) { + $summary['total_m1_urls'] += count($store['m1_urls']); + $summary['total_m2_urls'] += count($store['m2_urls']); + + switch ($store['status']) { + case 'match': + $summary['match']++; + break; + case 'missing_in_m2': + $summary['missing_in_m2']++; + break; + case 'missing_in_m1': + $summary['missing_in_m1']++; + break; + case 'different': + $summary['different']++; + break; + } + } + + return response()->json([ + 'success' => true, + 'sku' => $sku, + 'm1_product_id' => $m1Product->entity_id ?? null, + 'm2_product_id' => $m2Product->entity_id ?? null, + 'comparison' => $comparison, + 'summary' => $summary + ]); + + } catch (\Exception $e) { + Log::error('Error comparing single SKU URLs: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to compare URLs: ' . $e->getMessage(), + 'comparison' => [] + ], 500); + } + } +} diff --git a/app/Services/MagentoCategoryMigrationService.php b/app/Services/MagentoCategoryMigrationService.php index 1d806ac..df8586e 100644 --- a/app/Services/MagentoCategoryMigrationService.php +++ b/app/Services/MagentoCategoryMigrationService.php @@ -18,6 +18,7 @@ class MagentoCategoryMigrationService protected $migrationLog = []; protected $addedCount = 0; protected $existingCount = 0; + protected $columnMapCache = []; public function __construct() { @@ -6705,7 +6706,141 @@ public function getM2OrdersNotInM1() } /** - * Migrate all orders from Magento 1 to Magento 2 + * Build an M1 -> M2 column map for a table pair, using the columns that exist + * in both schemas plus a fixed list of known M1 -> M2 renames (e.g. M1's + * hidden_tax_* columns became M2's discount_tax_compensation_* columns). + */ + protected function getMappedColumns($m1Table, $m2Table) + { + $cacheKey = $m1Table . '=>' . $m2Table; + if (isset($this->columnMapCache[$cacheKey])) { + return $this->columnMapCache[$cacheKey]; + } + + $m1Cols = DB::connection($this->magento1Connection) + ->getSchemaBuilder() + ->getColumnListing($this->magento1Prefix . $m1Table); + $m2Cols = DB::connection($this->magento2Connection) + ->getSchemaBuilder() + ->getColumnListing($this->magento2Prefix . $m2Table); + $m2ColSet = array_flip($m2Cols); + + $renames = [ + 'hidden_tax_amount' => 'discount_tax_compensation_amount', + 'base_hidden_tax_amount' => 'base_discount_tax_compensation_amount', + 'shipping_hidden_tax_amount' => 'shipping_discount_tax_compensation_amount', + 'base_shipping_hidden_tax_amnt' => 'base_shipping_discount_tax_compensation_amnt', + 'hidden_tax_invoiced' => 'discount_tax_compensation_invoiced', + 'base_hidden_tax_invoiced' => 'base_discount_tax_compensation_invoiced', + 'hidden_tax_refunded' => 'discount_tax_compensation_refunded', + 'base_hidden_tax_refunded' => 'base_discount_tax_compensation_refunded', + 'hidden_tax_canceled' => 'discount_tax_compensation_canceled', + 'cc_last4' => 'cc_last_4', + ]; + + $map = []; + foreach ($m1Cols as $col) { + if (isset($m2ColSet[$col])) { + $map[$col] = $col; + } elseif (isset($renames[$col]) && isset($m2ColSet[$renames[$col]])) { + $map[$col] = $renames[$col]; + } + } + + return $this->columnMapCache[$cacheKey] = $map; + } + + /** + * Project an M1 row object onto its M2 column names using the supplied map. + */ + protected function buildMappedRow($m1Row, array $colMap) + { + $out = []; + foreach ($colMap as $m1Col => $m2Col) { + if (property_exists($m1Row, $m1Col)) { + $out[$m2Col] = $m1Row->$m1Col; + } + } + return $out; + } + + /** + * Populate sales_order_grid for the given M2 order id. Magento normally fills + * this via the order_grid indexer; we write to it directly so the order shows + * up in the admin grid without needing bin/magento indexer:reindex. + */ + protected function populateOrderGrid($m2OrderId) + { + $conn = DB::connection($this->magento2Connection); + + $order = $conn->table($this->magento2Prefix . 'sales_order') + ->where('entity_id', $m2OrderId)->first(); + if (!$order) { + return; + } + + $billing = $conn->table($this->magento2Prefix . 'sales_order_address') + ->where('parent_id', $m2OrderId) + ->where('address_type', 'billing') + ->first(); + $shipping = $conn->table($this->magento2Prefix . 'sales_order_address') + ->where('parent_id', $m2OrderId) + ->where('address_type', 'shipping') + ->first(); + $payment = $conn->table($this->magento2Prefix . 'sales_order_payment') + ->where('parent_id', $m2OrderId) + ->first(); + + $name = function ($a) { + if (!$a) return null; + return trim(($a->firstname ?? '') . ' ' . ($a->lastname ?? '')); + }; + $address = function ($a) { + if (!$a) return null; + return trim(implode(', ', array_filter([ + $a->street ?? null, + $a->city ?? null, + $a->region ?? null, + $a->postcode ?? null, + $a->country_id ?? null, + ]))); + }; + + $conn->table($this->magento2Prefix . 'sales_order_grid') + ->updateOrInsert( + ['entity_id' => $m2OrderId], + [ + 'status' => $order->status, + 'store_id' => $order->store_id, + 'store_name' => $order->store_name, + 'customer_id' => $order->customer_id, + 'base_grand_total' => $order->base_grand_total, + 'base_total_paid' => $order->base_total_paid, + 'grand_total' => $order->grand_total, + 'total_paid' => $order->total_paid, + 'increment_id' => $order->increment_id, + 'base_currency_code' => $order->base_currency_code, + 'order_currency_code' => $order->order_currency_code, + 'shipping_name' => $name($shipping), + 'billing_name' => $name($billing), + 'created_at' => $order->created_at, + 'updated_at' => $order->updated_at, + 'billing_address' => $address($billing), + 'shipping_address' => $address($shipping), + 'shipping_information' => $order->shipping_description, + 'customer_email' => $order->customer_email, + 'subtotal' => $order->subtotal, + 'shipping_and_handling' => $order->shipping_amount, + 'customer_name' => trim(($order->customer_firstname ?? '') . ' ' . ($order->customer_lastname ?? '')), + 'payment_method' => $payment->method ?? null, + 'total_refunded' => $order->total_refunded, + ] + ); + } + + /** + * Migrate all orders from Magento 1 to Magento 2 (entity + items + addresses + * + payment + status history + grid). */ public function migrateOrders($dryRun = false, $progressKey = null) { @@ -6714,12 +6849,21 @@ public function migrateOrders($dryRun = false, $progressKey = null) $addedCount = 0; $updatedCount = 0; $errorCount = 0; + $currentIndex = 0; + $totalOrders = 0; - // Get all M1 orders - $m1Orders = $this->getMagento1Orders(); + $orderColMap = $this->getMappedColumns('sales_flat_order', 'sales_order'); + $itemColMap = $this->getMappedColumns('sales_flat_order_item', 'sales_order_item'); + $addressColMap = $this->getMappedColumns('sales_flat_order_address', 'sales_order_address'); + $paymentColMap = $this->getMappedColumns('sales_flat_order_payment', 'sales_order_payment'); + $historyColMap = $this->getMappedColumns('sales_flat_order_status_history', 'sales_order_status_history'); + + $m1Orders = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'sales_flat_order') + ->orderBy('entity_id') + ->get(); $totalOrders = $m1Orders->count(); - - // Initialize progress tracking + if ($progressKey && !$dryRun) { Cache::put($progressKey, [ 'total' => $totalOrders, @@ -6732,130 +6876,224 @@ public function migrateOrders($dryRun = false, $progressKey = null) ], 3600); } - if (!$dryRun) { - DB::connection($this->magento2Connection)->beginTransaction(); - } - - $currentIndex = 0; foreach ($m1Orders as $m1Order) { $currentIndex++; + $m1OrderId = $m1Order->entity_id; + $m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null; + try { - $m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null; - - // Update progress if tracking enabled if ($progressKey && !$dryRun) { Cache::put($progressKey, [ - 'total' => $totalOrders, - 'current' => $currentIndex, - 'added' => $addedCount, - 'updated' => $updatedCount, - 'errors' => $errorCount, - 'status' => 'running', - 'current_increment_id' => $m1IncrementId ?? 'N/A' + 'total' => $totalOrders, + 'current' => $currentIndex, + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'status' => 'running', + 'current_increment_id' => $m1IncrementId ?? 'N/A', ], 3600); } - + if (empty($m1IncrementId)) { - $this->migrationLog[] = "SKIPPED: Order ID {$m1Order->entity_id} - no increment_id"; + $this->migrationLog[] = "SKIPPED: M1 order ID {$m1OrderId} - no increment_id"; continue; } - // Check if order exists in M2 by increment_id - $m2Order = DB::connection($this->magento2Connection) + $m2Existing = DB::connection($this->magento2Connection) ->table($this->magento2Prefix . 'sales_order') ->where('increment_id', $m1IncrementId) ->first(); + $isNew = !$m2Existing; + $m2OrderId = $m2Existing->entity_id ?? null; - $m2OrderId = null; - $isNew = false; - - if ($m2Order) { - // Order exists, update - $m2OrderId = $m2Order->entity_id; - if (!$dryRun) { - // Update order data - DB::connection($this->magento2Connection) - ->table($this->magento2Prefix . 'sales_order') - ->where('entity_id', $m2OrderId) - ->update([ - 'customer_email' => $m1Order->customer_email ?? null, - 'status' => $m1Order->status ?? null, - 'grand_total' => $m1Order->grand_total ?? 0, - 'updated_at' => $m1Order->updated_at ?? now(), - ]); - $this->migrationLog[] = "Updating existing order: {$m1IncrementId} (ID: {$m2OrderId})"; - } else { - $this->migrationLog[] = "Would update existing order: {$m1IncrementId} (ID: {$m2OrderId})"; - } - $updatedCount++; - } else { - // Order doesn't exist, create - if (!$dryRun) { - // Insert order entity - $m2OrderId = DB::connection($this->magento2Connection) - ->table($this->magento2Prefix . 'sales_order') - ->insertGetId([ - 'increment_id' => $m1IncrementId, - 'customer_email' => $m1Order->customer_email ?? null, - 'status' => $m1Order->status ?? 'pending', - 'grand_total' => $m1Order->grand_total ?? 0, - 'created_at' => $m1Order->created_at ?? now(), - 'updated_at' => $m1Order->updated_at ?? now(), - ]); - $this->migrationLog[] = "Added new order: {$m1IncrementId} (ID: {$m2OrderId})"; - } else { - $this->migrationLog[] = "Would add new order: {$m1IncrementId}"; - $m2OrderId = 0; // Placeholder for dry run - } - $addedCount++; - $isNew = true; + if ($dryRun) { + $this->migrationLog[] = $isNew + ? "Would add new order: {$m1IncrementId}" + : "Would update existing order: {$m1IncrementId} (ID: {$m2OrderId})"; + $isNew ? $addedCount++ : $updatedCount++; + continue; } - // Note: Full order migration would also migrate: - // - Order items (sales_order_item) - // - Order addresses (sales_order_address) - // - Order payment (sales_order_payment) - // - Order status history (sales_order_status_history) - // This is a simplified version that only migrates the main order record + // Look up M2 customer by email - M1 customer ids do not match M2. + $m2CustomerId = null; + if (!empty($m1Order->customer_email)) { + $m2Customer = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'customer_entity') + ->where('email', $m1Order->customer_email) + ->first(); + $m2CustomerId = $m2Customer ? $m2Customer->entity_id : null; + } + + $orderData = $this->buildMappedRow($m1Order, $orderColMap); + unset( + $orderData['entity_id'], + $orderData['billing_address_id'], + $orderData['shipping_address_id'] + ); + $orderData['customer_id'] = $m2CustomerId; + + DB::connection($this->magento2Connection)->beginTransaction(); + + if ($isNew) { + $m2OrderId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order') + ->insertGetId($orderData); + $addedCount++; + } else { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order') + ->where('entity_id', $m2OrderId) + ->update($orderData); + + // Wipe related rows so the re-import is deterministic. + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_item') + ->where('order_id', $m2OrderId)->delete(); + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_address') + ->where('parent_id', $m2OrderId)->delete(); + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_payment') + ->where('parent_id', $m2OrderId)->delete(); + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_status_history') + ->where('parent_id', $m2OrderId)->delete(); + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_grid') + ->where('entity_id', $m2OrderId)->delete(); + $updatedCount++; + } + + // Addresses + $billingAddressId = null; + $shippingAddressId = null; + $m1Addresses = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'sales_flat_order_address') + ->where('parent_id', $m1OrderId) + ->get(); + foreach ($m1Addresses as $m1Addr) { + $addrData = $this->buildMappedRow($m1Addr, $addressColMap); + unset($addrData['entity_id']); + $addrData['parent_id'] = $m2OrderId; + $newAddrId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_address') + ->insertGetId($addrData); + if (($m1Addr->address_type ?? null) === 'billing') { + $billingAddressId = $newAddrId; + } elseif (($m1Addr->address_type ?? null) === 'shipping') { + $shippingAddressId = $newAddrId; + } + } + if ($billingAddressId || $shippingAddressId) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order') + ->where('entity_id', $m2OrderId) + ->update(array_filter([ + 'billing_address_id' => $billingAddressId, + 'shipping_address_id' => $shippingAddressId, + ])); + } + + // Items (two-pass so parent_item_id can be remapped to new ids) + $m1Items = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'sales_flat_order_item') + ->where('order_id', $m1OrderId) + ->get(); + $itemIdMap = []; + foreach ($m1Items as $m1Item) { + $itemData = $this->buildMappedRow($m1Item, $itemColMap); + unset($itemData['item_id']); + $itemData['order_id'] = $m2OrderId; + $itemData['parent_item_id'] = null; + $newItemId = DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_item') + ->insertGetId($itemData); + $itemIdMap[$m1Item->item_id] = $newItemId; + } + foreach ($m1Items as $m1Item) { + if (!empty($m1Item->parent_item_id) && isset($itemIdMap[$m1Item->parent_item_id])) { + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_item') + ->where('item_id', $itemIdMap[$m1Item->item_id]) + ->update(['parent_item_id' => $itemIdMap[$m1Item->parent_item_id]]); + } + } + + // Payment + $m1Payment = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'sales_flat_order_payment') + ->where('parent_id', $m1OrderId) + ->first(); + if ($m1Payment) { + $payData = $this->buildMappedRow($m1Payment, $paymentColMap); + unset($payData['entity_id']); + $payData['parent_id'] = $m2OrderId; + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_payment') + ->insert($payData); + } + + // Status history + $m1History = DB::connection($this->magento1Connection) + ->table($this->magento1Prefix . 'sales_flat_order_status_history') + ->where('parent_id', $m1OrderId) + ->get(); + foreach ($m1History as $m1Hist) { + $histData = $this->buildMappedRow($m1Hist, $historyColMap); + unset($histData['entity_id']); + $histData['parent_id'] = $m2OrderId; + if (empty($histData['entity_name'])) { + $histData['entity_name'] = 'order'; + } + DB::connection($this->magento2Connection) + ->table($this->magento2Prefix . 'sales_order_status_history') + ->insert($histData); + } + + // sales_order_grid (Magento normally fills this from the indexer; + // populate directly so the order is visible in the admin grid). + $this->populateOrderGrid($m2OrderId); + + DB::connection($this->magento2Connection)->commit(); + + $this->migrationLog[] = $isNew + ? "Added new order: {$m1IncrementId} (ID: {$m2OrderId})" + : "Updated existing order: {$m1IncrementId} (ID: {$m2OrderId})"; } catch (Exception $e) { + try { + DB::connection($this->magento2Connection)->rollBack(); + } catch (Exception $rollbackEx) { + // Ignore - no active transaction + } $errorCount++; - $m1IncrementId = $m1Order->increment_id ?? 'N/A'; - $this->migrationLog[] = "ERROR: Failed to migrate order {$m1IncrementId}: " . $e->getMessage(); - Log::error("Error migrating order {$m1IncrementId}: " . $e->getMessage()); + $this->migrationLog[] = "ERROR: Failed to migrate order " . ($m1IncrementId ?? 'N/A') . ": " . $e->getMessage(); + Log::error("Error migrating order " . ($m1IncrementId ?? 'N/A') . ": " . $e->getMessage()); } } - if (!$dryRun) { - DB::connection($this->magento2Connection)->commit(); - } - - // Update progress to completed if ($progressKey && !$dryRun) { Cache::put($progressKey, [ - 'total' => $totalOrders, - 'current' => $totalOrders, - 'added' => $addedCount, - 'updated' => $updatedCount, - 'errors' => $errorCount, - 'status' => 'completed', - 'current_increment_id' => '' + 'total' => $totalOrders, + 'current' => $totalOrders, + 'added' => $addedCount, + 'updated' => $updatedCount, + 'errors' => $errorCount, + 'status' => 'completed', + 'current_increment_id' => '', ], 3600); } return [ 'success' => true, 'message' => $dryRun ? 'Dry run completed' : 'Order migration completed', - 'added' => $addedCount, + 'added' => $addedCount, 'updated' => $updatedCount, - 'errors' => $errorCount, - 'log' => $this->migrationLog + 'errors' => $errorCount, + 'log' => $this->migrationLog, ]; } catch (Exception $e) { - if (!$dryRun) { - DB::connection($this->magento2Connection)->rollBack(); - } // Update progress to failed if ($progressKey && !$dryRun) { diff --git a/fix-catalog-product-relation.sed b/fix-catalog-product-relation.sed new file mode 100644 index 0000000..cae8d4f --- /dev/null +++ b/fix-catalog-product-relation.sed @@ -0,0 +1,4 @@ +# Fix catalog_product_relation: InnoDB does not support ROW_FORMAT=FIXED (errno 140). +# Use: sed -f fix-catalog-product-relation.sed dump.sql > dump-fixed.sql +# Or in-place: sed -i -f fix-catalog-product-relation.sed dump.sql +s/ROW_FORMAT=FIXED/ROW_FORMAT=DYNAMIC/g diff --git a/oldspas.tar.gz b/oldspas.tar.gz new file mode 100644 index 0000000..8b6ee82 Binary files /dev/null and b/oldspas.tar.gz differ diff --git a/resources/css/app.css b/resources/css/app.css index 8246030..87e2562 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,7 @@ @import './migration.css'; @import './attributes.css'; @import './products.css'; +@import './product-urls.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; diff --git a/resources/css/base.css b/resources/css/base.css index 916d14b..40db38d 100644 --- a/resources/css/base.css +++ b/resources/css/base.css @@ -8,7 +8,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #FF6B35 0%, #E54A0F 100%); min-height: 100vh; padding: 20px; } @@ -24,7 +24,7 @@ .container { } .header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #FF6B35 0%, #E54A0F 100%); color: white; padding: 30px; text-align: center; @@ -49,7 +49,7 @@ .section { padding: 20px; background: #f8f9fa; border-radius: 8px; - border-left: 4px solid #667eea; + border-left: 4px solid #E54A0F; } .section h2 { @@ -71,13 +71,13 @@ .btn { } .btn-primary { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #FF6B35 0%, #E54A0F 100%); color: white; } .btn-primary:hover { transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); + box-shadow: 0 5px 15px rgba(229, 74, 15, 0.4); } .btn-primary:disabled { @@ -135,7 +135,7 @@ .loading.active { .spinner { border: 4px solid #f3f3f3; - border-top: 4px solid #667eea; + border-top: 4px solid #E54A0F; border-radius: 50%; width: 40px; height: 40px; @@ -166,7 +166,7 @@ .stat-card { .stat-card .number { font-size: 2em; font-weight: bold; - color: #667eea; + color: #E54A0F; } .stat-card .label { @@ -232,8 +232,8 @@ .nav-link:hover { } .nav-link.active { - color: #667eea; - border-bottom-color: #667eea; + color: #E54A0F; + border-bottom-color: #E54A0F; background: white; } diff --git a/resources/css/product-urls.css b/resources/css/product-urls.css new file mode 100644 index 0000000..1115b5a --- /dev/null +++ b/resources/css/product-urls.css @@ -0,0 +1,46 @@ +/* Product URLs comparison page specific styles */ +.status-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 0.85em; + font-weight: 600; + text-transform: uppercase; +} + +.status-match { + background: #FF8C42; + color: white; +} + +.status-warning { + background: #FF6B35; + color: white; +} + +.status-info { + background: #FFA366; + color: white; +} + +.status-error { + background: #C43A0D; + color: white; +} + +#comparisonTable td, +#m1UrlsTable td, +#m2UrlsTable td { + word-break: break-word; + max-width: 300px; +} + +#comparisonTable td:nth-child(5), +#comparisonTable td:nth-child(6), +#m1UrlsTable td:nth-child(4), +#m1UrlsTable td:nth-child(5), +#m2UrlsTable td:nth-child(4), +#m2UrlsTable td:nth-child(5) { + font-family: monospace; + font-size: 0.8em; +} diff --git a/resources/js/product-urls.js b/resources/js/product-urls.js new file mode 100644 index 0000000..12886ea --- /dev/null +++ b/resources/js/product-urls.js @@ -0,0 +1,510 @@ +// Product URLs comparison functionality + +let currentPage = 0; +let currentM1Page = 0; +let currentM2Page = 0; +const pageSize = 100; +let currentComparisonData = []; +let currentM1Data = []; +let currentM2Data = []; +let currentFilter = 'all'; + +// Compare URLs between M1 and M2 +window.compareUrls = async function compareUrls() { + const sku = document.getElementById('skuFilter').value.trim(); + const btn = document.getElementById('compareUrlsBtn'); + + btn.disabled = true; + btn.textContent = 'Comparing...'; + + try { + const response = await fetch(window.productUrlsRoutes.compareUrls + '?sku=' + encodeURIComponent(sku) + '&limit=' + pageSize + '&offset=' + (currentPage * pageSize), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + } + }); + + const data = await response.json(); + + if (data.success) { + currentComparisonData = data.comparison; + displayComparison(data); + updateSummary(data.summary); + document.getElementById('comparisonSection').style.display = 'block'; + document.getElementById('summarySection').style.display = 'block'; + } else { + alert('Error: ' + data.message); + } + } catch (error) { + console.error('Error comparing URLs:', error); + alert('Failed to compare URLs: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Compare URLs'; + } +} + +// Display comparison results +function displayComparison(data) { + const tbody = document.getElementById('comparisonTableBody'); + tbody.innerHTML = ''; + + if (data.comparison.length === 0) { + tbody.innerHTML = 'No results found'; + return; + } + + data.comparison.forEach(item => { + const row = document.createElement('tr'); + const statusClass = getStatusClass(item.status); + const statusLabel = getStatusLabel(item.status); + + row.innerHTML = ` + ${escapeHtml(item.sku || 'N/A')} + ${item.m1_product_id || '-'} + ${item.m2_product_id || '-'} + ${item.store_id || '-'} + ${escapeHtml(item.m1_url || '-')} + ${escapeHtml(item.m2_url || '-')} + ${statusLabel} + `; + + tbody.appendChild(row); + }); + + // Update pagination + updatePagination(data.total, currentPage); + document.getElementById('resultsCount').textContent = `Showing ${data.comparison.length} of ${data.total} results`; +} + +// Update summary statistics +function updateSummary(summary) { + document.getElementById('summaryMatch').textContent = summary.match || 0; + document.getElementById('summaryMissingM2').textContent = summary.missing_in_m2 || 0; + document.getElementById('summaryMissingM1').textContent = summary.missing_in_m1 || 0; + document.getElementById('summaryDifferent').textContent = summary.different || 0; +} + +// Get status class for styling +function getStatusClass(status) { + switch(status) { + case 'match': return 'status-match'; + case 'missing_in_m2': return 'status-warning'; + case 'missing_in_m1': return 'status-info'; + case 'different': return 'status-error'; + default: return ''; + } +} + +// Get status label +function getStatusLabel(status) { + switch(status) { + case 'match': return 'Match'; + case 'missing_in_m2': return 'Missing in M2'; + case 'missing_in_m1': return 'Missing in M1'; + case 'different': return 'Different'; + default: return status; + } +} + +// Filter results by status +window.filterResults = function filterResults() { + const filter = document.getElementById('statusFilter').value; + currentFilter = filter; + + // Re-fetch with filter (or filter client-side) + // For now, we'll filter client-side if we have all data + // In a real implementation, you might want to pass filter to the server + compareUrls(); +} + +// Load Magento 1 URLs +window.loadM1Urls = async function loadM1Urls() { + const sku = document.getElementById('skuFilter').value.trim(); + const btn = document.getElementById('loadM1UrlsBtn'); + + btn.disabled = true; + btn.textContent = 'Loading...'; + + try { + const response = await fetch(window.productUrlsRoutes.getM1Urls + '?sku=' + encodeURIComponent(sku) + '&limit=' + pageSize + '&offset=' + (currentM1Page * pageSize), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + } + }); + + const data = await response.json(); + + if (data.success) { + currentM1Data = data; + displayM1Urls(data); + document.getElementById('m1UrlsSection').style.display = 'block'; + } else { + alert('Error: ' + data.message); + } + } catch (error) { + console.error('Error loading M1 URLs:', error); + alert('Failed to load M1 URLs: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Load M1 URLs'; + } +} + +// Display M1 URLs +function displayM1Urls(data) { + const tbody = document.getElementById('m1UrlsTableBody'); + tbody.innerHTML = ''; + + if (data.urls.length === 0) { + tbody.innerHTML = 'No URLs found'; + return; + } + + data.urls.forEach(item => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${item.entity_id || '-'} + ${escapeHtml(item.sku || 'N/A')} + ${item.store_id || '-'} + ${escapeHtml(item.request_path || '-')} + ${escapeHtml(item.target_path || '-')} + `; + tbody.appendChild(row); + }); + + updateM1Pagination(data.total, currentM1Page); +} + +// Load Magento 2 URLs +window.loadM2Urls = async function loadM2Urls() { + const sku = document.getElementById('skuFilter').value.trim(); + const btn = document.getElementById('loadM2UrlsBtn'); + + btn.disabled = true; + btn.textContent = 'Loading...'; + + try { + const response = await fetch(window.productUrlsRoutes.getM2Urls + '?sku=' + encodeURIComponent(sku) + '&limit=' + pageSize + '&offset=' + (currentM2Page * pageSize), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + } + }); + + const data = await response.json(); + + if (data.success) { + currentM2Data = data; + displayM2Urls(data); + document.getElementById('m2UrlsSection').style.display = 'block'; + } else { + alert('Error: ' + data.message); + } + } catch (error) { + console.error('Error loading M2 URLs:', error); + alert('Failed to load M2 URLs: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Load M2 URLs'; + } +} + +// Display M2 URLs +function displayM2Urls(data) { + const tbody = document.getElementById('m2UrlsTableBody'); + tbody.innerHTML = ''; + + if (data.urls.length === 0) { + tbody.innerHTML = 'No URLs found'; + return; + } + + data.urls.forEach(item => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${item.entity_id || '-'} + ${escapeHtml(item.sku || 'N/A')} + ${item.store_id || '-'} + ${escapeHtml(item.request_path || '-')} + ${escapeHtml(item.target_path || '-')} + `; + tbody.appendChild(row); + }); + + updateM2Pagination(data.total, currentM2Page); +} + +// Pagination functions +window.changePage = function changePage(direction) { + currentPage += direction; + if (currentPage < 0) currentPage = 0; + compareUrls(); +} + +window.changeM1Page = function changeM1Page(direction) { + currentM1Page += direction; + if (currentM1Page < 0) currentM1Page = 0; + loadM1Urls(); +} + +window.changeM2Page = function changeM2Page(direction) { + currentM2Page += direction; + if (currentM2Page < 0) currentM2Page = 0; + loadM2Urls(); +} + +function updatePagination(total, currentPage) { + const totalPages = Math.ceil(total / pageSize); + const pageInfo = document.getElementById('pageInfo'); + const prevBtn = document.getElementById('prevPageBtn'); + const nextBtn = document.getElementById('nextPageBtn'); + + pageInfo.textContent = `Page ${currentPage + 1} of ${totalPages}`; + prevBtn.disabled = currentPage === 0; + nextBtn.disabled = currentPage >= totalPages - 1; +} + +function updateM1Pagination(total, currentPage) { + const totalPages = Math.ceil(total / pageSize); + const pageInfo = document.getElementById('m1PageInfo'); + const prevBtn = document.getElementById('m1PrevPageBtn'); + const nextBtn = document.getElementById('m1NextPageBtn'); + + pageInfo.textContent = `Page ${currentPage + 1} of ${totalPages}`; + prevBtn.disabled = currentPage === 0; + nextBtn.disabled = currentPage >= totalPages - 1; +} + +function updateM2Pagination(total, currentPage) { + const totalPages = Math.ceil(total / pageSize); + const pageInfo = document.getElementById('m2PageInfo'); + const prevBtn = document.getElementById('m2PrevPageBtn'); + const nextBtn = document.getElementById('m2NextPageBtn'); + + pageInfo.textContent = `Page ${currentPage + 1} of ${totalPages}`; + prevBtn.disabled = currentPage === 0; + nextBtn.disabled = currentPage >= totalPages - 1; +} + +// Utility function to escape HTML +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Compare single product SKU +window.compareSingleSku = async function compareSingleSku() { + const sku = document.getElementById('singleSkuInput').value.trim(); + const btn = document.getElementById('compareSingleSkuBtn'); + const section = document.getElementById('singleProductComparisonSection'); + const content = document.getElementById('singleProductComparisonContent'); + + if (!sku) { + alert('Please enter a product SKU'); + return; + } + + btn.disabled = true; + btn.textContent = 'Comparing...'; + content.innerHTML = '
Loading...
'; + section.style.display = 'block'; + document.getElementById('singleSkuDisplay').textContent = sku; + + try { + const response = await fetch(window.productUrlsRoutes.compareSingleSku + '?sku=' + encodeURIComponent(sku), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content + } + }); + + const data = await response.json(); + + if (data.success) { + displaySingleProductComparison(data); + } else { + content.innerHTML = '
Error: ' + escapeHtml(data.message) + '
'; + } + } catch (error) { + console.error('Error comparing single SKU:', error); + content.innerHTML = '
Failed to compare URLs: ' + escapeHtml(error.message) + '
'; + } finally { + btn.disabled = false; + btn.textContent = 'Compare Product URLs'; + } +} + +// Display single product comparison results +function displaySingleProductComparison(data) { + const content = document.getElementById('singleProductComparisonContent'); + + if (!data.comparison || data.comparison.length === 0) { + content.innerHTML = '
No URLs found for this product SKU.
'; + return; + } + + let html = '
'; + html += '
'; + html += '
'; + html += '
' + (data.summary.match || 0) + '
'; + html += '
Matching Stores
'; + html += '
'; + html += '
' + (data.summary.missing_in_m2 || 0) + '
'; + html += '
Missing in M2
'; + html += '
'; + html += '
' + (data.summary.missing_in_m1 || 0) + '
'; + html += '
Missing in M1
'; + html += '
'; + html += '
' + (data.summary.different || 0) + '
'; + html += '
Different URLs
'; + html += '
'; + + html += '
'; + html += '
Product IDs: M1: ' + (data.m1_product_id || 'N/A') + ' | M2: ' + (data.m2_product_id || 'N/A') + '
'; + html += '
Total URLs: M1: ' + (data.summary.total_m1_urls || 0) + ' | M2: ' + (data.summary.total_m2_urls || 0) + ' | Stores: ' + (data.summary.total_stores || 0) + '
'; + html += '
'; + html += '
'; + + // Group by store + data.comparison.forEach(storeData => { + const statusClass = getStatusClass(storeData.status); + const statusLabel = getStatusLabel(storeData.status); + const storeId = storeData.store_id || 'Default'; + + html += '
'; + html += '
'; + html += '
Store ID: ' + storeId + '
'; + html += '
' + statusLabel + '
'; + html += '
'; + + html += '
'; + html += '
'; + + // Magento 1 URLs + html += '
'; + html += '

Magento 1 URLs (' + (storeData.m1_urls.length || 0) + ')

'; + if (storeData.m1_urls.length > 0) { + html += '
'; + storeData.m1_urls.forEach((urlData, index) => { + html += '
'; + html += '
URL ' + (index + 1) + ':
'; + html += '
' + escapeHtml(urlData.url || '-') + '
'; + if (urlData.target_path) { + html += '
Target: ' + escapeHtml(urlData.target_path) + '
'; + } + html += '
'; + }); + html += '
'; + } else { + html += '
No URLs found in Magento 1
'; + } + html += '
'; + + // Magento 2 URLs + html += '
'; + html += '

Magento 2 URLs (' + (storeData.m2_urls.length || 0) + ')

'; + if (storeData.m2_urls.length > 0) { + html += '
'; + storeData.m2_urls.forEach((urlData, index) => { + html += '
'; + html += '
URL ' + (index + 1) + ':
'; + html += '
' + escapeHtml(urlData.url || '-') + '
'; + if (urlData.target_path) { + html += '
Target: ' + escapeHtml(urlData.target_path) + '
'; + } + html += '
'; + }); + html += '
'; + } else { + html += '
No URLs found in Magento 2
'; + } + html += '
'; + + html += '
'; + html += '
'; + html += '
'; + }); + + content.innerHTML = html; +} + +// Allow Enter key to trigger search +document.addEventListener('DOMContentLoaded', function() { + const skuFilter = document.getElementById('skuFilter'); + if (skuFilter) { + skuFilter.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + compareUrls(); + } + }); + } + + const singleSkuInput = document.getElementById('singleSkuInput'); + if (singleSkuInput) { + singleSkuInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + compareSingleSku(); + } + }); + } +}); + +// Fix / migrate product URLs from M1 to M2 +window.fixProductUrls = async function fixProductUrls() { + const sku = document.getElementById('fixSkuInput').value.trim(); + const dryRun = document.getElementById('fixDryRunInput').checked; + const btn = document.getElementById('fixUrlsBtn'); + + if (!dryRun && !confirm('This will INSERT URL rewrites into Magento 2. Continue?')) { + return; + } + + btn.disabled = true; + btn.textContent = dryRun ? 'Previewing...' : 'Fixing...'; + + try { + const formData = new FormData(); + if (sku) formData.append('sku', sku); + formData.append('dry_run', dryRun ? '1' : '0'); + + const response = await fetch(window.productUrlsRoutes.fixUrls, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json' + }, + body: formData + }); + + const data = await response.json(); + if (!data.success) { + alert('Error: ' + (data.message || 'unknown')); + return; + } + + document.getElementById('fixAdded').textContent = data.added; + document.getElementById('fixAddedLabel').textContent = data.dry_run ? 'Would Add' : 'Added'; + document.getElementById('fixSkippedExisting').textContent = data.skipped_existing; + document.getElementById('fixSkippedNoProduct').textContent = data.skipped_no_m2_product; + document.getElementById('fixErrors').textContent = data.errors; + document.getElementById('fixLogOutput').textContent = + (data.log || []).join('\n') || + `(no log lines — ${data.total_m1_rewrites} M1 rewrites scanned)`; + document.getElementById('fixResultsSection').style.display = 'block'; + } catch (error) { + console.error('fixProductUrls error:', error); + alert('Failed: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Fix URLs'; + } +}; diff --git a/resources/views/additional/index.blade.php b/resources/views/additional/index.blade.php new file mode 100644 index 0000000..f9afb3a --- /dev/null +++ b/resources/views/additional/index.blade.php @@ -0,0 +1,358 @@ +@extends('layouts.app') + +@section('content') + +
+

Sync Frontpage Tabs

+
+

Sync product frontpage tabs from Magento 1 to Magento 2

+

Scans all products by SKU in the Magento 1 database and ensures Magento 2 products have the same frontpage tabs. Missing values are added to M2; existing values are updated to match M1.

+ + @if(isset($frontpageTabsInfo['m1']) && $frontpageTabsInfo['m1']) +

Detected attribute: {{ $frontpageTabsInfo['m1']->attribute_code }} (M1: backend_type {{ $frontpageTabsInfo['m1']->backend_type }})@if(isset($frontpageTabsInfo['m2']) && $frontpageTabsInfo['m2']) — M2 attribute present.@else — M2 attribute not found; create it in Magento 2 first.@endif

+ @elseif(!empty($frontpageTabsInfo['suggested_codes'])) +

No default frontpage-tabs attribute found. Try attribute code: {{ implode(', ', $frontpageTabsInfo['suggested_codes']) }}

+ @else +

Enter the product attribute code that stores frontpage tabs (e.g. frontpage_tabs).

+ @endif +

⚠️ This will modify your Magento 2 database. Use dry run first if unsure.

+
+ +
+
+ + +
+ + + +
+ + + + +
+ + +
+

Sync Frontend Tabs Configuration (MGS Protabs)

+
+

Find and add missing product tabs in Magento 2

+

Compares M1 product text attributes (those with actual product values) against M2's mgs_protabs configuration per website scope. Any attribute that has content in M1 but no corresponding Protabs entry in M2 is shown as missing.

+ +

⚠️ This will modify M2's mgs_protabs table. Flush the M2 cache after syncing.

+
+ +
+ +
+ + + + +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/partials/navigation.blade.php b/resources/views/partials/navigation.blade.php index 282107c..1cd88df 100644 --- a/resources/views/partials/navigation.blade.php +++ b/resources/views/partials/navigation.blade.php @@ -20,5 +20,11 @@ Orders + + Product URLs + + + Additional + diff --git a/resources/views/product-urls/index.blade.php b/resources/views/product-urls/index.blade.php new file mode 100644 index 0000000..ebaf901 --- /dev/null +++ b/resources/views/product-urls/index.blade.php @@ -0,0 +1,251 @@ +@extends('layouts.app') + +@section('content') + +
+

🔍 Compare Single Product URL

+
+

Compare URLs for a Specific Product

+

Enter a product SKU to compare its URLs between Magento 1 and Magento 2:

+
+ +
+
+ + +
+ +
+ + + +
+ + +
+

🔧 Fix / Migrate Product URLs

+
+

Migrate missing product URL rewrites from M1 to M2

+

For each M1 product URL whose target is catalog/product/view/... (canonical and category-bound URLs), this inserts the matching row into M2. Non-catalog rewrites (e.g. productquestions module URLs) are skipped, the M2 product is resolved by SKU, and category-bound URLs get metadata={"category_id":"X"}.

+

Existing M2 rewrites with the same (request_path, store_id) are left untouched.

+
+ +
+
+ + +
+ + +
+ + +
+ + +
+

🔗 Bulk Product URL Comparison

+
+

Compare Multiple Product URLs

+

Compare product URLs between Magento 1 and Magento 2 to identify differences:

+ +
+ + +
+
+ + +
+ + + +
+ + + + + + + + + + + + +
+@endsection + +@push('scripts') + @vite(['resources/js/product-urls.js']) + +@endpush diff --git a/routes/web.php b/routes/web.php index 005a7ce..7de07df 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,8 @@ use App\Http\Controllers\ProductsController; use App\Http\Controllers\CustomersController; use App\Http\Controllers\OrdersController; +use App\Http\Controllers\ProductUrlsController; +use App\Http\Controllers\AdditionalController; Route::get('/', function () { return redirect('/connections'); @@ -77,3 +79,22 @@ Route::get('/migration-progress', [OrdersController::class, 'getMigrationProgress'])->name('migration-progress'); Route::delete('/{orderId}', [OrdersController::class, 'deleteM2Order'])->name('delete-order'); }); + +// Product URLs routes +Route::prefix('product-urls')->name('product-urls.')->group(function () { + Route::get('/', [ProductUrlsController::class, 'index'])->name('index'); + Route::get('/compare', [ProductUrlsController::class, 'compareUrls'])->name('compare-urls'); + Route::get('/compare-single', [ProductUrlsController::class, 'compareSingleSku'])->name('compare-single-sku'); + Route::get('/m1-urls', [ProductUrlsController::class, 'getMagento1Urls'])->name('get-m1-urls'); + Route::get('/m2-urls', [ProductUrlsController::class, 'getMagento2Urls'])->name('get-m2-urls'); + Route::post('/fix', [ProductUrlsController::class, 'fixProductUrls'])->name('fix-urls'); +}); + +// Additional routes +Route::prefix('additional')->name('additional.')->group(function () { + Route::get('/', [AdditionalController::class, 'index'])->name('index'); + Route::get('/attribute-diagnostic', [AdditionalController::class, 'attributeDiagnostic'])->name('attribute-diagnostic'); + Route::post('/sync-frontpage-tabs', [AdditionalController::class, 'syncFrontpageTabs'])->name('sync-frontpage-tabs'); + Route::get('/compare-protabs', [AdditionalController::class, 'compareProtabs'])->name('compare-protabs'); + Route::post('/sync-protabs', [AdditionalController::class, 'syncProtabs'])->name('sync-protabs'); +}); diff --git a/spasmar.tar.gz b/spasmar.tar.gz new file mode 100644 index 0000000..be6c53d Binary files /dev/null and b/spasmar.tar.gz differ diff --git a/vite.config.js b/vite.config.js index e1d61f9..9b3c234 100644 --- a/vite.config.js +++ b/vite.config.js @@ -15,6 +15,7 @@ export default defineConfig({ 'resources/js/products.js', 'resources/js/customers.js', 'resources/js/orders.js', + 'resources/js/product-urls.js', ], refresh: true, }),