Added new music

This commit is contained in:
Chris Rosenau 2026-08-04 19:46:20 -06:00
parent fecc231e30
commit c75755aac9
16 changed files with 2787 additions and 103 deletions

6
.ddev/config.hooks.yaml Normal file
View File

@ -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;"

View File

@ -0,0 +1,626 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class AdditionalController extends Controller
{
protected $magento1Connection = 'magento1';
protected $magento2Connection = 'magento2';
protected $magento1Prefix;
protected $magento2Prefix;
/** Default attribute code for frontpage tabs (can be overridden in request) */
protected $frontpageTabsAttributeCode = 'frontpage_tabs';
public function __construct()
{
$this->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);
}
}
}

View File

@ -0,0 +1,616 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProductUrlsController extends Controller
{
protected $magento1Connection = 'magento1';
protected $magento2Connection = 'magento2';
protected $magento1Prefix;
protected $magento2Prefix;
public function __construct()
{
$this->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);
}
}
}

View File

@ -18,6 +18,7 @@ class MagentoCategoryMigrationService
protected $migrationLog = []; protected $migrationLog = [];
protected $addedCount = 0; protected $addedCount = 0;
protected $existingCount = 0; protected $existingCount = 0;
protected $columnMapCache = [];
public function __construct() 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) public function migrateOrders($dryRun = false, $progressKey = null)
{ {
@ -6714,12 +6849,21 @@ public function migrateOrders($dryRun = false, $progressKey = null)
$addedCount = 0; $addedCount = 0;
$updatedCount = 0; $updatedCount = 0;
$errorCount = 0; $errorCount = 0;
$currentIndex = 0;
$totalOrders = 0;
// Get all M1 orders $orderColMap = $this->getMappedColumns('sales_flat_order', 'sales_order');
$m1Orders = $this->getMagento1Orders(); $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(); $totalOrders = $m1Orders->count();
// Initialize progress tracking
if ($progressKey && !$dryRun) { if ($progressKey && !$dryRun) {
Cache::put($progressKey, [ Cache::put($progressKey, [
'total' => $totalOrders, 'total' => $totalOrders,
@ -6732,130 +6876,224 @@ public function migrateOrders($dryRun = false, $progressKey = null)
], 3600); ], 3600);
} }
if (!$dryRun) {
DB::connection($this->magento2Connection)->beginTransaction();
}
$currentIndex = 0;
foreach ($m1Orders as $m1Order) { foreach ($m1Orders as $m1Order) {
$currentIndex++; $currentIndex++;
try { $m1OrderId = $m1Order->entity_id;
$m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null; $m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null;
// Update progress if tracking enabled try {
if ($progressKey && !$dryRun) { if ($progressKey && !$dryRun) {
Cache::put($progressKey, [ Cache::put($progressKey, [
'total' => $totalOrders, 'total' => $totalOrders,
'current' => $currentIndex, 'current' => $currentIndex,
'added' => $addedCount, 'added' => $addedCount,
'updated' => $updatedCount, 'updated' => $updatedCount,
'errors' => $errorCount, 'errors' => $errorCount,
'status' => 'running', 'status' => 'running',
'current_increment_id' => $m1IncrementId ?? 'N/A' 'current_increment_id' => $m1IncrementId ?? 'N/A',
], 3600); ], 3600);
} }
if (empty($m1IncrementId)) { 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; continue;
} }
// Check if order exists in M2 by increment_id $m2Existing = DB::connection($this->magento2Connection)
$m2Order = DB::connection($this->magento2Connection)
->table($this->magento2Prefix . 'sales_order') ->table($this->magento2Prefix . 'sales_order')
->where('increment_id', $m1IncrementId) ->where('increment_id', $m1IncrementId)
->first(); ->first();
$isNew = !$m2Existing;
$m2OrderId = $m2Existing->entity_id ?? null;
$m2OrderId = null; if ($dryRun) {
$isNew = false; $this->migrationLog[] = $isNew
? "Would add new order: {$m1IncrementId}"
if ($m2Order) { : "Would update existing order: {$m1IncrementId} (ID: {$m2OrderId})";
// Order exists, update $isNew ? $addedCount++ : $updatedCount++;
$m2OrderId = $m2Order->entity_id; continue;
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;
} }
// Note: Full order migration would also migrate: // Look up M2 customer by email - M1 customer ids do not match M2.
// - Order items (sales_order_item) $m2CustomerId = null;
// - Order addresses (sales_order_address) if (!empty($m1Order->customer_email)) {
// - Order payment (sales_order_payment) $m2Customer = DB::connection($this->magento2Connection)
// - Order status history (sales_order_status_history) ->table($this->magento2Prefix . 'customer_entity')
// This is a simplified version that only migrates the main order record ->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) { } catch (Exception $e) {
try {
DB::connection($this->magento2Connection)->rollBack();
} catch (Exception $rollbackEx) {
// Ignore - no active transaction
}
$errorCount++; $errorCount++;
$m1IncrementId = $m1Order->increment_id ?? 'N/A'; $this->migrationLog[] = "ERROR: Failed to migrate order " . ($m1IncrementId ?? 'N/A') . ": " . $e->getMessage();
$this->migrationLog[] = "ERROR: Failed to migrate order {$m1IncrementId}: " . $e->getMessage(); Log::error("Error migrating order " . ($m1IncrementId ?? 'N/A') . ": " . $e->getMessage());
Log::error("Error migrating order {$m1IncrementId}: " . $e->getMessage());
} }
} }
if (!$dryRun) {
DB::connection($this->magento2Connection)->commit();
}
// Update progress to completed
if ($progressKey && !$dryRun) { if ($progressKey && !$dryRun) {
Cache::put($progressKey, [ Cache::put($progressKey, [
'total' => $totalOrders, 'total' => $totalOrders,
'current' => $totalOrders, 'current' => $totalOrders,
'added' => $addedCount, 'added' => $addedCount,
'updated' => $updatedCount, 'updated' => $updatedCount,
'errors' => $errorCount, 'errors' => $errorCount,
'status' => 'completed', 'status' => 'completed',
'current_increment_id' => '' 'current_increment_id' => '',
], 3600); ], 3600);
} }
return [ return [
'success' => true, 'success' => true,
'message' => $dryRun ? 'Dry run completed' : 'Order migration completed', 'message' => $dryRun ? 'Dry run completed' : 'Order migration completed',
'added' => $addedCount, 'added' => $addedCount,
'updated' => $updatedCount, 'updated' => $updatedCount,
'errors' => $errorCount, 'errors' => $errorCount,
'log' => $this->migrationLog 'log' => $this->migrationLog,
]; ];
} catch (Exception $e) { } catch (Exception $e) {
if (!$dryRun) {
DB::connection($this->magento2Connection)->rollBack();
}
// Update progress to failed // Update progress to failed
if ($progressKey && !$dryRun) { if ($progressKey && !$dryRun) {

View File

@ -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

BIN
oldspas.tar.gz Normal file

Binary file not shown.

View File

@ -5,6 +5,7 @@
@import './migration.css'; @import './migration.css';
@import './attributes.css'; @import './attributes.css';
@import './products.css'; @import './products.css';
@import './product-urls.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';

View File

@ -8,7 +8,7 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 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; min-height: 100vh;
padding: 20px; padding: 20px;
} }
@ -24,7 +24,7 @@ .container {
} }
.header { .header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #FF6B35 0%, #E54A0F 100%);
color: white; color: white;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
@ -49,7 +49,7 @@ .section {
padding: 20px; padding: 20px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 8px; border-radius: 8px;
border-left: 4px solid #667eea; border-left: 4px solid #E54A0F;
} }
.section h2 { .section h2 {
@ -71,13 +71,13 @@ .btn {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #FF6B35 0%, #E54A0F 100%);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-2px); 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 { .btn-primary:disabled {
@ -135,7 +135,7 @@ .loading.active {
.spinner { .spinner {
border: 4px solid #f3f3f3; border: 4px solid #f3f3f3;
border-top: 4px solid #667eea; border-top: 4px solid #E54A0F;
border-radius: 50%; border-radius: 50%;
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -166,7 +166,7 @@ .stat-card {
.stat-card .number { .stat-card .number {
font-size: 2em; font-size: 2em;
font-weight: bold; font-weight: bold;
color: #667eea; color: #E54A0F;
} }
.stat-card .label { .stat-card .label {
@ -232,8 +232,8 @@ .nav-link:hover {
} }
.nav-link.active { .nav-link.active {
color: #667eea; color: #E54A0F;
border-bottom-color: #667eea; border-bottom-color: #E54A0F;
background: white; background: white;
} }

View File

@ -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;
}

View File

@ -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 = '<tr><td colspan="7" style="text-align: center; color: #999; padding: 20px;">No results found</td></tr>';
return;
}
data.comparison.forEach(item => {
const row = document.createElement('tr');
const statusClass = getStatusClass(item.status);
const statusLabel = getStatusLabel(item.status);
row.innerHTML = `
<td>${escapeHtml(item.sku || 'N/A')}</td>
<td>${item.m1_product_id || '-'}</td>
<td>${item.m2_product_id || '-'}</td>
<td>${item.store_id || '-'}</td>
<td>${escapeHtml(item.m1_url || '-')}</td>
<td>${escapeHtml(item.m2_url || '-')}</td>
<td><span class="status-badge ${statusClass}">${statusLabel}</span></td>
`;
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 = '<tr><td colspan="5" style="text-align: center; color: #999; padding: 20px;">No URLs found</td></tr>';
return;
}
data.urls.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.entity_id || '-'}</td>
<td>${escapeHtml(item.sku || 'N/A')}</td>
<td>${item.store_id || '-'}</td>
<td>${escapeHtml(item.request_path || '-')}</td>
<td>${escapeHtml(item.target_path || '-')}</td>
`;
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 = '<tr><td colspan="5" style="text-align: center; color: #999; padding: 20px;">No URLs found</td></tr>';
return;
}
data.urls.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.entity_id || '-'}</td>
<td>${escapeHtml(item.sku || 'N/A')}</td>
<td>${item.store_id || '-'}</td>
<td>${escapeHtml(item.request_path || '-')}</td>
<td>${escapeHtml(item.target_path || '-')}</td>
`;
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 = '<div style="text-align: center; padding: 20px; color: #666;">Loading...</div>';
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 = '<div style="padding: 20px; color: #dc3545; background: #f8d7da; border-radius: 4px;">Error: ' + escapeHtml(data.message) + '</div>';
}
} catch (error) {
console.error('Error comparing single SKU:', error);
content.innerHTML = '<div style="padding: 20px; color: #dc3545; background: #f8d7da; border-radius: 4px;">Failed to compare URLs: ' + escapeHtml(error.message) + '</div>';
} 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 = '<div style="padding: 20px; color: #666; text-align: center;">No URLs found for this product SKU.</div>';
return;
}
let html = '<div style="margin-bottom: 20px;">';
html += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px;">';
html += '<div style="background: #FF8C42; padding: 15px; border-radius: 6px; color: white; text-align: center;">';
html += '<div style="font-size: 1.5em; font-weight: bold;">' + (data.summary.match || 0) + '</div>';
html += '<div>Matching Stores</div></div>';
html += '<div style="background: #FF6B35; padding: 15px; border-radius: 6px; color: white; text-align: center;">';
html += '<div style="font-size: 1.5em; font-weight: bold;">' + (data.summary.missing_in_m2 || 0) + '</div>';
html += '<div>Missing in M2</div></div>';
html += '<div style="background: #FFA366; padding: 15px; border-radius: 6px; color: white; text-align: center;">';
html += '<div style="font-size: 1.5em; font-weight: bold;">' + (data.summary.missing_in_m1 || 0) + '</div>';
html += '<div>Missing in M1</div></div>';
html += '<div style="background: #C43A0D; padding: 15px; border-radius: 6px; color: white; text-align: center;">';
html += '<div style="font-size: 1.5em; font-weight: bold;">' + (data.summary.different || 0) + '</div>';
html += '<div>Different URLs</div></div>';
html += '</div>';
html += '<div style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 6px;">';
html += '<div style="margin-bottom: 10px;"><strong>Product IDs:</strong> M1: ' + (data.m1_product_id || 'N/A') + ' | M2: ' + (data.m2_product_id || 'N/A') + '</div>';
html += '<div><strong>Total URLs:</strong> M1: ' + (data.summary.total_m1_urls || 0) + ' | M2: ' + (data.summary.total_m2_urls || 0) + ' | Stores: ' + (data.summary.total_stores || 0) + '</div>';
html += '</div>';
html += '</div>';
// Group by store
data.comparison.forEach(storeData => {
const statusClass = getStatusClass(storeData.status);
const statusLabel = getStatusLabel(storeData.status);
const storeId = storeData.store_id || 'Default';
html += '<div style="margin-top: 25px; border: 2px solid #ddd; border-radius: 8px; overflow: hidden;">';
html += '<div style="background: #f8f9fa; padding: 15px; border-bottom: 2px solid #ddd; display: flex; justify-content: space-between; align-items: center;">';
html += '<div><strong style="font-size: 1.1em;">Store ID: ' + storeId + '</strong></div>';
html += '<div><span class="status-badge ' + statusClass + '">' + statusLabel + '</span></div>';
html += '</div>';
html += '<div style="padding: 20px;">';
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">';
// Magento 1 URLs
html += '<div>';
html += '<h4 style="margin-bottom: 10px; color: #333; border-bottom: 2px solid #FF6B35; padding-bottom: 5px;">Magento 1 URLs (' + (storeData.m1_urls.length || 0) + ')</h4>';
if (storeData.m1_urls.length > 0) {
html += '<div style="background: #fff5f0; padding: 10px; border-radius: 4px; border-left: 3px solid #FF6B35;">';
storeData.m1_urls.forEach((urlData, index) => {
html += '<div style="margin-bottom: 10px; padding: 8px; background: white; border-radius: 4px; font-family: monospace; font-size: 0.9em;">';
html += '<div style="color: #333; margin-bottom: 3px;"><strong>URL ' + (index + 1) + ':</strong></div>';
html += '<div style="color: #E54A0F; word-break: break-all;">' + escapeHtml(urlData.url || '-') + '</div>';
if (urlData.target_path) {
html += '<div style="color: #666; font-size: 0.85em; margin-top: 3px;">Target: ' + escapeHtml(urlData.target_path) + '</div>';
}
html += '</div>';
});
html += '</div>';
} else {
html += '<div style="color: #999; font-style: italic; padding: 10px;">No URLs found in Magento 1</div>';
}
html += '</div>';
// Magento 2 URLs
html += '<div>';
html += '<h4 style="margin-bottom: 10px; color: #333; border-bottom: 2px solid #E54A0F; padding-bottom: 5px;">Magento 2 URLs (' + (storeData.m2_urls.length || 0) + ')</h4>';
if (storeData.m2_urls.length > 0) {
html += '<div style="background: #fff5f0; padding: 10px; border-radius: 4px; border-left: 3px solid #E54A0F;">';
storeData.m2_urls.forEach((urlData, index) => {
html += '<div style="margin-bottom: 10px; padding: 8px; background: white; border-radius: 4px; font-family: monospace; font-size: 0.9em;">';
html += '<div style="color: #333; margin-bottom: 3px;"><strong>URL ' + (index + 1) + ':</strong></div>';
html += '<div style="color: #E54A0F; word-break: break-all;">' + escapeHtml(urlData.url || '-') + '</div>';
if (urlData.target_path) {
html += '<div style="color: #666; font-size: 0.85em; margin-top: 3px;">Target: ' + escapeHtml(urlData.target_path) + '</div>';
}
html += '</div>';
});
html += '</div>';
} else {
html += '<div style="color: #999; font-style: italic; padding: 10px;">No URLs found in Magento 2</div>';
}
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
});
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';
}
};

View File

@ -0,0 +1,358 @@
@extends('layouts.app')
@section('content')
<!-- Sync Frontpage Tabs Section -->
<div class="section">
<h2>Sync Frontpage Tabs</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">Sync product frontpage tabs from Magento 1 to Magento 2</h3>
<p style="margin: 5px 0; color: #1976D2;">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.</p>
<ul style="margin: 10px 0 0 20px; color: #1976D2; line-height: 1.8;">
<li><strong>Attribute:</strong> Uses the product attribute that stores frontpage tabs (default: <code>frontpage_tabs</code>). If your attribute has a different code, enter it below.</li>
<li><strong>Match by SKU:</strong> Only products that exist in both M1 and M2 are updated.</li>
<li><strong>Store scope:</strong> All store views (store_id) are synced.</li>
</ul>
@if(isset($frontpageTabsInfo['m1']) && $frontpageTabsInfo['m1'])
<p style="margin: 10px 0 0 0; color: #2e7d32; font-weight: 600;">Detected attribute: <code>{{ $frontpageTabsInfo['m1']->attribute_code }}</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</p>
@elseif(!empty($frontpageTabsInfo['suggested_codes']))
<p style="margin: 10px 0 0 0; color: #ed6c02;">No default frontpage-tabs attribute found. Try attribute code: <code>{{ implode('</code>, <code>', $frontpageTabsInfo['suggested_codes']) }}</code></p>
@else
<p style="margin: 10px 0 0 0; color: #666;">Enter the product attribute code that stores frontpage tabs (e.g. <code>frontpage_tabs</code>).</p>
@endif
<p style="margin: 10px 0 0 0; color: #d32f2f; font-weight: 600;">⚠️ This will modify your Magento 2 database. Use dry run first if unsure.</p>
</div>
<div style="margin-top: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; gap: 10px; align-items: center;">
<label for="frontpageTabsAttributeCode" style="font-weight: 600; color: #333;">Attribute code:</label>
<input
type="text"
id="frontpageTabsAttributeCode"
value="{{ isset($frontpageTabsInfo['m1']) && $frontpageTabsInfo['m1'] ? $frontpageTabsInfo['m1']->attribute_code : 'frontpage_tabs' }}"
placeholder="frontpage_tabs"
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 1em; min-width: 180px;"
/>
</div>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="frontpageTabsDryRun" checked />
<span style="color: #333;">Dry run (no changes)</span>
</label>
<button id="checkAttributeBtn" class="btn btn-secondary" onclick="checkAttribute()" type="button">
Check attribute
</button>
<button id="syncFrontpageTabsBtn" class="btn btn-primary" onclick="syncFrontpageTabs()">
Sync Frontpage Tabs
</button>
</div>
<div id="attributeDiagnosticResult" style="display: none; margin-top: 16px; padding: 16px; background: #f0f7ff; border: 1px solid #90caf9; border-radius: 6px;">
<h4 style="margin-bottom: 8px; color: #333;">Attribute diagnostic</h4>
<pre id="attributeDiagnosticContent" style="max-height: 280px; overflow-y: auto; font-size: 0.85em; margin: 0;"></pre>
</div>
<div id="syncFrontpageTabsResult" style="display: none; margin-top: 20px; padding: 16px; background: white; border: 1px solid #ddd; border-radius: 6px;">
<div id="syncFrontpageTabsMessage" style="font-weight: 600; margin-bottom: 10px;"></div>
<div id="syncFrontpageTabsSummary" style="margin-bottom: 10px; color: #666;"></div>
<pre id="syncFrontpageTabsLog" style="max-height: 300px; overflow-y: auto; font-size: 0.85em; background: #f5f5f5; padding: 12px; border-radius: 4px;"></pre>
</div>
</div>
<!-- Protabs Configuration Sync Section -->
<div class="section" style="margin-top: 30px;">
<h2>Sync Frontend Tabs Configuration (MGS Protabs)</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">Find and add missing product tabs in Magento 2</h3>
<p style="margin: 5px 0; color: #1976D2;">Compares M1 product text attributes (those with actual product values) against M2's <code>mgs_protabs</code> configuration per website scope. Any attribute that has content in M1 but no corresponding Protabs entry in M2 is shown as missing.</p>
<ul style="margin: 10px 0 0 20px; color: #1976D2; line-height: 1.8;">
<li>Tabs are shown grouped by M2 website/store scope.</li>
<li>You can edit the tab title and position before adding.</li>
<li>Use "Add Selected" to insert only the tabs you check, or "Add All Missing" for a scope.</li>
</ul>
<p style="margin: 10px 0 0 0; color: #d32f2f; font-weight: 600;">⚠️ This will modify M2's <code>mgs_protabs</code> table. Flush the M2 cache after syncing.</p>
</div>
<div style="margin-bottom: 16px;">
<button id="compareProtabsBtn" class="btn btn-secondary" onclick="compareProtabs()" type="button">
Compare Tabs (M1 vs M2)
</button>
</div>
<div id="protabsCompareResult" style="display: none; margin-top: 10px;">
<div id="protabsCompareContent"></div>
</div>
<div id="protabsSyncResult" style="display: none; margin-top: 20px; padding: 16px; background: white; border: 1px solid #ddd; border-radius: 6px;">
<div id="protabsSyncMessage" style="font-weight: 600; margin-bottom: 10px;"></div>
<pre id="protabsSyncLog" style="max-height: 260px; overflow-y: auto; font-size: 0.85em; background: #f5f5f5; padding: 12px; border-radius: 4px;"></pre>
</div>
</div>
@endsection
@push('scripts')
<script>
window.additionalRoutes = {
syncFrontpageTabs: '{{ route("additional.sync-frontpage-tabs") }}',
attributeDiagnostic: '{{ route("additional.attribute-diagnostic") }}',
compareProtabs: '{{ route("additional.compare-protabs") }}',
syncProtabs: '{{ route("additional.sync-protabs") }}'
};
window.csrfToken = '{{ csrf_token() }}';
function syncFrontpageTabs() {
const btn = document.getElementById('syncFrontpageTabsBtn');
const resultEl = document.getElementById('syncFrontpageTabsResult');
const messageEl = document.getElementById('syncFrontpageTabsMessage');
const summaryEl = document.getElementById('syncFrontpageTabsSummary');
const logEl = document.getElementById('syncFrontpageTabsLog');
const attributeCode = document.getElementById('frontpageTabsAttributeCode').value.trim() || 'frontpage_tabs';
const dryRun = document.getElementById('frontpageTabsDryRun').checked;
btn.disabled = true;
resultEl.style.display = 'block';
messageEl.textContent = 'Running sync…';
summaryEl.textContent = '';
logEl.textContent = '';
const formData = new FormData();
formData.append('_token', window.csrfToken);
formData.append('attribute_code', attributeCode);
formData.append('dry_run', dryRun ? '1' : '0');
fetch(window.additionalRoutes.syncFrontpageTabs, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(function (res) { return res.json(); })
.then(function (data) {
messageEl.textContent = data.message || (data.success ? 'Done.' : 'Error.');
messageEl.style.color = data.success ? '#2e7d32' : '#d32f2f';
if (data.updated !== undefined || data.skipped !== undefined || data.errors !== undefined) {
summaryEl.textContent = 'Updated: ' + (data.updated || 0) + ', Skipped: ' + (data.skipped || 0) + ', Errors: ' + (data.errors || 0);
}
if (data.diagnostic) {
summaryEl.textContent = (summaryEl.textContent ? summaryEl.textContent + ' | ' : '') + 'Diagnostic: ' + JSON.stringify(data.diagnostic);
}
if (data.log && data.log.length) {
logEl.textContent = data.log.join('\n');
} else if (data.diagnostic && !data.log) {
logEl.textContent = JSON.stringify(data.diagnostic, null, 2);
}
})
.catch(function (err) {
messageEl.textContent = 'Request failed: ' + err.message;
messageEl.style.color = '#d32f2f';
})
.finally(function () {
btn.disabled = false;
});
}
// ── Protabs Compare & Sync ────────────────────────────────────────────
function compareProtabs() {
const btn = document.getElementById('compareProtabsBtn');
const resultEl = document.getElementById('protabsCompareResult');
const contentEl = document.getElementById('protabsCompareContent');
btn.disabled = true;
btn.textContent = 'Loading…';
resultEl.style.display = 'block';
contentEl.innerHTML = '<p style="color:#666;">Comparing M1 attributes vs M2 Protabs configuration…</p>';
fetch(window.additionalRoutes.compareProtabs, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (!data.success) {
contentEl.innerHTML = '<p style="color:#d32f2f;">Error: ' + (data.message || 'Unknown error') + '</p>';
return;
}
renderProtabsComparison(data.comparison, data.m1_text_attr_count);
})
.catch(err => {
contentEl.innerHTML = '<p style="color:#d32f2f;">Request failed: ' + err.message + '</p>';
})
.finally(() => {
btn.disabled = false;
btn.textContent = 'Compare Tabs (M1 vs M2)';
});
}
function renderProtabsComparison(comparison, m1Count) {
const contentEl = document.getElementById('protabsCompareContent');
let html = '<p style="color:#555; margin-bottom:16px;">M1 has <strong>' + m1Count + '</strong> text attribute(s) with product values.</p>';
comparison.forEach(scope => {
const missingCount = scope.missing.length;
const scopeColor = missingCount > 0 ? '#b71c1c' : '#2e7d32';
const badge = missingCount > 0
? '<span style="background:#d32f2f;color:#fff;border-radius:12px;padding:2px 10px;font-size:0.8em;margin-left:8px;">' + missingCount + ' missing</span>'
: '<span style="background:#2e7d32;color:#fff;border-radius:12px;padding:2px 10px;font-size:0.8em;margin-left:8px;">✓ complete</span>';
html += '<div style="border:1px solid #ddd;border-radius:6px;margin-bottom:20px;overflow:hidden;">';
html += '<div style="background:#f5f5f5;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;">';
html += '<strong style="color:#333;">' + escHtml(scope.scope_label) + badge + '</strong>';
if (missingCount > 0) {
html += '<button class="btn btn-primary" style="font-size:0.85em;padding:5px 14px;" onclick="addAllMissing(' + JSON.stringify(scope.scope) + ',' + scope.scope_id + ')">Add All Missing</button>';
}
html += '</div>';
// Existing tabs table
if (scope.existing.length > 0) {
html += '<div style="padding:12px 16px 0;">';
html += '<p style="font-size:0.85em;color:#555;margin:0 0 6px;">Configured tabs (' + scope.existing.length + '):</p>';
html += '<table style="width:100%;border-collapse:collapse;font-size:0.85em;">';
html += '<thead><tr style="background:#f9f9f9;"><th style="padding:6px 10px;text-align:left;border-bottom:1px solid #eee;">Title</th><th style="padding:6px 10px;text-align:left;border-bottom:1px solid #eee;">Attribute</th><th style="padding:6px 10px;text-align:left;border-bottom:1px solid #eee;">Type</th><th style="padding:6px 10px;text-align:center;border-bottom:1px solid #eee;">Pos</th></tr></thead><tbody>';
scope.existing.forEach(tab => {
html += '<tr><td style="padding:5px 10px;border-bottom:1px solid #f0f0f0;">' + escHtml(tab.title || '') + '</td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #f0f0f0;"><code>' + escHtml(tab.value || '—') + '</code></td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #f0f0f0;">' + escHtml(tab.tab_type || '') + '</td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #f0f0f0;text-align:center;">' + (tab.position || '—') + '</td></tr>';
});
html += '</tbody></table></div>';
}
// Missing tabs
if (missingCount > 0) {
const missingId = 'missing_' + scope.scope + '_' + scope.scope_id;
html += '<div style="padding:12px 16px;background:#fff8f8;border-top:1px solid #ffcdd2;">';
html += '<p style="font-size:0.85em;color:#c62828;margin:0 0 8px;font-weight:600;">Missing from M2 Protabs (' + missingCount + ') — M1 has product values for these attributes:</p>';
html += '<table style="width:100%;border-collapse:collapse;font-size:0.85em;" id="' + missingId + '">';
html += '<thead><tr style="background:#ffebee;">';
html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #ffcdd2;"><input type="checkbox" onchange="toggleAll(this,\'' + missingId + '\')" title="Select all"> All</th>';
html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #ffcdd2;">Attribute Code</th>';
html += '<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #ffcdd2;">Tab Title</th>';
html += '<th style="padding:6px 10px;text-align:center;border-bottom:1px solid #ffcdd2;">Position</th>';
html += '<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #ffcdd2;">M1 Products</th>';
html += '</tr></thead><tbody>';
scope.missing.forEach((m, idx) => {
const rowId = missingId + '_' + idx;
html += '<tr>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #ffebee;"><input type="checkbox" class="missing-tab-check" data-scope="' + escHtml(scope.scope) + '" data-scope-id="' + scope.scope_id + '" data-attr="' + escHtml(m.attribute_code) + '" id="chk_' + rowId + '" checked></td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #ffebee;"><code>' + escHtml(m.attribute_code) + '</code></td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #ffebee;"><input type="text" id="title_' + rowId + '" value="' + escHtml(m.suggested_title) + '" style="padding:4px 8px;border:1px solid #ddd;border-radius:3px;width:150px;" /></td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #ffebee;text-align:center;"><input type="number" id="pos_' + rowId + '" value="' + m.suggested_pos + '" style="padding:4px 6px;border:1px solid #ddd;border-radius:3px;width:60px;" /></td>';
html += '<td style="padding:5px 10px;border-bottom:1px solid #ffebee;text-align:right;color:#555;">' + m.value_count + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
html += '<div style="margin-top:10px;">';
html += '<button class="btn btn-primary" style="font-size:0.85em;" onclick="addSelectedMissing(\'' + missingId + '\')">Add Selected to M2 Protabs</button>';
html += '</div></div>';
} else {
html += '<div style="padding:10px 16px;background:#f9fff9;border-top:1px solid #c8e6c9;color:#2e7d32;font-size:0.85em;">All M1 text attributes with values are configured in this scope.</div>';
}
html += '</div>'; // end scope card
});
contentEl.innerHTML = html;
}
function toggleAll(masterChk, tableId) {
document.querySelectorAll('#' + tableId + ' .missing-tab-check').forEach(chk => {
chk.checked = masterChk.checked;
});
}
function addAllMissing(scope, scopeId) {
const tableId = 'missing_' + scope + '_' + scopeId;
const checkboxes = document.querySelectorAll('#' + tableId + ' .missing-tab-check');
checkboxes.forEach(chk => { chk.checked = true; });
addSelectedMissing(tableId);
}
function addSelectedMissing(tableId) {
const checkboxes = document.querySelectorAll('#' + tableId + ' .missing-tab-check:checked');
if (checkboxes.length === 0) {
alert('No tabs selected.');
return;
}
const tabs = [];
checkboxes.forEach(chk => {
const idx = chk.id.replace('chk_', '');
const title = (document.getElementById('title_' + idx) || {}).value || chk.dataset.attr;
const pos = parseInt((document.getElementById('pos_' + idx) || {}).value || 99, 10);
tabs.push({
title: title,
tab_type: 'attribute',
value: chk.dataset.attr,
position: pos,
scope: chk.dataset.scope,
scope_id: parseInt(chk.dataset.scopeId, 10),
});
});
const resultEl = document.getElementById('protabsSyncResult');
const messageEl = document.getElementById('protabsSyncMessage');
const logEl = document.getElementById('protabsSyncLog');
resultEl.style.display = 'block';
messageEl.textContent = 'Adding ' + tabs.length + ' tab(s)…';
messageEl.style.color = '#555';
logEl.textContent = '';
fetch(window.additionalRoutes.syncProtabs, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': window.csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({ tabs }),
})
.then(r => r.json())
.then(data => {
messageEl.textContent = data.message || (data.success ? 'Done.' : 'Error.');
messageEl.style.color = data.success ? '#2e7d32' : '#d32f2f';
if (data.log && data.log.length) {
logEl.textContent = data.log.join('\n');
}
if (data.inserted > 0) {
// Re-run compare to refresh state
setTimeout(compareProtabs, 400);
}
})
.catch(err => {
messageEl.textContent = 'Request failed: ' + err.message;
messageEl.style.color = '#d32f2f';
});
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── End Protabs ───────────────────────────────────────────────────────
function checkAttribute() {
const attributeCode = document.getElementById('frontpageTabsAttributeCode').value.trim() || 'frontpage_tabs';
const resultEl = document.getElementById('attributeDiagnosticResult');
const contentEl = document.getElementById('attributeDiagnosticContent');
resultEl.style.display = 'block';
contentEl.textContent = 'Loading…';
fetch(window.additionalRoutes.attributeDiagnostic + '?attribute_code=' + encodeURIComponent(attributeCode), {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.diagnostic) {
contentEl.textContent = JSON.stringify(data.diagnostic, null, 2);
} else {
contentEl.textContent = (data.message || 'Error') + '\n' + JSON.stringify(data, null, 2);
}
})
.catch(function (err) {
contentEl.textContent = 'Request failed: ' + err.message;
});
}
</script>
@endpush

View File

@ -20,5 +20,11 @@
<a href="{{ route('orders.index') }}" class="nav-link {{ request()->routeIs('orders.*') ? 'active' : '' }}"> <a href="{{ route('orders.index') }}" class="nav-link {{ request()->routeIs('orders.*') ? 'active' : '' }}">
Orders Orders
</a> </a>
<a href="{{ route('product-urls.index') }}" class="nav-link {{ request()->routeIs('product-urls.*') ? 'active' : '' }}">
Product URLs
</a>
<a href="{{ route('additional.index') }}" class="nav-link {{ request()->routeIs('additional.*') ? 'active' : '' }}">
Additional
</a>
</nav> </nav>

View File

@ -0,0 +1,251 @@
@extends('layouts.app')
@section('content')
<!-- Single Product SKU Comparison Section -->
<div class="section">
<h2>🔍 Compare Single Product URL</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">Compare URLs for a Specific Product</h3>
<p style="margin: 5px 0; color: #1976D2;">Enter a product SKU to compare its URLs between Magento 1 and Magento 2:</p>
</div>
<div style="margin-top: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; gap: 10px; align-items: center; flex: 1; min-width: 300px;">
<label for="singleSkuInput" style="font-weight: 600; color: #333; min-width: 100px;">Product SKU:</label>
<input
type="text"
id="singleSkuInput"
placeholder="Enter product SKU"
style="padding: 10px 15px; border: 2px solid #ddd; border-radius: 4px; font-size: 1em; flex: 1; min-width: 200px;"
/>
</div>
<button id="compareSingleSkuBtn" class="btn btn-primary" onclick="compareSingleSku()">
Compare Product URLs
</button>
</div>
<!-- Single Product Comparison Results -->
<div id="singleProductComparisonSection" style="display: none; margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: #333;">Comparison Results for SKU: <span id="singleSkuDisplay"></span></h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px;">
<div id="singleProductComparisonContent">
<!-- Results will be displayed here -->
</div>
</div>
</div>
</div>
<!-- Fix / Migrate Product URLs Section -->
<div class="section" style="margin-top: 30px;">
<h2>🔧 Fix / Migrate Product URLs</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">Migrate missing product URL rewrites from M1 to M2</h3>
<p style="margin: 5px 0; color: #1976D2;">For each M1 product URL whose target is <code>catalog/product/view/...</code> (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 <code>metadata={"category_id":"X"}</code>.</p>
<p style="margin: 5px 0; color: #1976D2;">Existing M2 rewrites with the same <code>(request_path, store_id)</code> are left untouched.</p>
</div>
<div style="margin-top: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; gap: 10px; align-items: center;">
<label for="fixSkuInput" style="font-weight: 600; color: #333;">SKU (optional):</label>
<input
type="text"
id="fixSkuInput"
placeholder="Leave blank to fix all"
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 1em; min-width: 200px;"
/>
</div>
<label style="display: flex; gap: 6px; align-items: center; color: #333;">
<input type="checkbox" id="fixDryRunInput" checked />
Dry run (preview only)
</label>
<button id="fixUrlsBtn" class="btn btn-primary" onclick="fixProductUrls()">
Fix URLs
</button>
</div>
<div id="fixResultsSection" style="display: none; margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: #333;">Result</h3>
<div class="stats">
<div class="stat-card" style="background: #FF8C42;">
<div class="number" id="fixAdded">0</div>
<div class="label" id="fixAddedLabel">Added</div>
</div>
<div class="stat-card" style="background: #FFA366;">
<div class="number" id="fixSkippedExisting">0</div>
<div class="label">Already in M2</div>
</div>
<div class="stat-card" style="background: #FF6B35;">
<div class="number" id="fixSkippedNoProduct">0</div>
<div class="label">No M2 Product</div>
</div>
<div class="stat-card" style="background: #C43A0D;">
<div class="number" id="fixErrors">0</div>
<div class="label">Errors</div>
</div>
</div>
<div style="margin-top: 20px; background: #1e1e1e; color: #ddd; border-radius: 6px; padding: 16px; max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85em; white-space: pre-wrap;" id="fixLogOutput"></div>
</div>
</div>
<!-- Bulk Comparison Section -->
<div class="section" style="margin-top: 30px;">
<h2>🔗 Bulk Product URL Comparison</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">Compare Multiple Product URLs</h3>
<p style="margin: 5px 0; color: #1976D2;">Compare product URLs between Magento 1 and Magento 2 to identify differences:</p>
<ul style="margin: 10px 0 0 20px; color: #1976D2; line-height: 1.8;">
<li><strong>Match:</strong> URLs are identical in both systems</li>
<li><strong>Missing in M2:</strong> URL exists in Magento 1 but not in Magento 2</li>
<li><strong>Missing in M1:</strong> URL exists in Magento 2 but not in Magento 1</li>
<li><strong>Different:</strong> URLs exist in both but are different</li>
</ul>
</div>
<!-- Search and Filter Section -->
<div style="margin-top: 20px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; gap: 10px; align-items: center;">
<label for="skuFilter" style="font-weight: 600; color: #333;">Filter by SKU:</label>
<input
type="text"
id="skuFilter"
placeholder="Enter SKU (optional)"
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 1em; min-width: 200px;"
/>
</div>
<button id="compareUrlsBtn" class="btn btn-primary" onclick="compareUrls()">
Compare URLs
</button>
<button id="loadM1UrlsBtn" class="btn btn-secondary" onclick="loadM1Urls()">
Load M1 URLs
</button>
<button id="loadM2UrlsBtn" class="btn btn-secondary" onclick="loadM2Urls()">
Load M2 URLs
</button>
</div>
<!-- Summary Statistics -->
<div id="summarySection" style="display: none; margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: #333;">Summary</h3>
<div class="stats">
<div class="stat-card" style="background: #FF8C42;">
<div class="number" id="summaryMatch">0</div>
<div class="label">Matching URLs</div>
</div>
<div class="stat-card" style="background: #FF6B35;">
<div class="number" id="summaryMissingM2">0</div>
<div class="label">Missing in M2</div>
</div>
<div class="stat-card" style="background: #FFA366;">
<div class="number" id="summaryMissingM1">0</div>
<div class="label">Missing in M1</div>
</div>
<div class="stat-card" style="background: #C43A0D;">
<div class="number" id="summaryDifferent">0</div>
<div class="label">Different URLs</div>
</div>
</div>
</div>
<!-- Comparison Results -->
<div id="comparisonSection" style="display: none; margin-top: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="color: #333;">Comparison Results</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<span id="resultsCount" style="color: #666; font-size: 0.9em;"></span>
<select id="statusFilter" onchange="filterResults()" style="padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px;">
<option value="all">All Statuses</option>
<option value="match">Match</option>
<option value="missing_in_m2">Missing in M2</option>
<option value="missing_in_m1">Missing in M1</option>
<option value="different">Different</option>
</select>
</div>
</div>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
<table class="products-table" id="comparisonTable">
<thead>
<tr>
<th>SKU</th>
<th>M1 Product ID</th>
<th>M2 Product ID</th>
<th>Store ID</th>
<th>M1 URL</th>
<th>M2 URL</th>
<th>Status</th>
</tr>
</thead>
<tbody id="comparisonTableBody">
</tbody>
</table>
</div>
<div id="paginationSection" style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; align-items: center;">
<button id="prevPageBtn" class="btn btn-secondary" onclick="changePage(-1)" disabled>Previous</button>
<span id="pageInfo" style="color: #666;"></span>
<button id="nextPageBtn" class="btn btn-secondary" onclick="changePage(1)" disabled>Next</button>
</div>
</div>
<!-- M1 URLs Section -->
<div id="m1UrlsSection" style="display: none; margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: #333;">Magento 1 URLs</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
<table class="products-table" id="m1UrlsTable">
<thead>
<tr>
<th>Product ID</th>
<th>SKU</th>
<th>Store ID</th>
<th>Request Path</th>
<th>Target Path</th>
</tr>
</thead>
<tbody id="m1UrlsTableBody">
</tbody>
</table>
</div>
<div id="m1PaginationSection" style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; align-items: center;">
<button id="m1PrevPageBtn" class="btn btn-secondary" onclick="changeM1Page(-1)" disabled>Previous</button>
<span id="m1PageInfo" style="color: #666;"></span>
<button id="m1NextPageBtn" class="btn btn-secondary" onclick="changeM1Page(1)" disabled>Next</button>
</div>
</div>
<!-- M2 URLs Section -->
<div id="m2UrlsSection" style="display: none; margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: #333;">Magento 2 URLs</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
<table class="products-table" id="m2UrlsTable">
<thead>
<tr>
<th>Product ID</th>
<th>SKU</th>
<th>Store ID</th>
<th>Request Path</th>
<th>Target Path</th>
</tr>
</thead>
<tbody id="m2UrlsTableBody">
</tbody>
</table>
</div>
<div id="m2PaginationSection" style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; align-items: center;">
<button id="m2PrevPageBtn" class="btn btn-secondary" onclick="changeM2Page(-1)" disabled>Previous</button>
<span id="m2PageInfo" style="color: #666;"></span>
<button id="m2NextPageBtn" class="btn btn-secondary" onclick="changeM2Page(1)" disabled>Next</button>
</div>
</div>
</div>
@endsection
@push('scripts')
@vite(['resources/js/product-urls.js'])
<script>
window.productUrlsRoutes = {
compareUrls: '{{ route("product-urls.compare-urls") }}',
compareSingleSku: '{{ route("product-urls.compare-single-sku") }}',
getM1Urls: '{{ route("product-urls.get-m1-urls") }}',
getM2Urls: '{{ route("product-urls.get-m2-urls") }}',
fixUrls: '{{ route("product-urls.fix-urls") }}'
};
</script>
@endpush

View File

@ -8,6 +8,8 @@
use App\Http\Controllers\ProductsController; use App\Http\Controllers\ProductsController;
use App\Http\Controllers\CustomersController; use App\Http\Controllers\CustomersController;
use App\Http\Controllers\OrdersController; use App\Http\Controllers\OrdersController;
use App\Http\Controllers\ProductUrlsController;
use App\Http\Controllers\AdditionalController;
Route::get('/', function () { Route::get('/', function () {
return redirect('/connections'); return redirect('/connections');
@ -77,3 +79,22 @@
Route::get('/migration-progress', [OrdersController::class, 'getMigrationProgress'])->name('migration-progress'); Route::get('/migration-progress', [OrdersController::class, 'getMigrationProgress'])->name('migration-progress');
Route::delete('/{orderId}', [OrdersController::class, 'deleteM2Order'])->name('delete-order'); 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');
});

BIN
spasmar.tar.gz Normal file

Binary file not shown.

View File

@ -15,6 +15,7 @@ export default defineConfig({
'resources/js/products.js', 'resources/js/products.js',
'resources/js/customers.js', 'resources/js/customers.js',
'resources/js/orders.js', 'resources/js/orders.js',
'resources/js/product-urls.js',
], ],
refresh: true, refresh: true,
}), }),