Added new music
This commit is contained in:
parent
fecc231e30
commit
c75755aac9
|
|
@ -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;"
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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++;
|
||||||
|
$m1OrderId = $m1Order->entity_id;
|
||||||
|
$m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$m1IncrementId = !empty($m1Order->increment_id) ? trim($m1Order->increment_id) : null;
|
|
||||||
|
|
||||||
// Update progress if tracking enabled
|
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue