Мощный AI-аналитик для выявления проблем с продажами, анализа показателей и рекомендаций по улучшению
Size
134.7 KB
Version
1.1.119
Created
Mar 24, 2026
Updated
23 days ago
1// ==UserScript==
2// @name Ozon AI Analyzer 5.0
3// @description Мощный AI-аналитик для выявления проблем с продажами, анализа показателей и рекомендаций по улучшению
4// @version 1.1.119
5// @match https://*.seller.ozon.ru/*
6// @icon https://st.ozone.ru/s3/seller-ui-static/icon/favicon32.png
7// @grant GM.getValue
8// @grant GM.setValue
9// @grant GM.xmlHttpRequest
10// ==/UserScript==
11(function() {
12
13 'use strict';
14
15
16 console.log('🚀 Ozon AI Аналитик Продаж запущен');
17
18
19 // Утилита для задержки
20
21 const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
22
23
24 // Парсинг процентов
25
26 function parsePercent(str) {
27
28 if (!str || str === '-' || str === '') return null;
29
30 const match = str.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
31
32 return match ? parseFloat(match[1]) : null;
33
34 }
35
36
37 // Парсинг цены (убираем пробелы между цифрами)
38 function parsePrice(str) {
39 if (!str || str === '-' || str === '') return null;
40 // Извлекаем первое число ДО знака процента
41 const match = str.match(/^([\d\s,.]+)/);
42 if (!match) return null;
43 // Убираем пробелы, затем все кроме цифр и точек
44 const cleaned = match[1].replace(/\s/g, '').replace(',', '.');
45 const num = parseFloat(cleaned);
46 return isNaN(num) ? null : num;
47 }
48
49
50 // Таблица с данными для расчета прибыли
51
52 const PRODUCT_COST_DATA = {
53
54 '72252': { cost: 158.4, commission: 0.30, delivery: 90 },
55 '71613': { cost: 108, commission: 0.30, delivery: 90 }
56 };
57
58 // Функция для загрузки данных о расходах из хранилища
59 async function loadCostData() {
60 try {
61 const savedData = await GM.getValue('product_cost_data', null);
62 if (savedData) {
63 const parsedData = JSON.parse(savedData);
64 // Объединяем с дефолтными данными
65 Object.assign(PRODUCT_COST_DATA, parsedData);
66 console.log('✅ Загружены данные о расходах из хранилища:', Object.keys(PRODUCT_COST_DATA).length, 'товаров');
67 }
68 } catch (error) {
69 console.error('❌ Ошибка загрузки данных о расходах:', error);
70 }
71 }
72
73 // Функция для сохранения данных о расходах
74 async function saveCostData(newData) {
75 try {
76 // Объединяем новые данные со старыми
77 Object.assign(PRODUCT_COST_DATA, newData);
78
79 // Сохраняем в хранилище
80 await GM.setValue('product_cost_data', JSON.stringify(PRODUCT_COST_DATA));
81 console.log('✅ Данные о расходах сохранены:', Object.keys(PRODUCT_COST_DATA).length, 'товаров');
82 return true;
83 } catch (error) {
84 console.error('❌ Ошибка сохранения данных о расходах:', error);
85 return false;
86 }
87 }
88
89 // Функция для парсинга файла с расходами
90 function parseCostDataFile(fileContent) {
91 try {
92 console.log('📄 Парсим файл с расходами...');
93 console.log('📄 Содержимое файла (первые 200 символов):', fileContent.substring(0, 200));
94
95 // Удаляем комментарии и лишние пробелы
96 let cleanContent = fileContent
97 .replace(/\/\/.*/g, '') // Удаляем однострочные комментарии
98 .replace(/\/\*[\s\S]*?\*\//g, '') // Удаляем многострочные комментарии
99 .trim();
100
101 console.log('📄 После удаления комментариев (первые 200 символов):', cleanContent.substring(0, 200));
102
103 // Пытаемся найти объект в формате { 'артикул': { cost: ..., commission: ..., delivery: ... } }
104 // Ищем первую открывающую скобку и последнюю закрывающую
105 const firstBrace = cleanContent.indexOf('{');
106 const lastBrace = cleanContent.lastIndexOf('}');
107
108 if (firstBrace === -1 || lastBrace === -1 || firstBrace >= lastBrace) {
109 throw new Error('Не найден объект с данными в файле');
110 }
111
112 // Извлекаем только объект
113 let objectString = cleanContent.substring(firstBrace, lastBrace + 1);
114
115 console.log('📄 Извлеченный объект (первые 200 символов):', objectString.substring(0, 200));
116
117 // Преобразуем JavaScript объект в валидный JSON
118 let jsonString = objectString
119 // Заменяем одинарные кавычки на двойные для ключей объектов
120 .replace(/'([^']+)':/g, '"$1":')
121 // Заменяем одинарные кавычки на двойные для значений-строк
122 .replace(/:\s*'([^']*)'/g, ': "$1"')
123 // Добавляем кавычки к ключам без кавычек (например, cost: 158.4 -> "cost": 158.4)
124 .replace(/(\w+):/g, '"$1":')
125 // Исправляем двойное кавычкование (если ключ уже был в кавычках)
126 .replace(/""([^"]+)"":/g, '"$1":');
127
128 console.log('📝 Преобразованный JSON (первые 300 символов):', jsonString.substring(0, 300));
129
130 // Парсим JSON
131 const parsedData = JSON.parse(jsonString);
132
133 console.log('✅ JSON успешно распарсен, найдено записей:', Object.keys(parsedData).length);
134
135 // Валидация данных
136 let validCount = 0;
137 let invalidCount = 0;
138 const validatedData = {};
139
140 for (const [article, data] of Object.entries(parsedData)) {
141 if (typeof data === 'object' &&
142 typeof data.cost === 'number' &&
143 typeof data.commission === 'number' &&
144 typeof data.delivery === 'number') {
145 validatedData[article] = data;
146 validCount++;
147 } else {
148 console.warn(`⚠️ Некорректные данные для артикула ${article}:`, data);
149 invalidCount++;
150 }
151 }
152
153 console.log(`✅ Валидация завершена: ${validCount} корректных, ${invalidCount} некорректных записей`);
154
155 if (validCount === 0) {
156 throw new Error('Не найдено корректных данных в файле');
157 }
158
159 return validatedData;
160
161 } catch (error) {
162 console.error('❌ Ошибка парсинга файла:', error);
163 console.error('❌ Стек ошибки:', error.stack);
164 throw new Error('Ошибка парсинга файла: ' + error.message);
165 }
166 }
167
168
169 // Функция расчета прибыли
170
171 function calculateProfit(article, revenue, orders, drr) {
172
173 const costData = PRODUCT_COST_DATA[article];
174
175 if (!costData || !revenue || !orders) return null;
176
177
178
179 // Расходы на рекламу = выручка * (ДРР / 100)
180
181 const adCost = drr ? (revenue * (drr / 100)) : 0;
182
183
184
185 // Прибыль = Выручка - (заказы * себестоимость) - (заказы * доставка) - (выручка * комиссия) - расходы на рекламу
186
187 const profit = revenue - (orders * costData.cost) - (orders * costData.delivery) - (revenue * costData.commission) - adCost;
188
189 return Math.round(profit); // Округляем до целых
190
191 }
192
193
194 // Класс для сбора данных о товарах
195
196 class ProductDataCollector {
197
198 constructor() {
199
200 this.products = [];
201
202 this.isCollecting = false;
203
204 this.analysisPeriodDays = 7; // По умолчанию 7 дней
205
206 this.totalRowData = null; // Данные из строки "Итого и среднее"
207
208 }
209
210
211 // Определение периода анализа из интерфейса
212 detectAnalysisPeriod() {
213 try {
214 console.log('🔍 Ищем период анализа в интерфейсе...');
215
216 // ПРИОРИТЕТ 1: Проверяем активную кнопку периода с полными датами
217 const periodButtons = document.querySelectorAll('button[data-active="true"]');
218 console.log(`🔘 Найдено активных кнопок: ${periodButtons.length}`);
219
220 for (const button of periodButtons) {
221 const buttonText = button.textContent.trim();
222 console.log(`🔘 Проверяем кнопку: "${buttonText}"`);
223
224 // Проверяем кнопки с полным диапазоном дат (например "24 – 30 ноября 2025")
225 // Используем \s для любых пробелов (включая ) и [-–-] для любых тире
226 const fullDateMatch = buttonText.match(/(\d{1,2})\s*[-–-]\s*(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+(\d{4})/i);
227 if (fullDateMatch) {
228 const startDay = parseInt(fullDateMatch[1]);
229 const endDay = parseInt(fullDateMatch[2]);
230 const days = endDay - startDay + 1;
231
232 console.log(`📅 Найден диапазон дат: ${startDay} - ${endDay} = ${days} дней`);
233
234 if (days > 0 && days <= 365) {
235 this.analysisPeriodDays = days;
236 console.log(`✅ Определен период по кнопке с полной датой: ${days} дней (${startDay}-${endDay})`);
237 return this.analysisPeriodDays;
238 }
239 }
240
241 // Маппинг кнопок на количество дней
242 const periodMap = {
243 'Сегодня': 1,
244 'Вчера': 1,
245 '7 дней': 7,
246 '28 дней': 28,
247 'Квартал': 90,
248 'Год': 365
249 };
250
251 if (periodMap[buttonText]) {
252 this.analysisPeriodDays = periodMap[buttonText];
253 console.log(`✅ Определен период по кнопке: ${this.analysisPeriodDays} дней (кнопка: "${buttonText}")`);
254 return this.analysisPeriodDays;
255 }
256 }
257
258 // ПРИОРИТЕТ 2: Ищем диапазон дат в тексте страницы
259 const allText = document.body.innerText;
260 console.log('🔍 Ищем даты в тексте страницы...');
261
262 // Паттерн: "30 нояб - 6 дек" или "9 нояб - 6 дек" или "07 сент - 6 дек"
263 let dateRangeMatch = allText.match(/(\d{1,2})\s*(янв|фев|мар|апр|мая|май|июн|июл|авг|сен|сент|окт|ноя|дек)[а-я]*\s*[--–]\s*(\d{1,2})\s*(янв|фев|мар|апр|мая|май|июн|июл|авг|сен|сент|окт|ноя|дек)[а-я]*/i);
264
265 if (dateRangeMatch) {
266 const startDay = parseInt(dateRangeMatch[1]);
267 const endDay = parseInt(dateRangeMatch[3]);
268 const startMonth = dateRangeMatch[2].toLowerCase();
269 const endMonth = dateRangeMatch[4].toLowerCase();
270
271 console.log(`📅 Найдены даты: ${startDay} ${startMonth} - ${endDay} ${endMonth}`);
272
273 // Маппинг месяцев
274 const monthMap = {
275 'янв': 1, 'фев': 2, 'мар': 3, 'апр': 4, 'мая': 5, 'май': 5,
276 'июн': 6, 'июл': 7, 'авг': 8, 'сен': 9, 'сент': 9, 'окт': 10, 'ноя': 11, 'дек': 12
277 };
278
279 const startMonthNum = monthMap[startMonth.substring(0, 3)];
280 const endMonthNum = monthMap[endMonth.substring(0, 3)];
281
282 console.log(`📅 Месяцы: ${startMonthNum} → ${endMonthNum}`);
283
284 let days;
285
286 if (startMonthNum === endMonthNum) {
287 // Даты в одном месяце
288 days = endDay - startDay + 1;
289 console.log(`📅 Один месяц: ${days} дней`);
290 } else {
291 // Разные месяцы - считаем точно
292 const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
293
294 // Дни от начальной даты до конца начального месяца
295 const daysInStartMonth = daysInMonth[startMonthNum - 1];
296 const daysFromStart = daysInStartMonth - startDay + 1;
297
298 // Дни полных месяцев между начальным и конечным
299 let daysInBetween = 0;
300 let currentMonth = startMonthNum;
301 while (true) {
302 currentMonth++;
303 if (currentMonth > 12) currentMonth = 1; // Переход через год
304 if (currentMonth === endMonthNum) break;
305 daysInBetween += daysInMonth[currentMonth - 1];
306 }
307
308 // Дни конечного месяца
309 const daysInEndMonth = endDay;
310
311 days = daysFromStart + daysInBetween + daysInEndMonth;
312 console.log(`📅 Несколько месяцев: ${daysFromStart} + ${daysInBetween} + ${daysInEndMonth} = ${days} дней`);
313 }
314
315 if (days > 0 && days <= 365) {
316 this.analysisPeriodDays = days;
317 console.log(`✅ Определен период анализа: ${days} дней`);
318 return days;
319 }
320 }
321
322 // Паттерн 3: Ищем текст типа "за 7 дней", "за 14 дней", "за 28 дней"
323 const periodMatch = allText.match(/за\s+(\d+)\s+дн/i);
324 if (periodMatch) {
325 const days = parseInt(periodMatch[1]);
326 if (days > 0 && days <= 365) {
327 this.analysisPeriodDays = days;
328 console.log(`✅ Определен период анализа: ${days} дней`);
329 return days;
330 }
331 }
332
333 console.log(`⚠️ Период анализа не определен, используем по умолчанию: ${this.analysisPeriodDays} дней`);
334 return this.analysisPeriodDays;
335 } catch (error) {
336 console.error('Ошибка определения периода:', error);
337 return this.analysisPeriodDays;
338 }
339 }
340
341 // Парсинг данных из строки "Итого и среднее"
342 parseTotalRow() {
343 try {
344 console.log('📊 Парсим строку "Итого и среднее"...');
345
346 // Ищем строку "Итого и среднее" по тексту
347 const allRows = document.querySelectorAll('tr');
348 let totalRow = null;
349
350 for (const row of allRows) {
351 const rowText = row.textContent.trim();
352 if (rowText.includes('Итого и среднее') || rowText.includes('Итого')) {
353 totalRow = row;
354 console.log('✅ Найдена строка "Итого и среднее"');
355 break;
356 }
357 }
358
359 if (!totalRow) {
360 console.warn('⚠️ Строка "Итого и среднее" не найдена');
361 return null;
362 }
363
364 const cells = totalRow.querySelectorAll('th, td');
365
366 console.log(`📊 Найдено ячеек в строке "Итого и среднее": ${cells.length}`);
367
368 // Функция для извлечения значения и изменения из ячейки
369 const parseCell = (cell, cellIndex) => {
370 if (!cell) return { value: null, change: null };
371
372 console.log(`DEBUG: Парсим ячейку ${cellIndex}`);
373
374 let value = null;
375 let change = null;
376
377 // Ищем все div'ы в ячейке
378 const allDivs = Array.from(cell.querySelectorAll('div'));
379
380 // Ищем значение - это обычно первый div с числом (не процент с +/-)
381 for (const div of allDivs) {
382 const text = div.textContent.trim();
383 // Пропускаем пустые, tooltip и проценты с +/-
384 if (text && !text.match(/^[+-]\d+/) && div.children.length === 0) {
385 value = text;
386 console.log(`DEBUG: Ячейка ${cellIndex} - найдено значение: "${value}"`);
387 break;
388 }
389 }
390
391 // Парсим значение
392 if (value) {
393 const cleanText = value.replace(/\s/g, '').replace(/\u00A0/g, '');
394
395 if (cleanText.includes('₽')) {
396 const numStr = cleanText.replace('₽', '');
397 value = parseFloat(numStr);
398 } else if (cleanText.includes('%')) {
399 const numStr = cleanText.replace('%', '').replace(',', '.');
400 value = parseFloat(numStr);
401 } else {
402 value = parseFloat(cleanText);
403 }
404
405 console.log(`DEBUG: Ячейка ${cellIndex} - распарсенное значение: ${value}`);
406 }
407
408 // Ищем изменение - это div с процентом и знаком +/-
409 for (const div of allDivs) {
410 const text = div.textContent.trim();
411 if (text.match(/^[+-]\d+.*%/)) {
412 change = text;
413 console.log(`DEBUG: Ячейка ${cellIndex} - найдено изменение: "${change}"`);
414
415 const changeMatch = change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
416 if (changeMatch) {
417 change = parseFloat(changeMatch[1]);
418 console.log(`DEBUG: Ячейка ${cellIndex} - распарсенное изменение: ${change}%`);
419 }
420 break;
421 }
422 }
423
424 return { value, change };
425 };
426
427 // Парсим нужные ячейки
428 const data = {
429 revenue: parseCell(cells[2], 2), // Выручка
430 impressions: parseCell(cells[5], 5), // Показы
431 ctr: parseCell(cells[12], 12), // CTR
432 cardVisits: parseCell(cells[13], 13), // Посещения карточки
433 crl: parseCell(cells[15], 15), // CRL
434 cartAdditions: parseCell(cells[18], 18), // Добавления в корзину
435 orders: parseCell(cells[20], 20), // Заказы
436 avgPrice: parseCell(cells[28], 28), // Средняя цена
437 drr: parseCell(cells[32], 32) // ДРР
438 };
439
440 console.log('✅ Данные из строки "Итого и среднее":', data);
441
442 this.totalRowData = data;
443 return data;
444
445 } catch (error) {
446 console.error('❌ Ошибка парсинга строки "Итого и среднее":', error);
447 return null;
448 }
449 }
450
451
452 // Автоматическая подгрузка всех товаров
453
454 async loadAllProducts() {
455
456 console.log('📦 Начинаем загрузку всех товаров...');
457
458 this.isCollecting = true;
459
460
461 let previousCount = 0;
462
463 let stableCount = 0; // Счетчик стабильных попыток
464
465 let attempts = 0;
466
467 const maxAttempts = 300; // Увеличили максимум попыток до 300
468
469 const maxStableAttempts = 3; // Уменьшили до 3 стабильных попыток для ускорения
470
471
472 while (attempts < maxAttempts) {
473
474 // Ищем кнопку "Показать ещё" по тексту (универсальный подход)
475 const loadMoreBtn = Array.from(document.querySelectorAll('button')).find(btn =>
476 btn.textContent.includes('Показать ещё') ||
477 btn.textContent.includes('Показать еще')
478 );
479
480 if (!loadMoreBtn) {
481
482 console.log('✅ Кнопка "Показать ещё" не найдена - все товары загружены');
483
484 break;
485
486 }
487
488
489 // Проверяем, не отключена ли кнопка
490
491 if (loadMoreBtn.disabled || loadMoreBtn.classList.contains('disabled')) {
492
493 console.log('✅ Кнопка "Показать ещё" отключена - все товары загружены');
494
495 break;
496
497 }
498
499
500 // Проверяем, есть ли товары с нулевой выручкой (значит дошли до конца)
501 const rows = document.querySelectorAll('table tbody tr');
502
503 let hasZeroRevenue = false;
504
505
506 for (const row of rows) {
507
508 const cells = row.querySelectorAll('td');
509
510 if (cells.length >= 3) {
511
512 const revenueText = cells[2].textContent.trim();
513
514 // Проверяем, есть ли "0 ₽" или "0₽" в тексте выручки
515
516 if (revenueText.match(/^0\s*₽/) || revenueText === '0') {
517
518 hasZeroRevenue = true;
519
520 console.log('✅ Найден товар с нулевой выручкой - останавливаем загрузку');
521
522 break;
523
524 }
525
526 }
527
528 }
529
530 if (hasZeroRevenue) {
531 console.log('✅ Достигнут конец списка активных товаров');
532 break;
533 }
534
535
536 // Прокручиваем к кнопке, чтобы она была видна
537
538 loadMoreBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
539
540 await delay(500);
541
542
543 console.log(`🔄 Клик по кнопке "Показать ещё" (попытка ${attempts + 1})`);
544
545 loadMoreBtn.click();
546
547
548 // Умное ожидание: проверяем появление новых товаров
549 const startTime = Date.now();
550 const maxWaitTime = 5000; // Увеличили до 5 секунд
551 let newRowsAppeared = false;
552
553 while (Date.now() - startTime < maxWaitTime) {
554 await delay(300); // Проверяем каждые 300мс
555 const currentCount = document.querySelectorAll('table tbody tr').length;
556
557 if (currentCount > previousCount) {
558 newRowsAppeared = true;
559 console.log(`✅ Новые товары загружены: ${currentCount - previousCount} шт`);
560 break;
561 }
562 }
563
564 if (!newRowsAppeared) {
565 console.log('⏸️ Новые товары не появились за 5 секунд');
566 }
567
568 const currentCount = document.querySelectorAll('table tbody tr').length;
569
570 console.log(`📊 Загружено товаров: ${currentCount} (было: ${previousCount})`);
571
572
573 if (currentCount === previousCount) {
574
575 stableCount++;
576
577 console.log(`⏸️ Количество не изменилось (${stableCount}/${maxStableAttempts})`);
578
579
580
581 if (stableCount >= maxStableAttempts) {
582
583 console.log('✅ Количество товаров стабильно - загрузка завершена');
584
585 break;
586
587 }
588
589 } else {
590
591 stableCount = 0; // Сбрасываем счетчик, если количество изменилось
592
593 }
594
595
596 previousCount = currentCount;
597
598 attempts++;
599
600 }
601
602
603 const finalCount = document.querySelectorAll('table tbody tr').length;
604
605 console.log(`✅ Загрузка завершена. Всего товаров: ${finalCount}`);
606
607 this.isCollecting = false;
608
609 }
610
611
612 // Сбор данных из таблицы
613
614 collectProductData() {
615
616 console.log('📊 Собираем данные о товарах...');
617
618 this.products = []; // Очищаем массив перед сбором
619
620 // Определяем период анализа
621 this.detectAnalysisPeriod();
622
623 // Парсим строку "Итого и среднее"
624 this.parseTotalRow();
625
626
627 // Ищем строки товаров в tbody таблицы
628 const rows = document.querySelectorAll('table tbody tr');
629
630 console.log(`Найдено строк в таблице: ${rows.length}`);
631
632 // Используем Set для отслеживания уникальных артикулов
633 const seenArticles = new Set();
634
635 rows.forEach((row, index) => {
636
637 try {
638
639 const cells = row.querySelectorAll('td');
640
641 if (cells.length < 10) {
642 console.log(`⚠️ Строка ${index}: недостаточно ячеек (${cells.length}), пропускаем`);
643 return;
644 }
645
646 // Проверяем, что это не строка "Итого и среднее"
647 const rowText = row.textContent.trim();
648 if (rowText.includes('Итого и среднее') || rowText.includes('Итого')) {
649 console.log(`⚠️ Строка ${index}: это строка "Итого", пропускаем`);
650 return;
651 }
652
653 // Извлекаем данные из ячеек
654
655 const productData = this.extractProductData(cells);
656
657 if (productData) {
658 // Проверяем уникальность по артикулу
659 if (!seenArticles.has(productData.article)) {
660 seenArticles.add(productData.article);
661 this.products.push(productData);
662 console.log(`✅ Добавлен товар: ${productData.name} (Арт. ${productData.article})`);
663 } else {
664 console.log(`⚠️ Пропускаем дубликат товара с артикулом: ${productData.article}`);
665 }
666 }
667
668 } catch (error) {
669
670 console.error(`Ошибка при обработке строки ${index}:`, error);
671
672 }
673
674 });
675
676
677 console.log(`✅ Собрано товаров: ${this.products.length}`);
678
679 return this.products;
680
681 }
682
683
684 // Извлечение данных о товаре из ячеек
685
686 extractProductData(cells) {
687
688 try {
689
690 // Название и артикул (первая ячейка)
691
692 const nameCell = cells[0];
693
694 // Ищем ссылку с названием товара - это вторая ссылка или ссылка с текстом
695 const allLinks = nameCell.querySelectorAll('a');
696 let nameLink = null;
697
698 // Ищем ссылку, которая содержит текст (не только картинку)
699 for (const link of allLinks) {
700 const linkText = link.textContent.trim();
701 if (linkText && linkText.length > 10) { // Название товара обычно длиннее 10 символов
702 nameLink = link;
703 break;
704 }
705 }
706
707 // Если не нашли ссылку с текстом, берем текст из всей ячейки
708 const cellText = nameCell.textContent.trim();
709
710 const name = nameLink ? nameLink.textContent.trim() : cellText.split('Арт.')[0].trim();
711
712 const articleMatch = cellText.match(/Арт\.\s*(\d+)/);
713
714 const article = articleMatch ? articleMatch[1] : '';
715
716
717 if (!name || !article) {
718 console.log(`⚠️ Не удалось извлечь название или артикул. Название: "${name}", Артикул: "${article}"`);
719 return null;
720 }
721
722
723 // Получаем текстовое содержимое всех ячеек
724
725 const cellTexts = Array.from(cells).map(cell => cell.textContent.trim());
726
727 // Функция для извлечения значения и изменения из ячейки
728 const getValueAndChange = (cell) => {
729 // Ищем все div'ы в ячейке
730 const allDivs = Array.from(cell.querySelectorAll('div'));
731
732 let value = null;
733 let change = null;
734
735 // Ищем значение - это обычно первый div с числом (не процент)
736 for (const div of allDivs) {
737 const text = div.textContent.trim();
738 // Пропускаем пустые, tooltip и проценты с +/-
739 if (text && !text.match(/^[+-]\d+/) && div.children.length === 0) {
740 value = text;
741 break;
742 }
743 }
744
745 // Ищем изменение - это div с процентом и знаком +/-
746 for (const div of allDivs) {
747 const text = div.textContent.trim();
748 if (text.match(/^[+-]\d+.*%/)) {
749 change = text;
750 break;
751 }
752 }
753
754 return { value, change };
755 };
756
757
758 // --- ПАРСИНГ ЯЧЕЙКИ 2 (Выручка) ---
759 let revenue = null;
760 let revenueChange = null;
761
762 if (cells[2]) {
763 const data = getValueAndChange(cells[2]);
764 if (data.value) {
765 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '').replace('₽', '');
766 revenue = parseFloat(valueStr);
767 console.log(`DEBUG: Ячейка 2 - извлечено выручки: ${revenue} из "${data.value}"`);
768 }
769 if (data.change) {
770 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
771 if (changeMatch) {
772 revenueChange = parseFloat(changeMatch[1]);
773 console.log(`DEBUG: Ячейка 2 - извлечено изменение: ${revenueChange}% из "${data.change}"`);
774 }
775 }
776 }
777
778
779 // --- ПАРСИНГ ЯЧЕЙКИ 5 (Показы всего) ---
780 let impressions = null;
781 let impressionsChange = null;
782
783 if (cells[5]) {
784 const data = getValueAndChange(cells[5]);
785 if (data.value) {
786 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '');
787 impressions = parseInt(valueStr, 10);
788 console.log(`DEBUG: Ячейка 5 - извлечено показов: ${impressions} из "${data.value}"`);
789 }
790 if (data.change) {
791 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
792 if (changeMatch) {
793 impressionsChange = parseFloat(changeMatch[1]);
794 console.log(`DEBUG: Ячейка 5 - извлечено изменение: ${impressionsChange}% из "${data.change}"`);
795 }
796 }
797 }
798
799
800 // --- ПАРСИНГ ЯЧЕЙКИ 12 (Конверсия из поиска и каталога в карточку - CTR) ---
801 let conversionCatalogToCard = null;
802 let conversionCatalogToCardChange = null;
803
804 if (cells[12]) {
805 const data = getValueAndChange(cells[12]);
806 if (data.value) {
807 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '').replace('%', '');
808 conversionCatalogToCard = parseFloat(valueStr);
809 console.log(`DEBUG: Ячейка 12 - извлечено CTR: ${conversionCatalogToCard}% из "${data.value}"`);
810 }
811 if (data.change) {
812 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
813 if (changeMatch) {
814 conversionCatalogToCardChange = parseFloat(changeMatch[1]);
815 console.log(`DEBUG: Ячейка 12 - извлечено изменение: ${conversionCatalogToCardChange}% из "${data.change}"`);
816 }
817 }
818 }
819
820
821 // --- ПАРСИНГ ЯЧЕЙКИ 13 (Посещения карточки товара) ---
822 let cardVisits = null;
823 let cardVisitsChange = null;
824
825 if (cells[13]) {
826 const data = getValueAndChange(cells[13]);
827 if (data.value) {
828 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '');
829 cardVisits = parseInt(valueStr, 10);
830 console.log(`DEBUG: Ячейка 13 - извлечено посещений: ${cardVisits} из "${data.value}"`);
831 }
832 if (data.change) {
833 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
834 if (changeMatch) {
835 cardVisitsChange = parseFloat(changeMatch[1]);
836 console.log(`DEBUG: Ячейка 13 - извлечено изменение: ${cardVisitsChange}% из "${data.change}"`);
837 }
838 }
839 }
840
841
842 // --- ПАРСИНГ ЯЧЕЙКИ 15 (Конверсия из карточки в корзину - CRL) ---
843 let conversionCardToCart = null;
844 let conversionCardToCartChange = null;
845
846 if (cells[15]) {
847 const data = getValueAndChange(cells[15]);
848 if (data.value) {
849 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '').replace('%', '');
850 conversionCardToCart = parseFloat(valueStr);
851 console.log(`DEBUG: Ячейка 15 - извлечено CRL: ${conversionCardToCart}% из "${data.value}"`);
852 }
853 if (data.change) {
854 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
855 if (changeMatch) {
856 conversionCardToCartChange = parseFloat(changeMatch[1]);
857 console.log(`DEBUG: Ячейка 15 - извлечено изменение: ${conversionCardToCartChange}% из "${data.change}"`);
858 }
859 }
860 }
861
862
863 // --- ПАРСИНГ ЯЧЕЙКИ 18 (Добавления в корзину) ---
864 let cartAdditions = null;
865 let cartAdditionsChange = null;
866
867 if (cells[18]) {
868 const data = getValueAndChange(cells[18]);
869 if (data.value) {
870 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '');
871 cartAdditions = parseInt(valueStr, 10);
872 console.log(`DEBUG: Ячейка 18 - извлечено добавлений в корзину: ${cartAdditions} из "${data.value}"`);
873 }
874 if (data.change) {
875 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
876 if (changeMatch) {
877 cartAdditionsChange = parseFloat(changeMatch[1]);
878 console.log(`DEBUG: Ячейка 18 - извлечено изменение: ${cartAdditionsChange}% из "${data.change}"`);
879 }
880 }
881 }
882
883
884 // --- ПАРСИНГ ЯЧЕЙКИ 20 (Заказы и изменение заказов) ---
885 let orders = null;
886 let ordersChange = null;
887
888 if (cells[20]) {
889 const data = getValueAndChange(cells[20]);
890 if (data.value) {
891 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '');
892 orders = parseInt(valueStr, 10);
893 console.log(`DEBUG: Ячейка 20 - извлечено заказов: ${orders} из "${data.value}"`);
894 }
895 if (data.change) {
896 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
897 if (changeMatch) {
898 ordersChange = parseFloat(changeMatch[1]);
899 console.log(`DEBUG: Ячейка 20 - извлечено изменение: ${ordersChange}% из "${data.change}"`);
900 }
901 }
902 }
903
904
905 // CR - высчитываем: Заказано товаров / Посещения карточки товаров
906
907 const cr = (orders && cardVisits && cardVisits > 0) ? parseFloat(((orders / cardVisits) * 100).toFixed(1)) : null;
908
909 const crChange = null; // Изменение CR нужно высчитывать отдельно
910
911
912
913 // --- ПАРСИНГ ЯЧЕЙКИ 32 (ДРР) ---
914 let drr = null;
915 let drrChange = null;
916
917 if (cells[32]) {
918 const data = getValueAndChange(cells[32]);
919 if (data.value) {
920 const valueStr = data.value.replace(/\s/g, '').replace(/\u00A0/g, '').replace('%', '');
921 drr = parseFloat(valueStr);
922 console.log(`DEBUG: Ячейка 32 - извлечено ДРР: ${drr}% из "${data.value}"`);
923 }
924 if (data.change) {
925 const changeMatch = data.change.match(/([+-]?\d+(?:\.\d+)?)\s*%/);
926 if (changeMatch) {
927 drrChange = parseFloat(changeMatch[1]);
928 console.log(`DEBUG: Ячейка 32 - извлечено изменение ДРР: ${drrChange}% из "${data.change}"`);
929 }
930 }
931 }
932
933
934
935 // Остаток на конец периода - индекс 35
936
937 const stockText = cellTexts[35] || '';
938
939 const stockMatch = stockText.match(/(\d+)/);
940
941 const stock = stockMatch ? parseInt(stockMatch[1]) : null;
942
943
944
945 // Средняя цена - индекс 28 (используем parsePrice для корректного парсинга)
946
947 const avgPrice = parsePrice(cellTexts[28]);
948
949 const avgPriceChange = parsePercent(cellTexts[28]);
950
951
952
953 // Среднее время доставки - индекс 37
954
955 const deliveryTime = cellTexts[37] || null;
956
957
958
959 // Рассчитываем прибыль
960
961 const profit = calculateProfit(article, revenue, orders, drr);
962
963
964
965 // Рассчитываем прибыль в процентах от выручки
966 const profitPercent = (profit !== null && revenue && revenue !== 0) ?
967 parseFloat(((profit / revenue) * 100).toFixed(1)) : null;
968
969 // Рассчитываем изменение прибыли в процентах
970 let profitChange = null;
971 if (profit !== null && revenueChange !== null && ordersChange !== null) {
972 // Рассчитываем предыдущую выручку и заказы
973 const prevRevenue = revenue / (1 + revenueChange / 100);
974 const prevOrders = orders / (1 + ordersChange / 100);
975 const prevDrr = drr / (1 + drrChange / 100);
976
977 // Рассчитываем предыдущую прибыль
978 const prevProfit = calculateProfit(article, prevRevenue, prevOrders, prevDrr);
979
980 if (prevProfit !== null && prevProfit !== 0) {
981 profitChange = parseFloat((((profit - prevProfit) / Math.abs(prevProfit)) * 100).toFixed(1));
982 }
983
984 // Логируем детальный расчет для отладки
985 console.log(`💰 РАСЧЕТ ПРИБЫЛИ для ${article}:`);
986 console.log(` Текущий период: выручка=${revenue}₽, заказы=${orders}, ДРР=${drr}%, прибыль=${profit}₽`);
987 console.log(` Предыдущий период: выручка=${Math.round(prevRevenue)}₽, заказы=${Math.round(prevOrders)}, ДРР=${prevDrr}%, прибыль=${prevProfit}₽`);
988 console.log(` Изменение прибыли: ${profitChange}%`);
989 }
990
991 // Рассчитываем комиссию и себестоимость
992 const costData = PRODUCT_COST_DATA[article];
993 const totalCommission = costData && revenue ?
994 Math.round((orders * costData.delivery) + (revenue * costData.commission)) : null;
995 const totalCommissionPercent = (totalCommission !== null && revenue && revenue !== 0) ?
996 parseFloat(((totalCommission / revenue) * 100).toFixed(1)) : null;
997
998 const totalCost = costData && orders ? Math.round(orders * costData.cost) : null;
999 const totalCostPercent = (totalCost !== null && revenue && revenue !== 0) ?
1000 parseFloat(((totalCost / revenue) * 100).toFixed(1)) : null;
1001
1002 // ИСПРАВЛЕНИЕ: Рассчитываем "на дней" правильно
1003 // Для периода в 1 день (Вчера/Сегодня): остаток / заказы = дней
1004 // Для периода >1 дня: остаток / (заказы / период) = дней
1005 let daysOfStock = null;
1006 if (orders && stock !== null && orders > 0 && this.analysisPeriodDays > 0) {
1007 const avgDailyOrders = orders / this.analysisPeriodDays;
1008 daysOfStock = Math.floor(stock / avgDailyOrders);
1009 }
1010
1011 // Логируем расчет для отладки
1012 console.log(`📊 Артикул ${article}: остаток=${stock}, заказы=${orders}, период=${this.analysisPeriodDays} дней, среднедневные заказы=${orders && this.analysisPeriodDays > 0 ? (orders / this.analysisPeriodDays).toFixed(2) : 'н/д'}, на дней=${daysOfStock}`);
1013
1014
1015 const product = {
1016
1017 name,
1018
1019 article,
1020
1021 revenue,
1022
1023 revenueChange,
1024
1025 orders,
1026
1027 ordersChange,
1028
1029 impressions,
1030
1031 impressionsChange,
1032
1033 cardVisits,
1034
1035 cardVisitsChange,
1036
1037 conversionCatalogToCard,
1038
1039 conversionCatalogToCardChange,
1040
1041 conversionCardToCart,
1042
1043 conversionCardToCartChange,
1044
1045 cartAdditions,
1046
1047 cartAdditionsChange,
1048
1049 cr,
1050
1051 crChange,
1052
1053 avgPrice,
1054
1055 avgPriceChange,
1056
1057 drr,
1058
1059 drrChange,
1060
1061 stock,
1062
1063 deliveryTime,
1064
1065 daysOfStock,
1066
1067 profit,
1068
1069 profitPercent,
1070
1071 profitChange,
1072
1073 totalCommission,
1074
1075 totalCommissionPercent,
1076
1077 totalCost,
1078
1079 totalCostPercent,
1080
1081 rawData: cellTexts
1082
1083 };
1084
1085
1086 return product;
1087
1088 } catch (error) {
1089
1090 console.error('Ошибка извлечения данных товара:', error);
1091
1092 return null;
1093
1094 }
1095
1096 }
1097
1098 }
1099
1100
1101 // Класс для AI анализа
1102
1103 class AIAnalyzer {
1104
1105 // Батч-анализ товаров с умной фильтрацией
1106
1107 async analyzeProducts(products, onProgress) {
1108
1109 console.log('🤖 Начинаем AI анализ товаров...');
1110
1111
1112
1113 // Сначала вычисляем средние показатели
1114
1115 const avgMetrics = this.calculateAverageMetrics(products);
1116
1117 console.log('📊 Средние показатели:', avgMetrics);
1118
1119
1120
1121 // Разделяем товары на приоритетные и обычные
1122
1123 const priorityProducts = [];
1124
1125 const normalProducts = [];
1126
1127
1128
1129 products.forEach(product => {
1130
1131 const needsAIAnalysis = this.needsDetailedAnalysis(product, avgMetrics);
1132
1133 if (needsAIAnalysis) {
1134
1135 priorityProducts.push(product);
1136
1137 } else {
1138
1139 normalProducts.push(product);
1140
1141 }
1142
1143 });
1144
1145
1146
1147 console.log(`📊 Приоритетных товаров для AI анализа: ${priorityProducts.length}`);
1148
1149 console.log(`📊 Обычных товаров (базовый анализ): ${normalProducts.length}`);
1150
1151
1152
1153 const analyzedProducts = [];
1154
1155 const batchSize = 10; // Уменьшили до 10 товаров одновременно
1156
1157
1158
1159 // Сначала быстро обрабатываем обычные товары (без AI)
1160
1161 normalProducts.forEach(product => {
1162
1163 analyzedProducts.push({
1164
1165 ...product,
1166
1167 analysis: this.basicAnalysis(product, avgMetrics)
1168
1169 });
1170
1171 });
1172
1173
1174
1175 // Обновляем прогресс после базового анализа
1176
1177 if (onProgress) {
1178
1179 const percentage = Math.round((normalProducts.length / products.length) * 100);
1180
1181 const remaining = Math.ceil((priorityProducts.length / batchSize) * 2);
1182
1183 onProgress(normalProducts.length, products.length, percentage, remaining);
1184
1185 }
1186
1187
1188
1189 // Анализируем приоритетные товары с AI
1190
1191 for (let i = 0; i < priorityProducts.length; i += batchSize) {
1192
1193 const batch = priorityProducts.slice(i, i + batchSize);
1194
1195 const batchPromises = batch.map(product => this.analyzeProduct(product, avgMetrics, true));
1196
1197
1198
1199 const batchResults = await Promise.all(batchPromises);
1200
1201
1202
1203 batchResults.forEach((analysis, idx) => {
1204
1205 analyzedProducts.push({
1206
1207 ...batch[idx],
1208
1209 analysis
1210
1211 });
1212
1213 });
1214
1215
1216
1217 const progress = Math.min(i + batchSize, priorityProducts.length);
1218
1219 const totalProgress = normalProducts.length + progress;
1220
1221 const percentage = Math.round((totalProgress / products.length) * 100);
1222
1223 const remainingProducts = priorityProducts.length - progress;
1224 const remaining = Math.ceil(remainingProducts * 2); // 2 секунды на товар
1225
1226
1227
1228 if (onProgress) {
1229
1230 onProgress(totalProgress, products.length, percentage, remaining);
1231
1232 }
1233
1234
1235
1236 console.log(`✅ Проанализировано ${progress} из ${priorityProducts.length} приоритетных товаров`);
1237
1238 }
1239
1240
1241
1242 if (onProgress) {
1243
1244 onProgress(products.length, products.length, 100, 0);
1245
1246 }
1247
1248
1249 return analyzedProducts;
1250
1251 }
1252
1253
1254 // Базовый анализ товаров с фильтрацией по прибыли
1255
1256 async analyzeProductsBasic(products, onProgress) {
1257 console.log('📊 Начинаем базовый анализ товаров (без AI)...');
1258
1259 const avgMetrics = this.calculateAverageMetrics(products);
1260 console.log('📊 Средние показатели:', avgMetrics);
1261
1262 const analyzedProducts = [];
1263
1264 products.forEach((product, index) => {
1265 analyzedProducts.push({
1266 ...product,
1267 analysis: this.basicAnalysis(product, avgMetrics)
1268 });
1269
1270 // Обновляем прогресс
1271 if (onProgress && (index % 10 === 0 || index === products.length - 1)) {
1272 const percentage = Math.round(((index + 1) / products.length) * 100);
1273 onProgress(index + 1, products.length, percentage, 0);
1274 }
1275 });
1276
1277 console.log(`✅ Базовый анализ завершен: ${analyzedProducts.length} товаров`);
1278 return analyzedProducts;
1279 }
1280
1281
1282 // Определяем, нужен ли детальный AI анализ
1283
1284 needsDetailedAnalysis(product, avgMetrics) {
1285
1286 const threshold = 5; // Порог отклонения 5%
1287
1288
1289
1290 // Если есть значительное падение выручки
1291
1292 if (product.revenueChange !== null && product.revenueChange < avgMetrics.revenueChange - threshold) {
1293
1294 return true;
1295
1296 }
1297
1298
1299
1300 // Если есть значительное падение заказов
1301
1302 if (product.ordersChange !== null && product.ordersChange < avgMetrics.ordersChange - threshold) {
1303
1304 return true;
1305
1306 }
1307
1308
1309
1310 // Если высокий ДРР
1311
1312 if (product.drr !== null && product.drr > 20) {
1313
1314 return true;
1315
1316 }
1317
1318
1319
1320 // Если низкие остатки
1321
1322 const daysOfStock = product.daysOfStock;
1323
1324 if (daysOfStock !== null && daysOfStock < 49) {
1325
1326 return true;
1327
1328 }
1329
1330
1331
1332 // Если значительный рост (для масштабирования)
1333
1334 if (product.revenueChange !== null && product.revenueChange > avgMetrics.revenueChange + 15) {
1335
1336 return true;
1337
1338 }
1339
1340
1341
1342 return false;
1343
1344 }
1345
1346
1347 // Базовый анализ без AI (для товаров без проблем)
1348
1349 basicAnalysis(product, avgMetrics) {
1350
1351 const daysOfStock = product.daysOfStock;
1352
1353 const isLowStock = daysOfStock !== null && daysOfStock <= 14;
1354
1355 const isHighDRR = product.drr !== null && product.drr > 20;
1356
1357 const isOutOfStock = product.stock === 0 || product.stock === null || (daysOfStock !== null && daysOfStock < 2);
1358
1359
1360 const isLowDRR = product.drr !== null && product.drr <= 17;
1361
1362 const isGrowth = this.detectGrowth(product, avgMetrics);
1363
1364 const isLowImpressions = product.impressionsChange !== null && product.impressionsChange <= -20;
1365
1366 const isLowCR = (product.conversionCardToCartChange !== null && product.conversionCardToCartChange <= -20) ||
1367
1368 (product.conversionCatalogToCardChange !== null && product.conversionCatalogToCardChange <= -20);
1369
1370 const isLowProfit = product.profit !== null && product.revenue !== null && product.revenue > 0 &&
1371
1372 (product.profit / product.revenue) < 0.25;
1373
1374
1375
1376 // Проверяем время доставки (парсим число из строки типа "35 ч")
1377
1378 const deliveryHours = product.deliveryTime ? parseInt(product.deliveryTime) : null;
1379
1380 const isBadDeliveryTime = deliveryHours !== null && deliveryHours >= 35;
1381
1382 // Проверяем падение выручки
1383 const isRevenueDrop = this.detectRevenueDrop(product);
1384
1385 // Проверяем рост выручки
1386 const isRevenueGrowth = this.detectRevenueGrowth(product);
1387
1388
1389 // Генерируем рекомендации на основе проблем
1390
1391 const recommendations = [];
1392
1393
1394 if (isOutOfStock) {
1395
1396 recommendations.push('Out-ofStock - Срочно поставить товар!');
1397
1398 }
1399
1400
1401
1402 if (isLowStock) {
1403
1404 recommendations.push('Низкие остатки - Поставить товар, повысить цену, снизить ДРР');
1405
1406 }
1407
1408
1409 if (isHighDRR) {
1410
1411 recommendations.push('Высокий ДРР - Понизить ДРР, снизить цену');
1412
1413 }
1414
1415
1416 if (isLowDRR) {
1417
1418 recommendations.push('Повысить ДРР - Повысить ДРР, повысить цену');
1419
1420 }
1421
1422
1423 if (isLowImpressions) {
1424
1425 recommendations.push('Упали Показы - Проверить остатки, повысить ДРР, снизить цену');
1426
1427 }
1428
1429
1430
1431 if (isLowCR) {
1432
1433 recommendations.push('Повысить CR - Проверить остатки, снизить цену');
1434
1435 }
1436
1437
1438
1439 if (isLowProfit) {
1440
1441 recommendations.push('Низкая прибыль - снизить ДРР, проверить цену');
1442
1443 }
1444
1445
1446
1447 if (isBadDeliveryTime) {
1448
1449 recommendations.push('Плохое время - проверить остатки, сделать поставку');
1450
1451 }
1452
1453
1454 if (isGrowth) {
1455
1456 recommendations.push('Рост - поднять цену');
1457
1458 }
1459
1460 // Рекомендация добавляется только при падении 10% и более
1461 if (isRevenueDrop && product.revenueChange <= -10) {
1462
1463 recommendations.push('Упала выручка - проверить остатки, цену, ДРР и конверсию');
1464
1465 }
1466
1467 // Рекомендация при росте выручки
1468 if (isRevenueGrowth && product.revenueChange >= 10) {
1469 recommendations.push('Выросла выручка - рассмотреть повышение цены');
1470 }
1471
1472
1473
1474 // Если нет проблем - выводим "Всё хорошо"
1475
1476 if (recommendations.length === 0) {
1477
1478 recommendations.push('Всё хорошо, рекомендаций нет');
1479
1480 }
1481
1482
1483
1484 return {
1485
1486 priority: 'low',
1487
1488 problems: [],
1489
1490 recommendations,
1491
1492 daysOfStock,
1493
1494 isLowStock,
1495
1496 isHighDRR,
1497
1498 isLowDRR,
1499
1500 isGrowth,
1501
1502 isLowImpressions,
1503
1504 isLowCR,
1505
1506 isLowProfit,
1507
1508 isBadDeliveryTime,
1509
1510 isOutOfStock,
1511 isRevenueDrop,
1512 isRevenueGrowth
1513
1514 };
1515
1516 }
1517
1518
1519 // Вычисление средних показателей
1520
1521 calculateAverageMetrics(products) {
1522
1523 const validProducts = products.filter(p => p.revenueChange !== null);
1524
1525 if (validProducts.length === 0) return { revenueChange: 0, ordersChange: 0, impressionsChange: 0 };
1526
1527
1528
1529 const sum = validProducts.reduce((acc, p) => ({
1530
1531 revenueChange: acc.revenueChange + (p.revenueChange || 0),
1532
1533 ordersChange: acc.ordersChange + (p.ordersChange || 0),
1534
1535 impressionsChange: acc.impressionsChange + (p.impressionsChange || 0)
1536
1537 }), { revenueChange: 0, ordersChange: 0, impressionsChange: 0 });
1538
1539
1540
1541 return {
1542
1543 revenueChange: sum.revenueChange / validProducts.length,
1544
1545 ordersChange: sum.ordersChange / validProducts.length,
1546
1547 impressionsChange: sum.impressionsChange / validProducts.length
1548
1549 };
1550
1551 }
1552
1553
1554 async analyzeProduct(product, avgMetrics, useAI = true) {
1555
1556 try {
1557
1558 // Используем уже рассчитанное значение daysOfStock из product
1559 const daysOfStock = product.daysOfStock;
1560
1561 const isLowStock = daysOfStock !== null && daysOfStock <= 14;
1562
1563 const isHighDRR = product.drr !== null && product.drr > 20;
1564
1565 const isOutOfStock = product.stock === 0 || product.stock === null || (daysOfStock !== null && daysOfStock < 2);
1566
1567
1568 const isLowDRR = product.drr !== null && product.drr <= 17;
1569
1570 const isGrowth = this.detectGrowth(product, avgMetrics);
1571
1572 const isLowImpressions = product.impressionsChange !== null && product.impressionsChange <= -20;
1573
1574 const isLowCR = (product.conversionCardToCartChange !== null && product.conversionCardToCartChange <= -20) ||
1575
1576 (product.conversionCatalogToCardChange !== null && product.conversionCatalogToCardChange <= -20);
1577
1578 const isLowProfit = product.profit !== null && product.revenue !== null && product.revenue > 0 &&
1579
1580 (product.profit / product.revenue) < 0.25;
1581
1582
1583
1584 // Проверяем время доставки
1585
1586 const deliveryHours = product.deliveryTime ? parseInt(product.deliveryTime) : null;
1587
1588 const isBadDeliveryTime = deliveryHours !== null && deliveryHours >= 35;
1589
1590 // Проверяем падение выручки
1591 const isRevenueDrop = this.detectRevenueDrop(product);
1592
1593 // Проверяем рост выручки
1594 const isRevenueGrowth = this.detectRevenueGrowth(product);
1595
1596
1597 if (!useAI) {
1598
1599 return this.basicAnalysis(product, avgMetrics);
1600
1601 }
1602
1603
1604
1605 // Формируем промпт для AI
1606
1607 const prompt = `Ты - AI-аналитик маркетплейса Ozon. Анализируешь показатели товара за период с динамикой к прошлому периоду.
1608
1609## ЦЕЛЬ
1610Максимизировать оборот (выручка + заказы) при:
1611- Маржа: целевая 25–30%, минимум 15% (ниже - НЕЛЬЗЯ снижать цену)
1612- ДРР: целевой ~20% (норма 17–25%)
1613
1614## ПОРОГИ
1615
1616| Показатель | Критично | Низко | Норма | Избыток |
1617|------------|----------|-------|-------|---------|
1618| Запас (дни) | ≤7 | 8–14 | 15–30 | >30 (огромно >60) |
1619| Доставка (ч) | >40 | 36–40 | ≤35 | - |
1620| Маржа (%) | <15 | 15–24 | 25–30 | >30 |
1621| ДРР (%) | - | <17 (запас роста) | 17–25 | >25 (режет прибыль) |
1622
1623## РЫЧАГИ (только эти 4)
1624[Цена] [Реклама] [Остатки] [Карточка]
1625
1626Шаг цены: 5–10% за раз, не больше.
1627
1628## ПРИОРИТЕТЫ АНАЛИЗА
16291. Остатки + доставка → 2. Выручка/заказы/маржа/ДРР → 3. Показы/CTR/CR/карточка
1630
1631## ПРАВИЛА
1632
1633### 1. СТОП-ФАКТОРЫ: запас ≤14 дней ИЛИ доставка >35ч
1634**Цель:** не уйти в out of stock, восстановить доставку.
1635- [Остатки] Поставка/перераспределение на ближай склад (довести до 14–30 дней)
1636- [Цена] Повысить на 5–10% (притормозить спрос)
1637- [Реклама] Снизить ДРР (резать ставки, отключить слабые кампании)
1638⛔ ЗАПРЕЩЕНО: снижать цену, повышать ДРР - даже при падении показов/заказов
1639
1640### 2. НОРМА: запас >14 дней И доставка ≤35ч
1641Фокус на росте оборота и оптимизации прибыли.
1642
1643**При избытке запаса (30–60 дней):** аккуратно усиливать спрос, держать маржу ≥20%.
1644**При огромном запасе (>60 дней):** агрессивнее разгонять, но маржа ≥15%.
1645
1646### 3. МАРЖА И ПРИБЫЛЬ
1647Если маржа <20% или падает (особенно при ДРР >25%):
1648- [Реклама] Снизить ДРР (резать ставки, отключить неэффективное)
1649- [Цена] Повысить на 5–10%, если спрос позволяет
1650⛔ Не снижать цену при марже <20%
1651
1652### 4. РЕКЛАМА
1653**ДРР >25%:** снизить ставки/бюджеты; при низком CR - снизить цену или улучшить карточку
1654**ДРР <17% при хорошей марже и запасах:** повышать ставки, расширять кампании
1655**Падение показов при норме остатков:** повысить ДРР → снизить цену (если маржа позволяет)
1656
1657### 5. ВСЁ РАСТЁТ (выручка↑, заказы↑, маржа ≥25%, ДРР в норме, запас >14д, доставка ≤35ч)
1658- Без резких изменений
1659- [Цена] Тестировать +5–10% с контролем CR
1660- [Реклама] При низком ДРР - мягко расширять
1661
1662### 6. ОСОБЫЕ СЛУЧАИ
1663**Новый товар** (мало заказов, нормальные остатки): ДРР до ~35% допустим при марже >15%.
1664**Огромные остатки + слабые продажи:**
1665- Маржа <20%: ⛔ цену НЕ снижать → повышать ДРР, улучшать карточку, перераспределять/выводить
1666- Маржа 20–24%: снижать цену на 3–5%, контроль маржи ≥15%
1667- Маржа ≥25%: снижать цену на 5–10%, контроль маржи ≥20%
1668
1669## ФОРМАТ ОТВЕТА
1670
1671**Диагноз** (1–3 предложения): что происходит + основная причина
1672
1673**Рекомендации** (3–7 пунктов):
1674[Область] Действие - зачем.
1675
1676Пример:
1677- [Остатки] Поставка на ближай склад на 2–3 недели - сократить доставку, избежать out of stock
1678- [Реклама] Снизить ДРР до 18–20% - повысить маржу
1679- [Цена] Поднять на 5–10% - запас по конверсии позволяет
1680
1681**Ограничения:**
1682- Только релевантные действия, без противоречий
1683- При нехватке данных - гипотезы с отдельными действиями для каждой
1684- Простой язык, без воды
1685
1686---
1687## ДАННЫЕ ТОВАРА
1688
1689Товар: ${product.name} | Артикул: ${product.article}
1690
1691| Метрика | Значение | Δ% |
1692|---------|----------|-----|
1693| Выручка | ${product.revenue || 'н/д'} ₽ | ${product.revenueChange || 0}% |
1694| Прибыль | ${product.profit || 'н/д'} ₽ (${product.profitPercent || 'н/д'}%) | - |
1695| Показы | ${product.impressions || 'н/д'} | ${product.impressionsChange || 0}% |
1696| Посещения | ${product.cardVisits || 'н/д'} | ${product.cardVisitsChange || 0}% |
1697| CTR | ${product.conversionCatalogToCard || 'н/д'}% | ${product.conversionCatalogToCardChange || 0}% |
1698| В корзину | ${product.cartAdditions || 'н/д'} | ${product.cartAdditionsChange || 0}% |
1699| CRL | ${product.conversionCardToCart || 'н/д'}% | ${product.conversionCardToCartChange || 0}% |
1700| Заказы | ${product.orders || 'н/д'} шт | ${product.ordersChange || 0}% |
1701| CR | ${product.cr || 'н/д'}% | - |
1702| ДРР | ${product.drr || 'н/д'}% | ${product.drrChange || 0}% |
1703| Цена | ${product.avgPrice || 'н/д'} ₽ | ${product.avgPriceChange || 0}% |
1704| Остаток | ${product.stock || 'н/д'} шт | - |
1705| На дней | ${daysOfStock || 'н/д'} | - |
1706| Доставка | ${product.deliveryTime || 'н/д'} | - |
1707
1708 `;
1709
1710
1711 const response = await RM.aiCall(prompt, {
1712
1713 type: 'json_schema',
1714
1715 json_schema: {
1716
1717 name: 'product_analysis',
1718
1719 schema: {
1720
1721 type: 'object',
1722
1723 properties: {
1724
1725 priority: {
1726
1727 type: 'string',
1728
1729 enum: ['critical', 'high', 'medium', 'low']
1730
1731 },
1732
1733 problems: {
1734
1735 type: 'array',
1736
1737 items: {
1738
1739 type: 'object',
1740
1741 properties: {
1742
1743 type: { type: 'string' },
1744
1745 description: { type: 'string' }
1746
1747 },
1748
1749 required: ['type', 'description']
1750
1751 }
1752
1753 },
1754
1755 recommendations: {
1756
1757 type: 'array',
1758
1759 items: { type: 'string' }
1760
1761 }
1762
1763 },
1764
1765 required: ['priority', 'problems', 'recommendations']
1766
1767 }
1768
1769 }
1770
1771 });
1772
1773
1774 return {
1775
1776 ...response,
1777
1778 daysOfStock,
1779
1780 isLowStock,
1781
1782 isHighDRR,
1783
1784 isLowDRR,
1785
1786 isGrowth,
1787
1788 isLowImpressions,
1789
1790 isLowCR,
1791
1792 isLowProfit,
1793
1794 isBadDeliveryTime,
1795
1796 isOutOfStock,
1797 isRevenueDrop,
1798 isRevenueGrowth
1799
1800 };
1801
1802 } catch (error) {
1803
1804 console.error('Ошибка AI анализа:', error);
1805
1806 return this.basicAnalysis(product, avgMetrics);
1807
1808 }
1809
1810 }
1811
1812
1813 // Определение роста на основе средних показателей
1814
1815 detectGrowth(product, avgMetrics) {
1816
1817 const threshold = 15; // Порог отклонения от среднего в %
1818
1819
1820
1821 // Если выручка растет значительно выше среднего
1822
1823 if (product.revenueChange !== null &&
1824
1825 product.revenueChange > avgMetrics.revenueChange + threshold) {
1826
1827 return true;
1828
1829 }
1830
1831
1832
1833 // Если заказы растут значительно выше среднего
1834
1835 if (product.ordersChange !== null &&
1836
1837 product.ordersChange > avgMetrics.ordersChange + threshold) {
1838
1839 return true;
1840
1841 }
1842
1843
1844
1845 return false;
1846
1847 }
1848
1849 // Определение падения выручки
1850 detectRevenueDrop(product) {
1851 // Проверяем, упала ли выручка (любое отрицательное значение)
1852 return product.revenueChange !== null && product.revenueChange < 0;
1853 }
1854
1855 // Определение роста выручки
1856 detectRevenueGrowth(product) {
1857 // Проверяем, выросла ли выручка (любое положительное значение)
1858 return product.revenueChange !== null && product.revenueChange > 0;
1859 }
1860
1861 // Расчет прироста в рублях от роста выручки
1862 calculateRevenueGain(product) {
1863 if (!product.revenue || !product.revenueChange || product.revenueChange <= 0) {
1864 return 0;
1865 }
1866 // Рассчитываем предыдущую выручку и разницу
1867 const previousRevenue = product.revenue / (1 + product.revenueChange / 100);
1868 const revenueGain = product.revenue - previousRevenue;
1869 return Math.round(revenueGain);
1870 }
1871
1872 // Расчет убытка в рублях от падения выручки
1873 calculateRevenueLoss(product) {
1874 if (!product.revenue || !product.revenueChange || product.revenueChange >= 0) {
1875 return 0;
1876 }
1877 // Рассчитываем предыдущую выручку и разницу
1878 const previousRevenue = product.revenue / (1 + product.revenueChange / 100);
1879 const revenueLoss = previousRevenue - product.revenue;
1880 return Math.round(revenueLoss);
1881 }
1882
1883 }
1884
1885
1886 // Класс для UI
1887
1888 class AnalyticsUI {
1889
1890 constructor() {
1891
1892 this.container = null;
1893
1894 this.filteredProducts = [];
1895
1896 this.allProducts = [];
1897
1898 this.currentFilter = 'all';
1899
1900 this.isCollapsed = false;
1901
1902 this.isDragging = false;
1903
1904 this.isResizing = false;
1905
1906 this.dragStartX = 0;
1907
1908 this.dragStartY = 0;
1909
1910 this.containerStartX = 0;
1911
1912 this.containerStartY = 0;
1913
1914 this.resizeStartWidth = 0;
1915
1916 this.resizeStartHeight = 0;
1917
1918 this.useAI = true; // По умолчанию AI включен
1919
1920 }
1921
1922
1923 createUI() {
1924
1925 console.log('🎨 Создаем UI...');
1926
1927
1928 // Создаем контейнер для нашего UI
1929
1930 this.container = document.createElement('div');
1931
1932 this.container.id = 'ozon-ai-analytics';
1933
1934 this.container.style.cssText = `
1935
1936 position: fixed;
1937
1938 top: 80px;
1939
1940 right: 20px;
1941
1942 width: 500px;
1943
1944 max-height: 85vh;
1945
1946 background: white;
1947
1948 border-radius: 12px;
1949
1950 box-shadow: 0 4px 20px rgba(0,0,0,0.15);
1951
1952 z-index: 10000;
1953
1954 overflow: hidden;
1955
1956 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1957
1958 resize: both;
1959
1960 min-width: 400px;
1961
1962 min-height: 200px;
1963
1964 `;
1965
1966
1967 // Заголовок (с возможностью перетаскивания)
1968
1969 const header = document.createElement('div');
1970
1971 header.id = 'ozon-ai-header';
1972
1973 header.style.cssText = `
1974
1975 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1976
1977 color: white;
1978
1979 padding: 18px 24px;
1980
1981 font-weight: 600;
1982
1983 font-size: 18px;
1984
1985 display: flex;
1986
1987 justify-content: space-between;
1988
1989 align-items: center;
1990
1991 cursor: move;
1992
1993 user-select: none;
1994
1995 `;
1996
1997
1998 header.innerHTML = `
1999 <span>🤖 AI Аналитик Ozon</span>
2000 <div style="display: flex; gap: 8px;">
2001 <button id="ozon-ai-upload-costs" style="background: rgba(255,255,255,0.2); border: none; color: white; padding: 0 10px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; gap: 4px; font-weight: 500;">📁 Расходы</button>
2002 <button id="ozon-ai-collapse" style="background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center;">−</button>
2003 <button id="ozon-ai-close" style="background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center;">×</button>
2004 </div>
2005 `;
2006
2007
2008 // Кнопка запуска анализа
2009
2010 const startButton = document.createElement('button');
2011
2012 startButton.id = 'ozon-ai-start';
2013
2014 startButton.textContent = '🚀 Запустить анализ';
2015
2016 startButton.style.cssText = `
2017
2018 width: calc(100% - 40px);
2019
2020 margin: 20px;
2021
2022 padding: 16px;
2023
2024 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2025
2026 color: white;
2027
2028 border: none;
2029
2030 border-radius: 8px;
2031
2032 font-size: 16px;
2033
2034 font-weight: 600;
2035
2036 cursor: pointer;
2037
2038 transition: transform 0.2s;
2039
2040 `;
2041
2042 startButton.onmouseover = () => startButton.style.transform = 'scale(1.02)';
2043
2044 startButton.onmouseout = () => startButton.style.transform = 'scale(1)';
2045
2046
2047 // Переключатель AI анализа
2048 const aiToggleContainer = document.createElement('div');
2049 aiToggleContainer.style.cssText = `
2050 padding: 0 20px 10px 20px;
2051 display: flex;
2052 align-items: center;
2053 justify-content: space-between;
2054 background: #f8f9fa;
2055 margin: 0 20px;
2056 border-radius: 8px;
2057 margin-bottom: 10px;
2058 `;
2059
2060 const aiToggleLabel = document.createElement('label');
2061 aiToggleLabel.style.cssText = `
2062 display: flex;
2063 align-items: center;
2064 gap: 10px;
2065 cursor: pointer;
2066 user-select: none;
2067 padding: 12px 0;
2068 `;
2069
2070 const aiToggleText = document.createElement('span');
2071 aiToggleText.textContent = '🤖 AI анализ';
2072 aiToggleText.style.cssText = `
2073 font-size: 14px;
2074 font-weight: 500;
2075 color: #2c3e50;
2076 `;
2077
2078 const aiToggleSwitch = document.createElement('div');
2079 aiToggleSwitch.id = 'ozon-ai-toggle';
2080 aiToggleSwitch.style.cssText = `
2081 position: relative;
2082 width: 50px;
2083 height: 26px;
2084 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2085 border-radius: 13px;
2086 transition: background 0.3s;
2087 cursor: pointer;
2088 `;
2089
2090 const aiToggleCircle = document.createElement('div');
2091 aiToggleCircle.style.cssText = `
2092 position: absolute;
2093 top: 3px;
2094 left: 3px;
2095 width: 20px;
2096 height: 20px;
2097 background: white;
2098 border-radius: 50%;
2099 transition: transform 0.3s;
2100 transform: translateX(24px);
2101 `;
2102
2103 const aiToggleStatus = document.createElement('span');
2104 aiToggleStatus.id = 'ozon-ai-status';
2105 aiToggleStatus.textContent = 'Включен';
2106 aiToggleStatus.style.cssText = `
2107 font-size: 12px;
2108 color: #27ae60;
2109 font-weight: 600;
2110 `;
2111
2112 aiToggleSwitch.appendChild(aiToggleCircle);
2113 aiToggleLabel.appendChild(aiToggleText);
2114 aiToggleLabel.appendChild(aiToggleSwitch);
2115 aiToggleContainer.appendChild(aiToggleLabel);
2116 aiToggleContainer.appendChild(aiToggleStatus);
2117
2118
2119 // Кнопка запуска анализа
2120
2121 const content = document.createElement('div');
2122
2123 content.id = 'ozon-ai-content';
2124
2125 content.style.cssText = `
2126
2127 padding: 20px;
2128
2129 max-height: calc(85vh - 140px);
2130
2131 overflow-y: auto;
2132
2133 `;
2134
2135
2136 // Индикатор изменения размера
2137
2138 const resizeHandle = document.createElement('div');
2139
2140 resizeHandle.id = 'ozon-ai-resize';
2141
2142 resizeHandle.style.cssText = `
2143
2144 position: absolute;
2145
2146 bottom: 0;
2147
2148 right: 0;
2149
2150 width: 20px;
2151
2152 height: 20px;
2153
2154 cursor: nwse-resize;
2155
2156 background: linear-gradient(135deg, transparent 0%, transparent 50%, #667eea 50%, #667eea 100%);
2157
2158 border-bottom-right-radius: 12px;
2159
2160 `;
2161
2162
2163 this.container.appendChild(header);
2164
2165 this.container.appendChild(startButton);
2166
2167 this.container.appendChild(aiToggleContainer);
2168
2169 this.container.appendChild(content);
2170
2171 this.container.appendChild(resizeHandle);
2172
2173 document.body.appendChild(this.container);
2174
2175
2176 // События для перетаскивания
2177
2178 header.addEventListener('mousedown', (e) => this.startDragging(e));
2179
2180 document.addEventListener('mousemove', (e) => this.drag(e));
2181
2182 document.addEventListener('mouseup', () => this.stopDragging());
2183
2184
2185 // События для изменения размера
2186
2187 resizeHandle.addEventListener('mousedown', (e) => this.startResizing(e));
2188
2189
2190 // События кнопок
2191
2192 document.getElementById('ozon-ai-close').addEventListener('click', () => {
2193
2194 this.container.style.display = 'none';
2195
2196 });
2197
2198
2199 document.getElementById('ozon-ai-collapse').addEventListener('click', () => {
2200
2201 this.toggleCollapse();
2202
2203 });
2204
2205
2206 document.getElementById('ozon-ai-start').addEventListener('click', () => {
2207
2208 this.startAnalysis();
2209
2210 });
2211
2212
2213 // Обработчик переключателя AI
2214 aiToggleSwitch.addEventListener('click', () => {
2215 this.useAI = !this.useAI;
2216
2217 if (this.useAI) {
2218 aiToggleSwitch.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
2219 aiToggleCircle.style.transform = 'translateX(24px)';
2220 aiToggleStatus.textContent = 'Включен';
2221 aiToggleStatus.style.color = '#27ae60';
2222 console.log('✅ AI анализ включен');
2223 } else {
2224 aiToggleSwitch.style.background = '#95a5a6';
2225 aiToggleCircle.style.transform = 'translateX(0)';
2226 aiToggleStatus.textContent = 'Выключен';
2227 aiToggleStatus.style.color = '#e74c3c';
2228 console.log('⚠️ AI анализ выключен - будет использован базовый анализ');
2229 }
2230 });
2231
2232
2233 // Обработчик кнопки загрузки расходов
2234 document.getElementById('ozon-ai-upload-costs').addEventListener('click', () => {
2235 this.showUploadCostsDialog();
2236 });
2237
2238 console.log('✅ UI создан');
2239
2240 }
2241
2242
2243 startDragging(e) {
2244
2245 if (e.target.closest('button')) return; // Не перетаскиваем при клике на кнопки
2246
2247 this.isDragging = true;
2248
2249 this.dragStartX = e.clientX;
2250
2251 this.dragStartY = e.clientY;
2252
2253 const rect = this.container.getBoundingClientRect();
2254
2255 this.containerStartX = rect.left;
2256
2257 this.containerStartY = rect.top;
2258
2259 this.container.style.transition = 'none';
2260
2261 }
2262
2263
2264 drag(e) {
2265
2266 if (this.isDragging) {
2267
2268 const deltaX = e.clientX - this.dragStartX;
2269
2270 const deltaY = e.clientY - this.dragStartY;
2271
2272 this.container.style.left = `${this.containerStartX + deltaX}px`;
2273
2274 this.container.style.top = `${this.containerStartY + deltaY}px`;
2275
2276 this.container.style.right = 'auto';
2277
2278 } else if (this.isResizing) {
2279
2280 const deltaX = e.clientX - this.dragStartX;
2281
2282 const deltaY = e.clientY - this.dragStartY;
2283
2284 const newWidth = Math.max(400, this.resizeStartWidth + deltaX);
2285
2286 const newHeight = Math.max(200, this.resizeStartHeight + deltaY);
2287
2288 this.container.style.width = `${newWidth}px`;
2289
2290 this.container.style.maxHeight = `${newHeight}px`;
2291
2292 }
2293
2294 }
2295
2296
2297 stopDragging() {
2298
2299 this.isDragging = false;
2300
2301 this.isResizing = false;
2302
2303 this.container.style.transition = '';
2304
2305 }
2306
2307
2308 startResizing(e) {
2309
2310 e.stopPropagation();
2311
2312 this.isResizing = true;
2313
2314 this.dragStartX = e.clientX;
2315
2316 this.dragStartY = e.clientY;
2317
2318 this.resizeStartWidth = this.container.offsetWidth;
2319
2320 this.resizeStartHeight = this.container.offsetHeight;
2321
2322 }
2323
2324
2325 toggleCollapse() {
2326
2327 this.isCollapsed = !this.isCollapsed;
2328
2329 const header = document.getElementById('ozon-ai-header');
2330
2331 const startButton = document.getElementById('ozon-ai-start');
2332
2333 const content = document.getElementById('ozon-ai-content');
2334
2335 const resizeHandle = document.getElementById('ozon-ai-resize');
2336
2337 const collapseButton = document.getElementById('ozon-ai-collapse');
2338
2339
2340
2341 if (this.isCollapsed) {
2342
2343 header.style.display = 'none';
2344
2345 startButton.style.display = 'none';
2346
2347 content.style.display = 'none';
2348
2349 resizeHandle.style.display = 'none';
2350
2351 collapseButton.textContent = '+';
2352
2353 this.container.style.maxHeight = 'auto';
2354
2355 } else {
2356
2357 header.style.display = 'block';
2358
2359 startButton.style.display = 'block';
2360
2361 content.style.display = 'block';
2362
2363 resizeHandle.style.display = 'block';
2364
2365 collapseButton.textContent = '−';
2366
2367 this.container.style.maxHeight = '85vh';
2368
2369 }
2370
2371 }
2372
2373
2374 async startAnalysis() {
2375
2376 const startButton = document.getElementById('ozon-ai-start');
2377
2378
2379
2380 startButton.disabled = true;
2381
2382 startButton.textContent = '⏳ Загрузка товаров...';
2383
2384
2385 try {
2386
2387 // Шаг 1: Загрузка всех товаров
2388
2389 const collector = new ProductDataCollector();
2390
2391 await collector.loadAllProducts();
2392
2393
2394 startButton.textContent = '📊 Сбор данных...';
2395
2396
2397
2398 // Шаг 2: Сбор данных
2399
2400 const products = collector.collectProductData();
2401
2402
2403 if (products.length === 0) {
2404
2405 const content = document.getElementById('ozon-ai-content');
2406
2407 content.innerHTML = '<p style="color: #e74c3c; padding: 20px; text-align: center; font-size: 14px;">❌ Не удалось найти товары. Убедитесь, что вы на странице аналитики.</p>';
2408
2409 startButton.disabled = false;
2410
2411 startButton.textContent = '🚀 Запустить анализ';
2412
2413 return;
2414
2415 }
2416
2417
2418 // Шаг 3: AI анализ с прогрессом
2419
2420 const analyzer = new AIAnalyzer();
2421
2422 const onProgress = (current, total, percentage, remaining) => {
2423 const remainingText = remaining > 0 ? ` (~${remaining} сек)` : '';
2424 startButton.textContent = `🤖 AI анализ: ${current}/${total} (${percentage}%)${remainingText}`;
2425 };
2426
2427 // Передаем флаг useAI в анализатор
2428 const analyzedProducts = this.useAI
2429 ? await analyzer.analyzeProducts(products, onProgress)
2430 : await analyzer.analyzeProductsBasic(products, onProgress);
2431
2432
2433 this.allProducts = analyzedProducts;
2434
2435 this.filteredProducts = analyzedProducts;
2436
2437
2438 // Вычисляем общую выручку и прибыль
2439
2440 const totalRevenue = analyzedProducts.reduce((sum, p) => sum + (p.revenue || 0), 0);
2441
2442 const totalProfit = analyzedProducts.reduce((sum, p) => sum + (p.profit || 0), 0);
2443
2444 const totalOrders = analyzedProducts.reduce((sum, p) => sum + (p.orders || 0), 0);
2445
2446 // Вычисляем общую прибыль предыдущего периода
2447 let totalPrevProfit = 0;
2448 analyzedProducts.forEach(p => {
2449 if (p.profit !== null && p.revenueChange !== null && p.ordersChange !== null && p.revenue && p.orders) {
2450 const prevRevenue = p.revenue / (1 + p.revenueChange / 100);
2451 const prevOrders = p.orders / (1 + p.ordersChange / 100);
2452 const prevDrr = p.drr / (1 + p.drrChange / 100);
2453 const prevProfit = calculateProfit(p.article, prevRevenue, prevOrders, prevDrr);
2454 if (prevProfit !== null) {
2455 totalPrevProfit += prevProfit;
2456 }
2457 }
2458 });
2459
2460 // Вычисляем изменение общей прибыли
2461 const profitChange = totalPrevProfit > 0 ?
2462 parseFloat((((totalProfit - totalPrevProfit) / Math.abs(totalPrevProfit)) * 100).toFixed(1)) : null;
2463
2464 console.log(`💰 ОБЩАЯ ПРИБЫЛЬ: текущая=${totalProfit}₽, предыдущая=${Math.round(totalPrevProfit)}₽, изменение=${profitChange}%`);
2465
2466 // Вычисляем общие показатели с изменениями
2467 const totalCartAdditions = analyzedProducts.reduce((sum, p) => sum + (p.cartAdditions || 0), 0);
2468 const totalImpressions = analyzedProducts.reduce((sum, p) => sum + (p.impressions || 0), 0);
2469 const totalCardVisits = analyzedProducts.reduce((sum, p) => sum + (p.cardVisits || 0), 0);
2470 const totalStock = analyzedProducts.reduce((sum, p) => sum + (p.stock || 0), 0);
2471
2472 // Вычисляем средние показатели
2473 const validProducts = analyzedProducts.filter(p => p.avgPrice !== null);
2474 const avgPrice = validProducts.length > 0
2475 ? Math.round(validProducts.reduce((sum, p) => sum + (p.avgPrice || 0), 0) / validProducts.length)
2476 : 0;
2477
2478 const validDeliveryProducts = analyzedProducts.filter(p => p.deliveryTime !== null);
2479 const avgDeliveryTime = validDeliveryProducts.length > 0
2480 ? Math.round(validDeliveryProducts.reduce((sum, p) => {
2481 const hours = parseInt(p.deliveryTime);
2482 return sum + (isNaN(hours) ? 0 : hours);
2483 }, 0) / validDeliveryProducts.length)
2484 : 0;
2485
2486 const validDaysProducts = analyzedProducts.filter(p => p.daysOfStock !== null);
2487 const avgDaysOfStock = validDaysProducts.length > 0
2488 ? Math.round(validDaysProducts.reduce((sum, p) => sum + (p.daysOfStock || 0), 0) / validDaysProducts.length)
2489 : 0;
2490
2491 const validCTRProducts = analyzedProducts.filter(p => p.conversionCatalogToCard !== null);
2492 const avgCTR = validCTRProducts.length > 0
2493 ? (validCTRProducts.reduce((sum, p) => sum + (p.conversionCatalogToCard || 0), 0) / validCTRProducts.length).toFixed(1)
2494 : '0.0';
2495
2496
2497 // Вычисляем средний ДРР (взвешенный по выручке)
2498
2499 let totalDrrWeighted = 0;
2500
2501 let totalRevenueForDrr = 0;
2502
2503 analyzedProducts.forEach(p => {
2504
2505 if (p.drr !== null && p.revenue) {
2506
2507 totalDrrWeighted += p.drr * p.revenue;
2508
2509 totalRevenueForDrr += p.revenue;
2510
2511 }
2512
2513 });
2514
2515 const avgDrr = totalRevenueForDrr > 0 ? totalDrrWeighted / totalRevenueForDrr : 0;
2516
2517 // Вычисляем средний ДРР предыдущего периода (взвешенный по выручке)
2518 let totalPrevDrrWeighted = 0;
2519 let totalPrevRevenueForDrr = 0;
2520
2521 analyzedProducts.forEach(p => {
2522 if (p.drr !== null && p.drrChange !== null && p.revenue && p.revenueChange !== null) {
2523 const prevDrr = p.drr - p.drrChange; // Изменение ДРР в процентных пунктах
2524 const prevRevenue = p.revenue / (1 + p.revenueChange / 100);
2525 totalPrevDrrWeighted += prevDrr * prevRevenue;
2526 totalPrevRevenueForDrr += prevRevenue;
2527 }
2528 });
2529
2530 const avgPrevDrr = totalPrevRevenueForDrr > 0 ? totalPrevDrrWeighted / totalPrevRevenueForDrr : 0;
2531
2532 // Изменение среднего ДРР в процентных пунктах
2533 const drrChange = avgPrevDrr > 0 ? avgDrr - avgPrevDrr : null;
2534
2535 console.log(`📊 ДРР: текущий=${avgDrr.toFixed(2)}%, предыдущий=${avgPrevDrr.toFixed(2)}%, изменение=${drrChange ? drrChange.toFixed(2) : 'н/д'}%`);
2536
2537 // Используем данные из строки "Итого и среднее" для процентных изменений
2538 const totalRowData = collector.totalRowData;
2539
2540 // Если данные из строки "Итого и среднее" доступны, используем их
2541 const revenueChange = totalRowData && totalRowData.revenue ? totalRowData.revenue.change : null;
2542 const ordersChange = totalRowData && totalRowData.orders ? totalRowData.orders.change : null;
2543 const cartAdditionsChange = totalRowData && totalRowData.cartAdditions ? totalRowData.cartAdditions.change : null;
2544 const impressionsChange = totalRowData && totalRowData.impressions ? totalRowData.impressions.change : null;
2545 const cardVisitsChange = totalRowData && totalRowData.cardVisits ? totalRowData.cardVisits.change : null;
2546 const avgPriceChange = totalRowData && totalRowData.avgPrice ? totalRowData.avgPrice.change : null;
2547 const ctrChange = totalRowData && totalRowData.ctr ? totalRowData.ctr.change : null;
2548 const drrChangeFromTotal = totalRowData && totalRowData.drr ? totalRowData.drr.change : drrChange;
2549
2550 console.log('📊 Данные изменений из строки "Итого и среднее":', {
2551 revenueChange,
2552 ordersChange,
2553 cartAdditionsChange,
2554 impressionsChange,
2555 cardVisitsChange,
2556 avgPriceChange,
2557 ctrChange,
2558 drrChange: drrChangeFromTotal
2559 });
2560
2561 // Шаг 4: Отображение результатов
2562
2563 this.displayResults(analyzedProducts, {
2564
2565 totalRevenue,
2566
2567 totalProfit,
2568
2569 totalOrders,
2570
2571 avgDrr,
2572 totalCartAdditions,
2573 totalImpressions,
2574 totalCardVisits,
2575 totalStock,
2576 avgPrice,
2577 avgDeliveryTime,
2578 avgDaysOfStock,
2579 avgCTR,
2580 revenueChange,
2581 ordersChange,
2582 cartAdditionsChange,
2583 impressionsChange,
2584 cardVisitsChange,
2585 avgPriceChange,
2586 drrChange: drrChangeFromTotal,
2587 ctrChange,
2588 profitChange
2589
2590 });
2591
2592
2593 startButton.textContent = '🔄 Анализировать снова';
2594
2595 startButton.disabled = false;
2596
2597
2598 } catch (error) {
2599
2600 console.error('Ошибка анализа:', error);
2601
2602 const content = document.getElementById('ozon-ai-content');
2603
2604 content.innerHTML = `<p style="color: #e74c3c; padding: 20px; text-align: center; font-size: 14px;">❌ Ошибка: ${error.message}</p>`;
2605
2606 startButton.disabled = false;
2607
2608 startButton.textContent = '🚀 Запустить анализ';
2609
2610 }
2611
2612 }
2613
2614
2615 displayResults(products, totals) {
2616
2617 const content = document.getElementById('ozon-ai-content');
2618
2619
2620
2621 // Блок с общими показателями
2622
2623 const totalSalesBlock = this.createTotalSalesBlock(totals);
2624
2625
2626
2627 // Фильтры
2628
2629 const filters = this.createFilters(products);
2630
2631
2632
2633 // Список товаров
2634
2635 const productsList = this.createProductsList(products);
2636
2637 content.innerHTML = '';
2638
2639 content.appendChild(totalSalesBlock);
2640
2641 content.appendChild(filters);
2642
2643 content.appendChild(productsList);
2644
2645 }
2646
2647
2648 createTotalSalesBlock(totals) {
2649
2650 const block = document.createElement('div');
2651
2652 block.id = 'ozon-ai-total-sales';
2653
2654 block.style.cssText = `
2655
2656 margin-bottom: 20px;
2657
2658 padding: 16px;
2659
2660 background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
2661
2662 border-radius: 8px;
2663
2664 `;
2665
2666
2667 const profitColor = totals.totalProfit >= 0 ? '#27ae60' : '#e74c3c';
2668
2669 const profitPercent = totals.totalRevenue > 0 ? ((totals.totalProfit / totals.totalRevenue) * 100).toFixed(1) : 0;
2670
2671 const profitPercentColor = profitPercent >= 25 ? '#27ae60' : '#e74c3c';
2672
2673
2674 // Функция для форматирования изменения с цветом
2675 const formatChange = (change) => {
2676 if (change === null || change === undefined || isNaN(change)) return '';
2677 const rounded = parseFloat(change.toFixed(1));
2678 const color = rounded >= 0 ? '#27ae60' : '#e74c3c';
2679 const sign = rounded > 0 ? '+' : '';
2680 return `<span style="color: ${color}; font-size: 10px; margin-left: 4px;">(${sign}${rounded}%)</span>`;
2681 };
2682
2683 // Предварительно форматируем все изменения
2684 const revenueChangeHtml = formatChange(totals.revenueChange);
2685 const impressionsChangeHtml = formatChange(totals.impressionsChange);
2686 const cardVisitsChangeHtml = formatChange(totals.cardVisitsChange);
2687 const ctrChangeHtml = formatChange(totals.ctrChange);
2688 const cartAdditionsChangeHtml = formatChange(totals.cartAdditionsChange);
2689 const ordersChangeHtml = formatChange(totals.ordersChange);
2690 const drrChangeHtml = formatChange(totals.drrChange);
2691 const profitChangeHtml = formatChange(totals.profitChange);
2692 const avgPriceChangeHtml = formatChange(totals.avgPriceChange);
2693
2694 block.innerHTML = `
2695
2696 <div style="font-size: 14px; font-weight: 600; color: #2c3e50; margin-bottom: 12px;">📊 Общие показатели</div>
2697
2698 <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; font-size: 11px;">
2699
2700 <!-- Столбец 1: Трафик -->
2701 <div style="background: white; padding: 12px; border-radius: 8px;">
2702 <div style="font-size: 12px; font-weight: 600; color: #667eea; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;">
2703 <span style="font-size: 16px;">👁️</span> Трафик
2704 </div>
2705 <div style="display: flex; flex-direction: column; gap: 8px; font-size: 11px;">
2706 <div>
2707 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Показы</div>
2708 <div style="font-weight: 600; color: #2c3e50;">${totals.totalImpressions.toLocaleString()}${impressionsChangeHtml}</div>
2709 </div>
2710 <div>
2711 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Клики</div>
2712 <div style="font-weight: 600; color: #2c3e50;">${totals.totalCardVisits.toLocaleString()}${cardVisitsChangeHtml}</div>
2713 </div>
2714 <div>
2715 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">CTR</div>
2716 <div style="font-weight: 600; color: #2c3e50;">${totals.avgCTR}%${ctrChangeHtml}</div>
2717 </div>
2718 <div>
2719 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Корзины</div>
2720 <div style="font-weight: 600; color: #2c3e50;">${totals.totalCartAdditions.toLocaleString()}${cartAdditionsChangeHtml}</div>
2721 </div>
2722 </div>
2723 </div>
2724
2725 <!-- Столбец 2: Финансы -->
2726 <div style="background: white; padding: 12px; border-radius: 8px;">
2727 <div style="font-size: 12px; font-weight: 600; color: #27ae60; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;">
2728 <span style="font-size: 16px;">💰</span> Финансы
2729 </div>
2730 <div style="display: flex; flex-direction: column; gap: 8px; font-size: 11px;">
2731 <div>
2732 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Выручка</div>
2733 <div style="font-weight: 600; color: #2c3e50;">${totals.totalRevenue.toLocaleString()} ₽${revenueChangeHtml}</div>
2734 </div>
2735 <div>
2736 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Прибыль</div>
2737 <div style="font-weight: 600; color: ${profitColor};">${totals.totalProfit.toLocaleString()} ₽ <span style="color: ${profitPercentColor}; font-size: 10px;">(${profitPercent}%)</span>${profitChangeHtml}</div>
2738 </div>
2739 <div>
2740 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">ДРР</div>
2741 <div style="font-weight: 600; color: #2c3e50;">${totals.avgDrr.toFixed(1)}%${drrChangeHtml}</div>
2742 </div>
2743 <div>
2744 <div style="color: #7f8c8d; font-size: 10px; margin-bottom: 2px;">Заказы</div>
2745 <div style="font-weight: 600; color: #2c3e50;">${totals.totalOrders.toLocaleString()}${ordersChangeHtml}</div>
2746 </div>
2747 </div>
2748 </div>
2749
2750 <!-- Столбец 3: Логистика -->
2751 <div style="background: white; padding: 12px; border-radius: 8px; margin-bottom: 8px; font-size: 11px; font-weight: 600; color: #2c3e50;">
2752
2753 <div style="font-size: 11px; font-weight: 600; color: #2c3e50; margin-bottom: 6px;">📦 Логистика и цена</div>
2754
2755 <div style="display: flex; flex-direction: column; gap: 8px; font-size: 11px;">
2756
2757 <div><strong>Остаток:</strong> ${totals.totalStock.toLocaleString()} шт</div>
2758
2759 <div><strong>Средний запас:</strong> ${totals.avgDaysOfStock} дней</div>
2760
2761 <div><strong>Среднее время:</strong> ${totals.avgDeliveryTime} ч</div>
2762
2763 <div><strong>Средняя цена:</strong> ${totals.avgPrice.toLocaleString()} ₽${avgPriceChangeHtml}</div>
2764
2765 </div>
2766
2767 </div>
2768
2769 </div>
2770
2771 `;
2772
2773
2774 return block;
2775
2776 }
2777
2778 // Создание фильтров
2779 createFilters(products) {
2780 const filters = document.createElement('div');
2781 filters.style.cssText = `
2782 margin-bottom: 16px;
2783 display: flex;
2784 gap: 8px;
2785 flex-wrap: wrap;
2786 `;
2787
2788 // Добавляем поле поиска
2789 const searchContainer = document.createElement('div');
2790 searchContainer.style.cssText = `
2791 width: 100%;
2792 margin-bottom: 8px;
2793 `;
2794
2795 const searchInput = document.createElement('input');
2796 searchInput.type = 'text';
2797 searchInput.id = 'ozon-ai-search';
2798 searchInput.placeholder = '🔍 Поиск по артикулу, SKU или названию...';
2799 searchInput.style.cssText = `
2800 width: 100%;
2801 padding: 10px 16px;
2802 border: 1px solid #667eea;
2803 border-radius: 6px;
2804 font-size: 13px;
2805 outline: none;
2806 transition: all 0.2s;
2807 `;
2808
2809 searchInput.addEventListener('focus', () => {
2810 searchInput.style.borderColor = '#764ba2';
2811 searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
2812 });
2813
2814 searchInput.addEventListener('blur', () => {
2815 searchInput.style.borderColor = '#667eea';
2816 searchInput.style.boxShadow = 'none';
2817 });
2818
2819 searchInput.addEventListener('input', (e) => {
2820 this.applySearchAndFilter(e.target.value.toLowerCase());
2821 });
2822
2823 searchContainer.appendChild(searchInput);
2824 filters.appendChild(searchContainer);
2825
2826 const filterButtons = [
2827 { id: 'all', label: '📊 Все', count: products.length },
2828 { id: 'lowStock', label: '📦 Низкие остатки', count: products.filter(p => p.analysis.isLowStock).length },
2829 { id: 'highStock', label: '🏭 Большие остатки', count: products.filter(p => p.daysOfStock !== null && p.daysOfStock > 60).length },
2830 { id: 'highDRR', label: '💸 Высокий ДРР', count: products.filter(p => p.analysis.isHighDRR).length },
2831 { id: 'lowDRR', label: '🎯 Низкий ДРР', count: products.filter(p => p.analysis.isLowDRR).length },
2832 { id: 'lowImpressions', label: '👁️ Падение показов', count: products.filter(p => p.analysis.isLowImpressions).length },
2833 { id: 'revenueDrop', label: '📉 Падение выручки', count: products.filter(p => p.analysis.isRevenueDrop && p.revenueChange <= -10).length },
2834 { id: 'growth', label: '📈 Рост', count: products.filter(p => p.analysis.isGrowth).length },
2835 { id: 'lowProfit', label: '💰 Низкая прибыль', count: products.filter(p => p.analysis.isLowProfit).length }
2836 ];
2837
2838 filterButtons.forEach(filter => {
2839 const button = document.createElement('button');
2840 button.id = `filter-${filter.id}`;
2841 button.textContent = `${filter.label} (${filter.count})`;
2842 button.style.cssText = `
2843 padding: 8px 16px;
2844 border: 1px solid #667eea;
2845 background: ${this.currentFilter === filter.id ? '#667eea' : 'white'};
2846 color: ${this.currentFilter === filter.id ? 'white' : '#667eea'};
2847 border-radius: 6px;
2848 cursor: pointer;
2849 font-size: 12px;
2850 font-weight: 500;
2851 transition: all 0.2s;
2852 `;
2853
2854 button.addEventListener('click', () => {
2855 this.currentFilter = filter.id;
2856 this.applySearchAndFilter();
2857 });
2858
2859 filters.appendChild(button);
2860 });
2861
2862 return filters;
2863 }
2864
2865 // Применение поиска и фильтра
2866 applySearchAndFilter(searchQuery = '') {
2867 const searchInput = document.getElementById('ozon-ai-search');
2868 const query = searchQuery || (searchInput ? searchInput.value.toLowerCase() : '');
2869
2870 let filtered = this.allProducts;
2871
2872 // Применяем фильтр
2873 switch(this.currentFilter) {
2874 case 'lowStock':
2875 filtered = this.allProducts.filter(p => p.analysis.isLowStock);
2876 break;
2877 case 'highStock':
2878 filtered = this.allProducts.filter(p => p.daysOfStock !== null && p.daysOfStock > 60);
2879 break;
2880 case 'highDRR':
2881 filtered = this.allProducts.filter(p => p.analysis.isHighDRR);
2882 break;
2883 case 'lowDRR':
2884 filtered = this.allProducts.filter(p => p.analysis.isLowDRR);
2885 break;
2886 case 'lowImpressions':
2887 filtered = this.allProducts.filter(p => p.analysis.isLowImpressions);
2888 break;
2889 case 'revenueDrop':
2890 filtered = this.allProducts.filter(p => p.analysis.isRevenueDrop && p.revenueChange <= -10);
2891 break;
2892 case 'growth':
2893 filtered = this.allProducts.filter(p => p.analysis.isGrowth);
2894 break;
2895 case 'lowProfit':
2896 filtered = this.allProducts.filter(p => p.analysis.isLowProfit);
2897 break;
2898 default:
2899 filtered = this.allProducts;
2900 }
2901
2902 // Применяем поиск
2903 if (query) {
2904 filtered = filtered.filter(p =>
2905 p.article.toLowerCase().includes(query) ||
2906 p.name.toLowerCase().includes(query)
2907 );
2908 }
2909
2910 // Сортируем по выручке (от большей к меньшей)
2911 filtered.sort((a, b) => (b.revenue || 0) - (a.revenue || 0));
2912
2913 this.filteredProducts = filtered;
2914
2915 // Обновляем визуальное состояние кнопок фильтров
2916 const filterButtons = document.querySelectorAll('[id^="filter-"]');
2917 filterButtons.forEach(button => {
2918 const filterId = button.id.replace('filter-', '');
2919 if (filterId === this.currentFilter) {
2920 button.style.background = '#667eea';
2921 button.style.color = 'white';
2922 } else {
2923 button.style.background = 'white';
2924 button.style.color = '#667eea';
2925 }
2926 });
2927
2928 // Пересоздаем список товаров
2929 const content = document.getElementById('ozon-ai-content');
2930 const productsList = this.createProductsList(filtered);
2931
2932 // Удаляем старый список
2933 const oldList = content.querySelector('#products-list');
2934 if (oldList) {
2935 oldList.remove();
2936 }
2937
2938 content.appendChild(productsList);
2939 }
2940
2941 // Создание списка товаров
2942 createProductsList(products) {
2943 const list = document.createElement('div');
2944 list.id = 'products-list';
2945 list.style.cssText = `
2946 display: flex;
2947 flex-direction: column;
2948 gap: 12px;
2949 `;
2950
2951 if (products.length === 0) {
2952 list.innerHTML = '<div style="text-align: center; padding: 20px; color: #7f8c8d;">Нет товаров для отображения</div>';
2953 return list;
2954 }
2955
2956 products.forEach(product => {
2957 const card = this.createProductCard(product);
2958 list.appendChild(card);
2959 });
2960
2961 return list;
2962 }
2963
2964 // Создание карточки товара
2965 createProductCard(product) {
2966 const card = document.createElement('div');
2967 card.style.cssText = `
2968 background: white;
2969 border: 1px solid #e0e0e0;
2970 border-radius: 8px;
2971 padding: 12px;
2972 cursor: pointer;
2973 transition: all 0.2s;
2974 `;
2975
2976 card.addEventListener('mouseover', () => {
2977 card.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
2978 card.style.borderColor = '#667eea';
2979 });
2980
2981 card.addEventListener('mouseout', () => {
2982 card.style.boxShadow = 'none';
2983 card.style.borderColor = '#e0e0e0';
2984 });
2985
2986 const profitColor = product.profitPercent !== null && product.profitPercent >= 25 ? '#27ae60' : '#e74c3c';
2987
2988 // Рассчитываем изменение выручки в рублях
2989 const revenueChangeRub = this.calculateRevenueChangeRub(product);
2990 const revenueChangeColor = revenueChangeRub >= 0 ? '#27ae60' : '#e74c3c';
2991 const revenueChangeSign = revenueChangeRub > 0 ? '+' : '';
2992
2993 card.innerHTML = `
2994 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
2995 <div style="flex: 1;">
2996 <div style="font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 4px;">${product.name}</div>
2997 <div class="article-copy" style="font-size: 11px; color: #7f8c8d; cursor: pointer;">Арт. ${product.article} 📋</div>
2998 </div>
2999 </div>
3000
3001 <!-- БЛОК 1: Финансы -->
3002 <div style="background: #f8f9fa; padding: 10px; border-radius: 6px; margin-bottom: 8px; font-size: 11px; font-weight: 600; color: #2c3e50;">
3003 <div style="font-size: 11px; font-weight: 600; color: #27ae60; margin-bottom: 6px;">💰 Финансы</div>
3004 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px;">
3005 <div>
3006 <div style="color: #7f8c8d; font-size: 10px;">Выручка</div>
3007 <div style="font-weight: 600;">${this.formatMetric(product.revenue, product.revenueChange)} ₽</div>
3008 </div>
3009 <div>
3010 <div style="color: #7f8c8d; font-size: 10px;">Δ Выручка</div>
3011 <div style="font-weight: 600; color: ${revenueChangeColor};">${revenueChangeSign}${revenueChangeRub.toLocaleString()} ₽</div>
3012 </div>
3013 <div>
3014 <div style="color: #7f8c8d; font-size: 10px;">Прибыль</div>
3015 <div style="font-weight: 600;">${product.profit !== null ? product.profit.toLocaleString() + ' ₽' : '-'}</div>
3016 </div>
3017 <div>
3018 <div style="color: #7f8c8d; font-size: 10px;">Прибыль %</div>
3019 <div style="font-weight: 600;">${product.profitPercent !== null ? product.profitPercent + '%' : '-'}${this.formatMetric('', product.profitChange)}</div>
3020 </div>
3021 <div>
3022 <div style="color: #7f8c8d; font-size: 10px;">ДРР</div>
3023 <div style="font-weight: 600;">${this.formatMetric(product.drr, product.drrChange, true)}</div>
3024 </div>
3025 <div>
3026 <div style="color: #7f8c8d; font-size: 10px;">Комиссия</div>
3027 <div style="font-weight: 600;">${product.totalCommission !== null ? product.totalCommission.toLocaleString() + ' ₽' : '-'} ${product.totalCommissionPercent !== null ? '<span style="font-size: 9px; color: #7f8c8d;">(' + product.totalCommissionPercent + '%)</span>' : ''}</div>
3028 </div>
3029 <div>
3030 <div style="color: #7f8c8d; font-size: 10px;">Себестоимость</div>
3031 <div style="font-weight: 600;">${product.totalCost !== null ? product.totalCost.toLocaleString() + ' ₽' : '-'} ${product.totalCostPercent !== null ? '<span style="font-size: 9px; color: #7f8c8d;">(' + product.totalCostPercent + '%)</span>' : ''}</div>
3032 </div>
3033 </div>
3034 </div>
3035
3036 <!-- БЛОК 2: Трафик -->
3037 <div style="background: #f8f9fa; padding: 10px; border-radius: 6px; margin-bottom: 8px; font-size: 11px; font-weight: 600; color: #2c3e50;">
3038 <div style="font-size: 11px; font-weight: 600; color: #667eea; margin-bottom: 6px;">👁️ Трафик</div>
3039 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px;">
3040 <div>
3041 <div style="color: #7f8c8d; font-size: 10px;">Показы</div>
3042 <div style="font-weight: 600;">${this.formatMetric(product.impressions, product.impressionsChange)}</div>
3043 </div>
3044 <div>
3045 <div style="color: #7f8c8d; font-size: 10px;">Клики (Карточка)</div>
3046 <div style="font-weight: 600;">${this.formatMetric(product.cardVisits, product.cardVisitsChange)}</div>
3047 </div>
3048 <div>
3049 <div style="color: #7f8c8d; font-size: 10px;">CTR</div>
3050 <div style="font-weight: 600;">${this.formatMetric(product.conversionCatalogToCard, product.conversionCatalogToCardChange, true)}</div>
3051 </div>
3052 </div>
3053 </div>
3054
3055 <!-- БЛОК 3: Конверсия -->
3056 <div style="background: #f8f9fa; padding: 10px; border-radius: 6px; margin-bottom: 8px; font-size: 11px; font-weight: 600; color: #2c3e50;">
3057 <div style="font-size: 11px; font-weight: 600; color: #e67e22; margin-bottom: 6px;">🛒 Конверсия</div>
3058 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px;">
3059 <div>
3060 <div style="color: #7f8c8d; font-size: 10px;">Корзины</div>
3061 <div style="font-weight: 600;">${this.formatMetric(product.cartAdditions, product.cartAdditionsChange)}</div>
3062 </div>
3063 <div>
3064 <div style="color: #7f8c8d; font-size: 10px;">CRL</div>
3065 <div style="font-weight: 600;">${this.formatMetric(product.conversionCardToCart, product.conversionCardToCartChange, true)}</div>
3066 </div>
3067 <div>
3068 <div style="color: #7f8c8d; font-size: 10px;">Заказы</div>
3069 <div style="font-weight: 600;">${this.formatMetric(product.orders, product.ordersChange)}</div>
3070 </div>
3071 <div>
3072 <div style="color: #7f8c8d; font-size: 10px;">CR</div>
3073 <div style="font-weight: 600;">${this.formatMetric(product.cr, product.crChange, true)}</div>
3074 </div>
3075 </div>
3076 </div>
3077
3078 <!-- БЛОК 4: Логистика и цена -->
3079 <div style="background: #f8f9fa; padding: 10px; border-radius: 6px; margin-bottom: 8px; font-size: 11px; font-weight: 600; color: #2c3e50;">
3080 <div style="font-size: 11px; font-weight: 600; color: #9b59b6; margin-bottom: 6px;">📦 Логистика и цена</div>
3081 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px;">
3082 <div>
3083 <div style="color: #7f8c8d; font-size: 10px;">Остаток</div>
3084 <div style="font-weight: 600;">${product.stock || '-'} шт</div>
3085 </div>
3086 <div>
3087 <div style="color: #7f8c8d; font-size: 10px;">На дней</div>
3088 <div style="font-weight: 600;">${product.daysOfStock || '-'}</div>
3089 </div>
3090 <div>
3091 <div style="color: #7f8c8d; font-size: 10px;">Доставка</div>
3092 <div style="font-weight: 600;">${product.deliveryTime || '-'}</div>
3093 </div>
3094 <div>
3095 <div style="color: #7f8c8d; font-size: 10px;">Цена</div>
3096 <div style="font-weight: 600;">${this.formatMetric(product.avgPrice, product.avgPriceChange)} ₽</div>
3097 </div>
3098 </div>
3099 </div>
3100
3101 <!-- БЛОК 5: Рекомендации -->
3102 <div style="background: #d4edda; padding: 10px; border-radius: 6px; font-size: 11px;">
3103 <div style="font-weight: 600; margin-bottom: 6px; color: #155724;">💡 Рекомендации:</div>
3104 ${product.analysis.recommendations.map(r => `<div style="margin-bottom: 3px; color: #155724;">• ${r}</div>`).join('')}
3105 </div>
3106 `;
3107
3108 // Обработчик копирования артикула
3109 const articleElement = card.querySelector('.article-copy');
3110 articleElement.addEventListener('click', async (e) => {
3111 e.stopPropagation();
3112 try {
3113 await GM.setClipboard(product.article);
3114 const originalText = articleElement.textContent;
3115 articleElement.textContent = '✓ Скопировано!';
3116 articleElement.style.color = '#27ae60';
3117 setTimeout(() => {
3118 articleElement.textContent = originalText;
3119 articleElement.style.color = '#7f8c8d';
3120 }, 1500);
3121 } catch (error) {
3122 console.error('Ошибка копирования:', error);
3123 }
3124 });
3125
3126 card.addEventListener('click', () => {
3127 this.showProductDetails(product);
3128 });
3129
3130 return card;
3131 }
3132
3133 // Диалог загрузки расходов
3134 showUploadCostsDialog() {
3135 // Создаем input для файла
3136 const fileInput = document.createElement('input');
3137 fileInput.type = 'file';
3138 fileInput.accept = '.txt';
3139 fileInput.style.display = 'none';
3140
3141 fileInput.addEventListener('change', async (e) => {
3142 const file = e.target.files[0];
3143 if (!file) return;
3144
3145 try {
3146 // Показываем индикатор загрузки
3147 const uploadBtn = document.getElementById('ozon-ai-upload-costs');
3148 const originalText = uploadBtn.innerHTML;
3149 uploadBtn.innerHTML = '⏳ Загрузка...';
3150 uploadBtn.disabled = true;
3151
3152 // Читаем файл
3153 const fileContent = await file.text();
3154
3155 // Парсим данные
3156 const newCostData = parseCostDataFile(fileContent);
3157
3158 // Сохраняем данные
3159 const success = await saveCostData(newCostData);
3160
3161 if (success) {
3162 uploadBtn.innerHTML = '✅ Загружено!';
3163 uploadBtn.style.background = 'rgba(39, 174, 96, 0.3)';
3164
3165 // Показываем уведомление
3166 this.showNotification(
3167 `Успешно загружено ${Object.keys(newCostData).length} товаров`,
3168 'success'
3169 );
3170
3171 setTimeout(() => {
3172 uploadBtn.innerHTML = originalText;
3173 uploadBtn.style.background = 'rgba(255,255,255,0.2)';
3174 uploadBtn.disabled = false;
3175 }, 2000);
3176 } else {
3177 throw new Error('Не удалось сохранить данные');
3178 }
3179
3180 } catch (error) {
3181 console.error('Ошибка загрузки файла:', error);
3182
3183 const uploadBtn = document.getElementById('ozon-ai-upload-costs');
3184 uploadBtn.innerHTML = '❌ Ошибка';
3185 uploadBtn.style.background = 'rgba(231, 76, 60, 0.3)';
3186
3187 this.showNotification(
3188 'Ошибка загрузки: ' + error.message,
3189 'error'
3190 );
3191
3192 setTimeout(() => {
3193 uploadBtn.innerHTML = '📁 Расходы';
3194 uploadBtn.style.background = 'rgba(255,255,255,0.2)';
3195 uploadBtn.disabled = false;
3196 }, 3000);
3197 }
3198
3199 // Удаляем input
3200 fileInput.remove();
3201 });
3202
3203 // Добавляем в DOM и кликаем
3204 document.body.appendChild(fileInput);
3205 fileInput.click();
3206 }
3207
3208 // Показ уведомлений
3209 showNotification(message, type = 'info') {
3210 const notification = document.createElement('div');
3211 notification.style.cssText = `
3212 position: fixed;
3213 top: 20px;
3214 right: 20px;
3215 background: ${type === 'success' ? '#27ae60' : type === 'error' ? '#e74c3c' : '#3498db'};
3216 color: white;
3217 padding: 16px 24px;
3218 border-radius: 8px;
3219 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
3220 z-index: 10001;
3221 font-size: 14px;
3222 font-weight: 500;
3223 max-width: 400px;
3224 animation: slideIn 0.3s ease-out;
3225 `;
3226
3227 notification.textContent = message;
3228
3229 // Добавляем анимацию
3230 const style = document.createElement('style');
3231 style.textContent = `
3232 @keyframes slideIn {
3233 from {
3234 transform: translateX(400px);
3235 opacity: 0;
3236 }
3237 to {
3238 transform: translateX(0);
3239 opacity: 1;
3240 }
3241 }
3242 `;
3243 document.head.appendChild(style);
3244
3245 document.body.appendChild(notification);
3246
3247 // Удаляем через 4 секунды
3248 setTimeout(() => {
3249 notification.style.animation = 'slideIn 0.3s ease-out reverse';
3250 setTimeout(() => notification.remove(), 300);
3251 }, 4000);
3252 }
3253
3254 // Расчет изменения выручки в рублях
3255 calculateRevenueChangeRub(product) {
3256 if (!product.revenue || !product.revenueChange) {
3257 return 0;
3258 }
3259 // Рассчитываем предыдущую выручку и разницу
3260 const previousRevenue = product.revenue / (1 + product.revenueChange / 100);
3261 const revenueChange = product.revenue - previousRevenue;
3262 return Math.round(revenueChange);
3263 }
3264
3265 // Форматирование метрики с изменением
3266 formatMetric(value, change, isPercent = false) {
3267 if (value === null || value === undefined || value === '') {
3268 return '-';
3269 }
3270
3271 let formattedValue = isPercent ? `${value}%` : value.toLocaleString();
3272
3273 if (change !== null && change !== undefined && !isNaN(change)) {
3274 const color = change >= 0 ? '#27ae60' : '#e74c3c';
3275 const sign = change > 0 ? '+' : '';
3276 formattedValue += ` <span style="color: ${color}; font-size: 10px;">(${sign}${change.toFixed(1)}%)</span>`;
3277 }
3278
3279 return formattedValue;
3280 }
3281
3282 // Показ деталей товара
3283 showProductDetails(product) {
3284 // Можно добавить модальное окно с детальной информацией
3285 console.log('Детали товара:', product);
3286 }
3287
3288 }
3289
3290
3291 // Инициализация
3292
3293 async function init() {
3294
3295 console.log('🎯 Инициализация AI Аналитика Продаж...');
3296
3297
3298 // Проверяем, что мы на странице аналитики графиков
3299 if (!window.location.href.includes('seller.ozon.ru/app/analytics/graphs')) {
3300 console.log('⚠️ Не на странице аналитики графиков, ожидаем...');
3301 return;
3302 }
3303
3304 // Проверяем, не создан ли уже UI (предотвращаем дублирование)
3305 if (document.getElementById('ozon-ai-analytics')) {
3306 console.log('⚠️ UI уже создан, пропускаем инициализацию');
3307 return;
3308 }
3309
3310 // Загружаем данные о расходах из хранилища
3311 await loadCostData();
3312
3313 // Ждем загрузки таблицы
3314 const waitForTable = setInterval(() => {
3315 // Ищем таблицу на странице графиков (другой класс)
3316 const table = document.querySelector('table.ct5110-a') || document.querySelector('table');
3317 if (table) {
3318 clearInterval(waitForTable);
3319 console.log('✅ Таблица найдена, создаем UI');
3320
3321 const ui = new AnalyticsUI();
3322
3323 ui.createUI();
3324
3325 }
3326
3327 }, 1000);
3328
3329 }
3330
3331
3332 // Запуск при загрузке страницы
3333
3334 if (document.readyState === 'loading') {
3335
3336 document.addEventListener('DOMContentLoaded', init);
3337
3338 } else {
3339
3340 init();
3341
3342 }
3343
3344
3345 // Отслеживание изменений URL (для SPA) с debounce
3346 let lastUrl = location.href;
3347 let urlChangeTimeout = null;
3348
3349 new MutationObserver(() => {
3350 const url = location.href;
3351 if (url !== lastUrl) {
3352 lastUrl = url;
3353
3354 // Debounce: отменяем предыдущий таймер и создаем новый
3355 if (urlChangeTimeout) {
3356 clearTimeout(urlChangeTimeout);
3357 }
3358
3359 urlChangeTimeout = setTimeout(() => {
3360 console.log('🔄 URL изменился, переинициализация...');
3361 init();
3362 }, 500); // Ждем 500мс после последнего изменения URL
3363 }
3364 }).observe(document, { subtree: true, childList: true });
3365
3366
3367})();