Расчет маржи и маржинальности для товаров в акциях 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})();