Ozon Калькулятор маржинальности акций

Расчет маржи и маржинальности для товаров в акциях Ozon

Size

24.8 KB

Version

1.1.16

Created

Mar 20, 2026

Updated

27 days ago

1// ==UserScript==
2// @name		Ozon Калькулятор маржинальности акций
3// @description		Расчет маржи и маржинальности для товаров в акциях Ozon
4// @version		1.1.16
5// @match		https://*.seller.ozon.ru/*
6// @icon		https://st.ozone.ru/s3/seller-ui-static/icon/favicon32.png
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    // Хранилище для данных о расходах
12    let expensesData = {};
13
14    // Функция для создания панели с кнопками
15    function createControlPanel() {
16        console.log('Создание панели управления...');
17        
18        // Проверяем, не создана ли уже панель
19        if (document.getElementById('margin-calculator-panel')) {
20            console.log('Панель уже существует');
21            return;
22        }
23
24        // Создаем контейнер панели
25        const panel = document.createElement('div');
26        panel.id = 'margin-calculator-panel';
27        panel.style.cssText = `
28            position: fixed;
29            top: 80px;
30            right: 20px;
31            background: white;
32            border: 1px solid #e0e0e0;
33            border-radius: 8px;
34            padding: 16px;
35            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
36            z-index: 10000;
37            min-width: 250px;
38        `;
39
40        // Заголовок
41        const title = document.createElement('div');
42        title.textContent = 'Калькулятор маржинальности';
43        title.style.cssText = `
44            font-size: 14px;
45            font-weight: 600;
46            margin-bottom: 12px;
47            color: #333;
48        `;
49        panel.appendChild(title);
50
51        // Счетчик загруженных товаров
52        const counter = document.createElement('div');
53        counter.id = 'margin-calculator-counter';
54        counter.style.cssText = `
55            font-size: 12px;
56            color: #666;
57            margin-bottom: 12px;
58            text-align: center;
59            padding: 8px;
60            background: #f5f5f5;
61            border-radius: 4px;
62        `;
63        counter.textContent = 'Загружено: 0 товаров';
64        panel.appendChild(counter);
65
66        // Кнопка "Загрузить расходы"
67        const uploadBtn = document.createElement('button');
68        uploadBtn.textContent = 'Загрузить расходы';
69        uploadBtn.style.cssText = `
70            width: 100%;
71            padding: 10px;
72            margin-bottom: 8px;
73            background: #005bff;
74            color: white;
75            border: none;
76            border-radius: 6px;
77            cursor: pointer;
78            font-size: 13px;
79            font-weight: 500;
80        `;
81        uploadBtn.onmouseover = () => uploadBtn.style.background = '#0047cc';
82        uploadBtn.onmouseout = () => uploadBtn.style.background = '#005bff';
83        uploadBtn.onclick = loadExpensesFile;
84        panel.appendChild(uploadBtn);
85
86        // Кнопка "Расчет прибыли"
87        const calculateBtn = document.createElement('button');
88        calculateBtn.textContent = 'Расчет прибыли';
89        calculateBtn.style.cssText = `
90            width: 100%;
91            padding: 10px;
92            background: #00a046;
93            color: white;
94            border: none;
95            border-radius: 6px;
96            cursor: pointer;
97            font-size: 13px;
98            font-weight: 500;
99        `;
100        calculateBtn.onmouseover = () => calculateBtn.style.background = '#008037';
101        calculateBtn.onmouseout = () => calculateBtn.style.background = '#00a046';
102        calculateBtn.onclick = calculateMargins;
103        panel.appendChild(calculateBtn);
104
105        // Статус
106        const status = document.createElement('div');
107        status.id = 'margin-calculator-status';
108        status.style.cssText = `
109            margin-top: 12px;
110            font-size: 12px;
111            color: #666;
112            text-align: center;
113        `;
114        panel.appendChild(status);
115
116        // Добавляем панель на страницу
117        document.body.appendChild(panel);
118        console.log('Панель управления создана');
119        
120        // Обновляем счетчик при создании панели
121        updateCounter();
122    }
123
124    // Функция для обновления счетчика загруженных товаров
125    async function updateCounter() {
126        const counter = document.getElementById('margin-calculator-counter');
127        if (!counter) return;
128        
129        let count = Object.keys(expensesData).length;
130        
131        // Если в памяти нет данных, проверяем хранилище
132        if (count === 0) {
133            const savedData = await GM.getValue('expensesData');
134            if (savedData) {
135                const parsed = JSON.parse(savedData);
136                count = Object.keys(parsed).length;
137            }
138        }
139        
140        counter.textContent = `Загружено: ${count} товаров`;
141        counter.style.color = count > 0 ? '#00a046' : '#666';
142    }
143
144    // Функция для загрузки файла с расходами
145    async function loadExpensesFile() {
146        console.log('Загрузка файла с расходами...');
147        
148        const input = document.createElement('input');
149        input.type = 'file';
150        input.accept = '.txt';
151        
152        input.onchange = async (e) => {
153            const file = e.target.files[0];
154            if (!file) return;
155
156            try {
157                const text = await file.text();
158                console.log('Содержимое файла:', text);
159                
160                // Парсим данные из файла
161                expensesData = parseExpensesData(text);
162                
163                // Сохраняем данные в хранилище
164                await GM.setValue('expensesData', JSON.stringify(expensesData));
165                
166                // Обновляем счетчик
167                updateCounter();
168                
169                const status = document.getElementById('margin-calculator-status');
170                status.textContent = `Загружено ${Object.keys(expensesData).length} артикулов`;
171                status.style.color = '#00a046';
172                
173                console.log('Данные о расходах загружены:', expensesData);
174            } catch (error) {
175                console.error('Ошибка при загрузке файла:', error);
176                const status = document.getElementById('margin-calculator-status');
177                status.textContent = 'Ошибка загрузки файла';
178                status.style.color = '#ff0000';
179            }
180        };
181        
182        input.click();
183    }
184
185    // Функция для парсинга данных о расходах из текстового файла
186    function parseExpensesData(text) {
187        console.log('Парсинг данных о расходах...');
188        console.log('Исходный текст:', text);
189        const data = {};
190        
191        try {
192            // Убираем лишние пробелы и переносы строк
193            let cleanText = text.trim();
194            
195            // Пробуем разные варианты парсинга
196            // Вариант 1: построчный парсинг с регулярным выражением
197            const lines = cleanText.split('\n');
198            let parsedCount = 0;
199            
200            for (const line of lines) {
201                // Новый формат: 1740824669, cost: 146.4, commission: 0.39, delivery: 105 ,
202                let match = line.match(/(\d+)\s*,\s*cost:\s*([\d.]+)\s*,\s*commission:\s*([\d.]+)\s*,\s*delivery:\s*([\d.]+)/);
203                if (match) {
204                    const [, article, cost, commission, delivery] = match;
205                    data[article] = {
206                        cost: parseFloat(cost),
207                        commission: parseFloat(commission),
208                        delivery: parseFloat(delivery)
209                    };
210                    parsedCount++;
211                    console.log(`Распарсен артикул ${article}:`, data[article]);
212                    continue;
213                }
214                
215                // Старый формат: '72252': { cost: 158.4, commission: 0.30, delivery: 90 }
216                match = line.match(/['"]?(\d+)['"]?\s*:\s*\{\s*cost:\s*([\d.]+)\s*,\s*commission:\s*([\d.]+)\s*,\s*delivery:\s*([\d.]+)\s*\}/);
217                if (match) {
218                    const [, article, cost, commission, delivery] = match;
219                    data[article] = {
220                        cost: parseFloat(cost),
221                        commission: parseFloat(commission),
222                        delivery: parseFloat(delivery)
223                    };
224                    parsedCount++;
225                    console.log(`Распарсен артикул ${article}:`, data[article]);
226                }
227            }
228            
229            if (parsedCount > 0) {
230                console.log(`Успешно распарсено ${parsedCount} артикулов`);
231                return data;
232            }
233            
234            // Вариант 2: если не получилось построчно, пробуем как JSON
235            let jsonText = cleanText;
236            if (!jsonText.startsWith('{')) {
237                jsonText = '{' + jsonText + '}';
238            }
239            
240            // Заменяем одинарные кавычки на двойные
241            jsonText = jsonText.replace(/'/g, '"');
242            
243            const parsed = JSON.parse(jsonText);
244            
245            for (const [article, values] of Object.entries(parsed)) {
246                data[article] = {
247                    cost: values.cost || 0,
248                    commission: values.commission || 0,
249                    delivery: values.delivery || 0
250                };
251            }
252            
253            console.log('Данные успешно распарсены через JSON:', data);
254        } catch (error) {
255            console.error('Ошибка парсинга:', error);
256            console.error('Текст, который не удалось распарсить:', text);
257        }
258        
259        return data;
260    }
261
262    // Функция для извлечения числа из строки с ценой
263    function extractPrice(priceText) {
264        if (!priceText) return 0;
265        // Убираем все символы кроме цифр и точки/запятой
266        const cleaned = priceText.replace(/[^\d.,]/g, '').replace(',', '.');
267        // Убираем пробелы между цифрами (например "1 227" -> "1227")
268        const number = cleaned.replace(/\s/g, '');
269        return parseFloat(number) || 0;
270    }
271
272    // Функция для расчета маржи и маржинальности
273    async function calculateMargins() {
274        console.log('Начало расчета маржи...');
275        
276        // Загружаем данные из хранилища, если они еще не загружены
277        if (Object.keys(expensesData).length === 0) {
278            const savedData = await GM.getValue('expensesData');
279            if (savedData) {
280                expensesData = JSON.parse(savedData);
281                console.log('Данные загружены из хранилища:', expensesData);
282                // Обновляем счетчик
283                updateCounter();
284            } else {
285                const status = document.getElementById('margin-calculator-status');
286                status.textContent = 'Сначала загрузите файл с расходами';
287                status.style.color = '#ff0000';
288                return;
289            }
290        }
291
292        // Находим индексы столбцов по заголовкам
293        const headers = Array.from(document.querySelectorAll('thead th'));
294        console.log(`Всего заголовков найдено: ${headers.length}`);
295        
296        let articleColumnIndex = -1;
297        let priceColumnIndex = -1;
298        
299        headers.forEach((th, index) => {
300            // Ищем текст внутри вложенных div элементов
301            const allText = th.textContent.trim();
302            console.log(`Заголовок столбца ${index}: "${allText}"`);
303            
304            // Используем первое вхождение заголовка, а не последнее
305            if ((allText.includes('Артикул') || allText.includes('SKU')) && articleColumnIndex === -1) {
306                articleColumnIndex = index;
307                console.log(`Найден столбец "Артикул/SKU" с индексом ${index}`);
308            }
309            if (allText.includes('Цена по этой акции') && priceColumnIndex === -1) {
310                priceColumnIndex = index;
311                console.log(`Найден столбец "Цена по этой акции" с индексом ${index}`);
312            }
313        });
314        
315        console.log(`Итоговые индексы: articleColumnIndex=${articleColumnIndex}, priceColumnIndex=${priceColumnIndex}`);
316        
317        if (articleColumnIndex === -1 || priceColumnIndex === -1) {
318            console.error('Не удалось найти нужные столбцы в таблице');
319            console.error(`articleColumnIndex: ${articleColumnIndex}, priceColumnIndex: ${priceColumnIndex}`);
320            const status = document.getElementById('margin-calculator-status');
321            status.textContent = 'Ошибка: не найдены столбцы в таблице';
322            status.style.color = '#ff0000';
323            return;
324        }
325
326        // Находим все строки таблицы с товарами
327        const rows = document.querySelectorAll('tbody tr');
328        console.log(`Найдено строк: ${rows.length}`);
329        
330        let processedCount = 0;
331        let notFoundCount = 0;
332
333        rows.forEach((row, index) => {
334            try {
335                // Получаем ячейки строки
336                const cells = row.querySelectorAll('td');
337                console.log(`Строка ${index + 1}: найдено ячеек ${cells.length}`);
338                
339                if (cells.length <= Math.max(articleColumnIndex, priceColumnIndex)) {
340                    console.log(`Строка ${index + 1}: недостаточно ячеек (нужно минимум ${Math.max(articleColumnIndex, priceColumnIndex) + 1})`);
341                    return;
342                }
343
344                // Извлекаем SKU из ячейки артикула
345                const articleCell = cells[articleColumnIndex];
346                if (!articleCell) {
347                    console.log(`Строка ${index + 1}: не найдена ячейка артикула`);
348                    return;
349                }
350                
351                // Ищем SKU в отдельных div элементах с атрибутом title
352                const titleDivs = articleCell.querySelectorAll('[title]');
353                let article = null;
354                
355                if (titleDivs.length >= 2) {
356                    // SKU - это второй элемент с title (первый - внутренний артикул)
357                    article = titleDivs[1].getAttribute('title');
358                    console.log(`Строка ${index + 1}: найден SKU через title: ${article}`);
359                } else {
360                    // Fallback: пробуем старый метод с регулярным выражением
361                    const articleText = articleCell.textContent.trim();
362                    console.log(`Строка ${index + 1}: текст ячейки артикула "${articleText}"`);
363                    
364                    const numbers = articleText.match(/\d+/g);
365                    if (!numbers || numbers.length < 2) {
366                        console.log(`Строка ${index + 1}: не найден SKU (найдено чисел: ${numbers ? numbers.length : 0})`);
367                        return;
368                    }
369                    article = numbers[1]; // SKU - это второе число
370                }
371                
372                console.log(`Обработка товара ${index + 1}: SKU ${article}`);
373
374                // Проверяем, есть ли данные для этого артикула
375                if (!expensesData[article]) {
376                    console.log(`Нет данных о расходах для SKU ${article}`);
377                    notFoundCount++;
378                    return;
379                }
380
381                // Извлекаем цену из ячейки цены
382                const priceCell = cells[priceColumnIndex];
383                if (!priceCell) {
384                    console.log(`Не найдена ячейка цены для SKU ${article}`);
385                    return;
386                }
387                
388                const priceText = priceCell.textContent.trim();
389                const price = extractPrice(priceText);
390                console.log(`Цена по акции: ${priceText} -> ${price}`);
391
392                if (price === 0) {
393                    console.log(`Некорректная цена для SKU ${article}`);
394                    return;
395                }
396
397                // Получаем данные о расходах
398                const expenses = expensesData[article];
399                const { cost, commission, delivery } = expenses;
400
401                // Расчет маржи
402                // Маржа = Цена - (Цена * Commission) - Cost - Delivery
403                const margin = price - (price * commission) - cost - delivery;
404                
405                // Расчет маржинальности в процентах
406                const marginPercent = (margin / price) * 100;
407
408                console.log(`SKU ${article}: Цена=${price}, Cost=${cost}, Commission=${commission}, Delivery=${delivery}`);
409                console.log(`Маржа: ${margin.toFixed(2)} ₽, Маржинальность: ${marginPercent.toFixed(2)}%`);
410
411                // Отображаем результаты
412                displayMarginResults(priceCell, margin, marginPercent);
413                processedCount++;
414
415            } catch (error) {
416                console.error(`Ошибка при обработке строки ${index}:`, error);
417            }
418        });
419
420        // Обновляем статус
421        const status = document.getElementById('margin-calculator-status');
422        status.textContent = `Обработано: ${processedCount} товаров${notFoundCount > 0 ? `, не найдено данных: ${notFoundCount}` : ''}`;
423        status.style.color = processedCount > 0 ? '#00a046' : '#ff9900';
424        
425        console.log(`Расчет завершен. Обработано: ${processedCount}, Не найдено данных: ${notFoundCount}`);
426    }
427
428    // Функция для отображения результатов расчета
429    function displayMarginResults(priceCell, margin, marginPercent) {
430        // Удаляем предыдущие результаты, если они есть
431        const existingResults = priceCell.querySelector('.margin-results');
432        if (existingResults) {
433            existingResults.remove();
434        }
435
436        // Определяем цвет фона и текста в зависимости от маржинальности
437        let backgroundColor, textColor;
438        if (marginPercent < 20) {
439            backgroundColor = '#ffebee'; // светло-красный
440            textColor = '#c62828'; // темно-красный
441        } else if (marginPercent >= 20 && marginPercent <= 30) {
442            backgroundColor = '#fff9c4'; // светло-желтый
443            textColor = '#f57f17'; // темно-желтый/оранжевый
444        } else {
445            backgroundColor = '#e8f5e9'; // светло-зеленый
446            textColor = '#2e7d32'; // темно-зеленый
447        }
448
449        // Расчет tDRR
450        let tDRR;
451        if (marginPercent < 20) {
452            tDRR = 'не участвуем';
453        } else if (marginPercent >= 20 && marginPercent <= 30) {
454            tDRR = '10%';
455        } else {
456            tDRR = `${(marginPercent - 20).toFixed(2)}%`;
457        }
458
459        // Создаем контейнер для результатов
460        const resultsDiv = document.createElement('div');
461        resultsDiv.className = 'margin-results';
462        resultsDiv.style.cssText = `
463            margin-top: 8px;
464            padding: 8px;
465            background: ${backgroundColor};
466            border-radius: 4px;
467            font-size: 12px;
468            line-height: 1.5;
469        `;
470
471        // Маржинальность без рекламы
472        const marginPercentDiv = document.createElement('div');
473        marginPercentDiv.style.cssText = `
474            color: ${textColor};
475            font-weight: 600;
476        `;
477        marginPercentDiv.textContent = `Маржинальность без рекламы: ${marginPercent.toFixed(2)}%`;
478        resultsDiv.appendChild(marginPercentDiv);
479
480        // tDRR
481        const tDRRDiv = document.createElement('div');
482        tDRRDiv.style.cssText = `
483            color: ${textColor};
484            font-weight: 600;
485        `;
486        tDRRDiv.textContent = `tDRR: ${tDRR}`;
487        resultsDiv.appendChild(tDRRDiv);
488
489        // Добавляем результаты в ячейку с ценой
490        const cellContent = priceCell.querySelector('.m8d-b9a');
491        if (cellContent) {
492            cellContent.appendChild(resultsDiv);
493        }
494    }
495
496    // Функция инициализации
497    async function init() {
498        console.log('Инициализация расширения Калькулятор маржинальности...');
499        
500        // Проверяем, что мы на странице акций
501        if (!window.location.href.includes('/app/highlights/')) {
502            console.log('Не на странице акций, расширение не активно');
503            return;
504        }
505
506        // Создаем панель сразу на странице акции
507        const waitForPage = setInterval(() => {
508            const pageContent = document.querySelector('[data-widget="@seller-ui/highlights"]');
509            if (pageContent && !document.getElementById('margin-calculator-panel')) {
510                clearInterval(waitForPage);
511                console.log('Страница акции загружена, создаем панель управления');
512                createControlPanel();
513            }
514        }, 500);
515
516        // Останавливаем проверку через 10 секунд
517        setTimeout(() => clearInterval(waitForPage), 10000);
518
519        // Наблюдаем за появлением модального окна
520        const observer = new MutationObserver(() => {
521            const modal = document.querySelector('#ui-kit-side-page-portal-container');
522            if (modal && !document.getElementById('margin-calculator-panel')) {
523                console.log('Модальное окно обнаружено');
524                // Ждем загрузки таблицы в модальном окне
525                const checkTable = setInterval(() => {
526                    const table = modal.querySelector('tbody tr');
527                    if (table) {
528                        clearInterval(checkTable);
529                        console.log('Таблица в модальном окне найдена, создаем панель управления');
530                        createControlPanel();
531                    }
532                }, 500);
533                
534                // Останавливаем проверку через 10 секунд
535                setTimeout(() => clearInterval(checkTable), 10000);
536            }
537        });
538
539        // Начинаем наблюдение за изменениями в DOM
540        observer.observe(document.body, {
541            childList: true,
542            subtree: true
543        });
544
545        console.log('Наблюдатель за модальным окном запущен');
546    }
547
548    // Запускаем инициализацию при загрузке страницы
549    if (document.readyState === 'loading') {
550        document.addEventListener('DOMContentLoaded', init);
551    } else {
552        init();
553    }
554
555    console.log('Расширение Калькулятор маржинальности загружено');
556})();