updated customers
This commit is contained in:
parent
7777b6e9e7
commit
353f9e0564
|
|
@ -5,6 +5,7 @@
|
|||
use App\Services\MagentoCategoryMigrationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CustomersController extends Controller
|
||||
{
|
||||
|
|
@ -39,13 +40,30 @@ public function index()
|
|||
public function migrateCustomers(Request $request)
|
||||
{
|
||||
try {
|
||||
$dryRun = $request->input('dry_run', false);
|
||||
$result = $this->migrationService->migrateCustomers($dryRun);
|
||||
// Handle both JSON and form data
|
||||
$dryRun = false;
|
||||
$progressKey = null;
|
||||
|
||||
if ($request->isJson()) {
|
||||
$dryRun = $request->json()->get('dry_run', false);
|
||||
$progressKey = $request->json()->get('progress_key', null);
|
||||
} else {
|
||||
$dryRun = $request->input('dry_run', false);
|
||||
$progressKey = $request->input('progress_key', null);
|
||||
}
|
||||
|
||||
// Convert string "true"/"false" to boolean if needed
|
||||
if (is_string($dryRun)) {
|
||||
$dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
$result = $this->migrationService->migrateCustomers($dryRun, $progressKey);
|
||||
|
||||
return response()->json($result, $result['success'] ? 200 : 400);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Customer migration error: ' . $e->getMessage());
|
||||
Log::error('Stack trace: ' . $e->getTraceAsString());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
|
|
@ -58,6 +76,46 @@ public function migrateCustomers(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer migration progress
|
||||
*/
|
||||
public function getMigrationProgress(Request $request)
|
||||
{
|
||||
try {
|
||||
$progressKey = $request->input('progress_key');
|
||||
|
||||
if (empty($progressKey)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Progress key is required'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$progress = Cache::get($progressKey);
|
||||
|
||||
if (!$progress) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Progress not found',
|
||||
'progress' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Get customer migration progress error: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to get progress: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single customer from Magento 2
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Exception;
|
||||
|
||||
class MagentoCategoryMigrationService
|
||||
|
|
@ -5678,7 +5679,7 @@ public function getM2CustomersNotInM1()
|
|||
/**
|
||||
* Migrate all customers from Magento 1 to Magento 2
|
||||
*/
|
||||
public function migrateCustomers($dryRun = false)
|
||||
public function migrateCustomers($dryRun = false, $progressKey = null)
|
||||
{
|
||||
try {
|
||||
$this->migrationLog = [];
|
||||
|
|
@ -5710,6 +5711,20 @@ public function migrateCustomers($dryRun = false)
|
|||
|
||||
// Get all M1 customers
|
||||
$m1Customers = $this->getMagento1Customers();
|
||||
$totalCustomers = $m1Customers->count();
|
||||
|
||||
// Initialize progress tracking
|
||||
if ($progressKey && !$dryRun) {
|
||||
Cache::put($progressKey, [
|
||||
'total' => $totalCustomers,
|
||||
'current' => 0,
|
||||
'added' => 0,
|
||||
'updated' => 0,
|
||||
'errors' => 0,
|
||||
'status' => 'running',
|
||||
'current_email' => ''
|
||||
], 3600);
|
||||
}
|
||||
|
||||
// Get all customer attribute IDs from M1
|
||||
$m1AttributeIds = DB::connection($this->magento1Connection)
|
||||
|
|
@ -5729,10 +5744,25 @@ public function migrateCustomers($dryRun = false)
|
|||
DB::connection($this->magento2Connection)->beginTransaction();
|
||||
}
|
||||
|
||||
$currentIndex = 0;
|
||||
foreach ($m1Customers as $m1Customer) {
|
||||
$currentIndex++;
|
||||
try {
|
||||
$m1Email = !empty($m1Customer->email) ? strtolower(trim($m1Customer->email)) : null;
|
||||
|
||||
// Update progress if tracking enabled
|
||||
if ($progressKey && !$dryRun) {
|
||||
Cache::put($progressKey, [
|
||||
'total' => $totalCustomers,
|
||||
'current' => $currentIndex,
|
||||
'added' => $addedCount,
|
||||
'updated' => $updatedCount,
|
||||
'errors' => $errorCount,
|
||||
'status' => 'running',
|
||||
'current_email' => $m1Email ?? 'N/A'
|
||||
], 3600);
|
||||
}
|
||||
|
||||
if (empty($m1Email)) {
|
||||
$this->migrationLog[] = "SKIPPED: Customer ID {$m1Customer->entity_id} - no email address";
|
||||
continue;
|
||||
|
|
@ -5844,6 +5874,19 @@ public function migrateCustomers($dryRun = false)
|
|||
if (!$dryRun) {
|
||||
DB::connection($this->magento2Connection)->commit();
|
||||
}
|
||||
|
||||
// Update progress to completed
|
||||
if ($progressKey && !$dryRun) {
|
||||
Cache::put($progressKey, [
|
||||
'total' => $totalCustomers,
|
||||
'current' => $totalCustomers,
|
||||
'added' => $addedCount,
|
||||
'updated' => $updatedCount,
|
||||
'errors' => $errorCount,
|
||||
'status' => 'completed',
|
||||
'current_email' => ''
|
||||
], 3600);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
|
|
@ -5858,6 +5901,20 @@ public function migrateCustomers($dryRun = false)
|
|||
if (!$dryRun) {
|
||||
DB::connection($this->magento2Connection)->rollBack();
|
||||
}
|
||||
|
||||
// Update progress to failed
|
||||
if ($progressKey && !$dryRun) {
|
||||
Cache::put($progressKey, [
|
||||
'total' => isset($totalCustomers) ? $totalCustomers : 0,
|
||||
'current' => isset($currentIndex) ? $currentIndex : 0,
|
||||
'added' => $addedCount,
|
||||
'updated' => $updatedCount,
|
||||
'errors' => $errorCount,
|
||||
'status' => 'failed',
|
||||
'current_email' => ''
|
||||
], 3600);
|
||||
}
|
||||
|
||||
Log::error('Error migrating customers: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
|
|
|
|||
|
|
@ -2744,13 +2744,40 @@ protected function migrateCatalogProductOptions()
|
|||
|
||||
$m2ProductId = $productIdMapping[$m1ProductId];
|
||||
|
||||
// Check if option already exists in M2 (by product_id and type)
|
||||
$existingOption = DB::connection($this->magento2Connection)
|
||||
// Check if option already exists in M2
|
||||
// Match by product_id, type, sort_order, and sku (if sku is not null/empty)
|
||||
// This ensures we find the exact same option and update it instead of creating duplicates
|
||||
$m1Sku = $m1Option->sku ?? null;
|
||||
$m1SortOrder = $m1Option->sort_order ?? 0;
|
||||
|
||||
$query = DB::connection($this->magento2Connection)
|
||||
->table($this->magento2Prefix . 'catalog_product_option')
|
||||
->where('product_id', $m2ProductId)
|
||||
->where('type', $m1Option->type)
|
||||
->where('sku', $m1Option->sku ?? '')
|
||||
->first();
|
||||
->where('sort_order', $m1SortOrder);
|
||||
|
||||
// If SKU is provided and not empty, include it in the match
|
||||
if (!empty($m1Sku)) {
|
||||
$query->where('sku', $m1Sku);
|
||||
} else {
|
||||
// If SKU is null/empty, match options where SKU is also null/empty
|
||||
$query->where(function($q) {
|
||||
$q->whereNull('sku')->orWhere('sku', '');
|
||||
});
|
||||
}
|
||||
|
||||
$existingOption = $query->first();
|
||||
|
||||
// If still no match, try matching by product_id, type, and sort_order only
|
||||
// This catches cases where SKU might differ but it's the same option
|
||||
if (!$existingOption) {
|
||||
$existingOption = DB::connection($this->magento2Connection)
|
||||
->table($this->magento2Prefix . 'catalog_product_option')
|
||||
->where('product_id', $m2ProductId)
|
||||
->where('type', $m1Option->type)
|
||||
->where('sort_order', $m1SortOrder)
|
||||
->first();
|
||||
}
|
||||
|
||||
$optionData = [
|
||||
'product_id' => $m2ProductId,
|
||||
|
|
|
|||
|
|
@ -9,39 +9,115 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
routes = window.customerRoutes;
|
||||
csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
// Attach event listener to the button instead of using inline onclick
|
||||
const startButton = document.getElementById('startCustomerMigrationBtn');
|
||||
if (startButton) {
|
||||
startButton.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
startCustomerMigration();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function startCustomerMigration(dryRun) {
|
||||
const button = dryRun ? document.getElementById('dryRunCustomerMigrationBtn') : document.getElementById('startCustomerMigrationBtn');
|
||||
const otherButton = dryRun ? document.getElementById('startCustomerMigrationBtn') : document.getElementById('dryRunCustomerMigrationBtn');
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
otherButton.disabled = true;
|
||||
button.textContent = dryRun ? 'Running Dry Run...' : 'Migrating...';
|
||||
button.style.cursor = 'not-allowed';
|
||||
function startCustomerMigration() {
|
||||
try {
|
||||
const button = document.getElementById('startCustomerMigrationBtn');
|
||||
if (!button) {
|
||||
console.error('Start customer migration button not found');
|
||||
alert('Error: Button not found. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
const logContent = document.getElementById('customerMigrationLogContent');
|
||||
if (!logContent) {
|
||||
console.error('Customer migration log content not found');
|
||||
alert('Error: Log container not found. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button.textContent || 'Start Customer Migration';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Migrating...';
|
||||
button.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
const logContent = document.getElementById('customerMigrationLogContent');
|
||||
logContent.innerHTML = '<div class="log-entry">' + (dryRun ? 'Running dry run...' : 'Starting migration...') + '</div>';
|
||||
const progressContainer = document.getElementById('migrationProgressContainer');
|
||||
|
||||
// Show progress bar
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
updateProgressBar(0, 0, 0, 0, 0, 'Starting migration...', '');
|
||||
}
|
||||
|
||||
logContent.innerHTML = '<div class="log-entry">Starting migration...</div>';
|
||||
|
||||
// Generate progress key for tracking
|
||||
const progressKey = 'customer_migration_progress_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
let progressInterval = null;
|
||||
|
||||
// Check if routes are available
|
||||
if (!routes || !routes.migrateCustomers) {
|
||||
console.error('Customer routes not available', routes);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
button.style.cursor = 'pointer';
|
||||
}
|
||||
alert('Error: Routes not initialized. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
progressInterval = pollMigrationProgress(progressKey, button, originalText, logContent);
|
||||
|
||||
fetch(routes.migrateCustomers, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ dry_run: dryRun })
|
||||
body: JSON.stringify({ dry_run: false, progress_key: progressKey })
|
||||
})
|
||||
.then(async response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
// Check if response is JSON
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
} else {
|
||||
// Response is HTML (error page), get text and show error
|
||||
const text = await response.text();
|
||||
throw new Error(`Server returned HTML instead of JSON. Status: ${response.status}. This usually means there was a server error. Check the browser console or server logs for details.`);
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
button.disabled = false;
|
||||
otherButton.disabled = false;
|
||||
button.textContent = originalText;
|
||||
button.style.cursor = 'pointer';
|
||||
// Stop polling
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
// Hide progress bar
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
button.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// Update progress bar to 100% before hiding
|
||||
if (progressContainer) {
|
||||
updateProgressBar(100, data.added + data.updated, data.added + data.updated, data.added, data.updated, data.errors, 'completed', '');
|
||||
}
|
||||
|
||||
const successEntry = document.createElement('div');
|
||||
successEntry.className = 'log-entry success';
|
||||
successEntry.textContent = `✓ ${dryRun ? 'Dry run' : 'Migration'} completed! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: ${data.errors || 0}`;
|
||||
successEntry.textContent = `✓ Migration completed! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: ${data.errors || 0}`;
|
||||
logContent.appendChild(successEntry);
|
||||
|
||||
if (data.log && data.log.length > 0) {
|
||||
|
|
@ -58,21 +134,116 @@ function startCustomerMigration(dryRun) {
|
|||
} else {
|
||||
const errorEntry = document.createElement('div');
|
||||
errorEntry.className = 'log-entry error';
|
||||
errorEntry.textContent = '✗ ' + (dryRun ? 'Dry run' : 'Migration') + ' failed: ' + (data.message || 'Unknown error');
|
||||
errorEntry.textContent = '✗ Migration failed: ' + (data.message || 'Unknown error');
|
||||
logContent.appendChild(errorEntry);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
button.disabled = false;
|
||||
otherButton.disabled = false;
|
||||
button.textContent = originalText;
|
||||
button.style.cursor = 'pointer';
|
||||
// Stop polling
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
// Hide progress bar
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
button.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
const errorEntry = document.createElement('div');
|
||||
errorEntry.className = 'log-entry error';
|
||||
errorEntry.textContent = '✗ Error: ' + error.message;
|
||||
logContent.appendChild(errorEntry);
|
||||
if (logContent) {
|
||||
const errorEntry = document.createElement('div');
|
||||
errorEntry.className = 'log-entry error';
|
||||
errorEntry.textContent = '✗ Error: ' + error.message;
|
||||
logContent.appendChild(errorEntry);
|
||||
} else {
|
||||
console.error('Error during migration:', error);
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in startCustomerMigration:', error);
|
||||
alert('An unexpected error occurred. Please refresh the page and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function pollMigrationProgress(progressKey, button, originalText, logContent) {
|
||||
return setInterval(() => {
|
||||
if (!routes.migrationProgress) {
|
||||
console.error('Migration progress route not available');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(routes.migrationProgress + '?progress_key=' + encodeURIComponent(progressKey), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.progress) {
|
||||
const progress = data.progress;
|
||||
const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
updateProgressBar(
|
||||
percentage,
|
||||
progress.current,
|
||||
progress.total,
|
||||
progress.added,
|
||||
progress.updated,
|
||||
progress.errors,
|
||||
progress.status,
|
||||
progress.current_email
|
||||
);
|
||||
} else if (data.success === false && data.message === 'Progress not found') {
|
||||
// Progress not found yet, migration might not have started
|
||||
// This is okay, just continue polling
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error polling progress:', error);
|
||||
});
|
||||
}, 500); // Poll every 500ms for smoother updates
|
||||
}
|
||||
|
||||
function updateProgressBar(percentage, current, total, added, updated, errors, status, currentEmail) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressPercentage = document.getElementById('progressPercentage');
|
||||
const progressStatus = document.getElementById('progressStatus');
|
||||
const currentCustomer = document.getElementById('currentCustomer');
|
||||
const progressAdded = document.getElementById('progressAdded');
|
||||
const progressUpdated = document.getElementById('progressUpdated');
|
||||
const progressErrors = document.getElementById('progressErrors');
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = percentage + '%';
|
||||
}
|
||||
if (progressPercentage) {
|
||||
progressPercentage.textContent = percentage + '%';
|
||||
}
|
||||
if (progressStatus) {
|
||||
const statusText = status === 'running' ? 'Migrating...' :
|
||||
status === 'completed' ? 'Migration completed!' :
|
||||
status === 'failed' ? 'Migration failed!' : 'Starting...';
|
||||
progressStatus.textContent = statusText;
|
||||
}
|
||||
if (currentCustomer) {
|
||||
currentCustomer.textContent = currentEmail ? `Current: ${currentEmail}` : `Processing ${current} of ${total}`;
|
||||
}
|
||||
if (progressAdded) {
|
||||
progressAdded.textContent = added;
|
||||
}
|
||||
if (progressUpdated) {
|
||||
progressUpdated.textContent = updated;
|
||||
}
|
||||
if (progressErrors) {
|
||||
progressErrors.textContent = errors;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteM2Customer(customerId, email, firstname, lastname) {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,27 @@
|
|||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; gap: 15px; flex-wrap: wrap;">
|
||||
<button id="dryRunCustomerMigrationBtn" class="btn btn-secondary" onclick="startCustomerMigration(true)">
|
||||
Run Dry Run (Check for Errors)
|
||||
</button>
|
||||
<button id="startCustomerMigrationBtn" class="btn btn-primary" onclick="startCustomerMigration(false)">
|
||||
<button id="startCustomerMigrationBtn" class="btn btn-primary">
|
||||
Start Customer Migration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="migrationProgressContainer" style="display: none; margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span id="progressStatus" style="font-weight: 600; color: #333;">Starting migration...</span>
|
||||
<span id="progressPercentage" style="font-weight: 600; color: #1976D2;">0%</span>
|
||||
</div>
|
||||
<div style="width: 100%; height: 24px; background-color: #e0e0e0; border-radius: 12px; overflow: hidden;">
|
||||
<div id="progressBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #1976D2 0%, #42a5f5 100%); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.85em; font-weight: 600;"></div>
|
||||
</div>
|
||||
<div id="progressDetails" style="margin-top: 8px; font-size: 0.9em; color: #666;">
|
||||
<span id="currentCustomer">-</span> |
|
||||
Added: <span id="progressAdded">0</span> |
|
||||
Updated: <span id="progressUpdated">0</span> |
|
||||
Errors: <span id="progressErrors">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Migration Logs Section -->
|
||||
|
|
@ -34,7 +48,7 @@
|
|||
<div class="log-container" id="customerMigrationLogContainer" style="display: block;">
|
||||
<div id="customerMigrationLogContent">
|
||||
<div class="log-entry" style="color: #999; font-style: italic;">
|
||||
No customer migration logs yet. Click "Run Dry Run" or "Start Customer Migration" to begin.
|
||||
No customer migration logs yet. Click "Start Customer Migration" to begin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +168,8 @@ class="btn btn-danger"
|
|||
<script>
|
||||
window.customerRoutes = {
|
||||
migrateCustomers: '{{ route("customers.migrate-customers") }}',
|
||||
deleteCustomer: '{{ route("customers.delete-customer", ["customerId" => ":id"]) }}'
|
||||
deleteCustomer: '{{ route("customers.delete-customer", ["customerId" => ":id"]) }}',
|
||||
migrationProgress: '{{ route("customers.migration-progress") }}'
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
|
|
|
|||
|
|
@ -63,5 +63,6 @@
|
|||
Route::prefix('customers')->name('customers.')->group(function () {
|
||||
Route::get('/', [CustomersController::class, 'index'])->name('index');
|
||||
Route::post('/migrate', [CustomersController::class, 'migrateCustomers'])->name('migrate-customers');
|
||||
Route::get('/migration-progress', [CustomersController::class, 'getMigrationProgress'])->name('migration-progress');
|
||||
Route::delete('/{customerId}', [CustomersController::class, 'deleteM2Customer'])->name('delete-customer');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue