Size
21.8 KB
Version
1.1.1
Created
Apr 1, 2026
Updated
14 days ago
1// ==UserScript==
2// @name Ozon Price Calculator
3// @description A new extension
4// @version 1.1.1
5// @match https://*.calculator.ozon.ru/*
6// @icon https://ir.ozone.ru/s3/fe-favicon/favicon-media.svg
7// ==/UserScript==
8(function () {
9 'use strict';
10
11 // ── helpers ──────────────────────────────────────────────────────────────
12 function parseRub(text) {
13 const m = text.replace(/−/g, '-').match(/-?([\d\s]+)/);
14 return m ? parseFloat(m[1].replace(/\s/g, '')) : 0;
15 }
16
17 function parsePct(text) {
18 const m = text.match(/([\d.]+)%/);
19 return m ? parseFloat(m[1]) / 100 : 0;
20 }
21
22 // ── read commissions from Ozon table (FBO column) ─────────────────────
23 function readOzonTable() {
24 const t = document.querySelector('table');
25 if (!t) return null;
26 const rows = Array.from(t.rows);
27
28 function findRow(keyword) {
29 return rows.find(r => r.innerText.includes(keyword)) || null;
30 }
31
32 function extractRub(row) {
33 if (!row) return 0;
34 const text = row.innerText.replace(/−/g, '-');
35 const m = text.match(/-?([\d\s]+)\s*₽/);
36 return m ? parseFloat(m[1].replace(/\s/g, '')) : 0;
37 }
38
39 function extractPct(row) {
40 if (!row) return 0;
41 const m = row.innerText.match(/([\d.]+)%/);
42 return m ? parseFloat(m[1]) / 100 : 0;
43 }
44
45 const priceRow = findRow('Цена товара');
46 const commRow = findRow('Вознаграждение');
47 const acqRow = findRow('Эквайринг');
48 const delivRow = findRow('Обработка');
49 const taxRow = findRow('Налог на прибыль');
50
51 const price = extractRub(priceRow);
52 const commPct = extractPct(commRow);
53 const acqPct = extractPct(acqRow);
54 const deliveryRub = extractRub(delivRow);
55 const taxRub = extractRub(taxRow);
56 const taxPct = price > 0 ? taxRub / price : 0;
57
58 console.log('[RM Calc] Таблица Ozon:', { price, commPct, acqPct, deliveryRub, taxRub, taxPct });
59
60 return { price, commPct, acqPct, deliveryRub, taxPct, taxRub };
61 }
62
63 // ── formulas ──────────────────────────────────────────────────────────────
64 function calculate(inputs, ozon) {
65 const { cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode } = inputs;
66 const { commPct, acqPct, deliveryRub, taxPct } = ozon;
67
68 const costPerBuyout = cost / (buyoutPct / 100);
69 const totalCommPct = commPct + acqPct + taxPct + advPct / 100;
70
71 let P, divisor;
72
73 if (calcMode === 'margin') {
74 const targetMargin = targetValue / 100;
75 divisor = 1 - totalCommPct - targetMargin;
76 if (divisor <= 0) return null;
77 P = (deliveryRub + costPerBuyout) / divisor;
78 } else {
79 const targetRom = targetValue / 100;
80 divisor = 1 - totalCommPct;
81 if (divisor <= 0) return null;
82 P = (deliveryRub + costPerBuyout + targetRom * cost) / divisor;
83 }
84
85 const commRub = P * commPct;
86 const acqRub = P * acqPct;
87 const taxRub = P * taxPct;
88 const advRub = P * (advPct / 100);
89 const salePrice = P * (1 - ozonDiscount / 100);
90 const profit = P - commRub - acqRub - deliveryRub - taxRub - advRub - costPerBuyout;
91 const margin = profit / P * 100;
92 const rom = profit / cost * 100;
93 const costPct = costPerBuyout / P * 100;
94
95 return { P, salePrice, commRub, commPct: commPct * 100, acqRub, acqPct: acqPct * 100, deliveryRub, taxRub, taxPct: taxPct * 100, advRub, costPerBuyout, profit, margin, rom, costPct };
96 }
97
98 // ── styles ────────────────────────────────────────────────────────────────
99 function injectStyles() {
100 const style = document.createElement('style');
101 style.textContent = `
102 @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
103
104 #rm-calc {
105 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
106 border-radius: 24px;
107 padding: 32px;
108 margin: 24px 0;
109 font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
110 max-width: 1000px;
111 box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
112 color: white;
113 position: relative;
114 overflow: hidden;
115 }
116
117 #rm-calc::before {
118 content: '';
119 position: absolute;
120 top: 0;
121 left: 0;
122 right: 0;
123 bottom: 0;
124 background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
125 opacity: 0.5;
126 pointer-events: none;
127 z-index: 0;
128 }
129
130 #rm-calc > * {
131 position: relative;
132 z-index: 1;
133 }
134
135 #rm-calc h2 {
136 font-size: 28px;
137 font-weight: 700;
138 margin: 0 0 8px 0;
139 color: #ffffff;
140 }
141
142 .rm-subtitle {
143 font-size: 14px;
144 color: rgba(255, 255, 255, 0.7);
145 margin-bottom: 24px;
146 font-weight: 500;
147 }
148
149 .rm-mode-switch {
150 display: flex;
151 background: rgba(255, 255, 255, 0.15);
152 border-radius: 12px;
153 padding: 4px;
154 margin-bottom: 24px;
155 width: fit-content;
156 backdrop-filter: blur(10px);
157 }
158
159 .rm-mode-btn {
160 border: none;
161 background: transparent;
162 color: rgba(255, 255, 255, 0.8);
163 padding: 10px 20px;
164 border-radius: 10px;
165 cursor: pointer;
166 font-size: 14px;
167 font-weight: 600;
168 transition: all 0.3s ease;
169 font-family: inherit;
170 }
171
172 .rm-mode-btn.active {
173 background: rgba(255, 255, 255, 0.95);
174 color: #764ba2;
175 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
176 }
177
178 .rm-mode-btn:hover:not(.active) {
179 color: white;
180 background: rgba(255, 255, 255, 0.1);
181 }
182
183 .rm-grid {
184 display: grid;
185 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
186 gap: 16px;
187 margin-bottom: 24px;
188 }
189
190 .rm-field {
191 background: rgba(255, 255, 255, 0.1);
192 border-radius: 16px;
193 padding: 16px;
194 backdrop-filter: blur(10px);
195 border: 1px solid rgba(255, 255, 255, 0.2);
196 transition: all 0.3s ease;
197 }
198
199 .rm-field:hover {
200 background: rgba(255, 255, 255, 0.15);
201 transform: translateY(-2px);
202 box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
203 }
204
205 .rm-field label {
206 display: block;
207 font-size: 12px;
208 color: rgba(255, 255, 255, 0.8);
209 margin-bottom: 8px;
210 font-weight: 600;
211 text-transform: uppercase;
212 letter-spacing: 0.5px;
213 }
214
215 .rm-field input {
216 width: 100%;
217 box-sizing: border-box;
218 border: 2px solid rgba(255, 255, 255, 0.3);
219 border-radius: 12px;
220 padding: 12px 16px;
221 font-size: 18px;
222 color: white;
223 outline: none;
224 transition: all 0.3s ease;
225 background: rgba(0, 0, 0, 0.2);
226 font-weight: 600;
227 font-family: inherit;
228 }
229
230 .rm-field input::placeholder {
231 color: rgba(255, 255, 255, 0.4);
232 }
233
234 .rm-field input:focus {
235 border-color: rgba(255, 255, 255, 0.8);
236 background: rgba(0, 0, 0, 0.3);
237 box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
238 }
239
240 .rm-field input[type="number"]::-webkit-inner-spin-button,
241 .rm-field input[type="number"]::-webkit-outer-spin-button {
242 -webkit-appearance: none;
243 margin: 0;
244 }
245
246 .rm-btn {
247 background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
248 color: #764ba2;
249 border: none;
250 border-radius: 16px;
251 padding: 16px 40px;
252 font-size: 16px;
253 font-weight: 700;
254 cursor: pointer;
255 transition: all 0.3s ease;
256 box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
257 font-family: inherit;
258 text-transform: uppercase;
259 letter-spacing: 0.5px;
260 }
261
262 .rm-btn:hover {
263 transform: translateY(-3px);
264 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
265 background: linear-gradient(135deg, #ffffff 0%, #e8e8e8 100%);
266 }
267
268 .rm-btn:active {
269 transform: translateY(-1px);
270 }
271
272 .rm-result {
273 margin-top: 32px;
274 background: rgba(255, 255, 255, 0.98);
275 border-radius: 24px;
276 padding: 32px;
277 display: none;
278 box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
279 animation: slideUp 0.5s ease;
280 position: relative;
281 z-index: 2;
282 }
283
284 @keyframes slideUp {
285 from {
286 opacity: 0;
287 transform: translateY(20px);
288 }
289 to {
290 opacity: 1;
291 transform: translateY(0);
292 }
293 }
294
295 .rm-result.visible { display: block; }
296
297 .rm-result-main {
298 font-size: 32px;
299 font-weight: 800;
300 color: #764ba2;
301 margin-bottom: 24px;
302 text-align: center;
303 line-height: 1.4;
304 }
305
306 .rm-result-main .rm-price-value {
307 display: inline-block;
308 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
309 -webkit-background-clip: text;
310 -webkit-text-fill-color: transparent;
311 background-clip: text;
312 font-size: 36px;
313 }
314
315 .rm-result-main .rm-price-label {
316 display: block;
317 font-size: 16px;
318 font-weight: 500;
319 color: #6b7280;
320 margin-top: 8px;
321 -webkit-text-fill-color: #6b7280;
322 }
323
324 .rm-result-grid {
325 display: grid;
326 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
327 gap: 16px;
328 }
329
330 .rm-result-item {
331 background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
332 border-radius: 16px;
333 padding: 20px;
334 border: 1px solid #e2e8f0;
335 transition: all 0.3s ease;
336 }
337
338 .rm-result-item:hover {
339 transform: translateY(-4px);
340 box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
341 border-color: #cbd5e1;
342 }
343
344 .rm-result-item .rm-ri-label {
345 font-size: 11px;
346 color: #64748b;
347 font-weight: 700;
348 text-transform: uppercase;
349 letter-spacing: 0.8px;
350 margin-bottom: 8px;
351 }
352
353 .rm-result-item .rm-ri-value {
354 font-size: 20px;
355 font-weight: 800;
356 color: #1e293b;
357 }
358
359 .rm-result-item .rm-ri-value.green {
360 color: #059669;
361 }
362
363 .rm-result-item .rm-ri-value.red {
364 color: #dc2626;
365 }
366
367 .rm-error {
368 color: #fecaca;
369 font-size: 14px;
370 margin-top: 16px;
371 padding: 12px 16px;
372 background: rgba(220, 38, 38, 0.3);
373 border-radius: 12px;
374 border: 1px solid rgba(248, 113, 113, 0.3);
375 display: none;
376 font-weight: 500;
377 backdrop-filter: blur(10px);
378 }
379
380 .rm-error.visible { display: block; }
381
382 .rm-ozon-note {
383 font-size: 13px;
384 color: #64748b;
385 margin-top: 24px;
386 padding-top: 20px;
387 border-top: 1px solid #e2e8f0;
388 font-weight: 500;
389 }
390
391 .rm-highlight {
392 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
393 color: white;
394 padding: 2px 8px;
395 border-radius: 6px;
396 font-weight: 600;
397 }
398 `;
399 document.head.appendChild(style);
400 }
401
402 // ── build UI ──────────────────────────────────────────────────────────────
403 function buildCalc() {
404 const wrap = document.createElement('div');
405 wrap.id = 'rm-calc';
406 wrap.innerHTML = `
407 <h2>Калькулятор цены поручения</h2>
408 <div class="rm-subtitle">Рассчитайте оптимальную цену с учётом всех комиссий Ozon</div>
409
410 <div class="rm-mode-switch">
411 <button class="rm-mode-btn active" data-mode="margin">По маржинальности</button>
412 <button class="rm-mode-btn" data-mode="rom">По ROM</button>
413 </div>
414
415 <div class="rm-grid">
416 <div class="rm-field">
417 <label>Себестоимость, ₽</label>
418 <input id="rm-cost" type="number" min="0" placeholder="100" value="">
419 </div>
420 <div class="rm-field">
421 <label>% выкупа</label>
422 <input id="rm-buyout" type="number" min="1" max="100" placeholder="80" value="">
423 </div>
424 <div class="rm-field">
425 <label>Скидка от Озона, %</label>
426 <input id="rm-ozon-discount" type="number" min="0" max="100" placeholder="0" value="">
427 </div>
428 <div class="rm-field">
429 <label>% рекламных расходов</label>
430 <input id="rm-adv" type="number" min="0" placeholder="5" value="">
431 </div>
432 <div class="rm-field">
433 <label id="rm-target-label">Желаемая маржинальность, %</label>
434 <input id="rm-target" type="number" min="0" placeholder="20" value="">
435 </div>
436 </div>
437
438 <button class="rm-btn" id="rm-calc-btn">Рассчитать цену</button>
439
440 <div class="rm-error" id="rm-error"></div>
441
442 <div class="rm-result" id="rm-result">
443 <div class="rm-result-main" id="rm-price-out"></div>
444 <div class="rm-result-grid" id="rm-result-grid"></div>
445 <div class="rm-ozon-note" id="rm-ozon-note"></div>
446 </div>
447 `;
448 return wrap;
449 }
450
451 function fmt(n, decimals = 0) {
452 return n.toLocaleString('ru-RU', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
453 }
454
455 function renderResult(res, ozon, calcMode) {
456 const resultDiv = document.getElementById('rm-result');
457 const priceOut = document.getElementById('rm-price-out');
458 const grid = document.getElementById('rm-result-grid');
459 const note = document.getElementById('rm-ozon-note');
460
461 const modeText = calcMode === 'margin' ? `маржинальности ${fmt(res.margin, 1)}%` : `ROM ${fmt(res.rom, 1)}%`;
462
463 priceOut.innerHTML = `
464 <span class="rm-price-value">${fmt(res.P, 2)} ₽</span>
465 <span class="rm-price-label">Цена поручения при ${modeText}</span>
466 `;
467
468 const items = [
469 { label: 'Цена реализации', value: `${fmt(res.salePrice, 2)} ₽`, cls: '' },
470 { label: 'Прибыль за шт', value: `${fmt(res.profit, 2)} ₽`, cls: res.profit >= 0 ? 'green' : 'red' },
471 { label: 'Маржинальность', value: `${fmt(res.margin, 1)} %`, cls: res.margin >= 0 ? 'green' : 'red' },
472 { label: 'ROM (прибыль/себест.)', value: `${fmt(res.rom, 1)} %`, cls: res.rom >= 0 ? 'green' : 'red' },
473 { label: '% себестоимости в цене', value: `${fmt(res.costPct, 1)} %`, cls: '' },
474 { label: 'Вознаграждение Ozon', value: `${fmt(res.commRub, 2)} ₽ (${fmt(res.commPct, 1)}%)`, cls: 'red' },
475 { label: 'Эквайринг', value: `${fmt(res.acqRub, 2)} ₽ (${fmt(res.acqPct, 1)}%)`, cls: 'red' },
476 { label: 'Доставка (FBO)', value: `${fmt(res.deliveryRub, 2)} ₽`, cls: 'red' },
477 { label: 'Налог', value: `${fmt(res.taxRub, 2)} ₽ (${fmt(res.taxPct, 1)}%)`, cls: 'red' },
478 { label: 'Реклама', value: `${fmt(res.advRub, 2)} ₽`, cls: 'red' },
479 { label: 'Себест. на 1 выкуп', value: `${fmt(res.costPerBuyout, 2)} ₽`, cls: '' },
480 ];
481
482 grid.innerHTML = items.map(it => `
483 <div class="rm-result-item">
484 <div class="rm-ri-label">${it.label}</div>
485 <div class="rm-ri-value ${it.cls}">${it.value}</div>
486 </div>
487 `).join('');
488
489 note.innerHTML = `Данные комиссий из таблицы Ozon: <span class="rm-highlight">вознаграждение ${fmt(ozon.commPct * 100, 1)}%</span>, <span class="rm-highlight">эквайринг ${fmt(ozon.acqPct * 100, 1)}%</span>, <span class="rm-highlight">доставка ${fmt(ozon.deliveryRub, 0)} ₽</span>, <span class="rm-highlight">налог ${fmt(ozon.taxPct * 100, 2)}%</span>`;
490
491 resultDiv.classList.add('visible');
492 }
493
494 // ── init ──────────────────────────────────────────────────────────────────
495 function init() {
496 injectStyles();
497
498 const tableContainer = document.querySelector('._tableContainer_3e708_1');
499 if (!tableContainer) {
500 console.error('[RM Calc] Не найден контейнер таблицы ._tableContainer_3e708_1');
501 return;
502 }
503
504 const calcEl = buildCalc();
505 tableContainer.parentNode.insertBefore(calcEl, tableContainer.nextSibling);
506 console.log('[RM Calc] Калькулятор добавлен под таблицей');
507
508 let currentMode = 'margin';
509 const modeBtns = calcEl.querySelectorAll('.rm-mode-btn');
510 const targetLabel = document.getElementById('rm-target-label');
511 const targetInput = document.getElementById('rm-target');
512
513 modeBtns.forEach(btn => {
514 btn.addEventListener('click', () => {
515 modeBtns.forEach(b => b.classList.remove('active'));
516 btn.classList.add('active');
517 currentMode = btn.dataset.mode;
518
519 if (currentMode === 'margin') {
520 targetLabel.textContent = 'Желаемая маржинальность, %';
521 targetInput.placeholder = '20';
522 } else {
523 targetLabel.textContent = 'Желаемый ROM, %';
524 targetInput.placeholder = '50';
525 }
526 });
527 });
528
529 document.getElementById('rm-calc-btn').addEventListener('click', function () {
530 const cost = parseFloat(document.getElementById('rm-cost').value);
531 const buyoutPct = parseFloat(document.getElementById('rm-buyout').value);
532 const ozonDiscount = parseFloat(document.getElementById('rm-ozon-discount').value) || 0;
533 const advPct = parseFloat(document.getElementById('rm-adv').value) || 0;
534 const targetValue = parseFloat(document.getElementById('rm-target').value);
535
536 const errorDiv = document.getElementById('rm-error');
537 const resultDiv = document.getElementById('rm-result');
538
539 errorDiv.classList.remove('visible');
540 resultDiv.classList.remove('visible');
541
542 if (isNaN(cost) || isNaN(buyoutPct) || isNaN(targetValue)) {
543 errorDiv.textContent = 'Заполните обязательные поля: себестоимость, % выкупа, и целевое значение.';
544 errorDiv.classList.add('visible');
545 return;
546 }
547
548 const ozon = readOzonTable();
549 if (!ozon) {
550 errorDiv.textContent = 'Не удалось прочитать данные из таблицы Ozon. Убедитесь, что таблица загружена.';
551 errorDiv.classList.add('visible');
552 return;
553 }
554
555 console.log('[RM Calc] Данные Ozon:', ozon);
556 console.log('[RM Calc] Входные данные:', { cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode: currentMode });
557
558 const res = calculate({ cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode: currentMode }, ozon);
559
560 if (!res) {
561 errorDiv.textContent = 'Невозможно рассчитать: сумма комиссий и целевого показателя превышает 100%. Уменьшите желаемую маржинальность/ROM или % рекламы.';
562 errorDiv.classList.add('visible');
563 return;
564 }
565
566 console.log('[RM Calc] Результат:', res);
567 renderResult(res, ozon, currentMode);
568 });
569 }
570
571 function waitForTable() {
572 const t = document.querySelector('table.ozi__table__table__HAe8A');
573 const container = document.querySelector('._tableContainer_3e708_1');
574
575 if (t && container && !document.getElementById('rm-calc')) {
576 init();
577 } else if (!document.getElementById('rm-calc')) {
578 setTimeout(waitForTable, 1000);
579 }
580 }
581
582 if (document.readyState === 'loading') {
583 document.addEventListener('DOMContentLoaded', waitForTable);
584 } else {
585 waitForTable();
586 }
587
588})();